Skip to content

Commit 1024747

Browse files
andrasbacsaiclaude
andcommitted
feat: collapse builder onto coold's gRPC stream, isolate builds via systemd-run scope
Builder now rides on coold's Agent.Stream rather than holding a second persistent gRPC connection to broker — one stream per host, not two. Coold advertises the "builder" capability in its Hello frame (gated by COOLD_BUILDER_ENABLED) and the broker capability-routes build:cmd envelopes from Redis to a host that carries it. Each build runs as a short-lived builder subprocess wrapped in a `systemd-run --scope` transient unit so it gets cgroup + FS isolation (PrivateTmp, ProtectSystem=strict, ReadWritePaths allowlist, MemoryMax, CPUQuota, RuntimeMaxSec) without requiring an outer container. The subprocess emits NDJSON frames on stdout; coold parses them and relays a final BuildResponse over the existing stream. Cancellation is wired end-to-end: a `{"type":"cancel"}` payload on build:cmd routes via the broker's pending map to the owning host, where coold runs `systemctl kill --signal=SIGTERM <scope>`; the cgroup kill takes the builder + buildah + git down in one sweep. Workspace layout: - New builder-core crate — git + buildah pipeline as a reusable lib. - builder/ stays as a binary but is now a one-shot CLI (no gRPC, no JWT, no broker URL); coold invokes it per request. - broker/:6444 listener and builder.proto deleted; agent.proto gains BuildRequest/CancelBuild/BuildResponseBody plus capabilities + builder_capacity in Hello. - JWT audience collapsed to a single "coold"; per-capability auth moves into a `caps` claim the broker cross-checks against Hello. Breaking: a new coold/broker pair cannot interoperate with the old builder binary or split-port broker. Deploy in lockstep. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 2e84eeb commit 1024747

32 files changed

Lines changed: 1274 additions & 872 deletions

.github/workflows/nightly.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ jobs:
3939
env:
4040
COOLD_VERSION: ${{ needs.prep.outputs.tag }}
4141
BROKER_VERSION: ${{ needs.prep.outputs.tag }}
42-
BUILDER_VERSION: ${{ needs.prep.outputs.tag }}
4342
steps:
4443
- uses: actions/checkout@v4
4544

@@ -115,9 +114,9 @@ jobs:
115114
echo "**Commit:** $(git log -1 --pretty=format:'%s')"
116115
echo
117116
echo "### Artifacts"
118-
echo "- \`coold\` — per-host agent"
119-
echo "- \`broker\` — central-side gRPC stream broker"
120-
echo "- \`builder\` — OCI image build agent"
117+
echo "- \`coold\` — per-host agent (holds the single gRPC stream to broker; spawns builder subprocess per BuildRequest when COOLD_BUILDER_ENABLED=1)"
118+
echo "- \`broker\` — central-side gRPC stream broker (single listener on :6443; capability-aware routing)"
119+
echo "- \`builder\` — short-lived build subprocess invoked by coold under a \`systemd-run --scope\` transient unit"
121120
echo
122121
if [ -n "$PREV" ]; then
123122
echo "### Changes since $PREV"

Cargo.lock

Lines changed: 13 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace]
2-
members = ["coold", "broker", "builder", "proto"]
2+
members = ["coold", "broker", "builder", "builder-core", "proto"]
33
resolver = "2"
44

55
[workspace.package]

broker/examples/fake_coold.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ async fn main() -> Result<()> {
4141
coold_version: "fake-0.1".into(),
4242
schema_min: 1,
4343
schema_max: 1,
44+
capabilities: vec!["coold".into()],
45+
builder_capacity: 0,
4446
})),
4547
})
4648
.await?;
@@ -72,6 +74,9 @@ async fn main() -> Result<()> {
7274
})
7375
.await?;
7476
}
77+
server_msg::Command::Build(_) | server_msg::Command::CancelBuild(_) => {
78+
// fake_coold does not implement the builder capability.
79+
}
7580
}
7681
}
7782

broker/examples/sign_jwt.rs

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
//! Generate ES256 keypair and sign a test JWT.
22
//!
33
//! Usage:
4-
//! cargo run -p broker --example sign_jwt -- <host_id> <priv_pem_out> <pub_pem_out>
4+
//! cargo run -p broker --example sign_jwt -- <host_id> <priv_pem_out> <pub_pem_out> [caps]
5+
//!
6+
//! `caps` is an optional comma-separated capability list; defaults to "coold".
7+
//! Example: `coold,builder` for a host that should accept build dispatches.
58
//!
69
//! Prints the signed JWT to stdout.
710
@@ -15,34 +18,36 @@ use serde::Serialize;
1518
struct Claims {
1619
sub: String,
1720
aud: String,
21+
caps: Vec<String>,
1822
exp: usize,
1923
iat: usize,
2024
}
2125

2226
fn main() -> anyhow::Result<()> {
2327
let args: Vec<String> = std::env::args().collect();
2428
if args.len() < 4 {
25-
eprintln!("usage: sign_jwt <host_id> <priv_pem_out> <pub_pem_out>");
29+
eprintln!("usage: sign_jwt <host_id> <priv_pem_out> <pub_pem_out> [caps]");
2630
std::process::exit(2);
2731
}
2832
let host_id = &args[1];
2933
let priv_path = &args[2];
3034
let pub_path = &args[3];
35+
let caps: Vec<String> = args
36+
.get(4)
37+
.map(|s| s.split(',').map(|c| c.trim().to_string()).filter(|c| !c.is_empty()).collect())
38+
.unwrap_or_else(|| vec!["coold".to_string()]);
3139

32-
// Generate EC P-256 key via openssl CLI (keeps this example tiny).
3340
let status = Command::new("openssl")
3441
.args(["ecparam", "-name", "prime256v1", "-genkey", "-noout", "-out", priv_path])
3542
.status()?;
3643
anyhow::ensure!(status.success(), "openssl ecparam failed");
3744

38-
// Convert to PKCS8 (jsonwebtoken requires PKCS8 for EC).
3945
let pkcs8_path = format!("{priv_path}.pkcs8");
4046
let status = Command::new("openssl")
4147
.args(["pkcs8", "-topk8", "-nocrypt", "-in", priv_path, "-out", &pkcs8_path])
4248
.status()?;
4349
anyhow::ensure!(status.success(), "openssl pkcs8 failed");
4450

45-
// Derive public PEM.
4651
let status = Command::new("openssl")
4752
.args(["ec", "-in", priv_path, "-pubout", "-out", pub_path])
4853
.status()?;
@@ -57,6 +62,7 @@ fn main() -> anyhow::Result<()> {
5762
let claims = Claims {
5863
sub: host_id.clone(),
5964
aud: "coold".into(),
65+
caps,
6066
exp: now + 3600,
6167
iat: now,
6268
};

broker/src/auth.rs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,33 @@ use serde::Deserialize;
66
struct Claims {
77
/// host_id issued by Laravel at enrollment.
88
sub: String,
9+
/// Capabilities this host is authorized for. Always includes "coold";
10+
/// hosts running builds also carry "builder".
11+
#[serde(default)]
12+
caps: Vec<String>,
913
}
1014

11-
/// Verify a per-host JWT against the expected audience ("coold" or "builder").
12-
/// Returns the `sub` claim (caller id) on success.
13-
pub fn verify_jwt(token: &str, public_key_pem: &str, expected_audience: &str) -> Result<String> {
15+
pub struct VerifiedJwt {
16+
pub host_id: String,
17+
pub caps: Vec<String>,
18+
}
19+
20+
/// Verify a per-host JWT. Audience is fixed to "coold" — capability-based
21+
/// authorization (e.g. accepting a build dispatch) is decided from the
22+
/// `caps` claim, not from audience splits.
23+
pub fn verify_jwt(token: &str, public_key_pem: &str) -> Result<VerifiedJwt> {
1424
let key = DecodingKey::from_ec_pem(public_key_pem.as_bytes())
1525
.or_else(|_| DecodingKey::from_rsa_pem(public_key_pem.as_bytes()))
1626
.map_err(|e| anyhow!("load JWT pubkey: {e}"))?;
1727

1828
let mut validation = Validation::new(Algorithm::ES256);
19-
validation.set_audience(&[expected_audience]);
29+
validation.set_audience(&["coold"]);
2030

21-
let data = decode::<Claims>(token, &key, &validation)
22-
.map_err(|e| anyhow!("JWT verification failed: {e}"))?;
31+
let data =
32+
decode::<Claims>(token, &key, &validation).map_err(|e| anyhow!("JWT verification failed: {e}"))?;
2333

24-
Ok(data.claims.sub)
34+
Ok(VerifiedJwt {
35+
host_id: data.claims.sub,
36+
caps: data.claims.caps,
37+
})
2538
}

0 commit comments

Comments
 (0)