Skip to main content

kopiur_api/
seed.rs

1//! `spec.seed` — initialize a brand-new repository from an existing replica
2//! (issue #380).
3//!
4//! Disaster recovery starts with an empty cluster and a full off-site mirror.
5//! Without this block the only way to get the mirror's history back under
6//! kopiur's management is to point a `Repository` at the mirror itself (which
7//! then becomes the live repository the new cluster writes into) or to copy the
8//! blobs by hand. `spec.seed` makes the first bootstrap of an **uninitialized**
9//! backend pull the data across first, in one mover Job, before the repository
10//! is ever reported `Ready`.
11//!
12//! Two source shapes, one field:
13//!
14//! * [`SeedSource::Backend`] — **blob mode**: a bare storage backend holding a
15//!   byte-for-byte mirror of a kopia repository (what a
16//!   [`RepositoryReplication`](crate::repository_replication) writes). The copy
17//!   is `kopia repository sync-to`, so the new repository inherits the mirror's
18//!   format and password verbatim — this repository's own
19//!   `encryption.passwordSecretRef` must therefore already carry the mirror's
20//!   password.
21//! * [`SeedSource::Repository`] — **migrate mode**: another `Repository` or
22//!   `ClusterRepository` CR, opened read-only. The copy is
23//!   `kopia snapshot migrate`, which preserves each snapshot's
24//!   `username@hostname:path` identity and times, so seeded history stays
25//!   restorable by `identity`/`fromPolicy`. Source and destination are two real
26//!   repositories with their own passwords and formats.
27//!
28//! Not to be confused with the "seed job" fixtures in `deploy/examples` — those
29//! are one-shot Jobs that write test data into a volume. This block seeds a
30//! **repository**, from another repository.
31//!
32//! Seeding is armed only while the repository has never been initialized
33//! (`status.uniqueId` unset) AND the mover's first connect reports the backend
34//! uninitialized. On an already-initialized repository the block is a documented
35//! no-op (`Seeded=True`, reason `AlreadyInitialized`), so it is safe to leave
36//! standing in a GitOps manifest forever.
37
38use crate::backend::Backend;
39use crate::common::{CredentialProjection, FailurePolicy, MigrateThrottle, RepositoryRef};
40use crate::snapshot_replication::PolicyCopyMode;
41use schemars::JsonSchema;
42use serde::{Deserialize, Serialize};
43
44/// Default `activeDeadlineSeconds` for a bootstrap Job that is **seeding** — 24
45/// hours, versus the 120s a routine connect gets.
46///
47/// A seed copies a whole repository over the network exactly once; the ordinary
48/// bootstrap deadline exists to fail a wedged *connect* fast and is orders of
49/// magnitude too short for it. Overridable per repository via
50/// `spec.seed.failurePolicy.activeDeadlineSeconds` (see
51/// [`seed_active_deadline_seconds`]). Part of the documented API contract, so it
52/// lives beside the field rather than in the controller.
53pub const DEFAULT_SEED_BOOTSTRAP_DEADLINE_SECS: i64 = 86_400;
54
55/// Initialize this repository from an existing replica on its first bootstrap.
56///
57/// Every knob below is a sub-object so future tuning slots in without an API
58/// break. The `sync`/`migrate` blocks are **mode-specific** and admission
59/// rejects the mismatched pairing (`sync` with a repository source, `migrate`
60/// with a backend source) rather than silently ignoring one — no field in
61/// kopiur is inert.
62#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
63#[serde(rename_all = "camelCase")]
64pub struct SeedSpec {
65    /// Where the seed data comes from: exactly one of a bare storage backend
66    /// (blob mode) or another repository CR (migrate mode).
67    pub from: SeedSource,
68    /// Tuning for the `kopia repository sync-to` blob copy. **Blob mode only**
69    /// (`from.backend`); rejected at admission alongside `from.repository`.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub sync: Option<SeedSyncOptions>,
72    /// Tuning for the `kopia snapshot migrate` copy. **Migrate mode only**
73    /// (`from.repository`); rejected at admission alongside `from.backend`.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub migrate: Option<SeedMigrateOptions>,
76    /// Accept a source that holds zero snapshots (default `false`).
77    ///
78    /// A mirror that answers but is empty is almost always a mis-pointed
79    /// bucket/prefix, and silently seeding nothing would hand you a `Ready`
80    /// repository with no history — the failure mode #380 is about. By default
81    /// the bootstrap fails loudly and retries; set this when an empty source is
82    /// genuinely expected (e.g. re-homing a repository whose history was
83    /// deliberately pruned to nothing).
84    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
85    pub allow_empty_source: bool,
86    /// Deadline/backoff for the **seeding** bootstrap Job. An absent
87    /// `activeDeadlineSeconds` means **24h** here, rather than the 120s a
88    /// routine connect gets — a seed copies a whole repository, once. Only
89    /// applied while the seed is armed; later connects to the now-initialized
90    /// repository use `spec.bootstrap.failurePolicy` as before.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub failure_policy: Option<FailurePolicy>,
93    /// Opt-in projection of the SOURCE repository's credential Secrets into the
94    /// seeding mover Job's namespace. **Migrate mode only in practice** — a
95    /// blob-mode source's credentials must already be in the namespace the
96    /// bootstrap Job runs in (this CR's own namespace for a `Repository`; for a
97    /// `ClusterRepository` the operator's namespace, unless
98    /// `encryption.passwordSecretRef.namespace` pins another, in which case the
99    /// Job runs there). Requires the operator's `features.credentialProjection`
100    /// install flag.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub credential_projection: Option<CredentialProjection>,
103}
104
105/// Where a seed reads from — exactly one of, externally tagged
106/// (`from: { backend: { s3: {...} } }` or `from: { repository: {...} }`).
107///
108/// The two variants are genuinely different operations, not two spellings of
109/// one: blob mode copies raw storage and inherits the source's format and
110/// password; migrate mode copies snapshot manifests between two independently
111/// encrypted repositories. Making them one enum is what forces every handler to
112/// answer for both.
113///
114/// ```
115/// use kopiur_api::seed::SeedSource;
116///
117/// // Blob mode: the wire form is `{ "backend": { "s3": { ... } } }`.
118/// let blob: SeedSource = serde_json::from_value(serde_json::json!({
119///     "backend": { "s3": { "bucket": "offsite-mirror" } }
120/// }))
121/// .unwrap();
122/// assert_eq!(blob.mode(), kopiur_api::seed::SeedMode::Blob);
123/// assert_eq!(blob.describe(), "S3");
124///
125/// // Migrate mode: another repository CR.
126/// let migrate: SeedSource = serde_json::from_value(serde_json::json!({
127///     "repository": { "kind": "ClusterRepository", "name": "offsite" }
128/// }))
129/// .unwrap();
130/// assert_eq!(migrate.mode(), kopiur_api::seed::SeedMode::Migrate);
131/// assert_eq!(migrate.describe(), "ClusterRepository/offsite");
132/// ```
133#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
134#[serde(rename_all = "camelCase")]
135pub enum SeedSource {
136    /// A bare storage backend holding a byte-for-byte mirror of a kopia
137    /// repository (blob mode, `kopia repository sync-to`). The mirror's format
138    /// and encryption password are inherited verbatim, so this repository's
139    /// `encryption.passwordSecretRef` must already hold the MIRROR's password
140    /// and `spec.create`'s format knobs are refused as inert.
141    // Boxed because a `Backend` is far larger than a `RepositoryRef`; an
142    // unboxed variant would inflate every `SeedSource` — and every `SeedSpec`
143    // and repository spec embedding one — to the larger size.
144    Backend(Box<Backend>),
145    /// Another `Repository`/`ClusterRepository`, opened read-only (migrate mode,
146    /// `kopia snapshot migrate`). Snapshot identities and times are preserved,
147    /// so seeded history is restorable by `identity`/`fromPolicy`.
148    ///
149    /// A `kind: Repository` reference with no `namespace` resolves in the
150    /// referrer's own namespace — and, on a cluster-scoped `ClusterRepository`
151    /// (which has none), in the operator's namespace, the same rule its
152    /// credential `secretRef`s follow. Set `namespace` explicitly whenever the
153    /// source lives anywhere else.
154    Repository(RepositoryRef),
155}
156
157impl SeedSource {
158    /// Which copy mechanism this source selects. Exhaustive, so a new variant
159    /// cannot compile until its mode is decided.
160    pub fn mode(&self) -> SeedMode {
161        match self {
162            SeedSource::Backend(_) => SeedMode::Blob,
163            SeedSource::Repository(_) => SeedMode::Migrate,
164        }
165    }
166
167    /// The rendering pinned into `status.seed.source`: the
168    /// [`Backend::kind_str`] discriminant (`S3`, `Filesystem`, …) for blob
169    /// mode, `Kind/name` (with `namespace/` when the reference sets one) for
170    /// migrate mode.
171    ///
172    /// Lives here so the controller, the CLI and any diagnostic describe a seed
173    /// source identically instead of each re-deriving a string.
174    ///
175    /// ```
176    /// use kopiur_api::seed::SeedSource;
177    ///
178    /// let cross_ns: SeedSource = serde_json::from_value(serde_json::json!({
179    ///     "repository": { "name": "nas", "namespace": "backups" }
180    /// }))
181    /// .unwrap();
182    /// assert_eq!(cross_ns.describe(), "Repository/backups/nas");
183    /// ```
184    pub fn describe(&self) -> String {
185        match self {
186            SeedSource::Backend(b) => b.kind_str().to_string(),
187            SeedSource::Repository(r) => match r.namespace.as_deref() {
188                Some(ns) if !ns.is_empty() => {
189                    format!("{}/{ns}/{}", r.kind.kind_str(), r.name)
190                }
191                _ => format!("{}/{}", r.kind.kind_str(), r.name),
192            },
193        }
194    }
195}
196
197/// Which copy mechanism a seed ran. Mirrors the `SeedSource` variant, named
198/// after the operation rather than the input so status, metrics
199/// (`kopiur_repository_seed_total{mode}`) and docs share one vocabulary.
200// The wire strings are pinned by `tests::seed_mode_labels_are_stable` rather
201// than a doctest: schemars lifts a referenced enum's doc comment into the field
202// description, so a code block here would land in `docs/field-reference.md`.
203#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, JsonSchema)]
204#[serde(rename_all = "camelCase")]
205pub enum SeedMode {
206    /// `kopia repository sync-to` from a bare mirror backend.
207    Blob,
208    /// `kopia snapshot migrate` from another repository CR.
209    Migrate,
210}
211
212impl SeedMode {
213    /// Stable lowercase label for metrics/log fields. Exhaustive.
214    pub fn as_str(self) -> &'static str {
215        match self {
216            SeedMode::Blob => "blob",
217            SeedMode::Migrate => "migrate",
218        }
219    }
220}
221
222/// Blob-mode tuning for `kopia repository sync-to`.
223///
224/// Deliberately a strict subset of
225/// [`SyncOptions`](crate::repository_replication::SyncOptions): a seed writes
226/// into a repository that does not exist yet, so `deleteExtra` has nothing to
227/// prune, `mustExist` must be `false` (initializing the destination layout is
228/// the point), and `times`/`update` have no prior copy to compare against.
229/// Offering them would be offering inert fields.
230#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq, Default, JsonSchema)]
231#[serde(rename_all = "camelCase")]
232pub struct SeedSyncOptions {
233    /// `--parallel`: concurrent blob-copy workers (kopia default `1` —
234    /// sequential). Raise it: a first-time seed of a large repository over a
235    /// WAN is exactly the workload sequential copying is worst at.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub parallel: Option<u32>,
238    /// `--max-download-speed`: cap read throughput from the seed source, in
239    /// bytes/sec (kopia default: unlimited).
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub max_download_speed_bytes_per_second: Option<i64>,
242    /// `--max-upload-speed`: cap write throughput into this repository, in
243    /// bytes/sec (kopia default: unlimited).
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub max_upload_speed_bytes_per_second: Option<i64>,
246}
247
248/// Migrate-mode tuning for `kopia snapshot migrate`.
249///
250/// Scalars plus an optional throttle sub-object, so `Eq` but NOT `Copy`
251/// ([`Throttle`](crate::common::Throttle) isn't).
252#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
253#[serde(rename_all = "camelCase")]
254pub struct SeedMigrateOptions {
255    /// `--parallel`: snapshots migrated concurrently (kopia default `1` —
256    /// sequential). Must be >= 1 when set.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub parallel: Option<u32>,
259    /// Copy only each source identity's most recent snapshot instead of its full
260    /// history (`kopia snapshot migrate --latest`). Default `false` — a seed
261    /// exists to recover history, so the full copy is the sane default; set this
262    /// when you only need the latest restore point back quickly.
263    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
264    pub latest_only: bool,
265    /// Whether the source's kopia **policies** are copied along with the
266    /// snapshots. Defaults to `PolicyCopyMode::None` (an explicit
267    /// `--no-policies`), not kopia's own copy-by-default: retention in a
268    /// kopiur-managed repository is driven by `Snapshot` CRs, and importing the
269    /// source's kopia-side policies could delete manifests behind the
270    /// operator's back.
271    #[serde(default)]
272    pub policies: PolicyCopyMode,
273    /// Bandwidth/ops caps for THIS seed's copy, per side. A migrate seed opens
274    /// two repositories under two kopia connections and `snapshot migrate` has
275    /// no speed flags of its own, so each side is applied as `kopia repository
276    /// throttle set` on that side's connection:
277    ///
278    /// * `source` caps the REPLICA — the repository named by
279    ///   `spec.seed.from.repository`, opened read-only — overriding **its**
280    ///   `moverDefaults.throttle`;
281    /// * `destination` caps THIS repository, the one being seeded, overriding
282    ///   **its own** `moverDefaults.throttle`.
283    ///
284    /// Each side overrides field by field: a knob set here wins, a knob left
285    /// unset keeps that repository's default. Absent: both sides use their
286    /// repository's defaults. Applies only while the seed is armed — an
287    /// ordinary connect to the now-initialized repository is capped by
288    /// `moverDefaults.throttle` alone.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub throttle: Option<MigrateThrottle>,
291}
292
293/// What the last seed attempt did, pinned on `Repository`/`ClusterRepository`
294/// `status.seed`. Absent on a repository that was never seeded.
295#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Default, JsonSchema)]
296#[serde(rename_all = "camelCase")]
297pub struct SeedStatus {
298    /// RFC 3339 timestamp the operator LAUNCHED a seeding bootstrap Job for
299    /// this repository — the durable **seed-attempt marker**.
300    ///
301    /// Stamped before the Job is created, and never cleared. Its whole job is
302    /// to distinguish "a seed this operator started did not finish" from "this
303    /// backend was initialized by somebody else": the first must resume the
304    /// copy, the second must keep the no-clobber `AlreadyInitialized` path.
305    /// See `seed_resume` — the marker is the ONLY input the resume decision is
306    /// allowed to take, because a resuming migrate writes into whatever
307    /// repository is at the backend and then re-stamps its maintenance owner.
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub started_at: Option<String>,
310    /// RFC 3339 timestamp the seed completed. Set once: a repository is seeded
311    /// exactly once, at its first bootstrap.
312    #[serde(default, skip_serializing_if = "Option::is_none")]
313    pub seeded_at: Option<String>,
314    /// Which copy mechanism ran: `blob` (a `kopia repository sync-to` from a
315    /// mirror backend) or `migrate` (a `kopia snapshot migrate` from another
316    /// repository CR).
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub mode: Option<SeedMode>,
319    /// The source the data came from, rendered by `SeedSource::describe` —
320    /// the backend discriminant (`S3`, `Filesystem`, …) for blob mode,
321    /// `Kind/name` for migrate mode. Never a credential or a bucket path.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub source: Option<String>,
324    /// Snapshots observed at the SOURCE when the seed ran. Zero is only ever
325    /// recorded when `allowEmptySource` permitted it.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub snapshot_count: Option<i64>,
328    /// Snapshots actually copied into this repository. Migrate mode only — a
329    /// blob copy moves storage, not manifests, so there is no per-snapshot copy
330    /// count to report and it leaves this unset; its `snapshotCount` is the
331    /// listing taken at the SOURCE before the copy, which for a byte-for-byte
332    /// mirror is also what this repository ends up holding.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub snapshots_copied: Option<i64>,
335}
336
337/// The `activeDeadlineSeconds` a **seeding** bootstrap Job gets: the
338/// repository's own `spec.seed.failurePolicy.activeDeadlineSeconds`, else
339/// [`DEFAULT_SEED_BOOTSTRAP_DEADLINE_SECS`].
340///
341/// Pure + shared so the controller (which builds the Job) and any diagnostic
342/// explaining a long-running seed agree on the number.
343///
344/// ```
345/// use kopiur_api::seed::{DEFAULT_SEED_BOOTSTRAP_DEADLINE_SECS, seed_active_deadline_seconds};
346/// # use kopiur_api::common::FailurePolicy;
347/// # use kopiur_api::seed::{SeedSpec, SeedSource};
348/// # let from: SeedSource = serde_json::from_value(serde_json::json!({
349/// #     "repository": { "name": "offsite" }
350/// # })).unwrap();
351/// let mut seed = SeedSpec {
352///     from,
353///     sync: None,
354///     migrate: None,
355///     allow_empty_source: false,
356///     failure_policy: None,
357///     credential_projection: None,
358/// };
359/// assert_eq!(seed_active_deadline_seconds(&seed), DEFAULT_SEED_BOOTSTRAP_DEADLINE_SECS);
360///
361/// seed.failure_policy = Some(FailurePolicy {
362///     backoff_limit: None,
363///     active_deadline_seconds: Some(3600),
364///     pod_startup_deadline_seconds: None,
365/// });
366/// assert_eq!(seed_active_deadline_seconds(&seed), 3600);
367/// ```
368pub fn seed_active_deadline_seconds(seed: &SeedSpec) -> i64 {
369    seed.failure_policy
370        .as_ref()
371        .and_then(|fp| fp.active_deadline_seconds)
372        .unwrap_or(DEFAULT_SEED_BOOTSTRAP_DEADLINE_SECS)
373}
374
375/// The `RepositoryRef` a migrate-mode seed reads from, or `None` for blob mode.
376/// Exhaustive helper so callers that only care about the CR-reference case
377/// (referent watches, tenancy gates, credential resolution) do not each write
378/// their own `match`.
379pub fn seed_repository_ref(seed: &SeedSpec) -> Option<&RepositoryRef> {
380    match &seed.from {
381        SeedSource::Repository(r) => Some(r),
382        SeedSource::Backend(_) => None,
383    }
384}
385
386/// The `Backend` a blob-mode seed reads from, or `None` for migrate mode.
387/// Exhaustive counterpart to [`seed_repository_ref`].
388pub fn seed_backend(seed: &SeedSpec) -> Option<&Backend> {
389    match &seed.from {
390        SeedSource::Backend(b) => Some(b),
391        SeedSource::Repository(_) => None,
392    }
393}
394
395/// Whether a repository whose `status.uniqueId` is `unique_id` should arm its
396/// seed (D4): a seed runs only on a repository that has never been initialized.
397/// Once the bootstrap pins a unique ID the block is a standing no-op.
398pub fn seed_armed(seed: Option<&SeedSpec>, unique_id: Option<&str>) -> bool {
399    seed.is_some() && unique_id.is_none_or(str::is_empty)
400}
401
402/// Whether an armed seed must **RESUME** an attempt a previous bootstrap
403/// started but did not finish, rather than run as a first seed.
404///
405/// `armed` is [`seed_armed`]; `status` is the repository's `status.seed`. The
406/// answer is `true` exactly when the seed is armed, the durable seed-attempt
407/// marker ([`SeedStatus::started_at`]) is present, and
408/// [`SeedStatus::seeded_at`] is not — i.e. this operator recorded that it began
409/// seeding THIS repository and never recorded finishing.
410///
411/// **The marker is the sole guard**, deliberately. A resuming migrate re-runs
412/// `kopia snapshot migrate` into whatever repository is at the backend and then
413/// re-stamps its maintenance owner, with no kopia-side backstop (blob mode gets
414/// one for free — `sync-to` refuses a destination whose format blob differs
415/// from the source's). So a repository this operator never began seeding — an
416/// ordinary ADOPTION of a backend somebody else initialized, `spec.seed` left
417/// standing in a GitOps manifest — must never resume: it has no marker, and it
418/// keeps the no-clobber `AlreadyInitialized` no-op. Never derive `resume` from
419/// anything weaker (a Job's existence, an unset `status.uniqueId`, a
420/// condition).
421///
422/// ```
423/// use kopiur_api::seed::{SeedStatus, seed_resume};
424///
425/// let none = SeedStatus::default();
426/// // Fresh seed: armed, but no attempt was ever recorded.
427/// assert!(!seed_resume(true, Some(&none)));
428/// assert!(!seed_resume(true, None));
429///
430/// // A previous attempt started and never finished ⇒ resume.
431/// let attempted = SeedStatus { started_at: Some("2026-01-01T00:00:00Z".into()), ..none.clone() };
432/// assert!(seed_resume(true, Some(&attempted)));
433///
434/// // Finished ⇒ nothing to resume (and the seed is no longer armed anyway).
435/// let done = SeedStatus { seeded_at: Some("2026-01-01T01:00:00Z".into()), ..attempted.clone() };
436/// assert!(!seed_resume(true, Some(&done)));
437/// assert!(!seed_resume(false, Some(&attempted)));
438/// ```
439pub fn seed_resume(armed: bool, status: Option<&SeedStatus>) -> bool {
440    let Some(status) = status else {
441        return false;
442    };
443    armed
444        && status.started_at.as_deref().is_some_and(|s| !s.is_empty())
445        && !status.seeded_at.as_deref().is_some_and(|s| !s.is_empty())
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::common::RepositoryKind;
452    use crate::testutil::from_yaml;
453
454    #[test]
455    fn blob_source_parses_under_its_wire_key() {
456        // Externally tagged: `from.backend` selects blob mode. Parsed the way
457        // the cluster does (YAML -> JSON value -> typed).
458        let seed: SeedSpec = from_yaml(
459            r#"
460from:
461  backend:
462    s3:
463      bucket: offsite-mirror
464      prefix: kopiur/
465sync:
466  parallel: 8
467  maxDownloadSpeedBytesPerSecond: 20000000
468allowEmptySource: false
469failurePolicy:
470  activeDeadlineSeconds: 43200
471"#,
472        );
473        assert_eq!(seed.from.mode(), SeedMode::Blob);
474        assert_eq!(seed.from.describe(), "S3");
475        match seed_backend(&seed) {
476            Some(Backend::S3(s3)) => assert_eq!(s3.bucket, "offsite-mirror"),
477            other => panic!("expected an s3 seed backend, got {other:?}"),
478        }
479        assert!(seed_repository_ref(&seed).is_none());
480        assert_eq!(seed.sync.and_then(|s| s.parallel), Some(8));
481        // An explicit failurePolicy wins over the 24h seed default.
482        assert_eq!(seed_active_deadline_seconds(&seed), 43_200);
483        // Round-trip through the wire shape.
484        let json = serde_json::to_value(&seed.from).expect("serialize");
485        assert!(
486            json.get("backend").is_some(),
487            "wire key must be `backend`: {json}"
488        );
489    }
490
491    #[test]
492    fn migrate_source_parses_under_its_wire_key() {
493        let seed: SeedSpec = from_yaml(
494            r#"
495from:
496  repository:
497    kind: ClusterRepository
498    name: offsite
499migrate:
500  parallel: 4
501  latestOnly: true
502  policies: copy
503credentialProjection:
504  enabled: true
505"#,
506        );
507        assert_eq!(seed.from.mode(), SeedMode::Migrate);
508        assert_eq!(seed.from.describe(), "ClusterRepository/offsite");
509        let r = seed_repository_ref(&seed).expect("migrate mode carries a repository ref");
510        assert_eq!(r.kind, RepositoryKind::ClusterRepository);
511        assert_eq!(r.name, "offsite");
512        assert!(seed_backend(&seed).is_none());
513        let m = seed.migrate.expect("migrate options");
514        assert_eq!(m.parallel, Some(4));
515        assert!(m.latest_only);
516        assert_eq!(m.policies, PolicyCopyMode::Copy);
517        assert!(seed.credential_projection.expect("projection").enabled);
518        let json = serde_json::to_value(&seed.from).expect("serialize");
519        assert!(
520            json.get("repository").is_some(),
521            "wire key must be `repository`: {json}"
522        );
523    }
524
525    #[test]
526    fn migrate_throttle_parses_per_side_and_round_trips() {
527        // Two repositories, two kopia connections, two independent caps — so
528        // the wire shape has to keep them apart. Parsed the cluster's way
529        // (YAML -> JSON value -> typed), which is also what proves the
530        // camelCase knob names survive.
531        let seed: SeedSpec = from_yaml(
532            r#"
533from:
534  repository:
535    name: offsite
536migrate:
537  parallel: 4
538  throttle:
539    source:
540      downloadBytesPerSecond: 20000000
541      readOpsPerSecond: 500
542    destination:
543      uploadBytesPerSecond: 10000000
544"#,
545        );
546        let throttle = seed
547            .migrate
548            .as_ref()
549            .and_then(|m| m.throttle.as_ref())
550            .expect("per-side migrate throttle");
551        let source = throttle.source.as_ref().expect("source side");
552        assert_eq!(source.download_bytes_per_second, Some(20_000_000));
553        assert_eq!(source.read_ops_per_second, Some(500));
554        assert_eq!(
555            source.upload_bytes_per_second, None,
556            "an unset knob stays unset rather than defaulting to a cap"
557        );
558        let destination = throttle.destination.as_ref().expect("destination side");
559        assert_eq!(destination.upload_bytes_per_second, Some(10_000_000));
560        assert_eq!(
561            destination.download_bytes_per_second, None,
562            "the SOURCE side's knobs must not leak into the destination"
563        );
564
565        let json = serde_json::to_value(&seed).expect("serialize");
566        assert_eq!(
567            json.pointer("/migrate/throttle/source/downloadBytesPerSecond"),
568            Some(&serde_json::json!(20_000_000)),
569            "{json}"
570        );
571        assert_eq!(
572            json.pointer("/migrate/throttle/destination/uploadBytesPerSecond"),
573            Some(&serde_json::json!(10_000_000)),
574            "{json}"
575        );
576        let reparsed: SeedSpec = serde_json::from_value(json).expect("reparse");
577        assert_eq!(seed, reparsed);
578
579        // Absent on a migrate seed that does not cap anything: the common case
580        // must not grow a key (and must not show up in a GitOps diff).
581        let plain: SeedSpec = from_yaml(
582            r#"
583from:
584  repository:
585    name: offsite
586migrate:
587  parallel: 2
588"#,
589        );
590        assert!(plain.migrate.expect("migrate options").throttle.is_none());
591    }
592
593    #[test]
594    fn unknown_source_variant_is_rejected() {
595        // An externally-tagged enum must refuse a key it does not know rather
596        // than silently defaulting to one of the two real modes.
597        let v: serde_json::Value = serde_yaml::from_str("mirror:\n  name: offsite\n").unwrap();
598        let err = serde_json::from_value::<SeedSource>(v).expect_err("unknown variant must fail");
599        assert!(
600            err.to_string().contains("unknown variant"),
601            "unexpected error: {err}"
602        );
603    }
604
605    #[test]
606    fn migrate_policies_default_to_none() {
607        // kopia's own migrate default IMPORTS the source policies; kopiur's must
608        // not, or a seeded repository inherits kopia-side retention that fights
609        // the CR-driven timeline.
610        let seed: SeedSpec = from_yaml(
611            r#"
612from:
613  repository:
614    name: offsite
615migrate:
616  parallel: 2
617"#,
618        );
619        assert_eq!(
620            seed.migrate.expect("migrate options").policies,
621            PolicyCopyMode::None
622        );
623    }
624
625    #[test]
626    fn optional_blocks_are_elided_when_absent() {
627        // `#[serde(skip_serializing_if)]` keeps a minimal spec minimal on the
628        // wire (and out of GitOps diffs).
629        let seed = seed_of(from_yaml::<SeedSource>("repository:\n  name: offsite\n"));
630        let json = serde_json::to_value(&seed).expect("serialize");
631        let obj = json.as_object().expect("object");
632        assert_eq!(obj.keys().collect::<Vec<_>>(), vec!["from"], "{json}");
633    }
634
635    #[test]
636    fn seed_is_armed_only_before_the_first_unique_id() {
637        let seed = seed_of(from_yaml::<SeedSource>("repository:\n  name: offsite\n"));
638        assert!(seed_armed(Some(&seed), None));
639        assert!(
640            seed_armed(Some(&seed), Some("")),
641            "an empty pin is not a pin"
642        );
643        assert!(!seed_armed(Some(&seed), Some("abc123")));
644        assert!(!seed_armed(None, None));
645    }
646
647    #[test]
648    fn seed_mode_labels_are_stable() {
649        // Metrics label values and the status enum share one vocabulary.
650        assert_eq!(SeedMode::Blob.as_str(), "blob");
651        assert_eq!(SeedMode::Migrate.as_str(), "migrate");
652        assert_eq!(
653            serde_json::to_value(SeedMode::Migrate).unwrap(),
654            serde_json::json!("migrate")
655        );
656    }
657
658    #[test]
659    fn seed_status_round_trips() {
660        let status: SeedStatus = from_yaml(
661            r#"
662seededAt: "2026-08-17T04:05:06Z"
663mode: migrate
664source: ClusterRepository/offsite
665snapshotCount: 412
666snapshotsCopied: 412
667"#,
668        );
669        assert_eq!(status.mode, Some(SeedMode::Migrate));
670        assert_eq!(status.snapshots_copied, Some(412));
671        let reparsed: SeedStatus =
672            serde_json::from_value(serde_json::to_value(&status).unwrap()).unwrap();
673        assert_eq!(status, reparsed);
674        // The marker is optional on the wire and elided when unset, so an
675        // upgrade over a status written before #380 decodes cleanly.
676        assert_eq!(status.started_at, None);
677        assert!(
678            !serde_json::to_value(&status)
679                .unwrap()
680                .as_object()
681                .unwrap()
682                .contains_key("startedAt")
683        );
684    }
685
686    #[test]
687    fn resume_is_decided_by_the_attempt_marker_and_nothing_else() {
688        // The full matrix the controller depends on. `armed` alone never
689        // resumes — that is the ADOPTION case (a backend somebody else
690        // initialized, with `spec.seed` standing in the manifest), and it must
691        // keep the no-clobber AlreadyInitialized path.
692        let marker = |started: Option<&str>, seeded: Option<&str>| SeedStatus {
693            started_at: started.map(str::to_string),
694            seeded_at: seeded.map(str::to_string),
695            ..SeedStatus::default()
696        };
697        // (armed, status) -> resume
698        let cases: &[(bool, Option<SeedStatus>, bool, &str)] = &[
699            (true, None, false, "fresh seed: no status at all"),
700            (
701                true,
702                Some(marker(None, None)),
703                false,
704                "fresh seed: status exists but no attempt was recorded",
705            ),
706            (
707                true,
708                Some(marker(Some(""), None)),
709                false,
710                "an empty marker is not a marker",
711            ),
712            (
713                true,
714                Some(marker(Some("2026-01-01T00:00:00Z"), None)),
715                true,
716                "retry after a recorded attempt: RESUME",
717            ),
718            (
719                true,
720                Some(marker(
721                    Some("2026-01-01T00:00:00Z"),
722                    Some("2026-01-01T01:00:00Z"),
723                )),
724                false,
725                "already seeded: nothing to resume",
726            ),
727            (
728                false,
729                Some(marker(Some("2026-01-01T00:00:00Z"), None)),
730                false,
731                "not armed (uniqueId pinned): never resume",
732            ),
733            (
734                true,
735                Some(marker(None, Some("2026-01-01T01:00:00Z"))),
736                false,
737                "seeded without a marker (impossible, but must not resume)",
738            ),
739        ];
740        for (armed, status, expected, why) in cases {
741            assert_eq!(
742                seed_resume(*armed, status.as_ref()),
743                *expected,
744                "seed_resume({armed}, {status:?}): {why}"
745            );
746        }
747    }
748
749    fn seed_of(from: SeedSource) -> SeedSpec {
750        SeedSpec {
751            from,
752            sync: None,
753            migrate: None,
754            allow_empty_source: false,
755            failure_policy: None,
756            credential_projection: None,
757        }
758    }
759}