Skip to main content

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}