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 a cloud object-store backend with an IAM plane (S3 / Azure / GCS).
15#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
16#[serde(rename_all = "camelCase")]
17pub struct BackendAuth {
18 /// Secret holding the backend's static access credentials (mutually exclusive with `workloadIdentity`).
19 #[serde(default, skip_serializing_if = "Option::is_none")]
20 pub secret_ref: Option<SecretRef>,
21 /// Use a cloud-federated ServiceAccount instead of static keys (mutually exclusive with `secretRef`).
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub workload_identity: Option<WorkloadIdentity>,
24}
25
26/// Cloud workload-identity binding: the mover runs as a federated `ServiceAccount`, not static keys.
27#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
28#[serde(rename_all = "camelCase")]
29pub struct WorkloadIdentity {
30 /// Name of the `ServiceAccount` the mover pod runs as, resolved in the Job's own namespace.
31 pub service_account_name: String,
32}
33
34/// Credentials for a backend **without** a cloud IAM plane (B2, SFTP, WebDAV): static Secret only.
35#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
36#[serde(rename_all = "camelCase")]
37pub struct SecretAuth {
38 /// Secret holding the backend's access credentials, read by well-known keys.
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub secret_ref: Option<SecretRef>,
41}
42
43/// The discriminated backend union (`backend: { s3: {...} }`); exactly one variant by construction.
44#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
45#[serde(rename_all = "camelCase")]
46pub enum Backend {
47 /// Amazon S3 or any S3-compatible object store (MinIO, RustFS, Ceph RGW, …).
48 S3(S3Backend),
49 /// Azure Blob Storage.
50 Azure(AzureBackend),
51 /// Google Cloud Storage.
52 Gcs(GcsBackend),
53 /// Backblaze B2.
54 B2(B2Backend),
55 /// A local filesystem path, backed by a PVC the operator mounts into the mover.
56 Filesystem(FilesystemBackend),
57 /// SFTP server.
58 Sftp(SftpBackend),
59 /// WebDAV endpoint.
60 WebDav(WebDavBackend),
61 /// Any rclone remote (kopia shells out to `rclone`), broadening reach to
62 /// providers without a native kopia backend.
63 Rclone(RcloneBackend),
64 /// Google Drive via kopia's native `gdrive` provider (service-account JSON).
65 /// kopia marks this provider experimental / not maintained, and a native
66 /// gdrive repository is not interchangeable with an rclone-backed Drive remote.
67 Gdrive(GdriveBackend),
68}
69
70impl Backend {
71 /// Stable discriminant string for status/metrics/printcolumns.
72 ///
73 /// Returns the variant's PascalCase name, independent of the camelCase wire
74 /// key (`backend: { s3: ... }` deserializes to [`Backend::S3`], whose
75 /// `kind_str()` is `"S3"`).
76 ///
77 /// ```
78 /// use kopiur_api::backend::{Backend, FilesystemBackend};
79 ///
80 /// let b = Backend::Filesystem(FilesystemBackend {
81 /// path: "/repo".into(),
82 /// volume: None,
83 /// });
84 /// assert_eq!(b.kind_str(), "Filesystem");
85 ///
86 /// // The wire key is camelCase, but the discriminant stays PascalCase.
87 /// let s3: Backend = serde_json::from_value(serde_json::json!({
88 /// "s3": { "bucket": "my-backups" }
89 /// }))
90 /// .unwrap();
91 /// assert_eq!(s3.kind_str(), "S3");
92 /// ```
93 pub fn kind_str(&self) -> &'static str {
94 match self {
95 Backend::S3(_) => "S3",
96 Backend::Azure(_) => "Azure",
97 Backend::Gcs(_) => "Gcs",
98 Backend::B2(_) => "B2",
99 Backend::Filesystem(_) => "Filesystem",
100 Backend::Sftp(_) => "Sftp",
101 Backend::WebDav(_) => "WebDav",
102 Backend::Rclone(_) => "Rclone",
103 Backend::Gdrive(_) => "Gdrive",
104 }
105 }
106}
107
108/// S3 / S3-compatible object-store backend.
109#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
110#[serde(rename_all = "camelCase")]
111pub struct S3Backend {
112 /// Bucket holding the kopia repository.
113 pub bucket: String,
114 /// Key prefix under the bucket, letting several repositories share one bucket
115 /// (e.g. `clusters/prod/`). Empty/absent means the bucket root.
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub prefix: Option<String>,
118 /// S3 endpoint host. Omit for AWS; set it for MinIO/RustFS/other
119 /// S3-compatible stores.
120 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub endpoint: Option<String>,
122 /// S3 region. Required by AWS and some compatible providers.
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub region: Option<String>,
125 /// Access credentials (Secret ref / workload identity).
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub auth: Option<BackendAuth>,
128 /// TLS overrides for self-signed CAs or HTTP-only endpoints.
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub tls: Option<TlsConfig>,
131}
132
133/// Azure Blob Storage backend.
134#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
135#[serde(rename_all = "camelCase")]
136pub struct AzureBackend {
137 /// Blob container holding the kopia repository.
138 pub container: String,
139 /// Blob-name prefix within the container; empty/absent means the container root.
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub prefix: Option<String>,
142 /// Storage-account name (when not inferred from credentials).
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub storage_account: Option<String>,
145 /// Access credentials (Secret ref / workload identity).
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub auth: Option<BackendAuth>,
148}
149
150/// Google Cloud Storage backend.
151#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
152#[serde(rename_all = "camelCase")]
153pub struct GcsBackend {
154 /// GCS bucket holding the kopia repository.
155 pub bucket: String,
156 /// Object-name prefix within the bucket; empty/absent means the bucket root.
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub prefix: Option<String>,
159 /// Access credentials (service-account key Secret / workload identity).
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub auth: Option<BackendAuth>,
162}
163
164/// Backblaze B2 backend.
165#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
166#[serde(rename_all = "camelCase")]
167pub struct B2Backend {
168 /// B2 bucket holding the kopia repository.
169 pub bucket: String,
170 /// Object-name prefix within the bucket; empty/absent means the bucket root.
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub prefix: Option<String>,
173 /// Access credentials (application key ID/key Secret); Secret-only.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub auth: Option<SecretAuth>,
176}
177
178/// Local-filesystem backend: kopia writes the repository to a path inside the mover pod.
179#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
180#[serde(rename_all = "camelCase")]
181pub struct FilesystemBackend {
182 /// Mount path inside the mover pod where kopia writes the repository (e.g. `/repo`).
183 pub path: String,
184 /// What backs `path`: a PVC or an inline NFS export; absent for a path already on the node/image.
185 #[serde(default, skip_serializing_if = "Option::is_none")]
186 pub volume: Option<RepoVolume>,
187}
188
189/// What backs a filesystem repository's mount path (a PVC or an inline NFS export).
190#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
191#[serde(rename_all = "camelCase")]
192pub enum RepoVolume {
193 /// A `PersistentVolumeClaim` mounted read-write at the repo path.
194 Pvc(PvcVolume),
195 /// An inline NFS export mounted directly (no PVC).
196 Nfs(NfsVolume),
197}
198
199impl RepoVolume {
200 /// Stable discriminant string for status/metrics.
201 pub fn kind_str(&self) -> &'static str {
202 match self {
203 RepoVolume::Pvc(_) => "Pvc",
204 RepoVolume::Nfs(_) => "Nfs",
205 }
206 }
207}
208
209/// A `PersistentVolumeClaim` mounted into the mover pod.
210#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
211#[serde(rename_all = "camelCase")]
212pub struct PvcVolume {
213 /// Name of the `PersistentVolumeClaim` to mount (in the mover's namespace).
214 pub name: String,
215}
216
217/// An inline NFS export mounted directly into the mover pod — no PVC, no StorageClass.
218#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
219#[serde(rename_all = "camelCase")]
220pub struct NfsVolume {
221 /// NFS server hostname or IP (e.g. `nas.lan` or `expanse.internal`).
222 pub server: String,
223 /// Exported path on the NFS server (e.g. `/export/kopia` or `/mnt/eros/Media`).
224 pub path: String,
225}
226
227/// SFTP backend.
228#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
229#[serde(rename_all = "camelCase")]
230pub struct SftpBackend {
231 /// SFTP server hostname or IP.
232 pub host: String,
233 /// Remote path on the server that holds the kopia repository.
234 pub path: String,
235 /// TCP port; defaults to 22 when absent.
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub port: Option<u16>,
238 /// SSH username to connect as.
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub username: Option<String>,
241 /// Credentials (SSH private key / known-hosts) sourced from a Secret; Secret-only.
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub auth: Option<SecretAuth>,
244}
245
246/// WebDAV backend.
247#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
248#[serde(rename_all = "camelCase")]
249pub struct WebDavBackend {
250 /// WebDAV collection URL holding the kopia repository.
251 pub url: String,
252 /// HTTP basic-auth credentials sourced from a Secret; Secret-only.
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub auth: Option<SecretAuth>,
255}
256
257/// rclone-remote backend; kopia shells out to `rclone` so any rclone-supported provider is reachable.
258#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
259#[serde(rename_all = "camelCase")]
260pub struct RcloneBackend {
261 /// rclone path in `remote:path` form (the remote name must exist in the
262 /// supplied rclone config).
263 pub remote_path: String,
264 /// Secret holding the `rclone.conf` that defines the remote referenced by
265 /// `remote_path`.
266 #[serde(default, skip_serializing_if = "Option::is_none")]
267 pub config_secret_ref: Option<SecretRef>,
268 /// How long kopia waits for its embedded `rclone serve` to come up before
269 /// failing the connect, as a Go duration (e.g. `2m`). kopia's default is
270 /// `15s`; raise it for slow remotes whose repository metadata/indexes load
271 /// through the rclone/WebDAV bridge and take longer than the default budget.
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub startup_timeout: Option<String>,
274}
275
276/// Google Drive backend using kopia's native `gdrive` provider.
277///
278/// kopia marks this provider experimental / not maintained, so prefer a native
279/// object store where one is available. A native gdrive repository is not
280/// interchangeable with an rclone-backed Drive remote — the two lay out data
281/// differently and cannot read each other.
282#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
283#[serde(rename_all = "camelCase")]
284pub struct GdriveBackend {
285 /// Google Drive folder ID that holds the kopia repository.
286 pub folder_id: String,
287 /// Secret holding the Google service-account JSON used to reach the folder,
288 /// read by the well-known key `KOPIA_GDRIVE_CREDENTIALS`. Absent means kopia
289 /// falls back to ambient credentials (`GOOGLE_APPLICATION_CREDENTIALS` or
290 /// instance metadata).
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub credentials_secret_ref: Option<SecretRef>,
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::testutil::from_yaml;
299
300 #[test]
301 fn gdrive_backend_round_trips() {
302 let b: Backend = from_yaml(
303 r#"
304gdrive:
305 folderId: 0ABCDEF
306 credentialsSecretRef: { name: gdrive-sa }
307"#,
308 );
309 match &b {
310 Backend::Gdrive(g) => {
311 assert_eq!(g.folder_id, "0ABCDEF");
312 assert_eq!(
313 g.credentials_secret_ref.as_ref().map(|r| r.name.as_str()),
314 Some("gdrive-sa")
315 );
316 }
317 other => panic!("expected gdrive, got {other:?}"),
318 }
319 assert_eq!(b.kind_str(), "Gdrive");
320 // Externally tagged, camelCase — the wire key is the variant, not `kind`.
321 let json = serde_json::to_value(&b).unwrap();
322 assert_eq!(json["gdrive"]["folderId"], "0ABCDEF");
323 }
324
325 #[test]
326 fn rclone_startup_timeout_round_trips() {
327 let b: Backend = from_yaml(
328 r#"
329rclone:
330 remotePath: "remote:bucket"
331 startupTimeout: 2m
332"#,
333 );
334 let Backend::Rclone(r) = &b else {
335 panic!("expected rclone, got {b:?}")
336 };
337 assert_eq!(r.remote_path, "remote:bucket");
338 assert_eq!(r.startup_timeout.as_deref(), Some("2m"));
339
340 // Absent startupTimeout stays None and is omitted on the wire.
341 let bare: Backend = from_yaml("rclone: { remotePath: \"remote:bucket\" }");
342 let Backend::Rclone(r) = &bare else {
343 panic!("expected rclone")
344 };
345 assert!(r.startup_timeout.is_none());
346 let json = serde_json::to_value(&bare).unwrap();
347 assert!(json["rclone"].get("startupTimeout").is_none());
348 }
349}