Skip to content

Commit 5ea55ff

Browse files
committed
Add installable toolchains with persisted state
Introduces deployment-wide persistence for UI-installed toolchain images via a new `installed_toolchain_images` table and DB module, then wires it into toolchains APIs with install/uninstall endpoints, audit logging, and install lifecycle states (`pending/installed/failed`). The runner provisioner now accepts a DB pool, unions installed toolchains with env prewarm images on reconnect, and adds on-demand pull/remove helpers used by the new handlers. Frontend updates add install/uninstall mutations, polling while installs are pending, richer error messaging, install-status-aware cards and summary metrics, and a new branded `ToolchainLogo` component. Toolchain response types were expanded to include `installStatus`, `installError`, and `installSupported`.
1 parent bf259ba commit 5ea55ff

16 files changed

Lines changed: 735 additions & 80 deletions

File tree

‎backend/.env.example‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,10 @@ DEFAULT_JOB_IMAGE=catthehacker/ubuntu:act-latest
146146
# "Prewarmed"; capped at 20 entries, and the ~60 GB `full-*` image is best
147147
# left out):
148148
#RUNNER_PREPULL_IMAGES=catthehacker/ubuntu:act-latest,catthehacker/ubuntu:rust-latest,catthehacker/ubuntu:js-latest,catthehacker/ubuntu:go-latest
149+
# You can also Install/Uninstall toolchains from the UI Toolchains page — that
150+
# pulls the image onto this daemon now AND persists it, so it re-warms on every
151+
# reconnect ALONGSIDE this env list (no env edit needed). Install/Uninstall
152+
# needs RUNNER_PROVISIONER=docker (below); without it the buttons are disabled.
149153
# DOCKER_HOST passed through to runner containers so they can execute jobs.
150154
# SECURITY: leave unset to mount /var/run/docker.sock into runner containers
151155
# instead — that mount is root-equivalent on the host, so prefer a
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- Toolchains a user has installed (pulled + warmed) from the UI, so the set
2+
-- to prewarm is no longer env-only (RUNNER_PREPULL_IMAGES). The runner Docker
3+
-- daemon is a single deployment-global resource, so this table is global (no
4+
-- workspace_id) — an install is actioned/audited under the calling workspace's
5+
-- content.write, but the pulled image is shared. `toolchain_key` is the catalog
6+
-- alias (e.g. 'rust'); `image` is its resolved -latest reference. `error` holds
7+
-- a static category on a failed pull, never raw daemon text.
8+
CREATE TABLE installed_toolchain_images (
9+
toolchain_key TEXT PRIMARY KEY,
10+
image TEXT NOT NULL,
11+
status TEXT NOT NULL DEFAULT 'pending'
12+
CHECK (status IN ('pending', 'installed', 'failed')),
13+
error TEXT,
14+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
15+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
16+
);

‎backend/src/db/mod.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub mod runners;
1616
pub mod search;
1717
pub mod secrets;
1818
pub mod sessions;
19+
pub mod toolchain_images;
1920
pub mod users;
2021
pub mod webhook_deliveries;
2122
pub mod workflows;

‎backend/src/db/toolchain_images.rs‎

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! Access to `installed_toolchain_images` — the persisted set of toolchain
2+
//! images a user has installed from the UI. The set is global (one runner
3+
//! Docker daemon per deployment); `list_active_images` feeds the provisioner's
4+
//! prewarm union so installed toolchains re-warm on every reconnect. Audit
5+
//! entries for install/uninstall are written by the handler (they carry the
6+
//! calling workspace + actor); this module only owns the row lifecycle.
7+
8+
use chrono::{DateTime, Utc};
9+
use sqlx::PgPool;
10+
11+
/// One installed-toolchain row.
12+
#[derive(Debug, sqlx::FromRow)]
13+
pub struct InstalledToolchainRow {
14+
pub toolchain_key: String,
15+
#[allow(dead_code)]
16+
pub image: String,
17+
pub status: String,
18+
pub error: Option<String>,
19+
#[allow(dead_code)]
20+
pub created_at: DateTime<Utc>,
21+
#[allow(dead_code)]
22+
pub updated_at: DateTime<Utc>,
23+
}
24+
25+
/// Every install row (any status) — for the catalog read.
26+
pub async fn list(pool: &PgPool) -> sqlx::Result<Vec<InstalledToolchainRow>> {
27+
sqlx::query_as::<_, InstalledToolchainRow>(
28+
"SELECT toolchain_key, image, status, error, created_at, updated_at \
29+
FROM installed_toolchain_images",
30+
)
31+
.fetch_all(pool)
32+
.await
33+
}
34+
35+
/// Images to prewarm: installed, plus pending (a pull already in flight — warm
36+
/// it too so a reconnect during install doesn't drop it).
37+
pub async fn list_active_images(pool: &PgPool) -> sqlx::Result<Vec<String>> {
38+
let rows: Vec<(String,)> = sqlx::query_as(
39+
"SELECT image FROM installed_toolchain_images \
40+
WHERE status IN ('installed', 'pending')",
41+
)
42+
.fetch_all(pool)
43+
.await?;
44+
Ok(rows.into_iter().map(|(image,)| image).collect())
45+
}
46+
47+
/// Mark a toolchain as install-pending (idempotent: a re-install or retry
48+
/// resets an existing row to pending and clears any prior error).
49+
pub async fn upsert_pending(pool: &PgPool, key: &str, image: &str) -> sqlx::Result<()> {
50+
sqlx::query(
51+
"INSERT INTO installed_toolchain_images (toolchain_key, image, status) \
52+
VALUES ($1, $2, 'pending') \
53+
ON CONFLICT (toolchain_key) DO UPDATE \
54+
SET image = EXCLUDED.image, status = 'pending', error = NULL, updated_at = now()",
55+
)
56+
.bind(key)
57+
.bind(image)
58+
.execute(pool)
59+
.await?;
60+
Ok(())
61+
}
62+
63+
/// Flip a row to installed after a successful pull. Guards on the row still
64+
/// existing (a race with uninstall drops the update harmlessly).
65+
pub async fn mark_installed(pool: &PgPool, key: &str) -> sqlx::Result<()> {
66+
sqlx::query(
67+
"UPDATE installed_toolchain_images \
68+
SET status = 'installed', error = NULL, updated_at = now() \
69+
WHERE toolchain_key = $1",
70+
)
71+
.bind(key)
72+
.execute(pool)
73+
.await?;
74+
Ok(())
75+
}
76+
77+
/// Flip a row to failed with a static error category.
78+
pub async fn mark_failed(pool: &PgPool, key: &str, error: &str) -> sqlx::Result<()> {
79+
sqlx::query(
80+
"UPDATE installed_toolchain_images \
81+
SET status = 'failed', error = $2, updated_at = now() \
82+
WHERE toolchain_key = $1",
83+
)
84+
.bind(key)
85+
.bind(error)
86+
.execute(pool)
87+
.await?;
88+
Ok(())
89+
}
90+
91+
/// Delete a row (uninstall); returns the removed image so the caller can rmi it.
92+
pub async fn delete(pool: &PgPool, key: &str) -> sqlx::Result<Option<String>> {
93+
let row: Option<(String,)> = sqlx::query_as(
94+
"DELETE FROM installed_toolchain_images WHERE toolchain_key = $1 RETURNING image",
95+
)
96+
.bind(key)
97+
.fetch_optional(pool)
98+
.await?;
99+
Ok(row.map(|(image,)| image))
100+
}

‎backend/src/handlers/toolchains.rs‎

Lines changed: 188 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
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.
1+
//! Toolchains API: a catalog of the language-toolchain images overup
2+
//! understands (`services::toolchain_images`), tagged with the deployment's
3+
//! default image, prewarm/install state, and whether an image allow-list is
4+
//! active. The catalog read rides `content.read`; install/uninstall (an
5+
//! on-demand `docker pull`/`rmi` on the hosted-runner daemon) ride
6+
//! `content.write`, matching hosted-runner management. Image strings are always
7+
//! the static catalog values — no user text ever reaches Docker.
8+
9+
use std::time::Duration;
610

711
use axum::Json;
812
use axum::extract::{Path, State};
13+
use axum::http::{HeaderMap, StatusCode};
14+
use serde_json::json;
915
use uuid::Uuid;
1016

11-
use crate::error::AppResult;
17+
use crate::db;
18+
use crate::error::{AppError, AppResult};
1219
use crate::middleware::auth::CurrentUser;
1320
use crate::models::toolchain::ToolchainsResponse;
1421
use crate::services::authz;
22+
use crate::services::toolchain_images;
1523
use crate::state::AppState;
1624

25+
/// Cap for an on-demand toolchain pull — matches the hosted-create budget.
26+
const PULL_TIMEOUT: Duration = Duration::from_secs(600);
27+
1728
/// GET /api/workspaces/{workspace_id}/toolchains
1829
pub async fn list(
1930
State(state): State<AppState>,
@@ -31,9 +42,180 @@ pub async fn list(
3142
.map(|p| p.prepull_images.as_slice())
3243
.unwrap_or(&[]);
3344

45+
let install_supported = match &state.runner_provisioner {
46+
Some(provisioner) => provisioner.available().await,
47+
None => false,
48+
};
49+
let installed = db::toolchain_images::list(&state.pool).await?;
50+
3451
Ok(Json(ToolchainsResponse::build(
3552
state.config.default_job_image.clone(),
3653
prepull,
3754
!state.config.image_allowlist.is_empty(),
55+
install_supported,
56+
&installed,
3857
)))
3958
}
59+
60+
/// POST /api/workspaces/{workspace_id}/toolchains/{key}/install
61+
pub async fn install(
62+
State(state): State<AppState>,
63+
CurrentUser(user): CurrentUser,
64+
Path((workspace_id, key)): Path<(Uuid, String)>,
65+
headers: HeaderMap,
66+
) -> AppResult<StatusCode> {
67+
authz::require_permission(&state.pool, user.id, workspace_id, authz::CONTENT_WRITE).await?;
68+
69+
// Only known catalog keys install, and only their static -latest image.
70+
let toolchain = toolchain_images::all()
71+
.iter()
72+
.find(|t| t.key == key)
73+
.ok_or(AppError::NotFound)?;
74+
let image = toolchain.image_latest;
75+
76+
// Pulls need a live provisioner daemon. Static category the UI maps to copy.
77+
let Some(provisioner) = state.runner_provisioner.clone() else {
78+
return Err(AppError::Conflict("hosted_runner_unavailable"));
79+
};
80+
if !provisioner.available().await {
81+
return Err(AppError::Conflict("hosted_runner_unavailable"));
82+
}
83+
84+
let request_id = headers.get("x-request-id").and_then(|v| v.to_str().ok());
85+
db::toolchain_images::upsert_pending(&state.pool, toolchain.key, image).await?;
86+
record_audit(
87+
&state,
88+
workspace_id,
89+
user.id,
90+
"toolchain.install_requested",
91+
toolchain.key,
92+
image,
93+
request_id,
94+
)
95+
.await?;
96+
97+
// The pull can take minutes — run it off the request path and let the row
98+
// status (observed via the catalog GET) drive the UI.
99+
let pool = state.pool.clone();
100+
let tc_key = toolchain.key.to_string();
101+
let img = image.to_string();
102+
tokio::spawn(async move {
103+
let outcome = tokio::time::timeout(PULL_TIMEOUT, provisioner.pull(&img)).await;
104+
match outcome {
105+
Ok(Ok(())) => {
106+
let _ = db::toolchain_images::mark_installed(&pool, &tc_key).await;
107+
let _ = insert_audit(&pool, workspace_id, Some(user.id), "toolchain.installed", &tc_key, &img).await;
108+
}
109+
Ok(Err(err)) => {
110+
tracing::warn!(key = %tc_key, error = ?err, "toolchain install pull failed");
111+
let _ = db::toolchain_images::mark_failed(&pool, &tc_key, "image_pull_failed").await;
112+
let _ = insert_audit(&pool, workspace_id, Some(user.id), "toolchain.install_failed", &tc_key, &img).await;
113+
}
114+
Err(_) => {
115+
tracing::warn!(key = %tc_key, "toolchain install pull timed out");
116+
let _ = db::toolchain_images::mark_failed(&pool, &tc_key, "pull_timeout").await;
117+
let _ = insert_audit(&pool, workspace_id, Some(user.id), "toolchain.install_failed", &tc_key, &img).await;
118+
}
119+
}
120+
});
121+
122+
Ok(StatusCode::ACCEPTED)
123+
}
124+
125+
/// DELETE /api/workspaces/{workspace_id}/toolchains/{key}
126+
pub async fn uninstall(
127+
State(state): State<AppState>,
128+
CurrentUser(user): CurrentUser,
129+
Path((workspace_id, key)): Path<(Uuid, String)>,
130+
headers: HeaderMap,
131+
) -> AppResult<StatusCode> {
132+
authz::require_permission(&state.pool, user.id, workspace_id, authz::CONTENT_WRITE).await?;
133+
134+
let toolchain = toolchain_images::all()
135+
.iter()
136+
.find(|t| t.key == key)
137+
.ok_or(AppError::NotFound)?;
138+
139+
// Never remove the image jobs fall back to — that would break every job
140+
// that declares no container.
141+
if toolchain.image_latest == state.config.default_job_image {
142+
return Err(AppError::Conflict("toolchain_in_use"));
143+
}
144+
145+
let request_id = headers.get("x-request-id").and_then(|v| v.to_str().ok());
146+
// Dropping the row stops prewarming immediately; the rmi is best-effort.
147+
let removed = db::toolchain_images::delete(&state.pool, toolchain.key).await?;
148+
if removed.is_none() {
149+
return Err(AppError::NotFound);
150+
}
151+
record_audit(
152+
&state,
153+
workspace_id,
154+
user.id,
155+
"toolchain.uninstalled",
156+
toolchain.key,
157+
toolchain.image_latest,
158+
request_id,
159+
)
160+
.await?;
161+
162+
if let (Some(provisioner), Some(image)) = (state.runner_provisioner.clone(), removed) {
163+
tokio::spawn(async move {
164+
let _ = provisioner.remove_image(&image).await;
165+
});
166+
}
167+
168+
Ok(StatusCode::NO_CONTENT)
169+
}
170+
171+
/// Audit helper carrying the request id (for the synchronous request path).
172+
async fn record_audit(
173+
state: &AppState,
174+
workspace_id: Uuid,
175+
actor: Uuid,
176+
action: &str,
177+
key: &str,
178+
image: &str,
179+
request_id: Option<&str>,
180+
) -> AppResult<()> {
181+
sqlx::query(
182+
r#"
183+
INSERT INTO audit_logs
184+
(workspace_id, actor_user_id, action, subject_type, subject_id, metadata, request_id)
185+
VALUES ($1, $2, $3, 'toolchain', NULL, $4, $5)
186+
"#,
187+
)
188+
.bind(workspace_id)
189+
.bind(actor)
190+
.bind(action)
191+
.bind(json!({ "key": key, "image": image }))
192+
.bind(request_id)
193+
.execute(&state.pool)
194+
.await?;
195+
Ok(())
196+
}
197+
198+
/// Audit helper for the background pull result (no request id available).
199+
async fn insert_audit(
200+
pool: &sqlx::PgPool,
201+
workspace_id: Uuid,
202+
actor: Option<Uuid>,
203+
action: &str,
204+
key: &str,
205+
image: &str,
206+
) -> sqlx::Result<()> {
207+
sqlx::query(
208+
r#"
209+
INSERT INTO audit_logs
210+
(workspace_id, actor_user_id, action, subject_type, subject_id, metadata)
211+
VALUES ($1, $2, $3, 'toolchain', NULL, $4)
212+
"#,
213+
)
214+
.bind(workspace_id)
215+
.bind(actor)
216+
.bind(action)
217+
.bind(json!({ "key": key, "image": image }))
218+
.execute(pool)
219+
.await?;
220+
Ok(())
221+
}

‎backend/src/main.rs‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,9 @@ async fn main() -> anyhow::Result<()> {
5353
// its reconnect loop owns the Docker connection, so a daemon outage (at
5454
// boot or later) degrades to a clean 409 on the hosted-runner endpoint
5555
// and recovers automatically — never a permanently disabled feature.
56-
state.runner_provisioner = config
57-
.runner_provisioner
58-
.clone()
59-
.map(services::runner_provisioner::RunnerProvisioner::new);
56+
state.runner_provisioner = config.runner_provisioner.clone().map(|cfg| {
57+
services::runner_provisioner::RunnerProvisioner::new(cfg, state.pool.clone())
58+
});
6059
if let Some(provisioner) = state.runner_provisioner.clone() {
6160
tokio::spawn(provisioner.run_reconnect_loop());
6261
}

0 commit comments

Comments
 (0)