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