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);