Secrets mount as files: secret blocks, the claim's secrets, flow-init writes them e2c125610f43
A job's secret "name" { mount-path "/run/secrets/…" } names a secret
the hub holds for the repository; the claim carries each with its path
and value, and flow-init writes it there before the job starts, 0400,
the job's user's, on a tmpfs of its own. Never in the environment, the
receipt or an artifact (spec §9). flow run --secret NAME=VALUE supplies
one locally. refs oxygenhub#107
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 16:13 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
e2c125610f43c2ce8301e12710ac1f915bee4f17f93a632229aed319b234cad3- Depends on
- a67b97e9a717 c23d519168d8 c3db750ed96e c4285535b03a c58efc6a74db e0918b887850 e576e08617dc eb5c4db673e5 f98a8f7881e0 fc10d9e80383
- Depended on by
- 2be1fe4ad345 f4e51e63582d e20897216221 46fe2c815cc3 ea81796bb757 6d53fb7d00ec 9a6fac2b40a9 ea56c56eaf90 1abadd4a2fa6 720eb8191059
- In bookmarks
- main newer-pipeline push-uses-login
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 38 atoms, 0 tree operations, 12 files
Browse the tree at this change
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Secrets as files (oxygenhub#107).** A job's `secret "name" { mount-path "/run/secrets/…" }` names a secret the hub holds; it comes with the claim and `flow-init` writes it at that path, 0400, on a tmpfs, before the job starts. Never in the environment, the receipt or an artifact (spec §9). `flow run --secret NAME=VALUE` locally. |
|
| 8 | + | ||
| 7 | 9 | - **Variables from the hub (oxygenhub#102).** A claim carries `env`, the variables the hub holds for the repository; they join the job's environment under `FLOW_*` and the pipeline's own `env`. `flow run --var NAME=VALUE` does the same locally. |
|
| 8 | 10 | ||
| 9 | 11 | - **A job says how long it may run (#23).** `timeout "90m"` on a job, or on a sandbox for its jobs (hours, minutes, seconds; a minute to a day). The hub caps it at the site's maximum. |
|
| … | 10 unchanged lines |
modified crates/flow-core/src/api.rs +14 −0
| … | 101 unchanged lines |
||
| 102 | 102 | pub heads: Vec<String>, |
|
| 103 | 103 | } |
|
| 104 | 104 | ||
| 105 | + | /// A secret for a job, as the hub supplies it (spec §9): written by the |
|
| 106 | + | /// runner at `path` in the sandbox, 0400, on an in-memory mount; never in |
|
| 107 | + | /// the environment, the receipt or an artifact. |
|
| 108 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 109 | + | pub struct SecretInput { |
|
| 110 | + | pub name: String, |
|
| 111 | + | pub path: String, |
|
| 112 | + | pub value: String, |
|
| 113 | + | } |
|
| 114 | + | ||
| 105 | 115 | /// A claimed job: everything the runner needs, nothing more. |
|
| 106 | 116 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 107 | 117 | pub struct Claim { |
|
| … | 33 unchanged lines |
||
| 141 | 151 | /// those are mounted, not put in the environment (spec §9). |
|
| 142 | 152 | #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] |
|
| 143 | 153 | pub env: std::collections::BTreeMap<String, String>, |
|
| 154 | + | /// The secrets the job declares (`secret` blocks), as the hub holds |
|
| 155 | + | /// them for the repository (oxygenhub#107). |
|
| 156 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 157 | + | pub secrets: Vec<SecretInput>, |
|
| 144 | 158 | } |
|
| 145 | 159 | ||
| 146 | 160 | impl Claim { |
|
| … | 63 unchanged lines |
modified crates/flow-core/src/canonical.rs +4 −0
| … | 72 unchanged lines |
||
| 73 | 73 | if !j.outputs.is_empty() { |
|
| 74 | 74 | fields.push((7, Value::Array(j.outputs.iter().map(|o| Value::Array(vec![text(&o.name), text(&o.path)])).collect()))); |
|
| 75 | 75 | } |
|
| 76 | + | // Secrets as [name, mount path] sorted by name; absent when there are none. |
|
| 77 | + | if !j.secrets.is_empty() { |
|
| 78 | + | fields.push((8, Value::Array(j.secrets.iter().map(|s| Value::Array(vec![text(&s.name), text(&s.mount_path)])).collect()))); |
|
| 79 | + | } |
|
| 76 | 80 | Value::map(fields) |
|
| 77 | 81 | } |
|
| 78 | 82 | ||
| … | 41 unchanged lines |
modified crates/flow-core/src/lib.rs +1 −1
| … | 9 unchanged lines |
||
| 10 | 10 | pub mod receipt; |
|
| 11 | 11 | ||
| 12 | 12 | pub use canonical::{object_hash, scope_tree_hash, Hash}; |
|
| 13 | - | pub use pipeline::{Job, Mount, MountSource, Network, Pipeline, Problem, Problems, Runtime, Sandbox, PIPELINE_PATH}; |
|
| 13 | + | pub use pipeline::{Job, Mount, MountSource, Network, Pipeline, Problem, Problems, Runtime, Sandbox, Secret, PIPELINE_PATH}; |
|
| 14 | 14 | pub use receipt::{Environment, Receipt}; |
|
| 15 | 15 | ||
| 16 | 16 | #[cfg(test)] |
|
| … | 1 unchanged lines |
modified crates/flow-core/src/pipeline.rs +67 −2
| … | 95 unchanged lines |
||
| 96 | 96 | /// Files the job produces, sorted by name (spec §5.2 `output`). |
|
| 97 | 97 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 98 | 98 | pub outputs: Vec<Output>, |
|
| 99 | + | /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub |
|
| 100 | + | /// supplies each by name, the runner mounts it as a file. |
|
| 101 | + | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| 102 | + | pub secrets: Vec<Secret>, |
|
| 99 | 103 | /// Labels a runner must have to take this job (`runs-on`), sorted; |
|
| 100 | 104 | /// with its sandbox's, what the hub matches against a runner's. |
|
| 101 | 105 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
|
| … | 66 unchanged lines |
||
| 168 | 172 | pub name: String, |
|
| 169 | 173 | /// A clean absolute path under `/src`, not on a tmpfs mount. |
|
| 170 | 174 | pub path: String, |
|
| 175 | + | } |
|
| 176 | + | ||
| 177 | + | /// A secret the job reads as a file (spec §9): never in the environment. |
|
| 178 | + | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
|
| 179 | + | pub struct Secret { |
|
| 180 | + | /// Which secret, as the hub names it; unique in the job. |
|
| 181 | + | pub name: String, |
|
| 182 | + | /// A clean absolute path under `/run/secrets/`, unique in the job; |
|
| 183 | + | /// the file is 0400, the job's user's, on an in-memory mount. |
|
| 184 | + | pub mount_path: String, |
|
| 171 | 185 | } |
|
| 172 | 186 | ||
| 187 | + | /// A secret's mount path: under `/run/secrets/`, no `.` or `..`. |
|
| 188 | + | pub fn secret_path_ok(p: &str) -> bool { |
|
| 189 | + | p.strip_prefix("/run/secrets/").is_some_and(|rest| !rest.is_empty() && rest.split('/').all(|c| !c.is_empty() && c != "." && c != "..")) |
|
| 190 | + | } |
|
| 191 | + | ||
| 173 | 192 | /// Artifacts a job may declare. |
|
| 174 | 193 | pub const MAX_OUTPUTS: usize = 16; |
|
| 175 | 194 | ||
| … | 9 unchanged lines |
||
| 185 | 204 | ||
| 186 | 205 | /// Mounts a job may declare. |
|
| 187 | 206 | pub const MAX_MOUNTS: usize = 4; |
|
| 207 | + | /// The most secrets a job may mount. |
|
| 208 | + | pub const MAX_SECRETS: usize = 8; |
|
| 188 | 209 | /// Sizes a mount may have: 16 MiB to 64 GiB. |
|
| 189 | 210 | pub const MIN_MOUNT_MIB: u64 = 16; |
|
| 190 | 211 | pub const MAX_MOUNT_MIB: u64 = 64 * 1024; |
|
| … | 34 unchanged lines |
||
| 225 | 246 | ||
| 226 | 247 | /// Nodes of the full spec that M0 recognises and refuses. |
|
| 227 | 248 | const LATER_TOP: &[&str] = &["publish", "secret"]; |
|
| 228 | - | const LATER_JOB: &[&str] = &["secret", "attest"]; |
|
| 249 | + | const LATER_JOB: &[&str] = &["attest"]; |
|
| 229 | 250 | ||
| 230 | 251 | struct Ctx<'a> { |
|
| 231 | 252 | src: &'a str, |
|
| … | 553 unchanged lines |
||
| 785 | 806 | } |
|
| 786 | 807 | ||
| 787 | 808 | /// `output { artifact "name" { path "/src/target/release/tool" } }`. |
|
| 809 | + | /// `secret "deploy-token" { mount-path "/run/secrets/token" }` (spec §9.1). |
|
| 810 | + | /// The hub is the vault: the secret of that name set for the repository, |
|
| 811 | + | /// its organisation or the site. `vault-ref` is not in this slice. |
|
| 812 | + | fn parse_secret(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) { |
|
| 813 | + | let Some(name) = ctx.one_string(node) else { |
|
| 814 | + | ctx.err(node.span(), "`secret` takes a name and a block with mount-path"); |
|
| 815 | + | return; |
|
| 816 | + | }; |
|
| 817 | + | if !ctx.name_ok(node.span(), "secret", &name) { |
|
| 818 | + | return; |
|
| 819 | + | } |
|
| 820 | + | let mut path = None; |
|
| 821 | + | for c in block(ctx, node) { |
|
| 822 | + | ctx.no_children(c); |
|
| 823 | + | match c.name().value() { |
|
| 824 | + | "mount-path" => match ctx.one_string(c) { |
|
| 825 | + | Some(p) if secret_path_ok(&p) => path = Some(p), |
|
| 826 | + | Some(p) => { |
|
| 827 | + | ctx.err(c.span(), format!("secret mount-path {p:?} must be an absolute path under /run/secrets/, without . or ..")) |
|
| 828 | + | } |
|
| 829 | + | None => {} |
|
| 830 | + | }, |
|
| 831 | + | "vault-ref" => ctx.err(c.span(), "`vault-ref` is not in M0; the hub supplies the secret of that name"), |
|
| 832 | + | other => ctx.err(c.span(), format!("unknown secret setting `{other}`")), |
|
| 833 | + | } |
|
| 834 | + | } |
|
| 835 | + | let Some(mount_path) = path else { |
|
| 836 | + | ctx.err(node.span(), format!("secret {name:?} needs a mount-path")); |
|
| 837 | + | return; |
|
| 838 | + | }; |
|
| 839 | + | if job.secrets.iter().any(|s| s.name == name) { |
|
| 840 | + | ctx.err(node.span(), format!("secret {name:?} is given twice")); |
|
| 841 | + | } else if job.secrets.iter().any(|s| s.mount_path == mount_path) { |
|
| 842 | + | ctx.err(node.span(), format!("two secrets mount at {mount_path:?}")); |
|
| 843 | + | } else if job.secrets.len() == MAX_SECRETS { |
|
| 844 | + | ctx.err(node.span(), format!("a job has at most {MAX_SECRETS} secrets")); |
|
| 845 | + | } else { |
|
| 846 | + | job.secrets.push(Secret { name, mount_path }); |
|
| 847 | + | } |
|
| 848 | + | } |
|
| 849 | + | ||
| 788 | 850 | fn parse_output(ctx: &mut Ctx, node: &KdlNode, job: &mut Job) { |
|
| 789 | 851 | if !node.entries().is_empty() { |
|
| 790 | 852 | ctx.err(node.span(), "`output` takes a block of artifacts"); |
|
| … | 52 unchanged lines |
||
| 843 | 905 | env: BTreeMap::new(), |
|
| 844 | 906 | exec: vec![], |
|
| 845 | 907 | mounts: vec![], |
|
| 908 | + | secrets: vec![], |
|
| 846 | 909 | outputs: vec![], |
|
| 847 | 910 | runs_on: vec![], |
|
| 848 | 911 | timeout_s: None, |
|
| … | 2 unchanged lines |
||
| 851 | 914 | let mut outputs_at = None; |
|
| 852 | 915 | for c in block(ctx, node) { |
|
| 853 | 916 | let key = c.name().value(); |
|
| 854 | - | if key != "scope" && key != "depends-on" && key != "mount" && key != "runs-on" && !seen.insert(key.to_string()) { |
|
| 917 | + | if key != "scope" && key != "depends-on" && key != "mount" && key != "secret" && key != "runs-on" && !seen.insert(key.to_string()) { |
|
| 855 | 918 | ctx.err(c.span(), format!("`{key}` is given twice")); |
|
| 856 | 919 | } |
|
| 857 | 920 | match key { |
|
| … | 54 unchanged lines |
||
| 912 | 975 | outputs_at = Some(c.span()); |
|
| 913 | 976 | parse_output(ctx, c, &mut job); |
|
| 914 | 977 | } |
|
| 978 | + | "secret" => parse_secret(ctx, c, &mut job), |
|
| 915 | 979 | n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")), |
|
| 916 | 980 | other => ctx.err(c.span(), format!("unknown job setting `{other}`")), |
|
| 917 | 981 | } |
|
| 918 | 982 | } |
|
| 919 | 983 | job.mounts.sort_by(|a, b| a.target.cmp(&b.target)); |
|
| 920 | 984 | job.outputs.sort_by(|a, b| a.name.cmp(&b.name)); |
|
| 985 | + | job.secrets.sort_by(|a, b| a.name.cmp(&b.name)); |
|
| 921 | 986 | // A tmpfs is gone when the job ends; nothing on it can be kept. |
|
| 922 | 987 | for o in &job.outputs { |
|
| 923 | 988 | if let Some(m) = job.mounts.iter().find(|m| m.source == MountSource::Tmpfs && o.path.starts_with(&format!("{}/", m.target))) { |
|
| … | 21 unchanged lines |
modified crates/flow-core/src/tests.rs +49 −1
| … | 261 unchanged lines |
||
| 262 | 262 | } |
|
| 263 | 263 | ||
| 264 | 264 | #[test] |
|
| 265 | + | fn secrets_mount_as_files_and_are_part_of_the_job() { |
|
| 266 | + | let plain = Pipeline::parse(&doc("job \"d\" {\n sandbox \"alpine\"\n exec \"deploy\"\n}\n")).unwrap(); |
|
| 267 | + | let p = Pipeline::parse(&doc(r#"job "d" { |
|
| 268 | + | sandbox "alpine" |
|
| 269 | + | secret "token" { |
|
| 270 | + | mount-path "/run/secrets/token" |
|
| 271 | + | } |
|
| 272 | + | secret "aws" { |
|
| 273 | + | mount-path "/run/secrets/aws/credentials" |
|
| 274 | + | } |
|
| 275 | + | exec "deploy" |
|
| 276 | + | }"#)) |
|
| 277 | + | .unwrap(); |
|
| 278 | + | let d = &p.jobs["d"]; |
|
| 279 | + | assert_eq!( |
|
| 280 | + | d.secrets, |
|
| 281 | + | vec![ |
|
| 282 | + | Secret { name: "aws".into(), mount_path: "/run/secrets/aws/credentials".into() }, |
|
| 283 | + | Secret { name: "token".into(), mount_path: "/run/secrets/token".into() }, |
|
| 284 | + | ], |
|
| 285 | + | "sorted by name" |
|
| 286 | + | ); |
|
| 287 | + | assert_ne!(p.hash(), plain.hash(), "secrets are part of the job"); |
|
| 288 | + | for (bad, why) in [ |
|
| 289 | + | ("secret \"t\" {\n mount-path \"/tmp/t\"\n}", "under /run/secrets/"), |
|
| 290 | + | ("secret \"t\" {\n mount-path \"/run/secrets/../x\"\n}", "under /run/secrets/"), |
|
| 291 | + | ("secret \"t\" {\n}", "needs a mount-path"), |
|
| 292 | + | ("secret \"t\" {\n mount-path \"/run/secrets/a\"\n}\nsecret \"t\" {\n mount-path \"/run/secrets/b\"\n}", "given twice"), |
|
| 293 | + | ( |
|
| 294 | + | "secret \"t\" {\n mount-path \"/run/secrets/a\"\n}\nsecret \"u\" {\n mount-path \"/run/secrets/a\"\n}", |
|
| 295 | + | "two secrets mount at", |
|
| 296 | + | ), |
|
| 297 | + | ("secret \"t\" {\n vault-ref \"x\"\n mount-path \"/run/secrets/a\"\n}", "vault-ref"), |
|
| 298 | + | ] { |
|
| 299 | + | let e = Pipeline::parse(&doc(&format!("job \"d\" {{\n sandbox \"alpine\"\n {bad}\n exec \"deploy\"\n}}\n"))).unwrap_err(); |
|
| 300 | + | assert!(e.to_string().contains(why), "{bad}: {e}"); |
|
| 301 | + | } |
|
| 302 | + | } |
|
| 303 | + | ||
| 304 | + | #[test] |
|
| 265 | 305 | fn jobs_are_told_what_they_build() { |
|
| 266 | 306 | let p = Pipeline::parse(&doc(r#"job "build" { |
|
| 267 | 307 | sandbox "alpine" |
|
| … | 20 unchanged lines |
||
| 288 | 328 | bookmark: "feature/x".into(), |
|
| 289 | 329 | run_number: 3, |
|
| 290 | 330 | env: [("CC".to_string(), "clang".to_string()), ("FLOW_REPO".to_string(), "spoofed".to_string())].into_iter().collect(), |
|
| 331 | + | secrets: vec![], |
|
| 291 | 332 | }; |
|
| 292 | 333 | let env = job.with_flow_env(&claim.flow_env()).env; |
|
| 293 | 334 | assert_eq!(env["CC"], "clang", "the hub's variables reach the job"); |
|
| … | 4 unchanged lines |
||
| 298 | 339 | assert_eq!(env["FLOW_JOB"], "build"); |
|
| 299 | 340 | assert_eq!(env["FLOW_BOOKMARK"], "pinned-by-the-pipeline", "the pipeline's own value wins"); |
|
| 300 | 341 | // Unknown values (an older hub) are left out, not empty. |
|
| 301 | - | let old = crate::api::Claim { state: String::new(), bookmark: String::new(), run_number: 0, env: Default::default(), ..claim }; |
|
| 342 | + | let old = crate::api::Claim { |
|
| 343 | + | state: String::new(), |
|
| 344 | + | bookmark: String::new(), |
|
| 345 | + | run_number: 0, |
|
| 346 | + | env: Default::default(), |
|
| 347 | + | secrets: vec![], |
|
| 348 | + | ..claim |
|
| 349 | + | }; |
|
| 302 | 350 | let names: Vec<String> = old.flow_env().into_iter().map(|(k, _)| k).collect(); |
|
| 303 | 351 | assert_eq!(names, ["FLOW_REPO", "FLOW_HEADS", "FLOW_JOB"]); |
|
| 304 | 352 | } |
|
| … | 113 unchanged lines |
modified crates/flow-init/src/main.rs +33 −0
| … | 23 unchanged lines |
||
| 24 | 24 | /// `network "loopback"`: bring up `lo` (the guest has no other device). |
|
| 25 | 25 | #[serde(default)] |
|
| 26 | 26 | loopback: bool, |
|
| 27 | + | /// Secrets to write before the job starts (spec §9): each at `path` |
|
| 28 | + | /// under `/run/secrets`, 0400 and the job's user's, on a tmpfs. |
|
| 29 | + | #[serde(default)] |
|
| 30 | + | secrets: Vec<SecretSpec>, |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | #[derive(serde::Deserialize)] |
|
| 34 | + | struct SecretSpec { |
|
| 35 | + | path: String, |
|
| 36 | + | value: String, |
|
| 27 | 37 | } |
|
| 28 | 38 | ||
| 29 | 39 | /// An ephemeral mount from the host: a tmpfs of `size_mib`, or the disk at |
|
| … | 196 unchanged lines |
||
| 226 | 236 | chown(&target, uid, gid)?; |
|
| 227 | 237 | } |
|
| 228 | 238 | (kind, _) => return Err(format!("unknown mount kind {kind}")), |
|
| 239 | + | } |
|
| 240 | + | } |
|
| 241 | + | // Secrets: an in-memory mount of their own, only the job's user's, |
|
| 242 | + | // each file 0400; the job file they came from is outside the new root. |
|
| 243 | + | if !job.secrets.is_empty() { |
|
| 244 | + | if !std::path::Path::new("/newroot/run").is_dir() { |
|
| 245 | + | return Err("the image has no /run, where secrets are mounted".into()); |
|
| 246 | + | } |
|
| 247 | + | let dir = "/newroot/run/secrets"; |
|
| 248 | + | std::fs::create_dir_all(dir).map_err(|e| format!("mkdir {dir}: {e}"))?; |
|
| 249 | + | mount("tmpfs", dir, "tmpfs", nosuid | libc::MS_NOEXEC, &format!("size=1m,uid={uid},gid={gid},mode=700"))?; |
|
| 250 | + | for s in &job.secrets { |
|
| 251 | + | let Some(rel) = s.path.strip_prefix("/run/secrets/") else { |
|
| 252 | + | return Err(format!("secret path {:?} is not under /run/secrets/", s.path)); |
|
| 253 | + | }; |
|
| 254 | + | let full = format!("{dir}/{rel}"); |
|
| 255 | + | if let Some(parent) = std::path::Path::new(&full).parent().and_then(|p| p.to_str()) { |
|
| 256 | + | make_dirs_owned(parent, uid, gid)?; |
|
| 257 | + | } |
|
| 258 | + | std::fs::write(&full, s.value.as_bytes()).map_err(|e| format!("write {}: {e}", s.path))?; |
|
| 259 | + | chown(&full, uid, gid)?; |
|
| 260 | + | std::fs::set_permissions(&full, std::os::unix::fs::PermissionsExt::from_mode(0o400)) |
|
| 261 | + | .map_err(|e| format!("chmod {}: {e}", s.path))?; |
|
| 229 | 262 | } |
|
| 230 | 263 | } |
|
| 231 | 264 | if job.loopback { |
|
| … | 145 unchanged lines |
modified crates/flow/src/main.rs +36 −12
| … | 76 unchanged lines |
||
| 77 | 77 | /// the pipeline's own `env` wins |
|
| 78 | 78 | #[arg(long = "var", value_name = "NAME=VALUE")] |
|
| 79 | 79 | vars: Vec<String>, |
|
| 80 | + | /// A secret a job's `secret` block names, as the hub would supply |
|
| 81 | + | /// it; mounted as a file, never in the environment |
|
| 82 | + | #[arg(long = "secret", value_name = "NAME=VALUE")] |
|
| 83 | + | secrets: Vec<String>, |
|
| 80 | 84 | }, |
|
| 81 | 85 | /// Claim and run jobs for the hub in flow.toml's [runner] (or the |
|
| 82 | 86 | /// runner.toml `flow runner register` wrote) |
|
| … | 63 unchanged lines |
||
| 146 | 150 | } |
|
| 147 | 151 | } |
|
| 148 | 152 | } |
|
| 149 | - | Cmd::Run { jobs, dir, timeout, vars } => { |
|
| 153 | + | Cmd::Run { jobs, dir, timeout, vars, secrets } => { |
|
| 150 | 154 | let home = Home::locate(cli.home.as_deref())?; |
|
| 151 | - | let vars = vars |
|
| 152 | - | .iter() |
|
| 153 | - | .map(|e| { |
|
| 154 | - | e.split_once('=') |
|
| 155 | - | .map(|(k, v)| (k.to_string(), v.to_string())) |
|
| 156 | - | .ok_or_else(|| anyhow::anyhow!("--var {e:?}: expected NAME=VALUE")) |
|
| 157 | - | }) |
|
| 158 | - | .collect::<Result<Vec<_>>>()?; |
|
| 159 | - | run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars) |
|
| 155 | + | let pairs = |what: &str, list: &[String]| { |
|
| 156 | + | list.iter() |
|
| 157 | + | .map(|e| { |
|
| 158 | + | e.split_once('=') |
|
| 159 | + | .map(|(k, v)| (k.to_string(), v.to_string())) |
|
| 160 | + | .ok_or_else(|| anyhow::anyhow!("--{what} {e:?}: expected NAME=VALUE")) |
|
| 161 | + | }) |
|
| 162 | + | .collect::<Result<Vec<_>>>() |
|
| 163 | + | }; |
|
| 164 | + | let (vars, secrets) = (pairs("var", &vars)?, pairs("secret", &secrets)?); |
|
| 165 | + | run_local(&home, &dir, &jobs, Duration::from_secs(timeout * 60), &vars, &secrets) |
|
| 160 | 166 | } |
|
| 161 | 167 | Cmd::Runner { cmd: Some(RunnerCmd::Register { hub, token, name, ca, label }) } => { |
|
| 162 | 168 | let home = Home::locate(cli.home.as_deref())?; |
|
| … | 123 unchanged lines |
||
| 286 | 292 | Ok(()) |
|
| 287 | 293 | } |
|
| 288 | 294 | ||
| 289 | - | fn run_local(home: &Home, dir: &Path, only: &[String], timeout: Duration, vars: &[(String, String)]) -> Result<()> { |
|
| 295 | + | fn run_local( |
|
| 296 | + | home: &Home, |
|
| 297 | + | dir: &Path, |
|
| 298 | + | only: &[String], |
|
| 299 | + | timeout: Duration, |
|
| 300 | + | vars: &[(String, String)], |
|
| 301 | + | secrets: &[(String, String)], |
|
| 302 | + | ) -> Result<()> { |
|
| 290 | 303 | let p = load(&dir.join(PIPELINE_PATH))?; |
|
| 291 | 304 | // The named jobs and everything they depend on. |
|
| 292 | 305 | let mut wanted: BTreeSet<String> = only.iter().cloned().collect(); |
|
| … | 39 unchanged lines |
||
| 332 | 345 | .in_sandbox(sb) |
|
| 333 | 346 | .with_flow_env(&[("FLOW_JOB".into(), job.name.clone()), ("FLOW_RUN".into(), "local".into())]) |
|
| 334 | 347 | .with_flow_env(vars); |
|
| 335 | - | let out = microvm::run(home, sb, job, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?; |
|
| 348 | + | // Each secret the job declares must be given, as the hub would. |
|
| 349 | + | let inputs = job |
|
| 350 | + | .secrets |
|
| 351 | + | .iter() |
|
| 352 | + | .map(|s| { |
|
| 353 | + | let value = secrets.iter().find(|(k, _)| *k == s.name).map(|(_, v)| v.clone()); |
|
| 354 | + | value |
|
| 355 | + | .map(|value| flow_core::api::SecretInput { name: s.name.clone(), path: s.mount_path.clone(), value }) |
|
| 356 | + | .ok_or_else(|| anyhow::anyhow!("job {}: secret {:?} is not given; pass --secret {}=VALUE", job.name, s.name, s.name)) |
|
| 357 | + | }) |
|
| 358 | + | .collect::<Result<Vec<_>>>()?; |
|
| 359 | + | let out = microvm::run(home, sb, job, &inputs, &js.join("workspace.ext4"), &js, timeout, &mut |r: Record| print_record(&r))?; |
|
| 336 | 360 | let _ = std::fs::remove_dir_all(&js); |
|
| 337 | 361 | match out.exit_code { |
|
| 338 | 362 | Some(0) => println!("── {} passed in {} ms", job.name, out.duration_ms), |
|
| … | 41 unchanged lines |
modified crates/flow/src/microvm.rs +9 −1
| … | 4 unchanged lines |
||
| 5 | 5 | ||
| 6 | 6 | use crate::home::Home; |
|
| 7 | 7 | use anyhow::{bail, Context, Result}; |
|
| 8 | - | use flow_core::api::Record; |
|
| 8 | + | use flow_core::api::{Record, SecretInput}; |
|
| 9 | 9 | use flow_core::{Environment, Job, MountSource, Sandbox}; |
|
| 10 | 10 | use std::io::{BufRead, BufReader, Read}; |
|
| 11 | 11 | use std::path::Path; |
|
| … | 30 unchanged lines |
||
| 42 | 42 | } |
|
| 43 | 43 | ||
| 44 | 44 | /// Boot, run, collect. `scratch` is an empty directory this call may fill. |
|
| 45 | + | #[allow(clippy::too_many_arguments)] |
|
| 45 | 46 | pub fn run( |
|
| 46 | 47 | home: &Home, |
|
| 47 | 48 | sandbox: &Sandbox, |
|
| 48 | 49 | job: &Job, |
|
| 50 | + | secrets: &[SecretInput], |
|
| 49 | 51 | workspace: &Path, |
|
| 50 | 52 | scratch: &Path, |
|
| 51 | 53 | timeout: Duration, |
|
| … | 12 unchanged lines |
||
| 64 | 66 | ||
| 65 | 67 | let mut spec = guest_spec(job); |
|
| 66 | 68 | spec["loopback"] = serde_json::Value::Bool(sandbox.network == flow_core::Network::Loopback); |
|
| 69 | + | // Secrets ride in the guest's job file, which only flow-init reads, |
|
| 70 | + | // outside the root the job is confined to; it writes each as a file. |
|
| 71 | + | if !secrets.is_empty() { |
|
| 72 | + | spec["secrets"] = |
|
| 73 | + | serde_json::Value::Array(secrets.iter().map(|s| serde_json::json!({ "path": s.path, "value": s.value })).collect()); |
|
| 74 | + | } |
|
| 67 | 75 | // Each `ephemeral://disk` mount is a fresh sparse ext4 disk in the |
|
| 68 | 76 | // scratch directory, attached after the workspace (vdc, vdd, ...). |
|
| 69 | 77 | let mut disks = Vec::new(); |
|
| … | 157 unchanged lines |
modified crates/flow/src/outputs.rs +1 −0
| … | 68 unchanged lines |
||
| 69 | 69 | depends_on: vec![], |
|
| 70 | 70 | env: Default::default(), |
|
| 71 | 71 | exec: vec!["make".into()], |
|
| 72 | + | secrets: vec![], |
|
| 72 | 73 | mounts: vec![ |
|
| 73 | 74 | Mount { source: MountSource::Tmpfs, target: "/src/cache".into(), size_mib: 64 }, |
|
| 74 | 75 | Mount { source: MountSource::Disk, target: "/src/target".into(), size_mib: 1024 }, |
|
| … | 32 unchanged lines |
modified crates/flow/src/runner.rs +1 −0
| … | 442 unchanged lines |
||
| 443 | 443 | home, |
|
| 444 | 444 | &claim.sandbox, |
|
| 445 | 445 | &job, |
|
| 446 | + | &claim.secrets, |
|
| 446 | 447 | &scratch.join("workspace.ext4"), |
|
| 447 | 448 | &scratch, |
|
| 448 | 449 | Duration::from_secs(claim.timeout_s), |
|
| … | 265 unchanged lines |
modified docs/m0.md +10 −2
| … | 52 unchanged lines |
||
| 53 | 53 | - Names are 1 to 64 characters from `[A-Za-z0-9._-]`. |
|
| 54 | 54 | - `exec` becomes `argv` unchanged: metacharacters are bytes (spec §10.1). |
|
| 55 | 55 | A job that wants a shell names one, as `sh` above; nothing adds one. |
|
| 56 | - | - `publish` and `secret` are recognised and refused with "not in M0", so |
|
| 57 | - | a pipeline written for the full spec fails clearly. |
|
| 56 | + | - `publish` and a job's `attest` are recognised and refused with "not in |
|
| 57 | + | M0", so a pipeline written for the full spec fails clearly. |
|
| 58 | + | - `secret "token" { mount-path "/run/secrets/token" }` on a job (up to 8) |
|
| 59 | + | names a secret the hub holds for the repository (its own, its |
|
| 60 | + | organisation's or the site's; oxygenhub#107) and where the job reads it: |
|
| 61 | + | a clean path under `/run/secrets/`. The claim carries each as `secrets` |
|
| 62 | + | and `flow-init` writes it there, 0400 and the job's user's, on a tmpfs |
|
| 63 | + | of its own, before the job starts; it is never in the environment, the |
|
| 64 | + | receipt or an artifact (spec §9). Locally, `flow run --secret |
|
| 65 | + | NAME=VALUE` supplies it. `vault-ref` is not in M0: the hub is the vault. |
|
| 58 | 66 | - `output { artifact "oxy" { path "/src/target/release/oxy" } }` declares |
|
| 59 | 67 | up to 16 **artifacts** (spec §5.2). The path is clean, absolute, under |
|
| 60 | 68 | `/src`, and not on a tmpfs mount, which is gone when the job ends. After |
|
| … | 158 unchanged lines |