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}