From 5ea55ffbd7fc170959ed6b051afc97ad42517c53 Mon Sep 17 00:00:00 2001 From: TELVIN TEUM Date: Sat, 18 Jul 2026 01:57:49 +0300 Subject: [PATCH 1/2] 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`. --- backend/.env.example | 4 + ...60718000001_installed_toolchain_images.sql | 16 ++ backend/src/db/mod.rs | 1 + backend/src/db/toolchain_images.rs | 100 +++++++++ backend/src/handlers/toolchains.rs | 194 +++++++++++++++++- backend/src/main.rs | 7 +- backend/src/models/toolchain.rs | 72 +++++-- backend/src/routes/mod.rs | 8 + backend/src/services/runner_provisioner.rs | 79 ++++++- .../brand/toolchains/ToolchainLogo.tsx | 84 ++++++++ src/features/toolchains/api/toolchainsApi.ts | 10 + .../toolchains/components/ToolchainCard.tsx | 137 ++++++++++--- .../components/ToolchainsSummaryStrip.tsx | 10 +- .../toolchains/hooks/useToolchains.ts | 69 ++++++- .../toolchains/pages/ToolchainsPage.tsx | 16 +- src/types/toolchain.ts | 8 +- 16 files changed, 735 insertions(+), 80 deletions(-) create mode 100644 backend/migrations/20260718000001_installed_toolchain_images.sql create mode 100644 backend/src/db/toolchain_images.rs create mode 100644 src/components/brand/toolchains/ToolchainLogo.tsx diff --git a/backend/.env.example b/backend/.env.example index 162c4f3..1153475 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -146,6 +146,10 @@ DEFAULT_JOB_IMAGE=catthehacker/ubuntu:act-latest # "Prewarmed"; capped at 20 entries, and the ~60 GB `full-*` image is best # left out): #RUNNER_PREPULL_IMAGES=catthehacker/ubuntu:act-latest,catthehacker/ubuntu:rust-latest,catthehacker/ubuntu:js-latest,catthehacker/ubuntu:go-latest +# You can also Install/Uninstall toolchains from the UI Toolchains page — that +# pulls the image onto this daemon now AND persists it, so it re-warms on every +# reconnect ALONGSIDE this env list (no env edit needed). Install/Uninstall +# needs RUNNER_PROVISIONER=docker (below); without it the buttons are disabled. # DOCKER_HOST passed through to runner containers so they can execute jobs. # SECURITY: leave unset to mount /var/run/docker.sock into runner containers # instead — that mount is root-equivalent on the host, so prefer a diff --git a/backend/migrations/20260718000001_installed_toolchain_images.sql b/backend/migrations/20260718000001_installed_toolchain_images.sql new file mode 100644 index 0000000..c1c4a4b --- /dev/null +++ b/backend/migrations/20260718000001_installed_toolchain_images.sql @@ -0,0 +1,16 @@ +-- Toolchains a user has installed (pulled + warmed) from the UI, so the set +-- to prewarm is no longer env-only (RUNNER_PREPULL_IMAGES). The runner Docker +-- daemon is a single deployment-global resource, so this table is global (no +-- workspace_id) — an install is actioned/audited under the calling workspace's +-- content.write, but the pulled image is shared. `toolchain_key` is the catalog +-- alias (e.g. 'rust'); `image` is its resolved -latest reference. `error` holds +-- a static category on a failed pull, never raw daemon text. +CREATE TABLE installed_toolchain_images ( + toolchain_key TEXT PRIMARY KEY, + image TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'installed', 'failed')), + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 9ba4c6c..e9ca4ed 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -16,6 +16,7 @@ pub mod runners; pub mod search; pub mod secrets; pub mod sessions; +pub mod toolchain_images; pub mod users; pub mod webhook_deliveries; pub mod workflows; diff --git a/backend/src/db/toolchain_images.rs b/backend/src/db/toolchain_images.rs new file mode 100644 index 0000000..63ac0f0 --- /dev/null +++ b/backend/src/db/toolchain_images.rs @@ -0,0 +1,100 @@ +//! Access to `installed_toolchain_images` — the persisted set of toolchain +//! images a user has installed from the UI. The set is global (one runner +//! Docker daemon per deployment); `list_active_images` feeds the provisioner's +//! prewarm union so installed toolchains re-warm on every reconnect. Audit +//! entries for install/uninstall are written by the handler (they carry the +//! calling workspace + actor); this module only owns the row lifecycle. + +use chrono::{DateTime, Utc}; +use sqlx::PgPool; + +/// One installed-toolchain row. +#[derive(Debug, sqlx::FromRow)] +pub struct InstalledToolchainRow { + pub toolchain_key: String, + #[allow(dead_code)] + pub image: String, + pub status: String, + pub error: Option, + #[allow(dead_code)] + pub created_at: DateTime, + #[allow(dead_code)] + pub updated_at: DateTime, +} + +/// Every install row (any status) — for the catalog read. +pub async fn list(pool: &PgPool) -> sqlx::Result> { + sqlx::query_as::<_, InstalledToolchainRow>( + "SELECT toolchain_key, image, status, error, created_at, updated_at \ + FROM installed_toolchain_images", + ) + .fetch_all(pool) + .await +} + +/// Images to prewarm: installed, plus pending (a pull already in flight — warm +/// it too so a reconnect during install doesn't drop it). +pub async fn list_active_images(pool: &PgPool) -> sqlx::Result> { + let rows: Vec<(String,)> = sqlx::query_as( + "SELECT image FROM installed_toolchain_images \ + WHERE status IN ('installed', 'pending')", + ) + .fetch_all(pool) + .await?; + Ok(rows.into_iter().map(|(image,)| image).collect()) +} + +/// Mark a toolchain as install-pending (idempotent: a re-install or retry +/// resets an existing row to pending and clears any prior error). +pub async fn upsert_pending(pool: &PgPool, key: &str, image: &str) -> sqlx::Result<()> { + sqlx::query( + "INSERT INTO installed_toolchain_images (toolchain_key, image, status) \ + VALUES ($1, $2, 'pending') \ + ON CONFLICT (toolchain_key) DO UPDATE \ + SET image = EXCLUDED.image, status = 'pending', error = NULL, updated_at = now()", + ) + .bind(key) + .bind(image) + .execute(pool) + .await?; + Ok(()) +} + +/// Flip a row to installed after a successful pull. Guards on the row still +/// existing (a race with uninstall drops the update harmlessly). +pub async fn mark_installed(pool: &PgPool, key: &str) -> sqlx::Result<()> { + sqlx::query( + "UPDATE installed_toolchain_images \ + SET status = 'installed', error = NULL, updated_at = now() \ + WHERE toolchain_key = $1", + ) + .bind(key) + .execute(pool) + .await?; + Ok(()) +} + +/// Flip a row to failed with a static error category. +pub async fn mark_failed(pool: &PgPool, key: &str, error: &str) -> sqlx::Result<()> { + sqlx::query( + "UPDATE installed_toolchain_images \ + SET status = 'failed', error = $2, updated_at = now() \ + WHERE toolchain_key = $1", + ) + .bind(key) + .bind(error) + .execute(pool) + .await?; + Ok(()) +} + +/// Delete a row (uninstall); returns the removed image so the caller can rmi it. +pub async fn delete(pool: &PgPool, key: &str) -> sqlx::Result> { + let row: Option<(String,)> = sqlx::query_as( + "DELETE FROM installed_toolchain_images WHERE toolchain_key = $1 RETURNING image", + ) + .bind(key) + .fetch_optional(pool) + .await?; + Ok(row.map(|(image,)| image)) +} diff --git a/backend/src/handlers/toolchains.rs b/backend/src/handlers/toolchains.rs index 3501abc..3f04c7c 100644 --- a/backend/src/handlers/toolchains.rs +++ b/backend/src/handlers/toolchains.rs @@ -1,19 +1,30 @@ -//! Toolchains API: a read-only catalog of the language-toolchain images -//! overup understands (`services::toolchain_images`), tagged with the -//! deployment's default image, prewarm state, and whether an image allow-list -//! is active. Rides `content.read` like the other workspace read surfaces. -//! Static strings only — no mutation surface, no user/runner text. +//! Toolchains API: a catalog of the language-toolchain images overup +//! understands (`services::toolchain_images`), tagged with the deployment's +//! default image, prewarm/install state, and whether an image allow-list is +//! active. The catalog read rides `content.read`; install/uninstall (an +//! on-demand `docker pull`/`rmi` on the hosted-runner daemon) ride +//! `content.write`, matching hosted-runner management. Image strings are always +//! the static catalog values — no user text ever reaches Docker. + +use std::time::Duration; use axum::Json; use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use serde_json::json; use uuid::Uuid; -use crate::error::AppResult; +use crate::db; +use crate::error::{AppError, AppResult}; use crate::middleware::auth::CurrentUser; use crate::models::toolchain::ToolchainsResponse; use crate::services::authz; +use crate::services::toolchain_images; use crate::state::AppState; +/// Cap for an on-demand toolchain pull — matches the hosted-create budget. +const PULL_TIMEOUT: Duration = Duration::from_secs(600); + /// GET /api/workspaces/{workspace_id}/toolchains pub async fn list( State(state): State, @@ -31,9 +42,180 @@ pub async fn list( .map(|p| p.prepull_images.as_slice()) .unwrap_or(&[]); + let install_supported = match &state.runner_provisioner { + Some(provisioner) => provisioner.available().await, + None => false, + }; + let installed = db::toolchain_images::list(&state.pool).await?; + Ok(Json(ToolchainsResponse::build( state.config.default_job_image.clone(), prepull, !state.config.image_allowlist.is_empty(), + install_supported, + &installed, ))) } + +/// POST /api/workspaces/{workspace_id}/toolchains/{key}/install +pub async fn install( + State(state): State, + CurrentUser(user): CurrentUser, + Path((workspace_id, key)): Path<(Uuid, String)>, + headers: HeaderMap, +) -> AppResult { + authz::require_permission(&state.pool, user.id, workspace_id, authz::CONTENT_WRITE).await?; + + // Only known catalog keys install, and only their static -latest image. + let toolchain = toolchain_images::all() + .iter() + .find(|t| t.key == key) + .ok_or(AppError::NotFound)?; + let image = toolchain.image_latest; + + // Pulls need a live provisioner daemon. Static category the UI maps to copy. + let Some(provisioner) = state.runner_provisioner.clone() else { + return Err(AppError::Conflict("hosted_runner_unavailable")); + }; + if !provisioner.available().await { + return Err(AppError::Conflict("hosted_runner_unavailable")); + } + + let request_id = headers.get("x-request-id").and_then(|v| v.to_str().ok()); + db::toolchain_images::upsert_pending(&state.pool, toolchain.key, image).await?; + record_audit( + &state, + workspace_id, + user.id, + "toolchain.install_requested", + toolchain.key, + image, + request_id, + ) + .await?; + + // The pull can take minutes — run it off the request path and let the row + // status (observed via the catalog GET) drive the UI. + let pool = state.pool.clone(); + let tc_key = toolchain.key.to_string(); + let img = image.to_string(); + tokio::spawn(async move { + let outcome = tokio::time::timeout(PULL_TIMEOUT, provisioner.pull(&img)).await; + match outcome { + Ok(Ok(())) => { + let _ = db::toolchain_images::mark_installed(&pool, &tc_key).await; + let _ = insert_audit(&pool, workspace_id, Some(user.id), "toolchain.installed", &tc_key, &img).await; + } + Ok(Err(err)) => { + tracing::warn!(key = %tc_key, error = ?err, "toolchain install pull failed"); + let _ = db::toolchain_images::mark_failed(&pool, &tc_key, "image_pull_failed").await; + let _ = insert_audit(&pool, workspace_id, Some(user.id), "toolchain.install_failed", &tc_key, &img).await; + } + Err(_) => { + tracing::warn!(key = %tc_key, "toolchain install pull timed out"); + let _ = db::toolchain_images::mark_failed(&pool, &tc_key, "pull_timeout").await; + let _ = insert_audit(&pool, workspace_id, Some(user.id), "toolchain.install_failed", &tc_key, &img).await; + } + } + }); + + Ok(StatusCode::ACCEPTED) +} + +/// DELETE /api/workspaces/{workspace_id}/toolchains/{key} +pub async fn uninstall( + State(state): State, + CurrentUser(user): CurrentUser, + Path((workspace_id, key)): Path<(Uuid, String)>, + headers: HeaderMap, +) -> AppResult { + authz::require_permission(&state.pool, user.id, workspace_id, authz::CONTENT_WRITE).await?; + + let toolchain = toolchain_images::all() + .iter() + .find(|t| t.key == key) + .ok_or(AppError::NotFound)?; + + // Never remove the image jobs fall back to — that would break every job + // that declares no container. + if toolchain.image_latest == state.config.default_job_image { + return Err(AppError::Conflict("toolchain_in_use")); + } + + let request_id = headers.get("x-request-id").and_then(|v| v.to_str().ok()); + // Dropping the row stops prewarming immediately; the rmi is best-effort. + let removed = db::toolchain_images::delete(&state.pool, toolchain.key).await?; + if removed.is_none() { + return Err(AppError::NotFound); + } + record_audit( + &state, + workspace_id, + user.id, + "toolchain.uninstalled", + toolchain.key, + toolchain.image_latest, + request_id, + ) + .await?; + + if let (Some(provisioner), Some(image)) = (state.runner_provisioner.clone(), removed) { + tokio::spawn(async move { + let _ = provisioner.remove_image(&image).await; + }); + } + + Ok(StatusCode::NO_CONTENT) +} + +/// Audit helper carrying the request id (for the synchronous request path). +async fn record_audit( + state: &AppState, + workspace_id: Uuid, + actor: Uuid, + action: &str, + key: &str, + image: &str, + request_id: Option<&str>, +) -> AppResult<()> { + sqlx::query( + r#" + INSERT INTO audit_logs + (workspace_id, actor_user_id, action, subject_type, subject_id, metadata, request_id) + VALUES ($1, $2, $3, 'toolchain', NULL, $4, $5) + "#, + ) + .bind(workspace_id) + .bind(actor) + .bind(action) + .bind(json!({ "key": key, "image": image })) + .bind(request_id) + .execute(&state.pool) + .await?; + Ok(()) +} + +/// Audit helper for the background pull result (no request id available). +async fn insert_audit( + pool: &sqlx::PgPool, + workspace_id: Uuid, + actor: Option, + action: &str, + key: &str, + image: &str, +) -> sqlx::Result<()> { + sqlx::query( + r#" + INSERT INTO audit_logs + (workspace_id, actor_user_id, action, subject_type, subject_id, metadata) + VALUES ($1, $2, $3, 'toolchain', NULL, $4) + "#, + ) + .bind(workspace_id) + .bind(actor) + .bind(action) + .bind(json!({ "key": key, "image": image })) + .execute(pool) + .await?; + Ok(()) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index a2c2672..b8c7fb9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -53,10 +53,9 @@ async fn main() -> anyhow::Result<()> { // its reconnect loop owns the Docker connection, so a daemon outage (at // boot or later) degrades to a clean 409 on the hosted-runner endpoint // and recovers automatically — never a permanently disabled feature. - state.runner_provisioner = config - .runner_provisioner - .clone() - .map(services::runner_provisioner::RunnerProvisioner::new); + state.runner_provisioner = config.runner_provisioner.clone().map(|cfg| { + services::runner_provisioner::RunnerProvisioner::new(cfg, state.pool.clone()) + }); if let Some(provisioner) = state.runner_provisioner.clone() { tokio::spawn(provisioner.run_reconnect_loop()); } diff --git a/backend/src/models/toolchain.rs b/backend/src/models/toolchain.rs index 3e61bdd..1e02a89 100644 --- a/backend/src/models/toolchain.rs +++ b/backend/src/models/toolchain.rs @@ -1,11 +1,12 @@ //! API shapes for the language-toolchain catalog (`GET …/toolchains`). The //! catalog itself is static (`services::toolchain_images`); these DTOs project -//! it, tagging each entry with whether its `-latest` image is prewarmed by the -//! deployment's `RUNNER_PREPULL_IMAGES`. Static strings only — no user or -//! runner text ever reaches here. +//! it, tagging each entry with whether its `-latest` image is prewarmed and its +//! install state (pulled onto the hosted-runner daemon from the UI). Static +//! strings only — no user or runner text ever reaches here. use serde::Serialize; +use crate::db::toolchain_images::InstalledToolchainRow; use crate::services::toolchain_images; #[derive(Debug, Serialize)] @@ -21,8 +22,13 @@ pub struct ToolchainResponse { pub image2404: &'static str, /// Very large image (`full-*`) — the UI warns against prewarming it. pub large: bool, - /// The `-latest` image is in `RUNNER_PREPULL_IMAGES` (warmed on connect). + /// The `-latest` image is warmed on runner connect (env prepull OR install). pub prewarmed: bool, + /// Install lifecycle: 'none' | 'pending' | 'installed' | 'failed'. + pub install_status: &'static str, + /// Static failure category when `install_status == "failed"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub install_error: Option, } #[derive(Debug, Serialize)] @@ -33,31 +39,61 @@ pub struct ToolchainsResponse { pub default_image: String, /// Whether `RUNNER_IMAGE_ALLOWLIST` restricts which images may run. pub allowlist_enabled: bool, + /// Whether install/uninstall is possible right now (the hosted-runner + /// provisioner is configured AND its Docker daemon is reachable). + pub install_supported: bool, +} + +/// Normalize a stored status string onto the fixed vocabulary the UI expects. +fn status_str(raw: &str) -> &'static str { + match raw { + "pending" => "pending", + "installed" => "installed", + "failed" => "failed", + _ => "none", + } } impl ToolchainsResponse { - /// Build the response from the static catalog plus the deployment's - /// prepull list and default image. - pub fn build(default_image: String, prepull: &[String], allowlist_enabled: bool) -> Self { + /// Build the response from the static catalog plus deployment/install state. + pub fn build( + default_image: String, + prepull: &[String], + allowlist_enabled: bool, + install_supported: bool, + installed: &[InstalledToolchainRow], + ) -> Self { let toolchains = toolchain_images::all() .iter() - .map(|t| ToolchainResponse { - key: t.key, - label: t.label, - language: t.language, - description: t.description, - tools: t.tools.to_vec(), - image_latest: t.image_latest, - image2204: t.image_2204, - image2404: t.image_2404, - large: t.large, - prewarmed: prepull.iter().any(|p| p == t.image_latest), + .map(|t| { + let row = installed.iter().find(|r| r.toolchain_key == t.key); + let install_status = row.map(|r| status_str(&r.status)).unwrap_or("none"); + // Warmed when in the env prepull list OR actively installed. + let prewarmed = prepull.iter().any(|p| p == t.image_latest) + || matches!(install_status, "installed" | "pending"); + ToolchainResponse { + key: t.key, + label: t.label, + language: t.language, + description: t.description, + tools: t.tools.to_vec(), + image_latest: t.image_latest, + image2204: t.image_2204, + image2404: t.image_2404, + large: t.large, + prewarmed, + install_status, + install_error: row.and_then(|r| { + (install_status == "failed").then(|| r.error.clone()).flatten() + }), + } }) .collect(); Self { toolchains, default_image, allowlist_enabled, + install_supported, } } } diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index b19a9d4..92299b3 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -375,6 +375,14 @@ pub fn build_router(state: AppState) -> anyhow::Result { "/workspaces/{workspace_id}/toolchains", get(toolchains::list), ) + .route( + "/workspaces/{workspace_id}/toolchains/{key}/install", + post(toolchains::install), + ) + .route( + "/workspaces/{workspace_id}/toolchains/{key}", + delete(toolchains::uninstall), + ) .route( "/workspaces/{workspace_id}/runners", get(runners::list).post(runners::create), diff --git a/backend/src/services/runner_provisioner.rs b/backend/src/services/runner_provisioner.rs index abf7043..4b5c70c 100644 --- a/backend/src/services/runner_provisioner.rs +++ b/backend/src/services/runner_provisioner.rs @@ -27,13 +27,15 @@ use bollard::models::{ }; use bollard::query_parameters::{ CreateContainerOptionsBuilder, CreateImageOptionsBuilder, InspectNetworkOptions, - ListContainersOptionsBuilder, RemoveContainerOptionsBuilder, RemoveVolumeOptions, - StartContainerOptions, StopContainerOptionsBuilder, + ListContainersOptionsBuilder, RemoveContainerOptionsBuilder, RemoveImageOptions, + RemoveVolumeOptions, StartContainerOptions, StopContainerOptionsBuilder, }; use futures_util::StreamExt; +use sqlx::PgPool; use uuid::Uuid; use crate::config::RunnerProvisionerConfig; +use crate::db; use crate::services::runner_profiles::ResourceLimits; pub struct RunnerProvisioner { @@ -48,6 +50,10 @@ pub struct RunnerProvisioner { /// concurrent pulls of the same list. prepull_running: std::sync::atomic::AtomicBool, cfg: RunnerProvisionerConfig, + /// DB pool: the warm-up unions the env `prepull_images` with the toolchains + /// a user installed from the UI (`installed_toolchain_images`), so those + /// re-warm on every reconnect too. + pool: PgPool, } /// One `overup.managed=true` container as seen on the Docker host. @@ -315,12 +321,13 @@ impl RunnerProvisioner { /// [`Self::run_reconnect_loop`], so a Docker outage at boot (or any time /// after) degrades cleanly to "hosted runners unavailable" instead of /// disabling the feature for the process lifetime. - pub fn new(cfg: RunnerProvisionerConfig) -> std::sync::Arc { + pub fn new(cfg: RunnerProvisionerConfig, pool: PgPool) -> std::sync::Arc { std::sync::Arc::new(Self { docker: tokio::sync::RwLock::new(None), network: tokio::sync::RwLock::new(cfg.network.clone()), prepull_running: std::sync::atomic::AtomicBool::new(false), cfg, + pool, }) } @@ -516,6 +523,43 @@ impl RunnerProvisioner { } } + /// Pull an arbitrary image on demand (the UI "install toolchain" action). + /// Same local-copy fallback as [`Self::ensure_image`]: a registry failure + /// is tolerated when the tag is already present locally. + pub async fn pull(&self, image: &str) -> Result<(), ProvisionError> { + let Some(docker) = self.handle().await else { + return Err(ProvisionError::DockerUnavailable); + }; + match pull_image(&docker, image).await { + Ok(()) => Ok(()), + Err(error) if image_present(&docker, image).await => { + tracing::warn!( + %image, + error = ?error, + "toolchain pull: registry failed — using the locally present image" + ); + Ok(()) + } + Err(error) => Err(ProvisionError::ImagePull(error)), + } + } + + /// Best-effort image removal (the UI "uninstall toolchain" action). Ignores + /// "not found" and "image in use" — the intent is to stop warming it and + /// reclaim disk when possible, never to fail because a job holds it. + pub async fn remove_image(&self, image: &str) -> anyhow::Result<()> { + let Some(docker) = self.handle().await else { + anyhow::bail!("docker daemon unavailable"); + }; + if let Err(error) = docker + .remove_image(image, None::, None) + .await + { + tracing::warn!(%image, error = ?error, "toolchain image removal failed (ignored)"); + } + Ok(()) + } + /// Warm the daemon's image cache after a successful (re)connect: the /// runner image plus every RUNNER_PREPULL_IMAGES entry, so the runner's /// `pulling_image` stage (and the first hosted-runner create) resolves @@ -532,17 +576,32 @@ impl RunnerProvisioner { } let this = self; tokio::spawn(async move { - let mut images: Vec<&str> = vec![this.cfg.image.as_str()]; + // Runner image + env prepull list + toolchains installed from the + // UI (best-effort: a DB hiccup just warms the env set this round). + let mut images: Vec = vec![this.cfg.image.clone()]; for image in &this.cfg.prepull_images { - if !images.contains(&image.as_str()) { - images.push(image); + if !images.contains(image) { + images.push(image.clone()); + } + } + match db::toolchain_images::list_active_images(&this.pool).await { + Ok(installed) => { + for image in installed { + if !images.contains(&image) { + images.push(image); + } + } } + Err(error) => tracing::warn!( + error = ?error, + "could not read installed toolchains for prewarm — warming env set only" + ), } - for image in images { + for image in &images { let started = std::time::Instant::now(); match pull_image(&docker, image).await { Ok(()) => tracing::info!( - image = %image, + %image, elapsed_ms = started.elapsed().as_millis() as u64, "pre-pulled image into the docker daemon" ), @@ -550,12 +609,12 @@ impl RunnerProvisioner { // failed registry check isn't worth a warning on every // reconnect. Err(error) if image_present(&docker, image).await => tracing::info!( - image = %image, + %image, error = ?error, "image already present locally — registry pull failed, skipping" ), Err(error) => tracing::warn!( - image = %image, + %image, error = ?error, "image pre-pull failed — jobs needing it will pull on demand" ), diff --git a/src/components/brand/toolchains/ToolchainLogo.tsx b/src/components/brand/toolchains/ToolchainLogo.tsx new file mode 100644 index 0000000..3ad6ef5 --- /dev/null +++ b/src/components/brand/toolchains/ToolchainLogo.tsx @@ -0,0 +1,84 @@ +import { Boxes } from 'lucide-react'; + +/** + * Official language/tool brand marks for the Toolchains catalog, inlined as + * SVG (paths from simple-icons, CC0) in each brand's official color on a + * transparent background. A deliberate, logo-only exception to the app's + * monochrome palette — every other icon uses currentColor. Sized via + * `className` (e.g. `h-8 w-8`), following the `GitHubMark` convention. + */ +interface BrandMark { + color: string; + title: string; + path: string; +} + +const LOGOS: Record = { + rust: { + color: '#000000', + title: 'Rust', + path: 'M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z', + }, + js: { + color: '#5FA04E', + title: 'Node.js', + path: 'M11.998,24c-0.321,0-0.641-0.084-0.922-0.247l-2.936-1.737c-0.438-0.245-0.224-0.332-0.08-0.383 c0.585-0.203,0.703-0.25,1.328-0.604c0.065-0.037,0.151-0.023,0.218,0.017l2.256,1.339c0.082,0.045,0.197,0.045,0.272,0l8.795-5.076 c0.082-0.047,0.134-0.141,0.134-0.238V6.921c0-0.099-0.053-0.192-0.137-0.242l-8.791-5.072c-0.081-0.047-0.189-0.047-0.271,0 L3.075,6.68C2.99,6.729,2.936,6.825,2.936,6.921v10.15c0,0.097,0.054,0.189,0.139,0.235l2.409,1.392 c1.307,0.654,2.108-0.116,2.108-0.89V7.787c0-0.142,0.114-0.253,0.256-0.253h1.115c0.139,0,0.255,0.112,0.255,0.253v10.021 c0,1.745-0.95,2.745-2.604,2.745c-0.508,0-0.909,0-2.026-0.551L2.28,18.675c-0.57-0.329-0.922-0.945-0.922-1.604V6.921 c0-0.659,0.353-1.275,0.922-1.603l8.795-5.082c0.557-0.315,1.296-0.315,1.848,0l8.794,5.082c0.57,0.329,0.924,0.944,0.924,1.603 v10.15c0,0.659-0.354,1.273-0.924,1.604l-8.794,5.078C12.643,23.916,12.324,24,11.998,24z M19.099,13.993 c0-1.9-1.284-2.406-3.987-2.763c-2.731-0.361-3.009-0.548-3.009-1.187c0-0.528,0.235-1.233,2.258-1.233 c1.807,0,2.473,0.389,2.747,1.607c0.024,0.115,0.129,0.199,0.247,0.199h1.141c0.071,0,0.138-0.031,0.186-0.081 c0.048-0.054,0.074-0.123,0.067-0.196c-0.177-2.098-1.571-3.076-4.388-3.076c-2.508,0-4.004,1.058-4.004,2.833 c0,1.925,1.488,2.457,3.895,2.695c2.88,0.282,3.103,0.703,3.103,1.269c0,0.983-0.789,1.402-2.642,1.402 c-2.327,0-2.839-0.584-3.011-1.742c-0.02-0.124-0.126-0.215-0.253-0.215h-1.137c-0.141,0-0.254,0.112-0.254,0.253 c0,1.482,0.806,3.248,4.655,3.248C17.501,17.007,19.099,15.91,19.099,13.993z', + }, + go: { + color: '#00ADD8', + title: 'Go', + path: 'M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514-.176.046-.187.058-.34-.117-.174-.199-.303-.327-.548-.444-.737-.362-1.45-.257-2.115.175-.795.514-1.204 1.274-1.192 2.22.011.935.654 1.706 1.577 1.835.795.105 1.46-.175 1.987-.77.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35.152-.362.432-.97.596-1.274a.315.315 0 01.292-.187h4.253c-.023.316-.023.631-.07.947a4.983 4.983 0 01-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986-1.145.152-2.209-.07-3.143-.77-.865-.655-1.356-1.52-1.484-2.595-.152-1.274.222-2.419.993-3.424.83-1.086 1.928-1.776 3.272-2.02 1.098-.2 2.15-.07 3.096.571.62.41 1.063.97 1.356 1.648.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.665 3.665 0 01-1.262-2.255c-.21-1.32.152-2.489.947-3.529.853-1.122 1.881-1.706 3.272-1.95 1.192-.21 2.314-.095 3.33.595.923.63 1.496 1.484 1.648 2.605.198 1.578-.257 2.863-1.344 3.962-.771.783-1.718 1.273-2.805 1.495-.315.06-.63.07-.934.106zm2.78-4.72c-.011-.153-.011-.27-.034-.387-.21-1.157-1.274-1.81-2.384-1.554-1.087.245-1.788.935-2.045 2.033-.21.912.234 1.835 1.075 2.21.643.28 1.285.244 1.905-.07.923-.48 1.425-1.228 1.484-2.233z', + }, + dotnet: { + color: '#512BD4', + title: '.NET', + path: 'M24 8.77h-2.468v7.565h-1.425V8.77h-2.462V7.53H24zm-6.852 7.565h-4.821V7.53h4.63v1.24h-3.205v2.494h2.953v1.234h-2.953v2.604h3.396zm-6.708 0H8.882L4.78 9.863a2.896 2.896 0 0 1-.258-.51h-.036c.032.189.048.592.048 1.21v5.772H3.157V7.53h1.659l3.965 6.32c.167.261.275.442.323.54h.024c-.04-.233-.06-.629-.06-1.185V7.529h1.372zm-8.703-.693a.868.829 0 0 1-.869.829.868.829 0 0 1-.868-.83.868.829 0 0 1 .868-.828.868.829 0 0 1 .869.829Z', + }, + java: { + color: '#437291', + title: 'OpenJDK', + path: 'M11.915 0 11.7.215C9.515 2.4 7.47 6.39 6.046 10.483c-1.064 1.024-3.633 2.81-3.711 3.551-.093.87 1.746 2.611 1.55 3.235-.198.625-1.304 1.408-1.014 1.939.1.188.823.011 1.277-.491a13.389 13.389 0 0 0-.017 2.14c.076.906.27 1.668.643 2.232.372.563.956.911 1.667.911.397 0 .727-.114 1.024-.264.298-.149.571-.33.91-.5.68-.34 1.634-.666 3.53-.604 1.903.062 2.872.39 3.559.704.687.314 1.15.664 1.925.664.767 0 1.395-.336 1.807-.9.412-.563.631-1.33.72-2.24.06-.623.055-1.32 0-2.066.454.45 1.117.604 1.213.424.29-.53-.816-1.314-1.013-1.937-.198-.624 1.642-2.366 1.549-3.236-.08-.748-2.707-2.568-3.748-3.586C16.428 6.374 14.308 2.394 12.13.215zm.175 6.038a2.95 2.95 0 0 1 2.943 2.942 2.95 2.95 0 0 1-2.943 2.943A2.95 2.95 0 0 1 9.148 8.98a2.95 2.95 0 0 1 2.942-2.942zM8.685 7.983a3.515 3.515 0 0 0-.145.997c0 1.951 1.6 3.55 3.55 3.55 1.95 0 3.55-1.598 3.55-3.55 0-.329-.046-.648-.132-.951.334.095.64.208.915.336a42.699 42.699 0 0 1 2.042 5.829c.678 2.545 1.01 4.92.846 6.607-.082.844-.29 1.51-.606 1.94-.315.431-.713.651-1.315.651-.593 0-.932-.27-1.673-.61-.741-.338-1.825-.694-3.792-.758-1.974-.064-3.073.293-3.821.669-.375.188-.659.373-.911.5s-.466.2-.752.2c-.53 0-.876-.209-1.16-.64-.285-.43-.474-1.101-.545-1.948-.141-1.693.176-4.069.823-6.614a43.155 43.155 0 0 1 1.934-5.783c.348-.167.749-.31 1.192-.425zm-3.382 4.362a.216.216 0 0 1 .13.031c-.166.56-.323 1.116-.463 1.665a33.849 33.849 0 0 0-.547 2.555 3.9 3.9 0 0 0-.2-.39c-.58-1.012-.914-1.642-1.16-2.08.315-.24 1.679-1.755 2.24-1.781zm13.394.01c.562.027 1.926 1.543 2.24 1.783-.246.438-.58 1.068-1.16 2.08a4.428 4.428 0 0 0-.163.309 32.354 32.354 0 0 0-.562-2.49 40.579 40.579 0 0 0-.482-1.652.216.216 0 0 1 .127-.03z', + }, + pwsh: { + color: '#5391FE', + title: 'PowerShell', + path: 'M23.181 2.974c.568 0 .923.463.792 1.035l-3.659 15.982c-.13.572-.697 1.035-1.265 1.035H.819c-.568 0-.923-.463-.792-1.035L3.686 4.009c.13-.572.697-1.035 1.265-1.035zm-8.375 9.346c.251-.394.227-.905-.09-1.243L9.122 5.125c-.38-.404-1.037-.407-1.466-.003-.429.402-.468 1.056-.088 1.46l4.662 4.96v.11l-7.42 5.374c-.45.327-.533.977-.187 1.453.346.476.991.597 1.44.27l8.229-5.91c.28-.196.438-.365.514-.52zm-2.796 4.399a.928.928 0 00-.934.923c0 .51.418.923.934.923h4.433a.928.928 0 00.934-.923.928.928 0 00-.934-.923z', + }, + gh: { + color: '#181717', + title: 'GitHub', + path: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12', + }, + act: { + color: '#E95420', + title: 'Ubuntu', + path: 'M17.61.455a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zM12.92.8C8.923.777 5.137 2.941 3.148 6.451a4.5 4.5 0 0 1 .26-.007 4.92 4.92 0 0 1 2.585.737A8.316 8.316 0 0 1 12.688 3.6 4.944 4.944 0 0 1 13.723.834 11.008 11.008 0 0 0 12.92.8zm9.226 4.994a4.915 4.915 0 0 1-1.918 2.246 8.36 8.36 0 0 1-.273 8.303 4.89 4.89 0 0 1 1.632 2.54 11.156 11.156 0 0 0 .559-13.089zM3.41 7.932A3.41 3.41 0 0 0 0 11.342a3.41 3.41 0 0 0 3.41 3.409 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41zm2.027 7.866a4.908 4.908 0 0 1-2.915.358 11.1 11.1 0 0 0 7.991 6.698 11.234 11.234 0 0 0 2.422.249 4.879 4.879 0 0 1-.999-2.85 8.484 8.484 0 0 1-.836-.136 8.304 8.304 0 0 1-5.663-4.32zm11.405.928a3.41 3.41 0 0 0-3.41 3.41 3.41 3.41 0 0 0 3.41 3.41 3.41 3.41 0 0 0 3.41-3.41 3.41 3.41 0 0 0-3.41-3.41z', + }, +}; +// The full-runner image is Ubuntu-based; reuse the Ubuntu mark. +LOGOS.full = LOGOS.act; + +interface ToolchainLogoProps { + toolchainKey: string; + className?: string; +} + +/** Official brand logo for a toolchain key; a generic box for anything unmapped. */ +export function ToolchainLogo({ toolchainKey, className }: ToolchainLogoProps) { + const mark = LOGOS[toolchainKey]; + if (!mark) { + return