Skip to main content

max / makenotwork

7.0 KB · 211 lines History Blame Raw
1 //! The typed step vocabulary.
2 //!
3 //! Steps are *typed actions*, not raw command strings, so a capability check
4 //! means something: an executor granted only `deploy`+`restart` rejects a
5 //! `sign` step before it ever dispatches. The actual command to run still
6 //! travels in `argv` (and `env`/`cwd`); the `action` is the capability label.
7
8 use serde::{Deserialize, Serialize};
9 use std::path::PathBuf;
10
11 /// What a step *does*, for capability gating. The label is independent of the
12 /// concrete command in `Step::argv` — two different `sign` recipes are both
13 /// `Action::Sign` and both gated by the `sign` grant.
14 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
15 #[serde(rename_all = "snake_case")]
16 pub enum Action {
17 // Bento (app release)
18 Build,
19 Sign,
20 Notarize,
21 Staple,
22 Package,
23 // Sando (server promotion)
24 Deploy,
25 Restart,
26 Rollback,
27 /// Read-only host inspection. Never mutates; gated by the `observe` plane.
28 Observe(ObserveKind),
29 /// Escape hatch for one-off steps. Still grant-gated: a `Custom` action is
30 /// only permitted if the grant explicitly lists that same custom name.
31 Custom(String),
32 }
33
34 impl Action {
35 /// Is this an actuating (mutating) action, as opposed to observe?
36 pub fn is_actuate(&self) -> bool {
37 !matches!(self, Action::Observe(_))
38 }
39
40 /// The lower-case token used in topology config (`actuate = ["deploy", ...]`).
41 /// `None` for `Observe` (those live in the `observe` list under their kind).
42 pub fn token(&self) -> Option<String> {
43 Some(match self {
44 Action::Build => "build".into(),
45 Action::Sign => "sign".into(),
46 Action::Notarize => "notarize".into(),
47 Action::Staple => "staple".into(),
48 Action::Package => "package".into(),
49 Action::Deploy => "deploy".into(),
50 Action::Restart => "restart".into(),
51 Action::Rollback => "rollback".into(),
52 Action::Custom(s) => s.clone(),
53 Action::Observe(_) => return None,
54 })
55 }
56
57 /// Parse an actuate token from topology config. Unknown tokens become
58 /// `Custom` so a config typo is a denied capability, never a silent build
59 /// action.
60 pub fn actuate_from_token(token: &str) -> Action {
61 match token {
62 "build" => Action::Build,
63 "sign" => Action::Sign,
64 "notarize" => Action::Notarize,
65 "staple" => Action::Staple,
66 "package" => Action::Package,
67 "deploy" => Action::Deploy,
68 "restart" => Action::Restart,
69 "rollback" => Action::Rollback,
70 other => Action::Custom(other.to_string()),
71 }
72 }
73 }
74
75 /// The kinds of read-only host inspection an `observe` grant can cover.
76 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
77 #[serde(rename_all = "snake_case")]
78 pub enum ObserveKind {
79 Journal,
80 Metrics,
81 Health,
82 BuildLog,
83 /// Retrieval of a *release artifact* the host produced — the grant `GET
84 /// /pull` requires. Distinct from [`ObserveKind::BuildLog`]: a caller that
85 /// wants the signed DMG does not thereby want the build's log output, and a
86 /// caller that wants logs should not thereby be able to read the artifacts
87 /// tree. Scope is the agent's configured `pull_root`, not the whole host.
88 Artifact,
89 Custom(String),
90 }
91
92 impl ObserveKind {
93 pub fn token(&self) -> String {
94 match self {
95 ObserveKind::Journal => "journal".into(),
96 ObserveKind::Metrics => "metrics".into(),
97 ObserveKind::Health => "health".into(),
98 ObserveKind::BuildLog => "build-log".into(),
99 ObserveKind::Artifact => "artifact".into(),
100 ObserveKind::Custom(s) => s.clone(),
101 }
102 }
103
104 pub fn from_token(token: &str) -> ObserveKind {
105 match token {
106 "journal" => ObserveKind::Journal,
107 "metrics" => ObserveKind::Metrics,
108 "health" => ObserveKind::Health,
109 "build-log" | "build_log" => ObserveKind::BuildLog,
110 "artifact" => ObserveKind::Artifact,
111 other => ObserveKind::Custom(other.to_string()),
112 }
113 }
114 }
115
116 /// One executable step: a typed action plus the command to run for it.
117 ///
118 /// `argv` is the command and its arguments (argv[0] is the program). A shell
119 /// script is just `["/bin/sh", "-c", "<script>"]` — see [`Step::shell`], which
120 /// is how Sando's multi-statement deploy scripts ride this type.
121 #[derive(Clone, Debug, Serialize, Deserialize)]
122 pub struct Step {
123 pub action: Action,
124 pub argv: Vec<String>,
125 #[serde(default)]
126 pub env: Vec<(String, String)>,
127 #[serde(default)]
128 pub cwd: Option<PathBuf>,
129 }
130
131 impl Step {
132 /// A step that runs a literal `argv` (no shell).
133 pub fn new(action: Action, argv: impl IntoIterator<Item = impl Into<String>>) -> Self {
134 Self {
135 action,
136 argv: argv.into_iter().map(Into::into).collect(),
137 env: Vec::new(),
138 cwd: None,
139 }
140 }
141
142 /// A step that runs `script` through `/bin/sh -c` — pipes, `&&`, and
143 /// `set -e` all work as written. This is the Sando deploy idiom.
144 pub fn shell(action: Action, script: impl Into<String>) -> Self {
145 Self {
146 action,
147 argv: vec!["/bin/sh".into(), "-c".into(), script.into()],
148 env: Vec::new(),
149 cwd: None,
150 }
151 }
152
153 #[must_use]
154 pub fn with_env(mut self, key: impl Into<String>, val: impl Into<String>) -> Self {
155 self.env.push((key.into(), val.into()));
156 self
157 }
158
159 #[must_use]
160 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
161 self.cwd = Some(cwd.into());
162 self
163 }
164
165 /// True when this is a `/bin/sh -c <script>` shell step.
166 pub(crate) fn shell_script(&self) -> Option<&str> {
167 match self.argv.as_slice() {
168 [sh, dash_c, script] if (sh == "/bin/sh" || sh == "sh") && dash_c == "-c" => {
169 Some(script.as_str())
170 }
171 _ => None,
172 }
173 }
174 }
175
176 #[cfg(test)]
177 mod tests {
178 use super::*;
179
180 #[test]
181 fn token_roundtrip() {
182 for tok in ["build", "sign", "deploy", "restart", "rollback", "package"] {
183 assert_eq!(
184 Action::actuate_from_token(tok).token().as_deref(),
185 Some(tok)
186 );
187 }
188 }
189
190 #[test]
191 fn unknown_actuate_token_is_custom_not_build() {
192 let a = Action::actuate_from_token("frobnicate");
193 assert_eq!(a, Action::Custom("frobnicate".into()));
194 assert!(a.is_actuate());
195 }
196
197 #[test]
198 fn observe_is_not_actuate() {
199 assert!(!Action::Observe(ObserveKind::Health).is_actuate());
200 assert_eq!(Action::Observe(ObserveKind::Health).token(), None);
201 }
202
203 #[test]
204 fn shell_step_detected() {
205 let s = Step::shell(Action::Deploy, "set -e; echo hi");
206 assert_eq!(s.shell_script(), Some("set -e; echo hi"));
207 let p = Step::new(Action::Build, ["cargo", "build"]);
208 assert_eq!(p.shell_script(), None);
209 }
210 }
211