Skip to content

Commit 4a07eb2

Browse files
authored
Merge pull request #50 from BotCoder254/feat/event-driven-orchestration
Add toolchain catalog and image allowlist
2 parents 0bfc522 + bf259ba commit 4a07eb2

19 files changed

Lines changed: 1010 additions & 42 deletions

File tree

‎backend/.env.example‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,27 @@ RUNNER_JOB_SIGNING_KEY=
7676
# Node/npm/yarn, Python, git, build-essential etc. preinstalled
7777
# (~500 MB compressed). It does NOT ship every toolchain — notably no Rust
7878
# (`cargo: not found`) or Go — so a job needing one pins its own image in
79-
# workflow YAML with the GitHub `container:` key, e.g. `container: rust:1`,
80-
# `container: node:22`, `container: golang:1.23`. (`uses: actions/setup-*`
81-
# steps are not executed; overup emits a plan notice pointing at the
82-
# equivalent `container:` pin. `uses: actions/checkout` is a no-op — the
79+
# workflow YAML with the GitHub `container:` key. overup understands short
80+
# TOOLCHAIN ALIASES for the catthehacker specialized images: `container: rust`,
81+
# `container: js`, `container: go`, `container: dotnet`, `container: java`,
82+
# `container: pwsh`, `container: gh` (plus `act` and `full`). An alias pairs
83+
# with the job's Ubuntu version (`container: rust` + `runs-on: ubuntu-22.04`
84+
# → the rust-22.04 image). Full references like `container: node:22` still
85+
# work. See the Toolchains page in the UI for the full catalog. (`uses:
86+
# actions/setup-*` steps are not executed; overup emits a plan notice pointing
87+
# at the equivalent toolchain alias. `uses: actions/checkout` is a no-op — the
8388
# runner checks the repository out into /workspace automatically.)
8489
DEFAULT_JOB_IMAGE=catthehacker/ubuntu:act-latest
8590

91+
# Optional hardening: restrict which images a job `container:` may run.
92+
# Comma-separated; each entry is an exact image reference or a trailing-`*`
93+
# prefix glob (e.g. `catthehacker/*`, `node:*`, `ghcr.io/acme/*`). UNSET =
94+
# allow any syntactically valid reference (the default). When set, a
95+
# `container:` image outside this list (toolchain-catalog images are always
96+
# allowed) is rejected at plan time and the job falls back to DEFAULT_JOB_IMAGE
97+
# with a visible plan notice — it never fails the job.
98+
#RUNNER_IMAGE_ALLOWLIST=catthehacker/*
99+
86100
# --- Secrets Management (optional but recommended) ---------------------------
87101
# Master key for envelope-encrypting workspace/repository secrets (exactly
88102
# 32 bytes, hex- or base64-encoded). Generate with: openssl rand -hex 32
@@ -127,8 +141,11 @@ DEFAULT_JOB_IMAGE=catthehacker/ubuntu:act-latest
127141
# failures are warn-only and never affect hosted-runner availability.
128142
# NOTE: this is the way to "bake" job images into a deployment — the backend
129143
# Dockerfile cannot do it (images live in the host daemon's store, not
130-
# inside the backend image).
131-
#RUNNER_PREPULL_IMAGES=catthehacker/ubuntu:act-latest
144+
# inside the backend image). To warm the language toolchains surfaced on the
145+
# UI Toolchains page, list the ones your workflows use (they show as
146+
# "Prewarmed"; capped at 20 entries, and the ~60 GB `full-*` image is best
147+
# left out):
148+
#RUNNER_PREPULL_IMAGES=catthehacker/ubuntu:act-latest,catthehacker/ubuntu:rust-latest,catthehacker/ubuntu:js-latest,catthehacker/ubuntu:go-latest
132149
# DOCKER_HOST passed through to runner containers so they can execute jobs.
133150
# SECURITY: leave unset to mount /var/run/docker.sock into runner containers
134151
# instead — that mount is root-equivalent on the host, so prefer a

‎backend/src/config.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ pub struct Config {
4646
/// Container image used when a job declares no container and no known
4747
/// runs-on label.
4848
pub default_job_image: String,
49+
/// Optional allow-list of images a job's `container:` may run. Empty =
50+
/// allow any syntactically valid reference (default). When non-empty, a
51+
/// `container:` image outside this list (and outside the toolchain
52+
/// catalog) is rejected at plan time and the job falls back to the
53+
/// default image with a notice. Entries support a trailing `*` prefix
54+
/// glob (e.g. `catthehacker/*`, `node:*`).
55+
pub image_allowlist: Vec<String>,
4956
pub job_timeout_seconds: i32,
5057
pub pipeline_timeout_seconds: i32,
5158
pub max_log_bytes_per_job: i64,
@@ -277,6 +284,12 @@ impl Config {
277284
// no toolchains, so real workflows die on `npm: not found`.
278285
let default_job_image = optional("DEFAULT_JOB_IMAGE", "catthehacker/ubuntu:act-latest");
279286

287+
// Optional hardening: restrict which images a job `container:` may run.
288+
// Unset = allow any valid reference (the historical behavior). Parsed
289+
// with the same caps as the prepull list so a malformed deployment
290+
// fails loudly at startup.
291+
let image_allowlist = parse_image_list("RUNNER_IMAGE_ALLOWLIST")?;
292+
280293
// Hosted-runner provisioning is opt-in: the server needs Docker
281294
// access and a URL that provisioned containers can reach it on.
282295
let runner_provisioner = match optional("RUNNER_PROVISIONER", "").as_str() {
@@ -431,6 +444,7 @@ impl Config {
431444
.context("GITHUB_CHECKS_ENABLED must be true or false")?,
432445
runner_job_signing_key,
433446
default_job_image,
447+
image_allowlist,
434448
job_timeout_seconds: optional("JOB_TIMEOUT_SECONDS", "3600")
435449
.parse()
436450
.context("JOB_TIMEOUT_SECONDS must be an integer")?,
@@ -556,6 +570,30 @@ fn optional(key: &str, default: &str) -> String {
556570
std::env::var(key).unwrap_or_else(|_| default.to_string())
557571
}
558572

573+
/// Parse a comma-separated image-reference env var (unset/empty = empty list),
574+
/// applying the same caps as the prepull list. Entries may end in `*` for a
575+
/// prefix glob, so the length cap is generous enough for a registry path.
576+
fn parse_image_list(key: &str) -> anyhow::Result<Vec<String>> {
577+
let raw = optional(key, "");
578+
let mut images: Vec<String> = Vec::new();
579+
for entry in raw.split(',') {
580+
let entry = entry.trim();
581+
if entry.is_empty() {
582+
continue;
583+
}
584+
if entry.len() > 256 {
585+
anyhow::bail!("{key} entries must be at most 256 characters (got one of {} characters)", entry.len());
586+
}
587+
if !images.iter().any(|existing| existing == entry) {
588+
images.push(entry.to_string());
589+
}
590+
}
591+
if images.len() > 50 {
592+
anyhow::bail!("{key} supports at most 50 entries (got {})", images.len());
593+
}
594+
Ok(images)
595+
}
596+
559597
/// Parse the comma-separated RUNNER_PREPULL_IMAGES value: trim entries, drop
560598
/// empties, dedupe preserving order, and enforce sane caps so a malformed
561599
/// deployment fails at startup instead of spraying pull warnings forever.

‎backend/src/handlers/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ pub mod search;
1919
pub mod runners;
2020
pub mod secrets;
2121
pub mod sessions;
22+
pub mod toolchains;
2223
pub mod workflows;
2324
pub mod workspaces;
2425
pub mod ws_tickets;

‎backend/src/handlers/toolchains.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//! Toolchains API: a read-only catalog of the language-toolchain images
2+
//! overup understands (`services::toolchain_images`), tagged with the
3+
//! deployment's default image, prewarm state, and whether an image allow-list
4+
//! is active. Rides `content.read` like the other workspace read surfaces.
5+
//! Static strings only — no mutation surface, no user/runner text.
6+
7+
use axum::Json;
8+
use axum::extract::{Path, State};
9+
use uuid::Uuid;
10+
11+
use crate::error::AppResult;
12+
use crate::middleware::auth::CurrentUser;
13+
use crate::models::toolchain::ToolchainsResponse;
14+
use crate::services::authz;
15+
use crate::state::AppState;
16+
17+
/// GET /api/workspaces/{workspace_id}/toolchains
18+
pub async fn list(
19+
State(state): State<AppState>,
20+
CurrentUser(user): CurrentUser,
21+
Path(workspace_id): Path<Uuid>,
22+
) -> AppResult<Json<ToolchainsResponse>> {
23+
authz::require_permission(&state.pool, user.id, workspace_id, authz::CONTENT_READ).await?;
24+
25+
// Prewarm state comes from the provisioner's prepull list (only present
26+
// when hosted provisioning is configured); otherwise nothing is prewarmed.
27+
let prepull: &[String] = state
28+
.config
29+
.runner_provisioner
30+
.as_ref()
31+
.map(|p| p.prepull_images.as_slice())
32+
.unwrap_or(&[]);
33+
34+
Ok(Json(ToolchainsResponse::build(
35+
state.config.default_job_image.clone(),
36+
prepull,
37+
!state.config.image_allowlist.is_empty(),
38+
)))
39+
}

‎backend/src/models/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod repository;
88
pub mod runner;
99
pub mod search;
1010
pub mod secret;
11+
pub mod toolchain;
1112
pub mod user;
1213
pub mod workflow;
1314
pub mod workspace;

‎backend/src/models/toolchain.rs‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//! API shapes for the language-toolchain catalog (`GET …/toolchains`). The
2+
//! catalog itself is static (`services::toolchain_images`); these DTOs project
3+
//! it, tagging each entry with whether its `-latest` image is prewarmed by the
4+
//! deployment's `RUNNER_PREPULL_IMAGES`. Static strings only — no user or
5+
//! runner text ever reaches here.
6+
7+
use serde::Serialize;
8+
9+
use crate::services::toolchain_images;
10+
11+
#[derive(Debug, Serialize)]
12+
#[serde(rename_all = "camelCase")]
13+
pub struct ToolchainResponse {
14+
pub key: &'static str,
15+
pub label: &'static str,
16+
pub language: &'static str,
17+
pub description: &'static str,
18+
pub tools: Vec<&'static str>,
19+
pub image_latest: &'static str,
20+
pub image2204: &'static str,
21+
pub image2404: &'static str,
22+
/// Very large image (`full-*`) — the UI warns against prewarming it.
23+
pub large: bool,
24+
/// The `-latest` image is in `RUNNER_PREPULL_IMAGES` (warmed on connect).
25+
pub prewarmed: bool,
26+
}
27+
28+
#[derive(Debug, Serialize)]
29+
#[serde(rename_all = "camelCase")]
30+
pub struct ToolchainsResponse {
31+
pub toolchains: Vec<ToolchainResponse>,
32+
/// Image a job falls back to (`DEFAULT_JOB_IMAGE`).
33+
pub default_image: String,
34+
/// Whether `RUNNER_IMAGE_ALLOWLIST` restricts which images may run.
35+
pub allowlist_enabled: bool,
36+
}
37+
38+
impl ToolchainsResponse {
39+
/// Build the response from the static catalog plus the deployment's
40+
/// prepull list and default image.
41+
pub fn build(default_image: String, prepull: &[String], allowlist_enabled: bool) -> Self {
42+
let toolchains = toolchain_images::all()
43+
.iter()
44+
.map(|t| ToolchainResponse {
45+
key: t.key,
46+
label: t.label,
47+
language: t.language,
48+
description: t.description,
49+
tools: t.tools.to_vec(),
50+
image_latest: t.image_latest,
51+
image2204: t.image_2204,
52+
image2404: t.image_2404,
53+
large: t.large,
54+
prewarmed: prepull.iter().any(|p| p == t.image_latest),
55+
})
56+
.collect();
57+
Self {
58+
toolchains,
59+
default_image,
60+
allowlist_enabled,
61+
}
62+
}
63+
}

‎backend/src/routes/mod.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ use crate::error::AppError;
1818
use crate::handlers::{
1919
activity, artifacts, auth, browser_ws, dashboard, dashboard_ws, environments,
2020
github_installations, github_webhooks, health, jobs, me, notification_ws, notifications,
21-
pipelines, repositories, runner_ws, runners, search, secrets, sessions, workflows,
22-
workspaces, ws_tickets,
21+
pipelines, repositories, runner_ws, runners, search, secrets, sessions, toolchains,
22+
workflows, workspaces, ws_tickets,
2323
};
2424
use crate::middleware::{csrf, security_headers};
2525
use crate::services::session;
@@ -371,6 +371,10 @@ pub fn build_router(state: AppState) -> anyhow::Result<Router> {
371371
.patch(environments::update)
372372
.delete(environments::remove),
373373
)
374+
.route(
375+
"/workspaces/{workspace_id}/toolchains",
376+
get(toolchains::list),
377+
)
374378
.route(
375379
"/workspaces/{workspace_id}/runners",
376380
get(runners::list).post(runners::create),

‎backend/src/services/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pub mod scheduler;
2525
pub mod search_indexer;
2626
pub mod secrets_crypto;
2727
pub mod session;
28+
pub mod toolchain_images;
2829
pub mod trigger_eval;
2930
pub mod webhook_processor;
3031
pub mod webhook_stats;

0 commit comments

Comments
 (0)