kopiur_api/backend.rs
1//! Storage backends for a kopia repository.
2//!
3//! ADR-0003 §3.1: `Backend` is a `#[serde(tag = "kind")]` enum. This is the
4//! load-bearing example of the ADR's type-safety thesis — a deserialized
5//! `Backend` is *always exactly one* variant, so the "exactly one backend block"
6//! rule that predecessor drafts enforced with a JSON-schema `oneOf` + webhook
7//! check becomes a compile-time invariant. The webhook still validates *content*
8//! (bucket names, credential reachability) but cannot receive a multi-variant value.
9
10use crate::common::{SecretRef, TlsConfig};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14/// Credentials for an object-store backend. Always a Secret reference. ADR §3.1.
15#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
16#[serde(rename_all = "camelCase")]
17pub struct BackendAuth {
18 /// Secret holding the backend's access credentials. The operator reads
19 /// well-known keys (e.g. `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` for S3).
20 /// ADR §3.1.
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub secret_ref: Option<SecretRef>,
23 /// Advanced auth: workload identity (IRSA/WIF). Structurally present, deprioritized
24 /// for the homelab default (ADR §4.11).
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub workload_identity: Option<WorkloadIdentity>,
27}
28
29/// Cloud workload-identity binding (IRSA / GKE Workload Identity / Azure WIF):
30/// the mover authenticates as a Kubernetes `ServiceAccount` instead of a static
31/// Secret. Deprioritized for the homelab default. ADR §4.11.
32#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
33#[serde(rename_all = "camelCase")]
34pub struct WorkloadIdentity {
35 /// Name of the `ServiceAccount` the mover pod runs as, federated to the
36 /// cloud IAM role/identity that grants backend access.
37 pub service_account_name: String,
38}
39
40/// The discriminated backend union. Exactly one variant by construction.
41///
42/// ## Representation choice
43///
44/// ADR-0003 §3.1 *sketches* this as `#[serde(tag = "kind")]` (a `kind: S3` inline
45/// discriminant). In practice an internally-tagged enum cannot produce a valid
46/// Kubernetes *structural* schema: kube's schema rewriter hoists `oneOf` branch
47/// properties to the root and requires the shared `kind` property to be identical
48/// across branches, but each variant needs a distinct `kind` const — a hard
49/// conflict. We therefore use serde's **externally-tagged** representation
50/// (`backend: { s3: {...} }`), which:
51/// * is exactly the YAML shape ADR-0001 §3.1 actually used (`backend.s3.bucket`);
52/// * generates a valid structural schema (a `oneOf` of distinct optional
53/// properties — kubectl enforces "exactly one backend");
54/// * preserves the ADR's type-safety thesis verbatim — this is still a Rust
55/// `enum`, a value is still exactly one variant, and reconcilers still
56/// `match` it exhaustively.
57///
58/// The webhook (`api::validate`) validates *content* (bucket-name format,
59/// credential-secret reachability) that the schema can't express.
60#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
61#[serde(rename_all = "camelCase")]
62pub enum Backend {
63 /// Amazon S3 or any S3-compatible object store (MinIO, RustFS, Ceph RGW, …).
64 S3(S3Backend),
65 /// Azure Blob Storage.
66 Azure(AzureBackend),
67 /// Google Cloud Storage.
68 Gcs(GcsBackend),
69 /// Backblaze B2.
70 B2(B2Backend),
71 /// A local filesystem path, backed by a PVC the operator mounts into the mover.
72 Filesystem(FilesystemBackend),
73 /// SFTP server.
74 Sftp(SftpBackend),
75 /// WebDAV endpoint.
76 WebDav(WebDavBackend),
77 /// Any rclone remote (kopia shells out to `rclone`), broadening reach to
78 /// providers without a native kopia backend.
79 Rclone(RcloneBackend),
80}
81
82impl Backend {
83 /// Stable discriminant string for status/metrics/printcolumns.
84 ///
85 /// Returns the variant's PascalCase name, independent of the camelCase wire
86 /// key (`backend: { s3: ... }` deserializes to [`Backend::S3`], whose
87 /// `kind_str()` is `"S3"`).
88 ///
89 /// ```
90 /// use kopiur_api::backend::{Backend, FilesystemBackend};
91 ///
92 /// let b = Backend::Filesystem(FilesystemBackend {
93 /// path: "/repo".into(),
94 /// pvc_name: None,
95 /// });
96 /// assert_eq!(b.kind_str(), "Filesystem");
97 ///
98 /// // The wire key is camelCase, but the discriminant stays PascalCase.
99 /// let s3: Backend = serde_json::from_value(serde_json::json!({
100 /// "s3": { "bucket": "my-backups" }
101 /// }))
102 /// .unwrap();
103 /// assert_eq!(s3.kind_str(), "S3");
104 /// ```
105 pub fn kind_str(&self) -> &'static str {
106 match self {
107 Backend::S3(_) => "S3",
108 Backend::Azure(_) => "Azure",
109 Backend::Gcs(_) => "Gcs",
110 Backend::B2(_) => "B2",
111 Backend::Filesystem(_) => "Filesystem",
112 Backend::Sftp(_) => "Sftp",
113 Backend::WebDav(_) => "WebDav",
114 Backend::Rclone(_) => "Rclone",
115 }
116 }
117}
118
119/// S3 / S3-compatible object-store backend. ADR §3.1.
120#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
121#[serde(rename_all = "camelCase")]
122pub struct S3Backend {
123 /// Bucket holding the kopia repository.
124 pub bucket: String,
125 /// Key prefix under the bucket, letting several repositories share one bucket
126 /// (e.g. `clusters/prod/`). Empty/absent means the bucket root.
127 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub prefix: Option<String>,
129 /// S3 endpoint host. Omit for AWS; set it for MinIO/RustFS/other
130 /// S3-compatible stores.
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub endpoint: Option<String>,
133 /// S3 region. Required by AWS and some compatible providers.
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub region: Option<String>,
136 /// Access credentials (Secret ref / workload identity). ADR §3.1.
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub auth: Option<BackendAuth>,
139 /// TLS overrides for self-signed CAs or HTTP-only endpoints.
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub tls: Option<TlsConfig>,
142}
143
144/// Azure Blob Storage backend. ADR §3.1.
145#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
146#[serde(rename_all = "camelCase")]
147pub struct AzureBackend {
148 /// Blob container holding the kopia repository.
149 pub container: String,
150 /// Blob-name prefix within the container; empty/absent means the container root.
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub prefix: Option<String>,
153 /// Storage-account name (when not inferred from credentials).
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub storage_account: Option<String>,
156 /// Access credentials (Secret ref / workload identity).
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub auth: Option<BackendAuth>,
159}
160
161/// Google Cloud Storage backend. ADR §3.1.
162#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
163#[serde(rename_all = "camelCase")]
164pub struct GcsBackend {
165 /// GCS bucket holding the kopia repository.
166 pub bucket: String,
167 /// Object-name prefix within the bucket; empty/absent means the bucket root.
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub prefix: Option<String>,
170 /// Access credentials (service-account key Secret / workload identity).
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub auth: Option<BackendAuth>,
173}
174
175/// Backblaze B2 backend. ADR §3.1.
176#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
177#[serde(rename_all = "camelCase")]
178pub struct B2Backend {
179 /// B2 bucket holding the kopia repository.
180 pub bucket: String,
181 /// Object-name prefix within the bucket; empty/absent means the bucket root.
182 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub prefix: Option<String>,
184 /// Access credentials (application key ID/key Secret).
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub auth: Option<BackendAuth>,
187}
188
189/// Local-filesystem backend, backed by a PVC the operator mounts. ADR §3.1.
190#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
191#[serde(rename_all = "camelCase")]
192pub struct FilesystemBackend {
193 /// Mount path inside the mover pod. Backed by a PVC the operator mounts.
194 pub path: String,
195 /// Name of the `PersistentVolumeClaim` to mount at `path`. Absent for a path
196 /// already present on the node/image.
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub pvc_name: Option<String>,
199}
200
201/// SFTP backend. ADR §3.1.
202#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
203#[serde(rename_all = "camelCase")]
204pub struct SftpBackend {
205 /// SFTP server hostname or IP.
206 pub host: String,
207 /// Remote path on the server that holds the kopia repository.
208 pub path: String,
209 /// TCP port; defaults to 22 when absent.
210 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub port: Option<u16>,
212 /// SSH username to connect as.
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub username: Option<String>,
215 /// Credentials (e.g. SSH private key / known-hosts) sourced from a Secret.
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub auth: Option<BackendAuth>,
218}
219
220/// WebDAV backend. ADR §3.1.
221#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
222#[serde(rename_all = "camelCase")]
223pub struct WebDavBackend {
224 /// WebDAV collection URL holding the kopia repository.
225 pub url: String,
226 /// HTTP basic-auth credentials sourced from a Secret.
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub auth: Option<BackendAuth>,
229}
230
231/// rclone-remote backend; kopia shells out to `rclone` so any rclone-supported
232/// provider is reachable. ADR §3.1.
233#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
234#[serde(rename_all = "camelCase")]
235pub struct RcloneBackend {
236 /// rclone path in `remote:path` form (the remote name must exist in the
237 /// supplied rclone config).
238 pub remote_path: String,
239 /// Secret holding the `rclone.conf` that defines the remote referenced by
240 /// `remote_path`.
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 pub config_secret_ref: Option<SecretRef>,
243}