diff --git a/.clomonitor.yml b/.clomonitor.yml new file mode 100644 index 000000000..eb0d59284 --- /dev/null +++ b/.clomonitor.yml @@ -0,0 +1,7 @@ +# CLOMonitor metadata file +# https://github.com/cncf/clomonitor/blob/main/docs/metadata/.clomonitor.yml + +# Checks exemptions +exemptions: + - check: artifacthub_badge + reason: "Velero is not distributed as an Artifact Hub package. The Velero core project is consumed via container images and release binaries, and the community-maintained Helm chart is published from a separate repository (vmware-tanzu/helm-charts). There is no Artifact Hub listing for this repository." diff --git a/.github/workflows/get-go-version.yaml b/.github/workflows/get-go-version.yaml index fa4fb5e00..d77ed0af1 100644 --- a/.github/workflows/get-go-version.yaml +++ b/.github/workflows/get-go-version.yaml @@ -10,6 +10,9 @@ on: description: "The expected Go version" value: ${{ jobs.extract.outputs.version }} +permissions: + contents: read + jobs: extract: runs-on: ubuntu-latest diff --git a/.github/workflows/nightly-trivy-scan.yml b/.github/workflows/nightly-trivy-scan.yml index 09dcdbf4f..0999b1c0f 100644 --- a/.github/workflows/nightly-trivy-scan.yml +++ b/.github/workflows/nightly-trivy-scan.yml @@ -3,6 +3,9 @@ on: schedule: - cron: '0 2 * * *' # run at 2 AM UTC +permissions: + contents: read + jobs: nightly-scan: name: Trivy nightly scan @@ -15,6 +18,7 @@ jobs: # list of images that need scan images: [velero, velero-plugin-for-aws, velero-plugin-for-gcp, velero-plugin-for-microsoft-azure] permissions: + contents: read # for actions/checkout to fetch code security-events: write # for github/codeql-action/upload-sarif to upload SARIF results steps: diff --git a/.github/workflows/pr-changelog-check.yml b/.github/workflows/pr-changelog-check.yml index 67c1a6221..8f2f59733 100644 --- a/.github/workflows/pr-changelog-check.yml +++ b/.github/workflows/pr-changelog-check.yml @@ -4,6 +4,11 @@ name: Pull Request Changelog Check on: pull_request: types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + pull-requests: read + jobs: build: @@ -16,5 +21,6 @@ jobs: uses: actions/checkout@v7 - name: Changelog check - if: ${{ !(contains(github.event.pull_request.labels.*.name, 'kind/changelog-not-required') || contains(github.event.pull_request.labels.*.name, 'Design') || contains(github.event.pull_request.labels.*.name, 'Website') || contains(github.event.pull_request.labels.*.name, 'Documentation'))}} + env: + GH_TOKEN: ${{ github.token }} run: ./hack/changelog-check.sh diff --git a/.github/workflows/pr-ci-check.yml b/.github/workflows/pr-ci-check.yml index fd5948f7b..8f431af63 100644 --- a/.github/workflows/pr-ci-check.yml +++ b/.github/workflows/pr-ci-check.yml @@ -1,5 +1,9 @@ name: Pull Request CI Check on: [pull_request] + +permissions: + contents: read + jobs: get-go-version: uses: ./.github/workflows/get-go-version.yaml diff --git a/.github/workflows/pr-codespell.yml b/.github/workflows/pr-codespell.yml index 97cdb48d4..bd12201ca 100644 --- a/.github/workflows/pr-codespell.yml +++ b/.github/workflows/pr-codespell.yml @@ -1,5 +1,9 @@ name: Pull Request Codespell Check on: [pull_request] + +permissions: + contents: read + jobs: codespell: diff --git a/.github/workflows/pr-containers.yml b/.github/workflows/pr-containers.yml index b615d2b8e..555a68aa1 100644 --- a/.github/workflows/pr-containers.yml +++ b/.github/workflows/pr-containers.yml @@ -9,6 +9,9 @@ on: - 'Dockerfile' - 'Dockerfile-Windows' +permissions: + contents: read + jobs: build: name: Build diff --git a/.github/workflows/pr-filepath-check.yml b/.github/workflows/pr-filepath-check.yml index 5ec2cb03b..45954873a 100644 --- a/.github/workflows/pr-filepath-check.yml +++ b/.github/workflows/pr-filepath-check.yml @@ -1,5 +1,9 @@ name: Pull Request File Path Check on: [pull_request] + +permissions: + contents: read + jobs: filepath-check: diff --git a/.github/workflows/pr-goreleaser.yml b/.github/workflows/pr-goreleaser.yml index 0cbec3329..b93d0626c 100644 --- a/.github/workflows/pr-goreleaser.yml +++ b/.github/workflows/pr-goreleaser.yml @@ -9,6 +9,9 @@ on: - '.goreleaser.yml' - 'hack/release-tools/goreleaser.sh' +permissions: + contents: read + jobs: build: name: Build diff --git a/.github/workflows/pr-linter-check.yml b/.github/workflows/pr-linter-check.yml index 1a25569f1..57058757f 100644 --- a/.github/workflows/pr-linter-check.yml +++ b/.github/workflows/pr-linter-check.yml @@ -6,6 +6,10 @@ on: - "site/**" - "design/**" - "**/*.md" + +permissions: + contents: read + jobs: get-go-version: uses: ./.github/workflows/get-go-version.yaml diff --git a/.github/workflows/push-builder.yml b/.github/workflows/push-builder.yml index 164d9104a..7dd0a9ab4 100644 --- a/.github/workflows/push-builder.yml +++ b/.github/workflows/push-builder.yml @@ -6,6 +6,9 @@ on: paths: - 'hack/build-image/Dockerfile' +permissions: + contents: read + jobs: build: name: Build diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index b66a339c8..16ef764b7 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -3,6 +3,11 @@ on: schedule: - cron: "30 1 * * *" # Every day at 1:30 UTC +permissions: + contents: read + issues: write + pull-requests: write + jobs: stale: if: github.repository == 'velero-io/velero' diff --git a/.gitignore b/.gitignore index ce62679f1..a20b16f13 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,7 @@ tilt-resources/cloud # test generated files test/e2e/report.xml +test/e2e/debug-bundle*.tar.gz coverage.out __debug_bin* debug.test* diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 2789a573d..f5e43afa5 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,6 +1,6 @@ # Velero Maintainers -[GOVERNANCE.md](https://github.com/vmware-tanzu/velero/blob/main/GOVERNANCE.md) describes governance guidelines and maintainer responsibilities. +[GOVERNANCE.md](https://github.com/velero-io/.github/blob/main/GOVERNANCE.md) describes governance guidelines and maintainer responsibilities. ## Maintainers diff --git a/README.md b/README.md index 2357be2bb..9acdf18a8 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,25 @@ Please use the version selector at the top of the site to ensure you are using t If you encounter issues, review the [troubleshooting docs][30], [file an issue][4], or talk to us on the [#velero channel][25] on the Kubernetes Slack server. +## Community + +Velero is an open community and we welcome your participation. The best way to get involved is to join our bi-weekly community meetings: + +* Join the [Velero community meetings](https://velero.io/community/), held bi-weekly, alternating between Beijing-friendly and US/Europe-friendly time zones. +* Subscribe to the [project meeting calendar](https://zoom-lfx.platform.linuxfoundation.org/meetings/velero?view=week). +* Watch previous meetings on our [YouTube channel](https://www.youtube.com/playlist?list=PL7bmigfV0EqQRysvqvqOtRNk4L5S7uqwM). +* Chat with us on the [Kubernetes Slack][25] `#velero` channel and join the [mailing list][24]. + +See the [community page](https://velero.io/community/) for the full schedule and details. + ## Contributing If you are ready to jump in and test, add code, or help with documentation, follow the instructions on our [Start contributing][31] documentation for guidance on how to setup Velero for development. +## Governance + +Velero's [governance][32] describes how the project is run, including the decision-making process, the roles and responsibilities of maintainers, and how to become a maintainer. Governance applies across the Velero org and is maintained at [velero-io/.github][32]. + ## Changelog See [the list of releases][6] to find out about feature changes. @@ -87,4 +102,5 @@ For website terms of use, trademark policy and other project policies please see [29]: https://velero.io/docs/ [30]: https://velero.io/docs/troubleshooting [31]: https://velero.io/docs/start-contributing +[32]: https://github.com/velero-io/.github/blob/main/GOVERNANCE.md [100]: https://velero.io/docs/main/img/velero.png diff --git a/SECURITY-INSIGHTS.yml b/SECURITY-INSIGHTS.yml new file mode 100644 index 000000000..4389e7a2d --- /dev/null +++ b/SECURITY-INSIGHTS.yml @@ -0,0 +1,101 @@ +header: + schema-version: 2.2.0 + last-updated: '2026-09-02' + last-reviewed: '2026-09-02' + url: https://github.com/velero-io/velero/blob/main/SECURITY-INSIGHTS.yml + comment: | + OpenSSF Security Insights manifest for the Velero project. + +project: + name: Velero + homepage: https://velero.io + roadmap: https://github.com/velero-io/velero/blob/main/ROADMAP.md + administrators: + - name: Scott Seago + affiliation: Red Hat + primary: false + - name: Daniel Jiang + affiliation: Broadcom + primary: true + - name: Wenkai Yin + affiliation: Broadcom + primary: false + - name: Xun Jiang + affiliation: Broadcom + primary: false + - name: Shubham Pampattiwar + affiliation: Red Hat + primary: false + - name: Yonghui Li + affiliation: Broadcom + primary: false + - name: Anshul Ahuja + affiliation: Microsoft Azure + primary: false + - name: Tiger Kaovilai + affiliation: Red Hat + primary: false + documentation: + detailed-guide: https://velero.io/docs/ + repositories: + - name: velero + url: https://github.com/velero-io/velero + comment: | + velero is the core repository for the Velero project. + vulnerability-reporting: + reports-accepted: true + bug-bounty-available: false + contact: + name: Velero Security Team + email: cncf-velero-security@lists.cncf.io + primary: true + comment: | + Report vulnerabilities privately to the Velero Security Team by email or + via GitHub private vulnerability reporting on the repository Security tab. + See the security policy for full details. + +repository: + url: https://github.com/velero-io/velero + status: active + accepts-change-request: true + accepts-automated-change-request: true + core-team: + - name: Scott Seago + affiliation: Red Hat + primary: false + - name: Daniel Jiang + affiliation: Broadcom + primary: true + - name: Wenkai Yin + affiliation: Broadcom + primary: false + - name: Xun Jiang + affiliation: Broadcom + primary: false + - name: Shubham Pampattiwar + affiliation: Red Hat + primary: false + - name: Yonghui Li + affiliation: Broadcom + primary: false + - name: Anshul Ahuja + affiliation: Microsoft Azure + primary: false + - name: Tiger Kaovilai + affiliation: Red Hat + primary: false + license: + url: https://github.com/velero-io/velero/blob/main/LICENSE + expression: Apache-2.0 + documentation: + contributing-guide: https://velero.io/docs/main/start-contributing/ + governance: https://github.com/velero-io/.github/blob/main/GOVERNANCE.md + security-policy: https://github.com/velero-io/.github/blob/main/SECURITY.md + dependency-management-policy: https://github.com/velero-io/velero/blob/main/site/content/docs/main/development.md#dependency-management + security: + assessments: + self: + comment: | + A formal third-party security assessment has not yet been completed. + The project follows the CNCF security disclosure and response process + documented in the security policy. diff --git a/changelogs/unreleased/10475-chlins b/changelogs/unreleased/10475-chlins new file mode 100644 index 000000000..19ecba1ff --- /dev/null +++ b/changelogs/unreleased/10475-chlins @@ -0,0 +1 @@ +Add in-place restore pre-flight check: PVC must be bound to the backed-up PV diff --git a/changelogs/unreleased/10477-krishhna24 b/changelogs/unreleased/10477-krishhna24 new file mode 100644 index 000000000..4ee7a880f --- /dev/null +++ b/changelogs/unreleased/10477-krishhna24 @@ -0,0 +1 @@ +Ignore e2e debug bundles in .gitignore diff --git a/changelogs/unreleased/9983-prajoshi b/changelogs/unreleased/9983-prajoshi new file mode 100644 index 000000000..b496f4d6b --- /dev/null +++ b/changelogs/unreleased/9983-prajoshi @@ -0,0 +1 @@ +Add structured JSON output support for velero restore describe command diff --git a/design/volume-data-inplace-restore/volume-data-inplace-restore.md b/design/volume-data-inplace-restore/volume-data-inplace-restore.md index 4977c09d1..d6022084d 100644 --- a/design/volume-data-inplace-restore/volume-data-inplace-restore.md +++ b/design/volume-data-inplace-restore/volume-data-inplace-restore.md @@ -245,7 +245,11 @@ The check runs on both restore paths before any side effect on the existing PVC/ This check is a fail-fast validation, not an atomic guarantee; the `pvc-protection` finalizer remains the actual safety gate for PVC deletion. A residual `VolumeAttachment` check (e.g. a `Failed` Pod imposed by the control plane after a non-graceful node shutdown, where the node never unmounted the volume) may be added as a future enhancement. #### 2. PVC is Bound to the Original PV -Velero checks whether the existing PVC in the cluster is still bound to the same PersistentVolume (PV) it was bound to at the time of the backup. If the PVC is bound to a different PV, performing an in-place restore (especially an incremental one that relies on Changed Block Tracking) may be unsafe or result in unpredictable behavior. If this check fails, Velero will log an error and skip the in-place restore for that volume. +Velero checks whether the existing PVC in the cluster is still bound to the same PersistentVolume (PV) it was bound to at the time of the backup (compared by PV name against the backed-up PVC's `spec.volumeName`). If the PVC is bound to a different PV, performing an in-place restore (especially an incremental one that relies on Changed Block Tracking) may be unsafe or result in unpredictable behavior. If this check fails, Velero will log an error and skip the in-place restore for that volume. + +The PV comparison is skipped when the PVC is restored into a mapped namespace, where the target PVC is necessarily bound to a different PV (see [Namespace Mapping](#namespace-mapping) for the cross-namespace clone-and-restore workflow). The PVC must still be bound in all cases. + +The check runs on both restore paths: in the PVC CSI RIA (using the backed-up PVC's volume name), and before creating the `PodVolumeRestore` on the file system path (using the PVC-to-PV mapping recorded in the backup's volume info). #### 3. Volume Size Validation diff --git a/hack/changelog-check.sh b/hack/changelog-check.sh index f2e2fb0f9..1a3d588e1 100755 --- a/hack/changelog-check.sh +++ b/hack/changelog-check.sh @@ -28,6 +28,22 @@ CHANGELOG_PATH='changelogs/unreleased' # GITHUB_REF is something like "refs/pull/:prNumber/merge" pr_number=$(echo $GITHUB_REF | cut -d / -f 3) +# Some kinds of pull requests do not require a changelog entry. Rather than +# relying on the (frozen) event payload, query the PR's current labels so the +# check reflects the latest state. This makes re-runs and labels added after +# the initial run behave correctly. +EXEMPT_LABELS=("kind/changelog-not-required" "Design" "Website" "Documentation") + +if command -v gh > /dev/null 2>&1; then + current_labels=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.labels[].name' 2>/dev/null || true) + for label in "${EXEMPT_LABELS[@]}"; do + if grep -Fxq "$label" <<< "$current_labels"; then + echo "PR ${pr_number} has the '${label}' label; changelog not required." + exit 0 + fi + done +fi + change_log_file="${CHANGELOG_PATH}/${pr_number}-*" if ls ${change_log_file} 1> /dev/null 2>&1; then diff --git a/pkg/cmd/cli/backup/describe.go b/pkg/cmd/cli/backup/describe.go index dd819edd1..5235f9f9c 100644 --- a/pkg/cmd/cli/backup/describe.go +++ b/pkg/cmd/cli/backup/describe.go @@ -56,7 +56,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { cmd.CheckError(err) if outputFormat != "plaintext" && outputFormat != "json" { - cmd.CheckError(fmt.Errorf("invalid output format '%s'. valid value are 'plaintext, json'", outputFormat)) + cmd.CheckError(fmt.Errorf("invalid output format '%s'. valid values are 'plaintext' and 'json'", outputFormat)) } backups := new(velerov1api.BackupList) @@ -118,7 +118,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { c.Flags().BoolVar(&details, "details", details, "Display additional detail in the command output.") c.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", insecureSkipTLSVerify, "If true, the object store's TLS certificate will not be checked for validity. This is insecure and susceptible to man-in-the-middle attacks. Not recommended for production.") c.Flags().StringVar(&caCertFile, "cacert", caCertFile, "Path to a certificate bundle to use when verifying TLS connections. If not specified, the CA certificate from the BackupStorageLocation will be used if available.") - c.Flags().StringVarP(&outputFormat, "output", "o", outputFormat, "Output display format. Valid formats are 'plaintext, json'. 'json' only applies to a single backup") + c.Flags().StringVarP(&outputFormat, "output", "o", outputFormat, "Output display format. Valid formats are 'plaintext' and 'json'. 'json' only applies to a single backup") return c } diff --git a/pkg/cmd/cli/restore/describe.go b/pkg/cmd/cli/restore/describe.go index 7fc58ce22..a6cc4c59c 100644 --- a/pkg/cmd/cli/restore/describe.go +++ b/pkg/cmd/cli/restore/describe.go @@ -39,6 +39,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { listOptions metav1.ListOptions details bool insecureSkipTLSVerify bool + outputFormat = "plaintext" ) config, err := client.LoadConfig() @@ -54,6 +55,10 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { kbClient, err := f.KubebuilderClient() cmd.CheckError(err) + if outputFormat != "plaintext" && outputFormat != "json" { + cmd.CheckError(fmt.Errorf("invalid output format '%s'. valid values are 'plaintext' and 'json'", outputFormat)) + } + restoreList := new(velerov1api.RestoreList) if len(args) > 0 { for _, name := range args { @@ -81,12 +86,19 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { fmt.Fprintf(os.Stderr, "error getting PodVolumeRestores for restore %s: %v\n", restore.Name, err) } - s := output.DescribeRestore(context.Background(), kbClient, &restoreList.Items[i], podVolumeRestoreList.Items, details, insecureSkipTLSVerify, caCertFile) - if first { - first = false + // structured output only applies to a single restore in case of OOM + // To describe a list of restores in structured format, iterate and describe one at a time. + if len(restoreList.Items) == 1 && outputFormat != "plaintext" { + s := output.DescribeRestoreInSF(context.Background(), kbClient, &restoreList.Items[i], podVolumeRestoreList.Items, details, insecureSkipTLSVerify, caCertFile, outputFormat) fmt.Print(s) } else { - fmt.Printf("\n\n%s", s) + s := output.DescribeRestore(context.Background(), kbClient, &restoreList.Items[i], podVolumeRestoreList.Items, details, insecureSkipTLSVerify, caCertFile) + if first { + first = false + fmt.Print(s) + } else { + fmt.Printf("\n\n%s", s) + } } } cmd.CheckError(err) @@ -98,6 +110,7 @@ func NewDescribeCommand(f client.Factory, use string) *cobra.Command { c.Flags().BoolVar(&details, "details", details, "Display additional detail in the command output.") c.Flags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", insecureSkipTLSVerify, "If true, the object store's TLS certificate will not be checked for validity. This is insecure and susceptible to man-in-the-middle attacks. Not recommended for production.") c.Flags().StringVar(&caCertFile, "cacert", caCertFile, "Path to a certificate bundle to use when verifying TLS connections.") + c.Flags().StringVarP(&outputFormat, "output", "o", outputFormat, "Output display format. Valid formats are 'plaintext' and 'json'. 'json' only applies to a single restore") return c } diff --git a/pkg/cmd/util/output/restore_structured_describer.go b/pkg/cmd/util/output/restore_structured_describer.go new file mode 100644 index 000000000..106a43541 --- /dev/null +++ b/pkg/cmd/util/output/restore_structured_describer.go @@ -0,0 +1,542 @@ +/* +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 output + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + corev1api "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + kbclient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/vmware-tanzu/velero/internal/volume" + velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/cmd/util/cacert" + "github.com/vmware-tanzu/velero/pkg/cmd/util/downloadrequest" + "github.com/vmware-tanzu/velero/pkg/itemoperation" + "github.com/vmware-tanzu/velero/pkg/util/boolptr" + "github.com/vmware-tanzu/velero/pkg/util/results" +) + +// DescribeRestoreInSF describes a restore in structured format. +func DescribeRestoreInSF( + ctx context.Context, + kbClient kbclient.Client, + restore *velerov1api.Restore, + podVolumeRestores []velerov1api.PodVolumeRestore, + details bool, + insecureSkipTLSVerify bool, + caCertFile string, + outputFormat string, +) string { + return DescribeInSF(func(d *StructuredDescriber) { + d.DescribeMetadata(restore.ObjectMeta) + + phase := restore.Status.Phase + if phase == "" { + phase = velerov1api.RestorePhaseNew + } + phaseString := string(phase) + if !restore.DeletionTimestamp.IsZero() { + phaseString += " (Deleting)" + } + d.Describe("phase", phaseString) + + describeRestoreProgressInSF(d, restore) + describeRestoreTimestampsInSF(d, restore) + + if len(restore.Status.ValidationErrors) > 0 { + d.Describe("validationErrors", restore.Status.ValidationErrors) + } + + describeRestoreResultsInSF(ctx, kbClient, d, restore, insecureSkipTLSVerify, caCertFile) + + describeRestoreSpecInSF(d, restore.Spec) + + describePodVolumeRestoresInSF(d, podVolumeRestores, details) + + describeRestoreCSISnapshotsInSF(ctx, kbClient, d, restore, details, insecureSkipTLSVerify, caCertFile) + + describeRestoreItemOperationsInSF(ctx, kbClient, d, restore, details, insecureSkipTLSVerify, caCertFile) + + if restore.Status.HookStatus != nil { + hookStatus := map[string]any{ + "hooksAttempted": restore.Status.HookStatus.HooksAttempted, + "hooksFailed": restore.Status.HookStatus.HooksFailed, + } + d.Describe("hookStatus", hookStatus) + } + + if details { + describeRestoreResourceListInSF(ctx, kbClient, d, restore, insecureSkipTLSVerify, caCertFile) + } + }, outputFormat) +} + +func describeRestoreProgressInSF(d *StructuredDescriber, restore *velerov1api.Restore) { + if restore.Status.Progress == nil { + return + } + progress := map[string]any{} + if restore.Status.Phase == velerov1api.RestorePhaseInProgress { + progress["estimatedTotalItemsToBeRestored"] = restore.Status.Progress.TotalItems + progress["itemsRestoredSoFar"] = restore.Status.Progress.ItemsRestored + } else { + progress["totalItemsToBeRestored"] = restore.Status.Progress.TotalItems + progress["itemsRestored"] = restore.Status.Progress.ItemsRestored + } + d.Describe("progress", progress) +} + +func describeRestoreTimestampsInSF(d *StructuredDescriber, restore *velerov1api.Restore) { + timestamps := map[string]any{} + if restore.Status.StartTimestamp == nil || restore.Status.StartTimestamp.IsZero() { + timestamps["started"] = "" + } else { + timestamps["started"] = restore.Status.StartTimestamp.String() + } + if restore.Status.CompletionTimestamp == nil || restore.Status.CompletionTimestamp.IsZero() { + timestamps["completed"] = "" + } else { + timestamps["completed"] = restore.Status.CompletionTimestamp.String() + } + d.Describe("timestamps", timestamps) +} + +func describeRestoreSpecInSF(d *StructuredDescriber, spec velerov1api.RestoreSpec) { + specInfo := map[string]any{} + + specInfo["backupName"] = spec.BackupName + + // namespaces + namespaceInfo := map[string]any{} + var s string + if len(spec.IncludedNamespaces) == 0 || (len(spec.IncludedNamespaces) == 1 && spec.IncludedNamespaces[0] == "*") { + s = "all namespaces found in the backup" + } else { + s = strings.Join(spec.IncludedNamespaces, ", ") + } + namespaceInfo["included"] = s + if len(spec.ExcludedNamespaces) == 0 { + s = emptyDisplay + } else { + s = strings.Join(spec.ExcludedNamespaces, ", ") + } + namespaceInfo["excluded"] = s + specInfo["namespaces"] = namespaceInfo + + // resources + resourcesInfo := map[string]string{} + if len(spec.IncludedResources) == 0 { + s = "*" + } else { + s = strings.Join(spec.IncludedResources, ", ") + } + resourcesInfo["included"] = s + if len(spec.ExcludedResources) == 0 { + s = emptyDisplay + } else { + s = strings.Join(spec.ExcludedResources, ", ") + } + resourcesInfo["excluded"] = s + resourcesInfo["clusterScoped"] = BoolPointerString(spec.IncludeClusterResources, "excluded", "included", "auto") + specInfo["resources"] = resourcesInfo + + // namespace mappings + if len(spec.NamespaceMapping) > 0 { + specInfo["namespaceMappings"] = spec.NamespaceMapping + } else { + specInfo["namespaceMappings"] = emptyDisplay + } + + // label selector + s = emptyDisplay + if spec.LabelSelector != nil { + s = metav1.FormatLabelSelector(spec.LabelSelector) + } + specInfo["labelSelector"] = s + + // or label selectors + if len(spec.OrLabelSelectors) == 0 { + specInfo["orLabelSelectors"] = emptyDisplay + } else { + orSelectors := make([]string, 0, len(spec.OrLabelSelectors)) + for _, v := range spec.OrLabelSelectors { + orSelectors = append(orSelectors, metav1.FormatLabelSelector(v)) + } + specInfo["orLabelSelectors"] = strings.Join(orSelectors, " or ") + } + + specInfo["restorePVs"] = BoolPointerString(spec.RestorePVs, "false", "true", "auto") + + // existing resource policy + if spec.ExistingResourcePolicy != "" { + specInfo["existingResourcePolicy"] = string(spec.ExistingResourcePolicy) + } else { + specInfo["existingResourcePolicy"] = emptyDisplay + } + + specInfo["itemOperationTimeout"] = spec.ItemOperationTimeout.Duration.String() + specInfo["preserveNodePorts"] = BoolPointerString(spec.PreserveNodePorts, "false", "true", "auto") + + // resource modifier + if spec.ResourceModifier != nil { + specInfo["resourceModifier"] = describeResourceModifierInSF(spec.ResourceModifier) + } + + // resource policy + if spec.ResourcePolicy != nil { + specInfo["resourcePolicy"] = map[string]any{ + "type": spec.ResourcePolicy.Kind, + "name": spec.ResourcePolicy.Name, + } + } + + // uploader config + if spec.UploaderConfig != nil { + uploaderConfig := map[string]any{} + if boolptr.IsSetToTrue(spec.UploaderConfig.WriteSparseFiles) { + uploaderConfig["writeSparseFiles"] = true + } + if spec.UploaderConfig.ParallelFilesDownload > 0 { + uploaderConfig["parallelFilesDownload"] = spec.UploaderConfig.ParallelFilesDownload + } + specInfo["uploaderConfig"] = uploaderConfig + } + + d.Describe("spec", specInfo) +} + +func describeResourceModifierInSF(resModifier *corev1api.TypedLocalObjectReference) map[string]any { + return map[string]any{ + "type": resModifier.Kind, + "name": resModifier.Name, + } +} + +func describePodVolumeRestoresInSF(d *StructuredDescriber, restores []velerov1api.PodVolumeRestore, details bool) { + if len(restores) == 0 { + d.Describe("podVolumeRestores", "") + return + } + + uploaderType := restores[0].Spec.UploaderType + podVolumeInfo := map[string]any{ + "uploaderType": uploaderType, + } + + restoresByPhase := groupRestoresByPhase(restores) + + for _, phase := range []string{ + string(velerov1api.PodVolumeRestorePhaseCompleted), + string(velerov1api.PodVolumeRestorePhaseCanceled), + string(velerov1api.PodVolumeRestorePhaseFailed), + "In Progress", + string(velerov1api.PodVolumeRestorePhasePrepared), + string(velerov1api.PodVolumeRestorePhaseAccepted), + string(velerov1api.PodVolumeRestorePhaseNew), + } { + if len(restoresByPhase[phase]) == 0 { + continue + } + if !details { + podVolumeInfo[phase] = len(restoresByPhase[phase]) + continue + } + + restoresByPod := new(volumesByPod) + for _, restore := range restoresByPhase[phase] { + restoresByPod.Add(restore.Spec.Pod.Namespace, restore.Spec.Pod.Name, restore.Spec.Volume, phase, restore.Status.Progress, nil) + } + + podEntries := make([]map[string]string, 0) + for _, restoreGroup := range restoresByPod.Sorted() { + podEntries = append(podEntries, map[string]string{ + restoreGroup.label: strings.Join(restoreGroup.volumes, ", "), + }) + } + podVolumeInfo[phase] = podEntries + } + + d.Describe("podVolumeRestores", podVolumeInfo) +} + +func describeRestoreCSISnapshotsInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, restore *velerov1api.Restore, details bool, insecureSkipTLSVerify bool, caCertFile string) { + bslCACert, err := cacert.GetCACertFromRestore(ctx, kbClient, restore.Namespace, restore) + if err != nil { + bslCACert = "" + } + + buf := new(bytes.Buffer) + if err := downloadrequest.StreamWithBSLCACert(ctx, kbClient, restore.Namespace, restore.Name, velerov1api.DownloadTargetKindRestoreVolumeInfo, + buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertFile, bslCACert); err != nil { + if !errors.Is(err, downloadrequest.ErrNotFound) { + d.Describe("csiSnapshotRestores", fmt.Sprintf("", err)) + } + return + } + + describeCSISnapshotsRestoresFromReader(d, buf, details) +} + +func describeCSISnapshotsRestoresFromReader(d *StructuredDescriber, r io.Reader, details bool) { + var restoreVolInfo []volume.RestoreVolumeInfo + if err := json.NewDecoder(r).Decode(&restoreVolInfo); err != nil { + d.Describe("csiSnapshotRestores", fmt.Sprintf("", err)) + return + } + describeCSISnapshotsRestoresInSF(d, restoreVolInfo, details) +} + +func describeCSISnapshotsRestoresInSF(d *StructuredDescriber, restoreVolInfo []volume.RestoreVolumeInfo, details bool) { + var nonDMInfoList, dmInfoList []volume.RestoreVolumeInfo + for _, info := range restoreVolInfo { + if info.RestoreMethod != volume.CSISnapshot { + continue + } + if info.SnapshotDataMoved { + dmInfoList = append(dmInfoList, info) + } else { + nonDMInfoList = append(nonDMInfoList, info) + } + } + + if len(nonDMInfoList) == 0 && len(dmInfoList) == 0 { + d.Describe("csiSnapshotRestores", "") + return + } + + csiRestores := map[string]any{} + + for _, info := range nonDMInfoList { + key := fmt.Sprintf("%s/%s", info.PVCNamespace, info.PVCName) + if details { + if info.CSISnapshotInfo == nil { + csiRestores[key] = map[string]any{ + "snapshot": "", + } + continue + } + csiRestores[key] = map[string]any{ + "snapshot": map[string]any{ + "snapshotContentName": info.CSISnapshotInfo.VSCName, + "storageSnapshotID": info.CSISnapshotInfo.SnapshotHandle, + "csiDriver": info.CSISnapshotInfo.Driver, + }, + } + } else { + csiRestores[key] = map[string]any{ + "snapshot": "specify --details for more information", + } + } + } + + for _, info := range dmInfoList { + key := fmt.Sprintf("%s/%s", info.PVCNamespace, info.PVCName) + if details { + if info.SnapshotDataMovementInfo == nil { + csiRestores[key] = map[string]any{ + "dataMovement": "", + } + continue + } + csiRestores[key] = map[string]any{ + "dataMovement": map[string]any{ + "operationID": info.SnapshotDataMovementInfo.OperationID, + "dataMover": info.SnapshotDataMovementInfo.DataMover, + "uploaderType": info.SnapshotDataMovementInfo.UploaderType, + }, + } + } else { + csiRestores[key] = map[string]any{ + "dataMovement": "specify --details for more information", + } + } + } + + d.Describe("csiSnapshotRestores", csiRestores) +} + +func describeRestoreResultsInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, restore *velerov1api.Restore, insecureSkipTLSVerify bool, caCertPath string) { + if restore.Status.Warnings == 0 && restore.Status.Errors == 0 { + return + } + + bslCACert, err := cacert.GetCACertFromRestore(ctx, kbClient, restore.Namespace, restore) + if err != nil { + bslCACert = "" + } + + var buf bytes.Buffer + + warnings, errs := make(map[string]any), make(map[string]any) + defer func() { + if restore.Status.Warnings > 0 { + d.Describe("warnings", warnings) + } + if restore.Status.Errors > 0 { + d.Describe("errors", errs) + } + }() + + if err := downloadrequest.StreamWithBSLCACert(ctx, kbClient, restore.Namespace, restore.Name, velerov1api.DownloadTargetKindRestoreResults, &buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertPath, bslCACert); err != nil { + if restore.Status.Warnings > 0 { + warnings["errorGettingWarnings"] = fmt.Sprintf("", err) + } + if restore.Status.Errors > 0 { + errs["errorGettingErrors"] = fmt.Sprintf("", err) + } + return + } + + describeRestoreResultsFromReader(warnings, errs, &buf, restore) +} + +func describeRestoreResultsFromReader(warnings, errs map[string]any, r io.Reader, restore *velerov1api.Restore) { + var resultMap map[string]results.Result + if err := json.NewDecoder(r).Decode(&resultMap); err != nil { + if restore.Status.Warnings > 0 { + warnings["errorDecodingWarnings"] = fmt.Sprintf("", err) + } + if restore.Status.Errors > 0 { + errs["errorDecodingErrors"] = fmt.Sprintf("", err) + } + return + } + + if restore.Status.Warnings > 0 { + describeResultInSF(warnings, resultMap["warnings"]) + } + if restore.Status.Errors > 0 { + describeResultInSF(errs, resultMap["errors"]) + } +} + +func describeRestoreItemOperationsInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, restore *velerov1api.Restore, details bool, insecureSkipTLSVerify bool, caCertPath string) { + status := restore.Status + if status.RestoreItemOperationsAttempted == 0 { + return + } + + opsInfo := map[string]any{ + "attempted": status.RestoreItemOperationsAttempted, + "completed": status.RestoreItemOperationsCompleted, + "failed": status.RestoreItemOperationsFailed, + } + + if !details { + d.Describe("restoreItemOperations", opsInfo) + return + } + + bslCACert, err := cacert.GetCACertFromRestore(ctx, kbClient, restore.Namespace, restore) + if err != nil { + bslCACert = "" + } + + buf := new(bytes.Buffer) + if err := downloadrequest.StreamWithBSLCACert(ctx, kbClient, restore.Namespace, restore.Name, velerov1api.DownloadTargetKindRestoreItemOperations, buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertPath, bslCACert); err != nil { + opsInfo["errorGettingOperations"] = fmt.Sprintf("", err) + d.Describe("restoreItemOperations", opsInfo) + return + } + + describeRestoreItemOperationsFromReader(d, opsInfo, buf) +} + +func describeRestoreItemOperationsFromReader(d *StructuredDescriber, opsInfo map[string]any, r io.Reader) { + var operations []*itemoperation.RestoreOperation + if err := json.NewDecoder(r).Decode(&operations); err != nil { + opsInfo["errorReadingOperations"] = fmt.Sprintf("", err) + d.Describe("restoreItemOperations", opsInfo) + return + } + + opsList := make([]map[string]any, 0, len(operations)) + for _, op := range operations { + opsList = append(opsList, describeRestoreItemOperationInSF(op)) + } + opsInfo["operations"] = opsList + d.Describe("restoreItemOperations", opsInfo) +} + +func describeRestoreItemOperationInSF(op *itemoperation.RestoreOperation) map[string]any { + opEntry := map[string]any{ + "resource": fmt.Sprintf("%s %s/%s", op.Spec.ResourceIdentifier, op.Spec.ResourceIdentifier.Namespace, op.Spec.ResourceIdentifier.Name), + "restoreItemActionPlugin": op.Spec.RestoreItemAction, + "operationID": op.Spec.OperationID, + "phase": op.Status.Phase, + } + if op.Status.Error != "" { + opEntry["error"] = op.Status.Error + } + if op.Status.NTotal > 0 || op.Status.NCompleted > 0 { + opEntry["progress"] = map[string]any{ + "completed": op.Status.NCompleted, + "total": op.Status.NTotal, + "units": op.Status.OperationUnits, + } + } + if op.Status.Description != "" { + opEntry["progressDescription"] = op.Status.Description + } + if op.Status.Created != nil { + opEntry["created"] = op.Status.Created.String() + } + if op.Status.Started != nil { + opEntry["started"] = op.Status.Started.String() + } + if op.Status.Updated != nil { + opEntry["updated"] = op.Status.Updated.String() + } + return opEntry +} + +func describeRestoreResourceListInSF(ctx context.Context, kbClient kbclient.Client, d *StructuredDescriber, restore *velerov1api.Restore, insecureSkipTLSVerify bool, caCertPath string) { + bslCACert, err := cacert.GetCACertFromRestore(ctx, kbClient, restore.Namespace, restore) + if err != nil { + bslCACert = "" + } + + buf := new(bytes.Buffer) + if err := downloadrequest.StreamWithBSLCACert(ctx, kbClient, restore.Namespace, restore.Name, velerov1api.DownloadTargetKindRestoreResourceList, buf, downloadRequestTimeout, insecureSkipTLSVerify, caCertPath, bslCACert); err != nil { + if errors.Is(err, downloadrequest.ErrNotFound) { + d.Describe("resourceList", "") + } else { + d.Describe("resourceList", fmt.Sprintf("", err)) + } + return + } + + describeRestoreResourceListFromReader(d, buf) +} + +func describeRestoreResourceListFromReader(d *StructuredDescriber, r io.Reader) { + var resourceList map[string][]string + if err := json.NewDecoder(r).Decode(&resourceList); err != nil { + d.Describe("resourceList", fmt.Sprintf("", err)) + return + } + + d.Describe("resourceList", resourceList) +} diff --git a/pkg/cmd/util/output/restore_structured_describer_test.go b/pkg/cmd/util/output/restore_structured_describer_test.go new file mode 100644 index 000000000..42fbb4d44 --- /dev/null +++ b/pkg/cmd/util/output/restore_structured_describer_test.go @@ -0,0 +1,962 @@ +/* +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 output + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1api "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/vmware-tanzu/velero/internal/volume" + velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1" + "github.com/vmware-tanzu/velero/pkg/builder" + "github.com/vmware-tanzu/velero/pkg/itemoperation" + "github.com/vmware-tanzu/velero/pkg/test" + "github.com/vmware-tanzu/velero/pkg/util/boolptr" + "github.com/vmware-tanzu/velero/pkg/util/results" +) + +func TestDescribeRestoreProgressInSF(t *testing.T) { + testcases := []struct { + name string + input *velerov1api.Restore + expect map[string]any + }{ + { + name: "nil progress — nothing added", + input: builder.ForRestore("velero", "r1").Result(), + expect: map[string]any{}, + }, + { + name: "in-progress phase shows estimated labels", + input: func() *velerov1api.Restore { + r := builder.ForRestore("velero", "r2").Phase(velerov1api.RestorePhaseInProgress).Result() + r.Status.Progress = &velerov1api.RestoreProgress{TotalItems: 100, ItemsRestored: 50} + return r + }(), + expect: map[string]any{ + "progress": map[string]any{ + "estimatedTotalItemsToBeRestored": 100, + "itemsRestoredSoFar": 50, + }, + }, + }, + { + name: "completed phase shows final labels", + input: func() *velerov1api.Restore { + r := builder.ForRestore("velero", "r3").Phase(velerov1api.RestorePhaseCompleted).Result() + r.Status.Progress = &velerov1api.RestoreProgress{TotalItems: 80, ItemsRestored: 80} + return r + }(), + expect: map[string]any{ + "progress": map[string]any{ + "totalItemsToBeRestored": 80, + "itemsRestored": 80, + }, + }, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(tt *testing.T) { + sd := &StructuredDescriber{output: make(map[string]any), format: ""} + describeRestoreProgressInSF(sd, tc.input) + assert.Equal(tt, tc.expect, sd.output) + }) + } +} + +func TestDescribeRestoreTimestampsInSF(t *testing.T) { + t1 := time.Date(2024, 1, 10, 12, 0, 0, 0, time.UTC) + t2 := time.Date(2024, 1, 10, 13, 0, 0, 0, time.UTC) + mt1 := metav1.NewTime(t1) + mt2 := metav1.NewTime(t2) + + testcases := []struct { + name string + input *velerov1api.Restore + expect map[string]any + }{ + { + name: "nil timestamps show ", + input: builder.ForRestore("velero", "r1").Result(), + expect: map[string]any{ + "timestamps": map[string]any{ + "started": "", + "completed": "", + }, + }, + }, + { + name: "both timestamps set", + input: func() *velerov1api.Restore { + r := builder.ForRestore("velero", "r2").Result() + r.Status.StartTimestamp = &mt1 + r.Status.CompletionTimestamp = &mt2 + return r + }(), + expect: map[string]any{ + "timestamps": map[string]any{ + "started": mt1.String(), + "completed": mt2.String(), + }, + }, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(tt *testing.T) { + sd := &StructuredDescriber{output: make(map[string]any), format: ""} + describeRestoreTimestampsInSF(sd, tc.input) + assert.Equal(tt, tc.expect, sd.output) + }) + } +} + +func TestDescribeRestoreSpecInSF(t *testing.T) { + testcases := []struct { + name string + spec velerov1api.RestoreSpec + expect map[string]any + }{ + { + name: "minimal spec", + spec: velerov1api.RestoreSpec{ + BackupName: "backup-1", + }, + expect: map[string]any{ + "spec": map[string]any{ + "backupName": "backup-1", + "namespaces": map[string]any{ + "included": "all namespaces found in the backup", + "excluded": emptyDisplay, + }, + "resources": map[string]string{ + "included": "*", + "excluded": emptyDisplay, + "clusterScoped": "auto", + }, + "namespaceMappings": emptyDisplay, + "labelSelector": emptyDisplay, + "orLabelSelectors": emptyDisplay, + "restorePVs": "auto", + "existingResourcePolicy": emptyDisplay, + "itemOperationTimeout": "0s", + "preserveNodePorts": "auto", + }, + }, + }, + { + name: "included namespaces wildcard treated as all", + spec: velerov1api.RestoreSpec{ + BackupName: "backup-2", + IncludedNamespaces: []string{"*"}, + ExcludedNamespaces: []string{"kube-system"}, + IncludedResources: []string{"pods", "configmaps"}, + ExcludedResources: []string{"secrets"}, + ExistingResourcePolicy: velerov1api.ResourcePolicyTypeUpdate, + }, + expect: map[string]any{ + "spec": map[string]any{ + "backupName": "backup-2", + "namespaces": map[string]any{ + "included": "all namespaces found in the backup", + "excluded": "kube-system", + }, + "resources": map[string]string{ + "included": "pods, configmaps", + "excluded": "secrets", + "clusterScoped": "auto", + }, + "namespaceMappings": emptyDisplay, + "labelSelector": emptyDisplay, + "orLabelSelectors": emptyDisplay, + "restorePVs": "auto", + "existingResourcePolicy": string(velerov1api.ResourcePolicyTypeUpdate), + "itemOperationTimeout": "0s", + "preserveNodePorts": "auto", + }, + }, + }, + { + name: "spec with resource modifier and uploader config", + spec: velerov1api.RestoreSpec{ + BackupName: "backup-3", + ResourceModifier: &corev1api.TypedLocalObjectReference{ + Kind: "ConfigMap", + Name: "my-modifier", + }, + UploaderConfig: &velerov1api.UploaderConfigForRestore{ + WriteSparseFiles: boolptr.True(), + ParallelFilesDownload: 4, + }, + }, + expect: map[string]any{ + "spec": map[string]any{ + "backupName": "backup-3", + "namespaces": map[string]any{ + "included": "all namespaces found in the backup", + "excluded": emptyDisplay, + }, + "resources": map[string]string{ + "included": "*", + "excluded": emptyDisplay, + "clusterScoped": "auto", + }, + "namespaceMappings": emptyDisplay, + "labelSelector": emptyDisplay, + "orLabelSelectors": emptyDisplay, + "restorePVs": "auto", + "existingResourcePolicy": emptyDisplay, + "itemOperationTimeout": "0s", + "preserveNodePorts": "auto", + "resourceModifier": map[string]any{ + "type": "ConfigMap", + "name": "my-modifier", + }, + "uploaderConfig": map[string]any{ + "writeSparseFiles": true, + "parallelFilesDownload": 4, + }, + }, + }, + }, + { + name: "namespaces, mappings, selectors, resource policy and flags", + spec: velerov1api.RestoreSpec{ + BackupName: "backup-4", + IncludedNamespaces: []string{"ns-a", "ns-b"}, + NamespaceMapping: map[string]string{"ns-a": "ns-a-new"}, + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "nginx"}}, + OrLabelSelectors: []*metav1.LabelSelector{{MatchLabels: map[string]string{"env": "prod"}}, {MatchLabels: map[string]string{"env": "stage"}}}, + IncludeClusterResources: boolptr.True(), + RestorePVs: boolptr.True(), + PreserveNodePorts: boolptr.False(), + ResourcePolicy: &corev1api.TypedLocalObjectReference{ + Kind: "configmap", + Name: "volume-policy", + }, + UploaderConfig: &velerov1api.UploaderConfigForRestore{ + WriteSparseFiles: boolptr.False(), + }, + }, + expect: map[string]any{ + "spec": map[string]any{ + "backupName": "backup-4", + "namespaces": map[string]any{ + "included": "ns-a, ns-b", + "excluded": emptyDisplay, + }, + "resources": map[string]string{ + "included": "*", + "excluded": emptyDisplay, + "clusterScoped": "included", + }, + "namespaceMappings": map[string]string{"ns-a": "ns-a-new"}, + "labelSelector": "app=nginx", + "orLabelSelectors": "env=prod or env=stage", + "restorePVs": "true", + "existingResourcePolicy": emptyDisplay, + "itemOperationTimeout": "0s", + "preserveNodePorts": "false", + "resourcePolicy": map[string]any{ + "type": "configmap", + "name": "volume-policy", + }, + "uploaderConfig": map[string]any{}, + }, + }, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(tt *testing.T) { + sd := &StructuredDescriber{output: make(map[string]any), format: ""} + describeRestoreSpecInSF(sd, tc.spec) + assert.Equal(tt, tc.expect, sd.output) + }) + } +} + +func TestDescribePodVolumeRestoresInSF(t *testing.T) { + pvr1 := builder.ForPodVolumeRestore("velero", "pvr-1"). + UploaderType("kopia"). + Phase(velerov1api.PodVolumeRestorePhaseCompleted). + Volume("vol-1"). + PodName("pod-1"). + PodNamespace("ns-1").Result() + + pvr2 := builder.ForPodVolumeRestore("velero", "pvr-2"). + UploaderType("kopia"). + Phase(velerov1api.PodVolumeRestorePhaseCompleted). + Volume("vol-2"). + PodName("pod-2"). + PodNamespace("ns-1").Result() + + pvr3 := builder.ForPodVolumeRestore("velero", "pvr-3"). + UploaderType("kopia"). + Phase(velerov1api.PodVolumeRestorePhaseFailed). + Volume("vol-3"). + PodName("pod-3"). + PodNamespace("ns-1").Result() + + testcases := []struct { + name string + restores []velerov1api.PodVolumeRestore + details bool + expect map[string]any + }{ + { + name: "empty list", + restores: []velerov1api.PodVolumeRestore{}, + details: false, + expect: map[string]any{ + "podVolumeRestores": "", + }, + }, + { + name: "2 completed, no details", + restores: []velerov1api.PodVolumeRestore{*pvr1, *pvr2}, + details: false, + expect: map[string]any{ + "podVolumeRestores": map[string]any{ + "uploaderType": "kopia", + "Completed": 2, + }, + }, + }, + { + name: "2 completed with details", + restores: []velerov1api.PodVolumeRestore{*pvr1, *pvr2}, + details: true, + expect: map[string]any{ + "podVolumeRestores": map[string]any{ + "uploaderType": "kopia", + "Completed": []map[string]string{ + {"ns-1/pod-1": "vol-1"}, + {"ns-1/pod-2": "vol-2"}, + }, + }, + }, + }, + { + name: "completed and failed, no details", + restores: []velerov1api.PodVolumeRestore{*pvr1, *pvr2, *pvr3}, + details: false, + expect: map[string]any{ + "podVolumeRestores": map[string]any{ + "uploaderType": "kopia", + "Completed": 2, + "Failed": 1, + }, + }, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(tt *testing.T) { + sd := &StructuredDescriber{output: make(map[string]any), format: ""} + describePodVolumeRestoresInSF(sd, tc.restores, tc.details) + assert.Equal(tt, tc.expect, sd.output) + }) + } +} + +func TestDescribeRestoreCSISnapshotsInSF_NoData(t *testing.T) { + testcases := []struct { + name string + inputVolInfoList []volume.RestoreVolumeInfo + details bool + expect map[string]any + }{ + { + name: "no CSI entries — none included", + inputVolInfoList: []volume.RestoreVolumeInfo{}, + details: false, + expect: map[string]any{ + "csiSnapshotRestores": "", + }, + }, + { + name: "only native snapshot entries — none included", + inputVolInfoList: []volume.RestoreVolumeInfo{ + { + RestoreMethod: volume.NativeSnapshot, + PVCName: "pvc-1", + PVCNamespace: "ns-1", + }, + }, + details: false, + expect: map[string]any{ + "csiSnapshotRestores": "", + }, + }, + { + name: "CSI snapshot, no details", + inputVolInfoList: []volume.RestoreVolumeInfo{ + { + RestoreMethod: volume.CSISnapshot, + PVCName: "pvc-1", + PVCNamespace: "ns-1", + CSISnapshotInfo: &volume.CSISnapshotInfo{ + VSCName: "vsc-1", + SnapshotHandle: "snap-handle-1", + Driver: "csi.test.driver", + }, + }, + }, + details: false, + expect: map[string]any{ + "csiSnapshotRestores": map[string]any{ + "ns-1/pvc-1": map[string]any{ + "snapshot": "specify --details for more information", + }, + }, + }, + }, + { + name: "CSI snapshot, with details", + inputVolInfoList: []volume.RestoreVolumeInfo{ + { + RestoreMethod: volume.CSISnapshot, + PVCName: "pvc-2", + PVCNamespace: "ns-2", + CSISnapshotInfo: &volume.CSISnapshotInfo{ + VSCName: "vsc-2", + SnapshotHandle: "snap-handle-2", + Driver: "csi.test.driver", + }, + }, + }, + details: true, + expect: map[string]any{ + "csiSnapshotRestores": map[string]any{ + "ns-2/pvc-2": map[string]any{ + "snapshot": map[string]any{ + "snapshotContentName": "vsc-2", + "storageSnapshotID": "snap-handle-2", + "csiDriver": "csi.test.driver", + }, + }, + }, + }, + }, + { + name: "data movement entry, with details", + inputVolInfoList: []volume.RestoreVolumeInfo{ + { + RestoreMethod: volume.CSISnapshot, + SnapshotDataMoved: true, + PVCName: "pvc-3", + PVCNamespace: "ns-3", + SnapshotDataMovementInfo: &volume.SnapshotDataMovementInfo{ + OperationID: "op-3", + DataMover: "velero", + UploaderType: "kopia", + }, + }, + }, + details: true, + expect: map[string]any{ + "csiSnapshotRestores": map[string]any{ + "ns-3/pvc-3": map[string]any{ + "dataMovement": map[string]any{ + "operationID": "op-3", + "dataMover": "velero", + "uploaderType": "kopia", + }, + }, + }, + }, + }, + { + name: "data movement entry, no details", + inputVolInfoList: []volume.RestoreVolumeInfo{ + { + RestoreMethod: volume.CSISnapshot, + SnapshotDataMoved: true, + PVCName: "pvc-3", + PVCNamespace: "ns-3", + SnapshotDataMovementInfo: &volume.SnapshotDataMovementInfo{ + OperationID: "op-3", + DataMover: "velero", + UploaderType: "kopia", + }, + }, + }, + details: false, + expect: map[string]any{ + "csiSnapshotRestores": map[string]any{ + "ns-3/pvc-3": map[string]any{ + "dataMovement": "specify --details for more information", + }, + }, + }, + }, + { + name: "CSI snapshot with details and nil CSISnapshotInfo", + inputVolInfoList: []volume.RestoreVolumeInfo{ + { + RestoreMethod: volume.CSISnapshot, + PVCName: "pvc-4", + PVCNamespace: "ns-4", + CSISnapshotInfo: nil, + }, + }, + details: true, + expect: map[string]any{ + "csiSnapshotRestores": map[string]any{ + "ns-4/pvc-4": map[string]any{ + "snapshot": "", + }, + }, + }, + }, + { + name: "data movement with details and nil SnapshotDataMovementInfo", + inputVolInfoList: []volume.RestoreVolumeInfo{ + { + RestoreMethod: volume.CSISnapshot, + SnapshotDataMoved: true, + PVCName: "pvc-5", + PVCNamespace: "ns-5", + SnapshotDataMovementInfo: nil, + }, + }, + details: true, + expect: map[string]any{ + "csiSnapshotRestores": map[string]any{ + "ns-5/pvc-5": map[string]any{ + "dataMovement": "", + }, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(tt *testing.T) { + sd := &StructuredDescriber{output: make(map[string]any), format: ""} + describeCSISnapshotsRestoresInSF(sd, tc.inputVolInfoList, tc.details) + assert.Equal(tt, tc.expect, sd.output) + }) + } +} + +func TestDescribeCSISnapshotsRestoresFromReader(t *testing.T) { + t.Run("invalid json", func(t *testing.T) { + sd := &StructuredDescriber{output: make(map[string]any), format: ""} + describeCSISnapshotsRestoresFromReader(sd, strings.NewReader("not-json"), false) + got, ok := sd.output["csiSnapshotRestores"].(string) + require.True(t, ok) + assert.Contains(t, got, "= 1.16 preferred CRD API version is v1. + +## Dependency management + +Velero is written in Go and uses [Go modules](https://go.dev/ref/mod) to manage its dependencies. Direct and indirect dependencies are declared in `go.mod` and pinned in `go.sum`. + +The project keeps dependencies up to date and responds to upstream security fixes as follows: + +* [Dependabot](https://docs.github.com/en/code-security/dependabot) is configured in [`.github/dependabot.yml`](https://github.com/velero-io/velero/blob/main/.github/dependabot.yml) to open pull requests for Go module and GitHub Actions updates on a weekly schedule. Updates are grouped to reduce noise. +* Dependency update pull requests follow the same review process as any other change: they must pass CI and be approved by a maintainer before merging. +* Security-relevant updates are prioritized. Vulnerabilities in dependencies that affect Velero are handled through the [security release process](https://github.com/velero-io/.github/blob/main/SECURITY.md). +* New direct dependencies should be kept to a minimum and use a license compatible with Velero's [Apache 2.0 license](https://github.com/velero-io/velero/blob/main/LICENSE).