kopiur_kopia/client/mod.rs
1//! The `tokio::process`-based kopia client.
2//!
3//! `KopiaClient` is controller-agnostic: it knows how to invoke the `kopia`
4//! binary, stream its output, and parse the trailing JSON on stdout into the
5//! typed [`crate::model`] structs. It has **no** kube/k8s-openapi dependency
6//! (SKILL "keep it controller-agnostic").
7//!
8//! Per ADR §5.4, kopia prints progress to **stderr** and the `--json` result to
9//! **stdout**. We capture both: stdout is parsed as JSON, stderr is retained so
10//! a failure can carry the tail of the real error message.
11//!
12//! Secrets (the repository password) are passed via the environment
13//! (`KOPIA_PASSWORD`), never on argv, so they never leak into process listings
14//! or error messages.
15
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::PathBuf;
18use std::process::Stdio;
19use std::time::Duration;
20
21use serde::de::DeserializeOwned;
22use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
23use tokio::process::Command;
24
25use crate::error::{KopiaError, KopiaErrorClass, tail_lines};
26use crate::model::{
27 IndexBlobEntry, MaintenanceInfo, RepositoryStatus, SnapshotCreateOutcome, SnapshotCreateResult,
28 SnapshotListEntry, SnapshotSource,
29};
30
31/// Which maintenance pass to run.
32///
33/// `Serialize`/`Deserialize` so the mover work-spec can carry the mode as one
34/// shared type (no parallel enum in `kopiur-mover`). Wire form is the camelCase
35/// variant name (`"quick"` / `"full"`).
36#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37#[serde(rename_all = "camelCase")]
38pub enum MaintenanceMode {
39 /// `kopia maintenance run --no-full` — index compaction, epoch advance.
40 Quick,
41 /// `kopia maintenance run --full` — content GC + rewrite.
42 Full,
43}
44
45/// A typed description of how to reach a kopia repository. This is the input to
46/// [`KopiaClient::repository_connect`] / [`KopiaClient::repository_create`].
47/// Externally-tagged so exactly one backend is representable (mirrors the API
48/// crate's `Backend` discipline, though this is a separate, simpler type with
49/// no kube dependency).
50///
51/// ## Credentials are NOT here
52///
53/// Secrets are supplied two ways, never on argv. Env-delivered secrets (set with
54/// [`KopiaClientBuilder::env`]) cover the backends kopia reads from the
55/// environment; file-delivered secrets are written to a file by the caller (the
56/// mover) and the *path* is passed in the relevant `ConnectSpec` field. Either
57/// way only non-secret identifiers (bucket, host, path, …) and file *paths* live
58/// in `ConnectSpec`, so a secret never leaks into a ConfigMap, a process listing,
59/// or an error message. The relevant kopia inputs by backend:
60/// * S3: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN` (env)
61/// * Azure: `AZURE_STORAGE_KEY` / `AZURE_STORAGE_SAS_TOKEN` (env; or SP env)
62/// * B2: `B2_KEY_ID`, `B2_KEY` (env)
63/// * WebDAV:`KOPIA_WEBDAV_USERNAME`, `KOPIA_WEBDAV_PASSWORD` (env)
64/// * GCS: `Gcs::credentials_file` → `--credentials-file` (a JSON file path)
65/// * SFTP: `Sftp::keyfile`/`known_hosts` → `--keyfile`/`--known-hosts` (file paths)
66/// * rclone:`Rclone::config_file` → rclone `--config` (a file path)
67/// * all: `KOPIA_PASSWORD` (the repository encryption password; env)
68///
69/// This is the full set of kopia 0.23 `repository connect/create` backends. The
70/// operator's CRD `Backend` enum maps onto the first eight; `Gdrive`,
71/// `FromConfig`, and `Server` are exposed for client completeness (a kopia
72/// client connecting to an existing kopia API server is a legitimate backend —
73/// distinct from *running* a server, which the operator deliberately does not do).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum ConnectSpec {
76 /// Filesystem backend at a local path (used in-cluster for hostPath/PVC
77 /// repos and in tests).
78 Filesystem {
79 /// Absolute path to the repository root.
80 path: PathBuf,
81 },
82 /// S3-compatible backend.
83 S3 {
84 /// Bucket name.
85 bucket: String,
86 /// Optional custom endpoint (for MinIO / non-AWS).
87 endpoint: Option<String>,
88 /// Optional key prefix within the bucket.
89 prefix: Option<String>,
90 /// Region, if required by the endpoint.
91 region: Option<String>,
92 /// Talk plain HTTP to the endpoint (`--disable-tls`). For HTTP-only
93 /// endpoints (in-cluster MinIO/RustFS); kopia otherwise assumes HTTPS.
94 disable_tls: bool,
95 /// Skip TLS certificate verification (`--disable-tls-verification`).
96 disable_tls_verification: bool,
97 /// PEM CA bundle used to verify the endpoint's certificate
98 /// (`--root-ca-pem-base64`, base64-encoded on argv — a CA cert is
99 /// public, and the base64 form needs no file the way
100 /// `--root-ca-pem-path` would). kopia persists it into the connection
101 /// config (`s3.Options.RootCA`, `json:"rootCA"`), so one connect covers
102 /// every later verb reading that config — including an exec'd
103 /// `kopia server start`. NOTE: kopia builds a FRESH cert pool from this
104 /// bundle for the S3 connection (it does not extend the system roots),
105 /// so a chain that also needs public roots must include them in the
106 /// bundle.
107 root_ca_pem: Option<String>,
108 /// Authenticate via the ambient AWS credential chain (IRSA web-identity,
109 /// EKS Pod Identity, IMDS) instead of static keys — the workload-identity
110 /// path. kopia 0.23 marks `--access-key`/`--secret-access-key` as
111 /// *required* flags (env-bound to `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`),
112 /// but its storage layer skips empty static credentials and falls through
113 /// minio-go's chain — so this renders the flags **explicitly empty**
114 /// (`--access-key=`), which satisfies the parser and engages the chain.
115 ambient_credentials: bool,
116 },
117 /// Azure Blob Storage backend.
118 Azure {
119 /// Blob container name.
120 container: String,
121 /// Storage account name (when not supplied via env).
122 storage_account: Option<String>,
123 /// Optional object prefix.
124 prefix: Option<String>,
125 },
126 /// Google Cloud Storage backend.
127 Gcs {
128 /// Bucket name.
129 bucket: String,
130 /// Optional object prefix.
131 prefix: Option<String>,
132 /// Path to a JSON service-account credentials file inside the mover pod
133 /// (`--credentials-file`). The mover materializes this from the
134 /// credentials Secret at runtime; `None` falls back to ambient ADC.
135 credentials_file: Option<String>,
136 },
137 /// Backblaze B2 backend.
138 B2 {
139 /// Bucket name.
140 bucket: String,
141 /// Optional object prefix.
142 prefix: Option<String>,
143 },
144 /// SFTP/SSH backend.
145 Sftp {
146 /// Server hostname.
147 host: String,
148 /// Path to the repository on the server.
149 path: String,
150 /// Server port (defaults to 22 when `None`).
151 port: Option<u16>,
152 /// SSH username.
153 username: Option<String>,
154 /// Path to a private key file inside the mover pod (`--keyfile`). The
155 /// mover materializes this from the credentials Secret at runtime.
156 keyfile: Option<String>,
157 /// Path to a `known_hosts` file inside the mover pod (`--known-hosts`),
158 /// pinning the server host key. The mover materializes this from the
159 /// credentials Secret at runtime.
160 known_hosts: Option<String>,
161 },
162 /// WebDAV backend.
163 WebDav {
164 /// WebDAV server URL.
165 url: String,
166 },
167 /// Rclone backend (shells out to an `rclone` binary).
168 Rclone {
169 /// Rclone `remote:path`.
170 remote_path: String,
171 /// Path to an `rclone.conf` inside the mover pod, forwarded to rclone via
172 /// `--rclone-args=--config=<path>`. The mover materializes this from the
173 /// config Secret at runtime; `None` uses rclone's default config lookup.
174 config_file: Option<String>,
175 /// Go-duration value for kopia's `--rclone-startup-timeout` (how long to
176 /// wait for the embedded `rclone serve` to come up). `None` leaves kopia's
177 /// default (`15s`).
178 startup_timeout: Option<String>,
179 },
180 /// Google Drive backend.
181 Gdrive {
182 /// Drive folder id that holds the repository.
183 folder_id: String,
184 /// Path to a Google service-account JSON inside the mover pod, passed as
185 /// `--credentials-file`. The mover materializes this from the credentials
186 /// Secret at runtime; `None` uses kopia's ambient credential lookup.
187 credentials_file: Option<String>,
188 },
189 /// Reconnect from a kopia configuration token/file (`repository connect
190 /// from-config`). Exactly one of `file`/`token` is meaningful.
191 FromConfig {
192 /// Path to a kopia config file.
193 file: Option<String>,
194 /// A kopia configuration token.
195 token: Option<String>,
196 },
197 /// Connect to an existing kopia API server as a client.
198 Server {
199 /// Server URL.
200 url: String,
201 /// Expected server TLS certificate fingerprint (sha256 hex).
202 fingerprint: Option<String>,
203 },
204}
205
206/// Per-connection kopia cache budgets, applied at `repository connect`/`create`
207/// time (`--content-cache-size-mb` / `--metadata-cache-size-mb`). Each mover pod
208/// connects fresh, so these size that pod's local cache. `None` leaves kopia's
209/// default. Serializable so it rides the mover work spec from controller to mover.
210#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
211#[serde(rename_all = "camelCase")]
212pub struct CacheTuning {
213 /// `--content-cache-size-mb`: content (data) cache budget in MiB.
214 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub content_cache_size_mb: Option<i64>,
216 /// `--metadata-cache-size-mb`: metadata cache budget in MiB.
217 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub metadata_cache_size_mb: Option<i64>,
219}
220
221impl CacheTuning {
222 /// Whether no budgets are set (so the connect command adds no cache flags).
223 pub fn is_unset(&self) -> bool {
224 self.content_cache_size_mb.is_none() && self.metadata_cache_size_mb.is_none()
225 }
226
227 /// The `--content-cache-size-mb` / `--metadata-cache-size-mb` args for the set
228 /// budgets, in a stable order. Empty when nothing is set.
229 fn args(&self) -> Vec<String> {
230 let mut a = Vec::new();
231 if let Some(mb) = self.content_cache_size_mb {
232 a.push("--content-cache-size-mb".into());
233 a.push(mb.to_string());
234 }
235 if let Some(mb) = self.metadata_cache_size_mb {
236 a.push("--metadata-cache-size-mb".into());
237 a.push(mb.to_string());
238 }
239 a
240 }
241}
242
243impl ConnectSpec {
244 /// Stable discriminant string for logging/metrics (mirrors
245 /// `kopiur_api::backend::Backend::kind_str`).
246 ///
247 /// ```
248 /// use std::path::PathBuf;
249 /// use kopiur_kopia::ConnectSpec;
250 ///
251 /// let fs = ConnectSpec::Filesystem { path: PathBuf::from("/repo") };
252 /// assert_eq!(fs.kind_str(), "filesystem");
253 ///
254 /// let s3 = ConnectSpec::S3 {
255 /// bucket: "backups".into(),
256 /// endpoint: Some("https://minio.local".into()),
257 /// prefix: None,
258 /// region: None,
259 /// disable_tls: false,
260 /// disable_tls_verification: false,
261 /// ambient_credentials: false,
262 /// root_ca_pem: None,
263 /// };
264 /// assert_eq!(s3.kind_str(), "s3");
265 /// ```
266 pub fn kind_str(&self) -> &'static str {
267 match self {
268 ConnectSpec::Filesystem { .. } => "filesystem",
269 ConnectSpec::S3 { .. } => "s3",
270 ConnectSpec::Azure { .. } => "azure",
271 ConnectSpec::Gcs { .. } => "gcs",
272 ConnectSpec::B2 { .. } => "b2",
273 ConnectSpec::Sftp { .. } => "sftp",
274 ConnectSpec::WebDav { .. } => "webdav",
275 ConnectSpec::Rclone { .. } => "rclone",
276 ConnectSpec::Gdrive { .. } => "gdrive",
277 ConnectSpec::FromConfig { .. } => "from-config",
278 ConnectSpec::Server { .. } => "server",
279 }
280 }
281
282 /// The environment-variable names kopia reads this backend's credentials from
283 /// **directly** (no intermediate file). These are exactly the vars the
284 /// replication mover remaps from their `KOPIUR_DEST_`-prefixed copies onto their
285 /// plain names for the `sync-to` subprocess, so the destination authenticates
286 /// with its own keys instead of the source's identically named ones (issue #200).
287 ///
288 /// Exhaustive over [`ConnectSpec`] so a new backend cannot compile until its
289 /// credential-delivery mechanism is decided. File-based backends (GCS/SFTP/
290 /// Rclone/Gdrive) deliver credentials as a materialized *file* whose path is on
291 /// argv, so they read no direct credential env var and return `&[]` — the mover
292 /// stages their destination file separately. `AWS_WEB_IDENTITY_TOKEN_FILE` and
293 /// the other ambient-chain *hints* are deliberately excluded: they belong to the
294 /// pod's ServiceAccount (a workload-identity destination), not to a credential
295 /// Secret, and must never be remapped or unset.
296 pub fn direct_credential_env_names(&self) -> &'static [&'static str] {
297 match self {
298 ConnectSpec::S3 { .. } => &[
299 "AWS_ACCESS_KEY_ID",
300 "AWS_SECRET_ACCESS_KEY",
301 "AWS_SESSION_TOKEN",
302 ],
303 // Static shared key / SAS token, plus the service-principal env trio
304 // (the same names kopia's static Azure auth reads).
305 ConnectSpec::Azure { .. } => &[
306 "AZURE_STORAGE_KEY",
307 "AZURE_STORAGE_SAS_TOKEN",
308 "AZURE_TENANT_ID",
309 "AZURE_CLIENT_ID",
310 "AZURE_CLIENT_SECRET",
311 ],
312 ConnectSpec::B2 { .. } => &["B2_KEY_ID", "B2_KEY"],
313 ConnectSpec::WebDav { .. } => &["KOPIA_WEBDAV_USERNAME", "KOPIA_WEBDAV_PASSWORD"],
314 // File-delivered (materialized to a path) or credential-free.
315 ConnectSpec::Filesystem { .. }
316 | ConnectSpec::Gcs { .. }
317 | ConnectSpec::Sftp { .. }
318 | ConnectSpec::Rclone { .. }
319 | ConnectSpec::Gdrive { .. }
320 | ConnectSpec::FromConfig { .. }
321 | ConnectSpec::Server { .. } => &[],
322 }
323 }
324
325 /// The kopia subcommand args that select this backend, e.g.
326 /// `["filesystem", "--path", "/repo"]`. Used by both connect and create.
327 /// Credentials are expected in the environment, never here (see the type
328 /// docs). A new backend variant cannot compile until it is handled.
329 fn backend_args(&self) -> Vec<String> {
330 // Push `--flag value` only when the optional value is present.
331 fn opt(a: &mut Vec<String>, flag: &str, value: &Option<String>) {
332 if let Some(v) = value {
333 a.push(flag.into());
334 a.push(v.clone());
335 }
336 }
337 match self {
338 ConnectSpec::Filesystem { path } => {
339 vec![
340 "filesystem".into(),
341 "--path".into(),
342 path.display().to_string(),
343 ]
344 }
345 ConnectSpec::S3 {
346 bucket,
347 endpoint,
348 prefix,
349 region,
350 disable_tls,
351 disable_tls_verification,
352 root_ca_pem,
353 ambient_credentials,
354 } => {
355 let mut a = vec!["s3".into(), "--bucket".into(), bucket.clone()];
356 opt(&mut a, "--endpoint", endpoint);
357 opt(&mut a, "--prefix", prefix);
358 opt(&mut a, "--region", region);
359 if *disable_tls {
360 a.push("--disable-tls".into());
361 }
362 if *disable_tls_verification {
363 a.push("--disable-tls-verification".into());
364 }
365 if let Some(pem) = root_ca_pem {
366 use base64::Engine as _;
367 a.push("--root-ca-pem-base64".into());
368 a.push(base64::engine::general_purpose::STANDARD.encode(pem));
369 }
370 if *ambient_credentials {
371 // Single `=`-joined tokens: an empty value as a separate argv
372 // token (`--access-key ""`) would be consumed as the flag's
373 // value either way, but the joined form is unambiguous to
374 // kingpin and to a human reading the Job args. Satisfies the
375 // Required() flags with empty values so kopia's storage layer
376 // falls through to the ambient chain (IRSA / Pod Identity / IMDS).
377 a.push("--access-key=".into());
378 a.push("--secret-access-key=".into());
379 }
380 a
381 }
382 ConnectSpec::Azure {
383 container,
384 storage_account,
385 prefix,
386 } => {
387 let mut a = vec!["azure".into(), "--container".into(), container.clone()];
388 opt(&mut a, "--storage-account", storage_account);
389 opt(&mut a, "--prefix", prefix);
390 a
391 }
392 ConnectSpec::Gcs {
393 bucket,
394 prefix,
395 credentials_file,
396 } => {
397 let mut a = vec!["gcs".into(), "--bucket".into(), bucket.clone()];
398 opt(&mut a, "--prefix", prefix);
399 opt(&mut a, "--credentials-file", credentials_file);
400 a
401 }
402 ConnectSpec::B2 { bucket, prefix } => {
403 let mut a = vec!["b2".into(), "--bucket".into(), bucket.clone()];
404 opt(&mut a, "--prefix", prefix);
405 a
406 }
407 ConnectSpec::Sftp {
408 host,
409 path,
410 port,
411 username,
412 keyfile,
413 known_hosts,
414 } => {
415 let mut a = vec![
416 "sftp".into(),
417 "--host".into(),
418 host.clone(),
419 "--path".into(),
420 path.clone(),
421 ];
422 if let Some(p) = port {
423 a.push("--port".into());
424 a.push(p.to_string());
425 }
426 opt(&mut a, "--username", username);
427 opt(&mut a, "--keyfile", keyfile);
428 opt(&mut a, "--known-hosts", known_hosts);
429 a
430 }
431 ConnectSpec::WebDav { url } => {
432 vec!["webdav".into(), "--url".into(), url.clone()]
433 }
434 ConnectSpec::Rclone {
435 remote_path,
436 config_file,
437 startup_timeout,
438 } => {
439 let mut a = vec!["rclone".into(), "--remote-path".into(), remote_path.clone()];
440 // Forward the rclone config path to the embedded rclone. Must be a
441 // SINGLE `--rclone-args=<value>` token: kopia's CLI parser treats a
442 // separate value starting with `--` as the next flag, so
443 // `--rclone-args --config=…` fails with "expected argument".
444 if let Some(cfg) = config_file {
445 a.push(format!("--rclone-args=--config={cfg}"));
446 }
447 // kopia's own connect flag (not an rclone arg): how long to wait
448 // for the embedded `rclone serve` before failing the connect.
449 if let Some(t) = startup_timeout {
450 a.push(format!("--rclone-startup-timeout={t}"));
451 }
452 a
453 }
454 ConnectSpec::Gdrive {
455 folder_id,
456 credentials_file,
457 } => {
458 let mut a = vec!["gdrive".into(), "--folder-id".into(), folder_id.clone()];
459 opt(&mut a, "--credentials-file", credentials_file);
460 a
461 }
462 ConnectSpec::FromConfig { file, token } => {
463 let mut a = vec!["from-config".into()];
464 opt(&mut a, "--file", file);
465 opt(&mut a, "--token", token);
466 a
467 }
468 ConnectSpec::Server { url, fingerprint } => {
469 let mut a = vec!["server".into(), "--url".into(), url.clone()];
470 opt(&mut a, "--server-cert-fingerprint", fingerprint);
471 a
472 }
473 }
474 }
475}
476
477/// Options for `kopia repository connect` beyond backend/cache selection.
478/// `Copy` + `Default` so the common read-write, non-persisted connect is
479/// `ConnectOptions::default()` — which produces byte-identical argv to a
480/// pre-`ConnectOptions` [`KopiaClient::repository_connect`].
481#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
482pub struct ConnectOptions {
483 /// `--readonly`: persist kopia's read-only bit into the client config, so
484 /// every subsequent invocation on this connection is structurally unable
485 /// to mutate the repository (browse sessions; replication source connects).
486 pub readonly: bool,
487 /// `--persist-credentials`: write the repository password beside the
488 /// config file (`<config>.kopia-password`) so later invocations reading
489 /// that config — including `kopia snapshot migrate --source-config` run
490 /// under a DIFFERENT `KOPIA_PASSWORD` — authenticate with the persisted
491 /// password. kopia's own default is already persist-on; passing it
492 /// explicitly pins the contract rather than relying on the default.
493 pub persist_credentials: bool,
494}
495
496/// Which source-repository snapshot sources `kopia snapshot migrate` copies.
497#[derive(Debug, Clone, PartialEq, Eq)]
498pub enum MigrateSources {
499 /// `--all`: migrate every source present in the source repository.
500 All,
501 /// One `--sources <spec>` per entry. Each spec is a kopia source triple
502 /// rendered as `username@hostname:/path` (see
503 /// [`crate::model::SnapshotSource::identity`]).
504 List(Vec<String>),
505}
506
507/// How `kopia snapshot migrate` treats kopia-side policies on the destination.
508///
509/// kopia's OWN default is `--policies` **true** (copy policies), so
510/// [`MigratePolicies::None`] must be rendered as an EXPLICIT `--no-policies` —
511/// omitting the flag would silently import the source repository's kopia
512/// policies (retention among them) into the destination.
513#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
514pub enum MigratePolicies {
515 /// `--no-policies`: copy no kopia policies (kopiur's default — the
516 /// operator owns policy on both sides).
517 #[default]
518 None,
519 /// `--policies`: copy policies for the migrated sources, keeping any that
520 /// already exist on the destination.
521 Copy,
522 /// `--policies --overwrite-policies`: copy policies, overwriting existing
523 /// destination policies for the migrated sources.
524 CopyOverwrite,
525}
526
527/// Options for `kopia snapshot migrate --source-config <path>` — logical
528/// (snapshot-level) replication from another repository into the CONNECTED
529/// one. See [`KopiaClient::snapshot_migrate`] for the execution contract.
530#[derive(Debug, Clone, PartialEq, Eq)]
531pub struct SnapshotMigrateOptions {
532 /// `--source-config`: path to the kopia config file of the SOURCE
533 /// repository. The source password is read from that config's persisted
534 /// credentials (`<config>.kopia-password` — see
535 /// [`ConnectOptions::persist_credentials`]), never from this client's
536 /// `KOPIA_PASSWORD` (which belongs to the connected destination).
537 pub source_config_path: String,
538 /// Which sources to migrate.
539 pub sources: MigrateSources,
540 /// `--latest-only`: migrate only the latest snapshot per source.
541 pub latest_only: bool,
542 /// `--parallel <n>`: how many sources to migrate concurrently.
543 pub parallel: Option<u32>,
544 /// Policy copy mode (kopiur defaults to [`MigratePolicies::None`]).
545 pub policies: MigratePolicies,
546}
547
548/// Options for `kopia snapshot verify`. All fields default to kopia's defaults
549/// when `None`/empty.
550#[derive(Debug, Clone, Default, PartialEq, Eq)]
551pub struct VerifyOptions {
552 /// `--sources`: restrict verification to these kopia sources
553 /// (`username@hostname:path`). Empty (the default) verifies EVERY snapshot
554 /// in the repository — kopia's own default. For a per-`SnapshotPolicy`
555 /// verify against a shared repository that is both wrong (it re-verifies
556 /// every other policy's data under a different identity) and expensive
557 /// (`verifyFilesPercent` then samples the WHOLE repository, not just this
558 /// policy's snapshots — issue #250), so the controller always scopes a
559 /// quick verify to the policy's resolved identity.
560 pub sources: Vec<String>,
561 /// `--verify-files-percent`: randomly fully-read this percentage of files.
562 pub verify_files_percent: Option<u8>,
563 /// `--max-errors`: stop after this many errors (0 = never stop early).
564 pub max_errors: Option<u32>,
565 /// `--parallel`: verification parallelism (kopia default: 8).
566 pub parallel: Option<u32>,
567 /// `--file-parallelism`: parallelism for file verification (kopia default: unset).
568 pub file_parallelism: Option<u32>,
569 /// `--file-queue-length`: queue length for file verification (kopia default: 20000).
570 pub file_queue_length: Option<u32>,
571}
572
573/// Options for `kopia snapshot create` (M4 flag sweep, issue #216 category
574/// sweep). All-default reproduces kopia's own defaults / today's argv:
575/// `fail_fast: None` (kopia default: keep going past per-file errors, subject
576/// to the `errorHandling.ignore*Errors` policy knobs), `upload_limit_mb: None`
577/// (kopia default: unlimited), `description: None` (kopia default: empty).
578#[derive(Debug, Clone, Default, PartialEq, Eq)]
579pub struct SnapshotCreateOptions {
580 /// `--[no-]fail-fast`: abort the snapshot at the first error instead of
581 /// collecting and continuing (kopia default: false — collect and continue).
582 pub fail_fast: Option<bool>,
583 /// `--upload-limit-mb`: abort the snapshot once this many MB have been
584 /// uploaded (kopia default: 0 — unlimited).
585 pub upload_limit_mb: Option<i64>,
586 /// `--description`: free-form text recorded on the snapshot manifest
587 /// (kopia default: empty).
588 pub description: Option<String>,
589}
590
591/// Options for `kopia restore` / `kopia snapshot restore`. The tri-state
592/// booleans map to kopia's `--[no-]flag` form: `Some(true)` → `--flag`,
593/// `Some(false)` → `--no-flag`, `None` → omit (kopia default). M2 flag sweep
594/// (issue #216 gap analysis) added everything below `overwrite_files`; all of
595/// them, plus `delete_extra`, were previously either absent or dormant (the
596/// mover's `RestoreOp::restore_options()` dropped them via `..Default::default()`).
597#[derive(Debug, Clone, Default, PartialEq, Eq)]
598pub struct RestoreOptions {
599 /// `--[no-]ignore-permission-errors` (kopia default: true).
600 pub ignore_permission_errors: Option<bool>,
601 /// `--[no-]write-files-atomically`.
602 pub write_files_atomically: Option<bool>,
603 /// `--[no-]overwrite-files` (kopia default: true).
604 pub overwrite_files: Option<bool>,
605 /// `--[no-]overwrite-directories` (kopia default: true).
606 pub overwrite_directories: Option<bool>,
607 /// `--[no-]overwrite-symlinks` (kopia default: true).
608 pub overwrite_symlinks: Option<bool>,
609 /// `--[no-]write-sparse-files` (kopia default: false).
610 pub write_sparse_files: Option<bool>,
611 /// `--[no-]skip-owners` (kopia default: false).
612 pub skip_owners: Option<bool>,
613 /// `--[no-]skip-permissions` (kopia default: false).
614 pub skip_permissions: Option<bool>,
615 /// `--[no-]skip-times` (kopia default: false).
616 pub skip_times: Option<bool>,
617 /// `--[no-]ignore-errors` (kopia default: false).
618 pub ignore_errors: Option<bool>,
619 /// `--[no-]skip-existing`: skip files/symlinks that already exist in the
620 /// target (kopia default: false). A genuine kingpin tri-state, not a
621 /// presence-only flag — widened from a bare `bool`.
622 pub skip_existing: Option<bool>,
623 /// `--[no-]delete-extra`: delete files/directories/symlinks present in the
624 /// restore path but absent from the snapshot (kopia default: false). Backs
625 /// `Restore.spec.options.enableFileDeletion`, which was previously a silent
626 /// no-op — this struct had no field for it at all.
627 pub delete_extra: Option<bool>,
628 /// `--parallel`: restore parallelism (1 disables).
629 pub parallel: Option<u32>,
630}
631
632/// Options for `kopia repository sync-to` (ADR-0005 §13(d) / issue #216). Every
633/// field's `None`/`false` reproduces kopia's own default — an all-`None`,
634/// `delete_extra: false` instance yields the exact same argv `sync_to_args`
635/// produced before this struct existed. The tri-state booleans map to kopia's
636/// `--[no-]flag` grammar, same as [`RestoreOptions`]: `Some(true)` → `--flag`,
637/// `Some(false)` → `--no-flag`, `None` → omit (kopia default).
638#[derive(Debug, Clone, Default, PartialEq, Eq)]
639pub struct SyncToOptions {
640 /// `--parallel`: copy parallelism to the destination (kopia default `1` —
641 /// sequential; the root cause of #216's multi-week initial-seed times).
642 pub parallel: Option<u32>,
643 /// `--delete`: prune destination-only blobs for a true mirror (kopia
644 /// default `false` — additive sync, never removes destination content).
645 pub delete_extra: bool,
646 /// `--[no-]must-exist`: fail instead of initializing the destination's
647 /// repository-format blob (kopia default `false`).
648 pub must_exist: Option<bool>,
649 /// `--[no-]times`: synchronize blob modification times to the destination,
650 /// when supported (kopia default `true`).
651 pub times: Option<bool>,
652 /// `--[no-]update`: update blobs already present at the destination when
653 /// the source copy is newer (kopia default `true`).
654 pub update: Option<bool>,
655 /// `--max-download-speed`: cap read throughput from the source, bytes/sec
656 /// (kopia default: unlimited).
657 pub max_download_speed_bytes_per_second: Option<i64>,
658 /// `--max-upload-speed`: cap write throughput to the destination, bytes/sec
659 /// (kopia default: unlimited).
660 pub max_upload_speed_bytes_per_second: Option<i64>,
661}
662
663/// Policy fields kopia applies via `kopia policy set`. Mirrors the operator's
664/// `SnapshotPolicy.spec.policy` without depending on the api crate, so the kopia
665/// crate stays controller-agnostic. The caller translates the CRD policy into
666/// this and the controller applies it before the first snapshot.
667#[derive(Debug, Clone, Default, PartialEq, Eq)]
668pub struct PolicyArgs {
669 /// `--compression` algorithm (e.g. `zstd`, `none`).
670 pub compression: Option<String>,
671 /// `--splitter` algorithm.
672 pub splitter: Option<String>,
673 /// `--add-ignore` glob patterns.
674 pub ignore: Vec<String>,
675 /// `--add-never-compress` glob patterns.
676 pub never_compress: Vec<String>,
677 /// `--ignore-cache-dirs` tri-state (honor `CACHEDIR.TAG`). `None` leaves kopia's default.
678 pub ignore_cache_dirs: Option<bool>,
679 /// `--ignore-identical-snapshots` tri-state: skip writing a manifest when
680 /// the source is byte-identical to the previous snapshot.
681 ///
682 /// This is a kopia **retention** knob, not a files one, despite living
683 /// beside `ignore-cache-dirs` on the `policy set` command line.
684 ///
685 /// Kopiur pins it explicitly rather than inheriting: kopia's own default is
686 /// `false`, but a repository's global policy (or a third-party `extraArgs`)
687 /// can turn it on out of band, and a deduped run writes no manifest — which
688 /// breaks the one-`Snapshot`-CR-owns-one-manifest invariant the finalizer,
689 /// retention and restore all rest on. The mover therefore pins `false` at
690 /// the identity scope on every run, and only an explicit
691 /// `files.ignoreIdenticalSnapshots: true` raises it at the (more specific)
692 /// path scope. See #351.
693 pub ignore_identical_snapshots: Option<bool>,
694 /// Backup-side error handling (`--ignore-file-errors`) tri-state. ADR-0005 §13(b).
695 pub ignore_file_errors: Option<bool>,
696 /// `--ignore-dir-errors` tri-state. ADR-0005 §13(b).
697 pub ignore_dir_errors: Option<bool>,
698 /// `--ignore-unknown-types` tri-state. ADR-0005 §13(b).
699 pub ignore_unknown_types: Option<bool>,
700 /// `--max-parallel-snapshots` upload parallelism. ADR-0005 §13(f).
701 pub max_parallel_snapshots: Option<u32>,
702 /// `--max-parallel-file-reads` upload parallelism. ADR-0005 §13(f).
703 pub max_parallel_file_reads: Option<u32>,
704 /// `--keep-latest`: most-recent-N backups to keep per source.
705 ///
706 /// This field (and its five siblings below) exists ONLY so the mover can
707 /// pin kopia's own create-time retention to effectively-infinite at the
708 /// identity scope — see `kopiur_mover::workspec::KOPIA_KEEP_MAX`'s doc
709 /// comment for the full hazard. There is deliberately no CRD/workspec
710 /// surface that lets a user set these: kopia-side retention stays
711 /// forbidden (`crates/api/src/error.rs`'s `InlineRetentionForbidden`),
712 /// and `PolicyArgsSpec::to_kopia` never populates them.
713 pub keep_latest: Option<i64>,
714 /// `--keep-hourly`. See [`Self::keep_latest`].
715 pub keep_hourly: Option<i64>,
716 /// `--keep-daily`. See [`Self::keep_latest`].
717 pub keep_daily: Option<i64>,
718 /// `--keep-weekly`. See [`Self::keep_latest`].
719 pub keep_weekly: Option<i64>,
720 /// `--keep-monthly`. See [`Self::keep_latest`].
721 pub keep_monthly: Option<i64>,
722 /// `--keep-annual`. See [`Self::keep_latest`].
723 pub keep_annual: Option<i64>,
724 /// Verbatim extra `policy set` flags (the CRD escape hatch).
725 pub extra_args: Vec<String>,
726}
727
728/// Create-time-fixed repository options applied at `kopia repository create`
729/// (ADR-0005 §13(a)): the encryption/splitter/hash algorithms baked into the repo
730/// format, plus optional Reed-Solomon ECC parity guarding blobs against backend
731/// bit-rot. All fields are immutable post-create (webhook-enforced, §7); kopia only
732/// honors them at create time. Pure args builder so it's unit-testable.
733#[derive(Debug, Clone, Default, PartialEq, Eq)]
734pub struct CreateOptions {
735 /// `--encryption` algorithm (e.g. `AES256-GCM-HMAC-SHA256`).
736 pub encryption: Option<String>,
737 /// `--object-splitter` algorithm.
738 pub splitter: Option<String>,
739 /// `--block-hash` content-hash algorithm.
740 pub hash: Option<String>,
741 /// `--ecc` Reed-Solomon algorithm (e.g. `REED-SOLOMON-CRC32`). ADR-0005 §13(a).
742 pub ecc: Option<String>,
743 /// `--ecc-overhead-percent` parity overhead. ADR-0005 §13(a).
744 pub ecc_overhead_percent: Option<i64>,
745}
746
747impl CreateOptions {
748 /// The create-time `--encryption`/`--object-splitter`/`--block-hash`/`--ecc`/
749 /// `--ecc-overhead-percent` args, in a stable order. Empty when nothing is set.
750 pub fn args(&self) -> Vec<String> {
751 let mut a = Vec::new();
752 if let Some(v) = &self.encryption {
753 a.push("--encryption".into());
754 a.push(v.clone());
755 }
756 if let Some(v) = &self.splitter {
757 a.push("--object-splitter".into());
758 a.push(v.clone());
759 }
760 if let Some(v) = &self.hash {
761 a.push("--block-hash".into());
762 a.push(v.clone());
763 }
764 if let Some(v) = &self.ecc {
765 a.push("--ecc".into());
766 a.push(v.clone());
767 }
768 if let Some(p) = self.ecc_overhead_percent {
769 a.push("--ecc-overhead-percent".into());
770 a.push(p.to_string());
771 }
772 a
773 }
774}
775
776/// Repository throttling limits applied via `kopia repository throttle set`
777/// (ADR-0005 §13(e)). Each `None` leaves kopia's current value untouched. Pure args
778/// builder so it's unit-testable; an all-`None` instance yields no flags.
779#[derive(Debug, Clone, Default, PartialEq, Eq)]
780pub struct ThrottleArgs {
781 /// `--upload-bytes-per-second`.
782 pub upload_bytes_per_second: Option<i64>,
783 /// `--download-bytes-per-second`.
784 pub download_bytes_per_second: Option<i64>,
785 /// `--read-requests-per-second`.
786 pub read_ops_per_second: Option<i64>,
787 /// `--write-requests-per-second`.
788 pub write_ops_per_second: Option<i64>,
789}
790
791impl ThrottleArgs {
792 /// The `--*-per-second` flags for the set limits, in a stable order. Empty when
793 /// nothing is set (the caller then skips the `throttle set` invocation).
794 pub fn args(&self) -> Vec<String> {
795 let mut a = Vec::new();
796 if let Some(v) = self.upload_bytes_per_second {
797 a.push("--upload-bytes-per-second".into());
798 a.push(v.to_string());
799 }
800 if let Some(v) = self.download_bytes_per_second {
801 a.push("--download-bytes-per-second".into());
802 a.push(v.to_string());
803 }
804 if let Some(v) = self.read_ops_per_second {
805 a.push("--read-requests-per-second".into());
806 a.push(v.to_string());
807 }
808 if let Some(v) = self.write_ops_per_second {
809 a.push("--write-requests-per-second".into());
810 a.push(v.to_string());
811 }
812 a
813 }
814
815 /// Whether no limits are set (so `throttle set` is skipped).
816 pub fn is_empty(&self) -> bool {
817 self.args().is_empty()
818 }
819}
820
821/// Flags for `kopia repository set-parameters`. Modeled on [`ThrottleArgs`]: an all-`None`
822/// builder whose caller skips the invocation entirely when nothing is set.
823///
824/// Durations are pre-rendered **strings** with a unit, not numbers — kopia's
825/// `time.ParseDuration` rejects a bare number (`--epoch-min-duration=3600` →
826/// `time: missing unit in duration "3600"`), so the caller must render them
827/// (`kopiur_api::render_go_duration`) rather than pass user text through.
828#[derive(Debug, Clone, Default, PartialEq, Eq)]
829pub struct SetParametersArgs {
830 /// `--epoch-min-duration` (e.g. `"6h"`).
831 pub epoch_min_duration: Option<String>,
832 /// `--epoch-refresh-frequency` (e.g. `"20m"`).
833 pub epoch_refresh_frequency: Option<String>,
834 /// `--epoch-advance-on-count`.
835 pub epoch_advance_on_count: Option<i64>,
836 /// `--epoch-advance-on-size-mb`. **MiB**, despite the flag name — kopia multiplies by
837 /// 1048576.
838 pub epoch_advance_on_size_mb: Option<i64>,
839 /// `--epoch-checkpoint-frequency`.
840 pub epoch_checkpoint_frequency: Option<i64>,
841 /// `--epoch-delete-parallelism`.
842 pub epoch_delete_parallelism: Option<i64>,
843 /// `--retention-mode`. kopia declares this as an ENUM flag accepting exactly
844 /// `"none"`, `"GOVERNANCE"`, or `"COMPLIANCE"` — note the lowercase `none` against the
845 /// uppercase modes, which is kopia's own inconsistency, not a typo here. Anything else
846 /// is rejected by kopia's flag parser before the command runs.
847 pub retention_mode: Option<String>,
848 /// `--retention-period` (e.g. `"720h"`). Pre-rendered like the epoch durations. kopia's
849 /// own parser also accepts `d`, but kopiur never emits it — `render_go_duration` only
850 /// produces `h`/`m`/`s`. Omitted when `retention_mode` is `"none"`: kopia short-circuits
851 /// the disable path before validating, so a period there is meaningless.
852 pub retention_period: Option<String>,
853}
854
855impl SetParametersArgs {
856 /// The flags for the set parameters, in a stable order. Empty when nothing is set (the
857 /// caller then skips the `set-parameters` invocation).
858 pub fn args(&self) -> Vec<String> {
859 let mut a = Vec::new();
860 if let Some(v) = &self.epoch_min_duration {
861 a.push("--epoch-min-duration".into());
862 a.push(v.clone());
863 }
864 if let Some(v) = &self.epoch_refresh_frequency {
865 a.push("--epoch-refresh-frequency".into());
866 a.push(v.clone());
867 }
868 if let Some(v) = self.epoch_advance_on_count {
869 a.push("--epoch-advance-on-count".into());
870 a.push(v.to_string());
871 }
872 if let Some(v) = self.epoch_advance_on_size_mb {
873 a.push("--epoch-advance-on-size-mb".into());
874 a.push(v.to_string());
875 }
876 if let Some(v) = self.epoch_checkpoint_frequency {
877 a.push("--epoch-checkpoint-frequency".into());
878 a.push(v.to_string());
879 }
880 if let Some(v) = self.epoch_delete_parallelism {
881 a.push("--epoch-delete-parallelism".into());
882 a.push(v.to_string());
883 }
884 // Appended AFTER the epoch flags so the existing positional order assertions in
885 // `tests.rs` keep holding.
886 if let Some(v) = &self.retention_mode {
887 a.push("--retention-mode".into());
888 a.push(v.clone());
889 }
890 if let Some(v) = &self.retention_period {
891 a.push("--retention-period".into());
892 a.push(v.clone());
893 }
894 a
895 }
896
897 /// Whether no parameters are set (so `set-parameters` is skipped).
898 pub fn is_empty(&self) -> bool {
899 self.args().is_empty()
900 }
901}
902
903/// UI authentication mode for `kopia server start`. Controller-agnostic mirror of
904/// the api crate's `ServerAuth` (this crate has no kube dependency).
905#[derive(Debug, Clone, PartialEq, Eq)]
906pub enum ServerAuthMode {
907 /// Require a UI login. `username` goes on argv; the password is supplied
908 /// separately to [`KopiaClient::server_start`] (it is never baked into the pure
909 /// arg builder, nor into a ConfigMap).
910 Password {
911 /// HTTP basic-auth username for the UI (`--server-username`).
912 username: String,
913 },
914 /// No UI authentication (`--without-password`). kopia requires `--insecure`
915 /// alongside it, which [`server_start_args`] always emits.
916 None,
917}
918
919impl ServerAuthMode {
920 /// Stable discriminant for logging.
921 pub fn kind_str(&self) -> &'static str {
922 match self {
923 ServerAuthMode::Password { .. } => "password",
924 ServerAuthMode::None => "none",
925 }
926 }
927}
928
929/// A typed description of how to run `kopia server start` (the web UI).
930///
931/// ## Why this is its own non-returning path (not `run_ok`)
932///
933/// `server start` is a long-running process that never exits on success, so the
934/// `run_ok`/`run_json` "spawn, read to EOF, wait for exit code" pattern would hang
935/// forever. [`KopiaClient::server_start`] instead `exec`s the binary so kopia takes
936/// over this PID and receives `SIGTERM` directly from the kubelet on pod shutdown.
937///
938/// ## TLS and auth
939///
940/// The server always runs with `--insecure` (no in-pod TLS): TLS is terminated by
941/// the user's ingress and the Service speaks plain HTTP. `--insecure` is kopia's
942/// *no-TLS* switch — it is required in every mode, and is **not** the no-auth knob
943/// (that is `--without-password`, selected by [`ServerAuthMode::None`]).
944#[derive(Debug, Clone, PartialEq, Eq)]
945pub struct ServerStartSpec {
946 /// Listen address — must be non-loopback (e.g. `0.0.0.0:51515`) to be reachable
947 /// through a Service.
948 pub address: String,
949 /// UI authentication mode.
950 pub auth: ServerAuthMode,
951 /// Serve the embedded HTML UI (`--ui`). Defaults to enabled.
952 pub ui: bool,
953}
954
955impl Default for ServerStartSpec {
956 fn default() -> Self {
957 Self {
958 address: "0.0.0.0:51515".to_string(),
959 auth: ServerAuthMode::None,
960 ui: true,
961 }
962 }
963}
964
965/// Builder for [`KopiaClient`].
966#[derive(Debug, Clone, Default)]
967pub struct KopiaClientBuilder {
968 binary: Option<PathBuf>,
969 common_env: BTreeMap<String, String>,
970 common_env_remove: BTreeSet<String>,
971 common_args: Vec<String>,
972 default_timeout: Option<Duration>,
973}
974
975impl KopiaClientBuilder {
976 /// Set the path to the kopia binary. Injectable so tests can point at a
977 /// fake shim. Defaults to `kopia` (resolved via `PATH`).
978 pub fn binary(mut self, binary: impl Into<PathBuf>) -> Self {
979 self.binary = Some(binary.into());
980 self
981 }
982
983 /// Add an environment variable applied to every invocation. Use this for
984 /// `KOPIA_PASSWORD`, `KOPIA_CONFIG_PATH`, cache dirs, and S3 credentials.
985 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
986 self.common_env.insert(key.into(), value.into());
987 self
988 }
989
990 /// Record an environment variable to UNSET on every spawned command
991 /// (`Command::env_remove`), mirroring the `None` half of the
992 /// per-invocation overlay semantics (see `run_with_env`: `Some(v)` sets,
993 /// `None` unsets). Use this to keep an ambient/inherited credential from
994 /// leaking into a client that must not see it (e.g. the source
995 /// `KOPIA_PASSWORD` on a destination-configured replication client).
996 /// Applied AFTER [`Self::env`], so a key both set and removed ends up
997 /// unset; a later per-invocation overlay `Some(v)` still wins.
998 pub fn env_remove(mut self, key: impl Into<String>) -> Self {
999 self.common_env_remove.insert(key.into());
1000 self
1001 }
1002
1003 /// Add a global arg applied (after the subcommand tokens) to every
1004 /// invocation. Must be a flag kopia accepts on *every* subcommand (e.g. a
1005 /// global flag); per-subcommand flags belong on the specific method.
1006 /// Prefer env vars (e.g. `KOPIA_CHECK_FOR_UPDATES=false`) for cross-cutting
1007 /// behavior.
1008 pub fn common_arg(mut self, arg: impl Into<String>) -> Self {
1009 self.common_args.push(arg.into());
1010 self
1011 }
1012
1013 /// Default per-invocation timeout. `None` means no timeout.
1014 pub fn default_timeout(mut self, timeout: Duration) -> Self {
1015 self.default_timeout = Some(timeout);
1016 self
1017 }
1018
1019 /// Finalize.
1020 pub fn build(self) -> KopiaClient {
1021 let mut common_args = self.common_args;
1022 // kopia's hidden default-on `--auto-maintenance` opportunistically runs
1023 // a maintenance pass as a side effect of other commands (`snapshot
1024 // create`/`delete`/`expire`, and — verified against the pinned kopia
1025 // 0.23.1 binary — even a bare `policy set`) whenever the connected
1026 // client identity equals the repository's designated maintenance
1027 // owner. Only the Maintenance CR's own `maintenance run` may trigger
1028 // maintenance (that explicit subcommand is unaffected by this flag —
1029 // also verified against the pinned binary), so every `KopiaClient`
1030 // carries `--no-auto-maintenance` on every invocation, unconditionally,
1031 // rather than relying on each call site to remember it.
1032 common_args.push("--no-auto-maintenance".into());
1033 KopiaClient {
1034 binary: self.binary.unwrap_or_else(|| PathBuf::from("kopia")),
1035 common_env: self.common_env,
1036 common_env_remove: self.common_env_remove,
1037 common_args,
1038 default_timeout: self.default_timeout,
1039 }
1040 }
1041}
1042
1043/// A kopia client backed by the real `kopia` binary via `tokio::process`.
1044///
1045/// Construction is pure — building a client never spawns a process. Only the
1046/// `async` methods invoke `kopia`. The builder defaults the binary to `kopia`
1047/// (resolved via `PATH`); inject a path for tests or non-standard images:
1048///
1049/// ```
1050/// use std::path::PathBuf;
1051/// use kopiur_kopia::KopiaClient;
1052///
1053/// let client = KopiaClient::builder().build();
1054/// assert_eq!(client.binary(), &PathBuf::from("kopia"));
1055///
1056/// let custom = KopiaClient::builder()
1057/// .binary("/usr/local/bin/kopia")
1058/// .env("KOPIA_PASSWORD", "s3cr3t")
1059/// .build();
1060/// assert_eq!(custom.binary(), &PathBuf::from("/usr/local/bin/kopia"));
1061/// ```
1062#[derive(Debug, Clone)]
1063pub struct KopiaClient {
1064 binary: PathBuf,
1065 common_env: BTreeMap<String, String>,
1066 common_env_remove: BTreeSet<String>,
1067 common_args: Vec<String>,
1068 default_timeout: Option<Duration>,
1069}
1070
1071/// SIGKILL a timed-out kopia child AND reap it before returning
1072/// (`Child::kill()` = `start_kill` + `wait`). Without the wait the killed
1073/// child lingers as a zombie until tokio's SIGCHLD-driven orphan reaper gets
1074/// to it — best-effort and non-deterministic; with the controller's 120s
1075/// `default_timeout`, a hung backend would leave one transient zombie per
1076/// retry. Reaping inline makes cleanup a guarantee instead of a race.
1077/// Best-effort on error: nothing here can improve on the Timeout being
1078/// returned, so a kill/wait failure is only logged.
1079async fn kill_and_reap(child: &mut tokio::process::Child) {
1080 if let Err(e) = child.kill().await {
1081 tracing::debug!(error = %e, "could not kill/reap a timed-out kopia child");
1082 }
1083}
1084
1085/// The raw outcome of running a kopia subprocess.
1086struct RawOutput {
1087 code: Option<i32>,
1088 stdout: String,
1089 stderr: String,
1090}
1091
1092impl KopiaClient {
1093 /// Start building a client.
1094 pub fn builder() -> KopiaClientBuilder {
1095 KopiaClientBuilder::default()
1096 }
1097
1098 /// The configured binary path (useful for diagnostics / tests).
1099 pub fn binary(&self) -> &PathBuf {
1100 &self.binary
1101 }
1102
1103 /// The environment applied to every invocation (useful for tests asserting
1104 /// that the cache/log/config dirs were injected).
1105 pub fn common_env(&self) -> &BTreeMap<String, String> {
1106 &self.common_env
1107 }
1108
1109 /// The environment-variable names UNSET on every invocation (useful for
1110 /// tests asserting a credential is structurally kept away from a client).
1111 pub fn common_env_remove(&self) -> &BTreeSet<String> {
1112 &self.common_env_remove
1113 }
1114
1115 /// The global args appended after the subcommand on every invocation
1116 /// (useful for tests asserting e.g. `--no-auto-maintenance` is always
1117 /// present).
1118 pub fn common_args(&self) -> &[String] {
1119 &self.common_args
1120 }
1121
1122 /// The timeout applied to every invocation when set (useful for tests
1123 /// asserting a caller time-bounds its subprocesses).
1124 pub fn default_timeout(&self) -> Option<Duration> {
1125 self.default_timeout
1126 }
1127
1128 /// Run kopia with the given subcommand args, returning raw output. Applies
1129 /// `common_env` and inserts `common_args` immediately after the subcommand,
1130 /// plus a per-invocation environment overlay (`Some(value)` sets a variable,
1131 /// `None` **unsets** an otherwise-inherited one — pass an empty map for the
1132 /// common case). stdout and stderr are fully captured. Honors the default
1133 /// timeout if set. Used by the replication mover
1134 /// to point `kopia repository sync-to` at the *destination* backend's
1135 /// credentials (remapped from their `KOPIUR_DEST_`-prefixed copies) while
1136 /// clearing any source credential the destination does not set, so a stale
1137 /// source `AWS_SESSION_TOKEN` (etc.) cannot leak into the destination auth.
1138 async fn run_with_env(
1139 &self,
1140 args: &[String],
1141 env_overlay: &BTreeMap<String, Option<String>>,
1142 ) -> Result<RawOutput, KopiaError> {
1143 let display_args = args.join(" ");
1144 let mut cmd = Command::new(&self.binary);
1145 // Do not inherit the ambient environment's KOPIA_* unless the caller
1146 // set it explicitly; but we *do* inherit PATH etc. by default, which is
1147 // fine. We only override what common_env specifies.
1148 for (k, v) in &self.common_env {
1149 cmd.env(k, v);
1150 }
1151 // Builder-recorded unsets (env_remove) are applied after common_env,
1152 // so a key both set and removed ends up unset.
1153 for k in &self.common_env_remove {
1154 cmd.env_remove(k);
1155 }
1156 // Per-invocation overlay wins over both the inherited env and common_env.
1157 for (k, v) in env_overlay {
1158 match v {
1159 Some(value) => cmd.env(k, value),
1160 None => cmd.env_remove(k),
1161 };
1162 }
1163 cmd.args(args);
1164 // Append common args (e.g. --no-check-for-updates) after the subcommand
1165 // tokens the caller passed.
1166 cmd.args(&self.common_args);
1167 cmd.stdin(Stdio::null());
1168 cmd.stdout(Stdio::piped());
1169 cmd.stderr(Stdio::piped());
1170
1171 // Spawn with a bounded retry on transient errnos. ETXTBSY (26) and
1172 // EAGAIN (11) are not "the binary is wrong" failures — they're transient
1173 // races: ETXTBSY appears when another thread in a multithreaded process
1174 // forks-for-exec while the target file still has a writable fd open
1175 // elsewhere (the classic fork/exec race), and EAGAIN appears under fork
1176 // pressure on a busy node. A real bad-binary error (ENOENT, EACCES) is
1177 // returned immediately. Retries are quick and capped.
1178 let mut child = {
1179 let mut attempt = 0u32;
1180 loop {
1181 match cmd.spawn() {
1182 Ok(c) => break c,
1183 Err(e) if matches!(e.raw_os_error(), Some(26) | Some(11)) && attempt < 10 => {
1184 attempt += 1;
1185 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1186 }
1187 Err(source) => {
1188 return Err(KopiaError::Spawn {
1189 binary: self.binary.display().to_string(),
1190 source,
1191 });
1192 }
1193 }
1194 }
1195 };
1196
1197 // Take the pipes so we can read both concurrently without deadlocking
1198 // on a full pipe buffer.
1199 let mut stdout_pipe = child.stdout.take().expect("stdout piped");
1200 let stderr_pipe = child.stderr.take().expect("stderr piped");
1201
1202 let read_out = async {
1203 let mut buf = String::new();
1204 stdout_pipe.read_to_string(&mut buf).await.map(|_| buf)
1205 };
1206 let read_err = async {
1207 // Stream kopia's stderr line-by-line so its real progress and log
1208 // output is visible in `kubectl logs` (at debug, target `kopia`) for
1209 // both the controller's short ops and the long-running mover Job —
1210 // while still accumulating the full text byte-for-byte for the
1211 // failure tail carried by `KopiaError::NonZeroExit`.
1212 let mut reader = BufReader::new(stderr_pipe);
1213 let mut buf = String::new();
1214 let mut line = String::new();
1215 loop {
1216 line.clear();
1217 match reader.read_line(&mut line).await {
1218 Ok(0) => break,
1219 Ok(_) => {
1220 let trimmed = line.trim_end_matches(['\n', '\r']);
1221 if !trimmed.is_empty() {
1222 tracing::debug!(target: "kopia", "{trimmed}");
1223 }
1224 buf.push_str(&line);
1225 }
1226 Err(e) => return Err(e),
1227 }
1228 }
1229 Ok(buf)
1230 };
1231
1232 let wait_with_io = async {
1233 let (out, err, status) = tokio::join!(read_out, read_err, child.wait());
1234 Ok::<_, std::io::Error>((out?, err?, status?))
1235 };
1236
1237 let (stdout, stderr, status) = match self.default_timeout {
1238 Some(t) => match tokio::time::timeout(t, wait_with_io).await {
1239 Ok(res) => res.map_err(|source| KopiaError::Spawn {
1240 binary: self.binary.display().to_string(),
1241 source,
1242 })?,
1243 Err(_) => {
1244 kill_and_reap(&mut child).await;
1245 return Err(KopiaError::Timeout {
1246 args: display_args,
1247 seconds: t.as_secs(),
1248 });
1249 }
1250 },
1251 None => wait_with_io.await.map_err(|source| KopiaError::Spawn {
1252 binary: self.binary.display().to_string(),
1253 source,
1254 })?,
1255 };
1256
1257 Ok(RawOutput {
1258 code: status.code(),
1259 stdout,
1260 stderr,
1261 })
1262 }
1263
1264 /// Run kopia and require a zero exit code, returning stdout. On a non-zero
1265 /// exit, builds a structured [`KopiaError::NonZeroExit`] with the stderr
1266 /// tail and a best-effort error class.
1267 async fn run_ok(&self, args: &[String]) -> Result<String, KopiaError> {
1268 self.run_ok_with_env(args, &BTreeMap::new()).await
1269 }
1270
1271 /// [`Self::run_ok`] with a per-invocation environment overlay (see
1272 /// [`Self::run_with_env`]).
1273 async fn run_ok_with_env(
1274 &self,
1275 args: &[String],
1276 env_overlay: &BTreeMap<String, Option<String>>,
1277 ) -> Result<String, KopiaError> {
1278 self.run_ok_full(args, env_overlay).await.map(|o| o.stdout)
1279 }
1280
1281 /// [`Self::run_ok_with_env`] keeping **stderr on success**.
1282 ///
1283 /// The plain `run_ok` shape — `Ok(out.stdout)`, dropping `out.stderr` —
1284 /// made a whole class of failure undiagnosable: kopia can exit **0**,
1285 /// print nothing on stdout, and explain itself only on stderr. That is
1286 /// exactly what `snapshot create` does when its retention policy has
1287 /// `ignoreIdenticalSnapshots` on and nothing changed, and it surfaced as
1288 /// `no JSON output found on stdout (class Unknown)` with no reason
1289 /// attached anywhere (#351).
1290 ///
1291 /// Callers that genuinely only want stdout keep using `run_ok`; the two
1292 /// that need to reason about a silent success use this.
1293 async fn run_ok_full(
1294 &self,
1295 args: &[String],
1296 env_overlay: &BTreeMap<String, Option<String>>,
1297 ) -> Result<RawOutput, KopiaError> {
1298 let out = self.run_with_env(args, env_overlay).await?;
1299 if out.code == Some(0) {
1300 Ok(out)
1301 } else {
1302 Err(KopiaError::NonZeroExit {
1303 args: args.join(" "),
1304 code: out.code,
1305 class: KopiaErrorClass::classify(&out.stderr),
1306 stderr_tail: tail_lines(&out.stderr),
1307 })
1308 }
1309 }
1310
1311 /// Run kopia, require success, and parse the trailing JSON value on stdout
1312 /// into `T`. Kopia prints the result as the *last* JSON value on stdout
1313 /// (progress goes to stderr), so we parse from the first `{`/`[`.
1314 ///
1315 /// An empty stdout carries kopia's stderr tail into the error, so
1316 /// "kopia said nothing" is never again indistinguishable from "kopia said
1317 /// why, and we threw it away" (#351).
1318 async fn run_json<T: DeserializeOwned>(
1319 &self,
1320 args: &[String],
1321 context: &str,
1322 ) -> Result<T, KopiaError> {
1323 let out = self.run_ok_full(args, &BTreeMap::new()).await?;
1324 let json = extract_json(&out.stdout).ok_or_else(|| KopiaError::EmptyOutput {
1325 context: context.to_string(),
1326 stderr_tail: tail_lines(&out.stderr),
1327 })?;
1328 serde_json::from_str::<T>(json).map_err(|source| KopiaError::Json {
1329 context: context.to_string(),
1330 source,
1331 })
1332 }
1333
1334 /// Connect to an existing repository (`kopia repository connect <backend>`).
1335 /// `cache` sizes this connection's local kopia cache; pass
1336 /// [`CacheTuning::default`] to leave kopia's defaults.
1337 pub async fn repository_connect(
1338 &self,
1339 spec: &ConnectSpec,
1340 cache: CacheTuning,
1341 ) -> Result<(), KopiaError> {
1342 self.repository_connect_with(spec, cache, ConnectOptions::default())
1343 .await
1344 }
1345
1346 /// Connect to an existing repository **read-only** (`kopia repository
1347 /// connect <backend> --readonly`). The read-only bit persists in kopia's
1348 /// client config, so every subsequent invocation on this connection is
1349 /// structurally unable to mutate the repository — the connect mode for
1350 /// browse sessions. Every other mover flow stays on the read-write
1351 /// [`Self::repository_connect`].
1352 pub async fn repository_connect_readonly(
1353 &self,
1354 spec: &ConnectSpec,
1355 cache: CacheTuning,
1356 ) -> Result<(), KopiaError> {
1357 self.repository_connect_with(
1358 spec,
1359 cache,
1360 ConnectOptions {
1361 readonly: true,
1362 persist_credentials: false,
1363 },
1364 )
1365 .await
1366 }
1367
1368 /// Connect to an existing repository with explicit [`ConnectOptions`]
1369 /// (`kopia repository connect <backend> [--readonly]
1370 /// [--persist-credentials]`). [`Self::repository_connect`] and
1371 /// [`Self::repository_connect_readonly`] both delegate here with the
1372 /// options that reproduce their historical argv byte-for-byte. The
1373 /// replication mover's source connect uses `readonly` +
1374 /// `persist_credentials` together so `snapshot migrate --source-config`
1375 /// can later read the source password from the persisted credentials.
1376 pub async fn repository_connect_with(
1377 &self,
1378 spec: &ConnectSpec,
1379 cache: CacheTuning,
1380 opts: ConnectOptions,
1381 ) -> Result<(), KopiaError> {
1382 self.run_ok(&connect_args(spec, cache, opts))
1383 .await
1384 .map(|_| ())
1385 }
1386
1387 /// Create a new repository (`kopia repository create <backend>`). `cache` sizes
1388 /// the creating connection's local cache; pass [`CacheTuning::default`] to leave
1389 /// kopia's defaults. `create_opts` carries the create-time-fixed knobs
1390 /// (encryption/splitter/hash algorithms, ECC) baked into the repository format.
1391 pub async fn repository_create(
1392 &self,
1393 spec: &ConnectSpec,
1394 cache: CacheTuning,
1395 create_opts: &CreateOptions,
1396 ) -> Result<(), KopiaError> {
1397 let mut args = vec!["repository".into(), "create".into()];
1398 args.extend(spec.backend_args());
1399 args.extend(cache.args());
1400 args.extend(create_opts.args());
1401 self.run_ok(&args).await.map(|_| ())
1402 }
1403
1404 /// Set the repository's throttling limits (`kopia repository throttle set`).
1405 /// Caps upload/download bytes-per-sec and read/list/upload ops-per-sec so a run
1406 /// doesn't saturate a link or hammer an object store (ADR-0005 §13(e)). A no-op
1407 /// (skips the call) when nothing is set.
1408 pub async fn repository_throttle_set(&self, throttle: &ThrottleArgs) -> Result<(), KopiaError> {
1409 let flags = throttle.args();
1410 if flags.is_empty() {
1411 return Ok(());
1412 }
1413 let mut args = vec!["repository".into(), "throttle".into(), "set".into()];
1414 args.extend(flags);
1415 self.run_ok(&args).await.map(|_| ())
1416 }
1417
1418 /// Flip the CONNECTED repository's client-side read-only bit
1419 /// (`kopia repository set-client --read-only` / `--read-write`), issue #374.
1420 ///
1421 /// Three properties, all pinned against kopia 0.23.1 by
1422 /// `crates/kopia/tests/integration_set_client_throttle.rs`:
1423 ///
1424 /// - **Works on a read-only connection.** `set-client` registers kopia's
1425 /// `repositoryReaderAction`, so it opens fine against a config connected with
1426 /// `--readonly` — unlike [`Self::repository_set_parameters`], which needs a real
1427 /// blob write and hard-errors there.
1428 /// - **The bit lives in the local config, not the repository.** `read_only: true`
1429 /// writes `"readonly": true` into the `KOPIA_CONFIG_PATH` JSON; `false` removes the
1430 /// key. The test fingerprints every blob in the backend across the flip and requires
1431 /// it unchanged, so this never mutates shared state and needs no maintenance
1432 /// ownership/lease.
1433 /// - **It is the "flip window" primitive** for a read-only-connected config that must
1434 /// run a write-requiring verb: flip read-write, run the verb, flip back. Note that
1435 /// [`Self::repository_throttle_set`] does *not* need this window — M3a measured it
1436 /// succeeding directly on a `--readonly` connection, and leaving the read-only bit
1437 /// intact, because it only rewrites the local config's `throttlingLimits`. The flip
1438 /// is defensive there, not required.
1439 pub async fn repository_set_client_read_only(&self, read_only: bool) -> Result<(), KopiaError> {
1440 let mode = if read_only {
1441 "--read-only"
1442 } else {
1443 "--read-write"
1444 };
1445 self.run_ok(&[
1446 "repository".to_string(),
1447 "set-client".to_string(),
1448 mode.to_string(),
1449 ])
1450 .await
1451 .map(|_| ())
1452 }
1453
1454 /// Rewrite mutable repository parameters on the CONNECTED repository
1455 /// (`kopia repository set-parameters [flags]`), issue #258. No-op when nothing is set.
1456 ///
1457 /// Two properties the caller must respect:
1458 ///
1459 /// - **Never on a read-only connection.** kopia hard-errors (`unable to write blobcfg
1460 /// blob: PutBlob() failed for "kopia.blobcfg": storage is read-only`), so a
1461 /// `mode: ReadOnly` repository must not reach here.
1462 /// - **This invalidates every other client's cached format blob.** kopia says so itself
1463 /// ("you must disconnect and re-connect all other Kopia clients") and drops the local
1464 /// `kopia.repository`/`kopia.blobcfg` cache. Other clients re-read within
1465 /// `formatBlobCacheDuration` (15m) on their own, but this is why the caller applies
1466 /// only on observed drift rather than unconditionally.
1467 ///
1468 /// Needs no maintenance ownership/lease.
1469 pub async fn repository_set_parameters(
1470 &self,
1471 params: &SetParametersArgs,
1472 ) -> Result<(), KopiaError> {
1473 let flags = params.args();
1474 if flags.is_empty() {
1475 return Ok(());
1476 }
1477 let mut args = vec!["repository".into(), "set-parameters".into()];
1478 args.extend(flags);
1479 self.run_ok(&args).await.map(|_| ())
1480 }
1481
1482 /// Mirror the *connected* repository's blobs to a destination backend
1483 /// (`kopia repository sync-to <destination> [flags]`), ADR-0005 §13(d) / issue
1484 /// #216. The caller must already be connected to the **source** repository;
1485 /// this copies its blobs to `destination`. The destination's backend args are
1486 /// built by `ConnectSpec::backend_args` (the same builder connect/create use),
1487 /// so a new backend variant is wired through automatically. `opts` carries the
1488 /// tuning knobs (parallelism, `--delete`, the must-exist/times/update
1489 /// tri-states, throughput caps) — see [`SyncToOptions`]. Destination
1490 /// credentials are supplied via the environment, never on argv, exactly like
1491 /// connect/create. Success is exit code 0.
1492 pub async fn repository_sync_to(
1493 &self,
1494 destination: &ConnectSpec,
1495 opts: &SyncToOptions,
1496 ) -> Result<(), KopiaError> {
1497 self.repository_sync_to_with_env(destination, opts, &BTreeMap::new())
1498 .await
1499 }
1500
1501 /// [`Self::repository_sync_to`] with a per-invocation environment overlay
1502 /// applied to the `sync-to` subprocess only. The replication mover uses this to
1503 /// give the **destination** backend its own credentials: it maps each of the
1504 /// destination backend's env-delivered credential vars (`AWS_*`, `AZURE_*`, …)
1505 /// from the `KOPIUR_DEST_`-prefixed copy in its environment, and unsets any that
1506 /// the destination doesn't provide so a source credential cannot leak. The
1507 /// *source* repository is read from the persisted connection config (kopia bakes
1508 /// the source storage credentials in at `repository connect`), so overlaying the
1509 /// plain credential names here cannot disturb the source read. `Some(v)` sets a
1510 /// var, `None` removes it. Credentials travel via env, never argv.
1511 pub async fn repository_sync_to_with_env(
1512 &self,
1513 destination: &ConnectSpec,
1514 opts: &SyncToOptions,
1515 dest_env: &BTreeMap<String, Option<String>>,
1516 ) -> Result<(), KopiaError> {
1517 let args = sync_to_args(destination, opts);
1518 self.run_ok_with_env(&args, dest_env).await.map(|_| ())
1519 }
1520
1521 /// Create a snapshot of `source_path` with the given `tags`
1522 /// (`key:value`) and kopia's own defaults for `snapshot create`'s tuning
1523 /// knobs. Returns the parsed create result.
1524 ///
1525 /// `override_source`, when set, is passed to kopia as `--override-source`
1526 /// (format `username@hostname:path`). This is how Kopiur records snapshots
1527 /// under the operator-*resolved* identity (ADR §4.2 / anchoring principle 9)
1528 /// rather than the mover pod's ambient `user@host`. Without it kopia would
1529 /// attribute the snapshot to the pod, breaking the identity model that the
1530 /// whole catalog/retention/restore machinery keys on.
1531 pub async fn snapshot_create(
1532 &self,
1533 source_path: &str,
1534 tags: &BTreeMap<String, String>,
1535 override_source: Option<&str>,
1536 ) -> Result<SnapshotCreateResult, KopiaError> {
1537 self.snapshot_create_with(
1538 source_path,
1539 tags,
1540 override_source,
1541 &SnapshotCreateOptions::default(),
1542 )
1543 .await
1544 }
1545
1546 /// Create a snapshot honoring the operator's [`SnapshotCreateOptions`]
1547 /// (`failFast`, `uploadLimitMb`, `description` — M4 flag sweep, issue #216).
1548 /// Same identity/tags contract as [`Self::snapshot_create`], which now
1549 /// delegates here with an all-default `opts` (byte-for-byte the same argv
1550 /// as before this option struct existed).
1551 pub async fn snapshot_create_with(
1552 &self,
1553 source_path: &str,
1554 tags: &BTreeMap<String, String>,
1555 override_source: Option<&str>,
1556 opts: &SnapshotCreateOptions,
1557 ) -> Result<SnapshotCreateResult, KopiaError> {
1558 let args = snapshot_create_args(source_path, tags, override_source, opts);
1559 self.run_json(&args, "snapshot create result").await
1560 }
1561
1562 /// [`Self::snapshot_create_with`], but able to say "kopia deliberately
1563 /// wrote no manifest".
1564 ///
1565 /// `snapshot create` has exactly one legitimate silent success: with the
1566 /// retention knob `ignoreIdenticalSnapshots` on and the source
1567 /// byte-identical to the previous snapshot, kopia exits **0**, prints
1568 /// nothing on stdout, and says so only on stderr. Through `run_json` that
1569 /// was a hard `EmptyOutput` error and terminally failed the `Snapshot` CR
1570 /// (#351).
1571 ///
1572 /// Returning an enum rather than an `Option`/sentinel is deliberate: a
1573 /// deduped run owns **no kopia manifest**, and callers that quietly treated
1574 /// it as an ordinary success would go looking for one — which is how a CR
1575 /// ends up claiming its predecessor's manifest and deleting it on prune.
1576 /// The variant forces every caller to decide.
1577 ///
1578 /// Any *other* empty stdout still fails, loudly and now with kopia's own
1579 /// stderr attached.
1580 pub async fn snapshot_create_outcome_with(
1581 &self,
1582 source_path: &str,
1583 tags: &BTreeMap<String, String>,
1584 override_source: Option<&str>,
1585 opts: &SnapshotCreateOptions,
1586 ) -> Result<SnapshotCreateOutcome, KopiaError> {
1587 let args = snapshot_create_args(source_path, tags, override_source, opts);
1588 let out = self.run_ok_full(&args, &BTreeMap::new()).await?;
1589 match extract_json(&out.stdout) {
1590 Some(json) => serde_json::from_str::<SnapshotCreateResult>(json)
1591 .map(|r| SnapshotCreateOutcome::Created(Box::new(r)))
1592 .map_err(|source| KopiaError::Json {
1593 context: "snapshot create result".to_string(),
1594 source,
1595 }),
1596 None if crate::error::snapshot_skipped_unchanged(&out.stderr) => {
1597 Ok(SnapshotCreateOutcome::Unchanged)
1598 }
1599 None => Err(KopiaError::EmptyOutput {
1600 context: "snapshot create result".to_string(),
1601 stderr_tail: tail_lines(&out.stderr),
1602 }),
1603 }
1604 }
1605
1606 /// List snapshots, optionally filtered by source identity. With no filter
1607 /// this lists all snapshots in the repository.
1608 pub async fn snapshot_list(
1609 &self,
1610 filter: Option<&SnapshotSource>,
1611 ) -> Result<Vec<SnapshotListEntry>, KopiaError> {
1612 let mut args = vec!["snapshot".into(), "list".into(), "--json".into()];
1613 if let Some(src) = filter {
1614 // kopia accepts the identity string as a positional source filter.
1615 args.push(src.identity());
1616 }
1617 self.run_json(&args, "snapshot list").await
1618 }
1619
1620 /// List EVERY snapshot in the repository regardless of owning identity
1621 /// (`kopia snapshot list --json --all`).
1622 ///
1623 /// **What `--all` actually does** (verified against kopia 0.23.1, whose help
1624 /// text — "Show all snapshots (not just current username/host)" — reads more
1625 /// broadly than the behavior): the flag only takes effect when a `<source>`
1626 /// positional is supplied. A *source-less* `snapshot list --json` already
1627 /// returns every identity in the repository, foreign ones included; it is
1628 /// NOT scoped to the connected `user@host`. So [`Self::snapshot_list`] with
1629 /// `filter: None` is not, today, the identity-scoped call the flag name
1630 /// suggests.
1631 ///
1632 /// Use this one anyway wherever the count or the set is load-bearing —
1633 /// replication enumeration, post-migrate verification, the `spec.seed`
1634 /// empty-repository backstop. Those are decisions that strand or accept a
1635 /// repository, and none of them should rest on a kopia default that could
1636 /// change without the flag name changing with it. The behavior above is
1637 /// pinned by the `sync_to_seeds_*` integration test, which asserts a
1638 /// foreign-identity-only repository lists its snapshots BOTH ways.
1639 pub async fn snapshot_list_all(&self) -> Result<Vec<SnapshotListEntry>, KopiaError> {
1640 self.run_json(&snapshot_list_all_args(), "snapshot list --all")
1641 .await
1642 }
1643
1644 /// Copy snapshot manifests (and their content) from ANOTHER repository
1645 /// into the CONNECTED one (`kopia snapshot migrate --source-config
1646 /// <path>`). The caller must already be connected to the **destination**;
1647 /// the source repository is opened from the config file named in
1648 /// [`SnapshotMigrateOptions::source_config_path`], authenticating with
1649 /// that config's PERSISTED password (see
1650 /// [`ConnectOptions::persist_credentials`]) — this client's
1651 /// `KOPIA_PASSWORD` belongs to the destination. `snapshot migrate` has no
1652 /// `--json`; success is exit code 0, like
1653 /// [`Self::repository_sync_to`].
1654 ///
1655 /// **CAVEAT — kopia exits 0 even when a per-source migration failed**: the
1656 /// per-source migration goroutines only LOG their errors (verified against
1657 /// kopia 0.23.1's `cli/command_snapshot_migrate.go`), so a zero exit does
1658 /// NOT mean every selected snapshot arrived. Callers MUST post-verify by
1659 /// listing the destination ([`Self::snapshot_list_all`]) and checking that
1660 /// every expected `(identity, startTime)` pair is present.
1661 pub async fn snapshot_migrate(&self, opts: &SnapshotMigrateOptions) -> Result<(), KopiaError> {
1662 self.run_ok(&snapshot_migrate_args(opts)).await.map(|_| ())
1663 }
1664
1665 /// Delete a single snapshot by manifest id. kopia's `snapshot delete`
1666 /// requires `--delete` to actually remove (otherwise it dry-runs) and does
1667 /// not support `--json`; success is signaled by exit code 0.
1668 ///
1669 /// IDEMPOTENT: an already-absent snapshot (`no snapshots matched <id>` on
1670 /// stderr) is success — that IS the goal state. kopia dedups
1671 /// identical-content snapshot manifests, so several `Snapshot` CRs can
1672 /// legitimately pin the SAME kopia id; when GFS retention prunes more than
1673 /// one of them, the first finalizer's delete removes the manifest and the
1674 /// rest would otherwise fail terminally, wedging their CRs in `Deleting`
1675 /// forever (caught by the retention e2e under suite load).
1676 pub async fn snapshot_delete(&self, id: &str) -> Result<(), KopiaError> {
1677 let args = vec![
1678 "snapshot".into(),
1679 "delete".into(),
1680 id.to_string(),
1681 "--delete".into(),
1682 ];
1683 match self.run_ok(&args).await {
1684 Ok(_) => Ok(()),
1685 Err(KopiaError::NonZeroExit { stderr_tail, .. })
1686 if stderr_tail.contains("no snapshots matched") =>
1687 {
1688 tracing::debug!(%id, "snapshot already absent; delete is idempotent");
1689 Ok(())
1690 }
1691 Err(e) => Err(e),
1692 }
1693 }
1694
1695 /// Restore a snapshot's contents to a target directory with kopia's default
1696 /// options. kopia's `snapshot restore` does not emit JSON; success is exit
1697 /// code 0.
1698 pub async fn snapshot_restore(&self, id: &str, target_dir: &str) -> Result<(), KopiaError> {
1699 self.snapshot_restore_with(id, target_dir, &RestoreOptions::default())
1700 .await
1701 }
1702
1703 /// Restore a snapshot honoring the operator's [`RestoreOptions`]
1704 /// (`enableFileDeletion`, `ignorePermissionErrors`, `writeFilesAtomically`,
1705 /// …). Success is exit code 0.
1706 pub async fn snapshot_restore_with(
1707 &self,
1708 id: &str,
1709 target_dir: &str,
1710 opts: &RestoreOptions,
1711 ) -> Result<(), KopiaError> {
1712 let args = restore_args(id, target_dir, opts);
1713 self.run_ok(&args).await.map(|_| ())
1714 }
1715
1716 /// Verify repository/snapshot integrity (`kopia snapshot verify`). Success is
1717 /// exit code 0; a verification failure surfaces as a non-zero exit.
1718 pub async fn snapshot_verify(&self, opts: &VerifyOptions) -> Result<(), KopiaError> {
1719 let args = verify_args(opts);
1720 self.run_ok(&args).await.map(|_| ())
1721 }
1722
1723 /// Estimate the size/scope of snapshotting `source_path`
1724 /// (`kopia snapshot estimate`). Best-effort; success is exit code 0.
1725 pub async fn snapshot_estimate(&self, source_path: &str) -> Result<(), KopiaError> {
1726 let args = vec![
1727 "snapshot".into(),
1728 "estimate".into(),
1729 source_path.to_string(),
1730 ];
1731 self.run_ok(&args).await.map(|_| ())
1732 }
1733
1734 /// Add a pin to a snapshot so maintenance/expiration never deletes it
1735 /// (`kopia snapshot pin <id> --add <pin>`). Used to protect snapshots whose
1736 /// `Snapshot` carries `deletionPolicy: Retain`.
1737 pub async fn snapshot_pin(&self, id: &str, pin: &str) -> Result<(), KopiaError> {
1738 let args = vec![
1739 "snapshot".into(),
1740 "pin".into(),
1741 id.to_string(),
1742 "--add".into(),
1743 pin.to_string(),
1744 ];
1745 self.run_ok(&args).await.map(|_| ())
1746 }
1747
1748 /// Remove a pin from a snapshot (`kopia snapshot pin <id> --remove <pin>`).
1749 pub async fn snapshot_unpin(&self, id: &str, pin: &str) -> Result<(), KopiaError> {
1750 let args = vec![
1751 "snapshot".into(),
1752 "pin".into(),
1753 id.to_string(),
1754 "--remove".into(),
1755 pin.to_string(),
1756 ];
1757 self.run_ok(&args).await.map(|_| ())
1758 }
1759
1760 /// Expire snapshots per the repository's policy
1761 /// (`kopia snapshot expire --all`). When `delete` is false this is a dry-run
1762 /// (kopia requires `--delete` to actually remove). Success is exit code 0.
1763 pub async fn snapshot_expire(&self, delete: bool) -> Result<(), KopiaError> {
1764 let mut args = vec!["snapshot".into(), "expire".into(), "--all".into()];
1765 if delete {
1766 args.push("--delete".into());
1767 }
1768 self.run_ok(&args).await.map(|_| ())
1769 }
1770
1771 /// Validate that the connected storage provider behaves correctly
1772 /// (`kopia repository validate-provider`). A good Repository-readiness
1773 /// preflight for object-store backends. Success is exit code 0.
1774 pub async fn repository_validate_provider(&self) -> Result<(), KopiaError> {
1775 let args = vec!["repository".into(), "validate-provider".into()];
1776 self.run_ok(&args).await.map(|_| ())
1777 }
1778
1779 /// Apply a policy to `target` (an identity string, a path, or `--global`)
1780 /// via `kopia policy set`. The operator calls this before the first snapshot
1781 /// so `SnapshotPolicy.spec.policy` (compression/splitter/ignore) is honored.
1782 pub async fn policy_set(&self, target: &str, policy: &PolicyArgs) -> Result<(), KopiaError> {
1783 let args = policy_set_args(target, policy);
1784 self.run_ok(&args).await.map(|_| ())
1785 }
1786
1787 /// Show the effective policy for `target` (`kopia policy show <target>
1788 /// --json`), parsed as a generic JSON value.
1789 pub async fn policy_show(&self, target: &str) -> Result<serde_json::Value, KopiaError> {
1790 let args = vec![
1791 "policy".into(),
1792 "show".into(),
1793 target.to_string(),
1794 "--json".into(),
1795 ];
1796 self.run_json(&args, "policy show").await
1797 }
1798
1799 /// Get repository status (`kopia repository status --json`).
1800 ///
1801 /// This spawns `kopia`, so the example is `no_run` (it would need a real
1802 /// binary + connected repository):
1803 ///
1804 /// ```no_run
1805 /// # async fn run() -> Result<(), kopiur_kopia::KopiaError> {
1806 /// use kopiur_kopia::KopiaClient;
1807 ///
1808 /// let client = KopiaClient::builder()
1809 /// .env("KOPIA_PASSWORD", "s3cr3t")
1810 /// .build();
1811 /// let status = client.repository_status().await?;
1812 /// println!("repository unique id: {}", status.unique_id_hex);
1813 /// # Ok(())
1814 /// # }
1815 /// ```
1816 pub async fn repository_status(&self) -> Result<RepositoryStatus, KopiaError> {
1817 let args = vec!["repository".into(), "status".into(), "--json".into()];
1818 self.run_json(&args, "repository status").await
1819 }
1820
1821 /// Get maintenance info (`kopia maintenance info --json`).
1822 pub async fn maintenance_info(&self) -> Result<MaintenanceInfo, KopiaError> {
1823 let args = vec!["maintenance".into(), "info".into(), "--json".into()];
1824 self.run_json(&args, "maintenance info").await
1825 }
1826
1827 /// Count the repository's content-index blobs (`kopia index list --json`,
1828 /// array length). kopia's index is compacted by periodic maintenance; when
1829 /// maintenance stops (e.g. a stale lease owner), this grows unbounded and
1830 /// kopia eventually warns "Found too many index blobs (N), ensure periodic
1831 /// repository maintenance". The operator surfaces a Kubernetes Warning when
1832 /// this crosses a configurable threshold. Cheap — lists index-blob metadata
1833 /// only, no content read. Verified against kopia 0.23 (`index list
1834 /// --[no-]json`).
1835 pub async fn index_blob_count(&self) -> Result<i64, KopiaError> {
1836 let args = vec!["index".into(), "list".into(), "--json".into()];
1837 let entries: Vec<IndexBlobEntry> = self.run_json(&args, "index list").await?;
1838 Ok(entries.len() as i64)
1839 }
1840
1841 /// Claim the repository's maintenance ownership for the *currently connected*
1842 /// identity (`kopia maintenance set --owner me`). kopia ties "who may run
1843 /// maintenance" to the connected user@hostname and rejects a `maintenance run`
1844 /// from anyone but the designated owner ("maintenance must be run by designated
1845 /// user: …"). A repo bootstrapped by the controller in-process is owned by the
1846 /// controller's identity, so a mover Job (a different pod) MUST claim ownership
1847 /// before it can run maintenance. Idempotent; no JSON, success is exit 0.
1848 pub async fn maintenance_set_owner_me(&self) -> Result<(), KopiaError> {
1849 let args = vec![
1850 "maintenance".into(),
1851 "set".into(),
1852 "--owner".into(),
1853 "me".into(),
1854 ];
1855 self.run_ok(&args).await.map(|_| ())
1856 }
1857
1858 /// Set the repository's maintenance owner to an EXPLICIT `user@hostname`
1859 /// (`kopia maintenance set --owner <owner>`). Used by the bootstrap mover
1860 /// right after `repository create` to stamp the stable, lease-derived
1861 /// owner (`kopiur_api::maintenance::kopia_owner_for_lease`) instead of the
1862 /// creating pod's ephemeral identity — without this, every later
1863 /// maintenance mover sees a foreign owner and `takeoverPolicy: Never`
1864 /// yields forever. Verified against kopia 0.23 `maintenance set --help`
1865 /// (`--owner=OWNER Set maintenance owner user@hostname`).
1866 pub async fn maintenance_set_owner(&self, owner: &str) -> Result<(), KopiaError> {
1867 let args = vec![
1868 "maintenance".into(),
1869 "set".into(),
1870 "--owner".into(),
1871 owner.into(),
1872 ];
1873 self.run_ok(&args).await.map(|_| ())
1874 }
1875
1876 /// Switch the CONNECTED client identity (`kopia repository set-client
1877 /// --username … --hostname …`). The maintenance mover assumes the stable
1878 /// lease-derived identity this way (kopia 0.23 has no identity override on
1879 /// `repository connect`; the OS user@pod-hostname is ephemeral), so
1880 /// `maintenance set --owner me` records a stable string and the
1881 /// designated-user check passes on every later run. Verified against
1882 /// kopia 0.23 `repository set-client --help`.
1883 pub async fn repository_set_client_identity(
1884 &self,
1885 username: &str,
1886 hostname: &str,
1887 ) -> Result<(), KopiaError> {
1888 let args = vec![
1889 "repository".into(),
1890 "set-client".into(),
1891 format!("--username={username}"),
1892 format!("--hostname={hostname}"),
1893 ];
1894 self.run_ok(&args).await.map(|_| ())
1895 }
1896
1897 /// Run a maintenance pass. kopia's `maintenance run` does not emit JSON;
1898 /// success is exit code 0. The caller must already be the designated
1899 /// maintenance owner (see [`maintenance_set_owner_me`](Self::maintenance_set_owner_me)).
1900 pub async fn maintenance_run(&self, mode: MaintenanceMode) -> Result<(), KopiaError> {
1901 let mut args = vec!["maintenance".into(), "run".into()];
1902 match mode {
1903 MaintenanceMode::Quick => args.push("--no-full".into()),
1904 MaintenanceMode::Full => args.push("--full".into()),
1905 }
1906 self.run_ok(&args).await.map(|_| ())
1907 }
1908
1909 /// Run kopia with `args` and stream stdout **byte-for-byte** into `sink`
1910 /// (no line splitting, no UTF-8 assumption), returning the byte count on a
1911 /// zero exit. This is the file-content path for `kopia show <file-oid>`
1912 /// (the browse data-plane's `cat`/`download`), where stdout is the raw
1913 /// object bytes — buffering it whole or splitting it into lines would
1914 /// corrupt/clamp arbitrarily large binary files.
1915 ///
1916 /// stderr is accumulated like every other invocation; a non-zero exit
1917 /// yields [`KopiaError::NonZeroExit`] with the stderr tail. NOTE: bytes
1918 /// already streamed before a late failure have reached the sink — callers
1919 /// writing to a file should verify the count and discard partial output on
1920 /// error (kopia's `show` either streams the object or fails up front, so in
1921 /// practice a failure produces no payload). Honors `default_timeout`.
1922 pub async fn run_raw_streaming(
1923 &self,
1924 args: &[String],
1925 sink: &mut (dyn tokio::io::AsyncWrite + Unpin + Send),
1926 ) -> Result<u64, KopiaError> {
1927 let display_args = args.join(" ");
1928 let mut cmd = Command::new(&self.binary);
1929 for (k, v) in &self.common_env {
1930 cmd.env(k, v);
1931 }
1932 for k in &self.common_env_remove {
1933 cmd.env_remove(k);
1934 }
1935 cmd.args(args);
1936 cmd.args(&self.common_args);
1937 cmd.stdin(Stdio::null());
1938 cmd.stdout(Stdio::piped());
1939 cmd.stderr(Stdio::piped());
1940
1941 // Same bounded transient-errno retry as `run` (ETXTBSY/EAGAIN are
1942 // fork/exec races, not bad-binary failures).
1943 let mut child = {
1944 let mut attempt = 0u32;
1945 loop {
1946 match cmd.spawn() {
1947 Ok(c) => break c,
1948 Err(e) if matches!(e.raw_os_error(), Some(26) | Some(11)) && attempt < 10 => {
1949 attempt += 1;
1950 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1951 }
1952 Err(source) => {
1953 return Err(KopiaError::Spawn {
1954 binary: self.binary.display().to_string(),
1955 source,
1956 });
1957 }
1958 }
1959 }
1960 };
1961 let mut stdout_pipe = child.stdout.take().expect("stdout piped");
1962 let mut stderr_pipe = child.stderr.take().expect("stderr piped");
1963
1964 let copy_out = tokio::io::copy(&mut stdout_pipe, sink);
1965 let read_err = async {
1966 let mut buf = String::new();
1967 stderr_pipe.read_to_string(&mut buf).await.map(|_| buf)
1968 };
1969 let wait_with_io = async {
1970 let (copied, err, status) = tokio::join!(copy_out, read_err, child.wait());
1971 Ok::<_, std::io::Error>((copied?, err?, status?))
1972 };
1973
1974 let (bytes, stderr, status) = match self.default_timeout {
1975 Some(t) => match tokio::time::timeout(t, wait_with_io).await {
1976 Ok(res) => res.map_err(|source| KopiaError::Spawn {
1977 binary: self.binary.display().to_string(),
1978 source,
1979 })?,
1980 Err(_) => {
1981 kill_and_reap(&mut child).await;
1982 return Err(KopiaError::Timeout {
1983 args: display_args,
1984 seconds: t.as_secs(),
1985 });
1986 }
1987 },
1988 None => wait_with_io.await.map_err(|source| KopiaError::Spawn {
1989 binary: self.binary.display().to_string(),
1990 source,
1991 })?,
1992 };
1993
1994 if status.code() == Some(0) {
1995 Ok(bytes)
1996 } else {
1997 Err(KopiaError::NonZeroExit {
1998 args: display_args,
1999 code: status.code(),
2000 class: KopiaErrorClass::classify(&stderr),
2001 stderr_tail: tail_lines(&stderr),
2002 })
2003 }
2004 }
2005
2006 /// Run `kopia server start`, **replacing this process** with kopia via `exec`.
2007 ///
2008 /// On success this never returns (kopia takes over the PID and runs until it is
2009 /// signalled). It returns a [`KopiaError`] only if `exec` itself fails (e.g. the
2010 /// binary is missing). The repository must already be connected (call
2011 /// [`KopiaClient::repository_connect`] first) — the server reads the connected
2012 /// repo from the kopia config file.
2013 ///
2014 /// `password` is the UI password for [`ServerAuthMode::Password`]; it is appended
2015 /// to argv **here**, inside the server pod, so it never reaches the controller,
2016 /// a ConfigMap, or the pure [`server_start_args`] builder. For
2017 /// [`ServerAuthMode::None`] it is ignored.
2018 #[cfg(unix)]
2019 pub fn server_start(&self, spec: &ServerStartSpec, password: Option<&str>) -> KopiaError {
2020 use std::os::unix::process::CommandExt;
2021
2022 let mut args = server_start_args(spec);
2023 if let ServerAuthMode::Password { .. } = &spec.auth
2024 && let Some(pw) = password
2025 {
2026 args.push("--server-password".into());
2027 args.push(pw.to_string());
2028 }
2029
2030 let mut cmd = std::process::Command::new(&self.binary);
2031 for (k, v) in &self.common_env {
2032 cmd.env(k, v);
2033 }
2034 for k in &self.common_env_remove {
2035 cmd.env_remove(k);
2036 }
2037 cmd.args(&args);
2038 cmd.args(&self.common_args);
2039
2040 // exec(2) replaces the current image. It returns ONLY on failure.
2041 let source = cmd.exec();
2042 KopiaError::Spawn {
2043 binary: self.binary.display().to_string(),
2044 source,
2045 }
2046 }
2047}
2048
2049/// Push a kingpin `--[no-]flag` boolean tri-state (`Some(true)` → `--flag`,
2050/// `Some(false)` → `--no-flag`). This is `kopia snapshot restore`'s flag
2051/// grammar (`--[no-]overwrite-files`, …) — NOT `policy set`'s (see
2052/// [`push_valued_tristate`]); the two commands genuinely differ.
2053fn push_tristate(args: &mut Vec<String>, flag: &str, value: Option<bool>) {
2054 match value {
2055 Some(true) => args.push(format!("--{flag}")),
2056 Some(false) => args.push(format!("--no-{flag}")),
2057 None => {}
2058 }
2059}
2060
2061/// Push a kopia `policy set` boolean knob. These are VALUED flags
2062/// (`--flag=true|false`, "inherit" being the unset state) — NOT kingpin
2063/// `--flag/--no-flag` booleans: a bare `--ignore-file-errors` fails with
2064/// "expected argument for flag" (caught by the `policy_knobs` e2e; the old
2065/// `--no-` form never reached kopia). Verified against
2066/// `kopia policy set --help` (0.23).
2067fn push_valued_tristate(args: &mut Vec<String>, flag: &str, value: Option<bool>) {
2068 match value {
2069 Some(true) => args.push(format!("--{flag}=true")),
2070 Some(false) => args.push(format!("--{flag}=false")),
2071 None => {}
2072 }
2073}
2074
2075/// Split [`PolicyArgs`] into the path-scoped part and an optional
2076/// identity-scoped part. kopia rejects `--max-parallel-snapshots` on a
2077/// path-scoped policy ("max parallel snapshots cannot be specified for paths,
2078/// only global, username@hostname or @hostname" — the `policy_knobs` e2e
2079/// regression), so that one knob must be applied in a second `policy set`
2080/// against the bare `username@hostname` identity. Pure.
2081pub fn split_policy_scopes(mut policy: PolicyArgs) -> (PolicyArgs, Option<PolicyArgs>) {
2082 let identity = policy.max_parallel_snapshots.take().map(|n| PolicyArgs {
2083 max_parallel_snapshots: Some(n),
2084 ..Default::default()
2085 });
2086 (policy, identity)
2087}
2088
2089/// Build the args for `kopia snapshot restore <id> <target>` plus options. Pure
2090/// so it is unit-testable without spawning kopia. Every `--[no-]flag` form here
2091/// was smoke-tested against the pinned kopia 0.23.1 (`kopia snapshot restore
2092/// --help`); the real-kopia integration test in
2093/// `crates/kopia/tests/integration_roundtrip.rs` is the permanent guard that
2094/// kopia actually accepts them, not just that the argv shape looks right.
2095fn restore_args(id: &str, target_dir: &str, opts: &RestoreOptions) -> Vec<String> {
2096 let mut args = vec![
2097 "snapshot".into(),
2098 "restore".into(),
2099 id.to_string(),
2100 target_dir.to_string(),
2101 ];
2102 push_tristate(
2103 &mut args,
2104 "ignore-permission-errors",
2105 opts.ignore_permission_errors,
2106 );
2107 push_tristate(
2108 &mut args,
2109 "write-files-atomically",
2110 opts.write_files_atomically,
2111 );
2112 push_tristate(&mut args, "overwrite-files", opts.overwrite_files);
2113 push_tristate(
2114 &mut args,
2115 "overwrite-directories",
2116 opts.overwrite_directories,
2117 );
2118 push_tristate(&mut args, "overwrite-symlinks", opts.overwrite_symlinks);
2119 push_tristate(&mut args, "write-sparse-files", opts.write_sparse_files);
2120 push_tristate(&mut args, "skip-owners", opts.skip_owners);
2121 push_tristate(&mut args, "skip-permissions", opts.skip_permissions);
2122 push_tristate(&mut args, "skip-times", opts.skip_times);
2123 push_tristate(&mut args, "ignore-errors", opts.ignore_errors);
2124 push_tristate(&mut args, "skip-existing", opts.skip_existing);
2125 push_tristate(&mut args, "delete-extra", opts.delete_extra);
2126 if let Some(p) = opts.parallel {
2127 args.push("--parallel".into());
2128 args.push(p.to_string());
2129 }
2130 args
2131}
2132
2133/// Build the args for `kopia snapshot create <source> --json [flags]` plus
2134/// options. Pure so it is unit-testable without spawning kopia. `--fail-fast`
2135/// is a kingpin `--[no-]flag` tri-state (smoke-tested against the pinned
2136/// kopia 0.23.1: `snapshot create --fail-fast --upload-limit-mb 100
2137/// --description "smoke test"` is accepted; the real-kopia integration test
2138/// in `crates/kopia/tests/integration_roundtrip.rs` is the permanent guard).
2139/// All-default `opts` reproduces the pre-M4 argv byte-for-byte (tested).
2140fn snapshot_create_args(
2141 source_path: &str,
2142 tags: &BTreeMap<String, String>,
2143 override_source: Option<&str>,
2144 opts: &SnapshotCreateOptions,
2145) -> Vec<String> {
2146 let mut args = vec![
2147 "snapshot".into(),
2148 "create".into(),
2149 source_path.to_string(),
2150 "--json".into(),
2151 ];
2152 if let Some(src) = override_source {
2153 args.push("--override-source".into());
2154 args.push(src.to_string());
2155 }
2156 for (k, v) in tags {
2157 args.push("--tags".into());
2158 args.push(format!("{k}:{v}"));
2159 }
2160 push_tristate(&mut args, "fail-fast", opts.fail_fast);
2161 if let Some(mb) = opts.upload_limit_mb {
2162 args.push("--upload-limit-mb".into());
2163 args.push(mb.to_string());
2164 }
2165 if let Some(desc) = &opts.description {
2166 args.push("--description".into());
2167 args.push(desc.clone());
2168 }
2169 args
2170}
2171
2172/// Build the args for `kopia snapshot verify` plus options. Pure.
2173fn verify_args(opts: &VerifyOptions) -> Vec<String> {
2174 let mut args = vec!["snapshot".into(), "verify".into()];
2175 for src in &opts.sources {
2176 args.push("--sources".into());
2177 args.push(src.clone());
2178 }
2179 if let Some(pct) = opts.verify_files_percent {
2180 args.push("--verify-files-percent".into());
2181 args.push(pct.to_string());
2182 }
2183 if let Some(m) = opts.max_errors {
2184 args.push("--max-errors".into());
2185 args.push(m.to_string());
2186 }
2187 if let Some(p) = opts.parallel {
2188 args.push("--parallel".into());
2189 args.push(p.to_string());
2190 }
2191 if let Some(p) = opts.file_parallelism {
2192 args.push("--file-parallelism".into());
2193 args.push(p.to_string());
2194 }
2195 if let Some(q) = opts.file_queue_length {
2196 args.push("--file-queue-length".into());
2197 args.push(q.to_string());
2198 }
2199 args
2200}
2201
2202/// Build the args for `kopia repository connect <backend> [flags]`. Pure so the
2203/// option → argv mapping is unit-testable without spawning kopia.
2204/// `--readonly` (kopia's persistent read-only client-config bit) is appended
2205/// only for read-only connects (browse sessions; replication source connects),
2206/// then `--persist-credentials` when the password must be written beside the
2207/// config file. An all-default [`ConnectOptions`] appends nothing — byte-for-
2208/// byte the pre-`ConnectOptions` read-write argv.
2209fn connect_args(spec: &ConnectSpec, cache: CacheTuning, opts: ConnectOptions) -> Vec<String> {
2210 let mut args = vec!["repository".into(), "connect".into()];
2211 args.extend(spec.backend_args());
2212 args.extend(cache.args());
2213 if opts.readonly {
2214 args.push("--readonly".into());
2215 }
2216 if opts.persist_credentials {
2217 args.push("--persist-credentials".into());
2218 }
2219 args
2220}
2221
2222/// Build the args for `kopia snapshot list --json --all`. Pure so the
2223/// every-identity list argv is unit-testable without spawning kopia.
2224fn snapshot_list_all_args() -> Vec<String> {
2225 vec![
2226 "snapshot".into(),
2227 "list".into(),
2228 "--json".into(),
2229 "--all".into(),
2230 ]
2231}
2232
2233/// Build the args for `kopia snapshot migrate` plus options. Pure so it is
2234/// unit-testable without spawning kopia. Exact shape: `["snapshot", "migrate",
2235/// "--source-config", <path>]`, then `--all` OR one `--sources <spec>` per
2236/// list entry, then `--latest-only` when set, `--parallel <n>` when set, then
2237/// the policy mode. [`MigratePolicies::None`] MUST render an explicit
2238/// `--no-policies` — kopia's own default for `--policies` is TRUE, so omission
2239/// would silently import the source's kopia policies.
2240fn snapshot_migrate_args(opts: &SnapshotMigrateOptions) -> Vec<String> {
2241 let mut args = vec![
2242 "snapshot".into(),
2243 "migrate".into(),
2244 "--source-config".into(),
2245 opts.source_config_path.clone(),
2246 ];
2247 match &opts.sources {
2248 MigrateSources::All => args.push("--all".into()),
2249 MigrateSources::List(specs) => {
2250 for spec in specs {
2251 args.push("--sources".into());
2252 args.push(spec.clone());
2253 }
2254 }
2255 }
2256 if opts.latest_only {
2257 args.push("--latest-only".into());
2258 }
2259 if let Some(p) = opts.parallel {
2260 args.push("--parallel".into());
2261 args.push(p.to_string());
2262 }
2263 match opts.policies {
2264 MigratePolicies::None => args.push("--no-policies".into()),
2265 MigratePolicies::Copy => args.push("--policies".into()),
2266 MigratePolicies::CopyOverwrite => {
2267 args.push("--policies".into());
2268 args.push("--overwrite-policies".into());
2269 }
2270 }
2271 args
2272}
2273
2274/// Build the args for `kopia repository sync-to <destination> [flags]`. Pure so it
2275/// is unit-testable without spawning kopia (ADR-0005 §13(d) / issue #216). The
2276/// destination's backend selection reuses `ConnectSpec::backend_args`, so every
2277/// backend is wired through. `--must-exist`/`--times`/`--update` are kopia
2278/// (kingpin) BOOLEAN flags: `--must-exist=false` is a parse error (`unexpected
2279/// false`) — but the `--no-must-exist`/`--no-times`/`--no-update` negated forms
2280/// ARE accepted (smoke-tested against kopia 0.23.1), so [`push_tristate`] is used
2281/// for all three exactly like `snapshot restore`'s tri-states. `None` on any
2282/// field omits its flag entirely, leaving kopia's own default in effect.
2283fn sync_to_args(destination: &ConnectSpec, opts: &SyncToOptions) -> Vec<String> {
2284 let mut args = vec!["repository".into(), "sync-to".into()];
2285 args.extend(destination.backend_args());
2286 if let Some(p) = opts.parallel {
2287 args.push("--parallel".into());
2288 args.push(p.to_string());
2289 }
2290 if opts.delete_extra {
2291 args.push("--delete".into());
2292 }
2293 push_tristate(&mut args, "must-exist", opts.must_exist);
2294 push_tristate(&mut args, "times", opts.times);
2295 push_tristate(&mut args, "update", opts.update);
2296 if let Some(s) = opts.max_download_speed_bytes_per_second {
2297 args.push("--max-download-speed".into());
2298 args.push(s.to_string());
2299 }
2300 if let Some(s) = opts.max_upload_speed_bytes_per_second {
2301 args.push("--max-upload-speed".into());
2302 args.push(s.to_string());
2303 }
2304 args
2305}
2306
2307/// Build the args for `kopia policy set <target>` plus flags. Pure.
2308fn policy_set_args(target: &str, policy: &PolicyArgs) -> Vec<String> {
2309 let mut args = vec!["policy".into(), "set".into(), target.to_string()];
2310 if let Some(c) = &policy.compression {
2311 args.push("--compression".into());
2312 args.push(c.clone());
2313 }
2314 if let Some(s) = &policy.splitter {
2315 args.push("--splitter".into());
2316 args.push(s.clone());
2317 }
2318 for pat in &policy.ignore {
2319 args.push("--add-ignore".into());
2320 args.push(pat.clone());
2321 }
2322 for pat in &policy.never_compress {
2323 args.push("--add-never-compress".into());
2324 args.push(pat.clone());
2325 }
2326 push_valued_tristate(&mut args, "ignore-cache-dirs", policy.ignore_cache_dirs);
2327 push_valued_tristate(
2328 &mut args,
2329 "ignore-identical-snapshots",
2330 policy.ignore_identical_snapshots,
2331 );
2332 push_valued_tristate(&mut args, "ignore-file-errors", policy.ignore_file_errors);
2333 push_valued_tristate(&mut args, "ignore-dir-errors", policy.ignore_dir_errors);
2334 push_valued_tristate(
2335 &mut args,
2336 "ignore-unknown-types",
2337 policy.ignore_unknown_types,
2338 );
2339 if let Some(n) = policy.max_parallel_snapshots {
2340 args.push("--max-parallel-snapshots".into());
2341 args.push(n.to_string());
2342 }
2343 if let Some(n) = policy.max_parallel_file_reads {
2344 args.push("--max-parallel-file-reads".into());
2345 args.push(n.to_string());
2346 }
2347 if let Some(n) = policy.keep_latest {
2348 args.push("--keep-latest".into());
2349 args.push(n.to_string());
2350 }
2351 if let Some(n) = policy.keep_hourly {
2352 args.push("--keep-hourly".into());
2353 args.push(n.to_string());
2354 }
2355 if let Some(n) = policy.keep_daily {
2356 args.push("--keep-daily".into());
2357 args.push(n.to_string());
2358 }
2359 if let Some(n) = policy.keep_weekly {
2360 args.push("--keep-weekly".into());
2361 args.push(n.to_string());
2362 }
2363 if let Some(n) = policy.keep_monthly {
2364 args.push("--keep-monthly".into());
2365 args.push(n.to_string());
2366 }
2367 if let Some(n) = policy.keep_annual {
2368 args.push("--keep-annual".into());
2369 args.push(n.to_string());
2370 }
2371 args.extend(policy.extra_args.iter().cloned());
2372 args
2373}
2374
2375/// Build the args for `kopia server start` (everything except the secret password,
2376/// which [`KopiaClient::server_start`] appends at exec time). Pure and unit-testable.
2377///
2378/// Always emits `--insecure` (no in-pod TLS; the user's ingress terminates TLS).
2379/// [`ServerAuthMode::Password`] emits `--server-username`; [`ServerAuthMode::None`]
2380/// emits `--without-password`.
2381fn server_start_args(spec: &ServerStartSpec) -> Vec<String> {
2382 let mut args = vec![
2383 "server".into(),
2384 "start".into(),
2385 "--address".into(),
2386 spec.address.clone(),
2387 // No in-pod TLS — this is kopia's *no-TLS* switch, required in every mode.
2388 "--insecure".into(),
2389 ];
2390 if spec.ui {
2391 args.push("--ui".into());
2392 }
2393 match &spec.auth {
2394 ServerAuthMode::Password { username } => {
2395 args.push("--server-username".into());
2396 args.push(username.clone());
2397 }
2398 ServerAuthMode::None => {
2399 args.push("--without-password".into());
2400 // kopia 0.23+ refuses to bind a non-loopback address with
2401 // `--insecure --without-password` unless this escape hatch is set
2402 // (it is exactly the "exposed unauthenticated server" the project gates
2403 // behind `acknowledgeInsecure`). We always bind `0.0.0.0` so the Service
2404 // can reach the server, so the flag is required here.
2405 args.push("--allow-extremely-dangerous-unauthenticated-server-on-the-network".into());
2406 }
2407 }
2408 args
2409}
2410
2411/// Extract the JSON result from kopia stdout. kopia prints a single JSON object
2412/// or array; progress goes to stderr. We find the first `{` or `[` and return
2413/// the trimmed remainder, which is the JSON value. Returns `None` if stdout
2414/// contains no `{`/`[`.
2415fn extract_json(stdout: &str) -> Option<&str> {
2416 let trimmed = stdout.trim();
2417 let start = trimmed.find(['{', '['])?;
2418 Some(trimmed[start..].trim())
2419}
2420
2421#[cfg(test)]
2422mod tests;