swarm-builder: scope the forge token to the forge, one build at a time, opt-in
argus's review on #5177: the git credential helper answered every `get`, so a config PR's own flake inputs could have git send the builder's forge token to any host. The helper is now configured under `credential.<forge-url>.helper` and itself answers only an https request whose host is the forge's; a test runs it as git does and checks that other hosts, http and non-get operations get nothing. Builds are bounded: every `build_agent` node needs the one `builder` jobq resource, so one runs at a time and the rest wait pending, and a newer head cancels its PR's pending build (a running one finishes and writes on its own commit). The builder also holds a one-permit semaphore around each build. `builder` and `review-classifier`, the forge logins swarm-controller owns, join nix/reserved-names.nix, with a test tying the list to both constants. The builder is now opt-in like the CI runner: `allSwarmServices` no longer turns it on, since it runs unreviewed config PRs through the host's nix daemon. mara had not answered atlas's question on the PR.
This commit is contained in:
parent
b68b74d687
commit
cafa120d77
13 changed files with 360 additions and 70 deletions
|
|
@ -27,5 +27,6 @@ The container holds one credential: its own store certificate, which nspawn
|
|||
hands in at start without a copy on disk. With it the service reads its
|
||||
queue client secret at start and its read-only forge token at every build.
|
||||
`git` gets the token through a credential helper reading the child's
|
||||
environment, never in a URL or an argument. Its store role reads exactly
|
||||
environment, never in a URL or an argument, and the helper answers only an
|
||||
`https` request for the forge's host. Its store role reads exactly
|
||||
those two paths.
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ async fn main() -> Result<()> {
|
|||
check_flake: var("CHECK_FLAKE")?,
|
||||
hyperhive: var("HYPERHIVE_FLAKE")?,
|
||||
nixpkgs: var("NIXPKGS_FLAKE")?,
|
||||
forge_url: var("FORGE_URL")?.trim_end_matches('/').to_owned(),
|
||||
forge_url: forge_url()?,
|
||||
config_org: var("CONFIG_ORG")?,
|
||||
deadline: BUILD_DEADLINE,
|
||||
});
|
||||
|
|
@ -74,14 +74,22 @@ async fn main() -> Result<()> {
|
|||
.with_context(|| format!("subscribing to {SUBJECT}"))?;
|
||||
tracing::info!(subject = SUBJECT, "builder: awaiting builds");
|
||||
|
||||
// One build at a time. swarm-controller already sends one at a time; this
|
||||
// holds the bound for any request it did not serialise.
|
||||
let slot = Arc::new(tokio::sync::Semaphore::new(1));
|
||||
|
||||
while let Some(msg) = requests.next().await {
|
||||
let Some(reply_to) = msg.reply.clone() else {
|
||||
tracing::warn!("a build arrived with no reply subject; dropping it");
|
||||
continue;
|
||||
};
|
||||
let queue = queue.clone();
|
||||
let slot = Arc::clone(&slot);
|
||||
let builder = Arc::clone(&builder);
|
||||
tokio::spawn(async move {
|
||||
let Ok(_permit) = slot.acquire().await else {
|
||||
return;
|
||||
};
|
||||
let reply = handle(&msg.payload, &builder).await;
|
||||
match serde_json::to_vec(&reply) {
|
||||
Ok(body) => {
|
||||
|
|
@ -188,3 +196,15 @@ async fn client_secret(client_id: &str) -> Result<String> {
|
|||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The forge's base URL, which must be `https`: the credential helper offers
|
||||
/// the token to `https` requests for its host alone.
|
||||
fn forge_url() -> Result<String> {
|
||||
let name = format!("{PREFIX}_FORGE_URL");
|
||||
let url = std::env::var(&name).with_context(|| format!("{name} is unset"))?;
|
||||
let url = url.trim_end_matches('/');
|
||||
if !url.starts_with("https://") || url["https://".len()..].contains('/') {
|
||||
bail!("{name} is {url:?}, not https://<host>[:port]");
|
||||
}
|
||||
Ok(url.to_owned())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,11 @@
|
|||
//! token. The token reaches `git` through a credential helper that reads it
|
||||
//! from the child's environment: it is in no argument, no URL and no lock, so
|
||||
//! no error line `nix` prints can carry it into the reply.
|
||||
//!
|
||||
//! ⚠️ A config's own flake inputs are fetched by the same `git`, from hosts the
|
||||
//! config names. The helper is configured for the forge's URL only, and itself
|
||||
//! answers only an `https` request for the forge's host, so the token never
|
||||
//! goes anywhere else.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
|
@ -20,6 +25,10 @@ const TAIL_BYTES: usize = 4000;
|
|||
/// The variable the credential helper reads the token from, in the child only.
|
||||
const TOKEN_VAR: &str = "SWARM_BUILDER_GIT_TOKEN";
|
||||
|
||||
/// The variable holding the one host the helper answers for, in the child
|
||||
/// only.
|
||||
const HOST_VAR: &str = "SWARM_BUILDER_GIT_HOST";
|
||||
|
||||
/// What every build is run with, fixed at start.
|
||||
pub struct Builder {
|
||||
/// The `nix` binary.
|
||||
|
|
@ -30,7 +39,7 @@ pub struct Builder {
|
|||
pub hyperhive: String,
|
||||
/// The nixpkgs source this host was deployed from.
|
||||
pub nixpkgs: String,
|
||||
/// `https://<forge>`, with no trailing slash.
|
||||
/// `https://<forge>`, with no trailing slash and no path.
|
||||
pub forge_url: String,
|
||||
/// The org the config repos are in.
|
||||
pub config_org: String,
|
||||
|
|
@ -49,7 +58,11 @@ impl Builder {
|
|||
.env(TOKEN_VAR, token)
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_CONFIG_COUNT", "1")
|
||||
.env("GIT_CONFIG_KEY_0", "credential.helper")
|
||||
.env(HOST_VAR, forge_host(&self.forge_url))
|
||||
.env(
|
||||
"GIT_CONFIG_KEY_0",
|
||||
format!("credential.{}.helper", self.forge_url),
|
||||
)
|
||||
.env("GIT_CONFIG_VALUE_0", credential_helper())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
|
|
@ -97,11 +110,22 @@ impl Builder {
|
|||
}
|
||||
}
|
||||
|
||||
/// A `git` credential helper answering every `get` with the builder's login
|
||||
/// and the token from [`TOKEN_VAR`].
|
||||
/// The host part of `forge_url` (`https://<host>[:port]`), as `git` names it
|
||||
/// to a credential helper.
|
||||
fn forge_host(forge_url: &str) -> &str {
|
||||
let rest = forge_url.strip_prefix("https://").unwrap_or(forge_url);
|
||||
rest.split('/').next().unwrap_or(rest)
|
||||
}
|
||||
|
||||
/// A `git` credential helper that answers a `get` with the builder's login and
|
||||
/// the token from [`TOKEN_VAR`] only when the request is `https` to the host in
|
||||
/// [`HOST_VAR`], and with nothing otherwise.
|
||||
fn credential_helper() -> String {
|
||||
format!(
|
||||
"!f() {{ test \"$1\" = get && printf 'username=builder\\npassword=%s\\n' \"${TOKEN_VAR}\"; }}; f"
|
||||
"!f() {{ test \"$1\" = get || return 0; p=; h=; \
|
||||
while IFS='=' read -r k v; do case \"$k\" in '') break ;; protocol) p=$v ;; host) h=$v ;; esac; done; \
|
||||
test -n \"${HOST_VAR}\" && test \"$p\" = https && test \"$h\" = \"${HOST_VAR}\" \
|
||||
&& printf 'username=builder\\npassword=%s\\n' \"${TOKEN_VAR}\"; return 0; }}; f"
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -240,6 +264,61 @@ mod tests {
|
|||
assert!(args.iter().any(|a| a == "--no-write-lock-file"));
|
||||
}
|
||||
|
||||
/// What the helper prints for `op` and a request with `protocol` and
|
||||
/// `host`, run the way `git` runs a `!` helper.
|
||||
fn helper_answer(op: &str, protocol: &str, host: &str) -> String {
|
||||
use std::io::Write as _;
|
||||
|
||||
let helper = credential_helper();
|
||||
let script = format!("{} {op}", helper.trim_start_matches('!'));
|
||||
let mut child = std::process::Command::new("sh")
|
||||
.args(["-c", &script])
|
||||
.env(TOKEN_VAR, "s3cret")
|
||||
.env(HOST_VAR, "forge.example:3000")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("sh runs");
|
||||
write!(
|
||||
child.stdin.take().expect("piped"),
|
||||
"protocol={protocol}\nhost={host}\npath=agent-configs/atlas\n\n"
|
||||
)
|
||||
.expect("the helper reads its request");
|
||||
let out = child.wait_with_output().expect("the helper exits");
|
||||
assert!(out.status.success());
|
||||
String::from_utf8(out.stdout).expect("utf-8")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_helper_gives_the_token_to_the_forge_alone() {
|
||||
assert_eq!(
|
||||
helper_answer("get", "https", "forge.example:3000"),
|
||||
"username=builder\npassword=s3cret\n"
|
||||
);
|
||||
for (op, protocol, host) in [
|
||||
("get", "https", "github.com"),
|
||||
("get", "https", "forge.example"),
|
||||
("get", "https", "forge.example:3000.evil.example"),
|
||||
("get", "http", "forge.example:3000"),
|
||||
("store", "https", "forge.example:3000"),
|
||||
] {
|
||||
assert_eq!(
|
||||
helper_answer(op, protocol, host),
|
||||
"",
|
||||
"{op} {protocol}://{host}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_helper_is_configured_for_the_forge_url_only() {
|
||||
assert_eq!(
|
||||
forge_host("https://forge.example:3000"),
|
||||
"forge.example:3000"
|
||||
);
|
||||
assert_eq!(forge_host("https://forge.example/sub"), "forge.example");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_in_no_argument() {
|
||||
let args = builder().args(&job()).join(" ");
|
||||
|
|
|
|||
Loading…
Reference in a new issue