Skip to main content

isb_core/spec/
secret.rs

1//! Top-level secrets: where a value comes from, and what its new versions
2//! do to the stack services using it.
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::flex;
8
9/// Where a secret's value comes from. Exactly one source.
10#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
11#[serde(deny_unknown_fields)]
12pub struct SecretDef {
13    /// A host file holding the value (relative to the compose file).
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub file: Option<String>,
16
17    /// An environment variable of whoever deploys the file (`isb up`, or the
18    /// client calling `isb stack deploy`).
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub environment: Option<String>,
21
22    /// The secret already exists in the org's secret store (`isb secret
23    /// create`), under `name` (default: the key).
24    #[serde(
25        default,
26        deserialize_with = "flex::bool",
27        skip_serializing_if = "std::ops::Not::not"
28    )]
29    #[schemars(with = "flex::BoolOrString")]
30    pub external: bool,
31
32    /// With `external`: the store's name for it. With `driver`: the
33    /// driver's reference (a 1Password `op://` path, say).
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub name: Option<String>,
36
37    /// The value, age-encrypted to the daemon's recipients (`isb secret
38    /// encrypt`): ASCII-armored, or base64 of the binary format.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub age: Option<String>,
41
42    /// Read through this secrets driver, from `name`.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub driver: Option<String>,
45
46    /// With `driver`: how often `isb serve` checks the driver for a new
47    /// version (`30m`, `1h`; default 1h). A new version rolls the services
48    /// using it.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub refresh: Option<String>,
51
52    /// What a new version does to the services using it under `isb serve`:
53    /// `roll` (default), `restart` or `none`. A service's own reference
54    /// (`secrets: [{source, on_change}]`, `{secret, on_change}`) overrides it.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub on_change: Option<OnChange>,
57
58    /// Under `isb serve`: argv run in one running replica of each service
59    /// using the secret when it gets a new version, before any replica is
60    /// given it, to make the new value take effect where the old one is
61    /// stored (a database user's password). It reads the new value on stdin
62    /// and runs with the replica's own environment, which still holds the
63    /// old one. A failure stops the change: `isb secret set` stores nothing,
64    /// and a driver's new version is not taken up.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub rotate: Option<Vec<String>>,
67}
68
69/// What a new version of a secret does to the stack services using it.
70/// Ordered weakest first: a service that uses a secret twice with different
71/// settings gets the stronger one.
72#[derive(
73    Debug,
74    Clone,
75    Copy,
76    Default,
77    PartialEq,
78    Eq,
79    PartialOrd,
80    Ord,
81    Hash,
82    Serialize,
83    Deserialize,
84    JsonSchema,
85)]
86#[serde(rename_all = "lowercase")]
87pub enum OnChange {
88    /// Nothing is restarted: files under `/run/secrets` (and the variables a
89    /// later start reads) get the new value, and the replicas are reported
90    /// stale until they next start.
91    None,
92    /// The replicas keep their instances: each gets the new value and its
93    /// app is restarted in place, one batch (`update_config.parallelism`)
94    /// at a time, each waiting until healthy.
95    Restart,
96    /// A new revision: the replicas are replaced by a rolling update per
97    /// `update_config` (order, parallelism, health, `failure_action`).
98    #[default]
99    Roll,
100}
101
102impl OnChange {
103    pub fn as_str(self) -> &'static str {
104        match self {
105            OnChange::None => "none",
106            OnChange::Restart => "restart",
107            OnChange::Roll => "roll",
108        }
109    }
110}
111
112/// How an `environment` secret reaches the app (`KEY: {secret: NAME, as: ...}`).
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
114#[serde(rename_all = "lowercase")]
115pub enum SecretAs {
116    /// The variable `KEY` holds the value. An OCI image's variables are
117    /// instance config (`environment.KEY`), readable by anyone with access
118    /// to the incus project.
119    #[default]
120    Env,
121    /// The value is the file `/run/secrets/NAME` (mode 0400, owned by the
122    /// user the app starts as) and the variable `KEY_FILE` holds its path:
123    /// the `_FILE` convention of postgres, mariadb and many other images.
124    File,
125}
126
127impl std::fmt::Display for OnChange {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        f.write_str(self.as_str())
130    }
131}
132
133impl SecretDef {
134    /// Check that exactly one source is given: `file`, `environment`,
135    /// `external`, `age`, or `driver` with `name`.
136    pub fn validate(&self) -> std::result::Result<(), String> {
137        let sources = [
138            self.file.is_some(),
139            self.environment.is_some(),
140            self.external,
141            self.age.is_some(),
142            self.driver.is_some(),
143        ];
144        if sources.iter().filter(|s| **s).count() != 1 {
145            return Err(
146                "needs exactly one of file, environment, external, age, or driver (with name)"
147                    .into(),
148            );
149        }
150        if self.name.is_some() && !self.external && self.driver.is_none() {
151            return Err("name goes with external or driver".into());
152        }
153        if self.driver.is_some() && self.name.as_deref().is_none_or(str::is_empty) {
154            return Err("driver needs name: the driver's reference to the secret".into());
155        }
156        if self.external {
157            if let Some(n) = &self.name {
158                crate::secrets::validate_name(n).map_err(|e| e.to_string())?;
159            }
160        }
161        if self.age.as_deref().is_some_and(|a| a.trim().is_empty()) {
162            return Err("age is empty".into());
163        }
164        if self
165            .rotate
166            .as_ref()
167            .is_some_and(|a| a.is_empty() || a[0].is_empty())
168        {
169            return Err("rotate needs a command: [argv...]".into());
170        }
171        if let Some(r) = &self.refresh {
172            if self.driver.is_none() {
173                return Err("refresh goes with driver".into());
174            }
175            let d = flex::parse_duration(r).map_err(|e| format!("refresh: {e}"))?;
176            if d < std::time::Duration::from_secs(10) {
177                return Err(format!("refresh {r:?}: at least 10s"));
178            }
179        }
180        Ok(())
181    }
182
183    /// The store name of an `external` secret declared under `key`.
184    pub fn store_name<'a>(&'a self, key: &'a str) -> Option<&'a str> {
185        self.external.then(|| self.name.as_deref().unwrap_or(key))
186    }
187
188    /// How often a driver-backed secret is checked for a new version.
189    pub fn refresh_interval(&self) -> std::time::Duration {
190        self.refresh
191            .as_deref()
192            .and_then(|r| flex::parse_duration(r).ok())
193            .unwrap_or(DEFAULT_SECRET_REFRESH)
194    }
195
196    /// Resolved where the deployer stands (`file`, `environment`), rather
197    /// than from the org's store and the daemon's key.
198    pub fn is_client_side(&self) -> bool {
199        self.file.is_some() || self.environment.is_some()
200    }
201
202    /// The source kind, for messages.
203    pub fn source_kind(&self) -> &'static str {
204        if self.file.is_some() {
205            "file"
206        } else if self.environment.is_some() {
207            "environment"
208        } else if self.external {
209            "external"
210        } else if self.age.is_some() {
211            "age"
212        } else if self.driver.is_some() {
213            "driver"
214        } else {
215            "none"
216        }
217    }
218}
219
220/// How often `isb serve` checks a driver-backed secret by default.
221pub const DEFAULT_SECRET_REFRESH: std::time::Duration = std::time::Duration::from_secs(3600);