Merge branch 'main' into optimize-sub-object-description

This commit is contained in:
Lyndon-Li
2026-07-28 15:14:15 +08:00
94 changed files with 3779 additions and 1335 deletions
+1 -1
View File
@@ -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
-22
View File
@@ -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 }}
+22 -2
View File
@@ -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
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
-148
View File
@@ -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.
-3
View File
@@ -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
View File
@@ -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.
+7 -2
View File
@@ -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
View File
@@ -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 its 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.
-7
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Fix issue #9973, fail earlier when PVR pod is not ready
+1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Backup workflow for block data mover.
+1
View File
@@ -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
+1
View File
@@ -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.
+3 -3
View File
@@ -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
+6 -6
View File
@@ -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=
+6 -9
View File
@@ -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
+79 -9
View File
@@ -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",
},
+22
View File
@@ -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"
)
+8
View File
@@ -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
+12
View File
@@ -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,
},
}
+140 -12
View File
@@ -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)
})
}
}
+7 -9
View File
@@ -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
View File
@@ -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"}},
},
},
},
+5 -1
View File
@@ -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()
})
},
}
+5 -1
View File
@@ -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()
})
},
}
+5 -1
View File
@@ -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()
})
},
}
+5 -1
View File
@@ -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()
})
},
}
+5 -1
View File
@@ -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)
})
},
}
-118
View File
@@ -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))
}
+59 -43
View File
@@ -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
+43
View File
@@ -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) {
+2 -2
View File
@@ -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
}
+55 -22
View File
@@ -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 {
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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")
}
+2 -2
View File
@@ -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
+5 -2
View File
@@ -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
}
+10
View File
@@ -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",
+5 -1
View File
@@ -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")
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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()
+5 -5
View File
@@ -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)
}
+1 -1
View File
@@ -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()
+4 -1
View File
@@ -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))
}
}
}
+9
View File
@@ -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])
+8
View File
@@ -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 {
+27
View File
@@ -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 {
+48 -1
View File
@@ -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 {
+37 -5
View File
@@ -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 {
+1 -1
View File
@@ -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")
}
+2 -2
View File
@@ -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
+19 -3
View File
@@ -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
View File
@@ -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)
}
+40 -3
View File
@@ -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",
+414
View File
@@ -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.
+15 -2
View File
@@ -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)
}
+244 -4
View File
@@ -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")
+228
View File
@@ -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)
})
}
+12 -1
View File
@@ -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
+68
View File
@@ -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))
})
}
}
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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.
+26
View File
@@ -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
+1 -1
View File
@@ -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=
+2
View File
@@ -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
View File
@@ -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