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}