mirror of
https://github.com/vmware-tanzu/velero.git
synced 2026-08-03 22:06:07 +00:00
Merge pull request #10015 from adam-jian-zhang/fine-grained-restore-filters-for-1.18
Run the E2E test on kind / get-go-version (push) Successful in 58s
Run the E2E test on kind / setup-test-matrix (push) Successful in 3s
Main CI / get-go-version (push) Successful in 11s
Run the E2E test on kind / build (push) Failing after 26s
Run the E2E test on kind / run-e2e-test (push) Has been skipped
Main CI / Build (push) Failing after 27s
Run the E2E test on kind / get-go-version (push) Successful in 58s
Run the E2E test on kind / setup-test-matrix (push) Successful in 3s
Main CI / get-go-version (push) Successful in 11s
Run the E2E test on kind / build (push) Failing after 26s
Run the E2E test on kind / run-e2e-test (push) Has been skipped
Main CI / Build (push) Failing after 27s
Fine grained restore filters for 1.18
This commit is contained in:
@@ -1 +1 @@
|
||||
Cherry pick fine grained filters PRs: #9783, #9821, #9840, #9847, #9848, #9880, #9881, #9908
|
||||
Add fine-grained filters for backup via resource policy, introduced ClusterScopedFilterPolicy and NamespacedFilterPolicy section for resource policy
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Add fine-grained filters for restore via resource policy, introduced resourcePolicy field for restoreSpec, which contains ClusterScopedFilterPolicy and NamespacedFilterPolicy section
|
||||
@@ -1 +0,0 @@
|
||||
Fix issue #9811, add interface to support ClusterScopedFilterPolicy and NamespacedFilterPolicy
|
||||
@@ -1 +0,0 @@
|
||||
Fix issue #9812, validate ClusterScopedFilterPolicy and NamespacedFilterPolicy incompatible with legacy filters
|
||||
@@ -1 +0,0 @@
|
||||
Fix issue #9813, add validations for ClusterScopedFilterPolicy
|
||||
@@ -1 +0,0 @@
|
||||
Fix issue #9814, add validations for NamespacedFilterPolicies
|
||||
@@ -1 +0,0 @@
|
||||
Fix issue #9815, implement core logic of backup with ClusterScopedFilterPolicy and NamespacedFilterPolicies
|
||||
@@ -1 +0,0 @@
|
||||
Fix issue #9816, add cli support for backup with ClusterScopedFilterPolicy and NamespacedFilterPolicies
|
||||
@@ -1 +0,0 @@
|
||||
Fix issue #9907, add cache for the GetNamespaceFilter call
|
||||
@@ -404,6 +404,33 @@ spec:
|
||||
- name
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
resourcePolicy:
|
||||
description: |-
|
||||
ResourcePolicy specifies the reference to a ConfigMap containing resource
|
||||
filter policies for this restore. The ConfigMap can contain a
|
||||
namespacedFilterPolicies section that specifies per-namespace resource type
|
||||
filters, label selectors, and resource name patterns, and a
|
||||
clusterScopedFilterPolicy section for per-kind filtering of cluster-scoped
|
||||
resources. The ConfigMap format is the same as for BackupSpec.ResourcePolicy.
|
||||
nullable: true
|
||||
properties:
|
||||
apiGroup:
|
||||
description: |-
|
||||
APIGroup is the group for the resource being referenced.
|
||||
If APIGroup is not specified, the specified Kind must be in the core API group.
|
||||
For any other third-party types, APIGroup is required.
|
||||
type: string
|
||||
kind:
|
||||
description: Kind is the type of resource being referenced
|
||||
type: string
|
||||
name:
|
||||
description: Name is the name of resource being referenced
|
||||
type: string
|
||||
required:
|
||||
- kind
|
||||
- name
|
||||
type: object
|
||||
x-kubernetes-map-type: atomic
|
||||
restorePVs:
|
||||
description: |-
|
||||
RestorePVs specifies whether to restore all included
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,833 @@
|
||||
# Fine Grained Restore Filters via Resource Policies
|
||||
|
||||
This is a continuation of the work done for backup filters enhancement introduced by [PR 9783](https://github.com/velero-io/velero/pull/9783), referred to as Phase 1 throughout this design.
|
||||
|
||||
## Glossary & Abbreviation
|
||||
|
||||
**Restore Filter**: The mechanism in Velero that determines which resources from a backup archive are restored into the target cluster. Restore filters currently operate on four dimensions: namespace, resource type, label, and cluster scope.
|
||||
**Global Filter**: A filter that applies uniformly across all namespaces in a restore. All existing Velero restore filters are global filters.
|
||||
**Namespace-Scoped Filter**: A filter that applies only within specific namespaces, overriding the global filter for those namespaces. This is the capability introduced by this design.
|
||||
**ClusterScopedFilterPolicy**: A global filter for cluster-scoped resources that allows per-kind label selectors and name patterns, functioning similarly to `NamespacedFilterPolicy` but applied to cluster-scoped resources globally. Mirrors the backup-side concept of the same name.
|
||||
**Resource Filter**: A filter rule that pairs one or more resource kinds with their own label selector and/or name patterns. Multiple resource filters within a namespace-scoped policy allow different filtering criteria for different resource types.
|
||||
**Resource Name Filter**: A filter that matches individual resource instances by their metadata.name, using glob patterns. This filter dimension was introduced in Phase 1 (backup-side) and is extended to restore in this design.
|
||||
**Resource Policy**: An existing Velero mechanism where backup behavior rules are defined in a ConfigMap and referenced from `BackupSpec.ResourcePolicy`. Phase 1 extended this with `namespacedFilterPolicies` and `clusterScopedFilterPolicy` for backup. This design adds an analogous `RestoreSpec.ResourcePolicy` for restore, reusing the same ConfigMap format.
|
||||
|
||||
## Background
|
||||
|
||||
### Why Restore-Side Filters?
|
||||
|
||||
Phase 1 enables selective backup — for example, backing up only Deployments and ConfigMaps from `ns-a` while backing up everything from `ns-b`. However, backup-time filtering alone is insufficient for several real-world restore scenarios:
|
||||
|
||||
**Scenario 1 — Selective restore from a full backup.** An organization performs full-cluster backups (all namespaces, all resource types) for disaster recovery. When a specific application needs recovery, the administrator wants to restore only the application's resources (specific resource types, specific names) from a single namespace — without restoring monitoring, logging, or infrastructure resources that exist in the same namespace. Today, `RestoreSpec.IncludedResources` applies globally, so filtering out ConfigMaps means filtering them out of *every* namespace being restored.
|
||||
|
||||
**Scenario 2 — Cross-environment migration with selective resources.** When migrating workloads between clusters, different namespaces may need different resource types restored. A database namespace needs StatefulSets and PVCs but not Deployments; a frontend namespace needs Deployments and Services but not PVCs. The current global filter cannot express this.
|
||||
|
||||
**Scenario 3 — Restore with name-based selection.** A backup contains many ConfigMaps and Secrets in a namespace (e.g., `app-config`, `app-secret`, `monitoring-config`, `monitoring-secret`). The user wants to restore only the `app-*` resources. Without name-based filtering at restore time, this requires either pre-filtering at backup time (which may not have been done) or post-restore manual cleanup.
|
||||
|
||||
**Scenario 4 — Restore-time override of backup-time filters.** A backup was produced with `namespacedFilterPolicies` that included specific resources per namespace. At restore time, the operator may want to apply *different* per-namespace filters — for example, restoring only a subset of what was backed up, or applying different label selectors to handle environment differences.
|
||||
|
||||
### Existing Restore Filter Mechanisms
|
||||
|
||||
The restore pipeline currently supports:
|
||||
|
||||
| Filter | Scope | Where Applied |
|
||||
|---|---|---|
|
||||
| `RestoreSpec.IncludedNamespaces` / `ExcludedNamespaces` | Global | `getOrderedResourceCollection()` |
|
||||
| `RestoreSpec.IncludedResources` / `ExcludedResources` | Global | `getOrderedResourceCollection()`, `restoreItem()` |
|
||||
| `RestoreSpec.LabelSelector` / `OrLabelSelectors` | Global | `getSelectedRestoreableItems()` |
|
||||
| `RestoreSpec.IncludeClusterResources` | Global | `getOrderedResourceCollection()` |
|
||||
| `RestoreSpec.NamespaceMapping` | Per-namespace | `getSelectedRestoreableItems()` |
|
||||
|
||||
All resource-type, label, and name filters are global. There is no per-namespace override capability.
|
||||
|
||||
### Design Approach: New `RestoreSpec.ResourcePolicy` Field
|
||||
|
||||
Phase 1 avoided CRD changes for backup by reusing the existing `BackupSpec.ResourcePolicy` ConfigMap reference. For restore, no equivalent field exists — `RestoreSpec` has no `ResourcePolicy` field today.
|
||||
|
||||
Two approaches were evaluated:
|
||||
|
||||
**Option A — Reuse the backup's ResourcePolicy ConfigMap.** The restore pipeline could read the `namespacedFilterPolicies` from the backup's ConfigMap. This is rejected because:
|
||||
- Restore should be able to apply *different* filters than backup
|
||||
- The backup's ConfigMap may no longer exist at restore time
|
||||
- The backup's ConfigMap is semantically about backup behavior, not restore
|
||||
- The ConfigMap may have been updated since the backup was taken
|
||||
- The ConfigMap may not exist on the target cluster, because it's maybe on a different velero instance.
|
||||
|
||||
**Option B — Add `RestoreSpec.ResourcePolicy` (minimal CRD change).** Add a single `TypedLocalObjectReference` field to `RestoreSpec`, mirroring the existing `BackupSpec.ResourcePolicy` and `RestoreSpec.ResourceModifier` patterns. This is a small, focused CRD change that follows an established pattern in the codebase.
|
||||
|
||||
This design uses **Option B**. The rationale:
|
||||
|
||||
| Consideration | Assessment |
|
||||
|---|---|
|
||||
| CRD change size | **Minimal** — one `TypedLocalObjectReference` field, identical pattern to `ResourceModifier` |
|
||||
| Precedent | `RestoreSpec.ResourceModifier` already uses the exact same pattern (ConfigMap ref loaded in `validateAndComplete()`) |
|
||||
| Independence from backup | Restore filters are decoupled from backup filters — different ConfigMap, different lifecycle |
|
||||
| Reuse | The `NamespacedFilterPolicy` and `ClusterScopedFilterPolicy` types from Phase 1 (`internal/resourcepolicies/`) are reused unchanged |
|
||||
|
||||
### Why Not Just Reuse `BackupSpec.ResourcePolicy` Semantics?
|
||||
|
||||
The backup-side `ResourcePolicy` ConfigMap contains multiple policy types (`volumePolicies`, `includeExcludePolicy`, `namespacedFilterPolicies`, `clusterScopedFilterPolicy`). Rather than forcing users to create a ConfigMap with backup-specific sections just to specify restore filters, this design introduces a restore-specific ConfigMap format that contains only `namespacedFilterPolicies` and `clusterScopedFilterPolicy` (and potentially other restore-specific policies in the future).
|
||||
|
||||
The restore-side ConfigMap uses the **same YAML structure** for both sections. The `NamespacedFilterPolicy` and `ClusterScopedFilterPolicy` types are reused without modification. This means:
|
||||
- Users who already understand the backup-side format can immediately use the restore-side one
|
||||
- The `internal/resourcepolicies/` validation code is reused
|
||||
- A single ConfigMap can be used for both backup and restore if the user wants (by specifying it in both `BackupSpec.ResourcePolicy` and `RestoreSpec.ResourcePolicy`)
|
||||
|
||||
## Goals
|
||||
|
||||
- Add a `ResourcePolicy` field to `RestoreSpec` pointing to a ConfigMap with `namespacedFilterPolicies` and/or `clusterScopedFilterPolicy`
|
||||
- Reuse the `NamespacedFilterPolicy`, `ClusterScopedFilterPolicy`, and `ResourceFilter` types from Phase 1 unchanged
|
||||
- Apply per-namespace resource type filters, label selectors, and resource name patterns during restore
|
||||
- Apply per-kind label selectors and name patterns for cluster-scoped resources during restore
|
||||
- Maintain full backward compatibility — existing restores without `ResourcePolicy` behave exactly as they do today
|
||||
- Define clear precedence rules for how per-namespace filters interact with global restore filters
|
||||
- Add corresponding validation in the restore controller
|
||||
- Update `velero restore describe` output to display per-namespace and cluster-scoped filter information when present
|
||||
- Ensure restore-side filters work correctly with both filtered and unfiltered backups
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Modifying the existing `NamespacedFilterPolicy`, `ClusterScopedFilterPolicy`, or `ResourceFilter` types or the `internal/resourcepolicies/` package structure (reused as-is from Phase 1)
|
||||
- Adding volume policies or include/exclude policies to the restore-side ResourcePolicy ConfigMap
|
||||
- Supporting regex patterns for resource names (glob patterns only, consistent with Phase 1)
|
||||
- Modifying the restore plugin `ResourceSelector` system (`AppliesTo()` / `resolvedAction.ShouldUse()`)
|
||||
- CLI flags for inline specification of namespace-scoped restore filters (configuration is in ConfigMap YAML)
|
||||
|
||||
## Architecture of Restore-Side Filters
|
||||
|
||||
### Configuration Model
|
||||
|
||||
The restore-side filters are defined in a ConfigMap referenced by a new `RestoreSpec.ResourcePolicy` field. The ConfigMap YAML format reuses the `namespacedFilterPolicies` and `clusterScopedFilterPolicy` sections from Phase 1, with the same `resourceFilters` model:
|
||||
|
||||
```yaml
|
||||
version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
# NEW: global overrides for cluster-scoped resources during restore
|
||||
resourceFilters:
|
||||
- kinds: [ClusterRole, ClusterRoleBinding]
|
||||
names: ["my-app-*"]
|
||||
- kinds: [CustomResourceDefinition]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret, Deployment]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
- namespaces:
|
||||
- ns-b
|
||||
resourceFilters:
|
||||
- kinds: [Deployment]
|
||||
names: [app-1, app-2]
|
||||
- kinds: [ConfigMap]
|
||||
labelSelector:
|
||||
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`.
|
||||
|
||||
### The `resourceFilters` Model
|
||||
|
||||
Each `namespacedFilterPolicies` entry targets one or more namespaces and contains a `resourceFilters` array. Each entry in `resourceFilters` pairs one or more resource kinds with their own label selector and name patterns:
|
||||
|
||||
```yaml
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: [ns-a]
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret] # these kinds share a selector
|
||||
labelSelector: {app: my-app}
|
||||
names: ["app-*"]
|
||||
- kinds: [Deployment] # this kind has its own selector
|
||||
names: [workload-1, workload-2]
|
||||
- kinds: [StatefulSet] # this kind has no extra filtering
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
#### 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`).
|
||||
|
||||
To ensure consistent case-insensitive behavior across all code paths, Velero normalizes all input `kinds` to lowercase *before* attempting discovery or fallback matching.
|
||||
|
||||
During a restore, Velero attempts to resolve `Kind` names to fully-qualified plural resource names using the cluster's discovery helper. However, for Custom Resources (CRDs), the CRD might not exist in the cluster yet when the restore begins.
|
||||
|
||||
To handle this, Velero implements a **peek-and-map fallback**:
|
||||
1. If a normalized `Kind` cannot be resolved via the discovery helper at the start of the restore, Velero stores the normalized string as provided in the policy.
|
||||
2. Later, when iterating through the backup tarball, if Velero encounters a resource type (e.g., `mycustomkinds.mygroup.io`) that doesn't match any resolved filters, it peeks at the `Kind` of the first item in the tarball for that resource type.
|
||||
3. It then checks if this actual `Kind` (case-insensitively) matches any of the unresolved normalized strings in the user's policy.
|
||||
4. If a match is found, the filter is applied and cached for subsequent lookups.
|
||||
|
||||
This ensures that users can intuitively write `kinds: [MyCustomKind]` and it will work reliably, even if the CRD hasn't been restored yet. This logic applies to both `namespacedFilterPolicies` and `clusterScopedFilterPolicy`.
|
||||
|
||||
#### 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.
|
||||
|
||||
**Rules for catch-all entries:**
|
||||
- At most **one** catch-all entry is allowed per `NamespacedFilterPolicy`.
|
||||
- `names` and `excludedNames` are **not** supported on catch-all entries. Name patterns are kind-specific by nature and cannot be applied across arbitrary kinds; use kind-specific entries for name-based filtering.
|
||||
- The catch-all applies to kinds that are **not listed in any other `resourceFilters` entry** in the same policy. Kind-specific entries take precedence over the catch-all.
|
||||
- A catch-all entry **does not inherit or fall back to `RestoreSpec.LabelSelector`**. If a catch-all entry has no `labelSelector`/`orLabelSelectors`, all unlisted resource kinds in the namespace are included with **no label filtering** — the global label selector is not applied.
|
||||
- **Catch-all is a `namespacedFilterPolicies`-only feature**. `clusterScopedFilterPolicy` does **not** support catch-all entries (empty or `["*"]` kinds). This is because `clusterScopedFilterPolicy` is a refinement overlay — unlisted cluster-scoped kinds already fall back to global filters by default. A catch-all would conflict with that fallback semantics. Validation rejects catch-all entries in `clusterScopedFilterPolicy`.
|
||||
|
||||
**Evaluation order within a namespace filter policy:**
|
||||
1. For each resource kind encountered during restore, the system first checks whether a kind-specific `resourceFilters` entry exists for that kind.
|
||||
2. If a kind-specific entry exists, it is used exclusively (label selectors, name patterns from that entry).
|
||||
3. If no kind-specific entry exists but a catch-all entry is present, the catch-all's `labelSelector`/`orLabelSelectors` is applied to that kind.
|
||||
4. If neither a kind-specific entry nor a catch-all entry exists, the kind is excluded from the restore for that namespace.
|
||||
|
||||
### Filter Precedence Model
|
||||
|
||||
The restore-side namespace-scoped filter system layers on top of the existing global restore filter system. The evaluation order is:
|
||||
|
||||
1. **Global namespace filter** (`RestoreSpec.IncludedNamespaces`/`ExcludedNamespaces`) is checked first. A namespace must pass this filter to be considered at all. `namespacedFilterPolicies` cannot override namespace exclusion — if a namespace is excluded globally, no filter policy entry can bring it back.
|
||||
|
||||
2. **Global resource type filter** (`RestoreSpec.IncludedResources`/`ExcludedResources`) is checked next. A resource type must pass the global filter to be considered. Per-namespace filters can further narrow the set of resource types within a namespace, but cannot include a resource type that is globally excluded.
|
||||
|
||||
3. **Per-namespace filter lookup.** For each namespace that passes the global filters, the system checks whether any `namespacedFilterPolicies` entry matches (by namespace name or glob pattern). If a match is found, the `resourceFilters` array determines what gets restored for that namespace:
|
||||
- Only resource kinds listed in `resourceFilters[].kinds` are restored (globally excluded kinds cannot be re-included by a per-namespace policy)
|
||||
- Each kind uses its own `labelSelector`/`orLabelSelectors` from its `ResourceFilter` entry, **replacing** the global label selector for that kind
|
||||
- Each kind uses its own `names`/`excludedNames` patterns from its `ResourceFilter` entry
|
||||
|
||||
4. **Namespaces without a matching filter policy** continue to use the global filters (`RestoreSpec.IncludedResources`, `RestoreSpec.LabelSelector`, etc.) exactly as they do today.
|
||||
|
||||
5. **If multiple filter policy entries could match the same namespace** (e.g., `team-*` and `team-frontend-*` both matching `team-frontend-prod`), the **first matching policy in the list** is used. **Important: Place more specific patterns before broader patterns** to achieve the intended filtering behavior.
|
||||
|
||||
6. **Namespace mapping** is applied after filter lookup. If `RestoreSpec.NamespaceMapping` maps `ns-a` to `ns-a-restored`, the filter policy lookup uses the *original* namespace name (`ns-a`), since the ConfigMap was authored against the backup's namespace structure.
|
||||
|
||||
**For Cluster-Scoped Resources:**
|
||||
|
||||
1. If `clusterScopedFilterPolicy` is present, it acts as a **refinement overlay** over the existing global filters for cluster-scoped resources. It is NOT an exclusive allowlist.
|
||||
- If a cluster-scoped kind is listed in its `resourceFilters`, its specific `labelSelector`/`orLabelSelectors` and `names`/`excludedNames` patterns are applied.
|
||||
- If a cluster-scoped kind is **not listed**, it falls back to the standard global filters (`RestoreSpec.LabelSelector`, etc.).
|
||||
|
||||
2. If `clusterScopedFilterPolicy` is absent, Velero falls back to the existing global filters (`IncludedResources`, `LabelSelector`, etc.) for cluster-scoped resources.
|
||||
|
||||
3. **The `velero.io/exclude-from-backup=true` label** always takes precedence over all filters. Although named for backup, this label is set on resources at backup time and remains present on items in the archive. The restore pipeline honors it: any item carrying this label is skipped regardless of whether it matches global or per-namespace restore filters.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["RestoreSpec Global<br>IncludedNamespaces / ExcludedNamespaces"]
|
||||
B{Namespace passes<br>global filter?}
|
||||
C[Namespace excluded<br>from restore]
|
||||
D{"Resource type passes<br>IncludedResources / ExcludedResources?"}
|
||||
E[Resource type excluded<br>from restore]
|
||||
G{namespacedFilterPolicies<br>lookup by original namespace}
|
||||
H{"For each resource kind:<br>is kind in resourceFilters?"}
|
||||
I["Apply namespace kind-specific filters:<br>- labelSelector / orLabelSelectors<br>- names / excludedNames"]
|
||||
J[Kind skipped for<br>this namespace]
|
||||
K["Use global filters:<br>- RestoreSpec LabelSelector<br>- RestoreSpec OrLabelSelectors"]
|
||||
L{"Is resource<br>cluster-scoped?"}
|
||||
M{"Is clusterScopedFilterPolicy<br>present?"}
|
||||
N{"Is kind in clusterScopedFilterPolicy<br>resourceFilters?"}
|
||||
O["Apply cluster kind-specific filters:<br>- labelSelector / orLabelSelectors<br>- names / excludedNames"]
|
||||
|
||||
L -- Yes --> M
|
||||
M -- Yes --> N
|
||||
N -- Yes --> O
|
||||
N -- No --> K
|
||||
M -- No --> K
|
||||
L -- No --> A
|
||||
A --> B
|
||||
B -- No --> C
|
||||
B -- Yes --> D
|
||||
D -- No --> E
|
||||
D -- Yes --> G
|
||||
G -- Match found --> H
|
||||
H -- Yes --> I
|
||||
H -- No --> J
|
||||
G -- No match found --> K
|
||||
```
|
||||
|
||||
### Key Difference from Backup-Side Precedence
|
||||
|
||||
Both sides enforce the same fundamental rule: **a per-namespace filter policy cannot re-include a resource kind that has been globally excluded**. The difference lies in which global gate enforces this constraint and how unlisted kinds are handled for namespaces *without* a matching filter policy:
|
||||
|
||||
- **Backup side**: The global exclusion gate is `includeExcludePolicy` (in the ResourcePolicy ConfigMap). It runs first at the resource-type level before any per-namespace lookup occurs. For a namespace that *has* a matching `namespacedFilterPolicies` entry, the per-namespace kind list acts as an exclusive allowlist — only listed kinds are collected, and no fallback to `BackupSpec.IncludedResources` occurs. However, any kind that `includeExcludePolicy` globally excludes remains excluded even if it appears in the per-namespace `resourceFilters`. For a namespace *without* a matching entry, the standard global filters (`BackupSpec.IncludedResources`, `BackupSpec.LabelSelector`, `includeExcludePolicy`) apply as before. See point 6 in the backup design's Filter Precedence Model (`fine-grained-backup-filters-design.md`) for the full treatment, including the warning log emitted when a per-namespace entry lists a globally excluded kind.
|
||||
- **Restore side**: The global exclusion gate is `RestoreSpec.IncludedResources`/`ExcludedResources` directly on the RestoreSpec. It runs first, globally. For a namespace that *has* a matching `namespacedFilterPolicies` entry, the per-namespace kind list acts as an exclusive allowlist within what the global gate permits — a kind must pass the global filter and be listed in `resourceFilters` to be restored. No fallback to `RestoreSpec.IncludedResources` for additional kinds occurs. For a namespace *without* a matching entry, the standard global filters apply as before. See the "Interaction with Global `IncludedResources`/`ExcludedResources`" entry in the Edge Cases section below for a detailed example.
|
||||
|
||||
In both cases, per-namespace policies are an **allowlist that operates within globally established bounds** — the label selector for a matched kind is fully replaced by the per-namespace one on both sides.
|
||||
|
||||
For label selectors, **replacement** semantics are used on both sides, because label selectors are typically workload-specific and a per-namespace selector is a complete override of the filtering intent for that namespace.
|
||||
|
||||
| | Backup | Restore |
|
||||
|---|---|---|
|
||||
| **Data source** | Live cluster — items are listed from Kubernetes API | Backup archive — items are read from tarball |
|
||||
| **Operator intent** | "What should go into the archive for this namespace?" | "Of what's in the archive, what should I restore for this namespace?" |
|
||||
| **Global exclusion gate** | `includeExcludePolicy` in ResourcePolicy ConfigMap | `RestoreSpec.IncludedResources` / `ExcludedResources` |
|
||||
| **Namespaces without a matching policy** | Fall back to `BackupSpec.IncludedResources` + `includeExcludePolicy` | Fall back to `RestoreSpec.IncludedResources` / `ExcludedResources` |
|
||||
| **Per-namespace label selector** | Replaces global label selector for that kind | Replaces global label selector for that kind |
|
||||
| **clusterScopedFilterPolicy behavior** | Refinement overlay (unlisted kinds fall back to global) | Refinement overlay (unlisted kinds fall back to global) |
|
||||
|
||||
### Data Flow in the Restore Pipeline
|
||||
|
||||
The restore pipeline has two phases: resource selection and item restore. Namespace-scoped filters are applied in both:
|
||||
|
||||
**Phase A — Resource Selection (`getOrderedResourceCollection()` + `getSelectedRestoreableItems()`)**
|
||||
|
||||
Resources are enumerated from the backup archive (not from the live cluster — this is a key difference from backup).
|
||||
|
||||
- **Resource type check** in `getOrderedResourceCollection()`: The global resource type check still applies. Within the namespace iteration, a per-namespace resource type check is added. If a filter policy matches the current namespace, only kinds listed in `resourceFilters[].kinds` (or matched by a catch-all) are restored — unlisted kinds are skipped for that namespace. Globally excluded kinds cannot be re-included by a per-namespace policy.
|
||||
- **Label selector** in `getSelectedRestoreableItems()`: The function looks up the filter policy for the current namespace and retrieves the `ResourceFilter` entry for the current resource kind. If found, it uses that entry's `labelSelector`/`orLabelSelectors` instead of the global ones. If not found, the global selectors are used as before.
|
||||
- **Name pattern check** in `getSelectedRestoreableItems()`: After the label selector check, the item's name is checked against the `ResourceFilter` entry's `names`/`excludedNames` glob patterns for the current kind.
|
||||
|
||||
**Phase B — Item Restore (`restoreItem()`)**
|
||||
|
||||
The `restoreItem()` function is called for each selected item and also for "additional items" requested by restore plugins.
|
||||
|
||||
**Important:** Like the backup-side Stage 2 which is permissive for unlisted kinds requested by plugins, the restore-side Phase B is permissive for AdditionalItems requested by plugins regarding kind, name, and label selectors. This means if a plugin requests an AdditionalItem, it bypasses the fine-grained `namespacedFilterPolicies` and `clusterScopedFilterPolicy` checks, though it must still pass global resource/namespace exclusions. This is intentional to ensure that semantic dependencies (like a PV needed by a PVC) are successfully restored even if their specific resource kind or name pattern wasn't explicitly allowed in the user's namespace-scoped filter policy.
|
||||
|
||||
### Interaction with NamespaceMapping
|
||||
|
||||
When `RestoreSpec.NamespaceMapping` remaps namespaces (e.g., `ns-a` -> `ns-a-staging`), the filter policy lookup uses the **original** (backup-side) namespace name. This is because:
|
||||
|
||||
- The filter ConfigMap is authored against the backup's namespace structure
|
||||
- The archive directory structure uses the original namespace names
|
||||
- The `getSelectedRestoreableItems()` function receives `originalNamespace` and applies mapping afterward
|
||||
|
||||
The `getNamespaceFilter()` method on `restoreContext` takes the original namespace name as input.
|
||||
|
||||
### Interaction with Existing Restore Features
|
||||
|
||||
| Feature | Interaction |
|
||||
|---|---|
|
||||
| `RestoreSpec.RestorePVs` | Orthogonal — controls PV snapshot restoration, not resource inclusion |
|
||||
| `RestoreSpec.ExistingResourcePolicy` | Orthogonal — controls overwrite behavior for resources that pass all filters |
|
||||
| `RestoreSpec.RestoreStatus` | Orthogonal — controls status field restoration for resources that pass all filters |
|
||||
| `RestoreSpec.Hooks` | Applied to resources that pass all filters. Hooks run regardless of how the item was selected |
|
||||
| `RestoreSpec.ResourceModifier` | Applied to resources that pass all filters. Modifiers run on resources after filter selection |
|
||||
| `RestoreSpec.PreserveNodePorts` | Orthogonal — applies to Services that pass all filters |
|
||||
| Restore Item Actions (plugins) | Plugins may request "additional items." These go through `restoreItem()` which permits them, bypassing the fine-grained filter checks (similar to backup side Stage 2). |
|
||||
|
||||
### Edge Cases and Behavior Documentation
|
||||
|
||||
**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.
|
||||
|
||||
**Multiple Glob Patterns Matching Same Namespace (Incorrect Order):**
|
||||
```yaml
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["team-*"] # Broader pattern listed first
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service]
|
||||
- namespaces: ["team-frontend-*"] # More specific pattern listed second
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret, Deployment, Service]
|
||||
```
|
||||
**Behavior:** For namespace `team-frontend-prod`, the broader `team-*` pattern matches first, so only `Deployment` and `Service` are restored. The more specific `team-frontend-*` rule is never reached.
|
||||
|
||||
**Multiple Glob Patterns Matching Same Namespace (Correct Order):**
|
||||
```yaml
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["team-frontend-*"] # More specific pattern listed first
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret, Deployment, Service]
|
||||
- namespaces: ["team-*"] # Broader pattern listed second
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service]
|
||||
```
|
||||
**Behavior:** For namespace `team-frontend-prod`, the specific `team-frontend-*` pattern matches first, restoring all specified resources. For `team-backend-dev`, the broader `team-*` pattern matches, restoring only `Deployment` and `Service`. This achieves the intended behavior.
|
||||
|
||||
**Namespace Included Globally But No Matching Filter Policy:**
|
||||
```yaml
|
||||
# RestoreSpec includes "production" namespace
|
||||
# ResourcePolicy has no namespacedFilterPolicies entry for "production"
|
||||
```
|
||||
**Behavior:** The namespace uses global filters exactly as it does today. This is the backward compatibility behavior.
|
||||
|
||||
**Empty ResourceFilters Array:**
|
||||
```yaml
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["test-namespace"]
|
||||
resourceFilters: [] # empty array
|
||||
```
|
||||
**Behavior:** Validation error during restore creation:
|
||||
```
|
||||
namespacedFilterPolicies[0]: at least one resourceFilter must be specified
|
||||
```
|
||||
|
||||
**Namespace Pattern with No Matches:**
|
||||
```yaml
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["nonexistent-*"]
|
||||
resourceFilters: [...]
|
||||
```
|
||||
**Behavior:** No error. The filter policy is loaded but never applied since no namespaces match the pattern.
|
||||
|
||||
**Resource Kind Not Present in Target Namespaces:**
|
||||
```yaml
|
||||
resourceFilters:
|
||||
- kinds: ["StatefulSet"] # namespace has no StatefulSets in the backup archive
|
||||
names: ["workload-1"]
|
||||
```
|
||||
**Behavior:** No error. The filter is applied but finds no matching resources. Empty result set is valid.
|
||||
|
||||
**Conflicting Name Patterns:**
|
||||
```yaml
|
||||
resourceFilters:
|
||||
- kinds: ["ConfigMap"]
|
||||
names: ["app-*"]
|
||||
excludedNames: ["app-config"] # conflicts with names pattern
|
||||
```
|
||||
**Behavior:** The `excludedNames` takes precedence. Resources matching `app-*` are included, then `app-config` is excluded. Net result: includes `app-secret`, `app-data`, etc., but excludes `app-config`.
|
||||
|
||||
**Invalid Label Selector Syntax:**
|
||||
```yaml
|
||||
resourceFilters:
|
||||
- kinds: ["Deployment"]
|
||||
labelSelector:
|
||||
"invalid label key!": "value" # invalid key syntax
|
||||
```
|
||||
**Behavior:** Validation error during restore creation when `labels.ValidatedSelectorFromSet()` fails:
|
||||
```
|
||||
namespacedFilterPolicies[0].resourceFilters[0]: invalid label selector: "invalid label key!" is not a valid label key
|
||||
```
|
||||
|
||||
**Out-of-Scope Kinds in Filter Entries:**
|
||||
A user may accidentally list a cluster-scoped kind (e.g., `ClusterRole`) inside a `namespacedFilterPolicies` entry, or a namespace-scoped kind (e.g., `ConfigMap`) inside `clusterScopedFilterPolicy`. The system silently ignores such entries at the archive traversal level: namespace-scoped items are never in the cluster-scope portion of the archive, and vice versa. A warning is logged at restore start so the user can detect the misconfiguration:
|
||||
|
||||
```
|
||||
WARN kind "ClusterRole" in namespacedFilterPolicies[0].resourceFilters[1] is a cluster-scoped resource; it will never match in a namespace-scoped filter — did you mean clusterScopedFilterPolicy?
|
||||
```
|
||||
|
||||
**Discovery Helper Unavailable:**
|
||||
If the discovery helper is unavailable during restore initialization, the restore fails with:
|
||||
```
|
||||
failed to resolve namespace filter policies: discovery client unavailable
|
||||
```
|
||||
|
||||
**Interaction with Global `IncludedResources`/`ExcludedResources`:**
|
||||
|
||||
`namespacedFilterPolicies` operates within the bounds already established by the global resource type filter — it is a refinement, not a replacement. `RestoreSpec.IncludedResources`/`ExcludedResources` is applied first at the resource-type level, before any per-namespace filter policy is consulted. A namespace-scoped filter policy cannot re-include a resource kind that has been globally excluded.
|
||||
|
||||
Two separate gates are applied in order:
|
||||
1. **`RestoreSpec.IncludedResources`/`ExcludedResources` runs first**, globally, across all namespaces. It decides which resource types are eligible at all.
|
||||
2. **`namespacedFilterPolicies` runs second**, within the bounds established by step 1. It can only further restrict kinds that survived the global gate — it cannot widen it.
|
||||
|
||||
```yaml
|
||||
# RestoreSpec
|
||||
excludedResources: [secrets] # global — Secrets excluded from all namespaces
|
||||
|
||||
# ResourcePolicy ConfigMap
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: [ns-a]
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret] # Secret listed here is ineffective — globally excluded
|
||||
labelSelector:
|
||||
app: my-app
|
||||
- kinds: [Deployment]
|
||||
```
|
||||
|
||||
**What gets restored from `ns-a`:**
|
||||
- `ConfigMap` with label `app=my-app` — restored (listed in per-namespace policy, not globally excluded)
|
||||
- `Secret` with label `app=my-app` — **not restored** (globally excluded by `ExcludedResources`, even though listed in the per-namespace policy)
|
||||
- `Deployment` — restored (listed in per-namespace policy, not globally excluded)
|
||||
|
||||
The "no fallback to `RestoreSpec.IncludedResources`" rule means that for a namespace *with* a matching policy, only the kinds listed in `resourceFilters` are candidates for restore — `RestoreSpec.IncludedResources` is not consulted to add additional kinds. The global `ExcludedResources` exclusions, however, still apply because they are enforced at an earlier, separate stage.
|
||||
|
||||
To restore `Secret` in specific namespaces, users must remove `secrets` from `ExcludedResources` globally, or restructure their policy.
|
||||
|
||||
A warning is logged at restore start when a `namespacedFilterPolicies` entry lists a kind that is globally excluded:
|
||||
```
|
||||
level=warn msg="namespacedFilterPolicies entry lists a kind that is globally excluded by RestoreSpec.ExcludedResources; the per-namespace filter entry has no effect" kind="secrets" namespacePattern="ns-a"
|
||||
```
|
||||
|
||||
> **See also:** The backup-side design's "Interaction with `includeExcludePolicy`" (point 6 in the Filter Precedence Model of `fine-grained-backup-filters-design.md`) documents the structurally identical behavior for backup. The only difference is the global gate: on the backup side it is `includeExcludePolicy` (in the ResourcePolicy ConfigMap); on the restore side it is `RestoreSpec.IncludedResources`/`ExcludedResources` (on the RestoreSpec directly).
|
||||
|
||||
# Detailed Design
|
||||
|
||||
## Workflow
|
||||
|
||||
### Restore Workflow
|
||||
|
||||
The restore workflow is preserved with the following additions. The modules in the existing restore path remain unchanged when `ResourcePolicy` is absent from `RestoreSpec`.
|
||||
|
||||
**Step 1 — Load and parse policies (in `restore_controller.go`, `validateAndComplete()`)**
|
||||
|
||||
The restore controller loads the ConfigMap, similar to how `ResourceModifier` is loaded today:
|
||||
|
||||
The loaded policies are passed through to `runValidatedRestore()` and stored on the `restore.Request`.
|
||||
|
||||
**Step 2 — Resolve namespace and cluster-scoped filter maps (in `restore.go`, `RestoreWithResolvers()`)**
|
||||
|
||||
After existing filter setup, the filter policies are resolved into the runtime maps:
|
||||
|
||||
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`
|
||||
- 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
|
||||
- Returns both the map and an ordered `namespacedFilterPatterns` slice for first-match traversal
|
||||
|
||||
**Step 3 — Per-namespace resource type check (in `restore.go`, `getOrderedResourceCollection()`)**
|
||||
|
||||
Inside the namespace iteration, after the global namespace check and global resource type check, and before calling `getSelectedRestoreableItems()`:
|
||||
|
||||
**Step 4 — Label selector and name filter (in `restore.go`, `getSelectedRestoreableItems()`)**
|
||||
|
||||
Before the items loop, resolve the effective `ResourceFilter` (hoisted for performance). The function handles three cases in order:
|
||||
|
||||
1. **Namespace-scoped item with a matching `namespacedFilterPolicies` entry** — resolve the effective `ResourceFilter` by checking the kind-specific entry first, then falling back to the catch-all
|
||||
2. **Cluster-scoped item with the kind listed in `clusterScopedFilterPolicy`** — apply that kind's label/name filters (refinement overlay; unlisted cluster-scoped kinds fall through to global)
|
||||
3. **All other cases** — fall back to the existing global label selector logic
|
||||
|
||||
**Note on cluster-scoped resources:** There is no separate kind-level skip step in `getOrderedResourceCollection()` for cluster-scoped resources analogous to Step 3. `clusterScopedFilterPolicy` is a refinement overlay — unlisted cluster-scoped kinds are not skipped; they fall through to existing global filter handling. Behavior changes only when the kind is explicitly listed in `clusterScopedFilterMap`, and only in `getSelectedRestoreableItems()` (above).
|
||||
|
||||
### Backup Workflow
|
||||
|
||||
No changes. The backup pipeline is unaffected by this design.
|
||||
|
||||
### Delete Workflow
|
||||
|
||||
No changes. Restore deletion removes the restore metadata. The backup archive is unaffected.
|
||||
|
||||
## Validation
|
||||
|
||||
The following validation is added in `restore_controller.go`'s `validateAndComplete()`:
|
||||
|
||||
1. **ConfigMap existence and format**: Handled by `GetResourcePoliciesFromRestore()`, which returns validation errors if the ConfigMap is missing, malformed, or fails `Policies.Validate()`.
|
||||
|
||||
2. **`ResourcePolicy.Kind` must be `"configmap"`** (case-insensitive): Consistent with `BackupSpec.ResourcePolicy` and `RestoreSpec.ResourceModifier`.
|
||||
|
||||
3. **Namespace filter policy validation** (delegated to `Policies.Validate()`):
|
||||
- Each filter policy must specify at least one namespace
|
||||
- Each filter policy must specify at least one resource filter
|
||||
- Each resource filter without kinds can only be defined once (at most one catch-all), and cannot specify `names`/`excludedNames`
|
||||
- No duplicate kinds across resource filter entries within the same namespace filter
|
||||
- `labelSelector` and `orLabelSelectors` cannot co-exist within each resource filter
|
||||
- No duplicate exact namespace patterns across filter policies (overlapping glob patterns are allowed — first-match semantics handle them at runtime)
|
||||
- Name/excludedNames patterns must be valid globs
|
||||
|
||||
4. **`clusterScopedFilterPolicy` validation** (delegated to `Policies.Validate()`):
|
||||
- At least one resourceFilter must be specified
|
||||
- Each resource filter must specify at least one kind — **catch-all (empty `kinds` or `["*"]`) is NOT permitted in `clusterScopedFilterPolicy`** since it is a refinement overlay rather than an allowlist
|
||||
- No duplicate kinds across resource filters
|
||||
- `labelSelector` and `orLabelSelectors` mutual exclusion
|
||||
- Resource name patterns must be valid globs
|
||||
|
||||
5. **Mutual exclusion with global `OrLabelSelectors`/`LabelSelector`**: If `namespacedFilterPolicies` are present and the `RestoreSpec` also has both `LabelSelector` and `OrLabelSelectors`, the existing validation catches this. No additional validation needed for the interaction — per-namespace selectors simply override the global ones for matching namespaces.
|
||||
|
||||
## ConfigMap Examples
|
||||
|
||||
### Restore-Specific ResourcePolicy ConfigMap
|
||||
|
||||
Restore only Deployments and ConfigMaps (labeled `app=my-app`) from `ns-a`, but everything from `ns-b`:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: restore-filter-policy
|
||||
namespace: velero
|
||||
data:
|
||||
policy: |
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, ConfigMap]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
# ns-b has no filter policy entry, so global filters apply (restore everything)
|
||||
```
|
||||
|
||||
Restore CR:
|
||||
|
||||
```yaml
|
||||
apiVersion: velero.io/v1
|
||||
kind: Restore
|
||||
metadata:
|
||||
name: selective-restore
|
||||
namespace: velero
|
||||
spec:
|
||||
backupName: full-backup
|
||||
includedNamespaces:
|
||||
- ns-a
|
||||
- ns-b
|
||||
resourcePolicy:
|
||||
kind: configmap
|
||||
name: restore-filter-policy
|
||||
```
|
||||
|
||||
### Restore with Name Pattern Filtering
|
||||
|
||||
Restore only `app-*` ConfigMaps and Secrets from `production`:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-restore-filter
|
||||
namespace: velero
|
||||
data:
|
||||
policy: |
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [ConfigMap, Secret]
|
||||
names: ["app-*"]
|
||||
excludedNames: ["*-tmp", "*-debug"]
|
||||
```
|
||||
|
||||
### Catch-All with No Label Selector (Override-Only)
|
||||
|
||||
A user may want to use the global configuration for 99% of resources in a namespace, but only apply a specific name filter to a single kind. A catch-all filter without a label selector achieves this:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: override-only-restore-policy
|
||||
namespace: velero
|
||||
data:
|
||||
policy: |
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-a
|
||||
resourceFilters:
|
||||
- kinds: [Secret]
|
||||
names: [my-secret] # Specific override for Secrets
|
||||
- kinds: ["*"] # Catch-all: NO label selector
|
||||
# Restores all other kinds unconditionally
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- `Secret` resources: only `my-secret` is restored.
|
||||
- All other resource types: restored unconditionally (acting like a global fallback).
|
||||
|
||||
### Catch-All with Per-Kind Name Overrides
|
||||
|
||||
Use exact names for specific kinds, and fall back to a label selector for all remaining kinds:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: mixed-restore-filter-policy
|
||||
namespace: velero
|
||||
data:
|
||||
policy: |
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [Deployment]
|
||||
names: [api-server, worker] # these exact Deployments by name
|
||||
- kinds: [Secret]
|
||||
names: [db-credentials, tls-cert] # these exact Secrets by name
|
||||
- kinds: ["*"] # catch-all for all other kinds
|
||||
labelSelector:
|
||||
backup: "true" # restore by label
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- `Deployment` resources: only `api-server` and `worker` are restored.
|
||||
- `Secret` resources: only `db-credentials` and `tls-cert` are restored.
|
||||
- All other resource types: restored only if they carry `backup=true`.
|
||||
|
||||
### Cluster-Scoped Filter Policy
|
||||
|
||||
Restore only specific ClusterRoles and CRDs matching a label:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: cluster-restore-filter
|
||||
namespace: velero
|
||||
data:
|
||||
policy: |
|
||||
version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: [ClusterRole, ClusterRoleBinding]
|
||||
names: ["my-app-*"]
|
||||
- kinds: [CustomResourceDefinition]
|
||||
labelSelector:
|
||||
app: my-app
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, ConfigMap, Secret, StatefulSet, PersistentVolumeClaim]
|
||||
```
|
||||
|
||||
### Restore with Glob Namespace Patterns
|
||||
|
||||
Apply the same filter to all namespaces matching a pattern. **Critical: Order patterns from most specific to least specific:**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: team-restore-filter
|
||||
namespace: velero
|
||||
data:
|
||||
policy: |
|
||||
version: v1
|
||||
namespacedFilterPolicies:
|
||||
# More specific patterns first
|
||||
- namespaces:
|
||||
- "team-frontend-prod" # Most specific (exact match)
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service, ConfigMap, Secret, PersistentVolumeClaim]
|
||||
- namespaces:
|
||||
- "team-frontend-*" # Less specific (pattern match)
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service, ConfigMap]
|
||||
- namespaces:
|
||||
- "team-*" # Least specific (broad pattern)
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, Service]
|
||||
```
|
||||
|
||||
**Pattern Matching Results:**
|
||||
- `team-frontend-prod` → Uses exact match policy (restores 5 resource types)
|
||||
- `team-frontend-dev` → Uses `team-frontend-*` policy (restores 3 resource types)
|
||||
- `team-backend-test` → Uses `team-*` policy (restores 2 resource types)
|
||||
- `app-namespace` → No match, uses global filters
|
||||
|
||||
### Same ConfigMap for Backup and Restore
|
||||
|
||||
A single ConfigMap can be referenced by both `BackupSpec.ResourcePolicy` and `RestoreSpec.ResourcePolicy`. The backup pipeline uses `volumePolicies`, `includeExcludePolicy`, `namespacedFilterPolicies`, and `clusterScopedFilterPolicy`. The restore pipeline uses only `namespacedFilterPolicies` and `clusterScopedFilterPolicy`:
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: shared-policy
|
||||
namespace: velero
|
||||
data:
|
||||
policy: |
|
||||
version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: "0,10Gi"
|
||||
action:
|
||||
type: fs-backup
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: [ClusterRole, ClusterRoleBinding]
|
||||
names: ["my-app-*"]
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- production
|
||||
resourceFilters:
|
||||
- kinds: [Deployment, ConfigMap, Secret, StatefulSet, PersistentVolumeClaim]
|
||||
```
|
||||
|
||||
### Restore CR — No ResourcePolicy (backward compatible)
|
||||
|
||||
Existing restores continue to work exactly as before:
|
||||
|
||||
```yaml
|
||||
apiVersion: velero.io/v1
|
||||
kind: Restore
|
||||
metadata:
|
||||
name: full-restore
|
||||
namespace: velero
|
||||
spec:
|
||||
backupName: my-backup
|
||||
includedNamespaces:
|
||||
- "*"
|
||||
```
|
||||
|
||||
## CLI
|
||||
|
||||
### `velero restore describe`
|
||||
|
||||
The output is extended to display resource policy configmap name when present:
|
||||
|
||||
```
|
||||
Name: selective-restore
|
||||
Namespace: velero
|
||||
Labels: <none>
|
||||
Annotations: <none>
|
||||
|
||||
Phase: Completed
|
||||
|
||||
Errors: 0
|
||||
Warnings: 0
|
||||
|
||||
Backup: full-backup
|
||||
|
||||
Namespaces:
|
||||
Included: ns-a, ns-b
|
||||
Excluded: <none>
|
||||
|
||||
Resources:
|
||||
Included: *
|
||||
Excluded: <none>
|
||||
Cluster-scoped: auto
|
||||
|
||||
Namespace Mapping: <none>
|
||||
|
||||
Label Selector: <none>
|
||||
|
||||
Resource Policy: restore-filter-policy
|
||||
|
||||
Restore PVs: auto
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
### `velero restore create`
|
||||
|
||||
A new `--resource-policies-configmap` flag is added to `velero restore create`, mirroring the existing backup-side flag:
|
||||
|
||||
```bash
|
||||
velero restore create selective-restore \
|
||||
--from-backup full-backup \
|
||||
--include-namespaces ns-a,ns-b \
|
||||
--resource-policies-configmap restore-filter-policy
|
||||
```
|
||||
|
||||
The `--help` output for `velero restore create` is updated to clarify the interaction between global and namespace-scoped filters:
|
||||
|
||||
```
|
||||
Restore Filtering Options:
|
||||
--include-namespaces stringArray namespaces to include in the restore (use '*' for all namespaces)
|
||||
--exclude-namespaces stringArray namespaces to exclude from the restore
|
||||
--include-resources stringArray resources to include in the restore, formatted as resource.group
|
||||
--exclude-resources stringArray resources to exclude from the restore, formatted as resource.group
|
||||
--include-cluster-resources optionalBool[=true] include cluster-scoped resources
|
||||
--selector labelSelector only restore resources matching this label selector
|
||||
--or-selector labelSelector restore resources matching any of the label selectors (can be repeated)
|
||||
--resource-policies-configmap string reference to a configmap containing resource policies for namespace-scoped and cluster-scoped filtering
|
||||
|
||||
Notes:
|
||||
- Global filters (--include-resources, --selector, etc.) apply to all included namespaces
|
||||
- Namespace-scoped filters defined in --resource-policies-configmap refine global filters for matching namespaces (globally excluded kinds cannot be re-included)
|
||||
- Fine-grained global filter policies defined in --resource-policies-configmap refine global filters for cluster-scoped resources
|
||||
- Use 'velero restore describe' to view resolved filter policies after restore creation
|
||||
```
|
||||
|
||||
## User Perspective
|
||||
|
||||
- **For users not using restore-side filter policies**: Zero changes. All existing restores work identically.
|
||||
- **For users adopting restore-side filter policies**: Create a ConfigMap with the `namespacedFilterPolicies` and/or `clusterScopedFilterPolicy` sections and reference it via `RestoreSpec.ResourcePolicy` (or `--resource-policies-configmap` CLI flag). The restore will selectively include/exclude resources per namespace.
|
||||
- **For users already using backup-side filter policies**: Restore-side policies are independent. A backup-side ConfigMap can be reused for restore (both `BackupSpec.ResourcePolicy` and `RestoreSpec.ResourcePolicy` can point to the same ConfigMap), or a different ConfigMap can be used.
|
||||
- **Interaction with NamespaceMapping**: Filter policies use the original (backup-side) namespace names. If `NamespaceMapping` remaps `ns-a` to `ns-b`, the filter ConfigMap should reference `ns-a`.
|
||||
- **`velero restore describe`**: Shows per-namespace and cluster-scoped filter details when `ResourcePolicy` is present.
|
||||
- **Validation errors**: Reported at restore start when the ConfigMap is invalid.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **Reuse Backup's ResourcePolicy ConfigMap**: Automatically apply the backup's `namespacedFilterPolicies` during restore without requiring restore-side configuration. Rejected because restore should be independently configurable from backup, and the backup's ConfigMap may not exist at restore time or may have been modified.
|
||||
|
||||
2. **No CRD Change — Annotation-Based Reference**: Use a Velero annotation on the Restore CR to point to the ConfigMap instead of a CRD field. Rejected because annotations are not validated, not documented via `kubectl explain`, and are inconsistent with how the backup side works.
|
||||
|
||||
3. **Embed Filter Policies in RestoreSpec (Full CRD Approach)**: Add `NamespacedFilters []NamespaceFilter` directly to `RestoreSpec`. Rejected because it requires complex nested CRD types, doesn't reuse the Phase 1 ConfigMap infrastructure, and is a drift from backup side design.
|
||||
|
||||
4. **CLI-Only (No CRD Change)**: Express restore filters entirely via CLI flags that get stored as annotations. Rejected because it doesn't support the declarative Restore CR workflow and is not auditable.
|
||||
@@ -303,6 +303,30 @@ func (p *Policies) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Policies) ValidateForRestore() error {
|
||||
if p.version != currentSupportDataVersion {
|
||||
return fmt.Errorf("incompatible version number %s with supported version %s", p.version, currentSupportDataVersion)
|
||||
}
|
||||
|
||||
if len(p.volumePolicies) > 0 {
|
||||
return fmt.Errorf("volumePolicies are not supported for restore")
|
||||
}
|
||||
|
||||
if p.GetIncludeExcludePolicy() != nil {
|
||||
return fmt.Errorf("includeExcludePolicy is not supported for restore")
|
||||
}
|
||||
|
||||
if err := p.validateClusterScopedFilterPolicy(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
if err := p.validateNamespacedFilterPolicies(); err != nil {
|
||||
return errors.WithStack(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Policies) GetIncludeExcludePolicy() *IncludeExcludePolicy {
|
||||
return p.includeExcludePolicy
|
||||
}
|
||||
@@ -331,20 +355,20 @@ func GetResourcePoliciesFromBackup(
|
||||
if err != nil {
|
||||
logger.Errorf("Fail to get ResourcePolicies %s ConfigMap with error %s.",
|
||||
backup.Namespace+"/"+backup.Spec.ResourcePolicy.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to get ResourcePolicies %s ConfigMap with error %s",
|
||||
backup.Namespace+"/"+backup.Spec.ResourcePolicy.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to get ResourcePolicies %s ConfigMap: %w",
|
||||
backup.Namespace+"/"+backup.Spec.ResourcePolicy.Name, err)
|
||||
}
|
||||
resourcePolicies, err = getResourcePoliciesFromConfig(policiesConfigMap)
|
||||
if err != nil {
|
||||
logger.Errorf("Fail to read ResourcePolicies from ConfigMap %s with error %s.",
|
||||
backup.Namespace+"/"+backup.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to read the ResourcePolicies from ConfigMap %s with error %s",
|
||||
backup.Namespace+"/"+backup.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to read the ResourcePolicies from ConfigMap %s: %w",
|
||||
backup.Namespace+"/"+backup.Name, err)
|
||||
} else if err = resourcePolicies.Validate(); err != nil {
|
||||
logger.Errorf("Fail to validate ResourcePolicies in ConfigMap %s with error %s.",
|
||||
backup.Namespace+"/"+backup.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to validate ResourcePolicies in ConfigMap %s with error %s",
|
||||
backup.Namespace+"/"+backup.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to validate ResourcePolicies in ConfigMap %s: %w",
|
||||
backup.Namespace+"/"+backup.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +449,49 @@ func GetResourcePoliciesFromBackupWithGlobal(
|
||||
return backupPolicies, nil
|
||||
}
|
||||
|
||||
// GetResourcePoliciesFromRestore retrieves the resource policies from the ConfigMap referenced in the Restore spec.
|
||||
func GetResourcePoliciesFromRestore(
|
||||
ctx context.Context,
|
||||
restore *velerov1api.Restore,
|
||||
client crclient.Client,
|
||||
logger logrus.FieldLogger,
|
||||
) (resourcePolicies *Policies, err error) {
|
||||
if restore.Spec.ResourcePolicy != nil {
|
||||
if !strings.EqualFold(restore.Spec.ResourcePolicy.Kind, ConfigmapRefType) {
|
||||
return nil, fmt.Errorf("invalid ResourcePolicy kind %q, only %q is supported",
|
||||
restore.Spec.ResourcePolicy.Kind, ConfigmapRefType)
|
||||
}
|
||||
policiesConfigMap := &corev1api.ConfigMap{}
|
||||
err = client.Get(
|
||||
ctx,
|
||||
crclient.ObjectKey{
|
||||
Namespace: restore.Namespace,
|
||||
Name: restore.Spec.ResourcePolicy.Name,
|
||||
},
|
||||
policiesConfigMap,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Errorf("Fail to get ResourcePolicies %s ConfigMap with error %s.",
|
||||
restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to get ResourcePolicies %s ConfigMap: %w",
|
||||
restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err)
|
||||
}
|
||||
resourcePolicies, err = getResourcePoliciesFromConfig(policiesConfigMap)
|
||||
if err != nil {
|
||||
logger.Errorf("Fail to read ResourcePolicies from ConfigMap %s with error %s.",
|
||||
restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to read the ResourcePolicies from ConfigMap %s: %w",
|
||||
restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err)
|
||||
} else if err = resourcePolicies.ValidateForRestore(); err != nil {
|
||||
logger.Errorf("Fail to validate ResourcePolicies in ConfigMap %s with error %s.",
|
||||
restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err.Error())
|
||||
return nil, fmt.Errorf("fail to validate ResourcePolicies in ConfigMap %s: %w",
|
||||
restore.Namespace+"/"+restore.Spec.ResourcePolicy.Name, err)
|
||||
}
|
||||
}
|
||||
return resourcePolicies, nil
|
||||
}
|
||||
|
||||
func getResourcePoliciesFromConfig(cm *corev1api.ConfigMap) (*Policies, error) {
|
||||
if cm == nil {
|
||||
return nil, fmt.Errorf("could not parse config from nil configmap")
|
||||
|
||||
@@ -16,6 +16,7 @@ limitations under the License.
|
||||
package resourcepolicies
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -24,6 +25,8 @@ import (
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
velerotest "github.com/vmware-tanzu/velero/pkg/test"
|
||||
@@ -209,6 +212,18 @@ volumePolicies:
|
||||
pvcAccessModes: ReadWriteOnce
|
||||
action:
|
||||
type: skip
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "error format of pvcAccessModes (list with non-string)",
|
||||
yamlData: `version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
pvcAccessModes:
|
||||
- 123
|
||||
action:
|
||||
type: skip
|
||||
`,
|
||||
wantErr: true,
|
||||
},
|
||||
@@ -407,14 +422,20 @@ func TestGetResourceMatchedAction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetResourcePoliciesFromConfig(t *testing.T) {
|
||||
// Create a test ConfigMap
|
||||
cm := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v1
|
||||
testCases := []struct {
|
||||
name string
|
||||
cm *corev1api.ConfigMap
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "valid configmap",
|
||||
cm: &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: '0,10Gi'
|
||||
@@ -435,63 +456,457 @@ volumePolicies:
|
||||
action:
|
||||
type: skip
|
||||
`,
|
||||
},
|
||||
},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "nil configmap",
|
||||
cm: nil,
|
||||
expectedErr: "could not parse config from nil configmap",
|
||||
},
|
||||
{
|
||||
name: "empty data configmap",
|
||||
cm: &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{},
|
||||
},
|
||||
expectedErr: "illegal resource policies test-namespace/test-configmap configmap",
|
||||
},
|
||||
{
|
||||
name: "multiple data configmap",
|
||||
cm: &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"data1": "value1",
|
||||
"data2": "value2",
|
||||
},
|
||||
},
|
||||
expectedErr: "illegal resource policies test-namespace/test-configmap configmap",
|
||||
},
|
||||
{
|
||||
name: "invalid yaml data",
|
||||
cm: &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: '0,10Gi'
|
||||
csi:
|
||||
driver: disks.csi.driver
|
||||
action:
|
||||
type: skip
|
||||
invalid-key: value
|
||||
`,
|
||||
},
|
||||
},
|
||||
expectedErr: "failed to decode yaml data into resource policies",
|
||||
},
|
||||
{
|
||||
name: "build policy error",
|
||||
cm: &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: 'invalid-capacity'
|
||||
csi:
|
||||
driver: disks.csi.driver
|
||||
action:
|
||||
type: skip
|
||||
`,
|
||||
},
|
||||
},
|
||||
expectedErr: "wrong format of Capacity invalid-capacity",
|
||||
},
|
||||
}
|
||||
|
||||
// Call the function and check for errors
|
||||
resPolicies, err := getResourcePoliciesFromConfig(cm)
|
||||
require.NoError(t, err)
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resPolicies, err := getResourcePoliciesFromConfig(tc.cm)
|
||||
if tc.expectedErr == "" {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v1", resPolicies.version)
|
||||
assert.Len(t, resPolicies.volumePolicies, 3)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tc.expectedErr)
|
||||
assert.Nil(t, resPolicies)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check that the returned resourcePolicies object contains the expected data
|
||||
assert.Equal(t, "v1", resPolicies.version)
|
||||
|
||||
assert.Len(t, resPolicies.volumePolicies, 3)
|
||||
|
||||
policies := ResourcePolicies{
|
||||
Version: "v1",
|
||||
VolumePolicies: []VolumePolicy{
|
||||
{
|
||||
Conditions: map[string]any{
|
||||
"capacity": "0,10Gi",
|
||||
"csi": map[string]any{
|
||||
"driver": "disks.csi.driver",
|
||||
},
|
||||
},
|
||||
Action: Action{
|
||||
Type: Skip,
|
||||
},
|
||||
},
|
||||
{
|
||||
Conditions: map[string]any{
|
||||
"csi": map[string]any{
|
||||
"driver": "files.csi.driver",
|
||||
"volumeAttributes": map[string]string{"protocol": "nfs"},
|
||||
},
|
||||
},
|
||||
Action: Action{
|
||||
Type: Skip,
|
||||
},
|
||||
},
|
||||
{
|
||||
Conditions: map[string]any{
|
||||
"pvcLabels": map[string]string{
|
||||
"environment": "production",
|
||||
},
|
||||
},
|
||||
Action: Action{
|
||||
Type: Skip,
|
||||
},
|
||||
},
|
||||
func TestGetResourcePoliciesFromBackup(t *testing.T) {
|
||||
validCM := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: '0,10Gi'
|
||||
csi:
|
||||
driver: disks.csi.driver
|
||||
action:
|
||||
type: skip
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
p := &Policies{}
|
||||
err = p.BuildPolicy(&policies)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to build policy: %v", err)
|
||||
invalidActionCM := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "invalid-action-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: '0,10Gi'
|
||||
csi:
|
||||
driver: disks.csi.driver
|
||||
action:
|
||||
type: invalid-action
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, p, resPolicies)
|
||||
invalidVersionCM := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "invalid-version-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v2
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: '0,10Gi'
|
||||
csi:
|
||||
driver: disks.csi.driver
|
||||
action:
|
||||
type: skip
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
emptyCM := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "empty-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(validCM, invalidActionCM, invalidVersionCM, emptyCM).Build()
|
||||
logger := logrus.New()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
backup velerov1api.Backup
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "valid configmap",
|
||||
backup: velerov1api.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-backup",
|
||||
},
|
||||
Spec: velerov1api.BackupSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "test-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "invalid kind",
|
||||
backup: velerov1api.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-backup",
|
||||
},
|
||||
Spec: velerov1api.BackupSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: "Secret",
|
||||
Name: "test-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "configmap not found",
|
||||
backup: velerov1api.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-backup",
|
||||
},
|
||||
Spec: velerov1api.BackupSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "non-existent-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "fail to get ResourcePolicies test-namespace/non-existent-configmap ConfigMap",
|
||||
},
|
||||
{
|
||||
name: "invalid action configmap",
|
||||
backup: velerov1api.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-backup",
|
||||
},
|
||||
Spec: velerov1api.BackupSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "invalid-action-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "fail to validate ResourcePolicies in ConfigMap test-namespace/test-backup",
|
||||
},
|
||||
{
|
||||
name: "invalid version configmap",
|
||||
backup: velerov1api.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-backup",
|
||||
},
|
||||
Spec: velerov1api.BackupSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "invalid-version-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "fail to validate ResourcePolicies in ConfigMap test-namespace/test-backup",
|
||||
},
|
||||
{
|
||||
name: "empty configmap",
|
||||
backup: velerov1api.Backup{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-backup",
|
||||
},
|
||||
Spec: velerov1api.BackupSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "empty-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "fail to read the ResourcePolicies from ConfigMap test-namespace/test-backup",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resPolicies, err := GetResourcePoliciesFromBackup(tc.backup, client, logger)
|
||||
if tc.expectedErr == "" {
|
||||
require.NoError(t, err)
|
||||
if tc.backup.Spec.ResourcePolicy != nil && tc.backup.Spec.ResourcePolicy.Kind == ConfigmapRefType {
|
||||
assert.NotNil(t, resPolicies)
|
||||
} else {
|
||||
assert.Nil(t, resPolicies)
|
||||
}
|
||||
} else {
|
||||
require.ErrorContains(t, err, tc.expectedErr)
|
||||
assert.Nil(t, resPolicies)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetResourcePoliciesFromRestore(t *testing.T) {
|
||||
validCM := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["default"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
invalidNfpCM := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "invalid-action-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: []
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
invalidVersionCM := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "invalid-version-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"test-data": `version: v2
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["default"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
emptyCM := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "empty-configmap",
|
||||
Namespace: "test-namespace",
|
||||
},
|
||||
}
|
||||
|
||||
client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(validCM, invalidNfpCM, invalidVersionCM, emptyCM).Build()
|
||||
logger := logrus.New()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
restore *velerov1api.Restore
|
||||
expectedErr string
|
||||
}{
|
||||
{
|
||||
name: "valid configmap",
|
||||
restore: &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-restore",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "test-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "invalid kind",
|
||||
restore: &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-restore",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: "Secret",
|
||||
Name: "test-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "invalid ResourcePolicy kind \"Secret\", only \"configmap\" is supported",
|
||||
},
|
||||
{
|
||||
name: "configmap not found",
|
||||
restore: &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-restore",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "non-existent-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "fail to get ResourcePolicies test-namespace/non-existent-configmap ConfigMap",
|
||||
},
|
||||
{
|
||||
name: "invalid action configmap",
|
||||
restore: &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-restore",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "invalid-action-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "fail to validate ResourcePolicies in ConfigMap test-namespace/invalid-action-configmap",
|
||||
},
|
||||
{
|
||||
name: "invalid version configmap",
|
||||
restore: &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-restore",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "invalid-version-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "fail to validate ResourcePolicies in ConfigMap test-namespace/invalid-version-configmap",
|
||||
},
|
||||
{
|
||||
name: "empty configmap",
|
||||
restore: &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: "test-namespace",
|
||||
Name: "test-restore",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: ConfigmapRefType,
|
||||
Name: "empty-configmap",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErr: "fail to read the ResourcePolicies from ConfigMap test-namespace/empty-configmap",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resPolicies, err := GetResourcePoliciesFromRestore(context.Background(), tc.restore, client, logger)
|
||||
if tc.expectedErr == "" {
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, resPolicies)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tc.expectedErr)
|
||||
assert.Nil(t, resPolicies)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMatchAction(t *testing.T) {
|
||||
@@ -1788,6 +2203,17 @@ namespacedFilterPolicies:
|
||||
wantErr: true,
|
||||
errMsg: "invalid glob pattern",
|
||||
},
|
||||
{
|
||||
name: "invalid - bad glob pattern in excludedNames",
|
||||
yamlData: `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["test"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]
|
||||
excludedNames: ["[invalid"]`,
|
||||
wantErr: true,
|
||||
errMsg: "invalid glob pattern",
|
||||
},
|
||||
{
|
||||
name: "invalid - duplicate namespace pattern",
|
||||
yamlData: `version: v1
|
||||
@@ -1801,6 +2227,16 @@ namespacedFilterPolicies:
|
||||
wantErr: true,
|
||||
errMsg: "duplicate namespace pattern",
|
||||
},
|
||||
{
|
||||
name: "invalid - bad namespace pattern",
|
||||
yamlData: `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["prod**uction"]
|
||||
resourceFilters:
|
||||
- kinds: ["Pod"]`,
|
||||
wantErr: true,
|
||||
errMsg: "wildcard pattern contains consecutive asterisks",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
@@ -1857,6 +2293,50 @@ namespacedFilterPolicies:
|
||||
assert.Equal(t, map[string]string{"app": "web"}, rf.LabelSelector)
|
||||
}
|
||||
|
||||
func TestClusterScopedFilterPoliciesAccessor(t *testing.T) {
|
||||
yamlData := `version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["ClusterRole"]
|
||||
names: ["my-app-*"]`
|
||||
|
||||
resPolicies, err := unmarshalResourcePolicies(&yamlData)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies := &Policies{}
|
||||
err = policies.BuildPolicy(resPolicies)
|
||||
require.NoError(t, err)
|
||||
|
||||
csfPolicy := policies.GetClusterScopedFilterPolicy()
|
||||
require.NotNil(t, csfPolicy)
|
||||
assert.Len(t, csfPolicy.ResourceFilters, 1)
|
||||
|
||||
rf := csfPolicy.ResourceFilters[0]
|
||||
assert.Equal(t, []string{"ClusterRole"}, rf.Kinds)
|
||||
assert.Equal(t, []string{"my-app-*"}, rf.Names)
|
||||
}
|
||||
|
||||
func TestIncludeExcludePolicyAccessor(t *testing.T) {
|
||||
yamlData := `version: v1
|
||||
includeExcludePolicy:
|
||||
includedClusterScopedResources:
|
||||
- ClusterRole
|
||||
excludedClusterScopedResources:
|
||||
- ClusterRoleBinding`
|
||||
|
||||
resPolicies, err := unmarshalResourcePolicies(&yamlData)
|
||||
require.NoError(t, err)
|
||||
|
||||
policies := &Policies{}
|
||||
err = policies.BuildPolicy(resPolicies)
|
||||
require.NoError(t, err)
|
||||
|
||||
iePolicy := policies.GetIncludeExcludePolicy()
|
||||
require.NotNil(t, iePolicy)
|
||||
assert.Equal(t, []string{"ClusterRole"}, iePolicy.IncludedClusterScopedResources)
|
||||
assert.Equal(t, []string{"ClusterRoleBinding"}, iePolicy.ExcludedClusterScopedResources)
|
||||
}
|
||||
|
||||
func TestFirstMatchSemantics(t *testing.T) {
|
||||
yamlData := `version: v1
|
||||
namespacedFilterPolicies:
|
||||
|
||||
@@ -568,3 +568,85 @@ func TestValidate(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForRestore(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
res *ResourcePolicies
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid restore policies",
|
||||
res: &ResourcePolicies{
|
||||
Version: "v1",
|
||||
ClusterScopedFilterPolicy: &ClusterScopedFilterPolicy{
|
||||
ResourceFilters: []ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"ClusterRole"},
|
||||
},
|
||||
},
|
||||
},
|
||||
NamespacedFilterPolicies: []NamespacedFilterPolicy{
|
||||
{
|
||||
Namespaces: []string{"default"},
|
||||
ResourceFilters: []ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"Pod"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unsupported volumePolicies for restore",
|
||||
res: &ResourcePolicies{
|
||||
Version: "v1",
|
||||
VolumePolicies: []VolumePolicy{
|
||||
{
|
||||
Action: Action{Type: "skip"},
|
||||
Conditions: map[string]any{
|
||||
"capacity": "10Gi",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported includeExcludePolicy for restore",
|
||||
res: &ResourcePolicies{
|
||||
Version: "v1",
|
||||
IncludeExcludePolicy: &IncludeExcludePolicy{
|
||||
IncludedClusterScopedResources: []string{"persistentvolumes"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong version",
|
||||
res: &ResourcePolicies{
|
||||
Version: "v2",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
policies := &Policies{}
|
||||
err1 := policies.BuildPolicy(tc.res)
|
||||
err2 := policies.ValidateForRestore()
|
||||
|
||||
if tc.wantErr {
|
||||
if err1 == nil && err2 == nil {
|
||||
t.Fatalf("Expected error %v, but not get error", tc.wantErr)
|
||||
}
|
||||
} else {
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("Expected error %v, but got error %v %v", tc.wantErr, err1, err2)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,16 @@ type RestoreSpec struct {
|
||||
// +nullable
|
||||
ResourceModifier *corev1api.TypedLocalObjectReference `json:"resourceModifier,omitempty"`
|
||||
|
||||
// ResourcePolicy specifies the reference to a ConfigMap containing resource
|
||||
// filter policies for this restore. The ConfigMap can contain a
|
||||
// namespacedFilterPolicies section that specifies per-namespace resource type
|
||||
// filters, label selectors, and resource name patterns, and a
|
||||
// clusterScopedFilterPolicy section for per-kind filtering of cluster-scoped
|
||||
// resources. The ConfigMap format is the same as for BackupSpec.ResourcePolicy.
|
||||
// +optional
|
||||
// +nullable
|
||||
ResourcePolicy *corev1api.TypedLocalObjectReference `json:"resourcePolicy,omitempty"`
|
||||
|
||||
// UploaderConfig specifies the configuration for the restore.
|
||||
// +optional
|
||||
// +nullable
|
||||
|
||||
@@ -1415,6 +1415,11 @@ func (in *RestoreSpec) DeepCopyInto(out *RestoreSpec) {
|
||||
*out = new(corev1.TypedLocalObjectReference)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.ResourcePolicy != nil {
|
||||
in, out := &in.ResourcePolicy, &out.ResourcePolicy
|
||||
*out = new(corev1.TypedLocalObjectReference)
|
||||
(*in).DeepCopyInto(*out)
|
||||
}
|
||||
if in.UploaderConfig != nil {
|
||||
in, out := &in.UploaderConfig, &out.UploaderConfig
|
||||
*out = new(UploaderConfigForRestore)
|
||||
|
||||
@@ -19,6 +19,7 @@ package builder
|
||||
import (
|
||||
"time"
|
||||
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
@@ -171,3 +172,12 @@ func (b *RestoreBuilder) ItemOperationTimeout(timeout time.Duration) *RestoreBui
|
||||
b.object.Spec.ItemOperationTimeout.Duration = timeout
|
||||
return b
|
||||
}
|
||||
|
||||
// ResourcePoliciesConfigmap sets the Restore's resource policies configmap.
|
||||
func (b *RestoreBuilder) ResourcePoliciesConfigmap(name string) *RestoreBuilder {
|
||||
b.object.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{
|
||||
Kind: "configmap",
|
||||
Name: name,
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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 builder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestRestoreBuilder_ResourcePoliciesConfigmap(t *testing.T) {
|
||||
restore := ForRestore("velero", "my-restore").
|
||||
ResourcePoliciesConfigmap("my-policy-cm").
|
||||
Result()
|
||||
|
||||
assert.Equal(t, "velero", restore.Namespace)
|
||||
assert.Equal(t, "my-restore", restore.Name)
|
||||
assert.NotNil(t, restore.Spec.ResourcePolicy)
|
||||
assert.Equal(t, "configmap", restore.Spec.ResourcePolicy.Kind)
|
||||
assert.Equal(t, "my-policy-cm", restore.Spec.ResourcePolicy.Name)
|
||||
assert.Equal(t, (*string)(nil), restore.Spec.ResourcePolicy.APIGroup)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
kbclient "sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
|
||||
"github.com/vmware-tanzu/velero/internal/resourcepolicies"
|
||||
api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
"github.com/vmware-tanzu/velero/pkg/client"
|
||||
"github.com/vmware-tanzu/velero/pkg/cmd"
|
||||
@@ -61,7 +62,13 @@ func NewCreateCommand(f client.Factory, use string) *cobra.Command {
|
||||
velero restore create --from-schedule schedule-1 --allow-partially-failed
|
||||
|
||||
# Create a restore for only persistentvolumeclaims and persistentvolumes within a backup.
|
||||
velero restore create --from-backup backup-2 --include-resources persistentvolumeclaims,persistentvolumes`,
|
||||
velero restore create --from-backup backup-2 --include-resources persistentvolumeclaims,persistentvolumes
|
||||
|
||||
Notes:
|
||||
- Global filters (--include-resources, --selector, etc.) apply to all included namespaces
|
||||
- Namespace-scoped filters defined in --resource-policies-configmap refine global filters for matching namespaces (globally excluded kinds cannot be re-included)
|
||||
- Fine-grained global filter policies defined in --resource-policies-configmap refine global filters for cluster-scoped resources
|
||||
- Use 'velero restore describe' to view the referenced resource policies ConfigMap after restore creation`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(c *cobra.Command, args []string) {
|
||||
cmd.CheckError(o.Complete(args, f))
|
||||
@@ -100,6 +107,7 @@ type CreateOptions struct {
|
||||
AllowPartiallyFailed flag.OptionalBool
|
||||
ItemOperationTimeout time.Duration
|
||||
ResourceModifierConfigMap string
|
||||
ResourcePoliciesConfigMap string
|
||||
WriteSparseFiles flag.OptionalBool
|
||||
ParallelFilesDownload int
|
||||
client kbclient.WithWatch
|
||||
@@ -154,6 +162,8 @@ func (o *CreateOptions) BindFlags(flags *pflag.FlagSet) {
|
||||
|
||||
flags.StringVar(&o.ResourceModifierConfigMap, "resource-modifier-configmap", "", "Reference to the resource modifier configmap that restore will use")
|
||||
|
||||
flags.StringVar(&o.ResourcePoliciesConfigMap, "resource-policies-configmap", "", "Reference to the ConfigMap containing restore resource filter policies")
|
||||
|
||||
f = flags.VarPF(&o.WriteSparseFiles, "write-sparse-files", "", "Whether to write sparse files during restoring volumes")
|
||||
f.NoOptDefVal = cmd.TRUE
|
||||
|
||||
@@ -310,6 +320,15 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error {
|
||||
}
|
||||
}
|
||||
|
||||
var resPolicies *corev1api.TypedLocalObjectReference
|
||||
|
||||
if o.ResourcePoliciesConfigMap != "" {
|
||||
resPolicies = &corev1api.TypedLocalObjectReference{
|
||||
Kind: resourcepolicies.ConfigmapRefType,
|
||||
Name: o.ResourcePoliciesConfigMap,
|
||||
}
|
||||
}
|
||||
|
||||
restore := &api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: f.Namespace(),
|
||||
@@ -332,6 +351,7 @@ func (o *CreateOptions) Run(c *cobra.Command, f client.Factory) error {
|
||||
PreserveNodePorts: o.PreserveNodePorts.Value,
|
||||
IncludeClusterResources: o.IncludeClusterResources.Value,
|
||||
ResourceModifier: resModifiers,
|
||||
ResourcePolicy: resPolicies,
|
||||
ItemOperationTimeout: metav1.Duration{
|
||||
Duration: o.ItemOperationTimeout,
|
||||
},
|
||||
|
||||
@@ -77,6 +77,8 @@ func TestCreateCommand(t *testing.T) {
|
||||
includeClusterResources := "true"
|
||||
allowPartiallyFailed := "true"
|
||||
itemOperationTimeout := "10m0s"
|
||||
resourceModifierConfigMap := "modifier-cm"
|
||||
ResourcePoliciesConfigMap := "policies-cm"
|
||||
writeSparseFiles := "true"
|
||||
parallel := 2
|
||||
flags := new(pflag.FlagSet)
|
||||
@@ -101,6 +103,8 @@ func TestCreateCommand(t *testing.T) {
|
||||
flags.Parse([]string{"--include-cluster-resources", includeClusterResources})
|
||||
flags.Parse([]string{"--allow-partially-failed", allowPartiallyFailed})
|
||||
flags.Parse([]string{"--item-operation-timeout", itemOperationTimeout})
|
||||
flags.Parse([]string{"--resource-modifier-configmap", resourceModifierConfigMap})
|
||||
flags.Parse([]string{"--resource-policies-configmap", ResourcePoliciesConfigMap})
|
||||
flags.Parse([]string{"--write-sparse-files", writeSparseFiles})
|
||||
flags.Parse([]string{"--parallel-files-download", "2"})
|
||||
client := velerotest.NewFakeControllerRuntimeClient(t).(kbclient.WithWatch)
|
||||
@@ -139,6 +143,8 @@ func TestCreateCommand(t *testing.T) {
|
||||
require.Equal(t, includeClusterResources, o.IncludeClusterResources.String())
|
||||
require.Equal(t, allowPartiallyFailed, o.AllowPartiallyFailed.String())
|
||||
require.Equal(t, itemOperationTimeout, o.ItemOperationTimeout.String())
|
||||
require.Equal(t, resourceModifierConfigMap, o.ResourceModifierConfigMap)
|
||||
require.Equal(t, ResourcePoliciesConfigMap, o.ResourcePoliciesConfigMap)
|
||||
require.Equal(t, writeSparseFiles, o.WriteSparseFiles.String())
|
||||
require.Equal(t, parallel, o.ParallelFilesDownload)
|
||||
})
|
||||
@@ -189,4 +195,37 @@ func TestCreateCommand(t *testing.T) {
|
||||
err := o.Validate(c, []string{}, f)
|
||||
require.Equal(t, "backups.velero.io \"not-exist\" not found", err.Error())
|
||||
})
|
||||
|
||||
t.Run("create a restore with resource policies configmap", func(t *testing.T) {
|
||||
f := &factorymocks.Factory{}
|
||||
c := NewCreateCommand(f, "")
|
||||
require.Equal(t, "Create a restore", c.Short)
|
||||
flags := new(pflag.FlagSet)
|
||||
o := NewCreateOptions()
|
||||
o.BindFlags(flags)
|
||||
|
||||
backupName := "backup-with-policies"
|
||||
ResourcePoliciesConfigMap := "test-policies-cm"
|
||||
flags.Parse([]string{"--from-backup", backupName})
|
||||
flags.Parse([]string{"--resource-policies-configmap", ResourcePoliciesConfigMap})
|
||||
|
||||
kbclient := velerotest.NewFakeControllerRuntimeClient(t).(kbclient.WithWatch)
|
||||
backup := builder.ForBackup(cmdtest.VeleroNameSpace, backupName).Phase(velerov1api.BackupPhaseCompleted).Result()
|
||||
require.NoError(t, kbclient.Create(t.Context(), backup, &controllerclient.CreateOptions{}))
|
||||
|
||||
f.On("Namespace").Return(cmdtest.VeleroNameSpace)
|
||||
f.On("KubebuilderWatchClient").Return(kbclient, nil)
|
||||
|
||||
require.NoError(t, o.Complete(args, f))
|
||||
require.NoError(t, o.Validate(c, []string{}, f))
|
||||
require.NoError(t, o.Run(c, f))
|
||||
|
||||
// Verify the created restore object
|
||||
createdRestore := &velerov1api.Restore{}
|
||||
err := kbclient.Get(t.Context(), controllerclient.ObjectKey{Namespace: cmdtest.VeleroNameSpace, Name: name}, createdRestore)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, createdRestore.Spec.ResourcePolicy)
|
||||
require.Equal(t, "configmap", createdRestore.Spec.ResourcePolicy.Kind)
|
||||
require.Equal(t, ResourcePoliciesConfigMap, createdRestore.Spec.ResourcePolicy.Name)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -219,6 +219,11 @@ func DescribeRestore(
|
||||
DescribeResourceModifier(d, restore.Spec.ResourceModifier)
|
||||
}
|
||||
|
||||
if restore.Spec.ResourcePolicy != nil {
|
||||
d.Println()
|
||||
DescribeResourcePolicies(d, restore.Spec.ResourcePolicy)
|
||||
}
|
||||
|
||||
describeUploaderConfigForRestore(d, restore.Spec)
|
||||
|
||||
d.Println()
|
||||
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
|
||||
"github.com/vmware-tanzu/velero/internal/hook"
|
||||
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
|
||||
"github.com/vmware-tanzu/velero/internal/resourcepolicies"
|
||||
"github.com/vmware-tanzu/velero/internal/volume"
|
||||
api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
"github.com/vmware-tanzu/velero/pkg/constant"
|
||||
@@ -232,7 +233,7 @@ func (r *restoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
|
||||
original := restore.DeepCopy()
|
||||
|
||||
// Validate the restore and fetch the backup
|
||||
info, resourceModifiers := r.validateAndComplete(restore)
|
||||
info, resourceModifiers, restoreResPolicies := r.validateAndComplete(ctx, restore)
|
||||
|
||||
// Register attempts after validation so we don't have to fetch the backup multiple times
|
||||
backupScheduleName := restore.Spec.ScheduleName
|
||||
@@ -267,7 +268,7 @@ func (r *restoreReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if err := r.runValidatedRestore(restore, info, resourceModifiers); err != nil {
|
||||
if err := r.runValidatedRestore(restore, info, resourceModifiers, restoreResPolicies); err != nil {
|
||||
log.WithError(err).Debug("Restore failed")
|
||||
restore.Status.Phase = api.RestorePhaseFailed
|
||||
restore.Status.FailureReason = err.Error()
|
||||
@@ -303,7 +304,7 @@ func (r *restoreReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
func (r *restoreReconciler) validateAndComplete(restore *api.Restore) (backupInfo, *resourcemodifiers.ResourceModifiers) {
|
||||
func (r *restoreReconciler) validateAndComplete(ctx context.Context, restore *api.Restore) (backupInfo, *resourcemodifiers.ResourceModifiers, *resourcepolicies.Policies) {
|
||||
// add non-restorable resources to restore's excluded resources
|
||||
excludedResources := sets.NewString(restore.Spec.ExcludedResources...)
|
||||
for _, nonrestorable := range nonRestorableResources {
|
||||
@@ -338,7 +339,7 @@ func (r *restoreReconciler) validateAndComplete(restore *api.Restore) (backupInf
|
||||
// validate that exactly one of BackupName and ScheduleName have been specified
|
||||
if !backupXorScheduleProvided(restore) {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, "Either a backup or schedule must be specified as a source for the restore, but not both")
|
||||
return backupInfo{}, nil
|
||||
return backupInfo{}, nil, nil
|
||||
}
|
||||
|
||||
// validate Restore Init Hook's InitContainers
|
||||
@@ -372,9 +373,9 @@ func (r *restoreReconciler) validateAndComplete(restore *api.Restore) (backupInf
|
||||
}))
|
||||
|
||||
backupList := &api.BackupList{}
|
||||
if err := r.kbClient.List(context.Background(), backupList, &client.ListOptions{LabelSelector: selector}); err != nil {
|
||||
if err := r.kbClient.List(ctx, backupList, &client.ListOptions{LabelSelector: selector}); err != nil {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, "Unable to list backups for schedule")
|
||||
return backupInfo{}, nil
|
||||
return backupInfo{}, nil, nil
|
||||
}
|
||||
if len(backupList.Items) == 0 {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, "No backups found for schedule")
|
||||
@@ -384,19 +385,19 @@ func (r *restoreReconciler) validateAndComplete(restore *api.Restore) (backupInf
|
||||
restore.Spec.BackupName = backup.Name
|
||||
} else {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, "No completed backups found for schedule")
|
||||
return backupInfo{}, nil
|
||||
return backupInfo{}, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
info, err := r.fetchBackupInfo(restore.Spec.BackupName)
|
||||
if err != nil {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, fmt.Sprintf("Error retrieving backup: %v", err))
|
||||
return backupInfo{}, nil
|
||||
return backupInfo{}, nil, nil
|
||||
}
|
||||
|
||||
if !veleroutil.BSLIsAvailable(*info.location) {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, fmt.Sprintf("The BSL %s is unavailable, cannot retrieve the backup", info.location.Name))
|
||||
return backupInfo{}, nil
|
||||
return backupInfo{}, nil, nil
|
||||
}
|
||||
|
||||
// Fill in the ScheduleName so it's easier to consume for metrics.
|
||||
@@ -404,26 +405,40 @@ func (r *restoreReconciler) validateAndComplete(restore *api.Restore) (backupInf
|
||||
restore.Spec.ScheduleName = info.backup.GetLabels()[api.ScheduleNameLabel]
|
||||
}
|
||||
|
||||
var restoreResPolicies *resourcepolicies.Policies
|
||||
if restore.Spec.ResourcePolicy != nil {
|
||||
var err error
|
||||
restoreResPolicies, err = resourcepolicies.GetResourcePoliciesFromRestore(
|
||||
ctx, restore, r.kbClient, r.logger,
|
||||
)
|
||||
if err != nil {
|
||||
restore.Status.ValidationErrors = append(
|
||||
restore.Status.ValidationErrors, err.Error(),
|
||||
)
|
||||
return backupInfo{}, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
var resourceModifiers *resourcemodifiers.ResourceModifiers
|
||||
if restore.Spec.ResourceModifier != nil && strings.EqualFold(restore.Spec.ResourceModifier.Kind, resourcemodifiers.ConfigmapRefType) {
|
||||
ResourceModifierConfigMap := &corev1api.ConfigMap{}
|
||||
err := r.kbClient.Get(context.Background(), client.ObjectKey{Namespace: restore.Namespace, Name: restore.Spec.ResourceModifier.Name}, ResourceModifierConfigMap)
|
||||
err := r.kbClient.Get(ctx, client.ObjectKey{Namespace: restore.Namespace, Name: restore.Spec.ResourceModifier.Name}, ResourceModifierConfigMap)
|
||||
if err != nil {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, fmt.Sprintf("failed to get resource modifiers configmap %s/%s", restore.Namespace, restore.Spec.ResourceModifier.Name))
|
||||
return backupInfo{}, nil
|
||||
return backupInfo{}, nil, nil
|
||||
}
|
||||
resourceModifiers, err = resourcemodifiers.GetResourceModifiersFromConfig(ResourceModifierConfigMap)
|
||||
if err != nil {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, errors.Wrapf(err, "Error in parsing resource modifiers provided in configmap %s/%s", restore.Namespace, restore.Spec.ResourceModifier.Name).Error())
|
||||
return backupInfo{}, nil
|
||||
return backupInfo{}, nil, nil
|
||||
} else if err = resourceModifiers.Validate(); err != nil {
|
||||
restore.Status.ValidationErrors = append(restore.Status.ValidationErrors, errors.Wrapf(err, "Validation error in resource modifiers provided in configmap %s/%s", restore.Namespace, restore.Spec.ResourceModifier.Name).Error())
|
||||
return backupInfo{}, nil
|
||||
return backupInfo{}, nil, nil
|
||||
}
|
||||
r.logger.Infof("Retrieved Resource modifiers provided in configmap %s/%s", restore.Namespace, restore.Spec.ResourceModifier.Name)
|
||||
}
|
||||
|
||||
return info, resourceModifiers
|
||||
return info, resourceModifiers, restoreResPolicies
|
||||
}
|
||||
|
||||
// backupXorScheduleProvided returns true if exactly one of BackupName and
|
||||
@@ -496,7 +511,7 @@ func fetchBackupInfoInternal(kbClient client.Client, namespace, backupName strin
|
||||
// The log and results files are uploaded to backup storage. Any error returned from this function
|
||||
// means that the restore failed. This function updates the restore API object with warning and error
|
||||
// counts, but *does not* update its phase or patch it via the API.
|
||||
func (r *restoreReconciler) runValidatedRestore(restore *api.Restore, info backupInfo, resourceModifiers *resourcemodifiers.ResourceModifiers) error {
|
||||
func (r *restoreReconciler) runValidatedRestore(restore *api.Restore, info backupInfo, resourceModifiers *resourcemodifiers.ResourceModifiers, restoreResPolicies *resourcepolicies.Policies) error {
|
||||
// instantiate the per-restore logger that will output both to a temp file
|
||||
// (for upload to object storage) and to stdout.
|
||||
restoreLog, err := logging.NewTempFileLogger(r.restoreLogLevel, r.logFormat, nil, logrus.Fields{"restore": kubeutil.NamespaceAndName(restore)})
|
||||
@@ -575,6 +590,7 @@ func (r *restoreReconciler) runValidatedRestore(restore *api.Restore, info backu
|
||||
VolumeSnapshots: volumeSnapshots,
|
||||
BackupReader: backupFile,
|
||||
ResourceModifiers: resourceModifiers,
|
||||
ResPolicies: restoreResPolicies,
|
||||
DisableInformerCache: r.disableInformerCache,
|
||||
CSIVolumeSnapshots: csiVolumeSnapshots,
|
||||
BackupVolumeInfoMap: backupVolumeInfoMap,
|
||||
|
||||
@@ -747,7 +747,7 @@ func TestValidateAndCompleteWhenScheduleNameSpecified(t *testing.T) {
|
||||
Phase(velerov1api.BackupPhaseCompleted).
|
||||
Result()))
|
||||
|
||||
r.validateAndComplete(restore)
|
||||
r.validateAndComplete(t.Context(), restore)
|
||||
assert.Contains(t, restore.Status.ValidationErrors, "No backups found for schedule")
|
||||
assert.Empty(t, restore.Spec.BackupName)
|
||||
|
||||
@@ -763,7 +763,7 @@ func TestValidateAndCompleteWhenScheduleNameSpecified(t *testing.T) {
|
||||
Result(),
|
||||
))
|
||||
|
||||
r.validateAndComplete(restore)
|
||||
r.validateAndComplete(t.Context(), restore)
|
||||
assert.Contains(t, restore.Status.ValidationErrors, "No completed backups found for schedule")
|
||||
assert.Empty(t, restore.Spec.BackupName)
|
||||
|
||||
@@ -794,11 +794,140 @@ func TestValidateAndCompleteWhenScheduleNameSpecified(t *testing.T) {
|
||||
ScheduleName: "schedule-1",
|
||||
},
|
||||
}
|
||||
r.validateAndComplete(restore)
|
||||
r.validateAndComplete(t.Context(), restore)
|
||||
assert.Nil(t, restore.Status.ValidationErrors)
|
||||
assert.Equal(t, "foo", restore.Spec.BackupName)
|
||||
}
|
||||
|
||||
func TestValidateAndCompleteWithResourcePolicySpecified(t *testing.T) {
|
||||
formatFlag := logging.FormatText
|
||||
|
||||
var (
|
||||
logger = velerotest.NewLogger()
|
||||
pluginManager = &pluginmocks.Manager{}
|
||||
fakeClient = velerotest.NewFakeControllerRuntimeClient(t)
|
||||
fakeGlobalClient = velerotest.NewFakeControllerRuntimeClient(t)
|
||||
backupStore = &persistencemocks.BackupStore{}
|
||||
)
|
||||
|
||||
r := NewRestoreReconciler(
|
||||
t.Context(),
|
||||
velerov1api.DefaultNamespace,
|
||||
nil,
|
||||
fakeClient,
|
||||
logger,
|
||||
logrus.DebugLevel,
|
||||
func(logrus.FieldLogger) clientmgmt.Manager { return pluginManager },
|
||||
NewFakeSingleObjectBackupStoreGetter(backupStore),
|
||||
metrics.NewServerMetrics(),
|
||||
formatFlag,
|
||||
60*time.Minute,
|
||||
false,
|
||||
fakeGlobalClient,
|
||||
10*time.Minute,
|
||||
)
|
||||
|
||||
restore := &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: velerov1api.DefaultNamespace,
|
||||
Name: "restore-1",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
BackupName: "backup-1",
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: "configmap",
|
||||
Name: "test-configmap",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
location := builder.ForBackupStorageLocation("velero", "default").Provider("myCloud").Bucket("bucket").Phase(velerov1api.BackupStorageLocationPhaseAvailable).Result()
|
||||
require.NoError(t, r.kbClient.Create(t.Context(), location))
|
||||
|
||||
require.NoError(t, r.kbClient.Create(
|
||||
t.Context(),
|
||||
defaultBackup().
|
||||
ObjectMeta(
|
||||
builder.WithName("backup-1"),
|
||||
).StorageLocation("default").
|
||||
Phase(velerov1api.BackupPhaseCompleted).
|
||||
Result(),
|
||||
))
|
||||
|
||||
r.validateAndComplete(t.Context(), restore)
|
||||
assert.Contains(t, restore.Status.ValidationErrors[0], "fail to get ResourcePolicies velero/test-configmap ConfigMap")
|
||||
|
||||
restore1 := &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: velerov1api.DefaultNamespace,
|
||||
Name: "restore-1",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
BackupName: "backup-1",
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
Kind: "configmap",
|
||||
Name: "test-configmap",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cm1 := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap",
|
||||
Namespace: velerov1api.DefaultNamespace,
|
||||
},
|
||||
Data: map[string]string{
|
||||
"policy.yaml": `version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds:
|
||||
- pods
|
||||
`,
|
||||
},
|
||||
}
|
||||
require.NoError(t, r.kbClient.Create(t.Context(), cm1))
|
||||
|
||||
r.validateAndComplete(t.Context(), restore1)
|
||||
assert.Nil(t, restore1.Status.ValidationErrors)
|
||||
|
||||
restore2 := &velerov1api.Restore{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Namespace: velerov1api.DefaultNamespace,
|
||||
Name: "restore-1",
|
||||
},
|
||||
Spec: velerov1api.RestoreSpec{
|
||||
BackupName: "backup-1",
|
||||
ResourcePolicy: &corev1api.TypedLocalObjectReference{
|
||||
// intentional to ensure case insensitivity works as expected
|
||||
Kind: "confIGMaP",
|
||||
Name: "test-configmap-invalid",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cm2 := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-configmap-invalid",
|
||||
Namespace: velerov1api.DefaultNamespace,
|
||||
},
|
||||
Data: map[string]string{
|
||||
"policy.yaml": `version: v1
|
||||
volumePolicies:
|
||||
- conditions:
|
||||
capacity: '0,10Gi'
|
||||
csi:
|
||||
driver: disks.csi.driver
|
||||
action:
|
||||
type: invalid_action
|
||||
`,
|
||||
},
|
||||
}
|
||||
require.NoError(t, r.kbClient.Create(t.Context(), cm2))
|
||||
|
||||
r.validateAndComplete(t.Context(), restore2)
|
||||
assert.Contains(t, restore2.Status.ValidationErrors[0], "fail to validate ResourcePolicies in ConfigMap velero/test-configmap-invalid")
|
||||
}
|
||||
|
||||
func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
|
||||
formatFlag := logging.FormatText
|
||||
|
||||
@@ -854,7 +983,7 @@ func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
|
||||
Result(),
|
||||
))
|
||||
|
||||
r.validateAndComplete(restore)
|
||||
r.validateAndComplete(t.Context(), restore)
|
||||
assert.Contains(t, restore.Status.ValidationErrors[0], "failed to get resource modifiers configmap")
|
||||
|
||||
restore1 := &velerov1api.Restore{
|
||||
@@ -882,7 +1011,7 @@ func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, r.kbClient.Create(t.Context(), cm1))
|
||||
|
||||
r.validateAndComplete(restore1)
|
||||
r.validateAndComplete(t.Context(), restore1)
|
||||
assert.Nil(t, restore1.Status.ValidationErrors)
|
||||
|
||||
restore2 := &velerov1api.Restore{
|
||||
@@ -911,7 +1040,7 @@ func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, r.kbClient.Create(t.Context(), invalidVersionCm))
|
||||
|
||||
r.validateAndComplete(restore2)
|
||||
r.validateAndComplete(t.Context(), restore2)
|
||||
assert.Contains(t, restore2.Status.ValidationErrors[0], "Error in parsing resource modifiers provided in configmap")
|
||||
|
||||
restore3 := &velerov1api.Restore{
|
||||
@@ -939,7 +1068,7 @@ func TestValidateAndCompleteWithResourceModifierSpecified(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, r.kbClient.Create(t.Context(), invalidOperatorCm))
|
||||
|
||||
r.validateAndComplete(restore3)
|
||||
r.validateAndComplete(t.Context(), restore3)
|
||||
assert.Contains(t, restore3.Status.ValidationErrors[0], "Validation error in resource modifiers provided in configmap")
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
|
||||
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
|
||||
"github.com/vmware-tanzu/velero/internal/resourcepolicies"
|
||||
"github.com/vmware-tanzu/velero/internal/volume"
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
"github.com/vmware-tanzu/velero/pkg/itemoperation"
|
||||
@@ -61,6 +62,7 @@ type Request struct {
|
||||
RestoredItems map[itemKey]restoredItemStatus
|
||||
itemOperationsList *[]*itemoperation.RestoreOperation
|
||||
ResourceModifiers *resourcemodifiers.ResourceModifiers
|
||||
ResPolicies *resourcepolicies.Policies
|
||||
DisableInformerCache bool
|
||||
CSIVolumeSnapshots []*snapshotv1api.VolumeSnapshot
|
||||
BackupVolumeInfoMap map[string]volume.BackupVolumeInfo
|
||||
|
||||
+431
-20
@@ -32,6 +32,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/cockroachdb/errors"
|
||||
"github.com/gobwas/glob"
|
||||
"github.com/google/uuid"
|
||||
snapshotv1api "github.com/kubernetes-csi/external-snapshotter/client/v8/apis/volumesnapshot/v1"
|
||||
"github.com/sirupsen/logrus"
|
||||
@@ -55,6 +56,7 @@ import (
|
||||
"github.com/vmware-tanzu/velero/internal/credentials"
|
||||
"github.com/vmware-tanzu/velero/internal/hook"
|
||||
"github.com/vmware-tanzu/velero/internal/resourcemodifiers"
|
||||
"github.com/vmware-tanzu/velero/internal/resourcepolicies"
|
||||
"github.com/vmware-tanzu/velero/internal/volume"
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
"github.com/vmware-tanzu/velero/pkg/archive"
|
||||
@@ -237,6 +239,43 @@ func (kr *kubernetesRestorer) RestoreWithResolvers(
|
||||
Includes(req.Restore.Spec.IncludedNamespaces...).
|
||||
Excludes(req.Restore.Spec.ExcludedNamespaces...)
|
||||
|
||||
var clusterScopedFilterMap map[string]*resolvedResourceFilter
|
||||
var namespacedFilterMap map[string]*resolvedNamespaceFilter
|
||||
var namespacedFilterPatterns []namespacedFilterPattern
|
||||
|
||||
if req.ResPolicies != nil {
|
||||
if kr.discoveryHelper == nil {
|
||||
return results.Result{}, results.Result{Velero: []string{"failed to resolve namespace filter policies: discovery client unavailable"}}
|
||||
}
|
||||
|
||||
// Resolve clusterScopedFilterPolicy
|
||||
csPolicy := req.ResPolicies.GetClusterScopedFilterPolicy()
|
||||
if csPolicy != nil {
|
||||
clusterScopedFilterMap, err = resolveRestoreClusterScopedFilterPolicy(
|
||||
csPolicy,
|
||||
kr.discoveryHelper,
|
||||
req.Log,
|
||||
)
|
||||
if err != nil {
|
||||
return results.Result{}, results.Result{Velero: []string{err.Error()}}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve namespacedFilterPolicies
|
||||
nfPolicies := req.ResPolicies.GetNamespacedFilterPolicies()
|
||||
if len(nfPolicies) > 0 {
|
||||
namespacedFilterMap, namespacedFilterPatterns, err = resolveRestoreNamespacedFilterPolicies(
|
||||
nfPolicies,
|
||||
req.Restore.Spec.ExcludedResources,
|
||||
kr.discoveryHelper,
|
||||
req.Log,
|
||||
)
|
||||
if err != nil {
|
||||
return results.Result{}, results.Result{Velero: []string{err.Error()}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolvedActions, err := restoreItemActionResolver.ResolveActions(kr.discoveryHelper, kr.logger)
|
||||
if err != nil {
|
||||
return results.Result{}, results.Result{Velero: []string{err.Error()}}
|
||||
@@ -333,6 +372,10 @@ func (kr *kubernetesRestorer) RestoreWithResolvers(
|
||||
restoreVolumeInfoTracker: req.RestoreVolumeInfoTracker,
|
||||
hooksWaitExecutor: hooksWaitExecutor,
|
||||
resourceDeletionStatusTracker: req.ResourceDeletionStatusTracker,
|
||||
clusterScopedFilterMap: clusterScopedFilterMap,
|
||||
namespacedFilterMap: namespacedFilterMap,
|
||||
namespacedFilterPatterns: namespacedFilterPatterns,
|
||||
namespaceFilterCache: make(map[string]*resolvedNamespaceFilter),
|
||||
}
|
||||
|
||||
return restoreCtx.execute()
|
||||
@@ -382,6 +425,247 @@ type restoreContext struct {
|
||||
restoreVolumeInfoTracker *volume.RestoreVolumeInfoTracker
|
||||
hooksWaitExecutor *hooksWaitExecutor
|
||||
resourceDeletionStatusTracker kube.ResourceDeletionStatusTracker
|
||||
|
||||
// clusterScopedFilterMap holds resolved per-kind filters for cluster-scoped resources.
|
||||
// Key is the resolved group-resource string.
|
||||
clusterScopedFilterMap map[string]*resolvedResourceFilter
|
||||
|
||||
// namespacedFilterMap holds resolved per-namespace filters.
|
||||
// Key is either an exact namespace name or a glob pattern string.
|
||||
namespacedFilterMap map[string]*resolvedNamespaceFilter
|
||||
|
||||
// namespacedFilterPatterns preserves the order of patterns for first-match
|
||||
// semantics and caches pre-compiled globs to avoid repeated compilation.
|
||||
namespacedFilterPatterns []namespacedFilterPattern
|
||||
|
||||
// namespaceFilterCache memoizes the resolved filter for a given namespace
|
||||
// to avoid re-evaluating glob patterns on every call.
|
||||
namespaceFilterCache map[string]*resolvedNamespaceFilter
|
||||
}
|
||||
|
||||
type resolvedResourceFilter struct {
|
||||
labelSelector labels.Selector
|
||||
orLabelSelectors []labels.Selector
|
||||
nameIE *collections.IncludesExcludes
|
||||
originalKinds []string
|
||||
}
|
||||
|
||||
type resolvedNamespaceFilter struct {
|
||||
// resourceFilterMap is keyed by the resolved group-resource string
|
||||
resourceFilterMap map[string]*resolvedResourceFilter
|
||||
// catchAllFilter holds the resolved filter for a catch-all entry (empty kinds or ["*"]).
|
||||
// nil when no catch-all entry is defined.
|
||||
catchAllFilter *resolvedResourceFilter
|
||||
// hasUnresolvedKinds is true if any kind in the policy failed discovery.
|
||||
// This is used to bypass the fast-path skip so the peek-and-map fallback can run.
|
||||
hasUnresolvedKinds bool
|
||||
}
|
||||
|
||||
// namespacedFilterPattern pairs a namespace pattern string with its pre-compiled
|
||||
// glob so that getNamespaceFilter does not recompile on every call.
|
||||
type namespacedFilterPattern struct {
|
||||
pattern string
|
||||
compiled glob.Glob // compiled once at restore start; nil for exact-match patterns
|
||||
}
|
||||
|
||||
func (ctx *restoreContext) getNamespaceFilter(namespace string) *resolvedNamespaceFilter {
|
||||
if ctx.namespacedFilterMap == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Check the cache first
|
||||
if filter, ok := ctx.namespaceFilterCache[namespace]; ok {
|
||||
return filter
|
||||
}
|
||||
|
||||
// 2. Walk patterns in definition order (first-match semantics)
|
||||
// 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.
|
||||
for _, p := range ctx.namespacedFilterPatterns {
|
||||
if p.compiled != nil {
|
||||
if p.compiled.Match(namespace) {
|
||||
filter := ctx.namespacedFilterMap[p.pattern]
|
||||
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
|
||||
ctx.namespaceFilterCache[namespace] = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRestoreClusterScopedFilterPolicy resolves the cluster-scoped filter policy
|
||||
// into a map keyed by group-resource string. Note: catch-all entries (empty or ["*"] kinds)
|
||||
// are NOT supported in clusterScopedFilterPolicy — validation rejects them earlier.
|
||||
// Cluster-scoped filtering is a refinement overlay; unlisted kinds fall back to global
|
||||
// filters via the existing pipeline, so there is no catchAllFilter field on this map.
|
||||
func resolveRestoreClusterScopedFilterPolicy(
|
||||
policy *resourcepolicies.ClusterScopedFilterPolicy,
|
||||
helper discovery.Helper,
|
||||
log logrus.FieldLogger,
|
||||
) (map[string]*resolvedResourceFilter, error) {
|
||||
result := make(map[string]*resolvedResourceFilter)
|
||||
for _, rf := range policy.ResourceFilters {
|
||||
resolved, err := resolveResourceFilter(rf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, kind := range resolved.originalKinds {
|
||||
gr, resource, err := helper.ResourceFor(schema.ParseGroupResource(kind).WithVersion(""))
|
||||
|
||||
key := kind
|
||||
if err != nil {
|
||||
log.WithField("kind", kind).Warnf("Cannot resolve kind via discovery, using as-is")
|
||||
} else {
|
||||
if resource.Namespaced {
|
||||
log.Warnf("kind %q in clusterScopedFilterPolicy is a namespace-scoped resource; it will never match in a cluster-scoped filter — did you mean namespacedFilterPolicies?", kind)
|
||||
}
|
||||
key = gr.GroupResource().String()
|
||||
}
|
||||
|
||||
if _, exists := result[key]; exists {
|
||||
return nil, fmt.Errorf("ambiguous policy: duplicate kind %q detected", key)
|
||||
}
|
||||
|
||||
result[key] = resolved
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveRestoreNamespacedFilterPolicies(
|
||||
policies []resourcepolicies.NamespacedFilterPolicy,
|
||||
excludedResources []string,
|
||||
helper discovery.Helper,
|
||||
log logrus.FieldLogger,
|
||||
) (map[string]*resolvedNamespaceFilter, []namespacedFilterPattern, error) {
|
||||
result := make(map[string]*resolvedNamespaceFilter)
|
||||
var patternOrder []namespacedFilterPattern
|
||||
|
||||
// Build a quick lookup map for globally excluded resources
|
||||
globalExcludes := make(map[string]bool)
|
||||
for _, ex := range excludedResources {
|
||||
// We lowercase the excluded resources here because the kinds in the resource filters
|
||||
// are lowercased during resolution, and we want to ensure case-insensitive matching.
|
||||
globalExcludes[strings.ToLower(ex)] = true
|
||||
}
|
||||
|
||||
for _, policy := range policies {
|
||||
rfMap := make(map[string]*resolvedResourceFilter)
|
||||
var catchAll *resolvedResourceFilter
|
||||
hasUnresolvedKinds := false
|
||||
|
||||
for _, rf := range policy.ResourceFilters {
|
||||
resolved, err := resolveResourceFilter(rf)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if rf.IsCatchAll() {
|
||||
catchAll = resolved
|
||||
continue
|
||||
}
|
||||
|
||||
for _, kind := range resolved.originalKinds {
|
||||
gr, resource, err := helper.ResourceFor(
|
||||
schema.ParseGroupResource(kind).WithVersion(""),
|
||||
)
|
||||
|
||||
key := kind
|
||||
if err != nil {
|
||||
log.WithField("kind", kind).Warnf(
|
||||
"Cannot resolve kind via discovery, using as-is")
|
||||
hasUnresolvedKinds = true
|
||||
} else {
|
||||
if !resource.Namespaced {
|
||||
log.Warnf("kind %q in namespacedFilterPolicies is a cluster-scoped resource; it will never match in a namespace-scoped filter — did you mean clusterScopedFilterPolicy?", kind)
|
||||
}
|
||||
|
||||
if globalExcludes[kind] || globalExcludes[gr.GroupResource().String()] {
|
||||
log.WithFields(logrus.Fields{
|
||||
"kind": kind,
|
||||
"namespacePattern": strings.Join(policy.Namespaces, ","),
|
||||
}).Warn("namespacedFilterPolicies entry lists a kind that is globally excluded by RestoreSpec.ExcludedResources; the per-namespace filter entry has no effect")
|
||||
}
|
||||
key = gr.GroupResource().String()
|
||||
}
|
||||
|
||||
if _, exists := rfMap[key]; exists {
|
||||
return nil, nil, fmt.Errorf("ambiguous policy: duplicate kind %q detected", key)
|
||||
}
|
||||
|
||||
rfMap[key] = resolved
|
||||
}
|
||||
}
|
||||
|
||||
nsFilter := &resolvedNamespaceFilter{
|
||||
resourceFilterMap: rfMap,
|
||||
catchAllFilter: catchAll,
|
||||
hasUnresolvedKinds: hasUnresolvedKinds,
|
||||
}
|
||||
for _, nsPattern := range policy.Namespaces {
|
||||
result[nsPattern] = nsFilter
|
||||
var compiled glob.Glob
|
||||
if strings.ContainsAny(nsPattern, "*?[") {
|
||||
var err error
|
||||
compiled, err = glob.Compile(nsPattern)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("Failed to compile namespace glob pattern %q, falling back to exact match", nsPattern)
|
||||
}
|
||||
}
|
||||
patternOrder = append(patternOrder, namespacedFilterPattern{
|
||||
pattern: nsPattern,
|
||||
compiled: compiled,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result, patternOrder, nil
|
||||
}
|
||||
|
||||
// resolveResourceFilter converts a ResourceFilter's label selectors and name patterns
|
||||
// into their runtime representations.
|
||||
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)
|
||||
}
|
||||
}
|
||||
var orSelectors []labels.Selector
|
||||
for _, ols := range rf.OrLabelSelectors {
|
||||
s, err := labels.ValidatedSelectorFromSet(labels.Set(ols))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid OR label selector in resource filter: %w", err)
|
||||
}
|
||||
orSelectors = append(orSelectors, s)
|
||||
}
|
||||
var nameIE *collections.IncludesExcludes
|
||||
if len(rf.Names) > 0 || len(rf.ExcludedNames) > 0 {
|
||||
nameIE = collections.NewIncludesExcludes().Includes(rf.Names...).Excludes(rf.ExcludedNames...)
|
||||
}
|
||||
|
||||
normalizedKinds := make([]string, len(rf.Kinds))
|
||||
for i, k := range rf.Kinds {
|
||||
normalizedKinds[i] = strings.ToLower(k)
|
||||
}
|
||||
|
||||
return &resolvedResourceFilter{
|
||||
labelSelector: selector,
|
||||
orLabelSelectors: orSelectors,
|
||||
nameIE: nameIE,
|
||||
originalKinds: normalizedKinds,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type resourceClientKey struct {
|
||||
@@ -1128,6 +1412,10 @@ func (ctx *restoreContext) restoreItem(obj *unstructured.Unstructured, groupReso
|
||||
// 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")
|
||||
@@ -2280,6 +2568,18 @@ func (ctx *restoreContext) getOrderedResourceCollection(
|
||||
continue
|
||||
}
|
||||
|
||||
// Per-namespace resource type check from restore filter policy
|
||||
if namespace != "" && !ctx.resourceMustHave.Has(groupResource.String()) {
|
||||
if nsFilter := ctx.getNamespaceFilter(namespace); nsFilter != nil {
|
||||
_, kindListed := nsFilter.resourceFilterMap[groupResource.String()]
|
||||
if !kindListed && nsFilter.catchAllFilter == nil && !nsFilter.hasUnresolvedKinds {
|
||||
ctx.log.Infof("Skipping resource %s in namespace %s: not in resourceFilters",
|
||||
resource, namespace)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res, w, e := ctx.getSelectedRestoreableItems(groupResource.String(), namespace, items)
|
||||
warnings.Merge(&w)
|
||||
errs.Merge(&e)
|
||||
@@ -2333,6 +2633,88 @@ func (ctx *restoreContext) getSelectedRestoreableItems(resource string, original
|
||||
resourceForPath = filepath.Join(resource, cgv.Dir)
|
||||
}
|
||||
|
||||
var rf *resolvedResourceFilter
|
||||
var useFilterPolicy bool
|
||||
|
||||
if !ctx.resourceMustHave.Has(resource) {
|
||||
if originalNamespace != "" {
|
||||
// Namespace-scoped path
|
||||
if nsFilter := ctx.getNamespaceFilter(originalNamespace); nsFilter != nil {
|
||||
// Resolve effective filter: kind-specific takes precedence over catch-all
|
||||
rf = nsFilter.resourceFilterMap[resource]
|
||||
|
||||
// Peek-and-map logic for unresolvable kinds
|
||||
if rf == nil && len(items) > 0 {
|
||||
peekPath := archive.GetItemFilePath(ctx.restoreDir, resourceForPath, originalNamespace, items[0])
|
||||
// Ignore unmarshal errors during peek; the main restore loop will catch and report them
|
||||
if obj, err := archive.Unmarshal(ctx.fileSystem, peekPath); err == nil {
|
||||
actualKind := obj.GroupVersionKind().Kind
|
||||
for _, filter := range nsFilter.resourceFilterMap {
|
||||
for _, k := range filter.originalKinds {
|
||||
if strings.EqualFold(k, actualKind) {
|
||||
rf = filter
|
||||
// Cache it for future lookups of this resource
|
||||
// Note: resourceFilterMap is mutated in place without synchronization.
|
||||
// This is safe today because resource collection runs sequentially.
|
||||
// If parallelized in the future, this will need a lock to prevent data races.
|
||||
nsFilter.resourceFilterMap[resource] = rf
|
||||
break
|
||||
}
|
||||
}
|
||||
if rf != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if rf == nil {
|
||||
rf = nsFilter.catchAllFilter // may be nil if no catch-all
|
||||
}
|
||||
useFilterPolicy = true
|
||||
|
||||
if rf == nil {
|
||||
ctx.log.Infof("Skipping resource %s in namespace %s: not in resourceFilters", resource, originalNamespace)
|
||||
return restorable, warnings, errs
|
||||
}
|
||||
}
|
||||
} else if ctx.clusterScopedFilterMap != nil {
|
||||
// Cluster-scoped path: only applies if kind is listed (refinement overlay)
|
||||
if listedRF, ok := ctx.clusterScopedFilterMap[resource]; ok {
|
||||
rf = listedRF
|
||||
useFilterPolicy = true
|
||||
} else if len(items) > 0 {
|
||||
// Peek-and-map logic for unresolvable kinds.
|
||||
// Note: Unlike the namespaced path, this fallback is always reachable
|
||||
// because the main restore loop does not have a fast-path skip for
|
||||
// unlisted cluster-scoped resources.
|
||||
peekPath := archive.GetItemFilePath(ctx.restoreDir, resourceForPath, originalNamespace, items[0])
|
||||
// Ignore unmarshal errors during peek; the main restore loop will catch and report them
|
||||
if obj, err := archive.Unmarshal(ctx.fileSystem, peekPath); err == nil {
|
||||
actualKind := obj.GroupVersionKind().Kind
|
||||
for _, filter := range ctx.clusterScopedFilterMap {
|
||||
for _, k := range filter.originalKinds {
|
||||
if strings.EqualFold(k, actualKind) {
|
||||
rf = filter
|
||||
// Cache it for future lookups of this resource
|
||||
// Note: clusterScopedFilterMap is mutated in place without synchronization.
|
||||
// This is safe today because resource collection runs sequentially.
|
||||
// If parallelized in the future, this will need a lock to prevent data races.
|
||||
ctx.clusterScopedFilterMap[resource] = rf
|
||||
useFilterPolicy = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if rf != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// If kind not listed, fall through to global selectors below
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
itemPath := archive.GetItemFilePath(ctx.restoreDir, resourceForPath, originalNamespace, item)
|
||||
|
||||
@@ -2350,29 +2732,58 @@ func (ctx *restoreContext) getSelectedRestoreableItems(resource string, original
|
||||
}
|
||||
|
||||
if !ctx.resourceMustHave.Has(resource) {
|
||||
if !ctx.selector.Matches(labels.Set(obj.GetLabels())) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Processing OrLabelSelectors when specified in the restore request. LabelSelectors as well as OrLabelSelectors
|
||||
// cannot co-exist, only one of them can be specified
|
||||
var skipItem = false
|
||||
var skip = 0
|
||||
ctx.log.Debugf("orSelectors specified: %s for item: %s", ctx.OrSelectors, item)
|
||||
for _, s := range ctx.OrSelectors {
|
||||
if !s.Matches(labels.Set(obj.GetLabels())) {
|
||||
skip++
|
||||
if useFilterPolicy {
|
||||
if rf != nil {
|
||||
// Per-kind label selector
|
||||
if rf.labelSelector != nil && !rf.labelSelector.Matches(labels.Set(obj.GetLabels())) {
|
||||
continue
|
||||
}
|
||||
// Per-kind OR label selectors
|
||||
if len(rf.orLabelSelectors) > 0 {
|
||||
matched := false
|
||||
for _, s := range rf.orLabelSelectors {
|
||||
if s.Matches(labels.Set(obj.GetLabels())) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
ctx.log.Infof("Excluding item %s: no OR label selector matched (restore filter policy)", item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Per-kind name filter
|
||||
if rf.nameIE != nil && !rf.nameIE.ShouldInclude(obj.GetName()) {
|
||||
ctx.log.Infof("Excluding item %s: name does not match restore filter policy", obj.GetName())
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Existing global selector logic
|
||||
if !ctx.selector.Matches(labels.Set(obj.GetLabels())) {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(ctx.OrSelectors) == skip && skip > 0 {
|
||||
ctx.log.Infof("setting skip flag to true for item: %s", item)
|
||||
skipItem = true
|
||||
}
|
||||
}
|
||||
// Processing OrLabelSelectors when specified in the restore request. LabelSelectors as well as OrLabelSelectors
|
||||
// cannot co-exist, only one of them can be specified
|
||||
var skipItem = false
|
||||
var skip = 0
|
||||
ctx.log.Debugf("orSelectors specified: %s for item: %s", ctx.OrSelectors, item)
|
||||
for _, s := range ctx.OrSelectors {
|
||||
if !s.Matches(labels.Set(obj.GetLabels())) {
|
||||
skip++
|
||||
}
|
||||
|
||||
if skipItem {
|
||||
ctx.log.Infof("restore orSelector labels did not match, skipping restore of item: %s", skipItem, item)
|
||||
continue
|
||||
if len(ctx.OrSelectors) == skip && skip > 0 {
|
||||
ctx.log.Infof("setting skip flag to true for item: %s", item)
|
||||
skipItem = true
|
||||
}
|
||||
}
|
||||
|
||||
if skipItem {
|
||||
ctx.log.Infof("restore orSelector labels did not match, skipping restore of item: %s", skipItem, item)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
package restore
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
logrustest "github.com/sirupsen/logrus/hooks/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1api "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/client-go/kubernetes/scheme"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
"github.com/vmware-tanzu/velero/internal/resourcepolicies"
|
||||
velerov1api "github.com/vmware-tanzu/velero/pkg/apis/velero/v1"
|
||||
"github.com/vmware-tanzu/velero/pkg/builder"
|
||||
"github.com/vmware-tanzu/velero/pkg/test"
|
||||
)
|
||||
|
||||
func TestRestoreResourcePoliciesFiltering(t *testing.T) {
|
||||
customKindRes := &test.APIResource{Group: "mygroup.io", Version: "v1", Name: "mycustomkinds", Kind: "MyCustomKind", Namespaced: true}
|
||||
clusterCustomKindRes := &test.APIResource{Group: "mygroup.io", Version: "v1", Name: "myclustercustomkinds", Kind: "MyClusterCustomKind", Namespaced: false}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
restore *velerov1api.Restore
|
||||
backup *velerov1api.Backup
|
||||
policyYAML string
|
||||
apiResources []*test.APIResource
|
||||
tarball io.Reader
|
||||
want map[*test.APIResource][]string
|
||||
}{
|
||||
{
|
||||
name: "namespaced filter policy with exact namespace match",
|
||||
restore: defaultRestore().Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
policyYAML: `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-1
|
||||
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(),
|
||||
).
|
||||
Done(),
|
||||
apiResources: []*test.APIResource{
|
||||
test.Pods(),
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1", "ns-2/pod-1"}, // ns-2 is not filtered, ns-1 only includes pod-1
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "namespaced filter policy with glob namespace match and first-match semantics",
|
||||
restore: defaultRestore().Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
policyYAML: `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-*
|
||||
resourceFilters:
|
||||
- kinds:
|
||||
- pods
|
||||
names:
|
||||
- pod-1
|
||||
- namespaces:
|
||||
- ns-1
|
||||
resourceFilters:
|
||||
- kinds:
|
||||
- pods
|
||||
names:
|
||||
- pod-2
|
||||
`,
|
||||
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-1", "ns-2/pod-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "cluster scoped filter policy",
|
||||
restore: defaultRestore().Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
policyYAML: `version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds:
|
||||
- persistentvolumes
|
||||
names:
|
||||
- pv-1
|
||||
`,
|
||||
tarball: test.NewTarWriter(t).
|
||||
AddItems("persistentvolumes",
|
||||
builder.ForPersistentVolume("pv-1").Result(),
|
||||
builder.ForPersistentVolume("pv-2").Result(),
|
||||
).
|
||||
Done(),
|
||||
apiResources: []*test.APIResource{
|
||||
test.PVs(),
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
test.PVs(): {"/pv-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "catch-all filter",
|
||||
restore: defaultRestore().Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
policyYAML: `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces:
|
||||
- ns-1
|
||||
resourceFilters:
|
||||
- kinds:
|
||||
- '*'
|
||||
labelSelector:
|
||||
app: test
|
||||
`,
|
||||
tarball: test.NewTarWriter(t).
|
||||
AddItems("pods",
|
||||
builder.ForPod("ns-1", "pod-1").ObjectMeta(builder.WithLabels("app", "test")).Result(),
|
||||
builder.ForPod("ns-1", "pod-2").Result(),
|
||||
).
|
||||
AddItems("deployments.apps",
|
||||
builder.ForDeployment("ns-1", "deploy-1").ObjectMeta(builder.WithLabels("app", "test")).Result(),
|
||||
builder.ForDeployment("ns-1", "deploy-2").Result(),
|
||||
).
|
||||
Done(),
|
||||
apiResources: []*test.APIResource{
|
||||
test.Pods(),
|
||||
test.Deployments(),
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
test.Pods(): {"ns-1/pod-1"},
|
||||
test.Deployments(): {"ns-1/deploy-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unresolved kind in namespaced filter policy is still restored via peek-and-map",
|
||||
restore: defaultRestore().Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
policyYAML: `version: v1
|
||||
namespacedFilterPolicies:
|
||||
- namespaces: ["ns-1"]
|
||||
resourceFilters:
|
||||
- kinds: ["MyCustomKind"]
|
||||
`,
|
||||
tarball: test.NewTarWriter(t).AddItems("mycustomkinds.mygroup.io",
|
||||
&unstructured.Unstructured{Object: map[string]any{"apiVersion": "mygroup.io/v1", "kind": "MyCustomKind", "metadata": map[string]any{"namespace": "ns-1", "name": "my-cr"}}},
|
||||
).Done(),
|
||||
apiResources: []*test.APIResource{
|
||||
customKindRes,
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
customKindRes: {"ns-1/my-cr"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unresolved kind in cluster-scoped filter policy is still restored via peek-and-map",
|
||||
restore: defaultRestore().Result(),
|
||||
backup: defaultBackup().Result(),
|
||||
policyYAML: `version: v1
|
||||
clusterScopedFilterPolicy:
|
||||
resourceFilters:
|
||||
- kinds: ["MyClusterCustomKind"]
|
||||
`,
|
||||
tarball: test.NewTarWriter(t).AddItems("myclustercustomkinds.mygroup.io",
|
||||
&unstructured.Unstructured{Object: map[string]any{"apiVersion": "mygroup.io/v1", "kind": "MyClusterCustomKind", "metadata": map[string]any{"name": "my-cluster-cr"}}},
|
||||
).Done(),
|
||||
apiResources: []*test.APIResource{
|
||||
clusterCustomKindRes,
|
||||
},
|
||||
want: map[*test.APIResource][]string{
|
||||
clusterCustomKindRes: {"/my-cluster-cr"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
for _, r := range tc.apiResources {
|
||||
h.DiscoveryClient.WithAPIResource(r)
|
||||
}
|
||||
require.NoError(t, h.restorer.discoveryHelper.Refresh())
|
||||
|
||||
var resPolicies *resourcepolicies.Policies
|
||||
if tc.policyYAML != "" {
|
||||
cm := &corev1api.ConfigMap{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "test-policies",
|
||||
Namespace: "velero",
|
||||
},
|
||||
Data: map[string]string{
|
||||
"policy.yaml": tc.policyYAML,
|
||||
},
|
||||
}
|
||||
client := fake.NewClientBuilder().WithScheme(scheme.Scheme).WithObjects(cm).Build()
|
||||
restore := tc.restore.DeepCopy()
|
||||
restore.Namespace = "velero"
|
||||
restore.Spec.ResourcePolicy = &corev1api.TypedLocalObjectReference{
|
||||
Kind: "configmap",
|
||||
Name: "test-policies",
|
||||
}
|
||||
var err error
|
||||
resPolicies, err = resourcepolicies.GetResourcePoliciesFromRestore(t.Context(), restore, client, logrus.New())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
data := &Request{
|
||||
Log: h.log,
|
||||
Restore: tc.restore,
|
||||
Backup: tc.backup,
|
||||
PodVolumeBackups: nil,
|
||||
VolumeSnapshots: nil,
|
||||
BackupReader: tc.tarball,
|
||||
ResPolicies: resPolicies,
|
||||
}
|
||||
warnings, errs := h.restorer.Restore(
|
||||
data,
|
||||
nil, // restoreItemActions
|
||||
nil, // volume snapshotter getter
|
||||
)
|
||||
|
||||
assertEmptyResults(t, warnings, errs)
|
||||
assertAPIContents(t, h, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRestoreNamespacedFilterPolicies_Validation(t *testing.T) {
|
||||
log := logrus.New()
|
||||
helper := test.NewFakeDiscoveryHelper(true, nil)
|
||||
|
||||
policies := []resourcepolicies.NamespacedFilterPolicy{
|
||||
{
|
||||
Namespaces: []string{"ns-1"},
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"MyKind", "mykind"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, _, err := resolveRestoreNamespacedFilterPolicies(policies, nil, helper, log)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "ambiguous policy: duplicate kind")
|
||||
}
|
||||
|
||||
func TestResolveRestoreClusterScopedFilterPolicy_Validation(t *testing.T) {
|
||||
log := logrus.New()
|
||||
helper := test.NewFakeDiscoveryHelper(true, nil)
|
||||
|
||||
policy := &resourcepolicies.ClusterScopedFilterPolicy{
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"MyKind", "mykind"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := resolveRestoreClusterScopedFilterPolicy(policy, helper, log)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "ambiguous policy: duplicate kind")
|
||||
}
|
||||
|
||||
func TestResolveRestoreNamespacedFilterPolicies_GlobalExcludesWarning(t *testing.T) {
|
||||
log, hook := logrustest.NewNullLogger()
|
||||
helper := test.NewFakeDiscoveryHelper(true, nil)
|
||||
|
||||
policies := []resourcepolicies.NamespacedFilterPolicy{
|
||||
{
|
||||
Namespaces: []string{"ns-1"},
|
||||
ResourceFilters: []resourcepolicies.ResourceFilter{
|
||||
{
|
||||
Kinds: []string{"ConfigMaps"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
excludedResources := []string{"ConfigMaps"} // Same case
|
||||
_, _, err := resolveRestoreNamespacedFilterPolicies(policies, excludedResources, helper, log)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check if a warning was emitted
|
||||
found := false
|
||||
for _, entry := range hook.Entries {
|
||||
if entry.Level == logrus.WarnLevel && strings.Contains(entry.Message, "namespacedFilterPolicies entry lists a kind that is globally excluded") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected warning about globally excluded resource")
|
||||
|
||||
hook.Reset()
|
||||
|
||||
excludedResourcesDiffCase := []string{"configmaps"} // Different case
|
||||
_, _, err = resolveRestoreNamespacedFilterPolicies(policies, excludedResourcesDiffCase, helper, log)
|
||||
require.NoError(t, err)
|
||||
|
||||
found = false
|
||||
for _, entry := range hook.Entries {
|
||||
if entry.Level == logrus.WarnLevel && strings.Contains(entry.Message, "namespacedFilterPolicies entry lists a kind that is globally excluded") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected warning about globally excluded resource even if case differs")
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/vmware-tanzu/velero/internal/resourcepolicies"
|
||||
"github.com/vmware-tanzu/velero/pkg/util/boolptr"
|
||||
"github.com/vmware-tanzu/velero/pkg/util/collections"
|
||||
|
||||
@@ -764,6 +765,10 @@ func TestRestoreResourceFiltering(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, h.restorer.discoveryHelper.Refresh())
|
||||
|
||||
// We need to fetch the policies using the actual function
|
||||
resPolicies, err := resourcepolicies.GetResourcePoliciesFromRestore(t.Context(), tc.restore, h.restorer.kbClient, h.log)
|
||||
require.NoError(t, err)
|
||||
|
||||
data := &Request{
|
||||
Log: h.log,
|
||||
Restore: tc.restore,
|
||||
@@ -771,6 +776,7 @@ func TestRestoreResourceFiltering(t *testing.T) {
|
||||
PodVolumeBackups: nil,
|
||||
VolumeSnapshots: nil,
|
||||
BackupReader: tc.tarball,
|
||||
ResPolicies: resPolicies,
|
||||
}
|
||||
warnings, errs := h.restorer.Restore(
|
||||
data,
|
||||
|
||||
@@ -56,6 +56,8 @@ func NewAPIServer(t *testing.T) *APIServer {
|
||||
{Group: "extensions", Version: "v1", Resource: "deployments"}: "ExtDeploymentsList",
|
||||
{Group: "velero.io", Version: "v1", Resource: "deployments"}: "VeleroDeploymentsList",
|
||||
{Group: "velero.io", Version: "v2alpha1", Resource: "datauploads"}: "DataUploadsList",
|
||||
{Group: "mygroup.io", Version: "v1", Resource: "mycustomkinds"}: "MyCustomKindList",
|
||||
{Group: "mygroup.io", Version: "v1", Resource: "myclustercustomkinds"}: "MyClusterCustomKindList",
|
||||
})
|
||||
discoveryClient = &DiscoveryClient{FakeDiscovery: kubeClient.Discovery().(*discoveryfake.FakeDiscovery)}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user