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
112impl std::fmt::Display for OnChange {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118impl SecretDef {
119    /// Check that exactly one source is given: `file`, `environment`,
120    /// `external`, `age`, or `driver` with `name`.
121    pub fn validate(&self) -> std::result::Result<(), String> {
122        let sources = [
123            self.file.is_some(),
124            self.environment.is_some(),
125            self.external,
126            self.age.is_some(),
127            self.driver.is_some(),
128        ];
129        if sources.iter().filter(|s| **s).count() != 1 {
130            return Err(
131                "needs exactly one of file, environment, external, age, or driver (with name)"
132                    .into(),
133            );
134        }
135        if self.name.is_some() && !self.external && self.driver.is_none() {
136            return Err("name goes with external or driver".into());
137        }
138        if self.driver.is_some() && self.name.as_deref().is_none_or(str::is_empty) {
139            return Err("driver needs name: the driver's reference to the secret".into());
140        }
141        if self.external {
142            if let Some(n) = &self.name {
143                crate::secrets::validate_name(n).map_err(|e| e.to_string())?;
144            }
145        }
146        if self.age.as_deref().is_some_and(|a| a.trim().is_empty()) {
147            return Err("age is empty".into());
148        }
149        if self
150            .rotate
151            .as_ref()
152            .is_some_and(|a| a.is_empty() || a[0].is_empty())
153        {
154            return Err("rotate needs a command: [argv...]".into());
155        }
156        if let Some(r) = &self.refresh {
157            if self.driver.is_none() {
158                return Err("refresh goes with driver".into());
159            }
160            let d = flex::parse_duration(r).map_err(|e| format!("refresh: {e}"))?;
161            if d < std::time::Duration::from_secs(10) {
162                return Err(format!("refresh {r:?}: at least 10s"));
163            }
164        }
165        Ok(())
166    }
167
168    /// The store name of an `external` secret declared under `key`.
169    pub fn store_name<'a>(&'a self, key: &'a str) -> Option<&'a str> {
170        self.external.then(|| self.name.as_deref().unwrap_or(key))
171    }
172
173    /// How often a driver-backed secret is checked for a new version.
174    pub fn refresh_interval(&self) -> std::time::Duration {
175        self.refresh
176            .as_deref()
177            .and_then(|r| flex::parse_duration(r).ok())
178            .unwrap_or(DEFAULT_SECRET_REFRESH)
179    }
180
181    /// Resolved where the deployer stands (`file`, `environment`), rather
182    /// than from the org's store and the daemon's key.
183    pub fn is_client_side(&self) -> bool {
184        self.file.is_some() || self.environment.is_some()
185    }
186
187    /// The source kind, for messages.
188    pub fn source_kind(&self) -> &'static str {
189        if self.file.is_some() {
190            "file"
191        } else if self.environment.is_some() {
192            "environment"
193        } else if self.external {
194            "external"
195        } else if self.age.is_some() {
196            "age"
197        } else if self.driver.is_some() {
198            "driver"
199        } else {
200            "none"
201        }
202    }
203}
204
205/// How often `isb serve` checks a driver-backed secret by default.
206pub const DEFAULT_SECRET_REFRESH: std::time::Duration = std::time::Duration::from_secs(3600);