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 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}