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