mirror of
https://github.com/vmware-tanzu/velero.git
synced 2026-08-03 13:56:07 +00:00
Merge branch 'main' into optimize-sub-object-description
This commit is contained in:
@@ -18,6 +18,6 @@ jobs:
|
||||
if: github.repository == 'velero-io/velero'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v5
|
||||
- uses: actions/labeler@v7
|
||||
with:
|
||||
configuration-path: .github/labeler.yml
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: "Auto Request Review"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-request-review:
|
||||
if: github.repository == 'velero-io/velero'
|
||||
name: Auto Request Review
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Request a PR review based on files types/paths, and/or groups the author belongs to
|
||||
uses: necojackarc/auto-request-review@v0.13.0
|
||||
with:
|
||||
config: .github/auto-assignees.yml
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -62,8 +62,28 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
DOCKERFILE_SHA=$(curl -s -H "Authorization: Bearer $GH_TOKEN" https://api.github.com/repos/bitnami/containers/commits?path=bitnami/minio/2026/debian-12/Dockerfile\&per_page=1 | jq -r '.[0].sha')
|
||||
echo "dockerfile_sha=${DOCKERFILE_SHA}" >> $GITHUB_OUTPUT
|
||||
set -euo pipefail
|
||||
|
||||
url="https://api.github.com/repos/bitnami/containers/commits?path=bitnami/minio/2026/debian-12/Dockerfile&per_page=1"
|
||||
|
||||
response="$(curl --fail-with-body -sS \
|
||||
--retry 5 \
|
||||
--retry-delay 2 \
|
||||
--retry-all-errors \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "Authorization: Bearer ${GH_TOKEN}" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"$url")"
|
||||
|
||||
DOCKERFILE_SHA="$(echo "$response" | jq -r '.[0].sha // empty')"
|
||||
|
||||
if [ -z "$DOCKERFILE_SHA" ]; then
|
||||
echo "Failed to resolve Bitnami MinIO Dockerfile SHA from GitHub API response"
|
||||
echo "$response"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "dockerfile_sha=${DOCKERFILE_SHA}" >> "$GITHUB_OUTPUT"
|
||||
- name: Cache MinIO Image
|
||||
uses: actions/cache@v4
|
||||
id: minio-cache
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1
|
||||
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25
|
||||
with:
|
||||
image-ref: 'docker.io/velero/${{ matrix.images }}:${{ matrix.versions }}'
|
||||
severity: 'CRITICAL,HIGH,MEDIUM'
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- name: Make ci
|
||||
run: make ci
|
||||
- name: Upload test coverage
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: coverage.out
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Test
|
||||
run: make test
|
||||
- name: Upload test coverage
|
||||
uses: codecov/codecov-action@v6
|
||||
uses: codecov/codecov-action@v7
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: coverage.out
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
# Velero Code of Conduct
|
||||
|
||||
Velero is a [Cloud Native Computing Foundation](https://www.cncf.io/) sandbox
|
||||
project. As a CNCF project, the Velero community follows the
|
||||
[**CNCF Code of Conduct**](https://github.com/cncf/foundation/blob/main/code-of-conduct.md).
|
||||
|
||||
The text below is the project's adopted Code of Conduct, based on the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org/), and is
|
||||
substantively aligned with the CNCF Code of Conduct. Where any conflict exists,
|
||||
the CNCF Code of Conduct prevails.
|
||||
|
||||
Instances of unacceptable behavior may be reported to the CNCF Code of
|
||||
Conduct Committee at [conduct@cncf.io](mailto:conduct@cncf.io). For more
|
||||
detailed instructions on how to submit a report, including how to submit a
|
||||
report anonymously, please see the CNCF
|
||||
[Incident Resolution Procedures](https://github.com/cncf/foundation/blob/main/code-of-conduct/coc-incident-resolution-procedures.md).
|
||||
You can expect a response within three business days.
|
||||
|
||||
---
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in the Velero project and our
|
||||
community a harassment-free experience for everyone, regardless of age, body
|
||||
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||
identity and expression, level of experience, education, socioeconomic status,
|
||||
nationality, personal appearance, race, religion, or sexual identity
|
||||
and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||
diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of
|
||||
acceptable behavior and will take appropriate and fair corrective action in
|
||||
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||
or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||
decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when
|
||||
an individual is officially representing the community in public spaces.
|
||||
Examples of representing our community include using an official e-mail address,
|
||||
posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the CNCF Code of Conduct Committee at
|
||||
[conduct@cncf.io](mailto:conduct@cncf.io).
|
||||
All complaints will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the
|
||||
reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining
|
||||
the consequences for any action they deem in violation of this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||
unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing
|
||||
clarity around the nature of the violation and an explanation of why the
|
||||
behavior was inappropriate. A public apology may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No
|
||||
interaction with the people involved, including unsolicited interaction with
|
||||
those enforcing the Code of Conduct, for a specified period of time. This
|
||||
includes avoiding interactions in community spaces as well as external channels
|
||||
like social media. Violating these terms may lead to a temporary or
|
||||
permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including
|
||||
sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public
|
||||
communication with the community for a specified period of time. No public or
|
||||
private interaction with the people involved, including unsolicited interaction
|
||||
with those enforcing the Code of Conduct, is allowed during this period.
|
||||
Violating these terms may lead to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||
version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||
enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at
|
||||
https://www.contributor-covenant.org/faq. Translations are available at
|
||||
https://www.contributor-covenant.org/translations.
|
||||
@@ -1,3 +0,0 @@
|
||||
# Contributing
|
||||
|
||||
Authors are expected to follow some guidelines when submitting PRs. Please see [our documentation](https://velero.io/docs/main/code-standards/) for details.
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
# Velero Governance
|
||||
|
||||
This document defines the project governance for Velero.
|
||||
|
||||
## Overview
|
||||
|
||||
**Velero**, an open source project, is committed to building an open, inclusive, productive and self-governing open source community focused on building a high quality tool that enables users to safely backup and restore, perform disaster recovery, and migrate Kubernetes cluster resources and persistent volumes. The community is governed by this document with the goal of defining how community should work together to achieve this goal.
|
||||
|
||||
## Code Repositories
|
||||
|
||||
The following code repositories are governed by Velero community and maintained under the `vmware-tanzu\Velero` organization.
|
||||
|
||||
* **[Velero](https://github.com/vmware-tanzu/velero):** Main Velero codebase
|
||||
* **[Helm Chart](https://github.com/vmware-tanzu/helm-charts/tree/main/charts/velero):** The Helm chart for the Velero server component
|
||||
* **[Velero CSI Plugin](https://github.com/vmware-tanzu/velero-plugin-for-csi):** This repository contains Velero plugins for snapshotting CSI backed PVCs using the CSI beta snapshot APIs
|
||||
* **[Velero Plugin for vSphere](https://github.com/vmware-tanzu/velero-plugin-for-vsphere):** This repository contains the Velero Plugin for vSphere. This plugin is a volume snapshotter plugin that provides crash-consistent snapshots of vSphere block volumes and backup of volume data into S3 compatible storage.
|
||||
* **[Velero Plugin for AWS](https://github.com/vmware-tanzu/velero-plugin-for-aws):** This repository contains the plugins to support running Velero on AWS, including the object store plugin and the volume snapshotter plugin
|
||||
* **[Velero Plugin for GCP](https://github.com/vmware-tanzu/velero-plugin-for-gcp):** This repository contains the plugins to support running Velero on GCP, including the object store plugin and the volume snapshotter plugin
|
||||
* **[Velero Plugin for Azure](https://github.com/vmware-tanzu/velero-plugin-for-microsoft-azure):** This repository contains the plugins to support running Velero on Azure, including the object store plugin and the volume snapshotter plugin
|
||||
* **[Velero Plugin Example](https://github.com/vmware-tanzu/velero-plugin-example):** This repository contains example plugins for Velero
|
||||
|
||||
|
||||
## Community Roles
|
||||
|
||||
* **Users:** Members that engage with the Velero community via any medium (Slack, GitHub, mailing lists, etc.).
|
||||
* **Contributors:** Regular contributions to projects (documentation, code reviews, responding to issues, participation in proposal discussions, contributing code, etc.).
|
||||
* **Maintainers**: The Velero project leaders. They are responsible for the overall health and direction of the project; final reviewers of PRs and responsible for releases. Some Maintainers are responsible for one or more components within a project, acting as technical leads for that component. Maintainers are expected to contribute code and documentation, review PRs including ensuring quality of code, triage issues, proactively fix bugs, and perform maintenance tasks for these components.
|
||||
|
||||
### Maintainers
|
||||
|
||||
New maintainers must be nominated by an existing maintainer and must be elected by a supermajority of existing maintainers. Likewise, maintainers can be removed by a supermajority of the existing maintainers or can resign by notifying one of the maintainers.
|
||||
|
||||
### Supermajority
|
||||
|
||||
A supermajority is defined as two-thirds of members in the group.
|
||||
A supermajority of [Maintainers](#maintainers) is required for certain
|
||||
decisions as outlined above. A supermajority vote is equivalent to the number of votes in favor being at least twice the number of votes against. For example, if you have 5 maintainers, a supermajority vote is 4 votes. Voting on decisions can happen on the mailing list, GitHub, Slack, email, or via a voting service, when appropriate. Maintainers can either vote "agree, yes, +1", "disagree, no, -1", or "abstain". A vote passes when supermajority is met. An abstain vote equals not voting at all.
|
||||
|
||||
### Decision Making
|
||||
|
||||
Ideally, all project decisions are resolved by consensus. If impossible, any
|
||||
maintainer may call a vote. Unless otherwise specified in this document, any
|
||||
vote will be decided by a supermajority of maintainers.
|
||||
|
||||
Votes by maintainers belonging to the same company
|
||||
will count as one vote; e.g., 4 maintainers employed by fictional company **Valerium** will
|
||||
only have **one** combined vote. If voting members from a given company do not
|
||||
agree, the company's vote is determined by a supermajority of voters from that
|
||||
company. If no supermajority is achieved, the company is considered to have
|
||||
abstained.
|
||||
|
||||
## Proposal Process
|
||||
|
||||
One of the most important aspects in any open source community is the concept
|
||||
of proposals. Large changes to the codebase and / or new features should be
|
||||
preceded by a proposal in our community repo. This process allows for all
|
||||
members of the community to weigh in on the concept (including the technical
|
||||
details), share their comments and ideas, and offer to help. It also ensures
|
||||
that members are not duplicating work or inadvertently stepping on toes by
|
||||
making large conflicting changes.
|
||||
|
||||
The project roadmap is defined by accepted proposals.
|
||||
|
||||
Proposals should cover the high-level objectives, use cases, and technical
|
||||
recommendations on how to implement. In general, the community member(s)
|
||||
interested in implementing the proposal should be either deeply engaged in the
|
||||
proposal process or be an author of the proposal.
|
||||
|
||||
The proposal should be documented as a separated markdown file pushed to the root of the
|
||||
`design` folder in the [Velero](https://github.com/vmware-tanzu/velero/tree/main/design)
|
||||
repository via PR. The name of the file should follow the name pattern `<short
|
||||
meaningful words joined by '-'>_design.md`, e.g:
|
||||
`restore-hooks-design.md`.
|
||||
|
||||
Use the [Proposal Template](https://github.com/vmware-tanzu/velero/blob/main/design/_template.md) as a starting point.
|
||||
|
||||
### Proposal Lifecycle
|
||||
|
||||
The proposal PR can follow the GitHub lifecycle of the PR to indicate its status:
|
||||
|
||||
* **Open**: Proposal is created and under review and discussion.
|
||||
* **Merged**: Proposal has been reviewed and is accepted (either by consensus or through a vote).
|
||||
* **Closed**: Proposal has been reviewed and was rejected (either by consensus or through a vote).
|
||||
|
||||
## Lazy Consensus
|
||||
|
||||
To maintain velocity in a project as busy as Velero, the concept of [Lazy
|
||||
Consensus](http://en.osswiki.info/concepts/lazy_consensus) is practiced. Ideas
|
||||
and / or proposals should be shared by maintainers via
|
||||
GitHub with the appropriate maintainer groups (e.g.,
|
||||
`@vmware-tanzu/velero-maintainers`) tagged. Out of respect for other contributors,
|
||||
major changes should also be accompanied by a ping on Slack or a note on the
|
||||
Velero mailing list as appropriate. Author(s) of proposal, Pull Requests,
|
||||
issues, etc. will give a time period of no less than five (5) working days for
|
||||
comment and remain cognizant of popular observed world holidays.
|
||||
|
||||
Other maintainers may chime in and request additional time for review, but
|
||||
should remain cognizant of blocking progress and abstain from delaying
|
||||
progress unless absolutely needed. The expectation is that blocking progress
|
||||
is accompanied by a guarantee to review and respond to the relevant action(s)
|
||||
(proposals, PRs, issues, etc.) in short order.
|
||||
|
||||
Lazy Consensus is practiced for all projects in the `Velero` org, including
|
||||
the main project repository and the additional repositories.
|
||||
|
||||
Lazy consensus does _not_ apply to the process of:
|
||||
|
||||
* Removal of maintainers from Velero
|
||||
|
||||
## Deprecation Policy
|
||||
|
||||
### Deprecation Process
|
||||
|
||||
Any contributor may introduce a request to deprecate a feature or an option of a feature by opening a feature request issue in the vmware-tanzu/velero GitHub project. The issue should describe why the feature is no longer needed or has become detrimental to Velero, as well as whether and how it has been superseded. The submitter should give as much detail as possible.
|
||||
|
||||
Once the issue is filed, a one-month discussion period begins. Discussions take place within the issue itself as well as in the community meetings. The person who opens the issue, or a maintainer, should add the date and time marking the end of the discussion period in a comment on the issue as soon as possible after it is opened. A decision on the issue needs to be made within this one-month period.
|
||||
|
||||
The feature will be deprecated by a supermajority vote of 50% plus one of the project maintainers at the time of the vote tallying, which is 72 hours after the end of the community meeting that is the end of the comment period. (Maintainers are permitted to vote in advance of the deadline, but should hold their votes until as close as possible to hear all possible discussion.) Votes will be tallied in comments on the issue.
|
||||
|
||||
Non-maintainers may add non-binding votes in comments to the issue as well; these are opinions to be taken into consideration by maintainers, but they do not count as votes.
|
||||
|
||||
If the vote passes, the deprecation window takes effect in the subsequent release, and the removal follows the schedule.
|
||||
|
||||
### Schedule
|
||||
If depreciation proposal passes by supermajority votes, the feature is deprecated in the next minor release and the feature can be removed completely after two minor version or equivalent major version e.g., if feature gets deprecated in Nth minor version, then feature can be removed after N+2 minor version or its equivalent if the major version number changes.
|
||||
|
||||
### Deprecation Window
|
||||
|
||||
The deprecation window is the period from the release in which the deprecation takes effect through the release in which the feature is removed. During this period, only critical security vulnerabilities and catastrophic bugs should be fixed.
|
||||
|
||||
**Note:** If a backup relies on a deprecated feature, then backups made with the last Velero release before this feature is removed must still be restorable in version `n+2`. For instance, something like restic feature support, that might mean that restic is removed from the list of supported uploader types in version `n` but the underlying implementation required to restore from a restic backup won't be removed until release `n+2`.
|
||||
|
||||
## Updating Governance
|
||||
|
||||
All substantive changes in Governance require a supermajority agreement by all maintainers.
|
||||
@@ -155,6 +155,11 @@ GOARCH = $(word 2, $(platform_temp))
|
||||
GOPROXY ?= https://proxy.golang.org
|
||||
GOBIN=$$(pwd)/.go/bin
|
||||
|
||||
# Keep these build-image tool versions in sync with go.mod so the CLI/library
|
||||
# pair doesn't drift (see https://github.com/velero-io/velero/issues/10023).
|
||||
PROTOC_GEN_GO_VERSION := $(shell go list -m -f '{{.Version}}' google.golang.org/protobuf)
|
||||
GOIMPORTS_VERSION := $(shell go list -m -f '{{.Version}}' golang.org/x/tools)
|
||||
|
||||
# If you want to build all binaries, see the 'all-build' rule.
|
||||
# If you want to build all containers, see the 'all-containers' rule.
|
||||
all:
|
||||
@@ -395,9 +400,9 @@ ifeq ($(BUILDX_ENABLED), true)
|
||||
ifneq ($(CONTAINER_TOOL),docker)
|
||||
$(error $(DOCKER_ONLY_ERROR))
|
||||
endif
|
||||
@cd hack/build-image && $(CONTAINER_TOOL) buildx build --build-arg=GOPROXY=$(GOPROXY) --output=type=docker --pull -t $(BUILDER_IMAGE) -f $(BUILDER_IMAGE_DOCKERFILE_REALPATH) .
|
||||
@cd hack/build-image && $(CONTAINER_TOOL) buildx build --build-arg=GOPROXY=$(GOPROXY) --build-arg=PROTOC_GEN_GO_VERSION=$(PROTOC_GEN_GO_VERSION) --build-arg=GOIMPORTS_VERSION=$(GOIMPORTS_VERSION) --output=type=docker --pull -t $(BUILDER_IMAGE) -f $(BUILDER_IMAGE_DOCKERFILE_REALPATH) .
|
||||
else
|
||||
@cd hack/build-image && $(CONTAINER_TOOL) build --build-arg=GOPROXY=$(GOPROXY) --pull -t $(BUILDER_IMAGE) -f $(BUILDER_IMAGE_DOCKERFILE_REALPATH) .
|
||||
@cd hack/build-image && $(CONTAINER_TOOL) build --build-arg=GOPROXY=$(GOPROXY) --build-arg=PROTOC_GEN_GO_VERSION=$(PROTOC_GEN_GO_VERSION) --build-arg=GOIMPORTS_VERSION=$(GOIMPORTS_VERSION) --pull -t $(BUILDER_IMAGE) -f $(BUILDER_IMAGE_DOCKERFILE_REALPATH) .
|
||||
endif
|
||||
$(eval new_id=$(shell $(CONTAINER_TOOL) image inspect --format '{{ .ID }}' ${BUILDER_IMAGE} 2>/dev/null))
|
||||
@if [ "$(old_id)" != "" ] && [ "$(old_id)" != "$(new_id)" ]; then \
|
||||
|
||||
-128
@@ -1,128 +0,0 @@
|
||||
# Security Release Process
|
||||
|
||||
Velero is an open source tool with a growing community devoted to safe backup and restore, disaster recovery, and data migration of Kubernetes resources and persistent volumes. The community has adopted this security disclosure and response policy to ensure we responsibly handle critical issues.
|
||||
|
||||
|
||||
## Supported Versions
|
||||
|
||||
The Velero project maintains the following [governance document](https://github.com/vmware-tanzu/velero/blob/main/GOVERNANCE.md), [release document](https://github.com/vmware-tanzu/velero/blob/f42c63af1b9af445e38f78a7256b1c48ef79c10e/site/docs/main/release-instructions.md), and [support document](https://velero.io/docs/main/support-process/). Please refer to these for release and related details. Only the most recent version of Velero is supported. Each [release](https://github.com/vmware-tanzu/velero/releases) includes information about upgrading to the latest version.
|
||||
|
||||
|
||||
## Reporting a Vulnerability - Private Disclosure Process
|
||||
|
||||
Security is of the highest importance and all security vulnerabilities or suspected security vulnerabilities should be reported to Velero privately, to minimize attacks against current users of Velero before they are fixed. Vulnerabilities will be investigated and patched on the next patch (or minor) release as soon as possible. This information could be kept entirely internal to the project.
|
||||
|
||||
If you know of a publicly disclosed security vulnerability for Velero, please **IMMEDIATELY** contact the Security Team (velero-security.pdl@broadcom.com).
|
||||
|
||||
|
||||
|
||||
**IMPORTANT: Do not file public issues on GitHub for security vulnerabilities**
|
||||
|
||||
To report a vulnerability or a security-related issue, please contact the email address with the details of the vulnerability. The email will be fielded by the Security Team and then shared with the Velero maintainers who have committer and release permissions. Emails will be addressed within 3 business days, including a detailed plan to investigate the issue and any potential workarounds to perform in the meantime. Do not report non-security-impacting bugs through this channel. Use [GitHub issues](https://github.com/vmware-tanzu/velero/issues/new/choose) instead.
|
||||
|
||||
|
||||
## Proposed Email Content
|
||||
|
||||
Provide a descriptive subject line and in the body of the email include the following information:
|
||||
|
||||
|
||||
|
||||
* Basic identity information, such as your name and your affiliation or company.
|
||||
* Detailed steps to reproduce the vulnerability (POC scripts, screenshots, and logs are all helpful to us).
|
||||
* Description of the effects of the vulnerability on Velero and the related hardware and software configurations, so that the Security Team can reproduce it.
|
||||
* How the vulnerability affects Velero usage and an estimation of the attack surface, if there is one.
|
||||
* List other projects or dependencies that were used in conjunction with Velero to produce the vulnerability.
|
||||
|
||||
|
||||
|
||||
|
||||
## When to report a vulnerability
|
||||
|
||||
|
||||
|
||||
* When you think Velero has a potential security vulnerability.
|
||||
* When you suspect a potential vulnerability but you are unsure that it impacts Velero.
|
||||
* When you know of or suspect a potential vulnerability on another project that is used by Velero.
|
||||
|
||||
|
||||
|
||||
|
||||
## Patch, Release, and Disclosure
|
||||
|
||||
The Security Team will respond to vulnerability reports as follows:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
1. The Security Team will investigate the vulnerability and determine its effects and criticality.
|
||||
2. If the issue is not deemed to be a vulnerability, the Security Team will follow up with a detailed reason for rejection.
|
||||
3. The Security Team will initiate a conversation with the reporter within 3 business days.
|
||||
4. If a vulnerability is acknowledged and the timeline for a fix is determined, the Security Team will work on a plan to communicate with the appropriate community, including identifying mitigating steps that affected users can take to protect themselves until the fix is rolled out.
|
||||
5. The Security Team will also create a [CVSS](https://www.first.org/cvss/specification-document) using the [CVSS Calculator](https://www.first.org/cvss/calculator/3.0). The Security Team makes the final call on the calculated CVSS; it is better to move quickly than making the CVSS perfect. Issues may also be reported to [Mitre](https://cve.mitre.org/) using this [scoring calculator](https://nvd.nist.gov/vuln-metrics/cvss/v3-calculator). The CVE will initially be set to private.
|
||||
6. The Security Team will work on fixing the vulnerability and perform internal testing before preparing to roll out the fix.
|
||||
7. The Security Team will provide early disclosure of the vulnerability by emailing the [Velero Distributors](https://groups.google.com/u/1/g/projectvelero-distributors) mailing list. Distributors can initially plan for the vulnerability patch ahead of the fix, and later can test the fix and provide feedback to the Velero team. See the section **Early Disclosure to Velero Distributors List** for details about how to join this mailing list.
|
||||
8. A public disclosure date is negotiated by the SecurityTeam, the bug submitter, and the distributors list. We prefer to fully disclose the bug as soon as possible once a user mitigation or patch is available. It is reasonable to delay disclosure when the bug or the fix is not yet fully understood, the solution is not well-tested, or for distributor coordination. The timeframe for disclosure is from immediate (especially if it’s already publicly known) to a few weeks. For a critical vulnerability with a straightforward mitigation, we expect the report date for the public disclosure date to be on the order of 14 business days. The Security Team holds the final say when setting a public disclosure date.
|
||||
9. Once the fix is confirmed, the Security Team will patch the vulnerability in the next patch or minor release, and backport a patch release into all earlier supported releases. Upon release of the patched version of Velero, we will follow the **Public Disclosure Process**.
|
||||
|
||||
|
||||
## Public Disclosure Process
|
||||
|
||||
The Security Team publishes a [public advisory](https://github.com/vmware-tanzu/velero/security/advisories) to the Velero community via GitHub. In most cases, additional communication via Slack, Twitter, mailing lists, blog and other channels will assist in educating Velero users and rolling out the patched release to affected users.
|
||||
|
||||
The Security Team will also publish any mitigating steps users can take until the fix can be applied to their Velero instances. Velero distributors will handle creating and publishing their own security advisories.
|
||||
|
||||
|
||||
|
||||
|
||||
## Mailing lists
|
||||
|
||||
|
||||
|
||||
* Use velero-security.pdl@broadcom.com to report security concerns to the Security Team, who uses the list to privately discuss security issues and fixes prior to disclosure.
|
||||
* Join the [Velero Distributors](https://groups.google.com/u/1/g/projectvelero-distributors) mailing list for early private information and vulnerability disclosure. Early disclosure may include mitigating steps and additional information on security patch releases. See below for information on how Velero distributors or vendors can apply to join this list.
|
||||
|
||||
|
||||
## Early Disclosure to Velero Distributors List
|
||||
|
||||
The private list is intended to be used primarily to provide actionable information to multiple distributor projects at once. This list is not intended to inform individuals about security issues.
|
||||
|
||||
|
||||
## Membership Criteria
|
||||
|
||||
To be eligible to join the [Velero Distributors](https://groups.google.com/u/1/g/projectvelero-distributors) mailing list, you should:
|
||||
|
||||
|
||||
|
||||
1. Be an active distributor of Velero.
|
||||
2. Have a user base that is not limited to your own organization.
|
||||
3. Have a publicly verifiable track record up to the present day of fixing security issues.
|
||||
4. Not be a downstream or rebuild of another distributor.
|
||||
5. Be a participant and active contributor in the Velero community.
|
||||
6. Accept the Embargo Policy that is outlined below.
|
||||
7. Have someone who is already on the list vouch for the person requesting membership on behalf of your distribution.
|
||||
|
||||
**The terms and conditions of the Embargo Policy apply to all members of this mailing list. A request for membership represents your acceptance to the terms and conditions of the Embargo Policy.**
|
||||
|
||||
|
||||
## Embargo Policy
|
||||
|
||||
The information that members receive on the Velero Distributors mailing list must not be made public, shared, or even hinted at anywhere beyond those who need to know within your specific team, unless you receive explicit approval to do so from the Security Team. This remains true until the public disclosure date/time agreed upon by the list. Members of the list and others cannot use the information for any reason other than to get the issue fixed for your respective distribution's users.
|
||||
|
||||
Before you share any information from the list with members of your team who are required to fix the issue, these team members must agree to the same terms, and only be provided with information on a need-to-know basis.
|
||||
|
||||
In the unfortunate event that you share information beyond what is permitted by this policy, you must urgently inform the Security Team (velero-security.pdl@broadcom.com) of exactly what information was leaked and to whom. If you continue to leak information and break the policy outlined here, you will be permanently removed from the list.
|
||||
|
||||
|
||||
|
||||
|
||||
## Requesting to Join
|
||||
|
||||
Send new membership requests to projectvelero-distributors@googlegroups.com. In the body of your request please specify how you qualify for membership and fulfill each criterion listed in the Membership Criteria section above.
|
||||
|
||||
|
||||
## Confidentiality, integrity and availability
|
||||
|
||||
We consider vulnerabilities leading to the compromise of data confidentiality, elevation of privilege, or integrity to be our highest priority concerns. Availability, in particular in areas relating to DoS and resource exhaustion, is also a serious security concern. The Security Team takes all vulnerabilities, potential vulnerabilities, and suspected vulnerabilities seriously and will investigate them in an urgent and expeditious manner.
|
||||
|
||||
Note that we do not currently consider the default settings for Velero to be secure-by-default. It is necessary for operators to explicitly configure settings, role based access control, and other resource related features in Velero to provide a hardened Velero environment. We will not act on any security disclosure that relates to a lack of safe defaults. Over time, we will work towards improved safe-by-default configuration, taking into account backwards compatibility.
|
||||
@@ -1,7 +0,0 @@
|
||||
# Velero Support
|
||||
|
||||
Thanks for trying out Velero! We welcome all feedback, find all the ways to connect with us on our Community page:
|
||||
|
||||
- [Velero Community](https://velero.io/community/)
|
||||
|
||||
You can find details on the Velero maintainers' support process [here](https://velero.io/docs/main/support-process/).
|
||||
@@ -0,0 +1 @@
|
||||
Fix stale backupLastSuccessfulTimestamp metric after schedule deletion
|
||||
@@ -0,0 +1 @@
|
||||
Fix issue #9973, fail earlier when PVR pod is not ready
|
||||
@@ -0,0 +1 @@
|
||||
Fix issue #9997, cancel ongoing PVB on timeout and wait for all PVBs to terminal state
|
||||
@@ -0,0 +1 @@
|
||||
Fix issue #10032, prioritize exact namespace match in restore
|
||||
@@ -0,0 +1 @@
|
||||
Trim whitespace around plugin image entries during install.
|
||||
@@ -0,0 +1 @@
|
||||
RIA must include additional items design
|
||||
@@ -0,0 +1 @@
|
||||
Add set based label selectors for fine-grained filters
|
||||
@@ -0,0 +1 @@
|
||||
Backup workflow for block data mover.
|
||||
@@ -0,0 +1 @@
|
||||
Fix issue #9828, add implementation for block uploader restore
|
||||
@@ -0,0 +1 @@
|
||||
Add restore.velero.io/must-include-additional-items so RestoreItemActions can opt in to bypassing global restore filters for AdditionalItems (mirrors the backup-side must-include annotation; no default behavior change for existing restores/plugins)
|
||||
@@ -0,0 +1 @@
|
||||
Fix issue #9820, user guide for backup fine-grained filters via resource policy
|
||||
@@ -0,0 +1 @@
|
||||
Disable fips140 enforcement because Kopia doesn't support it.
|
||||
@@ -131,6 +131,13 @@ spec:
|
||||
OperationTimeout specifies the time used to wait internal operations,
|
||||
before returning error as timeout.
|
||||
type: string
|
||||
parentSnapshot:
|
||||
description: |-
|
||||
ParentSnapshot specifies the parent snapshot that current backup is based on.
|
||||
If its value is "" or "auto", the data mover finds the recent backup of the same volume as parent.
|
||||
If its value is "none", the data mover will do a full backup
|
||||
If its value is a specific snapshotID, the data mover finds the specific snapshot as parent.
|
||||
type: string
|
||||
snapshotType:
|
||||
description: SnapshotType is the type of the snapshot to be backed
|
||||
up.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -41,7 +41,7 @@ This creates three critical gaps for common backup scenarios:
|
||||
- Maintain full backward compatibility — existing backups with no `namespacedFilterPolicies` behave exactly as they do today
|
||||
- Define clear precedence rules for how per-namespace filters interact with global filters
|
||||
- Add corresponding validation within the Resource Policies validation pipeline using existing Velero wildcard validation functions
|
||||
- Update `velero backup describe` output to display per-namespace filter information when present
|
||||
- Update `velero backup describe` output to display the referenced ResourcePolicy ConfigMap name when configured
|
||||
- Ensure the restore process works correctly with backups produced by namespace-scoped filters, without requiring restore-side code changes in the initial phase
|
||||
|
||||
## Non-Goals
|
||||
@@ -77,7 +77,8 @@ clusterScopedFilterPolicy:
|
||||
names: ["my-app-*"]
|
||||
- kinds: [CustomResourceDefinition]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
namespacedFilterPolicies:
|
||||
# NEW: per-namespace filter overrides
|
||||
- namespaces:
|
||||
@@ -85,7 +86,8 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret, Deployment]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
- namespaces:
|
||||
- ns-b
|
||||
resourceFilters:
|
||||
@@ -93,7 +95,8 @@ namespacedFilterPolicies:
|
||||
names: [app-1, app-2]
|
||||
- kinds: [ConfigMap]
|
||||
labelSelector:
|
||||
app: my-service
|
||||
matchLabels:
|
||||
app: my-service
|
||||
```
|
||||
|
||||
All four sections coexist in the same ConfigMap. They are independent — `volumePolicies` handles volume backup strategy, `includeExcludePolicy` handles global resource type filtering, `clusterScopedFilterPolicy` handles cluster-scoped resource filtering by kind/name/label, and `namespacedFilterPolicies` handles per-namespace, per-kind overrides.
|
||||
@@ -107,7 +110,9 @@ namespacedFilterPolicies:
|
||||
- namespaces: [ns-a]
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret] # these kinds share a selector
|
||||
labelSelector: {app: my-app}
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
names: ["app-*"]
|
||||
- kinds: [Deployment] # this kind has its own selector
|
||||
names: [workload-1, workload-2]
|
||||
@@ -116,6 +121,24 @@ namespacedFilterPolicies:
|
||||
|
||||
This model has one way to express filters — there is no ambiguity about how to structure the configuration. Only resource kinds listed in `resourceFilters` entries are included in the backup for the matched namespaces; unlisted kinds are implicitly excluded.
|
||||
|
||||
#### Label selectors (`matchLabels` / `matchExpressions`)
|
||||
|
||||
`labelSelector` and each entry of `orLabelSelectors` use the standard Kubernetes selector shape (same as `BackupSpec.labelSelector`):
|
||||
|
||||
```yaml
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
matchExpressions:
|
||||
- key: environment
|
||||
operator: In
|
||||
values: [prod, staging]
|
||||
- key: do-not-backup
|
||||
operator: DoesNotExist
|
||||
```
|
||||
|
||||
Supported `matchExpressions` operators: `In`, `NotIn`, `Exists`, `DoesNotExist`. Prefer `In` for value-OR on one key; use `orLabelSelectors` for OR across independent multi-key groups. `labelSelector` and `orLabelSelectors` cannot co-exist in the same `resourceFilters` entry.
|
||||
|
||||
#### Catch-All Resource Filter (Empty `kinds` or `["*"]`)
|
||||
|
||||
A `ResourceFilter` entry with an empty (or omitted) `kinds` field, or a field explicitly set to `["*"]`, acts as a **catch-all**. Its `labelSelector` or `orLabelSelectors` (if provided) is applied to **all resource types in the namespace that are not already matched by a kind-specific filter entry**. If no selectors are provided, all unlisted resources are included. Using `["*"]` is highly recommended as it makes the catch-all intention explicit and self-documenting.
|
||||
@@ -319,9 +342,10 @@ resourceFilters:
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
labelSelector:
|
||||
"invalid label key!": "value" # invalid key syntax
|
||||
matchLabels:
|
||||
"invalid label key!": "value" # invalid key syntax
|
||||
```
|
||||
**Behavior:** Validation error during backup creation when `labels.SelectorFromSet()` fails:
|
||||
**Behavior:** Validation error during backup creation when `metav1.LabelSelectorAsSelector()` fails:
|
||||
```
|
||||
namespacedFilterPolicies[0].resourceFilters[0]: invalid label selector: "invalid label key!" is not a valid label key
|
||||
```
|
||||
@@ -340,7 +364,33 @@ This is consistent with how other discovery-dependent features handle this error
|
||||
|
||||
## ResourceFilter Field Notes
|
||||
|
||||
**`labelSelector`** supports equality-based selectors only (`key=value`). Set-based requirements (e.g., `environment in (prod, staging)`) are not supported. To match resources with any of several label combinations, use `orLabelSelectors` with multiple maps — each map is AND-evaluated internally, and the maps are OR-evaluated across the list. `labelSelector` and `orLabelSelectors` cannot co-exist in the same entry.
|
||||
**`labelSelector`** uses the standard Kubernetes shape: `matchLabels` (equality) and `matchExpressions` (set-based: `In`, `NotIn`, `Exists`, `DoesNotExist`). All requirements within one selector are AND-ed. Example:
|
||||
|
||||
```yaml
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
matchExpressions:
|
||||
- key: environment
|
||||
operator: In
|
||||
values: [prod, staging]
|
||||
- key: do-not-backup
|
||||
operator: DoesNotExist
|
||||
```
|
||||
|
||||
**`orLabelSelectors`** is a list of the same selector shape. Match if **any** entry matches (AND within each entry, OR across the list). Prefer `In` for value-OR on one key; use `orLabelSelectors` for OR of independent multi-key groups. `labelSelector` and `orLabelSelectors` cannot co-exist in the same entry.
|
||||
|
||||
```yaml
|
||||
orLabelSelectors:
|
||||
- matchLabels:
|
||||
tier: frontend
|
||||
matchExpressions:
|
||||
- key: track
|
||||
operator: In
|
||||
values: [canary]
|
||||
- matchLabels:
|
||||
tier: backend
|
||||
```
|
||||
|
||||
**`names` / `excludedNames`** accept exact resource names or glob patterns. If `names` is empty, all resource names are included (subject to label filters). `excludedNames` takes precedence over `names` when a name matches both.
|
||||
|
||||
@@ -420,7 +470,8 @@ data:
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret, Deployment]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
# ns-b has no filter policy entry, so global filters apply (include everything)
|
||||
```
|
||||
|
||||
@@ -462,10 +513,12 @@ data:
|
||||
resourceFilters:
|
||||
- kinds: [Deployment]
|
||||
labelSelector:
|
||||
app: production-workload-1
|
||||
matchLabels:
|
||||
app: production-workload-1
|
||||
- kinds: [StatefulSet]
|
||||
labelSelector:
|
||||
app: production-workload-2
|
||||
matchLabels:
|
||||
app: production-workload-2
|
||||
```
|
||||
|
||||
### Per-Kind Exact Names
|
||||
@@ -561,7 +614,8 @@ data:
|
||||
resourceFilters:
|
||||
- kinds: ["*"] # catch-all: applies to every kind not listed below
|
||||
labelSelector:
|
||||
backup: "true" # back up any resource carrying this label
|
||||
matchLabels:
|
||||
backup: "true" # back up any resource carrying this label
|
||||
```
|
||||
|
||||
**Result:** Every resource type in `production` that has the label `backup=true` is backed up. Resources without that label are excluded. No kind enumeration is required.
|
||||
@@ -589,7 +643,8 @@ data:
|
||||
names: [db-credentials, tls-cert] # these exact Secrets by name
|
||||
- kinds: ["*"] # catch-all for all other kinds
|
||||
labelSelector:
|
||||
backup: "true" # back up by label
|
||||
matchLabels:
|
||||
backup: "true" # back up by label
|
||||
```
|
||||
|
||||
**Result:**
|
||||
@@ -666,7 +721,8 @@ data:
|
||||
names: [workload-1, workload-2]
|
||||
- kinds: [StatefulSet]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
- kinds: [ConfigMap, Secret]
|
||||
names: ["app-*"]
|
||||
excludedNames: ["*-tmp", "*-debug"]
|
||||
@@ -697,7 +753,7 @@ spec:
|
||||
|
||||
### `velero backup describe`
|
||||
|
||||
The output is extended to display namespace-scoped filter policies when present in the ResourcePolicy ConfigMap:
|
||||
The output displays the referenced ResourcePolicy ConfigMap name when configured on the backup. It intentionally avoids resolving and displaying the live ConfigMap contents, because the ConfigMap content in the cluster may be modified or deleted after the backup execution, which could lead to displaying out-of-sync or inaccurate information:
|
||||
|
||||
```
|
||||
Name: selective-backup
|
||||
@@ -721,46 +777,9 @@ Resources:
|
||||
|
||||
Label selector: <none>
|
||||
|
||||
Resource Policy: backup-filter-policy
|
||||
|
||||
Namespace-Scoped Filter Policies:
|
||||
ns-a:
|
||||
Resource Filters:
|
||||
ConfigMap, Secret, Deployment:
|
||||
Label selector: app=my-app
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
target-namespace:
|
||||
Resource Filters:
|
||||
Deployment:
|
||||
Label selector: app=production-workload-1
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
StatefulSet:
|
||||
Label selector: app=production-workload-2
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
production:
|
||||
Resource Filters:
|
||||
Deployment:
|
||||
Label selector: <none>
|
||||
Included names: [api-server, worker]
|
||||
Excluded names: <none>
|
||||
<catch-all> (all other kinds):
|
||||
Label selector: backup=true
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
|
||||
Fine-Grained Global Filter Policy:
|
||||
Resource Filters:
|
||||
ClusterRole, ClusterRoleBinding:
|
||||
Label selector: <none>
|
||||
Included names: [my-app-*]
|
||||
Excluded names: <none>
|
||||
CustomResourceDefinition:
|
||||
Label selector: app=my-app
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
Resource policies:
|
||||
Type: configmap
|
||||
Name: backup-filter-policy
|
||||
|
||||
Storage Location: default
|
||||
|
||||
@@ -795,7 +814,7 @@ Notes:
|
||||
- Global filters (--include-resources, --selector, etc.) apply to all included namespaces
|
||||
- Namespace-scoped filters defined in --resource-policies-configmap override global filters for matching namespaces
|
||||
- Fine-grained global filter policies defined in --resource-policies-configmap override global filters for cluster-scoped resources
|
||||
- Use 'velero backup describe' to view resolved filter policies after backup creation
|
||||
- Use 'velero backup describe' to view the referenced ResourcePolicy ConfigMap name after backup creation
|
||||
```
|
||||
|
||||
### CLI Integration Points
|
||||
@@ -808,12 +827,12 @@ Notes:
|
||||
|
||||
**Help and Discovery:**
|
||||
- `velero backup create --help` includes updated filtering documentation
|
||||
- `velero backup describe` shows resolved filter policies for troubleshooting
|
||||
- `velero backup describe` shows the referenced ResourcePolicy ConfigMap name
|
||||
- Validation errors include ConfigMap field references for easy debugging
|
||||
|
||||
**Configuration Discovery:**
|
||||
- `velero backup create --help` includes namespace-scoped filtering documentation
|
||||
- `velero backup describe` shows resolved filter policies for verification
|
||||
- `velero backup describe` shows the referenced ResourcePolicy ConfigMap name for verification
|
||||
|
||||
## User Perspective
|
||||
|
||||
@@ -823,7 +842,7 @@ This design provides fine-grained, per-namespace, per-kind control over backup f
|
||||
- **For users adopting namespace-scoped filter policies**: Create a ConfigMap with the `namespacedFilterPolicies` section and reference it via `BackupSpec.ResourcePolicy` (or the existing `--resource-policies-configmap` flag). The backup will selectively include/exclude resources per namespace based on the filter rules.
|
||||
- **For users already using ResourcePolicy for volume policies**: Add the `namespacedFilterPolicies` section to the same ConfigMap. Both volume policies and namespace-scoped filters coexist.
|
||||
- **For restore from a namespace-filtered backup**: No changes to restore workflow. Restore processes whatever is in the archive. Users can use existing `RestoreSpec.IncludedNamespaces` for additional filtering at restore time.
|
||||
- **`velero backup describe` output**: Extended to show per-namespace, per-kind filter details when the ResourcePolicy ConfigMap contains `namespacedFilterPolicies`.
|
||||
- **`velero backup describe` output**: Displays the referenced ResourcePolicy ConfigMap name when configured on the backup.
|
||||
- **Validation errors**: Reported at backup start when the ResourcePolicy ConfigMap contains invalid `namespacedFilterPolicies` configurations. Consistent with how volume policy validation errors are reported today.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
@@ -108,14 +108,16 @@ clusterScopedFilterPolicy:
|
||||
names: ["my-app-*"]
|
||||
- kinds: [CustomResourceDefinition]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret, Deployment]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
- namespaces:
|
||||
- ns-b
|
||||
resourceFilters:
|
||||
@@ -123,7 +125,8 @@ namespacedFilterPolicies:
|
||||
names: [app-1, app-2]
|
||||
- kinds: [ConfigMap]
|
||||
labelSelector:
|
||||
app: my-service
|
||||
matchLabels:
|
||||
app: my-service
|
||||
```
|
||||
|
||||
The restore-side ConfigMap does **not** require `volumePolicies` or `includeExcludePolicy` sections. Those are backup-specific. The YAML parser will ignore unknown fields gracefully, so a user can technically point to the same ConfigMap used for backup — the restore pipeline will only read `namespacedFilterPolicies` and `clusterScopedFilterPolicy`.
|
||||
@@ -137,7 +140,9 @@ namespacedFilterPolicies:
|
||||
- namespaces: [ns-a]
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret] # these kinds share a selector
|
||||
labelSelector: {app: my-app}
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
names: ["app-*"]
|
||||
- kinds: [Deployment] # this kind has its own selector
|
||||
names: [workload-1, workload-2]
|
||||
@@ -146,6 +151,36 @@ namespacedFilterPolicies:
|
||||
|
||||
Only resource kinds listed in `resourceFilters` entries are restored for the matched namespaces; unlisted kinds are implicitly excluded (globally excluded kinds cannot be re-included — see precedence model).
|
||||
|
||||
#### Label selectors (`matchLabels` / `matchExpressions`)
|
||||
|
||||
`labelSelector` and each entry of `orLabelSelectors` use the standard Kubernetes selector shape (same as `RestoreSpec.labelSelector`):
|
||||
|
||||
```yaml
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
matchExpressions:
|
||||
- key: environment
|
||||
operator: In
|
||||
values: [prod, staging]
|
||||
- key: do-not-restore
|
||||
operator: DoesNotExist
|
||||
```
|
||||
|
||||
Supported `matchExpressions` operators: `In`, `NotIn`, `Exists`, `DoesNotExist`. Prefer `In` for value-OR on one key; use `orLabelSelectors` for OR across independent multi-key groups. `labelSelector` and `orLabelSelectors` cannot co-exist in the same `resourceFilters` entry.
|
||||
|
||||
```yaml
|
||||
orLabelSelectors:
|
||||
- matchLabels:
|
||||
tier: frontend
|
||||
matchExpressions:
|
||||
- key: track
|
||||
operator: In
|
||||
values: [canary]
|
||||
- matchLabels:
|
||||
tier: backend
|
||||
```
|
||||
|
||||
#### Peek-and-Map Fallback for Unresolved Kinds
|
||||
|
||||
The `kinds` field accepts both plural resource names (e.g., `configmaps`, `mycustomkinds.mygroup.io`) and singular `Kind` names (e.g., `ConfigMap`, `MyCustomKind`).
|
||||
@@ -307,6 +342,9 @@ The `getNamespaceFilter()` method on `restoreContext` takes the original namespa
|
||||
**Plugin Additional Items (Restore-Side):**
|
||||
Like the backup side — which is permissive at Stage 2 to allow CSI plugin-injected resources through — the restore side is permissive for AdditionalItems in `restoreItem()`. If a restore plugin requests an additional item, it is allowed to bypass the fine-grained `namespacedFilterPolicies` and `clusterScopedFilterPolicy` kind, name, and label selector checks. This allows plugins to successfully restore dependencies (like a PV needed by a PVC, or a specific Secret) without the user having to explicitly authorize every single dependent resource type in their configuration. Note that these additional items must still pass global resource/namespace exclusions.
|
||||
|
||||
**Exact Namespace Match Priority:**
|
||||
If a namespace matches both an exact name pattern and a glob pattern across different `namespacedFilterPolicies` entries, the exact match always takes precedence, regardless of list order. This aligns with the backup pipeline behavior and ensures specific overrides are always honored.
|
||||
|
||||
**Multiple Glob Patterns Matching Same Namespace (Incorrect Order):**
|
||||
```yaml
|
||||
namespacedFilterPolicies:
|
||||
@@ -379,9 +417,10 @@ resourceFilters:
|
||||
resourceFilters:
|
||||
- kinds: ["Deployment"]
|
||||
labelSelector:
|
||||
"invalid label key!": "value" # invalid key syntax
|
||||
matchLabels:
|
||||
"invalid label key!": "value" # invalid key syntax
|
||||
```
|
||||
**Behavior:** Validation error during restore creation when `labels.ValidatedSelectorFromSet()` fails:
|
||||
**Behavior:** Validation error during restore creation when `metav1.LabelSelectorAsSelector()` fails:
|
||||
```
|
||||
namespacedFilterPolicies[0].resourceFilters[0]: invalid label selector: "invalid label key!" is not a valid label key
|
||||
```
|
||||
@@ -417,7 +456,8 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret] # Secret listed here is ineffective — globally excluded
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
- kinds: [Deployment]
|
||||
```
|
||||
|
||||
@@ -458,8 +498,8 @@ After existing filter setup, the filter policies are resolved into the runtime m
|
||||
The `resolveRestoreNamespacedFilterPolicies` function:
|
||||
- For each `NamespacedFilterPolicy`, iterates its `ResourceFilters` entries
|
||||
- Resolves kind names to fully-qualified group-resource strings using the discovery helper
|
||||
- Converts `labelSelector` maps into `labels.Selector` objects using `labels.ValidatedSelectorFromSet()`
|
||||
- Converts `orLabelSelectors` maps into `[]labels.Selector`
|
||||
- Converts `labelSelector` into a `labels.Selector` via `ToMetaV1LabelSelector` + `metav1.LabelSelectorAsSelector()`
|
||||
- Converts `orLabelSelectors` into `[]labels.Selector` the same way
|
||||
- Creates `IncludesExcludes` instances for `names`/`excludedNames` patterns
|
||||
- Identifies catch-all entries (empty or `["*"]` kinds) and stores them in `catchAllFilter`
|
||||
- Builds a `resourceFilterMap` keyed by the resolved group-resource string
|
||||
@@ -534,7 +574,8 @@ data:
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, ConfigMap]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
# ns-b has no filter policy entry, so global filters apply (restore everything)
|
||||
```
|
||||
|
||||
@@ -628,7 +669,8 @@ data:
|
||||
names: [db-credentials, tls-cert] # these exact Secrets by name
|
||||
- kinds: ["*"] # catch-all for all other kinds
|
||||
labelSelector:
|
||||
backup: "true" # restore by label
|
||||
matchLabels:
|
||||
backup: "true" # restore by label
|
||||
```
|
||||
|
||||
**Result:**
|
||||
@@ -655,7 +697,8 @@ data:
|
||||
names: ["my-app-*"]
|
||||
- kinds: [CustomResourceDefinition]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- production
|
||||
@@ -665,7 +708,7 @@ data:
|
||||
|
||||
### Restore with Glob Namespace Patterns
|
||||
|
||||
Apply the same filter to all namespaces matching a pattern. **Critical: Order patterns from most specific to least specific:**
|
||||
Apply the same filter to all namespaces matching a pattern. **Note on Precedence:** Exact namespace matches always take precedence regardless of where they are listed. However, if multiple glob patterns could match a namespace, they are evaluated in the order they appear. Always list specific globs before broad globs.
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
@@ -677,19 +720,21 @@ data:
|
||||
policy: |
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
# More specific patterns first
|
||||
# Globs must be ordered specific-to-broad
|
||||
- namespaces:
|
||||
- "team-frontend-prod" # Most specific (exact match)
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service, ConfigMap, Secret, PersistentVolumeClaim]
|
||||
- namespaces:
|
||||
- "team-frontend-*" # Less specific (pattern match)
|
||||
- "team-frontend-*" # specific pattern match
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service, ConfigMap]
|
||||
- namespaces:
|
||||
- "team-*" # Least specific (broad pattern)
|
||||
- "team-*" # broad pattern
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service]
|
||||
|
||||
# Exact matches always win, even if placed at the bottom
|
||||
- namespaces:
|
||||
- "team-frontend-prod" # exact match
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service, ConfigMap, Secret, PersistentVolumeClaim]
|
||||
```
|
||||
|
||||
**Pattern Matching Results:**
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
# RestoreItemAction Must-Include Additional Items
|
||||
|
||||
## Abstract
|
||||
|
||||
Backup Item Actions (BIAs) can already mark additional items as must-include via `backup.velero.io/must-include-additional-items`, so Velero bypasses resource and namespace exclusion filters when backing those dependencies up.
|
||||
This proposal adds the same plugin-controlled escape hatch on restore: `restore.velero.io/must-include-additional-items`, so Restore Item Actions (RIAs) can force-restore declared `AdditionalItems` even when they would otherwise be dropped by global restore filters.
|
||||
|
||||
## Glossary & Abbreviation
|
||||
|
||||
**Additional Item**: A resource identifier returned by a Backup/Restore Item Action's `Execute()` result that Velero should process as a dependency of the current item.
|
||||
**BIA**: Backup Item Action plugin.
|
||||
**RIA**: Restore Item Action plugin.
|
||||
**Must-Include**: A plugin-set annotation on the action's `UpdatedItem` that tells Velero to bypass global include/exclude filters for that action's `AdditionalItems`.
|
||||
**Global Restore Filter**: `RestoreSpec` filters applied uniformly — `IncludedNamespaces`/`ExcludedNamespaces`, `IncludedResources`/`ExcludedResources`, `IncludeClusterResources`, and label selectors.
|
||||
**Fine-Grained Restore Filter**: Per-namespace / cluster-scoped policies from `RestoreSpec.ResourcePolicy` (`namespacedFilterPolicies`, `clusterScopedFilterPolicy`), as described in [Fine Grained Restore Filters via Resource Policies](https://github.com/velero-io/velero/blob/main/design/restore-filter-enhancement/fine-grained-restore-filters-design.md).
|
||||
**`resourceMustHave`**: A small hardcoded server-side set of resource types that bypass resource and namespace I/E checks inside `restoreItem()` today (but not `IncludeClusterResources=false`).
|
||||
|
||||
## Background
|
||||
|
||||
### Backup-side precedent
|
||||
|
||||
On backup, a BIA may set `backup.velero.io/must-include-additional-items: "true"` on the returned `UpdatedItem`.
|
||||
Velero strips that annotation (it is an internal signal, not intended to land on the live object) and passes `mustInclude=true` into recursive `backupItem` calls for that action's `AdditionalItems`.
|
||||
When `mustInclude` is true, `itemInclusionChecks` skips namespace/resource exclusion checks (and related exclusion labels / fine-grained name filters) so plugin-declared dependencies are not dropped by the user's backup filters.
|
||||
In-tree CSI BIAs already rely on this for VolumeSnapshot / VolumeSnapshotContent / VolumeSnapshotClass style dependency chains.
|
||||
|
||||
### Restore-side gap
|
||||
|
||||
On restore, RIAs can return `AdditionalItems`, and Velero recursively calls `restoreItem()` for each of them.
|
||||
That path already bypasses fine-grained restore filters and global label selectors, because those are evaluated earlier in `getOrderedResourceCollection` / `getSelectedRestoreableItems`.
|
||||
However, `restoreItem()` still enforces global resource includes/excludes, namespace includes/excludes, and `IncludeClusterResources=false`.
|
||||
|
||||
The fine-grained restore filters design explicitly documents this remaining floor:
|
||||
|
||||
> Note that these additional items must still pass global resource/namespace exclusions.
|
||||
|
||||
There is no restore-side equivalent of the BIA must-include annotation.
|
||||
Plugins that need a hard dependency restored despite a selective restore configuration have no opt-in way to express that, short of relying on the server-side `resourceMustHave` list (which is global, not plugin-scoped, and does not bypass `IncludeClusterResources=false`).
|
||||
|
||||
### Motivating scenario
|
||||
|
||||
Consider a selective restore that includes only application namespaces and excludes storage/snapshot resource types, while a plugin knows that restoring a PVC correctly requires a related cluster-scoped or cross-namespace dependency that exists in the backup archive.
|
||||
Today the RIA can request that dependency as an `AdditionalItem`, but Velero will skip it at the global exclusion checks inside `restoreItem()`.
|
||||
With a restore must-include annotation, the plugin can declare the dependency as required and Velero will restore it (provided the object is present in the backup tarball).
|
||||
|
||||
## Goals
|
||||
|
||||
- Add `restore.velero.io/must-include-additional-items` with the same parent-annotation contract as the backup-side must-include annotation.
|
||||
- When an RIA sets the annotation on `UpdatedItem`, bypass global resource I/E, namespace I/E, and `IncludeClusterResources=false` for that RIA's `AdditionalItems`.
|
||||
- Keep the change opt-in and backward compatible: restores and plugins that do not set the annotation behave exactly as today.
|
||||
- Document the trust model, precedence rules, and interaction with existing restore gates for plugin authors and operators.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Changing the plugin protobuf / `RestoreItemAction` interface shape (no new RPC fields).
|
||||
- Changing CRDs or adding CLI flags.
|
||||
- Changing the `resourceMustHave` list (including any narrowing related to VolumeSnapshotContent).
|
||||
- Updating in-tree RIAs (CSI or otherwise) to set the new annotation as part of this change.
|
||||
- Per-additional-item granularity (the annotation applies blanket to all `AdditionalItems` from that RIA invocation, matching BIA).
|
||||
- Materializing items that were never backed up.
|
||||
|
||||
## High-Level Design
|
||||
|
||||
Mirror the backup workflow:
|
||||
|
||||
1. Introduce annotation constant `restore.velero.io/must-include-additional-items`.
|
||||
2. After each RIA `Execute()`, if `UpdatedItem` carries the annotation with value `"true"`, strip it and set `mustIncludeAdditionalItems=true`.
|
||||
3. Pass that boolean into recursive `restoreItem(..., mustInclude)` calls for the action's `AdditionalItems`.
|
||||
4. When `mustInclude` is true, skip the global resource/namespace/`IncludeClusterResources` exclusion checks inside `restoreItem()`.
|
||||
5. Keep all non-filter gates unchanged (tarball presence, already-restored, completed Jobs, API errors, wait-for-additional-items, etc.).
|
||||
|
||||
Top-level items from the archive continue to be restored with `mustInclude=false`, so user filters still apply to the primary restore set.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
startRestore[Start Restore] --> readTarball[Read Item from Backup Tarball]
|
||||
readTarball --> topLevelRestoreItem["restoreItem(..., mustInclude=false)"]
|
||||
|
||||
topLevelRestoreItem --> checkMustInclude{"mustInclude == true?"}
|
||||
|
||||
checkMustInclude -- No --> checkFilters{"Pass Global Resource/Namespace Filters?"}
|
||||
checkFilters -- No --> skipItem[Skip Restore]
|
||||
checkFilters -- Yes --> nonFilterGates["Other gates: isCompleted, already-restored, ..."]
|
||||
|
||||
checkMustInclude -- Yes --> nonFilterGates
|
||||
|
||||
nonFilterGates --> executeRIA[Execute RestoreItemAction]
|
||||
|
||||
executeRIA --> checkSkip{"SkipRestore?"}
|
||||
checkSkip -- Yes --> skipItem
|
||||
checkSkip -- No --> checkAnnotation{"Has must-include annotation?"}
|
||||
|
||||
checkAnnotation -- Yes --> stripAnnotation[Strip Annotation]
|
||||
stripAnnotation --> setFlagTrue["mustIncludeAdditionalItems = true"]
|
||||
|
||||
checkAnnotation -- No --> setFlagFalse["mustIncludeAdditionalItems = false"]
|
||||
|
||||
setFlagTrue --> loopAdditionalItems[Loop over AdditionalItems]
|
||||
setFlagFalse --> loopAdditionalItems
|
||||
|
||||
loopAdditionalItems --> existsInBackup{"Item file in tarball?"}
|
||||
existsInBackup -- No --> warnSkip[Warn and skip]
|
||||
existsInBackup -- Yes --> recursiveRestoreItem["restoreItem(..., mustInclude=mustIncludeAdditionalItems)"]
|
||||
recursiveRestoreItem --> checkMustInclude
|
||||
```
|
||||
|
||||
> The edge `recursiveRestoreItem --> checkMustInclude` is a recursive call (new `restoreItem` stack frame), not a same-frame loop.
|
||||
|
||||
## Detailed Design
|
||||
|
||||
### Annotation constant
|
||||
|
||||
In `pkg/apis/velero/v1/labels_annotations.go`, next to the existing backup constant:
|
||||
|
||||
```go
|
||||
// Velero checks this annotation to determine whether to skip resource excluding check.
|
||||
MustIncludeAdditionalItemAnnotation = "backup.velero.io/must-include-additional-items"
|
||||
|
||||
// MustIncludeAdditionalItemRestoreAnnotation is set by RestoreItemActions on the UpdatedItem
|
||||
// to tell Velero to bypass global resource/namespace exclusion checks (and IncludeClusterResources=false)
|
||||
// for that action's AdditionalItems. Value must be "true". The annotation is stripped before
|
||||
// the item is applied to the cluster.
|
||||
//
|
||||
// Notice: SkipRestore on the Execute output takes precedence. If SkipRestore is true, the
|
||||
// annotation is never inspected and AdditionalItems are not processed.
|
||||
MustIncludeAdditionalItemRestoreAnnotation = "restore.velero.io/must-include-additional-items"
|
||||
```
|
||||
|
||||
Only the string value `"true"` enables the bypass (same as backup).
|
||||
|
||||
### `restoreItem` signature
|
||||
|
||||
```go
|
||||
func (ctx *restoreContext) restoreItem(
|
||||
obj *unstructured.Unstructured,
|
||||
groupResource schema.GroupResource,
|
||||
namespace string,
|
||||
mustInclude bool,
|
||||
) (results.Result, results.Result, bool)
|
||||
```
|
||||
|
||||
Call sites:
|
||||
|
||||
| Site | `mustInclude` value |
|
||||
|---|---|
|
||||
| Top-level restore loop | `false` |
|
||||
| Recursive additional-item restore after an RIA | derived from that RIA's `UpdatedItem` annotation |
|
||||
|
||||
### Bypass exclusion checks; keep namespace creation
|
||||
|
||||
Today, namespace exclusion and `EnsureNamespaceExistsAndIsReady` share one `if namespace != ""` block in `restoreItem()`.
|
||||
If must-include only skipped the exclusion check without refactoring, an additional item targeting an excluded namespace would fail because its target namespace was never ensured.
|
||||
|
||||
Required structure:
|
||||
|
||||
```go
|
||||
if mustInclude {
|
||||
restoreLogger.Info("Skipping the resource/namespace exclusion checks because the item is marked as must-include")
|
||||
} else {
|
||||
if !ctx.resourceIncludesExcludes.ShouldInclude(groupResource.String()) && !ctx.resourceMustHave.Has(groupResource.String()) {
|
||||
restoreLogger.Info("Not restoring item because resource is excluded")
|
||||
return warnings, errs, itemExists
|
||||
}
|
||||
|
||||
if namespace != "" {
|
||||
if !ctx.namespaceIncludesExcludes.ShouldInclude(obj.GetNamespace()) && !ctx.resourceMustHave.Has(groupResource.String()) {
|
||||
restoreLogger.Info("Not restoring item because namespace is excluded")
|
||||
return warnings, errs, itemExists
|
||||
}
|
||||
} else {
|
||||
if boolptr.IsSetToFalse(ctx.restore.Spec.IncludeClusterResources) {
|
||||
restoreLogger.Info("Not restoring item because it's cluster-scoped")
|
||||
return warnings, errs, itemExists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace creation runs regardless of mustInclude.
|
||||
if namespace != "" {
|
||||
nsToEnsure := getNamespace(restoreLogger, archive.GetItemFilePath(ctx.restoreDir, "namespaces", "", obj.GetNamespace()), namespace)
|
||||
_, nsCreated, err := kube.EnsureNamespaceExistsAndIsReady(nsToEnsure, ctx.namespaceClient, ctx.resourceTerminatingTimeout, ctx.resourceDeletionStatusTracker)
|
||||
// ... existing error handling and restoredItems bookkeeping ...
|
||||
}
|
||||
```
|
||||
|
||||
Namespace remapping is unchanged: exclusion checks use the original namespace (`obj.GetNamespace()`); namespace creation uses the remapped target `namespace` parameter.
|
||||
|
||||
### Process the annotation after each RIA
|
||||
|
||||
Inside the applicable-actions loop in `restoreItem()`, after `SkipRestore` handling and type-asserting `UpdatedItem`:
|
||||
|
||||
```go
|
||||
obj = unstructuredObj
|
||||
|
||||
mustIncludeAdditionalItems := false
|
||||
if annotations := obj.GetAnnotations(); annotations != nil &&
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] == "true" {
|
||||
mustIncludeAdditionalItems = true
|
||||
restoreLogger.Info("RestoreItemAction marked additional items as must-include; bypassing resource/namespace exclusion checks for them")
|
||||
delete(annotations, velerov1api.MustIncludeAdditionalItemRestoreAnnotation)
|
||||
obj.SetAnnotations(annotations)
|
||||
}
|
||||
|
||||
for _, additionalItem := range executeOutput.AdditionalItems {
|
||||
// existing tarball stat / unmarshal / namespace mapping ...
|
||||
w, e, additionalItemExists := ctx.restoreItem(
|
||||
additionalObj,
|
||||
additionalItem.GroupResource,
|
||||
additionalItemNamespace,
|
||||
mustIncludeAdditionalItems,
|
||||
)
|
||||
// existing merge / filteredAdditionalItems bookkeeping ...
|
||||
}
|
||||
```
|
||||
|
||||
### Filter bypass matrix
|
||||
|
||||
| Gate | Plain AdditionalItem | `resourceMustHave` | RIA `mustInclude=true` | BIA `mustInclude=true` (parity target) |
|
||||
|---|---|---|---|---|
|
||||
| Fine-grained policies (kind/name/label) | Bypass (never enter selection Phase B filters) | N/A in `restoreItem` | Bypass (same) | Bypass |
|
||||
| Global label selectors | Bypass (never re-enter selection) | N/A in `restoreItem` | Bypass (same) | Bypass |
|
||||
| Global resource I/E | Honored | Bypass | Bypass | Bypass |
|
||||
| Global namespace I/E | Honored | Bypass | Bypass | Bypass |
|
||||
| `IncludeClusterResources=false` | Honored | Honored (not bypassed) | Bypass | Bypass |
|
||||
| Item must exist in backup tarball | Required | Required | Required | N/A (fetched from cluster) |
|
||||
| `isCompleted` / already-restored / API errors | Still apply | Still apply | Still apply | `DeletionTimestamp` still applies on backup |
|
||||
|
||||
RIA must-include is intentionally a **stronger** override than `resourceMustHave` because it also bypasses `IncludeClusterResources=false`.
|
||||
That matches BIA must-include semantics (plugin-trusted hard dependencies), rather than widening the hardcoded server list.
|
||||
|
||||
### Interaction with fine-grained restore filters
|
||||
|
||||
Per [Fine Grained Restore Filters via Resource Policies](../restore-filter-enhancement/fine-grained-restore-filters-design.md), plugin additional items already bypass `namespacedFilterPolicies` / `clusterScopedFilterPolicy` kind, name, and label checks.
|
||||
Those filters live in the selection phases; additional items enter `restoreItem()` directly.
|
||||
|
||||
This proposal only changes the remaining global gates inside `restoreItem()`.
|
||||
With must-include set, an additional item effectively bypasses **all** restore filters (fine-grained and global).
|
||||
Without the annotation, behavior is unchanged: fine-grained filters are still bypassed, global exclusions still apply.
|
||||
|
||||
### Interaction with existing restore gates
|
||||
|
||||
#### `SkipRestore` precedence
|
||||
|
||||
If `Execute()` returns `SkipRestore: true`, `restoreItem()` returns before inspecting the annotation, and no `AdditionalItems` are processed.
|
||||
This mirrors backup-side precedence where `velero.io/skip-from-backup` outranks must-include.
|
||||
|
||||
#### Multi-RIA semantics
|
||||
|
||||
Annotation handling is per RIA invocation inside the actions loop:
|
||||
|
||||
1. RIA N executes → inspect/strip annotation on that `UpdatedItem` → restore that RIA's `AdditionalItems` with the derived flag.
|
||||
2. RIA N+1 sees the already-stripped object unless it sets the annotation again.
|
||||
|
||||
A later RIA does not inherit an earlier RIA's must-include decision.
|
||||
|
||||
#### Transitive propagation
|
||||
|
||||
The parent's `mustInclude` flag admits the child additional item through filters.
|
||||
It does **not** automatically force-include grandchildren.
|
||||
Each RIA level that needs the escape hatch must set the annotation on its own `UpdatedItem`, matching BIA behavior.
|
||||
|
||||
#### Non-filter gates that still apply
|
||||
|
||||
Even when `mustInclude=true`:
|
||||
|
||||
- Missing archive file → warn and skip (existing behavior).
|
||||
- `isCompleted` resources (e.g. completed Jobs) → skip.
|
||||
- Already present in `ctx.restoredItems` → skip.
|
||||
- Create/update API failures → errors as today.
|
||||
- `WaitForAdditionalItems` / `AreAdditionalItemsReady` polling after the additional-item loop → unchanged.
|
||||
|
||||
### Relationship to `resourceMustHave`
|
||||
|
||||
| Mechanism | Who decides | Bypasses resource/ns I/E | Bypasses `IncludeClusterResources=false` |
|
||||
|---|---|---|---|
|
||||
| `resourceMustHave` | Velero server (hardcoded) | Yes | No |
|
||||
| RIA must-include | Plugin author (annotation) | Yes | Yes |
|
||||
|
||||
The two mechanisms coexist.
|
||||
This proposal does not migrate in-tree CSI (or other) RIAs onto the annotation.
|
||||
Doing so would be a separate behavior change: it could force-restore types users explicitly excluded, and would newly restore cluster-scoped dependencies even when `IncludeClusterResources=false`.
|
||||
|
||||
### Plugin usage sketch
|
||||
|
||||
```go
|
||||
func (p *myRestoreAction) Execute(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: schema.GroupResource{Group: "example.io", Resource: "dependencies"}, Namespace: "dep-ns", Name: "dep-1"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Plugin authors must ensure the additional item was actually captured in the backup (typically via the corresponding BIA also using `backup.velero.io/must-include-additional-items`).
|
||||
|
||||
### Tests
|
||||
|
||||
Extend restore coverage (existing `TestRestoreActionAdditionalItems` patterns / focused cases) for:
|
||||
|
||||
1. Resource exclusion bypass with annotation; still skipped without annotation.
|
||||
2. Namespace exclusion bypass **and** target namespace creation.
|
||||
3. `IncludeClusterResources=false` bypass for cluster-scoped additional items.
|
||||
4. Annotation stripped from the object applied to the cluster.
|
||||
5. `SkipRestore: true` prevents additional-item processing even if the annotation is set.
|
||||
6. Missing tarball entry still warns and skips.
|
||||
7. Transitive case: child RIA must re-set the annotation for grandchildren.
|
||||
8. Top-level restore path still passes `mustInclude=false` and honors filters.
|
||||
|
||||
### Documentation
|
||||
|
||||
- Constant doc comment (including `SkipRestore` precedence).
|
||||
- Plugin-author docs for Restore Item Actions: annotation key/value, blanket scope, filter-bypass matrix, namespace-creation side effect, tarball requirement.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Installing an RIA that sets this annotation grants that plugin authority to restore dependencies outside the operator's restore filters, including:
|
||||
|
||||
- resources in namespaces the restore excluded (and creation of those target namespaces if needed);
|
||||
- resource types the restore excluded;
|
||||
- cluster-scoped resources even when `IncludeClusterResources=false`.
|
||||
|
||||
This matches the existing BIA trust model: item-action plugins are already privileged components of the Velero deployment.
|
||||
Operators should treat RIA installation as a trust decision.
|
||||
The annotation is stripped before apply so it does not persist as attacker-controlled cluster state from the backup archive alone; a matching RIA must run and return `AdditionalItems` for the bypass to take effect.
|
||||
|
||||
## Compatibility
|
||||
|
||||
- No CRD or plugin interface changes.
|
||||
- Existing restores unchanged when no RIA sets the annotation.
|
||||
- Existing tests that assert additional items are dropped under namespace filters / `IncludeClusterResources=false` remain valid for the no-annotation path.
|
||||
- Compatible with fine-grained restore filters: additional items already bypass those filters; this proposal only addresses the documented global-exclusion floor.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Per-item must-include on each `ResourceIdentifier`
|
||||
|
||||
Pros: selective control within one `AdditionalItems` list.
|
||||
Cons: requires API changes to `ResourceIdentifier` or a parallel structure; diverges from BIA; plugins that need selectivity can already split across actions or omit non-required items.
|
||||
|
||||
Rejected for this proposal; may be revisited later if plugin authors demonstrate a concrete need.
|
||||
|
||||
### Widen `resourceMustHave` instead of a plugin annotation
|
||||
|
||||
Pros: no plugin contract change.
|
||||
Cons: server-forced, global, not scoped to a plugin call; does not give third-party plugins a general tool; does not match BIA; conflicts with efforts to keep hardcoded force-include lists narrow.
|
||||
|
||||
Rejected — wrong trust model for a general plugin escape hatch.
|
||||
@@ -49,7 +49,7 @@ require (
|
||||
golang.org/x/sys v0.46.0
|
||||
golang.org/x/text v0.37.0
|
||||
google.golang.org/api v0.283.0
|
||||
google.golang.org/grpc v1.81.1
|
||||
google.golang.org/grpc v1.82.1
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
|
||||
k8s.io/api v0.36.0
|
||||
k8s.io/apiextensions-apiserver v0.36.0
|
||||
@@ -76,7 +76,7 @@ require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
|
||||
github.com/Masterminds/semver/v3 v3.5.0 // indirect
|
||||
@@ -189,7 +189,7 @@ require (
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
|
||||
@@ -48,8 +48,8 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 h1:RHK7bS+HQMs
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
|
||||
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0/go.mod h1:RD2SsorTmYhF6HkTmDw7KmPYQk8OBYwTkuasChwv7R4=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
|
||||
@@ -466,8 +466,8 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0 h1:kpt2PEJuOuqYkPcktfJqWWDjTEd/FNgrxcniL7kQrXQ=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.42.0/go.mod h1:W9zQ439utxymRrXsUOzZbFX4JhLxXU4+ZnCt8GG7yA8=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.43.0 h1:62yY3dT7/ShwOxzA0RsKRgshBmfElKI4d/Myu2OxDFU=
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.43.0/go.mod h1:RyaZMFY7yi1kAs45S6mbFGz8O8rqB0dTY14uzvG4LCs=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
@@ -564,8 +564,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
FROM --platform=$TARGETPLATFORM golang:1.26-trixie
|
||||
|
||||
ARG GOPROXY
|
||||
ARG PROTOC_GEN_GO_VERSION
|
||||
ARG GOIMPORTS_VERSION
|
||||
|
||||
ENV GO111MODULE=on
|
||||
# Use a proxy for go modules to reduce the likelihood of various hosts being down and breaking the build
|
||||
@@ -34,9 +36,9 @@ RUN wget --quiet https://github.com/kubernetes-sigs/kubebuilder/releases/downloa
|
||||
# get controller-tools
|
||||
RUN go install sigs.k8s.io/controller-tools/cmd/controller-gen@v0.16.5
|
||||
|
||||
# get goimports (the revision is pinned so we don't indiscriminately update, but the particular commit
|
||||
# is not important)
|
||||
RUN go install golang.org/x/tools/cmd/goimports@v0.33.0
|
||||
# get goimports, version derived from go.mod's golang.org/x/tools requirement
|
||||
# (see https://github.com/velero-io/velero/issues/10023)
|
||||
RUN go install golang.org/x/tools/cmd/goimports@${GOIMPORTS_VERSION}
|
||||
|
||||
# get protoc compiler and golang plugin
|
||||
WORKDIR /root
|
||||
@@ -71,7 +73,7 @@ RUN ARCH=$(go env GOARCH) && \
|
||||
chmod a+x /usr/include/google/protobuf && \
|
||||
chmod a+r -R /usr/include/google && \
|
||||
chmod +x /usr/bin/protoc
|
||||
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.33.0 \
|
||||
RUN go install google.golang.org/protobuf/cmd/protoc-gen-go@${PROTOC_GEN_GO_VERSION} \
|
||||
&& go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0
|
||||
|
||||
# get goreleaser
|
||||
@@ -100,10 +102,5 @@ RUN ARCH=$(go env GOARCH) && \
|
||||
# release API/CDN, which has been returning intermittent/persistent HTTP 504s.
|
||||
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0
|
||||
|
||||
# install kubectl
|
||||
RUN curl -LO https://storage.googleapis.com/kubernetes-release/release/$(curl -s https://storage.googleapis.com/kubernetes-release/release/stable.txt)/bin/linux/$(go env GOARCH)/kubectl
|
||||
RUN chmod +x ./kubectl
|
||||
RUN mv ./kubectl /usr/local/bin
|
||||
|
||||
# Fix the "dubious ownership" issue from git when running goreleaser.sh
|
||||
RUN echo "[safe] \n\t directory = *" > /.gitconfig
|
||||
|
||||
@@ -21,12 +21,13 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/gobwas/glob"
|
||||
"github.com/sirupsen/logrus"
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
crclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
@@ -100,13 +101,66 @@ func (a *Action) GetDataMover() (string, error) {
|
||||
return dataMover, nil
|
||||
}
|
||||
|
||||
// PolicyLabelSelector mirrors metav1.LabelSelector with yaml tags for ConfigMap decode.
|
||||
// metav1.LabelSelector only has json tags, which do not populate under go.yaml.in/yaml/v3.
|
||||
type PolicyLabelSelector struct {
|
||||
MatchLabels map[string]string `yaml:"matchLabels,omitempty"`
|
||||
MatchExpressions []PolicyLabelSelectorRequirement `yaml:"matchExpressions,omitempty"`
|
||||
}
|
||||
|
||||
// PolicyLabelSelectorRequirement mirrors metav1.LabelSelectorRequirement with yaml tags.
|
||||
type PolicyLabelSelectorRequirement struct {
|
||||
Key string `yaml:"key"`
|
||||
Operator string `yaml:"operator"`
|
||||
Values []string `yaml:"values,omitempty"`
|
||||
}
|
||||
|
||||
// IsPresentLabelSelector reports whether s defines any label constraints.
|
||||
// Empty {} (nil MatchLabels and empty MatchExpressions) is treated as absent.
|
||||
func IsPresentLabelSelector(s *PolicyLabelSelector) bool {
|
||||
return s != nil && (len(s.MatchLabels) > 0 || len(s.MatchExpressions) > 0)
|
||||
}
|
||||
|
||||
// ToMetaV1LabelSelector converts the YAML mirror type to metav1.LabelSelector.
|
||||
// Conversion itself is infallible; call LabelSelectorAsSelector (or
|
||||
// SelectorFromPolicyLabelSelector) to validate operators and values.
|
||||
func ToMetaV1LabelSelector(s *PolicyLabelSelector) *metav1.LabelSelector {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
ls := &metav1.LabelSelector{MatchLabels: s.MatchLabels}
|
||||
for _, expr := range s.MatchExpressions {
|
||||
ls.MatchExpressions = append(ls.MatchExpressions, metav1.LabelSelectorRequirement{
|
||||
Key: expr.Key,
|
||||
Operator: metav1.LabelSelectorOperator(expr.Operator),
|
||||
Values: expr.Values,
|
||||
})
|
||||
}
|
||||
return ls
|
||||
}
|
||||
|
||||
// SelectorFromPolicyLabelSelector converts a present policy label selector to a
|
||||
// runtime labels.Selector. Returns (nil, nil) when s defines no constraints.
|
||||
func SelectorFromPolicyLabelSelector(s *PolicyLabelSelector) (labels.Selector, error) {
|
||||
if !IsPresentLabelSelector(s) {
|
||||
return nil, nil
|
||||
}
|
||||
return metav1.LabelSelectorAsSelector(ToMetaV1LabelSelector(s))
|
||||
}
|
||||
|
||||
// validatePolicyLabelSelector converts and validates a policy label selector.
|
||||
func validatePolicyLabelSelector(s *PolicyLabelSelector) error {
|
||||
_, err := SelectorFromPolicyLabelSelector(s)
|
||||
return err
|
||||
}
|
||||
|
||||
// ResourceFilter defines a filter for specific resource kinds.
|
||||
type ResourceFilter struct {
|
||||
Kinds []string `yaml:"kinds"`
|
||||
LabelSelector map[string]string `yaml:"labelSelector,omitempty"`
|
||||
OrLabelSelectors []map[string]string `yaml:"orLabelSelectors,omitempty"`
|
||||
Names []string `yaml:"names,omitempty"`
|
||||
ExcludedNames []string `yaml:"excludedNames,omitempty"`
|
||||
Kinds []string `yaml:"kinds"`
|
||||
LabelSelector *PolicyLabelSelector `yaml:"labelSelector,omitempty"`
|
||||
OrLabelSelectors []*PolicyLabelSelector `yaml:"orLabelSelectors,omitempty"`
|
||||
Names []string `yaml:"names,omitempty"`
|
||||
ExcludedNames []string `yaml:"excludedNames,omitempty"`
|
||||
}
|
||||
|
||||
// IsCatchAll returns true if the filter is a catch-all entry (empty kinds or ["*"])
|
||||
@@ -605,9 +659,17 @@ func (p *Policies) validateNamespacedFilterPolicies() error {
|
||||
seenKinds[kind] = j
|
||||
}
|
||||
|
||||
if len(rf.LabelSelector) > 0 && len(rf.OrLabelSelectors) > 0 {
|
||||
if IsPresentLabelSelector(rf.LabelSelector) && len(rf.OrLabelSelectors) > 0 {
|
||||
return fmt.Errorf("namespacedFilterPolicies[%d].resourceFilters[%d]: labelSelector and orLabelSelectors cannot co-exist", i, j)
|
||||
}
|
||||
if err := validatePolicyLabelSelector(rf.LabelSelector); err != nil {
|
||||
return fmt.Errorf("namespacedFilterPolicies[%d].resourceFilters[%d]: invalid label selector: %w", i, j, err)
|
||||
}
|
||||
for k, ols := range rf.OrLabelSelectors {
|
||||
if err := validatePolicyLabelSelector(ols); err != nil {
|
||||
return fmt.Errorf("namespacedFilterPolicies[%d].resourceFilters[%d].orLabelSelectors[%d]: invalid label selector: %w", i, j, k, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate glob patterns for names and excludedNames using gobwas/glob
|
||||
for k, pattern := range rf.Names {
|
||||
@@ -657,9 +719,17 @@ func (p *Policies) validateClusterScopedFilterPolicy() error {
|
||||
seenKinds[kind] = j
|
||||
}
|
||||
|
||||
if len(rf.LabelSelector) > 0 && len(rf.OrLabelSelectors) > 0 {
|
||||
if IsPresentLabelSelector(rf.LabelSelector) && len(rf.OrLabelSelectors) > 0 {
|
||||
return fmt.Errorf("clusterScopedFilterPolicy.resourceFilters[%d]: labelSelector and orLabelSelectors cannot co-exist", j)
|
||||
}
|
||||
if err := validatePolicyLabelSelector(rf.LabelSelector); err != nil {
|
||||
return fmt.Errorf("clusterScopedFilterPolicy.resourceFilters[%d]: invalid label selector: %w", j, err)
|
||||
}
|
||||
for k, ols := range rf.OrLabelSelectors {
|
||||
if err := validatePolicyLabelSelector(ols); err != nil {
|
||||
return fmt.Errorf("clusterScopedFilterPolicy.resourceFilters[%d].orLabelSelectors[%d]: invalid label selector: %w", j, k, err)
|
||||
}
|
||||
}
|
||||
|
||||
for k, pattern := range rf.Names {
|
||||
if _, err := glob.Compile(pattern); err != nil {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
@@ -2027,7 +2028,8 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: ["Pod", "ConfigMap"]
|
||||
labelSelector:
|
||||
app: web
|
||||
matchLabels:
|
||||
app: web
|
||||
names: ["app-*"]
|
||||
- kinds: ["Secret"]
|
||||
excludedNames: ["temp-*"]`,
|
||||
@@ -2041,8 +2043,10 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
orLabelSelectors:
|
||||
- env: prod
|
||||
- env: staging`,
|
||||
- matchLabels:
|
||||
env: prod
|
||||
- matchLabels:
|
||||
env: staging`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -2084,7 +2088,8 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: ["*"]
|
||||
labelSelector:
|
||||
app: web`,
|
||||
matchLabels:
|
||||
app: web`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -2095,10 +2100,12 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: ["*"]
|
||||
labelSelector:
|
||||
app: web
|
||||
matchLabels:
|
||||
app: web
|
||||
- kinds: ["*"]
|
||||
labelSelector:
|
||||
app: db`,
|
||||
matchLabels:
|
||||
app: db`,
|
||||
wantErr: true,
|
||||
errMsg: "only one catch-all resource filter is allowed",
|
||||
},
|
||||
@@ -2110,10 +2117,12 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: []
|
||||
labelSelector:
|
||||
app: web
|
||||
matchLabels:
|
||||
app: web
|
||||
- kinds: ["*"]
|
||||
labelSelector:
|
||||
app: db`,
|
||||
matchLabels:
|
||||
app: db`,
|
||||
wantErr: true,
|
||||
errMsg: "only one catch-all resource filter is allowed",
|
||||
},
|
||||
@@ -2125,10 +2134,12 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: []
|
||||
labelSelector:
|
||||
app: web
|
||||
matchLabels:
|
||||
app: web
|
||||
- kinds: []
|
||||
labelSelector:
|
||||
app: db`,
|
||||
matchLabels:
|
||||
app: db`,
|
||||
wantErr: true,
|
||||
errMsg: "only one catch-all resource filter is allowed",
|
||||
},
|
||||
@@ -2141,7 +2152,8 @@ namespacedFilterPolicies:
|
||||
- kinds: []
|
||||
names: ["app-*"]
|
||||
labelSelector:
|
||||
app: web`,
|
||||
matchLabels:
|
||||
app: web`,
|
||||
wantErr: true,
|
||||
errMsg: "names or excludedNames cannot be specified for catch-all filters",
|
||||
},
|
||||
@@ -2154,7 +2166,8 @@ namespacedFilterPolicies:
|
||||
- kinds: []
|
||||
excludedNames: ["app-*"]
|
||||
labelSelector:
|
||||
app: web`,
|
||||
matchLabels:
|
||||
app: web`,
|
||||
wantErr: true,
|
||||
errMsg: "names or excludedNames cannot be specified for catch-all filters",
|
||||
},
|
||||
@@ -2186,9 +2199,11 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
labelSelector:
|
||||
app: web
|
||||
matchLabels:
|
||||
app: web
|
||||
orLabelSelectors:
|
||||
- env: prod`,
|
||||
- matchLabels:
|
||||
env: prod`,
|
||||
wantErr: true,
|
||||
errMsg: "labelSelector and orLabelSelectors cannot co-exist",
|
||||
},
|
||||
@@ -2272,7 +2287,8 @@ namespacedFilterPolicies:
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
labelSelector:
|
||||
app: web`
|
||||
matchLabels:
|
||||
app: web`
|
||||
|
||||
resPolicies, err := unmarshalResourcePolicies(&yamlData)
|
||||
require.NoError(t, err)
|
||||
@@ -2290,7 +2306,135 @@ namespacedFilterPolicies:
|
||||
|
||||
rf := policy.ResourceFilters[0]
|
||||
assert.Equal(t, []string{"Pod"}, rf.Kinds)
|
||||
assert.Equal(t, map[string]string{"app": "web"}, rf.LabelSelector)
|
||||
assert.Equal(t, &PolicyLabelSelector{MatchLabels: map[string]string{"app": "web"}}, rf.LabelSelector)
|
||||
}
|
||||
|
||||
func TestPolicyLabelSelectorSetBased(t *testing.T) {
|
||||
t.Run("yaml decode matchLabels and matchExpressions", func(t *testing.T) {
|
||||
yamlData := `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["ns1"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: web
|
||||
matchExpressions:
|
||||
- key: environment
|
||||
operator: In
|
||||
values: [prod, staging]
|
||||
- key: do-not-backup
|
||||
operator: DoesNotExist`
|
||||
|
||||
resPolicies, err := unmarshalResourcePolicies(&yamlData)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies := &Policies{}
|
||||
require.NoError(t, policies.BuildPolicy(resPolicies))
|
||||
require.NoError(t, policies.Validate())
|
||||
|
||||
rf := policies.GetNamespacedFilterPolicies()[0].ResourceFilters[0]
|
||||
require.NotNil(t, rf.LabelSelector)
|
||||
assert.Equal(t, map[string]string{"app": "web"}, rf.LabelSelector.MatchLabels)
|
||||
require.Len(t, rf.LabelSelector.MatchExpressions, 2)
|
||||
assert.Equal(t, "environment", rf.LabelSelector.MatchExpressions[0].Key)
|
||||
assert.Equal(t, "In", rf.LabelSelector.MatchExpressions[0].Operator)
|
||||
assert.Equal(t, []string{"prod", "staging"}, rf.LabelSelector.MatchExpressions[0].Values)
|
||||
assert.Equal(t, "do-not-backup", rf.LabelSelector.MatchExpressions[1].Key)
|
||||
assert.Equal(t, "DoesNotExist", rf.LabelSelector.MatchExpressions[1].Operator)
|
||||
})
|
||||
|
||||
t.Run("empty labelSelector is no filter", func(t *testing.T) {
|
||||
yamlData := `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["ns1"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
labelSelector: {}`
|
||||
|
||||
resPolicies, err := unmarshalResourcePolicies(&yamlData)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies := &Policies{}
|
||||
require.NoError(t, policies.BuildPolicy(resPolicies))
|
||||
require.NoError(t, policies.Validate())
|
||||
|
||||
rf := policies.GetNamespacedFilterPolicies()[0].ResourceFilters[0]
|
||||
assert.False(t, IsPresentLabelSelector(rf.LabelSelector))
|
||||
})
|
||||
|
||||
t.Run("invalid operator rejected", func(t *testing.T) {
|
||||
yamlData := `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["ns1"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: environment
|
||||
operator: Equals
|
||||
values: [prod]`
|
||||
|
||||
resPolicies, err := unmarshalResourcePolicies(&yamlData)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies := &Policies{}
|
||||
require.NoError(t, policies.BuildPolicy(resPolicies))
|
||||
err = policies.Validate()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid label selector")
|
||||
})
|
||||
|
||||
t.Run("NotIn Exists operators validate", func(t *testing.T) {
|
||||
yamlData := `version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["ClusterRole"]
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: tier
|
||||
operator: NotIn
|
||||
values: [debug]
|
||||
- key: managed-by
|
||||
operator: Exists`
|
||||
|
||||
resPolicies, err := unmarshalResourcePolicies(&yamlData)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies := &Policies{}
|
||||
require.NoError(t, policies.BuildPolicy(resPolicies))
|
||||
require.NoError(t, policies.Validate())
|
||||
})
|
||||
|
||||
t.Run("ToMetaV1LabelSelector and IsPresentLabelSelector", func(t *testing.T) {
|
||||
assert.False(t, IsPresentLabelSelector(nil))
|
||||
assert.False(t, IsPresentLabelSelector(&PolicyLabelSelector{}))
|
||||
assert.True(t, IsPresentLabelSelector(&PolicyLabelSelector{MatchLabels: map[string]string{"a": "b"}}))
|
||||
|
||||
ls := ToMetaV1LabelSelector(&PolicyLabelSelector{
|
||||
MatchLabels: map[string]string{"app": "web"},
|
||||
MatchExpressions: []PolicyLabelSelectorRequirement{
|
||||
{Key: "env", Operator: "In", Values: []string{"prod"}},
|
||||
},
|
||||
})
|
||||
require.NotNil(t, ls)
|
||||
assert.Equal(t, map[string]string{"app": "web"}, ls.MatchLabels)
|
||||
require.Len(t, ls.MatchExpressions, 1)
|
||||
assert.Equal(t, metav1.LabelSelectorOpIn, ls.MatchExpressions[0].Operator)
|
||||
|
||||
assert.Nil(t, ToMetaV1LabelSelector(nil))
|
||||
|
||||
sel, err := SelectorFromPolicyLabelSelector(&PolicyLabelSelector{
|
||||
MatchLabels: map[string]string{"app": "web"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, sel)
|
||||
assert.True(t, sel.Matches(labels.Set{"app": "web"}))
|
||||
|
||||
emptySel, err := SelectorFromPolicyLabelSelector(&PolicyLabelSelector{})
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, emptySel)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClusterScopedFilterPoliciesAccessor(t *testing.T) {
|
||||
@@ -2394,7 +2538,8 @@ clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["ClusterRole", "ClusterRoleBinding"]
|
||||
labelSelector:
|
||||
app: my-app`,
|
||||
matchLabels:
|
||||
app: my-app`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -2404,8 +2549,10 @@ clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["CustomResourceDefinition"]
|
||||
orLabelSelectors:
|
||||
- app: my-app
|
||||
- app: other-app`,
|
||||
- matchLabels:
|
||||
app: my-app
|
||||
- matchLabels:
|
||||
app: other-app`,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -2443,7 +2590,8 @@ clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["*"]
|
||||
labelSelector:
|
||||
app: my-app`,
|
||||
matchLabels:
|
||||
app: my-app`,
|
||||
wantErr: true,
|
||||
errMsg: "kinds must be specified",
|
||||
},
|
||||
@@ -2456,7 +2604,8 @@ clusterScopedFilterPolicy:
|
||||
names: ["my-app-*"]
|
||||
- kinds: ["ClusterRole"]
|
||||
labelSelector:
|
||||
app: other`,
|
||||
matchLabels:
|
||||
app: other`,
|
||||
wantErr: true,
|
||||
errMsg: `kind "ClusterRole" appears in both`,
|
||||
},
|
||||
@@ -2467,9 +2616,11 @@ clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["ClusterRole"]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
matchLabels:
|
||||
app: my-app
|
||||
orLabelSelectors:
|
||||
- app: other`,
|
||||
- matchLabels:
|
||||
app: other`,
|
||||
wantErr: true,
|
||||
errMsg: "labelSelector and orLabelSelectors cannot co-exist",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
Copyright The Velero Contributors.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package shared
|
||||
|
||||
const (
|
||||
DataUploadParentSnapshotNone = "none"
|
||||
DataUploadParentSnapshotAuto = "auto"
|
||||
)
|
||||
@@ -166,6 +166,14 @@ const (
|
||||
|
||||
// Velero checks this annotation to determine whether to skip resource excluding check.
|
||||
MustIncludeAdditionalItemAnnotation = "backup.velero.io/must-include-additional-items"
|
||||
// MustIncludeAdditionalItemRestoreAnnotation is set by RestoreItemActions on the UpdatedItem
|
||||
// to tell Velero to bypass global resource/namespace exclusion checks (and IncludeClusterResources=false)
|
||||
// for that action's AdditionalItems. Value must be "true" to enable the bypass. The annotation is
|
||||
// always stripped before the item is applied to the cluster when present, including non-"true" values.
|
||||
//
|
||||
// Notice: SkipRestore on the Execute output takes precedence. If SkipRestore is true, the
|
||||
// annotation is never inspected and AdditionalItems are not processed.
|
||||
MustIncludeAdditionalItemRestoreAnnotation = "restore.velero.io/must-include-additional-items"
|
||||
// SkippedNoCSIPVAnnotation - Velero checks this annotation on processed PVC to
|
||||
// find out if the snapshot was skipped b/c the PV is not provisioned via CSI
|
||||
SkippedNoCSIPVAnnotation = "backup.velero.io/skipped-no-csi-pv"
|
||||
|
||||
@@ -64,6 +64,12 @@ type DataUploadSpec struct {
|
||||
// SourceFSType is the file system type of the source volume.
|
||||
// +optional
|
||||
SourceFSType string `json:"sourceFSType,omitempty"`
|
||||
|
||||
// ParentSnapshot specifies the parent snapshot that current backup is based on.
|
||||
// If its value is "" or "auto", the data mover finds the recent backup of the same volume as parent.
|
||||
// If its value is "none", the data mover will do a full backup
|
||||
// If its value is a specific snapshotID, the data mover finds the specific snapshot as parent.
|
||||
ParentSnapshot string `json:"parentSnapshot,omitempty"`
|
||||
}
|
||||
|
||||
type SnapshotType string
|
||||
|
||||
@@ -42,6 +42,7 @@ import (
|
||||
crclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
|
||||
veleroshared "github.com/vmware-tanzu/velero/pkg/apis/velero/shared"
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
velerov2alpha1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v2alpha1"
|
||||
veleroclient "github.com/vmware-tanzu/velero/pkg/client"
|
||||
@@ -535,6 +536,16 @@ func newDataUpload(
|
||||
vsc *snapshotv1api.VolumeSnapshotContent,
|
||||
fsType string,
|
||||
) *velerov2alpha1.DataUpload {
|
||||
var parentSnapshot string
|
||||
switch backup.Spec.BackupType {
|
||||
case velerov1api.BackupTypeFull:
|
||||
parentSnapshot = veleroshared.DataUploadParentSnapshotNone
|
||||
case velerov1api.BackupTypeIncremental:
|
||||
parentSnapshot = veleroshared.DataUploadParentSnapshotAuto
|
||||
default:
|
||||
parentSnapshot = veleroshared.DataUploadParentSnapshotAuto
|
||||
}
|
||||
|
||||
dataUpload := &velerov2alpha1.DataUpload{
|
||||
TypeMeta: metav1.TypeMeta{
|
||||
APIVersion: velerov2alpha1.SchemeGroupVersion.String(),
|
||||
@@ -572,6 +583,7 @@ func newDataUpload(
|
||||
SourceNamespace: pvc.Namespace,
|
||||
OperationTimeout: backup.Spec.CSISnapshotTimeout,
|
||||
SourceFSType: fsType,
|
||||
ParentSnapshot: parentSnapshot,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -23,40 +23,39 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware-tanzu/velero/pkg/kuberesource"
|
||||
|
||||
volumegroupsnapshotv1beta2 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumegroupsnapshot/v1beta2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/utils/ptr"
|
||||
|
||||
"github.com/vmware-tanzu/velero/pkg/label"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
volumegroupsnapshotv1beta2 "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumegroupsnapshot/v1beta2"
|
||||
snapshotv1api "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
storagev1api "k8s.io/api/storage/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"k8s.io/utils/ptr"
|
||||
crclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/vmware-tanzu/velero/pkg/apis/velero/shared"
|
||||
veleroshared "github.com/vmware-tanzu/velero/pkg/apis/velero/shared"
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
velerov2alpha1 "github.com/vmware-tanzu/velero/pkg/apis/velero/v2alpha1"
|
||||
"github.com/vmware-tanzu/velero/pkg/builder"
|
||||
factorymocks "github.com/vmware-tanzu/velero/pkg/client/mocks"
|
||||
"github.com/vmware-tanzu/velero/pkg/kuberesource"
|
||||
"github.com/vmware-tanzu/velero/pkg/label"
|
||||
"github.com/vmware-tanzu/velero/pkg/plugin/velero"
|
||||
velerotest "github.com/vmware-tanzu/velero/pkg/test"
|
||||
uploaderUtil "github.com/vmware-tanzu/velero/pkg/uploader/util"
|
||||
"github.com/vmware-tanzu/velero/pkg/util/boolptr"
|
||||
)
|
||||
|
||||
const testDriver = "csi.example.com"
|
||||
@@ -163,6 +162,7 @@ func TestExecute(t *testing.T) {
|
||||
SourcePVC: "testPVC",
|
||||
SourceNamespace: "velero",
|
||||
OperationTimeout: metav1.Duration{Duration: 1 * time.Minute},
|
||||
ParentSnapshot: veleroshared.DataUploadParentSnapshotAuto,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2176,3 +2176,131 @@ func TestGetOrCreateVolumeHelper(t *testing.T) {
|
||||
// The pvcPodCache should be the same instance
|
||||
require.Same(t, cache1, action.pvcPodCache, "Expected same pvcPodCache instance on repeated calls")
|
||||
}
|
||||
|
||||
func TestNewDataUpload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
backupType velerov1api.BackupType
|
||||
vsClassName *string
|
||||
uploaderConfig *velerov1api.UploaderConfigForBackup
|
||||
expectedParentSnap string
|
||||
expectedDataMoverCfg map[string]string
|
||||
}{
|
||||
{
|
||||
name: "Full backup type, no uploader config, no vs class name",
|
||||
backupType: velerov1api.BackupTypeFull,
|
||||
vsClassName: nil,
|
||||
uploaderConfig: nil,
|
||||
expectedParentSnap: "none",
|
||||
expectedDataMoverCfg: nil,
|
||||
},
|
||||
{
|
||||
name: "Incremental backup type, with uploader config, with vs class name",
|
||||
backupType: velerov1api.BackupTypeIncremental,
|
||||
vsClassName: ptr.To("test-vs-class"),
|
||||
uploaderConfig: &velerov1api.UploaderConfigForBackup{ParallelFilesUpload: 10},
|
||||
expectedParentSnap: "auto",
|
||||
expectedDataMoverCfg: map[string]string{
|
||||
uploaderUtil.ParallelFilesUpload: "10",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Default backup type, uploader config with 0 parallel files",
|
||||
backupType: "",
|
||||
vsClassName: ptr.To("test-vs-class"),
|
||||
uploaderConfig: &velerov1api.UploaderConfigForBackup{ParallelFilesUpload: 0},
|
||||
expectedParentSnap: "auto",
|
||||
expectedDataMoverCfg: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
backup := &velerov1api.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-backup",
|
||||
Namespace: "velero",
|
||||
UID: types.UID("backup-uid"),
|
||||
},
|
||||
Spec: velerov1api.BackupSpec{
|
||||
BackupType: tc.backupType,
|
||||
DataMover: "velero",
|
||||
StorageLocation: "default",
|
||||
CSISnapshotTimeout: metav1.Duration{Duration: 10 * time.Minute},
|
||||
UploaderConfig: tc.uploaderConfig,
|
||||
},
|
||||
}
|
||||
|
||||
vs := &snapshotv1api.VolumeSnapshot{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-vs",
|
||||
},
|
||||
Spec: snapshotv1api.VolumeSnapshotSpec{
|
||||
VolumeSnapshotClassName: tc.vsClassName,
|
||||
},
|
||||
}
|
||||
|
||||
pvc := &corev1api.PersistentVolumeClaim{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-pvc",
|
||||
Namespace: "test-ns",
|
||||
UID: types.UID("pvc-uid"),
|
||||
},
|
||||
Spec: corev1api.PersistentVolumeClaimSpec{
|
||||
StorageClassName: ptr.To("test-storage-class"),
|
||||
},
|
||||
}
|
||||
|
||||
vsc := &snapshotv1api.VolumeSnapshotContent{
|
||||
Spec: snapshotv1api.VolumeSnapshotContentSpec{
|
||||
Driver: "test-driver",
|
||||
},
|
||||
}
|
||||
|
||||
operationID := "test-op-id"
|
||||
fsType := "ext4"
|
||||
|
||||
du := newDataUpload(backup, vs, pvc, operationID, vsc, fsType)
|
||||
|
||||
require.NotNil(t, du)
|
||||
assert.Equal(t, velerov2alpha1.SchemeGroupVersion.String(), du.APIVersion)
|
||||
assert.Equal(t, "DataUpload", du.Kind)
|
||||
assert.Equal(t, backup.Namespace, du.Namespace)
|
||||
assert.Equal(t, backup.Name+"-", du.GenerateName)
|
||||
|
||||
require.Len(t, du.OwnerReferences, 1)
|
||||
assert.Equal(t, velerov1api.SchemeGroupVersion.String(), du.OwnerReferences[0].APIVersion)
|
||||
assert.Equal(t, "Backup", du.OwnerReferences[0].Kind)
|
||||
assert.Equal(t, backup.Name, du.OwnerReferences[0].Name)
|
||||
assert.Equal(t, backup.UID, du.OwnerReferences[0].UID)
|
||||
assert.Equal(t, boolptr.True(), du.OwnerReferences[0].Controller)
|
||||
|
||||
expectedLabels := map[string]string{
|
||||
velerov1api.BackupNameLabel: label.GetValidName(backup.Name),
|
||||
velerov1api.BackupUIDLabel: string(backup.UID),
|
||||
velerov1api.PVCUIDLabel: string(pvc.UID),
|
||||
velerov1api.AsyncOperationIDLabel: operationID,
|
||||
}
|
||||
assert.Equal(t, expectedLabels, du.Labels)
|
||||
|
||||
assert.Equal(t, velerov2alpha1.SnapshotTypeCSI, du.Spec.SnapshotType)
|
||||
assert.Equal(t, vs.Name, du.Spec.CSISnapshot.VolumeSnapshot)
|
||||
assert.Equal(t, *pvc.Spec.StorageClassName, du.Spec.CSISnapshot.StorageClass)
|
||||
assert.Equal(t, vsc.Spec.Driver, du.Spec.CSISnapshot.Driver)
|
||||
if tc.vsClassName != nil {
|
||||
assert.Equal(t, *tc.vsClassName, du.Spec.CSISnapshot.SnapshotClass)
|
||||
} else {
|
||||
assert.Empty(t, du.Spec.CSISnapshot.SnapshotClass)
|
||||
}
|
||||
|
||||
assert.Equal(t, pvc.Name, du.Spec.SourcePVC)
|
||||
assert.Equal(t, backup.Spec.DataMover, du.Spec.DataMover)
|
||||
assert.Equal(t, backup.Spec.StorageLocation, du.Spec.BackupStorageLocation)
|
||||
assert.Equal(t, pvc.Namespace, du.Spec.SourceNamespace)
|
||||
assert.Equal(t, backup.Spec.CSISnapshotTimeout, du.Spec.OperationTimeout)
|
||||
assert.Equal(t, fsType, du.Spec.SourceFSType)
|
||||
assert.Equal(t, tc.expectedParentSnap, du.Spec.ParentSnapshot)
|
||||
assert.Equal(t, tc.expectedDataMoverCfg, du.Spec.DataMoverConfig)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1428,22 +1428,20 @@ func resolveClusterScopedFilterPolicy(
|
||||
}
|
||||
|
||||
func resolveResourceFilter(rf resourcepolicies.ResourceFilter) (*ResolvedResourceFilter, error) {
|
||||
var selector labels.Selector
|
||||
if len(rf.LabelSelector) > 0 {
|
||||
var err error
|
||||
selector, err = labels.ValidatedSelectorFromSet(labels.Set(rf.LabelSelector))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid label selector in resource filter: %w", err)
|
||||
}
|
||||
selector, err := resourcepolicies.SelectorFromPolicyLabelSelector(rf.LabelSelector)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid label selector in resource filter: %w", err)
|
||||
}
|
||||
|
||||
var orSelectors []labels.Selector
|
||||
for _, ols := range rf.OrLabelSelectors {
|
||||
s, err := labels.ValidatedSelectorFromSet(labels.Set(ols))
|
||||
s, err := resourcepolicies.SelectorFromPolicyLabelSelector(ols)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid OR label selector in resource filter: %w", err)
|
||||
}
|
||||
orSelectors = append(orSelectors, s)
|
||||
if s != nil {
|
||||
orSelectors = append(orSelectors, s)
|
||||
}
|
||||
}
|
||||
|
||||
var nameIE *collections.IncludesExcludes
|
||||
|
||||
+77
-15
@@ -5741,7 +5741,7 @@ func TestResolveResourceFilter(t *testing.T) {
|
||||
{
|
||||
name: "valid label selector",
|
||||
rf: resourcepolicies.ResourceFilter{
|
||||
LabelSelector: map[string]string{"app": "foo"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"app": "foo"}},
|
||||
},
|
||||
expectErr: false,
|
||||
checkResult: func(t *testing.T, r *ResolvedResourceFilter) {
|
||||
@@ -5754,16 +5754,16 @@ func TestResolveResourceFilter(t *testing.T) {
|
||||
{
|
||||
name: "invalid label selector",
|
||||
rf: resourcepolicies.ResourceFilter{
|
||||
LabelSelector: map[string]string{"invalid/label/key": "value"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"invalid/label/key": "value"}},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid or label selectors",
|
||||
rf: resourcepolicies.ResourceFilter{
|
||||
OrLabelSelectors: []map[string]string{
|
||||
{"app": "foo"},
|
||||
{"app": "bar"},
|
||||
OrLabelSelectors: []*resourcepolicies.PolicyLabelSelector{
|
||||
{MatchLabels: map[string]string{"app": "foo"}},
|
||||
{MatchLabels: map[string]string{"app": "bar"}},
|
||||
},
|
||||
},
|
||||
expectErr: false,
|
||||
@@ -5776,8 +5776,8 @@ func TestResolveResourceFilter(t *testing.T) {
|
||||
{
|
||||
name: "invalid or label selectors",
|
||||
rf: resourcepolicies.ResourceFilter{
|
||||
OrLabelSelectors: []map[string]string{
|
||||
{"invalid/label/key": "value"},
|
||||
OrLabelSelectors: []*resourcepolicies.PolicyLabelSelector{
|
||||
{MatchLabels: map[string]string{"invalid/label/key": "value"}},
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
@@ -5797,6 +5797,68 @@ func TestResolveResourceFilter(t *testing.T) {
|
||||
assert.False(t, r.NameIE.ShouldInclude("exc1"))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty labelSelector is no filter",
|
||||
rf: resourcepolicies.ResourceFilter{
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{},
|
||||
},
|
||||
expectErr: false,
|
||||
checkResult: func(t *testing.T, r *ResolvedResourceFilter) {
|
||||
t.Helper()
|
||||
require.NotNil(t, r)
|
||||
assert.Nil(t, r.LabelSelector)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set-based In and DoesNotExist",
|
||||
rf: resourcepolicies.ResourceFilter{
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{
|
||||
MatchExpressions: []resourcepolicies.PolicyLabelSelectorRequirement{
|
||||
{Key: "environment", Operator: "In", Values: []string{"prod", "staging"}},
|
||||
{Key: "do-not-backup", Operator: "DoesNotExist"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErr: false,
|
||||
checkResult: func(t *testing.T, r *ResolvedResourceFilter) {
|
||||
t.Helper()
|
||||
require.NotNil(t, r.LabelSelector)
|
||||
assert.True(t, r.LabelSelector.Matches(labels.Set{"environment": "prod"}))
|
||||
assert.True(t, r.LabelSelector.Matches(labels.Set{"environment": "staging"}))
|
||||
assert.False(t, r.LabelSelector.Matches(labels.Set{"environment": "dev"}))
|
||||
assert.False(t, r.LabelSelector.Matches(labels.Set{"environment": "prod", "do-not-backup": "true"}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set-based NotIn and Exists",
|
||||
rf: resourcepolicies.ResourceFilter{
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{
|
||||
MatchExpressions: []resourcepolicies.PolicyLabelSelectorRequirement{
|
||||
{Key: "tier", Operator: "NotIn", Values: []string{"debug"}},
|
||||
{Key: "app", Operator: "Exists"},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErr: false,
|
||||
checkResult: func(t *testing.T, r *ResolvedResourceFilter) {
|
||||
t.Helper()
|
||||
require.NotNil(t, r.LabelSelector)
|
||||
assert.True(t, r.LabelSelector.Matches(labels.Set{"app": "web", "tier": "frontend"}))
|
||||
assert.False(t, r.LabelSelector.Matches(labels.Set{"app": "web", "tier": "debug"}))
|
||||
assert.False(t, r.LabelSelector.Matches(labels.Set{"tier": "frontend"}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid operator",
|
||||
rf: resourcepolicies.ResourceFilter{
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{
|
||||
MatchExpressions: []resourcepolicies.PolicyLabelSelectorRequirement{
|
||||
{Key: "env", Operator: "Equals", Values: []string{"prod"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -5834,11 +5896,11 @@ func TestResolveClusterScopedFilterPolicy(t *testing.T) {
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"pods", "secrets"},
|
||||
LabelSelector: map[string]string{"app": "foo"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"app": "foo"}},
|
||||
},
|
||||
{
|
||||
Kinds: []string{"invalid-kind"},
|
||||
LabelSelector: map[string]string{"invalid/label/key": "value"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"invalid/label/key": "value"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -5852,7 +5914,7 @@ func TestResolveClusterScopedFilterPolicy(t *testing.T) {
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"pods", "secrets"},
|
||||
LabelSelector: map[string]string{"app": "foo"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"app": "foo"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -5900,11 +5962,11 @@ func TestResolveNamespacedFilterPolicies(t *testing.T) {
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"pods"},
|
||||
LabelSelector: map[string]string{"app": "foo"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"app": "foo"}},
|
||||
},
|
||||
{
|
||||
Kinds: []string{"*"},
|
||||
LabelSelector: map[string]string{"catch": "all"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"catch": "all"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -5932,7 +5994,7 @@ func TestResolveNamespacedFilterPolicies(t *testing.T) {
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"pods"},
|
||||
LabelSelector: map[string]string{"invalid/label/key": "value"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"invalid/label/key": "value"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -6016,7 +6078,7 @@ func TestBackupWithResPoliciesLogs(t *testing.T) {
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"pods"},
|
||||
LabelSelector: map[string]string{"invalid/label/key": "value"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"invalid/label/key": "value"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -6035,7 +6097,7 @@ func TestBackupWithResPoliciesLogs(t *testing.T) {
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"pods"},
|
||||
LabelSelector: map[string]string{"invalid/label/key": "value"},
|
||||
LabelSelector: &resourcepolicies.PolicyLabelSelector{MatchLabels: map[string]string{"invalid/label/key": "value"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,6 +15,7 @@ package datamover
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/fips140"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -87,7 +88,10 @@ func NewBackupCommand(f client.Factory) *cobra.Command {
|
||||
kube.ExitPodWithMessage(logger, false, "Failed to create data mover backup, %v", err)
|
||||
}
|
||||
|
||||
s.run()
|
||||
// Disable FIPS-140 compliance check, because Kopia doesn't support FIPS-140 yet.
|
||||
fips140.WithoutEnforcement(func() {
|
||||
s.run()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ package datamover
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/fips140"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -81,7 +82,10 @@ func NewRestoreCommand(f client.Factory) *cobra.Command {
|
||||
kube.ExitPodWithMessage(logger, false, "Failed to create data mover restore, %v", err)
|
||||
}
|
||||
|
||||
s.run()
|
||||
// Disable FIPS-140 compliance check, because Kopia doesn't support FIPS-140 yet.
|
||||
fips140.WithoutEnforcement(func() {
|
||||
s.run()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ package podvolume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/fips140"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -80,7 +81,10 @@ func NewBackupCommand(f client.Factory) *cobra.Command {
|
||||
kube.ExitPodWithMessage(logger, false, "Failed to create pod volume backup, %v", err)
|
||||
}
|
||||
|
||||
s.run()
|
||||
// Disable FIPS-140 compliance check, because Kopia doesn't support FIPS-140 yet.
|
||||
fips140.WithoutEnforcement(func() {
|
||||
s.run()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ package podvolume
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/fips140"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -79,7 +80,10 @@ func NewRestoreCommand(f client.Factory) *cobra.Command {
|
||||
kube.ExitPodWithMessage(logger, false, "Failed to create pod volume restore, %v", err)
|
||||
}
|
||||
|
||||
s.run()
|
||||
// Disable FIPS-140 compliance check, because Kopia doesn't support FIPS-140 yet.
|
||||
fips140.WithoutEnforcement(func() {
|
||||
s.run()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package repomantenance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/fips140"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -57,7 +58,10 @@ func NewCommand(f velerocli.Factory) *cobra.Command {
|
||||
Hidden: true,
|
||||
Short: "VELERO INTERNAL COMMAND ONLY - not intended to be run directly by users",
|
||||
Run: func(c *cobra.Command, args []string) {
|
||||
o.Run(f)
|
||||
// Disable FIPS-140 compliance check, because Kopia doesn't support FIPS-140 yet.
|
||||
fips140.WithoutEnforcement(func() {
|
||||
o.Run(f)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -31,7 +30,6 @@ import (
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
snapshotv1api "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/fatih/color"
|
||||
kbclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@@ -94,9 +92,6 @@ func DescribeBackup(
|
||||
if backup.Spec.ResourcePolicy != nil {
|
||||
d.Println()
|
||||
DescribeResourcePolicies(d, backup.Spec.ResourcePolicy)
|
||||
|
||||
// Display fine-grained filter policies if they exist
|
||||
DescribeFineGrainedFilterPolicies(ctx, kbClient, d, backup)
|
||||
}
|
||||
|
||||
DescribeGlobalVolumePolicy(d, backup)
|
||||
@@ -151,119 +146,6 @@ func DescribeGlobalVolumePolicy(d *Describer, backup *velerov1api.Backup) {
|
||||
d.Printf("\tName:\t%s\n", name)
|
||||
}
|
||||
|
||||
// DescribeFineGrainedFilterPolicies describes cluster-scoped and namespace-scoped filter policies if present
|
||||
func DescribeFineGrainedFilterPolicies(ctx context.Context, kbClient kbclient.Client, d *Describer, backup *velerov1api.Backup) {
|
||||
if backup.Spec.ResourcePolicy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Create a discard logger for the resource policies function since this is CLI output context
|
||||
discardLogger := logrus.New()
|
||||
discardLogger.Out = io.Discard
|
||||
|
||||
resourcePolicies, err := resourcepolicies.GetResourcePoliciesFromBackup(*backup, kbClient, discardLogger)
|
||||
if err != nil {
|
||||
// Don't fail the describe if we can't read policies, just skip
|
||||
return
|
||||
}
|
||||
|
||||
if resourcePolicies == nil {
|
||||
return
|
||||
}
|
||||
|
||||
clusterScopedFilterPolicy := resourcePolicies.GetClusterScopedFilterPolicy()
|
||||
if clusterScopedFilterPolicy != nil {
|
||||
d.Printf("\nCluster Scoped Filter Policy:\n")
|
||||
d.Printf(" Resource Filters:\n")
|
||||
for _, rf := range clusterScopedFilterPolicy.ResourceFilters {
|
||||
kindsStr := strings.Join(rf.Kinds, ", ")
|
||||
d.Printf(" %s:\n", kindsStr)
|
||||
|
||||
// Label selector
|
||||
if len(rf.LabelSelector) > 0 {
|
||||
selectorStr := formatLabelMap(rf.LabelSelector)
|
||||
d.Printf(" Label selector: %s\n", selectorStr)
|
||||
} else if len(rf.OrLabelSelectors) > 0 {
|
||||
var orStrs []string
|
||||
for _, ols := range rf.OrLabelSelectors {
|
||||
orStrs = append(orStrs, formatLabelMap(ols))
|
||||
}
|
||||
d.Printf(" OR label selectors: [%s]\n", strings.Join(orStrs, ", "))
|
||||
} else {
|
||||
d.Printf(" Label selector: <none>\n")
|
||||
}
|
||||
|
||||
// Name patterns
|
||||
if len(rf.Names) > 0 {
|
||||
d.Printf(" Included names: [%s]\n", strings.Join(rf.Names, ", "))
|
||||
} else {
|
||||
d.Printf(" Included names: <none>\n")
|
||||
}
|
||||
|
||||
if len(rf.ExcludedNames) > 0 {
|
||||
d.Printf(" Excluded names: [%s]\n", strings.Join(rf.ExcludedNames, ", "))
|
||||
} else {
|
||||
d.Printf(" Excluded names: <none>\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nfPolicies := resourcePolicies.GetNamespacedFilterPolicies()
|
||||
if len(nfPolicies) > 0 {
|
||||
d.Printf("\nNamespace-Scoped Filter Policies:\n")
|
||||
for _, policy := range nfPolicies {
|
||||
for _, ns := range policy.Namespaces {
|
||||
d.Printf(" %s:\n", ns)
|
||||
d.Printf(" Resource Filters:\n")
|
||||
for _, rf := range policy.ResourceFilters {
|
||||
var kindsStr string
|
||||
if rf.IsCatchAll() {
|
||||
kindsStr = "<catch-all> (all other kinds)"
|
||||
} else {
|
||||
kindsStr = strings.Join(rf.Kinds, ", ")
|
||||
}
|
||||
d.Printf(" %s:\n", kindsStr)
|
||||
|
||||
// Label selector
|
||||
if len(rf.LabelSelector) > 0 {
|
||||
selectorStr := formatLabelMap(rf.LabelSelector)
|
||||
d.Printf(" Label selector: %s\n", selectorStr)
|
||||
} else if len(rf.OrLabelSelectors) > 0 {
|
||||
var orStrs []string
|
||||
for _, ols := range rf.OrLabelSelectors {
|
||||
orStrs = append(orStrs, formatLabelMap(ols))
|
||||
}
|
||||
d.Printf(" OR label selectors: [%s]\n", strings.Join(orStrs, ", "))
|
||||
} else {
|
||||
d.Printf(" Label selector: <none>\n")
|
||||
}
|
||||
|
||||
// Name patterns
|
||||
if len(rf.Names) > 0 {
|
||||
d.Printf(" Included names: [%s]\n", strings.Join(rf.Names, ", "))
|
||||
} else {
|
||||
d.Printf(" Included names: <none>\n")
|
||||
}
|
||||
|
||||
if len(rf.ExcludedNames) > 0 {
|
||||
d.Printf(" Excluded names: [%s]\n", strings.Join(rf.ExcludedNames, ", "))
|
||||
} else {
|
||||
d.Printf(" Excluded names: <none>\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatLabelMap(labelMap map[string]string) string {
|
||||
var pairs []string
|
||||
for k, v := range labelMap {
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
return strings.Join(pairs, ",")
|
||||
}
|
||||
|
||||
// DescribeUploaderConfigForBackup describes uploader config in human-readable format
|
||||
func DescribeUploaderConfigForBackup(d *Describer, spec velerov1api.BackupSpec) {
|
||||
d.Printf("Uploader config:\n")
|
||||
|
||||
@@ -18,7 +18,6 @@ package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
@@ -26,8 +25,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
"github.com/vmware-tanzu/velero/internal/volume"
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
@@ -897,85 +894,3 @@ func TestDescribeBackupItemOperation(t *testing.T) {
|
||||
d.out.Flush()
|
||||
assert.Equal(t, expected, d.buf.String())
|
||||
}
|
||||
|
||||
func TestDescribeFineGrainedFilterPolicies(t *testing.T) {
|
||||
yamlData := `
|
||||
version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["StorageClass"]
|
||||
labelSelector: {"app": "velero"}
|
||||
- kinds: ["ClusterRole"]
|
||||
orLabelSelectors:
|
||||
- {"app": "velero"}
|
||||
- {"app": "test"}
|
||||
names: ["role1"]
|
||||
excludedNames: ["role2"]
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["ns1", "ns2"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod", "ConfigMap"]
|
||||
labelSelector: {"app": "velero"}
|
||||
- kinds: ["*"]
|
||||
`
|
||||
cm := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-policy",
|
||||
Namespace: "velero",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"policy.yaml": yamlData,
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().WithRuntimeObjects(cm).Build()
|
||||
|
||||
backup := builder.ForBackup("velero", "test-backup").
|
||||
ResourcePolicies("test-policy").Result()
|
||||
|
||||
d := &Describer{
|
||||
Prefix: "",
|
||||
out: &tabwriter.Writer{},
|
||||
buf: &bytes.Buffer{},
|
||||
}
|
||||
d.out.Init(d.buf, 0, 8, 2, ' ', 0)
|
||||
|
||||
DescribeFineGrainedFilterPolicies(context.Background(), client, d, backup)
|
||||
d.out.Flush()
|
||||
|
||||
expected := `
|
||||
Cluster Scoped Filter Policy:
|
||||
Resource Filters:
|
||||
StorageClass:
|
||||
Label selector: app=velero
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
ClusterRole:
|
||||
OR label selectors: [app=velero, app=test]
|
||||
Included names: [role1]
|
||||
Excluded names: [role2]
|
||||
|
||||
Namespace-Scoped Filter Policies:
|
||||
ns1:
|
||||
Resource Filters:
|
||||
Pod, ConfigMap:
|
||||
Label selector: app=velero
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
<catch-all> (all other kinds):
|
||||
Label selector: <none>
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
ns2:
|
||||
Resource Filters:
|
||||
Pod, ConfigMap:
|
||||
Label selector: app=velero
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
<catch-all> (all other kinds):
|
||||
Label selector: <none>
|
||||
Included names: <none>
|
||||
Excluded names: <none>
|
||||
`
|
||||
assert.Equal(t, expected, d.buf.String())
|
||||
}
|
||||
|
||||
@@ -21,10 +21,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
@@ -57,7 +55,6 @@ func DescribeBackupInSF(
|
||||
|
||||
if backup.Spec.ResourcePolicy != nil {
|
||||
DescribeResourcePoliciesInSF(d, backup.Spec.ResourcePolicy)
|
||||
DescribeFineGrainedFilterPoliciesInSF(ctx, kbClient, d, backup)
|
||||
}
|
||||
|
||||
DescribeGlobalVolumePolicyInSF(d, backup)
|
||||
@@ -228,88 +225,6 @@ func DescribeBackupSpecInSF(d *StructuredDescriber, spec velerov1api.BackupSpec)
|
||||
d.Describe("spec", backupSpecInfo)
|
||||
}
|
||||
|
||||
// DescribeFineGrainedFilterPoliciesInSF adds the clusterScopedFilterPolicy
|
||||
// and namespacedFilterPolicies sections to the structured describer output when present
|
||||
// in the ResourcePolicy ConfigMap referenced by the backup.
|
||||
func DescribeFineGrainedFilterPoliciesInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, backup *velerov1api.Backup) {
|
||||
if backup.Spec.ResourcePolicy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
discardLogger := logrus.New()
|
||||
discardLogger.Out = io.Discard
|
||||
|
||||
resPolicies, err := resourcepolicies.GetResourcePoliciesFromBackup(*backup, kbClient, discardLogger)
|
||||
if err != nil || resPolicies == nil {
|
||||
return
|
||||
}
|
||||
|
||||
clusterScopedFilterPolicy := resPolicies.GetClusterScopedFilterPolicy()
|
||||
if clusterScopedFilterPolicy != nil {
|
||||
var clusterScopedFilters []map[string]any
|
||||
for _, rf := range clusterScopedFilterPolicy.ResourceFilters {
|
||||
entry := map[string]any{
|
||||
"kinds": rf.Kinds,
|
||||
}
|
||||
if len(rf.LabelSelector) > 0 {
|
||||
entry["labelSelector"] = rf.LabelSelector
|
||||
}
|
||||
if len(rf.OrLabelSelectors) > 0 {
|
||||
entry["orLabelSelectors"] = rf.OrLabelSelectors
|
||||
}
|
||||
if len(rf.Names) > 0 {
|
||||
entry["names"] = rf.Names
|
||||
}
|
||||
if len(rf.ExcludedNames) > 0 {
|
||||
entry["excludedNames"] = rf.ExcludedNames
|
||||
}
|
||||
clusterScopedFilters = append(clusterScopedFilters, entry)
|
||||
}
|
||||
d.Describe("clusterScopedFilterPolicy", map[string]any{
|
||||
"resourceFilters": clusterScopedFilters,
|
||||
})
|
||||
}
|
||||
|
||||
nfPolicies := resPolicies.GetNamespacedFilterPolicies()
|
||||
if len(nfPolicies) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var structuredPolicies []map[string]any
|
||||
for _, policy := range nfPolicies {
|
||||
for _, ns := range policy.Namespaces {
|
||||
var rfEntries []map[string]any
|
||||
for _, rf := range policy.ResourceFilters {
|
||||
entry := map[string]any{}
|
||||
if rf.IsCatchAll() {
|
||||
entry["kinds"] = []string{}
|
||||
entry["isCatchAll"] = true
|
||||
} else {
|
||||
entry["kinds"] = rf.Kinds
|
||||
}
|
||||
if len(rf.LabelSelector) > 0 {
|
||||
entry["labelSelector"] = rf.LabelSelector
|
||||
}
|
||||
if len(rf.OrLabelSelectors) > 0 {
|
||||
entry["orLabelSelectors"] = rf.OrLabelSelectors
|
||||
}
|
||||
if len(rf.Names) > 0 {
|
||||
entry["names"] = rf.Names
|
||||
}
|
||||
if len(rf.ExcludedNames) > 0 {
|
||||
entry["excludedNames"] = rf.ExcludedNames
|
||||
}
|
||||
rfEntries = append(rfEntries, entry)
|
||||
}
|
||||
structuredPolicies = append(structuredPolicies, map[string]any{
|
||||
"namespace": ns,
|
||||
"resourceFilters": rfEntries,
|
||||
})
|
||||
}
|
||||
}
|
||||
d.Describe("namespacedFilterPolicies", structuredPolicies)
|
||||
}
|
||||
|
||||
// DescribeBackupStatusInSF describes a backup status in structured format.
|
||||
func DescribeBackupStatusInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, backup *velerov1api.Backup, details bool,
|
||||
insecureSkipTLSVerify bool, caCertPath string, podVolumeBackups []velerov1api.PodVolumeBackup) {
|
||||
|
||||
@@ -17,7 +17,6 @@ limitations under the License.
|
||||
package output
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -25,8 +24,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
"github.com/vmware-tanzu/velero/internal/volume"
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
@@ -731,96 +728,3 @@ func TestDescribeDeleteBackupRequestsInSF(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeFineGrainedFilterPoliciesInSF(t *testing.T) {
|
||||
yamlData := `
|
||||
version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["StorageClass"]
|
||||
labelSelector: {"app": "velero"}
|
||||
- kinds: ["ClusterRole"]
|
||||
orLabelSelectors:
|
||||
- {"app": "velero"}
|
||||
- {"app": "test"}
|
||||
names: ["role1"]
|
||||
excludedNames: ["role2"]
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["ns1", "ns2"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod", "ConfigMap"]
|
||||
labelSelector: {"app": "velero"}
|
||||
- kinds: ["*"]
|
||||
`
|
||||
cm := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-policy",
|
||||
Namespace: "velero",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"policy.yaml": yamlData,
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().WithRuntimeObjects(cm).Build()
|
||||
|
||||
backup := builder.ForBackup("velero", "test-backup").
|
||||
ResourcePolicies("test-policy").Result()
|
||||
|
||||
sd := &StructuredDescriber{
|
||||
output: make(map[string]any),
|
||||
format: "",
|
||||
}
|
||||
|
||||
DescribeFineGrainedFilterPoliciesInSF(context.Background(), client, sd, backup)
|
||||
|
||||
expect := map[string]any{
|
||||
"clusterScopedFilterPolicy": map[string]any{
|
||||
"resourceFilters": []map[string]any{
|
||||
{
|
||||
"kinds": []string{"StorageClass"},
|
||||
"labelSelector": map[string]string{"app": "velero"},
|
||||
},
|
||||
{
|
||||
"kinds": []string{"ClusterRole"},
|
||||
"orLabelSelectors": []map[string]string{
|
||||
{"app": "velero"},
|
||||
{"app": "test"},
|
||||
},
|
||||
"names": []string{"role1"},
|
||||
"excludedNames": []string{"role2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"namespacedFilterPolicies": []map[string]any{
|
||||
{
|
||||
"namespace": "ns1",
|
||||
"resourceFilters": []map[string]any{
|
||||
{
|
||||
"kinds": []string{"Pod", "ConfigMap"},
|
||||
"labelSelector": map[string]string{"app": "velero"},
|
||||
},
|
||||
{
|
||||
"kinds": []string{},
|
||||
"isCatchAll": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"namespace": "ns2",
|
||||
"resourceFilters": []map[string]any{
|
||||
{
|
||||
"kinds": []string{"Pod", "ConfigMap"},
|
||||
"labelSelector": map[string]string{"app": "velero"},
|
||||
},
|
||||
{
|
||||
"kinds": []string{},
|
||||
"isCatchAll": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.True(t, reflect.DeepEqual(sd.output, expect))
|
||||
}
|
||||
|
||||
@@ -84,33 +84,34 @@ var autoExcludeClusterScopedResources = []string{
|
||||
}
|
||||
|
||||
type backupReconciler struct {
|
||||
ctx context.Context
|
||||
logger logrus.FieldLogger
|
||||
discoveryHelper discovery.Helper
|
||||
backupper pkgbackup.Backupper
|
||||
kbClient kbclient.Client
|
||||
clock clock.WithTickerAndDelayedExecution
|
||||
backupLogLevel logrus.Level
|
||||
newPluginManager func(logrus.FieldLogger) clientmgmt.Manager
|
||||
backupTracker BackupTracker
|
||||
defaultBackupLocation string
|
||||
defaultVolumesToFsBackup bool
|
||||
defaultBackupTTL time.Duration
|
||||
defaultVGSLabelKey string
|
||||
defaultCSISnapshotTimeout time.Duration
|
||||
resourceTimeout time.Duration
|
||||
defaultItemOperationTimeout time.Duration
|
||||
defaultSnapshotLocations map[string]string
|
||||
metrics *metrics.ServerMetrics
|
||||
backupStoreGetter persistence.ObjectBackupStoreGetter
|
||||
formatFlag logging.Format
|
||||
credentialFileStore credentials.FileStore
|
||||
maxConcurrentK8SConnections int
|
||||
defaultSnapshotMoveData bool
|
||||
globalCRClient kbclient.Client
|
||||
itemBlockWorkerCount int
|
||||
concurrentBackups int
|
||||
globalVolumePoliciesConfigMap string
|
||||
ctx context.Context
|
||||
logger logrus.FieldLogger
|
||||
discoveryHelper discovery.Helper
|
||||
backupper pkgbackup.Backupper
|
||||
kbClient kbclient.Client
|
||||
clock clock.WithTickerAndDelayedExecution
|
||||
backupLogLevel logrus.Level
|
||||
newPluginManager func(logrus.FieldLogger) clientmgmt.Manager
|
||||
backupTracker BackupTracker
|
||||
defaultBackupLocation string
|
||||
defaultVolumesToFsBackup bool
|
||||
defaultBackupTTL time.Duration
|
||||
defaultVGSLabelKey string
|
||||
defaultCSISnapshotTimeout time.Duration
|
||||
resourceTimeout time.Duration
|
||||
defaultItemOperationTimeout time.Duration
|
||||
defaultSnapshotLocations map[string]string
|
||||
metrics *metrics.ServerMetrics
|
||||
backupStoreGetter persistence.ObjectBackupStoreGetter
|
||||
formatFlag logging.Format
|
||||
credentialFileStore credentials.FileStore
|
||||
maxConcurrentK8SConnections int
|
||||
defaultSnapshotMoveData bool
|
||||
globalCRClient kbclient.Client
|
||||
itemBlockWorkerCount int
|
||||
concurrentBackups int
|
||||
globalVolumePoliciesConfigMap string
|
||||
knownSchedulesWithSuccessfulBackup sets.Set[string]
|
||||
}
|
||||
|
||||
func NewBackupReconciler(
|
||||
@@ -204,28 +205,43 @@ func (b *backupReconciler) updateTotalBackupMetric() {
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
wait.Until(
|
||||
func() {
|
||||
// recompute backup_total metric
|
||||
backups := &velerov1api.BackupList{}
|
||||
err := b.kbClient.List(context.Background(), backups, &kbclient.ListOptions{LabelSelector: labels.Everything()})
|
||||
if err != nil {
|
||||
b.logger.Error(err, "Error computing backup_total metric")
|
||||
} else {
|
||||
b.metrics.SetBackupTotal(int64(len(backups.Items)))
|
||||
}
|
||||
|
||||
// recompute backup_last_successful_timestamp metric for each
|
||||
// schedule (including the empty schedule, i.e. ad-hoc backups)
|
||||
for schedule, timestamp := range getLastSuccessBySchedule(backups.Items) {
|
||||
b.metrics.SetBackupLastSuccessfulTimestamp(schedule, timestamp)
|
||||
}
|
||||
},
|
||||
b.resyncBackupMetrics,
|
||||
backupResyncPeriod,
|
||||
b.ctx.Done(),
|
||||
)
|
||||
}()
|
||||
}
|
||||
|
||||
func (b *backupReconciler) resyncBackupMetrics() {
|
||||
backups := &velerov1api.BackupList{}
|
||||
err := b.kbClient.List(context.Background(), backups, &kbclient.ListOptions{LabelSelector: labels.Everything()})
|
||||
if err != nil {
|
||||
b.logger.Error(err, "Error computing backup_total metric")
|
||||
return
|
||||
}
|
||||
|
||||
b.metrics.SetBackupTotal(int64(len(backups.Items)))
|
||||
|
||||
currentSchedules := getLastSuccessBySchedule(backups.Items)
|
||||
for schedule, timestamp := range currentSchedules {
|
||||
b.metrics.SetBackupLastSuccessfulTimestamp(schedule, timestamp)
|
||||
}
|
||||
|
||||
// Remove metrics for schedules that no longer have successful backups
|
||||
if b.knownSchedulesWithSuccessfulBackup != nil {
|
||||
for schedule := range b.knownSchedulesWithSuccessfulBackup {
|
||||
if _, exists := currentSchedules[schedule]; !exists {
|
||||
b.metrics.DeleteBackupLastSuccessfulTimestamp(schedule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
b.knownSchedulesWithSuccessfulBackup = sets.New[string]()
|
||||
for schedule := range currentSchedules {
|
||||
b.knownSchedulesWithSuccessfulBackup.Insert(schedule)
|
||||
}
|
||||
}
|
||||
|
||||
// getLastSuccessBySchedule finds the most recent completed backup for each schedule
|
||||
// and returns a map of schedule name -> completion time of the most recent completed
|
||||
// backup. This map includes an entry for ad-hoc/non-scheduled backups, where the key
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
snapshotv1api "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
@@ -2041,6 +2042,48 @@ func Test_getLastSuccessBySchedule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Test_resyncBackupMetrics_prunesStaleTimestamps verifies that resyncBackupMetrics
|
||||
// removes backupLastSuccessfulTimestamp entries for schedules that no longer have
|
||||
// any completed backups (e.g. after the schedule and its backups are deleted).
|
||||
func Test_resyncBackupMetrics_prunesStaleTimestamps(t *testing.T) {
|
||||
baseTime, err := time.Parse(time.RFC1123, time.RFC1123)
|
||||
require.NoError(t, err)
|
||||
|
||||
m := metrics.NewServerMetrics()
|
||||
gauge := m.Metrics()["backup_last_successful_timestamp"]
|
||||
|
||||
activeBackup := builder.ForBackup("velero", "b1").
|
||||
ObjectMeta(builder.WithLabels(velerov1api.ScheduleNameLabel, "active-schedule")).
|
||||
Phase(velerov1api.BackupPhaseCompleted).
|
||||
CompletionTimestamp(baseTime).
|
||||
Result()
|
||||
|
||||
deletedBackup := builder.ForBackup("velero", "b2").
|
||||
ObjectMeta(builder.WithLabels(velerov1api.ScheduleNameLabel, "deleted-schedule")).
|
||||
Phase(velerov1api.BackupPhaseCompleted).
|
||||
CompletionTimestamp(baseTime).
|
||||
Result()
|
||||
|
||||
fakeClient := velerotest.NewFakeControllerRuntimeClient(t, activeBackup, deletedBackup)
|
||||
|
||||
c := &backupReconciler{
|
||||
kbClient: fakeClient,
|
||||
logger: logrus.StandardLogger(),
|
||||
metrics: m,
|
||||
}
|
||||
|
||||
// First resync: sets metrics for both schedules
|
||||
c.resyncBackupMetrics()
|
||||
assert.Equal(t, 2, testutil.CollectAndCount(gauge))
|
||||
|
||||
// Simulate schedule deletion: remove the backup for "deleted-schedule"
|
||||
require.NoError(t, fakeClient.Delete(t.Context(), deletedBackup))
|
||||
|
||||
// Second resync: prunes "deleted-schedule" metric, keeps "active-schedule"
|
||||
c.resyncBackupMetrics()
|
||||
assert.Equal(t, 1, testutil.CollectAndCount(gauge))
|
||||
}
|
||||
|
||||
// Unit tests to make sure that the backup's status is updated correctly during reconcile.
|
||||
// To clear up confusion whether status can be updated with Patch alone without status writer and not kbClient.Status().Patch()
|
||||
func TestPatchResourceWorksWithStatus(t *testing.T) {
|
||||
|
||||
@@ -454,7 +454,7 @@ func (r *DataDownloadReconciler) startCancelableDataPath(asyncBR datapath.AsyncB
|
||||
|
||||
if err := asyncBR.StartRestore(dd.Spec.SnapshotID, datapath.AccessPoint{
|
||||
ByPath: res.ByPod.VolumeName,
|
||||
}, dd.Spec.DataMoverConfig); err != nil {
|
||||
}, dd.Spec.DataMoverConfig, nil); err != nil {
|
||||
return errors.Wrapf(err, "error starting async restore for pod %s, volume %s", res.ByPod.HostingPod.Name, res.ByPod.VolumeName)
|
||||
}
|
||||
|
||||
@@ -1096,7 +1096,7 @@ func (r *DataDownloadReconciler) resumeCancellableDataPath(ctx context.Context,
|
||||
|
||||
if err := asyncBR.StartRestore(dd.Spec.SnapshotID, datapath.AccessPoint{
|
||||
ByPath: res.ByPod.VolumeName,
|
||||
}, nil); err != nil {
|
||||
}, nil, nil); err != nil {
|
||||
return errors.Wrapf(err, "error to resume asyncBR watcher for dd %s", dd.Name)
|
||||
}
|
||||
|
||||
|
||||
@@ -529,7 +529,7 @@ func TestDataDownloadReconcile(t *testing.T) {
|
||||
}
|
||||
|
||||
if test.mockStart {
|
||||
asyncBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything).Return(test.mockStartErr)
|
||||
asyncBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(test.mockStartErr)
|
||||
}
|
||||
|
||||
if test.mockCancel {
|
||||
@@ -1288,7 +1288,7 @@ func TestResumeCancellableRestore(t *testing.T) {
|
||||
}
|
||||
|
||||
if test.mockStart {
|
||||
mockAsyncBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything).Return(test.startWatcherErr)
|
||||
mockAsyncBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(test.startWatcherErr)
|
||||
}
|
||||
|
||||
if test.mockClose {
|
||||
|
||||
@@ -348,7 +348,7 @@ func (f *fakeFSBR) StartBackup(source datapath.AccessPoint, uploaderConfigs map[
|
||||
return f.startErr
|
||||
}
|
||||
|
||||
func (f *fakeFSBR) StartRestore(snapshotID string, target datapath.AccessPoint, uploaderConfigs map[string]string) error {
|
||||
func (f *fakeFSBR) StartRestore(snapshotID string, target datapath.AccessPoint, uploaderConfigs map[string]string, param any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -236,9 +236,9 @@ func (r *PodVolumeRestoreReconciler) Reconcile(ctx context.Context, req ctrl.Req
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
shouldProcess, pod, err := shouldProcess(ctx, r.client, log, pvr)
|
||||
shouldProcess, pod, err := shouldProcess(ctx, r.client, log, pvr, r.resourceTimeout)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
return r.errorOut(ctx, pvr, err, "Pod for this PVR is not ready", log)
|
||||
}
|
||||
if !shouldProcess {
|
||||
return ctrl.Result{}, nil
|
||||
@@ -528,7 +528,7 @@ func (r *PodVolumeRestoreReconciler) startCancelableDataPath(asyncBR datapath.As
|
||||
|
||||
if err := asyncBR.StartRestore(pvr.Spec.SnapshotID, datapath.AccessPoint{
|
||||
ByPath: res.ByPod.VolumeName,
|
||||
}, pvr.Spec.UploaderSettings); err != nil {
|
||||
}, pvr.Spec.UploaderSettings, nil); err != nil {
|
||||
return errors.Wrapf(err, "error starting async restore for pod %s, volume %s", res.ByPod.HostingPod.Name, res.ByPod.VolumeName)
|
||||
}
|
||||
|
||||
@@ -565,7 +565,7 @@ func UpdatePVRStatusToFailed(ctx context.Context, c client.Client, pvr *velerov1
|
||||
return err
|
||||
}
|
||||
|
||||
func shouldProcess(ctx context.Context, client client.Client, log logrus.FieldLogger, pvr *velerov1api.PodVolumeRestore) (bool, *corev1api.Pod, error) {
|
||||
func shouldProcess(ctx context.Context, client client.Client, log logrus.FieldLogger, pvr *velerov1api.PodVolumeRestore, timeout time.Duration) (bool, *corev1api.Pod, error) {
|
||||
if !isPVRNew(pvr) {
|
||||
log.Debug("PVR is not new, skip")
|
||||
return false, nil, nil
|
||||
@@ -573,22 +573,63 @@ func shouldProcess(ctx context.Context, client client.Client, log logrus.FieldLo
|
||||
|
||||
// we filter the pods during the initialization of cache, if we can get a pod here, the pod must be in the same node with the controller
|
||||
// so we don't need to compare the node anymore
|
||||
pod := &corev1api.Pod{}
|
||||
if err := client.Get(ctx, types.NamespacedName{Namespace: pvr.Spec.Pod.Namespace, Name: pvr.Spec.Pod.Name}, pod); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
log.WithError(err).Debug("Pod not found on this node, skip")
|
||||
return false, nil, nil
|
||||
var targetPod *corev1api.Pod
|
||||
err := wait.PollUntilContextTimeout(ctx, time.Millisecond*100, timeout, true, func(ctx context.Context) (bool, error) {
|
||||
updated := &corev1api.Pod{}
|
||||
if err := client.Get(ctx, types.NamespacedName{Namespace: pvr.Spec.Pod.Namespace, Name: pvr.Spec.Pod.Name}, updated); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
targetPod = updated
|
||||
|
||||
return true, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return false, nil, errors.Errorf("timeout to wait for pod %s/%s", pvr.Spec.Pod.Namespace, pvr.Spec.Pod.Name)
|
||||
} else {
|
||||
return false, nil, errors.Wrapf(err, "error waiting for pod %s/%s", pvr.Spec.Pod.Namespace, pvr.Spec.Pod.Name)
|
||||
}
|
||||
log.WithError(err).Error("Unable to get pod")
|
||||
return false, nil, err
|
||||
}
|
||||
|
||||
if !isInitContainerRunning(pod) {
|
||||
if targetPod.Status.Phase == corev1api.PodFailed || targetPod.Status.Phase == corev1api.PodUnknown {
|
||||
return false, nil, errors.Errorf("unexpected state for pod %s/%s", targetPod.Namespace, targetPod.Name)
|
||||
}
|
||||
|
||||
idx := getInitContainerIndex(targetPod)
|
||||
if idx < 0 {
|
||||
return false, nil, errors.Errorf("no restore-wait init container in pod %s/%s", targetPod.Namespace, targetPod.Name)
|
||||
}
|
||||
|
||||
if len(targetPod.Status.InitContainerStatuses) <= idx {
|
||||
log.Debug("Pod init container statuses are not fully populated yet, skip")
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
containerStatus := targetPod.Status.InitContainerStatuses[idx]
|
||||
|
||||
if containerStatus.State.Terminated != nil {
|
||||
return false, nil, errors.Errorf("restore-wait init container has already completed in pod %s/%s", targetPod.Namespace, targetPod.Name)
|
||||
}
|
||||
|
||||
if containerStatus.State.Waiting != nil {
|
||||
reason := containerStatus.State.Waiting.Reason
|
||||
if reason == "ImagePullBackOff" || reason == "ErrImageNeverPull" || reason == "CreateContainerConfigError" || reason == "CreateContainerError" || reason == "InvalidImageName" || reason == "ErrImagePull" {
|
||||
return false, nil, errors.Errorf("restore-wait init container in pod %s/%s is in unrecoverable waiting state with reason %s", targetPod.Namespace, targetPod.Name, reason)
|
||||
}
|
||||
}
|
||||
|
||||
if containerStatus.State.Running == nil {
|
||||
log.Debug("Pod is not running restore-wait init container, skip")
|
||||
return false, nil, nil
|
||||
}
|
||||
|
||||
return true, pod, nil
|
||||
return true, targetPod, nil
|
||||
}
|
||||
|
||||
func (r *PodVolumeRestoreReconciler) closeDataPath(ctx context.Context, pvrName string) {
|
||||
@@ -770,14 +811,6 @@ func isPVRNew(pvr *velerov1api.PodVolumeRestore) bool {
|
||||
return pvr.Status.Phase == "" || pvr.Status.Phase == velerov1api.PodVolumeRestorePhaseNew
|
||||
}
|
||||
|
||||
func isInitContainerRunning(pod *corev1api.Pod) bool {
|
||||
// Pod volume wait container can be anywhere in the list of init containers, but must be running.
|
||||
i := getInitContainerIndex(pod)
|
||||
return i >= 0 &&
|
||||
len(pod.Status.InitContainerStatuses)-1 >= i &&
|
||||
pod.Status.InitContainerStatuses[i].State.Running != nil
|
||||
}
|
||||
|
||||
func getInitContainerIndex(pod *corev1api.Pod) int {
|
||||
// Pod volume wait container can be anywhere in the list of init containers so locate it.
|
||||
for i, initContainer := range pod.Spec.InitContainers {
|
||||
@@ -1113,7 +1146,7 @@ func (r *PodVolumeRestoreReconciler) resumeCancellableDataPath(ctx context.Conte
|
||||
|
||||
if err := asyncBR.StartRestore(pvr.Spec.SnapshotID, datapath.AccessPoint{
|
||||
ByPath: res.ByPod.VolumeName,
|
||||
}, pvr.Spec.UploaderSettings); err != nil {
|
||||
}, pvr.Spec.UploaderSettings, nil); err != nil {
|
||||
return errors.Wrapf(err, "error to resume asyncBR watcher for PVR %s", pvr.Name)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,8 @@ func TestShouldProcess(t *testing.T) {
|
||||
obj *velerov1api.PodVolumeRestore
|
||||
pod *corev1api.Pod
|
||||
shouldProcessed bool
|
||||
expectError bool
|
||||
errString string
|
||||
}{
|
||||
{
|
||||
name: "InProgress phase pvr should not be processed",
|
||||
@@ -115,6 +117,8 @@ func TestShouldProcess(t *testing.T) {
|
||||
},
|
||||
},
|
||||
shouldProcessed: false,
|
||||
expectError: true,
|
||||
errString: "timeout to wait for pod ns-1/pod-1",
|
||||
},
|
||||
{
|
||||
name: "Empty phase pvr with pod on node not running init container should not be processed",
|
||||
@@ -200,6 +204,268 @@ func TestShouldProcess(t *testing.T) {
|
||||
},
|
||||
shouldProcessed: true,
|
||||
},
|
||||
{
|
||||
name: "pod is in failed phase should return error",
|
||||
obj: &velerov1api.PodVolumeRestore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "velero",
|
||||
Name: "pvr-1",
|
||||
},
|
||||
Spec: velerov1api.PodVolumeRestoreSpec{
|
||||
Pod: corev1api.ObjectReference{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
},
|
||||
Status: velerov1api.PodVolumeRestoreStatus{
|
||||
Phase: "",
|
||||
},
|
||||
},
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
Phase: corev1api.PodFailed,
|
||||
},
|
||||
},
|
||||
shouldProcessed: false,
|
||||
expectError: true,
|
||||
errString: "unexpected state for pod",
|
||||
},
|
||||
{
|
||||
name: "pod is in unknown phase should return error",
|
||||
obj: &velerov1api.PodVolumeRestore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "velero",
|
||||
Name: "pvr-1",
|
||||
},
|
||||
Spec: velerov1api.PodVolumeRestoreSpec{
|
||||
Pod: corev1api.ObjectReference{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
},
|
||||
Status: velerov1api.PodVolumeRestoreStatus{
|
||||
Phase: "",
|
||||
},
|
||||
},
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
Phase: corev1api.PodUnknown,
|
||||
},
|
||||
},
|
||||
shouldProcessed: false,
|
||||
expectError: true,
|
||||
errString: "unexpected state for pod",
|
||||
},
|
||||
{
|
||||
name: "pod with no init containers should return error",
|
||||
obj: &velerov1api.PodVolumeRestore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "velero",
|
||||
Name: "pvr-1",
|
||||
},
|
||||
Spec: velerov1api.PodVolumeRestoreSpec{
|
||||
Pod: corev1api.ObjectReference{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
},
|
||||
Status: velerov1api.PodVolumeRestoreStatus{
|
||||
Phase: "",
|
||||
},
|
||||
},
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
NodeName: controllerNode,
|
||||
},
|
||||
},
|
||||
shouldProcessed: false,
|
||||
expectError: true,
|
||||
errString: "no restore-wait init container",
|
||||
},
|
||||
{
|
||||
name: "pod init container statuses are not fully populated yet should skip",
|
||||
obj: &velerov1api.PodVolumeRestore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "velero",
|
||||
Name: "pvr-1",
|
||||
},
|
||||
Spec: velerov1api.PodVolumeRestoreSpec{
|
||||
Pod: corev1api.ObjectReference{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
},
|
||||
Status: velerov1api.PodVolumeRestoreStatus{
|
||||
Phase: "",
|
||||
},
|
||||
},
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
NodeName: controllerNode,
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: restorehelper.WaitInitContainer,
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{},
|
||||
},
|
||||
},
|
||||
shouldProcessed: false,
|
||||
},
|
||||
{
|
||||
name: "restore-wait init container has already completed should return error",
|
||||
obj: &velerov1api.PodVolumeRestore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "velero",
|
||||
Name: "pvr-1",
|
||||
},
|
||||
Spec: velerov1api.PodVolumeRestoreSpec{
|
||||
Pod: corev1api.ObjectReference{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
},
|
||||
Status: velerov1api.PodVolumeRestoreStatus{
|
||||
Phase: "",
|
||||
},
|
||||
},
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
NodeName: controllerNode,
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: restorehelper.WaitInitContainer,
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Terminated: &corev1api.ContainerStateTerminated{
|
||||
ExitCode: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldProcessed: false,
|
||||
expectError: true,
|
||||
errString: "restore-wait init container has already completed",
|
||||
},
|
||||
{
|
||||
name: "restore-wait init container is in unrecoverable waiting state should return error",
|
||||
obj: &velerov1api.PodVolumeRestore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "velero",
|
||||
Name: "pvr-1",
|
||||
},
|
||||
Spec: velerov1api.PodVolumeRestoreSpec{
|
||||
Pod: corev1api.ObjectReference{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
},
|
||||
Status: velerov1api.PodVolumeRestoreStatus{
|
||||
Phase: "",
|
||||
},
|
||||
},
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
NodeName: controllerNode,
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: restorehelper.WaitInitContainer,
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Waiting: &corev1api.ContainerStateWaiting{
|
||||
Reason: "ImagePullBackOff",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldProcessed: false,
|
||||
expectError: true,
|
||||
errString: "is in unrecoverable waiting state with reason ImagePullBackOff",
|
||||
},
|
||||
{
|
||||
name: "restore-wait init container is in normal waiting state should skip",
|
||||
obj: &velerov1api.PodVolumeRestore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "velero",
|
||||
Name: "pvr-1",
|
||||
},
|
||||
Spec: velerov1api.PodVolumeRestoreSpec{
|
||||
Pod: corev1api.ObjectReference{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
},
|
||||
Status: velerov1api.PodVolumeRestoreStatus{
|
||||
Phase: "",
|
||||
},
|
||||
},
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
NodeName: controllerNode,
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: restorehelper.WaitInitContainer,
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Waiting: &corev1api.ContainerStateWaiting{
|
||||
Reason: "ContainerCreating",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldProcessed: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, ts := range tests {
|
||||
@@ -221,179 +487,16 @@ func TestShouldProcess(t *testing.T) {
|
||||
clock: &clocks.RealClock{},
|
||||
}
|
||||
|
||||
shouldProcess, _, _ := shouldProcess(ctx, c.client, c.logger, ts.obj)
|
||||
shouldProcess, _, err := shouldProcess(ctx, c.client, c.logger, ts.obj, time.Second)
|
||||
require.Equal(t, ts.shouldProcessed, shouldProcess)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInitContainerRunning(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pod *corev1api.Pod
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "pod with no init containers should return false",
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "pod with running init container that's not restore init should return false",
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: "non-restore-init",
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Running: &corev1api.ContainerStateRunning{StartedAt: metav1.Time{Time: time.Now()}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "pod with running init container that's not first should still work",
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: "non-restore-init",
|
||||
},
|
||||
{
|
||||
Name: restorehelper.WaitInitContainer,
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Running: &corev1api.ContainerStateRunning{StartedAt: metav1.Time{Time: time.Now()}},
|
||||
},
|
||||
},
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Running: &corev1api.ContainerStateRunning{StartedAt: metav1.Time{Time: time.Now()}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "pod with init container as first initContainer that's not running should return false",
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: restorehelper.WaitInitContainer,
|
||||
},
|
||||
{
|
||||
Name: "non-restore-init",
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{
|
||||
{
|
||||
State: corev1api.ContainerState{},
|
||||
},
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Running: &corev1api.ContainerStateRunning{StartedAt: metav1.Time{Time: time.Now()}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "pod with running init container as first initContainer should return true",
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: restorehelper.WaitInitContainer,
|
||||
},
|
||||
{
|
||||
Name: "non-restore-init",
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Running: &corev1api.ContainerStateRunning{StartedAt: metav1.Time{Time: time.Now()}},
|
||||
},
|
||||
},
|
||||
{
|
||||
State: corev1api.ContainerState{
|
||||
Running: &corev1api.ContainerStateRunning{StartedAt: metav1.Time{Time: time.Now()}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "pod with init container with empty InitContainerStatuses should return 0",
|
||||
pod: &corev1api.Pod{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "ns-1",
|
||||
Name: "pod-1",
|
||||
},
|
||||
Spec: corev1api.PodSpec{
|
||||
InitContainers: []corev1api.Container{
|
||||
{
|
||||
Name: restorehelper.WaitInitContainer,
|
||||
},
|
||||
},
|
||||
},
|
||||
Status: corev1api.PodStatus{
|
||||
InitContainerStatuses: []corev1api.ContainerStatus{},
|
||||
},
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
assert.Equal(t, test.expected, isInitContainerRunning(test.pod))
|
||||
if ts.expectError {
|
||||
require.Error(t, err)
|
||||
if ts.errString != "" {
|
||||
assert.Contains(t, err.Error(), ts.errString)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -996,7 +1099,7 @@ func TestPodVolumeRestoreReconcile(t *testing.T) {
|
||||
}
|
||||
|
||||
if test.mockStart {
|
||||
asyncBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything).Return(test.mockStartErr)
|
||||
asyncBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(test.mockStartErr)
|
||||
}
|
||||
|
||||
if test.mockCancel {
|
||||
@@ -1798,7 +1901,7 @@ func TestResumeCancellablePodVolumeRestore(t *testing.T) {
|
||||
}
|
||||
|
||||
if test.mockStart {
|
||||
mockAsyncBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything).Return(test.startWatcherErr)
|
||||
mockAsyncBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(test.startWatcherErr)
|
||||
}
|
||||
|
||||
if test.mockClose {
|
||||
|
||||
@@ -204,7 +204,7 @@ func (r *BackupMicroService) RunCancelableDataPath(ctx context.Context) (string,
|
||||
|
||||
if err := dp.StartBackup(r.sourceTargetPath, du.Spec.DataMoverConfig, &datapath.BackupStartParam{
|
||||
RealSource: GetRealSource(du.Spec.SourceNamespace, du.Spec.SourcePVC),
|
||||
ParentSnapshot: "",
|
||||
ParentSnapshot: du.Spec.ParentSnapshot,
|
||||
ForceFull: false,
|
||||
Tags: tags,
|
||||
VolumeID: r.volumeID,
|
||||
|
||||
@@ -180,7 +180,7 @@ func (r *RestoreMicroService) RunCancelableDataPath(ctx context.Context) (string
|
||||
}
|
||||
log.Info("fs init")
|
||||
|
||||
if err := dp.StartRestore(dd.Spec.SnapshotID, r.sourceTargetPath, dd.Spec.DataMoverConfig); err != nil {
|
||||
if err := dp.StartRestore(dd.Spec.SnapshotID, r.sourceTargetPath, dd.Spec.DataMoverConfig, &datapath.RestoreStartParam{}); err != nil {
|
||||
return "", errors.Wrap(err, "error starting data path restore")
|
||||
}
|
||||
|
||||
|
||||
@@ -355,12 +355,12 @@ func TestRunCancelableRestore(t *testing.T) {
|
||||
|
||||
if test.startErr != nil {
|
||||
fsBR.On("Init", mock.Anything, mock.Anything).Return(nil)
|
||||
fsBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything).Return(test.startErr)
|
||||
fsBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(test.startErr)
|
||||
}
|
||||
|
||||
if test.dataPathStarted {
|
||||
fsBR.On("Init", mock.Anything, mock.Anything).Return(nil)
|
||||
fsBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
fsBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
}
|
||||
|
||||
return fsBR
|
||||
|
||||
@@ -19,12 +19,15 @@ package datamover
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/vmware-tanzu/velero/pkg/uploader"
|
||||
datamoverutil "github.com/vmware-tanzu/velero/pkg/util/datamover"
|
||||
)
|
||||
|
||||
func GetUploaderType(dataMover string) string {
|
||||
if datamoverutil.IsBuiltInDataMover(dataMover) {
|
||||
return "kopia"
|
||||
if datamoverutil.IsVeleroFSDataMover(dataMover) {
|
||||
return uploader.KopiaType
|
||||
} else if datamoverutil.IsVeleroBlockDataMover(dataMover) {
|
||||
return uploader.BlockType
|
||||
} else {
|
||||
return dataMover
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@ func TestGetUploaderType(t *testing.T) {
|
||||
input: "velero",
|
||||
want: "kopia",
|
||||
},
|
||||
{
|
||||
name: "velero-fs dataMover is kopia",
|
||||
input: "velero-fs",
|
||||
want: "kopia",
|
||||
},
|
||||
{
|
||||
name: "velero-block dataMover is velero-block",
|
||||
input: "velero-block",
|
||||
want: "velero-block",
|
||||
},
|
||||
{
|
||||
name: "kopia dataMover is kopia",
|
||||
input: "kopia",
|
||||
|
||||
@@ -59,6 +59,10 @@ type BackupStartParam struct {
|
||||
SnapshotID string
|
||||
}
|
||||
|
||||
// RestoreStartParam define the input param for restore start
|
||||
type RestoreStartParam struct {
|
||||
}
|
||||
|
||||
type generalDataPath struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -221,7 +225,7 @@ func (dp *generalDataPath) StartBackup(source AccessPoint, uploaderConfig map[st
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dp *generalDataPath) StartRestore(snapshotID string, target AccessPoint, uploaderConfigs map[string]string) error {
|
||||
func (dp *generalDataPath) StartRestore(snapshotID string, target AccessPoint, uploaderConfigs map[string]string, param any) error {
|
||||
if !dp.initialized {
|
||||
return errors.New("data path is not initialized")
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestAsyncRestore(t *testing.T) {
|
||||
dp.initialized = true
|
||||
dp.callbacks = test.callbacks
|
||||
|
||||
err := dp.StartRestore(test.snapshot, AccessPoint{ByPath: test.path}, map[string]string{})
|
||||
err := dp.StartRestore(test.snapshot, AccessPoint{ByPath: test.path}, map[string]string{}, &RestoreStartParam{})
|
||||
require.NoError(t, err)
|
||||
|
||||
<-finish
|
||||
|
||||
@@ -221,7 +221,7 @@ func (ms *microServiceBRWatcher) StartBackup(source AccessPoint, uploaderConfig
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ms *microServiceBRWatcher) StartRestore(snapshotID string, target AccessPoint, uploaderConfigs map[string]string) error {
|
||||
func (ms *microServiceBRWatcher) StartRestore(snapshotID string, target AccessPoint, uploaderConfigs map[string]string, param any) error {
|
||||
ms.log.Infof("Start watching restore ms to target %s, from snapshot %s", target.ByPath, snapshotID)
|
||||
|
||||
ms.startWatch()
|
||||
|
||||
@@ -60,17 +60,17 @@ func (_m *AsyncBR) StartBackup(source datapath.AccessPoint, dataMoverConfig map[
|
||||
return r0
|
||||
}
|
||||
|
||||
// StartRestore provides a mock function with given fields: snapshotID, target, dataMoverConfig
|
||||
func (_m *AsyncBR) StartRestore(snapshotID string, target datapath.AccessPoint, dataMoverConfig map[string]string) error {
|
||||
ret := _m.Called(snapshotID, target, dataMoverConfig)
|
||||
// StartRestore provides a mock function with given fields: snapshotID, target, dataMoverConfig, param
|
||||
func (_m *AsyncBR) StartRestore(snapshotID string, target datapath.AccessPoint, dataMoverConfig map[string]string, param interface{}) error {
|
||||
ret := _m.Called(snapshotID, target, dataMoverConfig, param)
|
||||
|
||||
if len(ret) == 0 {
|
||||
panic("no return value specified for StartRestore")
|
||||
}
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(string, datapath.AccessPoint, map[string]string) error); ok {
|
||||
r0 = rf(snapshotID, target, dataMoverConfig)
|
||||
if rf, ok := ret.Get(0).(func(string, datapath.AccessPoint, map[string]string, interface{}) error); ok {
|
||||
r0 = rf(snapshotID, target, dataMoverConfig, param)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ type AsyncBR interface {
|
||||
StartBackup(source AccessPoint, dataMoverConfig map[string]string, param any) error
|
||||
|
||||
// StartRestore starts an asynchronous data path instance for restore
|
||||
StartRestore(snapshotID string, target AccessPoint, dataMoverConfig map[string]string) error
|
||||
StartRestore(snapshotID string, target AccessPoint, dataMoverConfig map[string]string, param any) error
|
||||
|
||||
// Cancel cancels an asynchronous data path instance
|
||||
Cancel()
|
||||
|
||||
@@ -139,7 +139,10 @@ func WithPodVolumeOperationTimeout(val time.Duration) podTemplateOption {
|
||||
|
||||
func WithPlugins(plugins []string) podTemplateOption {
|
||||
return func(c *podTemplateConfig) {
|
||||
c.plugins = plugins
|
||||
c.plugins = make([]string, 0, len(plugins))
|
||||
for _, plugin := range plugins {
|
||||
c.plugins = append(c.plugins, strings.TrimSpace(plugin))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,15 @@ func TestDeployment(t *testing.T) {
|
||||
assert.Len(t, deploy.Spec.Template.Spec.Containers[0].Args, 2)
|
||||
assert.Equal(t, "--features=EnableCSI,foo,bar,baz", deploy.Spec.Template.Spec.Containers[0].Args[1])
|
||||
|
||||
deploy = Deployment("velero", WithPlugins([]string{
|
||||
"harbor-repo.vmware.com/harbor-ci/velero/velero-plugin-for-aws:v1.2.0",
|
||||
" \n vsphereveleroplugin/velero-plugin-for-vsphere:v1.1.1 ",
|
||||
}))
|
||||
assert.Len(t, deploy.Spec.Template.Spec.InitContainers, 2)
|
||||
assert.Equal(t, "harbor-repo.vmware.com/harbor-ci/velero/velero-plugin-for-aws:v1.2.0", deploy.Spec.Template.Spec.InitContainers[0].Image)
|
||||
assert.Equal(t, "vsphereveleroplugin/velero-plugin-for-vsphere:v1.1.1", deploy.Spec.Template.Spec.InitContainers[1].Image)
|
||||
assert.Equal(t, "vsphereveleroplugin-velero-plugin-for-vsphere", deploy.Spec.Template.Spec.InitContainers[1].Name)
|
||||
|
||||
deploy = Deployment("velero", WithUploaderType("kopia"))
|
||||
assert.Len(t, deploy.Spec.Template.Spec.Containers[0].Args, 2)
|
||||
assert.Equal(t, "--uploader-type=kopia", deploy.Spec.Template.Spec.Containers[0].Args[1])
|
||||
|
||||
@@ -758,6 +758,14 @@ func (m *ServerMetrics) RegisterPodVolumeOpLatencyGauge(node, pvbName, opName, b
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteBackupLastSuccessfulTimestamp removes the backupLastSuccessfulTimestamp
|
||||
// metric for a single schedule.
|
||||
func (m *ServerMetrics) DeleteBackupLastSuccessfulTimestamp(scheduleName string) {
|
||||
if g, ok := m.metrics[backupLastSuccessfulTimestamp].(*prometheus.GaugeVec); ok {
|
||||
g.DeleteLabelValues(scheduleName)
|
||||
}
|
||||
}
|
||||
|
||||
// SetBackupTarballSizeBytesGauge records the size, in bytes, of a backup tarball.
|
||||
func (m *ServerMetrics) SetBackupTarballSizeBytesGauge(backupSchedule string, size int64) {
|
||||
if g, ok := m.metrics[backupTarballSizeBytesGauge].(*prometheus.GaugeVec); ok {
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -457,6 +458,32 @@ func getHistogramCount(t *testing.T, vec *prometheus.HistogramVec, scheduleLabel
|
||||
return 0
|
||||
}
|
||||
|
||||
// TestDeleteBackupLastSuccessfulTimestamp verifies that DeleteBackupLastSuccessfulTimestamp
|
||||
// removes only the specified schedule's metric.
|
||||
func TestDeleteBackupLastSuccessfulTimestamp(t *testing.T) {
|
||||
m := NewServerMetrics()
|
||||
|
||||
now := time.Now()
|
||||
m.SetBackupLastSuccessfulTimestamp("schedule-1", now)
|
||||
m.SetBackupLastSuccessfulTimestamp("schedule-2", now.Add(-time.Hour))
|
||||
m.SetBackupLastSuccessfulTimestamp("", now.Add(-2*time.Hour))
|
||||
|
||||
g := m.metrics[backupLastSuccessfulTimestamp].(*prometheus.GaugeVec)
|
||||
assert.Equal(t, 3, testutil.CollectAndCount(g))
|
||||
|
||||
m.DeleteBackupLastSuccessfulTimestamp("schedule-1")
|
||||
assert.Equal(t, 2, testutil.CollectAndCount(g))
|
||||
assert.Equal(t, float64(now.Add(-time.Hour).Unix()), testutil.ToFloat64(g.WithLabelValues("schedule-2")))
|
||||
assert.Equal(t, float64(now.Add(-2*time.Hour).Unix()), testutil.ToFloat64(g.WithLabelValues("")))
|
||||
|
||||
m.DeleteBackupLastSuccessfulTimestamp("schedule-2")
|
||||
assert.Equal(t, 1, testutil.CollectAndCount(g))
|
||||
assert.Equal(t, float64(now.Add(-2*time.Hour).Unix()), testutil.ToFloat64(g.WithLabelValues("")))
|
||||
|
||||
m.DeleteBackupLastSuccessfulTimestamp("")
|
||||
assert.Equal(t, 0, testutil.CollectAndCount(g))
|
||||
}
|
||||
|
||||
// TestRepoMaintenanceMetrics verifies that repo maintenance metrics are properly recorded.
|
||||
func TestRepoMaintenanceMetrics(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -20,12 +20,15 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/sirupsen/logrus"
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/util/sets"
|
||||
"k8s.io/apimachinery/pkg/util/wait"
|
||||
"k8s.io/client-go/tools/cache"
|
||||
ctrlcache "sigs.k8s.io/controller-runtime/pkg/cache"
|
||||
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||
@@ -181,7 +184,7 @@ func newBackupper(
|
||||
// the PVB in the indexer is already in final status, no need to call WaitGroup.Done()
|
||||
if ok && (existPVB.Status.Phase == velerov1api.PodVolumeBackupPhaseCompleted ||
|
||||
existPVB.Status.Phase == velerov1api.PodVolumeBackupPhaseFailed ||
|
||||
pvb.Status.Phase == velerov1api.PodVolumeBackupPhaseCanceled) {
|
||||
existPVB.Status.Phase == velerov1api.PodVolumeBackupPhaseCanceled) {
|
||||
statusChangedToFinal = false
|
||||
}
|
||||
}
|
||||
@@ -411,6 +414,24 @@ func (b *backupper) WaitAllPodVolumesProcessed(log logrus.FieldLogger) []*velero
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
log.Error("timed out waiting for all PodVolumeBackups to complete")
|
||||
|
||||
for _, obj := range b.pvbIndexer.List() {
|
||||
pvb, ok := obj.(*velerov1api.PodVolumeBackup)
|
||||
if !ok {
|
||||
log.Errorf("expected PVB, but got %T", obj)
|
||||
continue
|
||||
}
|
||||
|
||||
if pvb.Status.Phase != velerov1api.PodVolumeBackupPhaseCompleted &&
|
||||
pvb.Status.Phase != velerov1api.PodVolumeBackupPhaseFailed &&
|
||||
pvb.Status.Phase != velerov1api.PodVolumeBackupPhaseCanceled {
|
||||
log.Infof("Setting cancel flag for ongoing PVB %s/%s", pvb.Namespace, pvb.Name)
|
||||
if err := updatePVBWithRetry(context.Background(), b.crClient, pvb.Namespace, pvb.Name); err != nil {
|
||||
log.WithError(err).Errorf("Failed to set cancel flag for PVB %s/%s", pvb.Namespace, pvb.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
<-done
|
||||
case <-done:
|
||||
}
|
||||
|
||||
@@ -432,6 +453,32 @@ func (b *backupper) WaitAllPodVolumesProcessed(log logrus.FieldLogger) []*velero
|
||||
return podVolumeBackups
|
||||
}
|
||||
|
||||
func updatePVBWithRetry(ctx context.Context, client ctrlclient.Client, namespace, name string) error {
|
||||
return wait.PollUntilContextCancel(ctx, 100*time.Millisecond, true, func(ctx context.Context) (bool, error) {
|
||||
pvb := &velerov1api.PodVolumeBackup{}
|
||||
if err := client.Get(ctx, ctrlclient.ObjectKey{Namespace: namespace, Name: name}, pvb); err != nil {
|
||||
return false, errors.Wrap(err, "getting PVB")
|
||||
}
|
||||
|
||||
if pvb.Spec.Cancel {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
pvb.Spec.Cancel = true
|
||||
pvb.Status.Message = "Cancel PVB on pod volume timeout"
|
||||
|
||||
err := client.Update(ctx, pvb)
|
||||
if err != nil {
|
||||
if apierrors.IsConflict(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, errors.Wrapf(err, "error updating PVB %s/%s", pvb.Namespace, pvb.Name)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *backupper) GetPodVolumeBackupByPodAndVolume(podNamespace, podName, volume string) (*velerov1api.PodVolumeBackup, error) {
|
||||
obj, exist, err := b.pvbIndexer.GetByKey(fmt.Sprintf(pvbKeyPattern, podNamespace, podName, volume))
|
||||
if err != nil {
|
||||
|
||||
@@ -733,14 +733,14 @@ func TestListPodVolumeBackupsByPodp(t *testing.T) {
|
||||
}
|
||||
|
||||
type logHook struct {
|
||||
entry *logrus.Entry
|
||||
entries []*logrus.Entry
|
||||
}
|
||||
|
||||
func (l *logHook) Levels() []logrus.Level {
|
||||
return []logrus.Level{logrus.ErrorLevel}
|
||||
}
|
||||
func (l *logHook) Fire(entry *logrus.Entry) error {
|
||||
l.entry = entry
|
||||
l.entries = append(l.entries, entry)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -808,12 +808,35 @@ func TestWaitAllPodVolumesProcessed(t *testing.T) {
|
||||
logHook := &logHook{}
|
||||
logger.Hooks.Add(logHook)
|
||||
|
||||
backuper := newBackupper(c.ctx, log, nil, nil, informer, nil, "", &velerov1api.Backup{})
|
||||
backuper := newBackupper(c.ctx, log, nil, nil, informer, client, "", &velerov1api.Backup{})
|
||||
if c.pvb != nil {
|
||||
require.NoError(t, backuper.pvbIndexer.Add(c.pvb))
|
||||
backuper.wg.Add(1)
|
||||
}
|
||||
|
||||
if c.ctx == timeoutCtx && c.pvb != nil {
|
||||
// Start a goroutine to simulate the controller's cancellation behavior
|
||||
go func() {
|
||||
// Wait a short time for the cancel flag to be set
|
||||
ticker := time.NewTicker(10 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
pvb := &velerov1api.PodVolumeBackup{}
|
||||
err := client.Get(t.Context(), ctrlclient.ObjectKey{Namespace: c.pvb.Namespace, Name: c.pvb.Name}, pvb)
|
||||
if err == nil && pvb.Spec.Cancel {
|
||||
oldPVB := pvb.DeepCopy()
|
||||
pvb.Status.Phase = velerov1api.PodVolumeBackupPhaseCanceled
|
||||
pvb.Status.Message = "canceled"
|
||||
_ = client.Update(t.Context(), pvb)
|
||||
if informer.handler != nil {
|
||||
informer.handler.OnUpdate(oldPVB, pvb)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if c.statusToBeUpdated != nil {
|
||||
pvb := &velerov1api.PodVolumeBackup{}
|
||||
err := client.Get(t.Context(), ctrlclient.ObjectKey{Namespace: c.pvb.Namespace, Name: c.pvb.Name}, pvb)
|
||||
@@ -831,9 +854,18 @@ func TestWaitAllPodVolumesProcessed(t *testing.T) {
|
||||
pvbs := backuper.WaitAllPodVolumesProcessed(logger)
|
||||
|
||||
if c.expectedErr != "" {
|
||||
assert.Equal(t, c.expectedErr, logHook.entry.Message)
|
||||
found := false
|
||||
var loggedMsgs []string
|
||||
for _, entry := range logHook.entries {
|
||||
loggedMsgs = append(loggedMsgs, entry.Message)
|
||||
if entry.Message == c.expectedErr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "Expected error %q to be logged, but got %v", c.expectedErr, loggedMsgs)
|
||||
} else {
|
||||
assert.Nil(t, logHook.entry)
|
||||
assert.Empty(t, logHook.entries)
|
||||
}
|
||||
|
||||
if c.expectedPVBCount > 0 {
|
||||
|
||||
@@ -184,7 +184,7 @@ func (r *RestoreMicroService) RunCancelableDataPath(ctx context.Context) (string
|
||||
|
||||
log.Info("Async fs br init")
|
||||
|
||||
if err := fsRestore.StartRestore(pvr.Spec.SnapshotID, r.sourceTargetPath, pvr.Spec.UploaderSettings); err != nil {
|
||||
if err := fsRestore.StartRestore(pvr.Spec.SnapshotID, r.sourceTargetPath, pvr.Spec.UploaderSettings, &datapath.RestoreStartParam{}); err != nil {
|
||||
return "", errors.Wrap(err, "error starting data path restore")
|
||||
}
|
||||
|
||||
|
||||
@@ -436,12 +436,12 @@ func TestRunCancelableDataPathRestore(t *testing.T) {
|
||||
|
||||
if test.startErr != nil {
|
||||
fsBR.On("Init", mock.Anything, mock.Anything).Return(nil)
|
||||
fsBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything).Return(test.startErr)
|
||||
fsBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(test.startErr)
|
||||
}
|
||||
|
||||
if test.dataPathStarted {
|
||||
fsBR.On("Init", mock.Anything, mock.Anything).Return(nil)
|
||||
fsBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
fsBR.On("StartRestore", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
}
|
||||
|
||||
return fsBR
|
||||
|
||||
@@ -18,6 +18,7 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/fips140"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -173,7 +174,13 @@ func (m *manager) PrepareRepo(repo *velerov1api.BackupRepository) error {
|
||||
if err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
return prd.PrepareRepo(context.Background(), param)
|
||||
|
||||
// Disable FIPS-140 compliance check, because Kopia doesn't support FIPS-140 yet.
|
||||
var prepareErr error
|
||||
fips140.WithoutEnforcement(func() {
|
||||
prepareErr = prd.PrepareRepo(context.Background(), param)
|
||||
})
|
||||
return prepareErr
|
||||
}
|
||||
|
||||
func (m *manager) PruneRepo(repo *velerov1api.BackupRepository) error {
|
||||
@@ -244,11 +251,20 @@ func (m *manager) BatchForget(ctx context.Context, repo *velerov1api.BackupRepos
|
||||
return []error{errors.WithStack(err)}
|
||||
}
|
||||
|
||||
if err := prd.BoostRepoConnect(context.Background(), param); err != nil {
|
||||
// Disable FIPS-140 compliance check, because Kopia doesn't support FIPS-140 yet.
|
||||
var connectErr error
|
||||
fips140.WithoutEnforcement(func() {
|
||||
connectErr = prd.BoostRepoConnect(context.Background(), param)
|
||||
})
|
||||
if connectErr != nil {
|
||||
return []error{errors.WithStack(err)}
|
||||
}
|
||||
|
||||
return prd.BatchForget(context.Background(), snapshots, param)
|
||||
forgetErr := make([]error, 0)
|
||||
fips140.WithoutEnforcement(func() {
|
||||
forgetErr = prd.BatchForget(context.Background(), snapshots, param)
|
||||
})
|
||||
return forgetErr
|
||||
}
|
||||
|
||||
func (m *manager) DefaultMaintenanceFrequency(repo *velerov1api.BackupRepository) (time.Duration, error) {
|
||||
|
||||
@@ -1208,6 +1208,10 @@ func TestKopiaObjectWriterEx_MixedWriteAndWriteAt(t *testing.T) {
|
||||
assert.Equal(t, int64(3072), kow.entries[3].Start)
|
||||
}
|
||||
|
||||
// TestKopiaObjectWriterEx_ConcurrentAsyncErrors verifies the async error contract
|
||||
// under real scheduling: once an async block write fails, the error either fails a
|
||||
// subsequent Write call fast or surfaces at Result — it is never lost. Which of the
|
||||
// two happens first depends on goroutine scheduling, and both are correct.
|
||||
func TestKopiaObjectWriterEx_ConcurrentAsyncErrors(t *testing.T) {
|
||||
mockRepoWriter := repomocks.NewMockRepositoryWriter(t)
|
||||
mockWriter := repomocks.NewWriter(t)
|
||||
@@ -1231,14 +1235,65 @@ func TestKopiaObjectWriterEx_ConcurrentAsyncErrors(t *testing.T) {
|
||||
|
||||
data := make([]byte, 1024)
|
||||
|
||||
// Issue multiple writes so they all spawn async goroutines
|
||||
// First few writes shouldn't fail immediately until getWriteError catches the asynchronous fault
|
||||
// Issue multiple writes so they all spawn async goroutines. A later Write may
|
||||
// observe the stored async error and fail fast — that is correct behavior.
|
||||
for i := 0; i < 10; i++ {
|
||||
l, err := kow.Write(data)
|
||||
if err != nil {
|
||||
assert.Contains(t, err.Error(), "simulated async error")
|
||||
break
|
||||
}
|
||||
assert.Equal(t, 1024, l)
|
||||
}
|
||||
|
||||
// Regardless of whether a Write observed the error first, Result must report it.
|
||||
id, err := kow.Result()
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "simulated async error")
|
||||
assert.Equal(t, udmrepo.ID(""), id)
|
||||
}
|
||||
|
||||
// TestKopiaObjectWriterEx_AsyncErrorSurfacesAtResult pins the late-error schedule:
|
||||
// async writes are held until all writes have been queued, so no Write call observes
|
||||
// the failure and Result alone must report it.
|
||||
func TestKopiaObjectWriterEx_AsyncErrorSurfacesAtResult(t *testing.T) {
|
||||
mockRepoWriter := repomocks.NewMockRepositoryWriter(t)
|
||||
mockWriter := repomocks.NewWriter(t)
|
||||
|
||||
releaseWrites := make(chan struct{})
|
||||
mockWriter.On("Write", mock.Anything).Run(func(mock.Arguments) {
|
||||
<-releaseWrites
|
||||
}).Return(0, errors.New("simulated async error"))
|
||||
mockWriter.On("Close").Return(nil)
|
||||
|
||||
mockRepoWriter.On("NewObjectWriter", mock.Anything, mock.Anything).Return(mockWriter)
|
||||
|
||||
sem := make(chan struct{}, 10)
|
||||
buf := freelist.New(10*1024, 1024)
|
||||
|
||||
kow := &kopiaObjectWriterEx{
|
||||
ctx: context.Background(),
|
||||
rawRepoWriter: mockRepoWriter,
|
||||
blockSize: 1024,
|
||||
asyncWritesSem: sem,
|
||||
asyncBuffer: buf,
|
||||
logger: velerotest.NewLogger(),
|
||||
}
|
||||
|
||||
data := make([]byte, 1024)
|
||||
|
||||
// All async writes block on releaseWrites, so no error can be stored yet and
|
||||
// every Write must succeed.
|
||||
for i := 0; i < 10; i++ {
|
||||
l, err := kow.Write(data)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1024, l)
|
||||
}
|
||||
|
||||
close(releaseWrites)
|
||||
|
||||
// Result waits for the async writers to finish and must report their error.
|
||||
id, err := kow.Result()
|
||||
|
||||
require.Error(t, err)
|
||||
|
||||
+65
-38
@@ -478,7 +478,15 @@ func (ctx *restoreContext) getNamespaceFilter(namespace string) *resolvedNamespa
|
||||
return filter
|
||||
}
|
||||
|
||||
// 2. Walk patterns in definition order (first-match semantics)
|
||||
// 2. Check for exact match first (O(1) map lookup)
|
||||
// This ensures exact namespace matches take precedence over globs,
|
||||
// regardless of where they are listed in the configuration.
|
||||
if filter, ok := ctx.namespacedFilterMap[namespace]; ok {
|
||||
ctx.namespaceFilterCache[namespace] = filter
|
||||
return filter
|
||||
}
|
||||
|
||||
// 3. Walk patterns in definition order using pre-compiled globs
|
||||
// Note: namespaceFilterCache is mutated below without synchronization. This is safe
|
||||
// today because resource collection runs sequentially. If the restore loop is
|
||||
// parallelized in the future, these map writes will need a lock to prevent data races.
|
||||
@@ -489,14 +497,10 @@ func (ctx *restoreContext) getNamespaceFilter(namespace string) *resolvedNamespa
|
||||
ctx.namespaceFilterCache[namespace] = filter
|
||||
return filter
|
||||
}
|
||||
} else if p.pattern == namespace {
|
||||
filter := ctx.namespacedFilterMap[p.pattern]
|
||||
ctx.namespaceFilterCache[namespace] = filter
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Cache the miss so we don't re-evaluate failed matches
|
||||
// 4. Cache the miss so we don't re-evaluate failed matches
|
||||
ctx.namespaceFilterCache[namespace] = nil
|
||||
return nil
|
||||
}
|
||||
@@ -634,21 +638,19 @@ func resolveRestoreNamespacedFilterPolicies(
|
||||
func resolveResourceFilter(
|
||||
rf resourcepolicies.ResourceFilter,
|
||||
) (*resolvedResourceFilter, error) {
|
||||
var selector labels.Selector
|
||||
if len(rf.LabelSelector) > 0 {
|
||||
var err error
|
||||
selector, err = labels.ValidatedSelectorFromSet(labels.Set(rf.LabelSelector))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid label selector in resource filter: %w", err)
|
||||
}
|
||||
selector, err := resourcepolicies.SelectorFromPolicyLabelSelector(rf.LabelSelector)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid label selector in resource filter: %w", err)
|
||||
}
|
||||
var orSelectors []labels.Selector
|
||||
for _, ols := range rf.OrLabelSelectors {
|
||||
s, err := labels.ValidatedSelectorFromSet(labels.Set(ols))
|
||||
s, err := resourcepolicies.SelectorFromPolicyLabelSelector(ols)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid OR label selector in resource filter: %w", err)
|
||||
}
|
||||
orSelectors = append(orSelectors, s)
|
||||
if s != nil {
|
||||
orSelectors = append(orSelectors, s)
|
||||
}
|
||||
}
|
||||
var nameIE *collections.IncludesExcludes
|
||||
if len(rf.Names) > 0 || len(rf.ExcludedNames) > 0 {
|
||||
@@ -1058,7 +1060,7 @@ func (ctx *restoreContext) processSelectedResource(
|
||||
continue
|
||||
}
|
||||
|
||||
w, e, _ := ctx.restoreItem(obj, groupResource, targetNS)
|
||||
w, e, _ := ctx.restoreItem(obj, groupResource, targetNS, false)
|
||||
warnings.Merge(&w)
|
||||
errs.Merge(&e)
|
||||
processedItems++
|
||||
@@ -1384,7 +1386,7 @@ func (ctx *restoreContext) getResource(groupResource schema.GroupResource, obj *
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupResource schema.GroupResource, namespace string) (results.Result, results.Result, bool) {
|
||||
func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupResource schema.GroupResource, namespace string, mustInclude bool) (results.Result, results.Result, bool) {
|
||||
warnings, errs := results.Result{}, results.Result{}
|
||||
// itemExists bool is used to determine whether to include this item in the "wait for additional items" list
|
||||
itemExists := false
|
||||
@@ -1401,27 +1403,41 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso
|
||||
// Check if group/resource should be restored. We need to do this here since
|
||||
// this method may be getting called for an additional item which is a group/resource
|
||||
// that's excluded.
|
||||
if !ctx.resourceIncludesExcludes.ShouldInclude(groupResource.String()) && !ctx.resourceMustHave.Has(groupResource.String()) {
|
||||
restoreLogger.Info("Not restoring item because resource is excluded")
|
||||
return warnings, errs, itemExists
|
||||
}
|
||||
|
||||
// Check if namespace/cluster-scoped resource should be restored. We need
|
||||
// to do this here since this method may be getting called for an additional
|
||||
// item which is in a namespace that's excluded, or which is cluster-scoped
|
||||
// and should be excluded. Note that we're checking the object's namespace (
|
||||
// via obj.GetNamespace()) instead of the namespace parameter, because we want
|
||||
// to check the *original* namespace, not the remapped one if it's been remapped.
|
||||
//
|
||||
// Note: Additional items intentionally bypass fine-grained resource filter policies
|
||||
// (like per-namespace label/name selectors) to avoid breaking semantic dependencies,
|
||||
// but they must still pass the global exclusions enforced below.
|
||||
if namespace != "" {
|
||||
if !ctx.namespaceIncludesExcludes.ShouldInclude(obj.GetNamespace()) && !ctx.resourceMustHave.Has(groupResource.String()) {
|
||||
restoreLogger.Info("Not restoring item because namespace is excluded")
|
||||
// but they must still pass the global exclusions enforced below unless mustInclude is set.
|
||||
if mustInclude {
|
||||
restoreLogger.Info("Skipping the resource/namespace exclusion checks because the item is marked as must-include")
|
||||
} else {
|
||||
if !ctx.resourceIncludesExcludes.ShouldInclude(groupResource.String()) && !ctx.resourceMustHave.Has(groupResource.String()) {
|
||||
restoreLogger.Info("Not restoring item because resource is excluded")
|
||||
return warnings, errs, itemExists
|
||||
}
|
||||
|
||||
// Check if namespace/cluster-scoped resource should be restored. We need
|
||||
// to do this here since this method may be getting called for an additional
|
||||
// item which is in a namespace that's excluded, or which is cluster-scoped
|
||||
// and should be excluded. Note that we're checking the object's namespace (
|
||||
// via obj.GetNamespace()) instead of the namespace parameter, because we want
|
||||
// to check the *original* namespace, not the remapped one if it's been remapped.
|
||||
if namespace != "" {
|
||||
if !ctx.namespaceIncludesExcludes.ShouldInclude(obj.GetNamespace()) && !ctx.resourceMustHave.Has(groupResource.String()) {
|
||||
restoreLogger.Info("Not restoring item because namespace is excluded")
|
||||
return warnings, errs, itemExists
|
||||
}
|
||||
} else {
|
||||
if boolptr.IsSetToFalse(ctx.restore.Spec.IncludeClusterResources) {
|
||||
restoreLogger.Info("Not restoring item because it's cluster-scoped")
|
||||
return warnings, errs, itemExists
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace creation runs unconditionally when namespace != "", regardless of
|
||||
// mustInclude. This ensures target namespaces exist for additional items that
|
||||
// bypass the namespace-exclusion check above.
|
||||
if namespace != "" {
|
||||
// If the namespace scoped resource should be restored, ensure that the
|
||||
// namespace into which the resource is being restored into exists.
|
||||
// This is the *remapped* namespace that we are ensuring exists.
|
||||
@@ -1440,11 +1456,6 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso
|
||||
}
|
||||
ctx.restoredItems[itemKey] = restoredItemStatus{action: ItemRestoreResultCreated, itemExists: true, createdName: nsToEnsure.Name}
|
||||
}
|
||||
} else {
|
||||
if boolptr.IsSetToFalse(ctx.restore.Spec.IncludeClusterResources) {
|
||||
restoreLogger.Info("Not restoring item because it's cluster-scoped")
|
||||
return warnings, errs, itemExists
|
||||
}
|
||||
}
|
||||
|
||||
// Make a copy of object retrieved from backup to make it available unchanged
|
||||
@@ -1666,6 +1677,21 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso
|
||||
|
||||
obj = unstructuredObj
|
||||
|
||||
mustIncludeAdditionalItems := false
|
||||
if annotations := obj.GetAnnotations(); annotations != nil {
|
||||
if _, present := annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation]; present {
|
||||
// Only the string value "true" enables the bypass.
|
||||
if annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] == "true" {
|
||||
mustIncludeAdditionalItems = true
|
||||
restoreLogger.Info("RestoreItemAction marked additional items as must-include; bypassing resource/namespace exclusion checks for them")
|
||||
}
|
||||
// Always strip the annotation so it never lands on the cluster,
|
||||
// regardless of whether the value enabled the bypass.
|
||||
delete(annotations, velerov1api.MustIncludeAdditionalItemRestoreAnnotation)
|
||||
obj.SetAnnotations(annotations)
|
||||
}
|
||||
}
|
||||
|
||||
var filteredAdditionalItems []velero.ResourceIdentifier
|
||||
for _, additionalItem := range executeOutput.AdditionalItems {
|
||||
itemPath := archive.GetItemFilePath(ctx.restoreDir, additionalItem.GroupResource.String(), additionalItem.Namespace, additionalItem.Name)
|
||||
@@ -1685,6 +1711,7 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso
|
||||
additionalObj, err := archive.Unmarshal(ctx.fileSystem, itemPath)
|
||||
if err != nil {
|
||||
errs.Add(namespace, errors.Wrapf(err, "error restoring additional item %s", additionalResourceID))
|
||||
continue
|
||||
}
|
||||
|
||||
additionalItemNamespace := additionalItem.Namespace
|
||||
@@ -1694,7 +1721,7 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso
|
||||
}
|
||||
}
|
||||
|
||||
w, e, additionalItemExists := ctx.restoreItem(additionalObj, additionalItem.GroupResource, additionalItemNamespace)
|
||||
w, e, additionalItemExists := ctx.restoreItem(additionalObj, additionalItem.GroupResource, additionalItemNamespace, mustIncludeAdditionalItems)
|
||||
if additionalItemExists {
|
||||
filteredAdditionalItems = append(filteredAdditionalItems, additionalItem)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ namespacedFilterPolicies:
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "namespaced filter policy with glob namespace match and first-match semantics",
|
||||
name: "namespaced filter policy with exact match priority over glob (glob listed first)",
|
||||
restore: defaultRestore().Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
policyYAML: `version: v1
|
||||
@@ -94,7 +94,43 @@ namespacedFilterPolicies:
|
||||
test.Pods(),
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1", "ns-2/pod-1"},
|
||||
test.Pods(): {"ns-1/pod-2", "ns-2/pod-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "namespaced filter policy with exact match priority over glob (exact listed first)",
|
||||
restore: defaultRestore().Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
policyYAML: `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-1
|
||||
resourceFilters:
|
||||
- kinds:
|
||||
- pods
|
||||
names:
|
||||
- pod-2
|
||||
- namespaces:
|
||||
- ns-*
|
||||
resourceFilters:
|
||||
- kinds:
|
||||
- pods
|
||||
names:
|
||||
- pod-1
|
||||
`,
|
||||
tarball: test.NewTarWriter(t).
|
||||
AddItems("pods",
|
||||
builder.ForPod("ns-1", "pod-1").Result(),
|
||||
builder.ForPod("ns-1", "pod-2").Result(),
|
||||
builder.ForPod("ns-2", "pod-1").Result(),
|
||||
builder.ForPod("ns-2", "pod-2").Result(),
|
||||
).
|
||||
Done(),
|
||||
apiResources: []*test.APIResource{
|
||||
test.Pods(),
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-2", "ns-2/pod-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -134,7 +170,8 @@ namespacedFilterPolicies:
|
||||
- kinds:
|
||||
- '*'
|
||||
labelSelector:
|
||||
app: test
|
||||
matchLabels:
|
||||
app: test
|
||||
`,
|
||||
tarball: test.NewTarWriter(t).
|
||||
AddItems("pods",
|
||||
|
||||
@@ -2150,6 +2150,102 @@ func TestRestoreActionAdditionalItems(t *testing.T) {
|
||||
test.PVs(): nil,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "must-include annotation bypasses resource exclusion for additional items",
|
||||
restore: defaultRestore().IncludedResources("pods").Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
tarball: test.NewTarWriter(t).
|
||||
AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()).
|
||||
AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()).
|
||||
Done(),
|
||||
apiResources: []*test.APIResource{test.Pods(), test.PVs()},
|
||||
actions: []riav2.RestoreItemAction{
|
||||
&pluggableAction{
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1"},
|
||||
test.PVs(): {"/pv-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "must-include annotation bypasses namespace exclusion for additional items",
|
||||
restore: defaultRestore().IncludedNamespaces("ns-1").Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
tarball: test.NewTarWriter(t).AddItems("pods", builder.ForPod("ns-1", "pod-1").Result(), builder.ForPod("ns-2", "pod-2").Result()).Done(),
|
||||
apiResources: []*test.APIResource{test.Pods()},
|
||||
actions: []riav2.RestoreItemAction{
|
||||
&pluggableAction{
|
||||
selector: velero.ResourceSelector{IncludedNamespaces: []string{"ns-1"}},
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.Pods, Namespace: "ns-2", Name: "pod-2"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1", "ns-2/pod-2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "must-include annotation bypasses IncludeClusterResources=false for additional items",
|
||||
restore: defaultRestore().IncludeClusterResources(false).Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
tarball: test.NewTarWriter(t).
|
||||
AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()).
|
||||
AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()).
|
||||
Done(),
|
||||
apiResources: []*test.APIResource{test.Pods(), test.PVs()},
|
||||
actions: []riav2.RestoreItemAction{
|
||||
&pluggableAction{
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1"},
|
||||
test.PVs(): {"/pv-1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
@@ -2180,6 +2276,324 @@ func TestRestoreActionAdditionalItems(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRestoreMustIncludeAdditionalItems covers restore must-include edge cases beyond the
|
||||
// basic filter-bypass cases in TestRestoreActionAdditionalItems.
|
||||
func TestRestoreMustIncludeAdditionalItems(t *testing.T) {
|
||||
t.Run("must-include annotation is stripped from the restored item", func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.AddItems(t, test.Pods())
|
||||
|
||||
data := &Request{
|
||||
Log: h.log,
|
||||
Restore: defaultRestore().Result(),
|
||||
Backup: defaultBackup().Result(),
|
||||
BackupReader: test.NewTarWriter(t).
|
||||
AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()).
|
||||
Done(),
|
||||
}
|
||||
warnings, errs := h.restorer.Restore(
|
||||
data,
|
||||
[]riav2.RestoreItemAction{
|
||||
&pluggableAction{
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
annotations["keep-me"] = "yes"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{UpdatedItem: item}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
assertEmptyResults(t, warnings, errs)
|
||||
|
||||
got, err := h.DynamicClient.Resource(test.Pods().GVR()).Namespace("ns-1").Get(t.Context(), "pod-1", metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
annotations := got.GetAnnotations()
|
||||
assert.NotContains(t, annotations, velerov1api.MustIncludeAdditionalItemRestoreAnnotation)
|
||||
assert.Equal(t, "yes", annotations["keep-me"])
|
||||
})
|
||||
|
||||
t.Run("non-true must-include annotation is stripped without bypassing filters", func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.AddItems(t, test.Pods())
|
||||
h.AddItems(t, test.PVs())
|
||||
|
||||
data := &Request{
|
||||
Log: h.log,
|
||||
Restore: defaultRestore().IncludedResources("pods").Result(),
|
||||
Backup: defaultBackup().Result(),
|
||||
BackupReader: test.NewTarWriter(t).
|
||||
AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()).
|
||||
AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()).
|
||||
Done(),
|
||||
}
|
||||
warnings, errs := h.restorer.Restore(
|
||||
data,
|
||||
[]riav2.RestoreItemAction{
|
||||
&pluggableAction{
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "True"
|
||||
annotations["keep-me"] = "yes"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
assertEmptyResults(t, warnings, errs)
|
||||
assertAPIContents(t, h, map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1"},
|
||||
test.PVs(): nil,
|
||||
})
|
||||
|
||||
got, err := h.DynamicClient.Resource(test.Pods().GVR()).Namespace("ns-1").Get(t.Context(), "pod-1", metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
annotations := got.GetAnnotations()
|
||||
assert.NotContains(t, annotations, velerov1api.MustIncludeAdditionalItemRestoreAnnotation)
|
||||
assert.Equal(t, "yes", annotations["keep-me"])
|
||||
})
|
||||
|
||||
t.Run("SkipRestore supersedes must-include annotation and skips additional items", func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.AddItems(t, test.Pods())
|
||||
h.AddItems(t, test.PVs())
|
||||
|
||||
data := &Request{
|
||||
Log: h.log,
|
||||
Restore: defaultRestore().IncludedResources("pods").Result(),
|
||||
Backup: defaultBackup().Result(),
|
||||
BackupReader: test.NewTarWriter(t).
|
||||
AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()).
|
||||
AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()).
|
||||
Done(),
|
||||
}
|
||||
warnings, errs := h.restorer.Restore(
|
||||
data,
|
||||
[]riav2.RestoreItemAction{
|
||||
&pluggableAction{
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
SkipRestore: true,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
assertEmptyResults(t, warnings, errs)
|
||||
assertAPIContents(t, h, map[*test.APIResource][]string{
|
||||
test.Pods(): nil,
|
||||
test.PVs(): nil,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("must-include does not restore additional items missing from the backup tarball", func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.AddItems(t, test.Pods())
|
||||
h.AddItems(t, test.PVs())
|
||||
|
||||
data := &Request{
|
||||
Log: h.log,
|
||||
Restore: defaultRestore().IncludedResources("pods").Result(),
|
||||
Backup: defaultBackup().Result(),
|
||||
BackupReader: test.NewTarWriter(t).
|
||||
AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()).
|
||||
Done(),
|
||||
}
|
||||
warnings, errs := h.restorer.Restore(
|
||||
data,
|
||||
[]riav2.RestoreItemAction{
|
||||
&pluggableAction{
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumes, Name: "pv-missing"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
assertEmptyResults(t, errs)
|
||||
assertNonEmptyResults(t, "warning", warnings)
|
||||
assertAPIContents(t, h, map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1"},
|
||||
test.PVs(): nil,
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("transitive must-include requires each RIA level to re-set the annotation", func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.AddItems(t, test.Pods())
|
||||
h.AddItems(t, test.PVs())
|
||||
h.AddItems(t, test.PVCs())
|
||||
|
||||
data := &Request{
|
||||
Log: h.log,
|
||||
Restore: defaultRestore().IncludedResources("pods").Result(),
|
||||
Backup: defaultBackup().Result(),
|
||||
BackupReader: test.NewTarWriter(t).
|
||||
AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()).
|
||||
AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()).
|
||||
AddItems("persistentvolumeclaims", builder.ForPersistentVolumeClaim("ns-2", "pvc-1").Result()).
|
||||
Done(),
|
||||
}
|
||||
warnings, errs := h.restorer.Restore(
|
||||
data,
|
||||
[]riav2.RestoreItemAction{
|
||||
// Parent pod RIA force-includes the excluded PV.
|
||||
&pluggableAction{
|
||||
selector: velero.ResourceSelector{IncludedResources: []string{"pods"}},
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
// Child PV RIA also re-sets the annotation to force-include an excluded PVC.
|
||||
&pluggableAction{
|
||||
selector: velero.ResourceSelector{IncludedResources: []string{"persistentvolumes"}},
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumeClaims, Namespace: "ns-2", Name: "pvc-1"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
assertEmptyResults(t, warnings, errs)
|
||||
assertAPIContents(t, h, map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1"},
|
||||
test.PVs(): {"/pv-1"},
|
||||
test.PVCs(): {"ns-2/pvc-1"},
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("without re-annotating, transitive additional items still respect filters", func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
h.AddItems(t, test.Pods())
|
||||
h.AddItems(t, test.PVs())
|
||||
h.AddItems(t, test.PVCs())
|
||||
|
||||
data := &Request{
|
||||
Log: h.log,
|
||||
Restore: defaultRestore().IncludedResources("pods").Result(),
|
||||
Backup: defaultBackup().Result(),
|
||||
BackupReader: test.NewTarWriter(t).
|
||||
AddItems("pods", builder.ForPod("ns-1", "pod-1").Result()).
|
||||
AddItems("persistentvolumes", builder.ForPersistentVolume("pv-1").Result()).
|
||||
AddItems("persistentvolumeclaims", builder.ForPersistentVolumeClaim("ns-2", "pvc-1").Result()).
|
||||
Done(),
|
||||
}
|
||||
warnings, errs := h.restorer.Restore(
|
||||
data,
|
||||
[]riav2.RestoreItemAction{
|
||||
&pluggableAction{
|
||||
selector: velero.ResourceSelector{IncludedResources: []string{"pods"}},
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
item := input.Item.(*unstructured.Unstructured)
|
||||
annotations := item.GetAnnotations()
|
||||
if annotations == nil {
|
||||
annotations = map[string]string{}
|
||||
}
|
||||
annotations[velerov1api.MustIncludeAdditionalItemRestoreAnnotation] = "true"
|
||||
item.SetAnnotations(annotations)
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumes, Name: "pv-1"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
// Child PV RIA returns an additional PVC but does NOT set must-include.
|
||||
&pluggableAction{
|
||||
selector: velero.ResourceSelector{IncludedResources: []string{"persistentvolumes"}},
|
||||
executeFunc: func(input *velero.RestoreItemActionExecuteInput) (*velero.RestoreItemActionExecuteOutput, error) {
|
||||
return &velero.RestoreItemActionExecuteOutput{
|
||||
UpdatedItem: input.Item,
|
||||
AdditionalItems: []velero.ResourceIdentifier{
|
||||
{GroupResource: kuberesource.PersistentVolumeClaims, Namespace: "ns-2", Name: "pvc-1"},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
nil,
|
||||
)
|
||||
|
||||
assertEmptyResults(t, warnings, errs)
|
||||
assertAPIContents(t, h, map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1"},
|
||||
test.PVs(): {"/pv-1"},
|
||||
test.PVCs(): nil,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// TestShouldRestore runs the ShouldRestore function for various permutations of
|
||||
// existing/nonexisting/being-deleted PVs, PVCs, and namespaces, and verifies the
|
||||
// result/error matches expectations.
|
||||
|
||||
@@ -121,7 +121,10 @@ func snapshotSource(
|
||||
return "", 0, errors.Wrapf(err, "Failed to run uploader backup for si %v", source)
|
||||
}
|
||||
|
||||
snap.Tags = make(map[string]string)
|
||||
if snap.Tags == nil {
|
||||
snap.Tags = make(map[string]string)
|
||||
}
|
||||
|
||||
snap.Tags[uploader.CBTChangeIDTag] = cbtSource.ChangeID
|
||||
snap.Tags[uploader.CBTVolumeIDTag] = cbtSource.VolumeID
|
||||
if snapshotTags != nil {
|
||||
@@ -222,7 +225,17 @@ func Restore(ctx context.Context, blkUp Uploader, rep udmrepo.BackupRepo, snapsh
|
||||
|
||||
defer destDev.Close()
|
||||
|
||||
size, err := blkUp.Restore(snapshot, destInfo{dev: destDev, path: destPath}, bitmap.Iterator(), uploaderCfg)
|
||||
destSize, err := destDev.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "error getting length of block device %s", dest)
|
||||
}
|
||||
|
||||
_, err = destDev.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "error reset pos of block device %s", dest)
|
||||
}
|
||||
|
||||
size, err := blkUp.Restore(snapshot, destInfo{dev: destDev, path: destPath, size: destSize}, bitmap.Iterator(), uploaderCfg)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "error restoring to block dev %s", destPath)
|
||||
}
|
||||
|
||||
@@ -17,11 +17,13 @@ limitations under the License.
|
||||
package block
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
@@ -36,8 +38,9 @@ import (
|
||||
var ErrCanceled = errors.New("uploader is canceled")
|
||||
|
||||
const (
|
||||
blockSize = (1 << 20)
|
||||
bufferSize = 100 << 20
|
||||
blockSize = (1 << 20)
|
||||
bufferSize = 100 << 20
|
||||
bdevSourceSizeTag = "bdev-source-size"
|
||||
)
|
||||
|
||||
type sourceInfo struct {
|
||||
@@ -49,6 +52,7 @@ type sourceInfo struct {
|
||||
type destInfo struct {
|
||||
dev *os.File
|
||||
path string
|
||||
size int64
|
||||
}
|
||||
|
||||
type Uploader interface {
|
||||
@@ -135,12 +139,52 @@ func (blkup *blockUploader) Backup(source sourceInfo, parentObject udmrepo.ID, b
|
||||
Type: udmrepo.ObjectDataTypeMetadata,
|
||||
Permissions: 0o777,
|
||||
},
|
||||
Tags: map[string]string{
|
||||
bdevSourceSizeTag: strconv.FormatInt(source.size, 10),
|
||||
},
|
||||
}, backupSize, nil
|
||||
}
|
||||
|
||||
// TODO implement in following PRs
|
||||
func (blkup *blockUploader) Restore(snapshot udmrepo.Snapshot, dest destInfo, bitmap cbt.Iterator, configs map[string]string) (int64, error) {
|
||||
return 0, errors.New("not implemented")
|
||||
if bitmap == nil {
|
||||
return 0, errors.New("bitmap is not available")
|
||||
}
|
||||
|
||||
meta, err := blkup.repoWriter.ReadMetadata(blkup.ctx, snapshot.RootObject.ID)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "error reading snapshot metadata for %s", snapshot.Description)
|
||||
}
|
||||
|
||||
if len(meta.SubObjects) != 1 {
|
||||
return 0, errors.Errorf("unexpected number of bdev object (%d) for snapshot %s", len(meta.SubObjects), snapshot.Description)
|
||||
}
|
||||
|
||||
sourceSize, err := getSourceSize(snapshot)
|
||||
if err != nil {
|
||||
sourceSize = meta.SubObjects[0].Size
|
||||
blkup.log.Warnf("Failed to get source size from snapshot %s, use backup size %v", snapshot.Description, sourceSize)
|
||||
}
|
||||
|
||||
if sourceSize > meta.SubObjects[0].Size {
|
||||
return 0, errors.Wrapf(err, "unexpected size (%v vs. %v) for bdev object %s", meta.SubObjects[0].Size, sourceSize, meta.SubObjects[0].Name)
|
||||
}
|
||||
|
||||
if sourceSize > dest.size {
|
||||
return 0, errors.Wrapf(err, "dest dev(%s) size is too small (%v vs. %v)", dest.path, dest.size, sourceSize)
|
||||
}
|
||||
|
||||
reader, err := blkup.repoWriter.OpenObject(blkup.ctx, meta.SubObjects[0].ID)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "error opening bdev object %v", meta.SubObjects[0].Name)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
size, err := blkup.restoreData(reader, dest.dev, bitmap, sourceSize, dest.path)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "error restoring bdev object %s to volume %s", meta.SubObjects[0].Name, dest.path)
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func (blkup *blockUploader) backupObject(dev *os.File, dest udmrepo.ObjectWriter, bitmap cbt.Iterator, totalLength int64) (udmrepo.ID, int64, int64, error) {
|
||||
@@ -319,6 +363,202 @@ func getObjectName(source string) string {
|
||||
return strings.Trim(s, "-")
|
||||
}
|
||||
|
||||
func (blkup *blockUploader) restoreData(reader io.ReadSeeker, dest *os.File, bitmap cbt.Iterator, totalLength int64, destPath string) (int64, error) {
|
||||
list := freelist.New(bufferSize, blockSize)
|
||||
resultChan := make(chan readResult, list.Capacity())
|
||||
zeroBlock := make([]byte, blockSize)
|
||||
totalCount := bitmap.Count()
|
||||
|
||||
quit := make(chan struct{})
|
||||
defer close(quit)
|
||||
|
||||
go func() {
|
||||
defer close(resultChan)
|
||||
|
||||
offset, valid := bitmap.Next()
|
||||
var buffer []byte
|
||||
var nextPos = uint64(0)
|
||||
for valid {
|
||||
select {
|
||||
case <-blkup.ctx.Done():
|
||||
return
|
||||
case <-quit:
|
||||
return
|
||||
case buffer = <-list.Chunks():
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
if nextPos != offset {
|
||||
_, err = reader.Seek(int64(offset), io.SeekStart)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
var length int
|
||||
length, err = io.ReadFull(reader, buffer)
|
||||
if err == nil && length <= 0 {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
|
||||
r := readResult{
|
||||
buffer: buffer,
|
||||
offset: int64(offset),
|
||||
err: err,
|
||||
}
|
||||
|
||||
if r.err != nil {
|
||||
r.resetBuffer(list)
|
||||
}
|
||||
|
||||
resultChan <- r
|
||||
|
||||
if r.err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
nextPos = offset + uint64(blockSize)
|
||||
offset, valid = bitmap.Next()
|
||||
}
|
||||
}()
|
||||
|
||||
var written int64
|
||||
var result readResult
|
||||
var writeErr error
|
||||
var readerRunning bool
|
||||
var zeroStart int64 = -1
|
||||
var zeroLength int64
|
||||
var curCount int64
|
||||
|
||||
for curCount < int64(totalCount) {
|
||||
select {
|
||||
case <-blkup.ctx.Done():
|
||||
writeErr = ErrCanceled
|
||||
case result, readerRunning = <-resultChan:
|
||||
if !readerRunning {
|
||||
if blkup.ctx.Err() != nil {
|
||||
writeErr = ErrCanceled
|
||||
} else {
|
||||
writeErr = io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if writeErr != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if result.err != nil {
|
||||
writeErr = result.err
|
||||
break
|
||||
}
|
||||
|
||||
length := min(int64(blockSize), totalLength-result.offset)
|
||||
if bytes.Equal(result.buffer, zeroBlock) {
|
||||
if zeroStart == -1 {
|
||||
zeroStart = result.offset
|
||||
zeroLength = length
|
||||
} else if result.offset == zeroStart+zeroLength {
|
||||
zeroLength += length
|
||||
} else {
|
||||
if err := blkup.flushZeroBlocks(dest, zeroStart, zeroLength, zeroBlock, destPath); err != nil {
|
||||
writeErr = errors.Wrapf(err, "error flushing zero blocks from %v, length %v", zeroStart, zeroLength)
|
||||
break
|
||||
}
|
||||
zeroStart = result.offset
|
||||
zeroLength = length
|
||||
}
|
||||
} else {
|
||||
if zeroStart != -1 {
|
||||
if err := blkup.flushZeroBlocks(dest, zeroStart, zeroLength, zeroBlock, destPath); err != nil {
|
||||
writeErr = errors.Wrapf(err, "error flushing zero blocks from %v, length %v", zeroStart, zeroLength)
|
||||
break
|
||||
}
|
||||
|
||||
zeroStart = -1
|
||||
zeroLength = 0
|
||||
}
|
||||
|
||||
n, err := dest.WriteAt(result.buffer[:length], result.offset)
|
||||
if err != nil {
|
||||
writeErr = err
|
||||
break
|
||||
}
|
||||
|
||||
if length != int64(n) {
|
||||
writeErr = io.ErrShortWrite
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
written += length
|
||||
curCount++
|
||||
|
||||
result.resetBuffer(list)
|
||||
|
||||
blkup.progress.UpdateProgress(&uploader.Progress{BytesDone: written, TotalBytes: totalLength})
|
||||
}
|
||||
|
||||
result.resetBuffer(list)
|
||||
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
|
||||
if zeroStart != -1 {
|
||||
if err := blkup.flushZeroBlocks(dest, zeroStart, zeroLength, zeroBlock, destPath); err != nil {
|
||||
return written, errors.Wrapf(err, "error flushing zero blocks from %v, length %v", zeroStart, zeroLength)
|
||||
}
|
||||
}
|
||||
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func (blkup *blockUploader) flushZeroBlocks(dest *os.File, start int64, length int64, zeroBlock []byte, destPath string) error {
|
||||
err := blkZeroOut(dest, start, length)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
blkup.log.WithError(err).Warnf("Failed to call zero out from dev %s, start %v, length %v. Fallback to conservative way", destPath, start, length)
|
||||
|
||||
var written int64
|
||||
for written < length {
|
||||
writeSize := min(len(zeroBlock), int(length-written))
|
||||
|
||||
n, err := dest.WriteAt(zeroBlock[:writeSize], start+written)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "error writing zero buffer at %v, length %v", start+written, writeSize)
|
||||
}
|
||||
|
||||
if writeSize != n {
|
||||
return errors.Wrapf(err, "short write zero buffer at %v, length %v", start+written, writeSize)
|
||||
}
|
||||
|
||||
written += int64(writeSize)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getSourceSize(snapshot udmrepo.Snapshot) (int64, error) {
|
||||
if snapshot.Tags == nil {
|
||||
return 0, errors.New("source size tag is empty")
|
||||
}
|
||||
|
||||
s, found := snapshot.Tags[bdevSourceSizeTag]
|
||||
if !found {
|
||||
return 0, errors.New("source size tag is missing")
|
||||
}
|
||||
|
||||
size, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.Wrapf(err, "error parsing size from %s", s)
|
||||
}
|
||||
|
||||
return size, nil
|
||||
}
|
||||
|
||||
func loadObjectFromSnapshot(ctx context.Context, rep udmrepo.BackupRepo, snapshot *udmrepo.Snapshot) (udmrepo.ID, error) {
|
||||
if snapshot == nil {
|
||||
return "", errors.New("snapshot is empty")
|
||||
|
||||
@@ -460,3 +460,231 @@ func TestLoadObjectFromSnapshot(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSourceSize(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
snapshot udmrepo.Snapshot
|
||||
expectErr bool
|
||||
expected int64
|
||||
}{
|
||||
{
|
||||
name: "nil tags",
|
||||
snapshot: udmrepo.Snapshot{},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing tag",
|
||||
snapshot: udmrepo.Snapshot{
|
||||
Tags: map[string]string{},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid tag value",
|
||||
snapshot: udmrepo.Snapshot{
|
||||
Tags: map[string]string{
|
||||
bdevSourceSizeTag: "abc",
|
||||
},
|
||||
},
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "valid tag value",
|
||||
snapshot: udmrepo.Snapshot{
|
||||
Tags: map[string]string{
|
||||
bdevSourceSizeTag: "1048576",
|
||||
},
|
||||
},
|
||||
expectErr: false,
|
||||
expected: 1048576,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
size, err := getSourceSize(tc.snapshot)
|
||||
if tc.expectErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expected, size)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushZeroBlocks(t *testing.T) {
|
||||
t.Run("success via write fallback", func(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "zerotest-*")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
require.NoError(t, f.Truncate(2048))
|
||||
|
||||
blkup := &blockUploader{
|
||||
log: logrus.New(),
|
||||
}
|
||||
blkup.log.(*logrus.Logger).Out = io.Discard
|
||||
|
||||
zeroBlock := make([]byte, 1024)
|
||||
err = blkup.flushZeroBlocks(f, 0, 2048, zeroBlock, f.Name())
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
data, err := os.ReadFile(f.Name())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, make([]byte, 2048), data)
|
||||
})
|
||||
}
|
||||
|
||||
type errReader struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *errReader) Read(p []byte) (n int, err error) {
|
||||
return 0, r.err
|
||||
}
|
||||
|
||||
func (r *errReader) Seek(offset int64, whence int) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func TestRestoreData(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
progress := &mockProgressUpdater{}
|
||||
progress.On("UpdateProgress", mock.Anything).Return()
|
||||
blkup := &blockUploader{
|
||||
ctx: ctx,
|
||||
progress: progress,
|
||||
log: logrus.New(),
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "restoretest-*")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
data := make([]byte, 1048576)
|
||||
for i := range data {
|
||||
data[i] = 1
|
||||
}
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
iterMock := cbtmocks.NewIterator(t)
|
||||
iterMock.On("Count").Return(uint64(1))
|
||||
iterMock.On("Next").Return(uint64(0), true).Once()
|
||||
iterMock.On("Next").Return(uint64(0), false)
|
||||
|
||||
written, err := blkup.restoreData(reader, f, iterMock, 1048576, f.Name())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1048576), written)
|
||||
|
||||
f.Seek(0, 0)
|
||||
writtenData, err := io.ReadAll(f)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, data, writtenData)
|
||||
})
|
||||
|
||||
t.Run("read err", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
blkup := &blockUploader{
|
||||
ctx: ctx,
|
||||
log: logrus.New(),
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "restoretest-*")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
reader := &errReader{err: errors.New("read error")}
|
||||
|
||||
iterMock := cbtmocks.NewIterator(t)
|
||||
iterMock.On("Count").Return(uint64(1))
|
||||
iterMock.On("Next").Return(uint64(0), true).Once()
|
||||
iterMock.On("Next").Return(uint64(0), false)
|
||||
|
||||
_, err = blkup.restoreData(reader, f, iterMock, 1048576, f.Name())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "read error")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBlockUploaderRestore(t *testing.T) {
|
||||
t.Run("missing metadata", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repoWriter := udmrepomocks.NewBackupRepo(t)
|
||||
blkup := NewUploader(ctx, repoWriter, nil, logrus.New())
|
||||
|
||||
repoWriter.On("ReadMetadata", mock.Anything, udmrepo.ID("root-id")).Return(nil, errors.New("meta not found"))
|
||||
|
||||
iterMock := cbtmocks.NewIterator(t)
|
||||
_, err := blkup.Restore(udmrepo.Snapshot{RootObject: udmrepo.ObjectMetadata{ID: "root-id"}}, destInfo{}, iterMock, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "meta not found")
|
||||
})
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repoWriter := udmrepomocks.NewBackupRepo(t)
|
||||
progress := &mockProgressUpdater{}
|
||||
progress.On("UpdateProgress", mock.Anything).Return()
|
||||
|
||||
blkup := NewUploader(ctx, repoWriter, progress, logrus.New())
|
||||
|
||||
f, err := os.CreateTemp(t.TempDir(), "restoretest-*")
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(f.Name())
|
||||
defer f.Close()
|
||||
|
||||
meta := &udmrepo.Metadata{
|
||||
SubObjects: []udmrepo.ObjectMetadata{
|
||||
{
|
||||
ID: "data-id",
|
||||
Name: "bdev",
|
||||
Size: 1048576,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
repoWriter.On("ReadMetadata", mock.Anything, udmrepo.ID("root-id")).Return(meta, nil)
|
||||
|
||||
objReader := udmrepomocks.NewObjectReader(t)
|
||||
objReader.On("Read", mock.Anything).Run(func(args mock.Arguments) {
|
||||
p := args.Get(0).([]byte)
|
||||
for i := range p {
|
||||
p[i] = 1
|
||||
}
|
||||
}).Return(1048576, io.EOF).Once()
|
||||
objReader.On("Read", mock.Anything).Return(0, io.EOF)
|
||||
objReader.On("Close").Return(nil)
|
||||
|
||||
repoWriter.On("OpenObject", mock.Anything, udmrepo.ID("data-id")).Return(objReader, nil)
|
||||
|
||||
snap := udmrepo.Snapshot{
|
||||
Description: "test snapshot",
|
||||
RootObject: udmrepo.ObjectMetadata{ID: "root-id"},
|
||||
Tags: map[string]string{
|
||||
bdevSourceSizeTag: "1048576",
|
||||
},
|
||||
}
|
||||
|
||||
dest := destInfo{
|
||||
dev: f,
|
||||
size: 2048576,
|
||||
path: f.Name(),
|
||||
}
|
||||
|
||||
iterMock := cbtmocks.NewIterator(t)
|
||||
iterMock.On("Count").Return(uint64(1))
|
||||
iterMock.On("Next").Return(uint64(0), true).Once()
|
||||
iterMock.On("Next").Return(uint64(0), false)
|
||||
|
||||
written, err := blkup.Restore(snap, dest, iterMock, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1048576), written)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,7 +32,18 @@ const (
|
||||
// IsBuiltInDataMover reports whether the given data mover value refers to a
|
||||
// Velero built-in data mover (an empty value or the default "velero" alias).
|
||||
func IsBuiltInDataMover(dataMover string) bool {
|
||||
return dataMover == "" || dataMover == DataMoverTypeVelero
|
||||
return IsVeleroBlockDataMover(dataMover) || IsVeleroFSDataMover(dataMover)
|
||||
}
|
||||
|
||||
func IsVeleroFSDataMover(dataMover string) bool {
|
||||
if dataMover == "" || dataMover == DataMoverTypeVelero {
|
||||
dataMover = DataMoverTypeVeleroFs
|
||||
}
|
||||
return dataMover == DataMoverTypeVeleroFs
|
||||
}
|
||||
|
||||
func IsVeleroBlockDataMover(dataMover string) bool {
|
||||
return dataMover == DataMoverTypeVeleroBlock
|
||||
}
|
||||
|
||||
// GetDefaultBuiltInDataMover returns the data mover used when the default
|
||||
|
||||
@@ -38,6 +38,16 @@ func TestIsBuiltInDataMover(t *testing.T) {
|
||||
dataMover: "velero",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "velero-fs dataMover is builtin",
|
||||
dataMover: "velero-fs",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "velero-block dataMover is builtin",
|
||||
dataMover: "velero-block",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "kopia dataMover is not builtin",
|
||||
dataMover: "kopia",
|
||||
@@ -54,3 +64,61 @@ func TestIsBuiltInDataMover(t *testing.T) {
|
||||
func TestGetDefaultBuiltInDataMover(t *testing.T) {
|
||||
assert.Equal(t, DataMoverTypeVeleroFs, GetDefaultBuiltInDataMover())
|
||||
}
|
||||
|
||||
func TestIsFSDataMover(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
dataMover string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "empty dataMover is fs",
|
||||
dataMover: "",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "velero dataMover is fs",
|
||||
dataMover: "velero",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "velero-fs dataMover is fs",
|
||||
dataMover: "velero-fs",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "velero-block dataMover is not fs",
|
||||
dataMover: "velero-block",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(tt *testing.T) {
|
||||
assert.Equal(tt, tc.want, IsVeleroFSDataMover(tc.dataMover))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBlockDataMover(t *testing.T) {
|
||||
testcases := []struct {
|
||||
name string
|
||||
dataMover string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "velero-block dataMover is block",
|
||||
dataMover: "velero-block",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "velero-fs dataMover is not block",
|
||||
dataMover: "velero-fs",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range testcases {
|
||||
t.Run(tc.name, func(tt *testing.T) {
|
||||
assert.Equal(tt, tc.want, IsVeleroBlockDataMover(tc.dataMover))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ hero:
|
||||
content: Velero is an open source tool to safely backup and restore, perform disaster recovery, and migrate Kubernetes cluster resources and persistent volumes.
|
||||
cta_link1:
|
||||
text: Latest Release Information
|
||||
url: /blog/Velero-1.11/
|
||||
url: https://github.com/velero-io/velero/releases/latest
|
||||
cta_link2:
|
||||
text: Download Velero
|
||||
url: https://github.com/velero-io/velero/releases/latest
|
||||
|
||||
@@ -67,7 +67,7 @@ On source cluster, Velero needs to manipulate CSI snapshots through the CSI volu
|
||||
|
||||
To integrate Velero with the CSI volume snapshot APIs, you must enable the `EnableCSI` feature flag.
|
||||
|
||||
From release-1.14, the `github.com/vmware-tanzu/velero-plugin-for-csi` repository, which is the Velero CSI plugin, is merged into the `github.com/velero-io/velero` repository.
|
||||
From release-1.14, the `github.com/velero-io/velero-plugin-for-csi` repository, which is the Velero CSI plugin, is merged into the `github.com/velero-io/velero` repository.
|
||||
The reasons to merge the CSI plugin are:
|
||||
* The VolumeSnapshot data mover depends on the CSI plugin, it's reasonabe to integrate them.
|
||||
* This change reduces the Velero deploying complexity.
|
||||
|
||||
@@ -8,7 +8,7 @@ Integrating Container Storage Interface (CSI) snapshot support into Velero enabl
|
||||
By supporting CSI snapshot APIs, Velero can support any volume provider that has a CSI driver, without requiring a Velero-specific plugin to be available. This page gives an overview of how to add support for CSI snapshots to Velero.
|
||||
|
||||
## Notice
|
||||
From release-1.14, the `github.com/vmware-tanzu/velero-plugin-for-csi` repository, which is the Velero CSI plugin, is merged into the `github.com/velero-io/velero` repository.
|
||||
From release-1.14, the `github.com/velero-io/velero-plugin-for-csi` repository, which is the Velero CSI plugin, is merged into the `github.com/velero-io/velero` repository.
|
||||
The reasons to merge the CSI plugin are:
|
||||
* The VolumeSnapshot data mover depends on the CSI plugin, it's reasonabe to integrate them.
|
||||
* This change reduces the Velero deploying complexity.
|
||||
|
||||
@@ -65,6 +65,32 @@ order in which item action plugins are invoked. However, if a single binary impl
|
||||
they may be invoked in the order in which they are registered but it is best to not depend on this
|
||||
implementation. This is not guaranteed officially and the implementation can change at any time.
|
||||
|
||||
### Must-include additional items (Restore Item Actions)
|
||||
|
||||
Restore Item Actions may return `AdditionalItems` that Velero restores as dependencies of the current item.
|
||||
By default those additional items must still pass the restore's global resource and namespace include/exclude
|
||||
filters (and `IncludeClusterResources=false` for cluster-scoped resources).
|
||||
|
||||
To force-restore hard dependencies despite those filters, set the following annotation on the `UpdatedItem`
|
||||
returned from `Execute()`:
|
||||
|
||||
```
|
||||
restore.velero.io/must-include-additional-items: "true"
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Only the string value `"true"` enables the bypass.
|
||||
- The annotation applies blanket to all `AdditionalItems` from that RIA invocation (not per-item).
|
||||
- Velero strips the annotation before applying the item to the cluster.
|
||||
- `SkipRestore: true` takes precedence: if set, the annotation is never inspected and `AdditionalItems` are not processed.
|
||||
- Must-include only bypasses filters; the additional item must still exist in the backup tarball.
|
||||
- When an additional item targets an excluded namespace, Velero may still create that target namespace so the item can be restored.
|
||||
- Cluster-scoped additional items are restored even when `IncludeClusterResources=false`.
|
||||
- Transitive force-include requires each RIA level to re-set the annotation on its own `UpdatedItem`.
|
||||
|
||||
This mirrors the backup-side annotation `backup.velero.io/must-include-additional-items` used by Backup Item Actions.
|
||||
Installing an RIA that sets this annotation is a trust decision: the plugin can restore resources outside the operator's restore filters.
|
||||
|
||||
## Plugin Logging
|
||||
|
||||
Velero provides a [logger][2] that can be used by plugins to log structured information to the main Velero server log or
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
---
|
||||
title: "Fine-Grained Backup Filters"
|
||||
layout: docs
|
||||
---
|
||||
|
||||
This guide explains how to use Velero's **fine-grained backup filters**: per-namespace, per-kind rules with independent label selectors and resource name patterns. Configuration lives in the same **ResourcePolicy ConfigMap** you may already use for volume policies.
|
||||
|
||||
For architecture and pipeline details, see the [design document](https://github.com/velero-io/velero/blob/main/design/backup-filter-enhancement/fine-grained-backup-filters-design.md).
|
||||
|
||||
---
|
||||
|
||||
## Introduction
|
||||
|
||||
Velero's global backup filters apply the same namespace list, resource types, and label selector to every namespace in a backup. That works for many clusters, but common scenarios need more control:
|
||||
|
||||
- **Different namespaces, different strategies** — back up everything in a database namespace, but only Deployments and ConfigMaps in a frontend namespace.
|
||||
- **Filter by resource name** — back up `app-config` and `app-secret` without also capturing `monitoring-config`.
|
||||
- **Different labels per kind** — Deployments labeled `app=workload-1` and StatefulSets labeled `app=workload-2` in the same namespace.
|
||||
|
||||
Fine-grained filters add two optional sections to the ResourcePolicy ConfigMap:
|
||||
|
||||
| Section | Scope | Behavior |
|
||||
|---------|-------|----------|
|
||||
| `namespacedFilterPolicies` | Namespaces you match (exact name or glob) | **Exclusive allowlist** — only resource kinds listed in `resourceFilters` (or covered by a catch-all) are backed up from those namespaces |
|
||||
| `clusterScopedFilterPolicy` | Cluster-scoped resources globally | **Refinement overlay** — listed kinds get per-kind label and name rules; unlisted cluster-scoped kinds still use global BackupSpec filters |
|
||||
|
||||
**No new BackupSpec CRD fields** are required. Reference the policy from `Backup.spec.resourcePolicy` or `velero backup create --resource-policies-configmap`.
|
||||
|
||||
**Backward compatible:** if you omit both new sections, backups behave exactly as they do today.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites and wiring
|
||||
|
||||
### What you need
|
||||
|
||||
- Velero installed with backup filters support (see your Velero release notes).
|
||||
- A ResourcePolicy ConfigMap in the Velero namespace (`velero` by default).
|
||||
- Permission to create Backups (or Schedules) that reference the ConfigMap.
|
||||
|
||||
### End-to-end pattern
|
||||
|
||||
Every example below follows the same three steps:
|
||||
|
||||
1. **Create or update** a ConfigMap with `data.policy` containing `version: v1` and your filter rules.
|
||||
2. **Create a Backup** (or Schedule) that includes the target namespaces and references the ConfigMap.
|
||||
3. **Verify** with `velero backup describe` and inspect backup contents or logs.
|
||||
|
||||
### Minimal skeleton
|
||||
|
||||
Use this once; later examples show only the `policy:` body.
|
||||
|
||||
**ResourcePolicy ConfigMap:**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: my-backup-filter-policy
|
||||
namespace: velero
|
||||
data:
|
||||
policy: |
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- my-namespace
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
```
|
||||
|
||||
**Backup:**
|
||||
|
||||
```yaml
|
||||
apiVersion: velero.io/v1
|
||||
kind: Backup
|
||||
metadata:
|
||||
name: my-backup
|
||||
namespace: velero
|
||||
spec:
|
||||
includedNamespaces:
|
||||
- my-namespace
|
||||
resourcePolicy:
|
||||
kind: configmap
|
||||
name: my-backup-filter-policy
|
||||
storageLocation: default
|
||||
```
|
||||
|
||||
**CLI equivalent:**
|
||||
|
||||
```bash
|
||||
velero backup create my-backup \
|
||||
--include-namespaces my-namespace \
|
||||
--resource-policies-configmap my-backup-filter-policy
|
||||
```
|
||||
|
||||
**Verify:**
|
||||
|
||||
```bash
|
||||
velero backup describe my-backup
|
||||
velero backup describe my-backup -o json | jq '.namespacedFilterPolicies'
|
||||
```
|
||||
|
||||
### Important: do not mix old-style BackupSpec resource filters
|
||||
|
||||
When `namespacedFilterPolicies` or `clusterScopedFilterPolicy` is present in the ResourcePolicy, **do not** set these on the Backup:
|
||||
|
||||
- `spec.includedResources` / `spec.excludedResources`
|
||||
- `spec.includeClusterResources`
|
||||
|
||||
Use `includeExcludePolicy` inside the ResourcePolicy ConfigMap for global resource-type include/exclude instead. Velero rejects backups that combine the new policy sections with old-style fields.
|
||||
|
||||
Schedules follow the same rule: configure filters in the ResourcePolicy ConfigMap, not via deprecated resource filter fields on the Schedule template.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
Each example includes: **goal**, **policy YAML**, **backup notes**, **expected outcome**, and **how to verify**.
|
||||
|
||||
---
|
||||
|
||||
### Example 0 — Baseline (no new filters)
|
||||
|
||||
**Goal:** Confirm that namespaces without a `namespacedFilterPolicies` entry still use global BackupSpec filters.
|
||||
|
||||
**Policy:** Omit `namespacedFilterPolicies` and `clusterScopedFilterPolicy` entirely (or use a ConfigMap with only `volumePolicies` / `includeExcludePolicy`).
|
||||
|
||||
**Backup:**
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
includedNamespaces:
|
||||
- ns-a
|
||||
- ns-b
|
||||
- production
|
||||
# No resourcePolicy — global filters only
|
||||
```
|
||||
|
||||
**Expected outcome:** All resources in included namespaces follow `includedNamespaces`, `labelSelector`, `includedResources`, and related global fields — same as before this feature.
|
||||
|
||||
**Verify:** `velero backup describe` shows no namespace-scoped filter policies section.
|
||||
|
||||
---
|
||||
|
||||
### Example 1 — Per-namespace kinds and labels
|
||||
|
||||
**Goal:** In `ns-a`, back up only ConfigMaps, Secrets, Deployments, and Pods with `app=my-app`. In `ns-b`, use global filters (no policy entry for that namespace).
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret, Deployment, Pod]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
```
|
||||
|
||||
**Backup:**
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
includedNamespaces:
|
||||
- ns-a
|
||||
- ns-b
|
||||
resourcePolicy:
|
||||
kind: configmap
|
||||
name: per-namespace-resource-filter-policy # or your ConfigMap name
|
||||
```
|
||||
|
||||
**Expected outcome:**
|
||||
|
||||
- **ns-a:** Only listed kinds with label `app=my-app` (e.g. `app-config`, `app-secret`, `app-deployment`). Resources like `monitoring-config` (different labels) are excluded.
|
||||
- **ns-b:** Everything allowed by global filters (no namespace policy match).
|
||||
|
||||
**Verify:** `velero backup describe` lists resolved filters for `ns-a`.
|
||||
|
||||
---
|
||||
|
||||
### Example 2 — Exact resource names
|
||||
|
||||
**Goal:** Back up only two ConfigMaps by exact name, optionally requiring a label.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- target-namespace
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap]
|
||||
names: [vm-1, vm-2]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
resource-type: VirtualMachine
|
||||
```
|
||||
|
||||
**Backup:** `includedNamespaces: [target-namespace]` plus `resourcePolicy` reference.
|
||||
|
||||
**Expected outcome:** Only `vm-1` and `vm-2` ConfigMaps with `resource-type=VirtualMachine`. `vm-3` and other ConfigMaps are excluded.
|
||||
|
||||
**Verify:** Backup archive contains exactly those two ConfigMaps in `target-namespace`.
|
||||
|
||||
---
|
||||
|
||||
### Example 3 — Glob name patterns with exclusions
|
||||
|
||||
**Goal:** Back up `app-*` ConfigMaps and Secrets in `production`, but exclude temporary and debug names.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret]
|
||||
names: ["app-*"]
|
||||
excludedNames: ["*-tmp-*", "*-debug-*", "*-tmp", "*-debug"]
|
||||
```
|
||||
|
||||
**Expected outcome:**
|
||||
|
||||
- **Included:** `app-config`, `app-cache-config`, `app-secret`, `app-db-secret`
|
||||
- **Excluded:** `app-tmp-config`, `app-debug-config` (excluded by `excludedNames`), and `monitoring-tmp-secret` (excluded because it does not match the `names: ["app-*"]` allowlist)
|
||||
|
||||
`excludedNames` takes precedence over `names` when both match.
|
||||
|
||||
**Verify:** Inspect backup item list.
|
||||
|
||||
---
|
||||
|
||||
### Example 4 — Per-kind label selectors
|
||||
|
||||
**Goal:** Apply different label rules to different resource types in the same namespace.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- target-namespace
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap]
|
||||
orLabelSelectors:
|
||||
- matchLabels:
|
||||
app: production-workload-1
|
||||
component: vm-group
|
||||
- matchLabels:
|
||||
app: production-workload-2
|
||||
component: vm-service
|
||||
```
|
||||
|
||||
**Expected outcome:** ConfigMaps matching either label combination are backed up; other ConfigMaps in the namespace are not (for this kind).
|
||||
|
||||
**Note:** Prefer `matchExpressions` with `In` for value-OR on a single key (see next example). Use `orLabelSelectors` when you need OR across **independent multi-key groups**. `labelSelector` and `orLabelSelectors` cannot appear in the same `resourceFilters` entry.
|
||||
|
||||
---
|
||||
|
||||
### Example 4b — Set-based label selectors (`matchExpressions`)
|
||||
|
||||
**Goal:** Back up Deployments and Pods that are in `prod` or `staging`, belong to `app=my-app`, and do **not** carry a skip label.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Pod]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
matchExpressions:
|
||||
- key: environment
|
||||
operator: In
|
||||
values: [prod, staging]
|
||||
- key: do-not-backup
|
||||
operator: DoesNotExist
|
||||
```
|
||||
|
||||
**Supported operators:** `In`, `NotIn`, `Exists`, `DoesNotExist` (same as Kubernetes / Velero global `--selector`).
|
||||
|
||||
**Other useful patterns:**
|
||||
|
||||
```yaml
|
||||
# Exclude environments
|
||||
matchExpressions:
|
||||
- key: environment
|
||||
operator: NotIn
|
||||
values: [dev, test]
|
||||
|
||||
# Require a label key to be present (any value)
|
||||
matchExpressions:
|
||||
- key: tier
|
||||
operator: Exists
|
||||
```
|
||||
|
||||
**Expected outcome:** Only Deployments/Pods with `app=my-app`, `environment` in `{prod, staging}`, and without `do-not-backup` are backed up.
|
||||
|
||||
---
|
||||
|
||||
### Example 5 — OR label selectors across kinds
|
||||
|
||||
**Goal:** Back up ConfigMaps, Secrets, or Deployments that match any of several label conditions.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret]
|
||||
orLabelSelectors:
|
||||
- matchLabels:
|
||||
app: my-app
|
||||
- matchLabels:
|
||||
app: monitoring
|
||||
- kinds: [Deployment]
|
||||
orLabelSelectors:
|
||||
- matchLabels:
|
||||
app: my-app
|
||||
- matchLabels:
|
||||
app: monitoring
|
||||
- matchLabels:
|
||||
component: backend
|
||||
```
|
||||
|
||||
**Expected outcome:** Resources included if they match **any** selector in `orLabelSelectors` for their kind (AND within each selector, OR across the list).
|
||||
|
||||
---
|
||||
|
||||
### Example 6 — Multiple criteria on one kind
|
||||
|
||||
**Goal:** Combine exact names with OR label selectors for a single kind.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- target-namespace
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap]
|
||||
names: [vm-1, vm-2]
|
||||
orLabelSelectors:
|
||||
- matchLabels:
|
||||
resource-type: VirtualMachine
|
||||
- matchLabels:
|
||||
component: vm-group
|
||||
- matchLabels:
|
||||
component: vm-service
|
||||
```
|
||||
|
||||
**Expected outcome:** Only `vm-1` and `vm-2` that also satisfy one of the label OR branches.
|
||||
|
||||
---
|
||||
|
||||
### Example 7 — One policy entry, multiple namespaces
|
||||
|
||||
**Goal:** Apply the same rules to `ns-a`, `ns-b`, and `production` in a single policy block.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
- ns-b
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap]
|
||||
- kinds: [Deployment]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
tier: web
|
||||
```
|
||||
|
||||
**Expected outcome:**
|
||||
|
||||
- All ConfigMaps in those namespaces (no label filter on that entry).
|
||||
- Deployments with `tier=web` only.
|
||||
|
||||
---
|
||||
|
||||
### Example 8 — Namespace glob patterns and ordering
|
||||
|
||||
**Goal:** Different backup breadth for `team-frontend-prod`, `team-frontend-dev`, and `team-backend-test` using glob patterns.
|
||||
|
||||
**Policy (correct order — most specific first):**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- "team-frontend-*"
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service, ConfigMap]
|
||||
- namespaces:
|
||||
- "team-*"
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service]
|
||||
- namespaces:
|
||||
- team-frontend-prod # exact match
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service, ConfigMap, Secret, PersistentVolumeClaim]
|
||||
```
|
||||
|
||||
**Expected outcome:**
|
||||
|
||||
| Namespace | Matched policy | Kinds backed up |
|
||||
|-----------|----------------|-----------------|
|
||||
| `team-frontend-prod` | First entry (exact) | 5 kinds |
|
||||
| `team-frontend-dev` | `team-frontend-*` | 3 kinds |
|
||||
| `team-backend-test` | `team-*` | 2 kinds |
|
||||
|
||||
**Wrong order (avoid):** If `team-*` is listed **before** `team-frontend-*`, then `team-frontend-dev` matches the broader `team-*` rule first and only Deployments and Services are backed up — the more specific `team-frontend-*` rule is never reached.
|
||||
|
||||
Velero evaluates namespaces by looking for an **exact match** first, and then evaluates glob patterns in **definition order** (first-match wins). Because `team-frontend-prod` is an exact match in this policy, its evaluation is unaffected by glob ordering. However, for namespaces relying on glob patterns like `team-frontend-dev`, the order of the glob patterns is critical.
|
||||
|
||||
**Backup:** Include all relevant namespaces in `includedNamespaces` (they must still pass the global namespace filter).
|
||||
|
||||
---
|
||||
|
||||
### Example 9 — Catch-all by label
|
||||
|
||||
**Goal:** Back up any resource kind that has a given label, without listing every kind. Kind-specific entries override the catch-all.
|
||||
|
||||
**Policy (recommended explicit form):**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: ["*"] # catch-all
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: common-app
|
||||
- kinds: [ConfigMap, Secret] # override for these kinds
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: specialized-app
|
||||
```
|
||||
|
||||
**Equivalent:** `kinds: []` (empty) also denotes a catch-all; `kinds: ["*"]` is preferred for readability.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- At most **one** catch-all per namespace policy entry.
|
||||
- Catch-all entries **cannot** use `names` or `excludedNames` — use kind-specific entries for name filtering.
|
||||
- Catch-all does **not** inherit `BackupSpec.labelSelector`; set `labelSelector` or `orLabelSelectors` on the catch-all entry explicitly.
|
||||
|
||||
**Expected outcome:** ConfigMaps and Secrets use `app=specialized-app`; all other kinds listed only via catch-all use `app=common-app`.
|
||||
|
||||
---
|
||||
|
||||
### Example 10 — Catch-all with per-kind name overrides
|
||||
|
||||
**Goal:** Pin critical Deployments and Secrets by exact name; back up everything else with a label convention.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [Deployment]
|
||||
names: [api-server, worker]
|
||||
- kinds: [Secret]
|
||||
names: [db-credentials, tls-cert]
|
||||
- kinds: ["*"]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
backup: "true"
|
||||
```
|
||||
|
||||
**Expected outcome:**
|
||||
|
||||
- Deployments: only `api-server` and `worker`
|
||||
- Secrets: only `db-credentials` and `tls-cert`
|
||||
- Other kinds (ConfigMap, Service, …): resources with `backup=true` only
|
||||
|
||||
**Verify:** `other-deployment` and `no-backup-label-config` should be absent; `backup-labeled-config` and `catch-all-labeled-service` should be present.
|
||||
|
||||
---
|
||||
|
||||
### Example 11 — Override-only catch-all (no label on catch-all)
|
||||
|
||||
**Goal:** Apply a strict name filter to one kind while including all other kinds without listing them or adding labels.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [Secret]
|
||||
names: [app-secret]
|
||||
- kinds: ["*"] # no labelSelector — all other kinds included
|
||||
```
|
||||
|
||||
**Expected outcome:**
|
||||
|
||||
- Secrets: only `app-secret`
|
||||
- Other kinds in `ns-a`: all instances included (subject to global filters and allowlist semantics for listed vs unlisted kinds via catch-all)
|
||||
|
||||
Use this when you need a narrow exception for one type and broad inclusion for the rest of the namespace.
|
||||
|
||||
---
|
||||
|
||||
### Example 12 — Cluster-scoped refinement
|
||||
|
||||
**Goal:** Refine which cluster-scoped resources are backed up by name and label, without replacing global cluster-scoped inclusion.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: [StorageClass]
|
||||
names: ["my-app-*"]
|
||||
- kinds: [ClusterRole, ClusterRoleBinding]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
```
|
||||
|
||||
**Backup (required):** You must still include cluster-scoped kinds on the Backup:
|
||||
|
||||
```yaml
|
||||
spec:
|
||||
includedNamespaces:
|
||||
- ns-a
|
||||
includedClusterScopedResources:
|
||||
- storageclasses
|
||||
- clusterroles
|
||||
- clusterrolebindings
|
||||
resourcePolicy:
|
||||
kind: configmap
|
||||
name: cluster-scoped-filter-policy
|
||||
```
|
||||
|
||||
**Expected outcome (full overlay):**
|
||||
|
||||
- StorageClasses matching `my-app-*` only
|
||||
- ClusterRoles and ClusterRoleBindings with `app=my-app` only
|
||||
- Namespace-scoped resources in `ns-a`: global filters (no `namespacedFilterPolicies` in this example)
|
||||
|
||||
**Partial overlay:** If `includedClusterScopedResources` lists only `clusterroles` and `clusterrolebindings`, StorageClasses are **not** backed up even if listed in `clusterScopedFilterPolicy` — global inclusion is evaluated first.
|
||||
|
||||
**Differences from namespace policies:**
|
||||
|
||||
- **Not** an allowlist — unlisted cluster-scoped kinds fall back to global filters.
|
||||
- **No catch-all** — `kinds: []` or `kinds: ["*"]` is invalid and fails validation.
|
||||
|
||||
---
|
||||
|
||||
### Example 13 — Global `includeExcludePolicy` and namespace filters
|
||||
|
||||
**Goal:** Set a global resource-type baseline, then refine per namespace. Understand that global **exclusions** cannot be overridden per namespace.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
includeExcludePolicy:
|
||||
includedNamespaceScopedResources:
|
||||
- configmaps
|
||||
- secrets
|
||||
- deployments
|
||||
- services
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
- namespaces:
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap]
|
||||
names: ["app-*"]
|
||||
```
|
||||
|
||||
**Expected outcome:**
|
||||
|
||||
- **ns-a:** ConfigMaps and Secrets with `app=my-app` (within global allowlist)
|
||||
- **production:** ConfigMaps matching `app-*` pattern
|
||||
- **Other included namespaces:** Only kinds allowed by `includeExcludePolicy` (no per-namespace override)
|
||||
|
||||
**Global exclusion wins (important):**
|
||||
|
||||
```yaml
|
||||
includeExcludePolicy:
|
||||
excludedNamespaceScopedResources:
|
||||
- secrets
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret, Deployment]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
```
|
||||
|
||||
**Result:** No Secrets in the backup — the namespace policy cannot re-include a globally excluded kind. Velero logs a warning at backup start if you list an excluded kind in `namespacedFilterPolicies`.
|
||||
|
||||
**Backup tip:** Do not set `includedResources` on the Backup; use `includeExcludePolicy` in the ConfigMap instead.
|
||||
|
||||
---
|
||||
|
||||
### Example 14 — Volume policies and namespace filters together
|
||||
|
||||
**Goal:** Use volume snapshot/fs-backup rules and namespace filters in one ConfigMap.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: "0,10Gi"
|
||||
storageClass:
|
||||
- standard
|
||||
action:
|
||||
type: fs-backup
|
||||
- conditions:
|
||||
capacity: "10Gi,100Gi"
|
||||
action:
|
||||
type: snapshot
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap]
|
||||
names: ["app-*"]
|
||||
excludedNames: ["*-tmp-*", "*-debug-*", "*-tmp", "*-debug"]
|
||||
- kinds: [Secret]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
workload: application
|
||||
```
|
||||
|
||||
**Expected outcome:** Volume actions apply to PVCs per `volumePolicies`; resource inclusion follows `namespacedFilterPolicies`. The sections are independent.
|
||||
|
||||
---
|
||||
|
||||
### Example 15 — `velero.io/exclude-from-backup=true` always wins
|
||||
|
||||
**Goal:** Ensure explicitly excluded resources never appear in the backup, even when they match namespace filters or catch-all rules.
|
||||
|
||||
**Policy:**
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
- kinds: ["*"]
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app: my-app
|
||||
```
|
||||
|
||||
**On resources to exclude**, set:
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
labels:
|
||||
velero.io/exclude-from-backup: "true"
|
||||
```
|
||||
|
||||
**Expected outcome:** Resources with `app=my-app` **and** `velero.io/exclude-from-backup=true` are excluded. Same rule applies to cluster-scoped resources refined by `clusterScopedFilterPolicy`.
|
||||
|
||||
---
|
||||
|
||||
## Concepts reference
|
||||
|
||||
### `resourceFilters` fields
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `kinds` | Resource type names (e.g. `ConfigMap`, `deployments`). Empty or `["*"]` = catch-all (namespace policies only). |
|
||||
| `labelSelector` | Kubernetes-style selector with `matchLabels` and/or `matchExpressions` (`In`, `NotIn`, `Exists`, `DoesNotExist`). All requirements are AND-ed. |
|
||||
| `orLabelSelectors` | List of selectors; match if **any** entry matches (AND within each, OR across the list). Use for OR of multi-key groups; prefer `In` for value-OR on one key. Mutually exclusive with `labelSelector`. |
|
||||
| `names` | Exact names or glob patterns to include. |
|
||||
| `excludedNames` | Patterns to exclude; wins over `names` when both match. |
|
||||
|
||||
Only kinds listed in `resourceFilters` (or covered by catch-all) are collected from namespaces matched by `namespacedFilterPolicies`.
|
||||
|
||||
### Glob pattern syntax
|
||||
|
||||
Name and namespace patterns use the same glob style as elsewhere in Velero (`gobwas/glob`):
|
||||
|
||||
- Supported: `*`, `?`, `[abc]`, `[a-z]`
|
||||
- Not supported: `**`, regex, `|`, `()`, `!`, `{}`, `,`
|
||||
|
||||
Examples: `app-*`, `team-frontend-*`, `*-tmp`.
|
||||
|
||||
### Precedence cheat sheet
|
||||
|
||||
**Namespaces**
|
||||
|
||||
1. `BackupSpec.excludedNamespaces` — excluded namespaces are never backed up; namespace policies cannot override this.
|
||||
2. `namespacedFilterPolicies` — first matching pattern (exact match checked before globs in pattern order).
|
||||
3. No match — use global BackupSpec + `includeExcludePolicy`.
|
||||
|
||||
**Namespace-scoped resources (when a namespace policy matches)**
|
||||
|
||||
1. Global `includeExcludePolicy` exclusions (e.g. `excludedNamespaceScopedResources`) apply first.
|
||||
2. Only kinds in `resourceFilters` (or catch-all) are allowlisted for collection.
|
||||
3. Per-kind `labelSelector` / `orLabelSelectors` for API list calls.
|
||||
4. Per-kind `names` / `excludedNames` at backup write time.
|
||||
5. Label `velero.io/exclude-from-backup=true` always excludes.
|
||||
|
||||
**Cluster-scoped resources**
|
||||
|
||||
1. Must be allowed by `includedClusterScopedResources` / global cluster settings.
|
||||
2. If `clusterScopedFilterPolicy` lists the kind, apply its label and name rules.
|
||||
3. If not listed in `clusterScopedFilterPolicy`, use global BackupSpec filters.
|
||||
4. `velero.io/exclude-from-backup=true` always excludes.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
nsGlobal[BackupSpec namespace include/exclude]
|
||||
nsPolicy{namespacedFilterPolicies match?}
|
||||
nsAllow[Allowlist kinds + per-kind filters]
|
||||
nsGlobalFallback[Global BackupSpec + includeExcludePolicy]
|
||||
|
||||
nsGlobal --> nsPolicy
|
||||
nsPolicy -->|yes| nsAllow
|
||||
nsPolicy -->|no| nsGlobalFallback
|
||||
|
||||
csInclude[includedClusterScopedResources]
|
||||
csPolicy{kind in clusterScopedFilterPolicy?}
|
||||
csRefine[Per-kind label and name rules]
|
||||
csGlobal[Global cluster filters]
|
||||
|
||||
csInclude --> csPolicy
|
||||
csPolicy -->|yes| csRefine
|
||||
csPolicy -->|no| csGlobal
|
||||
```
|
||||
|
||||
### Catch-all summary
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Syntax | `kinds: ["*"]` or `kinds: []` |
|
||||
| Count | At most one catch-all per `namespacedFilterPolicies` entry |
|
||||
| Names | `names` / `excludedNames` not allowed on catch-all |
|
||||
| Override | Kind-specific entries take precedence over catch-all |
|
||||
| Label inheritance | Does not use `BackupSpec.labelSelector` |
|
||||
| Cluster-scoped | Catch-all **not** supported in `clusterScopedFilterPolicy` |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting and validation
|
||||
|
||||
### Verify a backup
|
||||
|
||||
```bash
|
||||
velero backup describe BACKUP_NAME
|
||||
velero backup logs BACKUP_NAME
|
||||
velero backup describe BACKUP_NAME -o json | jq '.namespacedFilterPolicies'
|
||||
velero backup describe BACKUP_NAME -o json | jq '.clusterScopedFilterPolicy'
|
||||
```
|
||||
|
||||
Catch-all entries appear as `<catch-all> (all other kinds)` in text output, or `"isCatchAll": true` in JSON.
|
||||
|
||||
### Common misconfigurations
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|----------------|-----|
|
||||
| Fewer resources than expected in `team-frontend-prod` | Broad namespace pattern listed before specific one | Reorder policies: most specific `namespaces` first |
|
||||
| Namespace policy lists Secrets but none in backup | `includeExcludePolicy` excludes `secrets` globally | Remove global exclusion or accept no Secrets |
|
||||
| `ClusterRole` in namespace policy has no effect | Cluster-scoped kind in `namespacedFilterPolicies` | Move rule to `clusterScopedFilterPolicy`; check logs for warning |
|
||||
| Backup fails at creation with filter message | Old-style `includedResources` with new policies | Move resource types to `includeExcludePolicy` in ConfigMap |
|
||||
| Catch-all does not use backup-wide label | By design | Set `labelSelector` on the catch-all entry |
|
||||
| Cluster-scoped policy validation error on `kinds: ["*"]` | Catch-all not allowed for cluster policy | List each cluster-scoped kind explicitly |
|
||||
|
||||
### Velero logs
|
||||
|
||||
```bash
|
||||
kubectl logs -n velero deployment/velero | grep -i "namespacedFilterPolicies\|clusterScopedFilterPolicy"
|
||||
kubectl logs -n velero deployment/velero | grep "globally excluded by includeExcludePolicy"
|
||||
kubectl logs -n velero deployment/velero | grep "cluster-scoped"
|
||||
```
|
||||
|
||||
### Validation errors (policy ConfigMap)
|
||||
|
||||
Velero validates the ResourcePolicy when a backup starts. Common errors:
|
||||
|
||||
| Error (summary) | Cause |
|
||||
|-----------------|--------|
|
||||
| `at least one namespace must be specified` | Empty `namespaces: []` |
|
||||
| `at least one resourceFilter must be specified` | Empty `resourceFilters: []` |
|
||||
| `names or excludedNames cannot be specified for catch-all filters` | Name patterns on catch-all entry |
|
||||
| `only one catch-all resource filter is allowed` | Multiple catch-alls in one policy entry |
|
||||
| `kind "X" appears in both resourceFilters[...]` | Same kind in two entries |
|
||||
| `labelSelector and orLabelSelectors cannot co-exist` | Both set in one entry |
|
||||
| `invalid label selector` | Bad operator, values, or label key/value syntax |
|
||||
| `duplicate namespace pattern` | Same namespace string in two policy entries |
|
||||
| `invalid glob pattern` | Bad characters in namespace or name pattern |
|
||||
| `clusterScopedFilterPolicy... kinds must be specified (catch-all is not supported)` | Empty or `["*"]` kinds in cluster policy |
|
||||
| `include-resources, exclude-resources... cannot be used with namespace-scoped or cluster-scoped global filter policies` | Old-style BackupSpec filters with new policy |
|
||||
|
||||
### Silent edge cases (no error)
|
||||
|
||||
- Namespace pattern matches no existing namespace — policy loaded but never applied.
|
||||
- Kind listed but no instances in namespace — empty result, backup still succeeds.
|
||||
- `excludedNames` narrows `names` — e.g. `names: ["app-*"]` + `excludedNames: ["app-config"]` excludes `app-config` only.
|
||||
|
||||
---
|
||||
|
||||
## Restore behavior
|
||||
|
||||
Restore is unchanged: it restores whatever is in the backup archive. Resources excluded by fine-grained filters are simply absent. Use `Restore.spec.includedNamespaces` (and existing restore filters) to limit what you restore from a partial backup.
|
||||
|
||||
Fine-grained resource filtering is also available on the restore path using `namespacedFilterPolicies` and `clusterScopedFilterPolicy`. For details on the restore-side policies, see the [Fine-grained restore filters design](https://github.com/velero-io/velero/blob/main/design/restore-filter-enhancement/fine-grained-restore-filters-design.md).
|
||||
|
||||
---
|
||||
|
||||
## Related links
|
||||
|
||||
- [Fine-grained backup filters design](https://github.com/velero-io/velero/blob/main/design/backup-filter-enhancement/fine-grained-backup-filters-design.md)
|
||||
@@ -19,11 +19,11 @@ Plugins the Velero core team is responsible include all those listed in [the Vel
|
||||
1. Once the PR is merged, checkout the upstream `main` branch. Your local upstream might be named `upstream` or `origin`, so use this command: `git checkout <upstream-name>/main`.
|
||||
1. Tag the git version - `git tag v<version>`.
|
||||
1. Push the git tag - `git push --tags <upstream-name>` to trigger the image build.
|
||||
2. Wait for the container images to build. You may check the progress of the GH action that triggers the image build at `https://github.com/vmware-tanzu/<plugin-name>/actions`
|
||||
2. Wait for the container images to build. You may check the progress of the GH action that triggers the image build at `https://github.com/velero-io/<plugin-name>/actions`
|
||||
3. Verify that an image with the new tag is available at `https://hub.docker.com/repository/docker/velero/<plugin-name>/`.
|
||||
4. Run the Velero [e2e tests][2] using the new image. Until it is made configurable, you will have to edit the [plugin version][1] in the test.
|
||||
### Release
|
||||
1. If all e2e tests pass, go to the GitHub release page of the plugin (`https://github.com/vmware-tanzu/<plugin-name>/releases`) and manually create a release for the new tag.
|
||||
1. If all e2e tests pass, go to the GitHub release page of the plugin (`https://github.com/velero-io/<plugin-name>/releases`) and manually create a release for the new tag.
|
||||
1. Copy and paste the content of the new changelog file into the release description field.
|
||||
|
||||
[1]: https://github.com/velero-io/velero/blob/c8dfd648bbe85db0184ea53296de4220895497e6/test/e2e/velero_utils.go#L27
|
||||
|
||||
@@ -40,4 +40,4 @@ Generally speaking, new GitHub issues will fall into one of several categories.
|
||||
- If the issue ends up being a feature request or a bug, update the title and follow the appropriate process for it
|
||||
- If the reporter becomes unresponsive after multiple pings, close out the issue due to inactivity and comment that the user can always reach out again as needed
|
||||
|
||||
[0]: https://github.com/vmware-tanzu?q=velero&type=&language=
|
||||
[0]: https://github.com/velero-io?q=velero&type=&language=
|
||||
|
||||
@@ -33,6 +33,8 @@ toc:
|
||||
url: /enable-api-group-versions-feature
|
||||
- page: Resource filtering
|
||||
url: /resource-filtering
|
||||
- page: Fine-Grained Backup Filters
|
||||
url: /fine-grained-backup-filters
|
||||
- page: Namespace glob patterns
|
||||
url: /namespace-glob-patterns
|
||||
- page: Backup reference
|
||||
|
||||
+2
-1
@@ -48,6 +48,7 @@ GOBIN := $(REPO_ROOT)/.go/bin
|
||||
TOOLS_BIN_DIR := $(TOOLS_DIR)/$(BIN_DIR)
|
||||
|
||||
GINKGO := $(GOBIN)/ginkgo
|
||||
GINKGO_VERSION := $(shell go list -m -f '{{.Version}}' github.com/onsi/ginkgo/v2 2>/dev/null)
|
||||
|
||||
KUSTOMIZE := $(TOOLS_BIN_DIR)/kustomize
|
||||
|
||||
@@ -186,7 +187,7 @@ ginkgo: ${GOBIN}/ginkgo
|
||||
|
||||
# This target does not run if ginkgo is already in $GOBIN
|
||||
${GOBIN}/ginkgo:
|
||||
GOBIN=${GOBIN} go install github.com/onsi/ginkgo/v2/ginkgo@v2.22.0
|
||||
GOBIN=${GOBIN} go install github.com/onsi/ginkgo/v2/ginkgo@${GINKGO_VERSION}
|
||||
|
||||
.PHONY: run-e2e
|
||||
run-e2e: ginkgo
|
||||
|
||||
Reference in New Issue
Block a user