Skip to main content

kopiur_api/
common.rs

1//! Shared sub-objects reused across multiple CRDs.
2//!
3//! Per ADR-0003 §2.2 (principle 10) and §4.11, every credential, policy, and
4//! identity surface is modeled as a sub-object so future fields slot in without
5//! API breakage. Leaf Kubernetes types (`LabelSelector`, `ResourceRequirements`,
6//! `PodSecurityContext`) are reused from `k8s-openapi` rather than re-invented.
7
8use k8s_openapi::apimachinery::pkg::apis::meta::v1::LabelSelector;
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12/// serde `default` for a `bool` field whose absent value is `true`. Used by
13/// "enabled by default, opt out explicitly" surfaces (e.g.
14/// `RepositoryMaintenanceSpec.enabled`). `bool::default()` is `false`, so a
15/// default-true field cannot lean on `#[serde(default)]` alone.
16pub(crate) fn default_true() -> bool {
17    true
18}
19
20/// A lifecycle-phase enum that can be rendered as a metric label.
21///
22/// The single source of truth for a CRD's phase labels: [`PhaseLabel::ALL`]
23/// enumerates every variant and [`PhaseLabel::label`] is an exhaustive match.
24/// The controller's `kopiur_resource_phase` gauge uses these to set the active
25/// phase to 1 and the rest to 0 (and to clear all on deletion), so both the
26/// label string and the reset set come from the enum itself rather than a
27/// stringly-typed table that can silently drift (ADR §5.5 type-safety thesis).
28pub trait PhaseLabel: Copy + PartialEq + 'static {
29    /// Every variant, in declaration order.
30    const ALL: &'static [Self];
31    /// The stable metric label string for this variant (exhaustive `match`).
32    fn label(&self) -> &'static str;
33}
34
35/// Reference to a key within a `Secret` in the same namespace as the referrer,
36/// unless `namespace` is given (required for cluster-scoped CRs — ADR §3.2).
37#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
38#[serde(rename_all = "camelCase")]
39pub struct SecretKeyRef {
40    /// Name of the `Secret`.
41    pub name: String,
42    /// Namespace of the `Secret`. Absent = same namespace as the referrer;
43    /// required for cluster-scoped CRs which have no own namespace (ADR §3.2).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub namespace: Option<String>,
46    /// Which key inside the `Secret` to read. Defaults are documented per-field on
47    /// the consuming struct.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub key: Option<String>,
50}
51
52/// Reference to an entire `Secret` (the operator reads well-known keys from it,
53/// e.g. `AWS_ACCESS_KEY_ID`). See ADR §3.1 backend `auth.secretRef`.
54#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
55#[serde(rename_all = "camelCase")]
56pub struct SecretRef {
57    /// Name of the `Secret`.
58    pub name: String,
59    /// Namespace of the `Secret`. Absent = same namespace as the referrer;
60    /// required for cluster-scoped CRs (ADR §3.2).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub namespace: Option<String>,
63}
64
65/// Reference to a key within a `ConfigMap` (e.g. a CA bundle). ADR §3.1 `tls.caBundleRef`.
66#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
67#[serde(rename_all = "camelCase")]
68pub struct ConfigMapKeyRef {
69    /// Name of the `ConfigMap` holding the value (e.g. a CA bundle).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub config_map_name: Option<String>,
72    /// Which key inside the `ConfigMap` to read.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub key: Option<String>,
75}
76
77/// TLS settings for object-store backends. ADR §3.1.
78#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
79#[serde(rename_all = "camelCase")]
80pub struct TlsConfig {
81    /// CA bundle (PEM) used to verify the endpoint's certificate, sourced from a
82    /// `ConfigMap`. Preferred over `insecureSkipVerify` for self-signed endpoints.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub ca_bundle_ref: Option<ConfigMapKeyRef>,
85    /// Skip TLS certificate verification (still uses TLS). Maps to kopia's
86    /// `--disable-tls-verification`. For self-signed endpoints; prefer
87    /// `caBundleRef` in production.
88    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
89    pub insecure_skip_verify: bool,
90    /// Disable TLS entirely and talk plain HTTP. Maps to kopia's `--disable-tls`.
91    /// Needed for HTTP-only endpoints (e.g. an in-cluster MinIO/RustFS service);
92    /// kopia's S3 path otherwise assumes HTTPS. Off by default.
93    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
94    pub disable_tls: bool,
95}
96
97/// Which kind of repository a consumer CR references. ADR §3.2/§3.3.
98///
99/// This is a closed enum: a consumer's `repository.kind` is always exactly one
100/// of these two values, so reconcilers `match` it exhaustively.
101///
102/// ```
103/// use kopiur_api::common::RepositoryKind;
104///
105/// // Defaults to the namespaced `Repository`, so a same-namespace ref needs no `kind`.
106/// assert_eq!(RepositoryKind::default(), RepositoryKind::Repository);
107/// // Serializes to the bare CRD kind name (no payload — a plain string).
108/// assert_eq!(
109///     serde_json::to_value(RepositoryKind::ClusterRepository).unwrap(),
110///     "ClusterRepository"
111/// );
112/// ```
113#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
114pub enum RepositoryKind {
115    /// The namespaced `Repository` CRD; the default when `kind` is omitted.
116    #[default]
117    Repository,
118    /// The cluster-scoped `ClusterRepository` CRD; namespace is meaningless for it.
119    ClusterRepository,
120}
121
122/// Discriminated reference from a consumer CR (`BackupConfig`, `Backup`,
123/// `Restore`, `Maintenance`) to a `Repository` or `ClusterRepository`. ADR §3.2.
124///
125/// When `kind == ClusterRepository`, `namespace` MUST be absent — enforced by the
126/// admission webhook (`api::validate`), since the type system cannot express
127/// "this field is forbidden only for one variant of a sibling field".
128#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
129#[serde(rename_all = "camelCase")]
130pub struct RepositoryRef {
131    /// Which repository CRD this points at; defaults to [`RepositoryKind::Repository`].
132    #[serde(default)]
133    pub kind: RepositoryKind,
134    /// Name of the referenced `Repository`/`ClusterRepository`.
135    pub name: String,
136    /// Cross-namespace `Repository` reference; ignored/forbidden for `ClusterRepository`.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub namespace: Option<String>,
139}
140
141/// Repository encryption settings. A sub-object so future rotation fields
142/// (`rotation`, `previousPasswords`) slot in without breakage (ADR §4.11).
143#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
144#[serde(rename_all = "camelCase")]
145pub struct Encryption {
146    /// Always a Secret ref; never inline. ADR §3.1.
147    pub password_secret_ref: SecretKeyRef,
148}
149
150/// Behavior when the repository does not yet exist. ADR §3.1 `create`.
151#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
152#[serde(rename_all = "camelCase")]
153pub struct CreateBehavior {
154    /// Create the repository if it does not exist yet. Off by default, so a typo'd
155    /// backend can't silently spin up a brand-new empty repository.
156    #[serde(default)]
157    pub enabled: bool,
158    /// kopia encryption algorithm for a freshly-created repository (e.g.
159    /// `AES256-GCM-HMAC-SHA256`); only consulted at creation time.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub encryption: Option<String>,
162    /// kopia object splitter for a freshly-created repository; creation-time only.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub splitter: Option<String>,
165    /// kopia content hash algorithm for a freshly-created repository; creation-time only.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub hash: Option<String>,
168}
169
170/// Cache defaults inherited by `Backup`/`Restore` movers unless overridden. ADR §3.1.
171#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
172#[serde(rename_all = "camelCase")]
173pub struct CacheDefaults {
174    /// Size of the PVC backing the mover's kopia cache (e.g. `10Gi`).
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub capacity: Option<String>,
177    /// StorageClass for the cache PVC; absent uses the cluster default.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub storage_class_name: Option<String>,
180    /// kopia metadata cache budget in MiB (`--metadata-cache-size-mb`).
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub metadata_cache_size_mb: Option<i64>,
183    /// kopia content cache budget in MiB (`--content-cache-size-mb`).
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub content_cache_size_mb: Option<i64>,
186}
187
188/// Bounds on materialization of `origin: discovered` `Backup` CRs. ADR §3.1 `catalog`.
189#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
190#[serde(rename_all = "camelCase")]
191pub struct CatalogBounds {
192    /// How many discovered `Backup` CRs to keep materialized; bounds etcd footprint
193    /// for large repositories. Never deletes real snapshots (discovered backups are
194    /// always `deletionPolicy: Retain`). ADR §3.1/§4.5.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub retain: Option<CatalogRetain>,
197    /// How often to re-scan the repository for new snapshots to materialize
198    /// (Go-style duration, e.g. `1h`).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub refresh_interval: Option<String>,
201    /// Where to materialize discovered `Backup`s whose identity hostname does not
202    /// map to an allowed namespace (ClusterRepository only). ADR §3.2.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub fallback_namespace: Option<String>,
205}
206
207/// Bounds on the *number* of discovered `Backup` CRs kept materialized. ADR §3.1 `catalog.retain`.
208#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
209#[serde(rename_all = "camelCase")]
210pub struct CatalogRetain {
211    /// Most-recent N per `username@hostname:path`.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub per_identity: Option<i64>,
214    /// Drop materialized discovered `Backup`s for snapshots older than this many days.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub max_age_days: Option<i64>,
217}
218
219/// GFS retention policy. The single successful-retention driver (ADR §4.4).
220#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
221#[serde(rename_all = "camelCase")]
222pub struct Retention {
223    /// Keep the N most-recent snapshots regardless of age.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub keep_latest: Option<u32>,
226    /// Keep one snapshot per hour for the most-recent N hours.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub keep_hourly: Option<u32>,
229    /// Keep one snapshot per day for the most-recent N days.
230    #[serde(default, skip_serializing_if = "Option::is_none")]
231    pub keep_daily: Option<u32>,
232    /// Keep one snapshot per week for the most-recent N weeks.
233    #[serde(default, skip_serializing_if = "Option::is_none")]
234    pub keep_weekly: Option<u32>,
235    /// Keep one snapshot per month for the most-recent N months.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub keep_monthly: Option<u32>,
238    /// Keep one snapshot per year for the most-recent N years.
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub keep_annual: Option<u32>,
241}
242
243/// Identity overrides — what kopia records as `username@hostname:path`. ADR §3.3/§4.2.
244#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
245#[serde(rename_all = "camelCase")]
246pub struct Identity {
247    /// Override the `username` portion of `username@hostname:path`; absent uses the
248    /// resolved default. Templated with `tera` and pinned at admission (ADR §4.2).
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub username: Option<String>,
251    /// Override the `hostname` portion of `username@hostname:path`; absent uses the
252    /// resolved default. Templated with `tera` and pinned at admission (ADR §4.2).
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub hostname: Option<String>,
255}
256
257/// Fully-resolved identity pinned into status; never re-rendered after admission. ADR §4.2.
258#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
259#[serde(rename_all = "camelCase")]
260pub struct ResolvedIdentity {
261    /// The final `username` kopia records, fixed at admission.
262    pub username: String,
263    /// The final `hostname` kopia records, fixed at admission.
264    pub hostname: String,
265    /// The resolved snapshot source path, when applicable (`username@hostname:path`).
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub source_path: Option<String>,
268}
269
270/// Per-run failure controls passed through to the mover `Job`. ADR §3.4/§4.10 (G6).
271#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
272#[serde(rename_all = "camelCase")]
273pub struct FailurePolicy {
274    /// Passed through to the mover `Job.spec.backoffLimit` — how many times a failed
275    /// run is retried before the Job is marked failed.
276    #[serde(default, skip_serializing_if = "Option::is_none")]
277    pub backoff_limit: Option<i32>,
278    /// Passed through to the mover `Job.spec.activeDeadlineSeconds` — wall-clock cap
279    /// after which a still-running run is killed.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub active_deadline_seconds: Option<i64>,
282}
283
284/// Per-recipe mover overrides (resources, cache, security context). ADR §3.3.
285///
286/// Not `Eq`: embeds `k8s-openapi` types (`ResourceRequirements`, `SecurityContext`)
287/// which only implement `PartialEq`.
288#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default, JsonSchema)]
289#[serde(rename_all = "camelCase")]
290pub struct MoverSpec {
291    /// Resource requests/limits for the mover container.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub resources: Option<k8s_openapi::api::core::v1::ResourceRequirements>,
294    /// Override the repository's [`CacheDefaults`] for this recipe's movers.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub cache: Option<CacheDefaults>,
297    /// Security context applied to the mover container.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub security_context: Option<k8s_openapi::api::core::v1::SecurityContext>,
300    /// Opt-in, namespace-gated; preserves UID/GID on restore. ADR §4.11/§G16.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub privileged_mode: Option<bool>,
303    /// Opt-in: copy security context from a live workload pod. ADR §4.11.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub inherit_security_context_from: Option<PodSelector>,
306}
307
308/// Selects workload pods by label. Reuses k8s-openapi `LabelSelector`. ADR §3.3 hooks.
309///
310/// Not `Eq`: `LabelSelector` only implements `PartialEq`.
311#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
312#[serde(rename_all = "camelCase")]
313pub struct PodSelector {
314    /// Label selector matching the workload pod(s) to read context/hooks from.
315    pub pod_selector: LabelSelector,
316    /// Which container within the matched pod; absent uses the first/only container.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub container: Option<String>,
319}
320
321/// Reference to a `BackupConfig` CR (used by `Backup.spec.configRef` and
322/// `BackupSchedule.spec.configRef`). May cross namespaces, subject to RBAC. ADR §3.4/§3.5.
323#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
324#[serde(rename_all = "camelCase")]
325pub struct ConfigRef {
326    /// Name of the referenced `BackupConfig`.
327    pub name: String,
328    /// Namespace of the `BackupConfig`; absent = same namespace as the referrer.
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub namespace: Option<String>,
331}
332
333/// Generic name/namespace reference to another namespaced object — e.g. a `Backup`
334/// CR (`Restore.spec.source.backupRef`) or a PVC (`Restore.spec.target.pvcRef`). ADR §3.6.
335#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
336#[serde(rename_all = "camelCase")]
337pub struct ObjectRef {
338    /// Name of the referenced object.
339    pub name: String,
340    /// Namespace of the referenced object; absent = same namespace as the referrer.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub namespace: Option<String>,
343}
344
345/// Lifecycle of the underlying kopia snapshot when its `Backup` CR is deleted.
346/// Shared by `BackupConfig.spec.defaultDeletionPolicy` and `Backup.spec.deletionPolicy`.
347/// ADR-0003 §4.5 / ADR-0001 §4.5.
348///
349/// The reconciler distinguishes the three cases with an exhaustive `match` — Rust
350/// enforces that any new variant added later must be handled in every match site,
351/// preventing the class of bug where a new policy slips into production without a
352/// corresponding reconcile branch.
353///
354/// ```
355/// use kopiur_api::common::DeletionPolicy;
356///
357/// // Produced backups default to deleting the snapshot with the CR.
358/// assert_eq!(DeletionPolicy::default(), DeletionPolicy::Delete);
359/// // Variants serialize to their bare PascalCase names (plain string enum).
360/// assert_eq!(serde_json::to_value(DeletionPolicy::Retain).unwrap(), "Retain");
361/// assert_eq!(serde_json::to_value(DeletionPolicy::Orphan).unwrap(), "Orphan");
362/// ```
363#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
364pub enum DeletionPolicy {
365    /// Default for `origin: scheduled`/`manual`. Finalizer runs
366    /// `kopia snapshot delete <id>` then removes the finalizer.
367    #[default]
368    Delete,
369    /// Default for `origin: discovered`. CR is removed; snapshot stays.
370    /// Forced via webhook for discovered backups; cannot be overridden.
371    Retain,
372    /// CR is removed without contacting the repository at all (escape hatch
373    /// for "the bucket is gone, just let me delete the CR"). Status records
374    /// `orphaned: true` for the snapshot ID before removal.
375    Orphan,
376}
377
378/// A single cron entry with optional deterministic jitter. Shared by `Maintenance`'s
379/// quick/full schedules. ADR §3.7. `jitter` is a Go-style duration string (e.g. `30m`).
380#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
381#[serde(rename_all = "camelCase")]
382pub struct CronSpec {
383    /// The cron expression, parsed by `croner`. May contain an `H` placeholder for
384    /// deterministic per-schedule jitter (ADR §3.7).
385    pub cron: String,
386    /// Optional deterministic jitter window as a Go-style duration string (e.g.
387    /// `30m`), derived from `(scheduleUID, slot)` so it is stable across restarts.
388    #[serde(default, skip_serializing_if = "Option::is_none")]
389    pub jitter: Option<String>,
390}
391
392impl RepositoryRef {
393    /// True if this reference points at the given repository.
394    ///
395    /// `owner_namespace` is the namespace of the resource that holds the ref
396    /// (e.g. the `Maintenance` CR's own namespace), used to resolve a namespaced
397    /// `Repository` reference that omits `namespace`. The match is exhaustive over
398    /// [`RepositoryKind`] (ADR §5.5):
399    ///
400    /// - [`RepositoryKind::Repository`]: kind+name must match AND the effective
401    ///   namespace (`self.namespace` or `owner_namespace`) must equal
402    ///   `target_namespace`.
403    /// - [`RepositoryKind::ClusterRepository`]: kind+name must match; namespace is
404    ///   ignored on both sides (cluster-scoped).
405    ///
406    /// `target_namespace` is `None` for a `ClusterRepository` target.
407    ///
408    /// ```
409    /// use kopiur_api::common::{RepositoryKind, RepositoryRef};
410    ///
411    /// // A namespaced ref that omits `namespace` resolves against the owner's namespace.
412    /// let r = RepositoryRef { kind: RepositoryKind::Repository, name: "nas".into(), namespace: None };
413    /// assert!(r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("apps")));
414    /// assert!(!r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("other")));
415    ///
416    /// // A cluster-scoped target ignores namespace entirely.
417    /// let cr = RepositoryRef {
418    ///     kind: RepositoryKind::ClusterRepository,
419    ///     name: "hetzner".into(),
420    ///     namespace: None,
421    /// };
422    /// assert!(cr.resolves_to("apps", RepositoryKind::ClusterRepository, "hetzner", None));
423    /// // Kind must match even when names collide.
424    /// assert!(!r.resolves_to("apps", RepositoryKind::ClusterRepository, "nas", None));
425    /// ```
426    pub fn resolves_to(
427        &self,
428        owner_namespace: &str,
429        target_kind: RepositoryKind,
430        target_name: &str,
431        target_namespace: Option<&str>,
432    ) -> bool {
433        if self.kind != target_kind || self.name != target_name {
434            return false;
435        }
436        match self.kind {
437            RepositoryKind::Repository => {
438                Some(self.namespace.as_deref().unwrap_or(owner_namespace)) == target_namespace
439            }
440            RepositoryKind::ClusterRepository => true,
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn ref_of(kind: RepositoryKind, name: &str, namespace: Option<&str>) -> RepositoryRef {
450        RepositoryRef {
451            kind,
452            name: name.into(),
453            namespace: namespace.map(str::to_string),
454        }
455    }
456
457    #[test]
458    fn resolves_to_same_namespace_when_ref_omits_it() {
459        // A Maintenance in `apps` referencing `{ kind: Repository, name: nas }`
460        // (no namespace) points at Repository apps/nas.
461        let r = ref_of(RepositoryKind::Repository, "nas", None);
462        assert!(r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("apps")));
463        assert!(!r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("other")));
464    }
465
466    #[test]
467    fn resolves_to_honors_explicit_cross_namespace_ref() {
468        let r = ref_of(RepositoryKind::Repository, "nas", Some("backups"));
469        // Owner namespace is irrelevant once the ref pins one.
470        assert!(r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("backups")));
471        assert!(!r.resolves_to("apps", RepositoryKind::Repository, "nas", Some("apps")));
472    }
473
474    #[test]
475    fn resolves_to_name_mismatch_is_false() {
476        let r = ref_of(RepositoryKind::Repository, "nas", None);
477        assert!(!r.resolves_to("apps", RepositoryKind::Repository, "other", Some("apps")));
478    }
479
480    #[test]
481    fn resolves_to_kind_mismatch_is_false_even_with_same_name() {
482        // A `Repository` ref must never satisfy a `ClusterRepository` target and
483        // vice versa, even when the names collide.
484        let r = ref_of(RepositoryKind::Repository, "shared", None);
485        assert!(!r.resolves_to("apps", RepositoryKind::ClusterRepository, "shared", None));
486
487        let cr = ref_of(RepositoryKind::ClusterRepository, "shared", None);
488        assert!(!cr.resolves_to("apps", RepositoryKind::Repository, "shared", Some("apps")));
489    }
490
491    #[test]
492    fn resolves_to_cluster_repository_ignores_namespace() {
493        let cr = ref_of(RepositoryKind::ClusterRepository, "hetzner", None);
494        assert!(cr.resolves_to("apps", RepositoryKind::ClusterRepository, "hetzner", None));
495        // Even a stray namespace on the ref (webhook normally forbids it) still
496        // resolves cluster-scoped.
497        let stray = ref_of(RepositoryKind::ClusterRepository, "hetzner", Some("oops"));
498        assert!(stray.resolves_to("apps", RepositoryKind::ClusterRepository, "hetzner", None));
499    }
500}