From d205f14cbf8f6171e11046c373c83d672d1312a7 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Mon, 26 Oct 2026 14:32:44 +0200 Subject: [PATCH 1/2] Bake channels: a bake reaches buyers only after its smoke clone passes A bake started from POST /api/admin/bake now lands on the `unstable` channel, which no buyer gets. POST /api/admin/bake/{id}/promote (called by the harness after a green smoke clone) moves it to `latest`; the previous `latest` becomes `stable`. Clone and validate resolve `latest`, then `stable`, then the newest ungated snapshot, so stacks baked before this change (and by the `bake` binary) keep working. - migration: baked_snapshot_channels (pointer per stack/provider/channel), baked_snapshots.gated, Casbin rule for the promote route - db::bake_registry: point, state, promote (one transaction, pointers locked; only the current `unstable` can be promoted; a repeat is a no-op) - `gated` keeps a bake whose smoke failed hidden after the next bake replaces the `unstable` pointer Tests: tests/bake_channels.rs (written failing first). Co-Authored-By: Claude Opus 5.5 --- ...026100000_baked_snapshot_channels.down.sql | 3 + ...61026100000_baked_snapshot_channels.up.sql | 23 ++ src/db/bake_registry.rs | 155 +++++++++++++ src/db/baked_snapshot.rs | 143 +++++++++--- src/db/mod.rs | 1 + src/models/baked_snapshot.rs | 4 + src/routes/admin_bake.rs | 48 +++- src/startup.rs | 3 +- tests/bake_channels.rs | 208 ++++++++++++++++++ 9 files changed, 554 insertions(+), 34 deletions(-) create mode 100644 migrations/20261026100000_baked_snapshot_channels.down.sql create mode 100644 migrations/20261026100000_baked_snapshot_channels.up.sql create mode 100644 src/db/bake_registry.rs create mode 100644 tests/bake_channels.rs diff --git a/migrations/20261026100000_baked_snapshot_channels.down.sql b/migrations/20261026100000_baked_snapshot_channels.down.sql new file mode 100644 index 00000000..2052debe --- /dev/null +++ b/migrations/20261026100000_baked_snapshot_channels.down.sql @@ -0,0 +1,3 @@ +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v1 = '/api/admin/bake/:id/promote'; +ALTER TABLE baked_snapshots DROP COLUMN IF EXISTS gated; +DROP TABLE IF EXISTS baked_snapshot_channels; diff --git a/migrations/20261026100000_baked_snapshot_channels.up.sql b/migrations/20261026100000_baked_snapshot_channels.up.sql new file mode 100644 index 00000000..c5800c65 --- /dev/null +++ b/migrations/20261026100000_baked_snapshot_channels.up.sql @@ -0,0 +1,23 @@ +-- Release channels for baked snapshots (BAKE_PIPELINE_PLAN.md, Stage 4): the +-- pointers helpers/bake_registry.rs moves. A bake started from the API lands on +-- `unstable`; buyers get `latest` (promoted after a green smoke clone) or, when +-- that is unhealthy, `stable` (the previous `latest`). +CREATE TABLE IF NOT EXISTS baked_snapshot_channels ( + stack TEXT NOT NULL, + provider TEXT NOT NULL, + channel TEXT NOT NULL, + snapshot_id INTEGER NOT NULL REFERENCES baked_snapshots (id), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (stack, provider, channel) +); + +CREATE INDEX IF NOT EXISTS idx_baked_snapshot_channels_snapshot + ON baked_snapshot_channels (snapshot_id); + +-- A gated snapshot reaches buyers only through `latest` or `stable`. Rows +-- recorded before this migration, and by the `bake` binary, stay ungated and +-- keep the "newest healthy" behaviour. +ALTER TABLE baked_snapshots ADD COLUMN IF NOT EXISTS gated BOOLEAN NOT NULL DEFAULT FALSE; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/admin/bake/:id/promote', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'admin_service', '/api/admin/bake/:id/promote', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/src/db/bake_registry.rs b/src/db/bake_registry.rs new file mode 100644 index 00000000..bc76a661 --- /dev/null +++ b/src/db/bake_registry.rs @@ -0,0 +1,155 @@ +//! Channel pointers for baked snapshots (`baked_snapshot_channels`). The +//! promotion decision itself is `helpers::bake_registry::promote_ladder`. + +use crate::helpers::bake_registry::{ + promote_ladder, ChannelState, PromoteMoves, CHANNEL_LATEST, CHANNEL_STABLE, CHANNEL_UNSTABLE, +}; +use sqlx::PgPool; + +/// Point `channel` of a stack at a snapshot, replacing what it pointed at. +pub async fn point<'e, E: sqlx::PgExecutor<'e>>( + executor: E, + stack: &str, + provider: &str, + channel: &str, + snapshot_id: i32, +) -> Result<(), String> { + sqlx::query( + r#" + INSERT INTO baked_snapshot_channels (stack, provider, channel, snapshot_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (stack, provider, channel) + DO UPDATE SET snapshot_id = EXCLUDED.snapshot_id, updated_at = NOW() + "#, + ) + .bind(stack) + .bind(provider) + .bind(channel) + .bind(snapshot_id) + .execute(executor) + .await + .map(|_| ()) + .map_err(|e| format!("Failed to move the {channel} channel: {e}")) +} + +/// Where each channel of a stack points now. +pub async fn state(pool: &PgPool, stack: &str, provider: &str) -> Result { + let rows: Vec<(String, i32)> = sqlx::query_as( + "SELECT channel, snapshot_id FROM baked_snapshot_channels WHERE stack = $1 AND provider = $2", + ) + .bind(stack) + .bind(provider) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to read channels: {e}"))?; + Ok(to_state(rows)) +} + +/// The channels of the stack (and provider) a snapshot belongs to. +pub async fn state_for_snapshot(pool: &PgPool, snapshot_id: i32) -> Result { + let rows: Vec<(String, i32)> = sqlx::query_as( + r#" + SELECT c.channel, c.snapshot_id + FROM baked_snapshots s + JOIN baked_snapshot_channels c ON c.stack = s.stack AND c.provider = s.provider + WHERE s.id = $1 + "#, + ) + .bind(snapshot_id) + .fetch_all(pool) + .await + .map_err(|e| format!("Failed to read channels: {e}"))?; + Ok(to_state(rows)) +} + +fn to_state(rows: Vec<(String, i32)>) -> ChannelState { + let mut state = ChannelState { + unstable: None, + latest: None, + stable: None, + }; + for (channel, id) in rows { + match channel.as_str() { + CHANNEL_UNSTABLE => state.unstable = Some(id), + CHANNEL_LATEST => state.latest = Some(id), + CHANNEL_STABLE => state.stable = Some(id), + _ => {} + } + } + state +} + +#[derive(Debug)] +pub enum PromoteError { + /// No such snapshot. + NotFound, + /// The snapshot is not what `unstable` points at: a newer bake replaced it, + /// or it was never baked through the API. + NotUnstable, + Db(String), +} + +impl std::fmt::Display for PromoteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PromoteError::NotFound => write!(f, "no such baked snapshot"), + PromoteError::NotUnstable => write!( + f, + "the snapshot is not the stack's current unstable bake, so it cannot be promoted" + ), + PromoteError::Db(e) => write!(f, "{e}"), + } + } +} + +/// Promote the stack's `unstable` snapshot to `latest` (old `latest` to +/// `stable`) after a green smoke clone. `snapshot_id` must be the current +/// `unstable`, so a smoke result is never applied to a different bake. +/// `Ok(None)` when it is already `latest`: repeating a promote is harmless. +pub async fn promote( + pool: &PgPool, + snapshot_id: i32, +) -> Result, PromoteError> { + let db = |e: sqlx::Error| PromoteError::Db(format!("Failed to promote: {e}")); + let mut tx = pool.begin().await.map_err(db)?; + let (stack, provider): (String, String) = + sqlx::query_as("SELECT stack, provider FROM baked_snapshots WHERE id = $1") + .bind(snapshot_id) + .fetch_optional(&mut *tx) + .await + .map_err(db)? + .ok_or(PromoteError::NotFound)?; + // Lock the stack's pointers: two promotes, or a bake landing meanwhile, + // must not interleave. + let rows: Vec<(String, i32)> = sqlx::query_as( + "SELECT channel, snapshot_id FROM baked_snapshot_channels WHERE stack = $1 AND provider = $2 FOR UPDATE", + ) + .bind(&stack) + .bind(&provider) + .fetch_all(&mut *tx) + .await + .map_err(db)?; + let state = to_state(rows); + if state.unstable != Some(snapshot_id) { + return Err(PromoteError::NotUnstable); + } + let Some(moves) = promote_ladder(&state) else { + return Ok(None); + }; + point( + &mut *tx, + &stack, + &provider, + CHANNEL_LATEST, + moves.new_latest, + ) + .await + .map_err(PromoteError::Db)?; + if let Some(stable) = moves.new_stable { + point(&mut *tx, &stack, &provider, CHANNEL_STABLE, stable) + .await + .map_err(PromoteError::Db)?; + } + tx.commit().await.map_err(db)?; + Ok(Some(moves)) +} diff --git a/src/db/baked_snapshot.rs b/src/db/baked_snapshot.rs index ab65abfd..8a043da0 100644 --- a/src/db/baked_snapshot.rs +++ b/src/db/baked_snapshot.rs @@ -1,26 +1,34 @@ use crate::models::BakedSnapshot; use sqlx::PgPool; -/// Resolve the most recent healthy baked snapshot image_id for a stack+version -/// on a provider. Used by the clone deploy path to find what to clone from. +/// Which snapshots buyers may get, and in what order: a gated snapshot (baked +/// through the API) only through the `latest` or `stable` channel; `latest` +/// first, then `stable`, then the newest ungated one, so stacks baked before +/// channels existed keep working. `s` is `baked_snapshots`. +const SERVABLE: &str = r#" + LEFT JOIN baked_snapshot_channels c + ON c.snapshot_id = s.id AND c.channel IN ('latest', 'stable') + WHERE s.healthy = TRUE + AND (s.gated = FALSE OR c.channel IS NOT NULL) +"#; +const SERVE_ORDER: &str = r#" + ORDER BY CASE c.channel WHEN 'latest' THEN 0 WHEN 'stable' THEN 1 ELSE 2 END, + s.created_at DESC + LIMIT 1 +"#; + +/// Resolve the snapshot buyers get for a stack+version on a provider. Used by +/// the clone deploy path to find what to clone from. pub async fn resolve( pool: &PgPool, stack: &str, version: &str, provider: &str, ) -> Result, String> { - sqlx::query_as::<_, BakedSnapshot>( - r#" - SELECT * - FROM baked_snapshots - WHERE stack = $1 - AND version = $2 - AND provider = $3 - AND healthy = TRUE - ORDER BY created_at DESC - LIMIT 1 - "#, - ) + sqlx::query_as::<_, BakedSnapshot>(&format!( + "SELECT s.* FROM baked_snapshots s {SERVABLE} \ + AND s.stack = $1 AND s.version = $2 AND s.provider = $3 {SERVE_ORDER}" + )) .bind(stack) .bind(version) .bind(provider) @@ -29,24 +37,17 @@ pub async fn resolve( .map_err(|e| format!("Failed to resolve baked snapshot: {e}")) } -/// Resolve any healthy snapshot for a stack (any version), newest first. -/// Used when the exact version has not been baked but a generic one exists. +/// Resolve the snapshot buyers get for a stack (any version): `latest`, else +/// `stable`, else the newest ungated healthy one. pub async fn resolve_latest( pool: &PgPool, stack: &str, provider: &str, ) -> Result, String> { - sqlx::query_as::<_, BakedSnapshot>( - r#" - SELECT * - FROM baked_snapshots - WHERE stack = $1 - AND provider = $2 - AND healthy = TRUE - ORDER BY created_at DESC - LIMIT 1 - "#, - ) + sqlx::query_as::<_, BakedSnapshot>(&format!( + "SELECT s.* FROM baked_snapshots s {SERVABLE} \ + AND s.stack = $1 AND s.provider = $2 {SERVE_ORDER}" + )) .bind(stack) .bind(provider) .fetch_optional(pool) @@ -54,7 +55,9 @@ pub async fn resolve_latest( .map_err(|e| format!("Failed to resolve latest baked snapshot: {e}")) } -/// Record a published bake. Returns the new row. +/// Record a published bake. Returns the new row. The snapshot is served at +/// once (ungated): the `bake` binary's path. +#[allow(clippy::too_many_arguments)] pub async fn record( pool: &PgPool, stack: &str, @@ -66,11 +69,88 @@ pub async fn record( config_contract: Option, required_env_keys: Option, server_type: Option<&str>, +) -> Result { + insert( + pool, + stack, + version, + provider, + image_id, + healthy, + digests, + config_contract, + required_env_keys, + server_type, + false, + ) + .await +} + +/// Record a bake that must pass a smoke clone before buyers get it: the row is +/// gated and lands on the `unstable` channel, in one transaction. +#[allow(clippy::too_many_arguments)] +pub async fn record_unstable( + pool: &PgPool, + stack: &str, + version: &str, + provider: &str, + image_id: i64, + healthy: bool, + digests: Option, + config_contract: Option, + required_env_keys: Option, + server_type: Option<&str>, +) -> Result { + let mut tx = pool + .begin() + .await + .map_err(|e| format!("Failed to record baked snapshot: {e}"))?; + let row = insert( + &mut *tx, + stack, + version, + provider, + image_id, + healthy, + digests, + config_contract, + required_env_keys, + server_type, + true, + ) + .await?; + crate::db::bake_registry::point( + &mut *tx, + &row.stack, + &row.provider, + crate::helpers::bake_registry::CHANNEL_UNSTABLE, + row.id, + ) + .await?; + tx.commit() + .await + .map_err(|e| format!("Failed to record baked snapshot: {e}"))?; + Ok(row) +} + +#[allow(clippy::too_many_arguments)] +async fn insert<'e, E: sqlx::PgExecutor<'e>>( + executor: E, + stack: &str, + version: &str, + provider: &str, + image_id: i64, + healthy: bool, + digests: Option, + config_contract: Option, + required_env_keys: Option, + server_type: Option<&str>, + gated: bool, ) -> Result { sqlx::query_as::<_, BakedSnapshot>( r#" - INSERT INTO baked_snapshots (stack, version, provider, image_id, healthy, digests, config_contract, required_env_keys, server_type) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + INSERT INTO baked_snapshots (stack, version, provider, image_id, healthy, digests, config_contract, required_env_keys, server_type, gated) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING * "#, ) @@ -83,7 +163,8 @@ pub async fn record( .bind(config_contract) .bind(required_env_keys) .bind(server_type) - .fetch_one(pool) + .bind(gated) + .fetch_one(executor) .await .map_err(|e| format!("Failed to record baked snapshot: {e}")) } diff --git a/src/db/mod.rs b/src/db/mod.rs index 85d65a78..b722d52a 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2,6 +2,7 @@ pub mod agent; pub mod agent_audit_log; pub(crate) mod agreement; pub mod bake_job; +pub mod bake_registry; pub mod baked_snapshot; pub mod chat; pub mod chat_admin; diff --git a/src/models/baked_snapshot.rs b/src/models/baked_snapshot.rs index e1c17fc5..5063f6a9 100644 --- a/src/models/baked_snapshot.rs +++ b/src/models/baked_snapshot.rs @@ -33,6 +33,10 @@ pub struct BakedSnapshot { /// baked before this column existed — the default type applies. #[sqlx(default)] pub server_type: Option, + /// Baked through the API: reaches buyers only once a channel (`latest` or + /// `stable`) points at it. `false` for older rows and the `bake` binary's. + #[sqlx(default)] + pub gated: bool, pub created_at: DateTime, } diff --git a/src/routes/admin_bake.rs b/src/routes/admin_bake.rs index 014963ed..a50db1ef 100644 --- a/src/routes/admin_bake.rs +++ b/src/routes/admin_bake.rs @@ -1,4 +1,5 @@ -//! `POST /api/admin/bake` and `GET /api/admin/bake/{id}`: bake a deployed +//! `POST /api/admin/bake`, `GET /api/admin/bake/{id}` and +//! `POST /api/admin/bake/{id}/promote`: bake a deployed //! build box into a Quick Deploy snapshot without the operator typing its IP, //! SSH key or version. //! @@ -8,6 +9,9 @@ //! box being one Hetzner knows. Only then does the job connect, check that //! every service is up, sanitize the box, snapshot it and record the snapshot. //! The box is never removed: the result lists it for the operator. +//! +//! The snapshot lands on the `unstable` channel, which no buyer gets. After a +//! green smoke clone the harness promotes it to `latest`. use std::collections::BTreeSet; use std::sync::Arc; @@ -341,7 +345,7 @@ async fn run_job( .map(serde_json::Value::String) .collect(), ); - let row = db::baked_snapshot::record( + let row = db::baked_snapshot::record_unstable( pool, &record.stack, &record.version, @@ -372,3 +376,43 @@ pub async fn status_handler( .ok_or_else(|| JsonResponse::not_found("bake not found"))?; Ok(JsonResponse::build().set_item(job).ok("OK")) } + +/// `POST /api/admin/bake/{id}/promote`: the smoke clone of this bake passed, so +/// its snapshot becomes `latest` and the previous `latest` becomes `stable`. +#[tracing::instrument(name = "Promote a bake (admin)", skip_all)] +#[post("/{id}/promote")] +pub async fn promote_handler( + _admin: web::ReqData>, // role enforced by Casbin + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| JsonResponse::bad_request("invalid bake id"))?; + let job = db::bake_job::fetch(pg_pool.get_ref(), id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("bake not found"))?; + let Some(snapshot_id) = job.snapshot_id.filter(|_| job.status == "done") else { + return Err(JsonResponse::<()>::build().conflict(format!( + "the bake is {}, it has no snapshot to promote", + job.status + ))); + }; + let moves = db::bake_registry::promote(pg_pool.get_ref(), snapshot_id) + .await + .map_err(|e| match e { + db::bake_registry::PromoteError::Db(e) => JsonResponse::internal_server_error(e), + other => JsonResponse::<()>::build().conflict(other.to_string()), + })?; + let state = db::bake_registry::state_for_snapshot(pg_pool.get_ref(), snapshot_id) + .await + .map_err(JsonResponse::internal_server_error)?; + Ok(JsonResponse::build() + .set_item(serde_json::json!({ + "snapshot_id": snapshot_id, + "promoted": moves.is_some(), + "latest": state.latest, + "stable": state.stable, + })) + .ok("OK")) +} diff --git a/src/startup.rs b/src/startup.rs index 0ea3aee7..6af95a67 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -475,7 +475,8 @@ pub async fn run( .service( web::scope("/bake") .service(crate::routes::admin_bake::start_handler) - .service(crate::routes::admin_bake::status_handler), + .service(crate::routes::admin_bake::status_handler) + .service(crate::routes::admin_bake::promote_handler), ) .service( web::scope("/templates") diff --git a/tests/bake_channels.rs b/tests/bake_channels.rs new file mode 100644 index 00000000..556749a5 --- /dev/null +++ b/tests/bake_channels.rs @@ -0,0 +1,208 @@ +//! Release channels for baked snapshots (BAKE_PIPELINE_PLAN.md, Stage 4). +//! +//! A bake started from the API lands on `unstable`. Buyers get a snapshot only +//! once a green smoke clone promotes it to `latest`; the old `latest` becomes +//! `stable`. Snapshots recorded before channels existed (and by the `bake` +//! binary) keep the old "newest healthy" behaviour, so live links do not break. + +mod common; + +use chrono::{Duration, Utc}; +use serde_json::{json, Value}; +use stacker::db::{bake_job, bake_registry, baked_snapshot}; + +const PROVIDER: &str = "hetzner"; + +/// A snapshot recorded the old way: served at once. +async fn legacy(pool: &sqlx::PgPool, stack: &str, version: &str, image_id: i64) -> i32 { + baked_snapshot::record( + pool, stack, version, PROVIDER, image_id, true, None, None, None, None, + ) + .await + .expect("record legacy snapshot") + .id +} + +/// A snapshot baked through the API: waits on `unstable`. +async fn baked(pool: &sqlx::PgPool, stack: &str, version: &str, image_id: i64) -> i32 { + baked_snapshot::record_unstable( + pool, stack, version, PROVIDER, image_id, true, None, None, None, None, + ) + .await + .expect("record unstable snapshot") + .id +} + +async fn served(pool: &sqlx::PgPool, stack: &str) -> Option { + baked_snapshot::resolve_latest(pool, stack, PROVIDER) + .await + .expect("resolve latest") + .map(|s| s.image_id) +} + +async fn served_version(pool: &sqlx::PgPool, stack: &str, version: &str) -> Option { + baked_snapshot::resolve(pool, stack, version, PROVIDER) + .await + .expect("resolve version") + .map(|s| s.image_id) +} + +#[tokio::test] +async fn a_bake_reaches_buyers_only_after_promotion() { + let Some(app) = common::spawn_app().await else { + return; + }; + let pool = &app.db_pool; + let stack = "chan-promote"; + + legacy(pool, stack, "1.0.0", 100).await; + let b = baked(pool, stack, "1.1.0", 200).await; + + assert_eq!( + served(pool, stack).await, + Some(100), + "the unpromoted bake is not served" + ); + assert_eq!( + served_version(pool, stack, "1.1.0").await, + None, + "not even when its version is asked for" + ); + let state = bake_registry::state(pool, stack, PROVIDER).await.unwrap(); + assert_eq!(state.unstable, Some(b)); + assert_eq!(state.latest, None); + + let moves = bake_registry::promote(pool, b).await.expect("promote"); + assert!(moves.is_some(), "first promotion moves latest"); + assert_eq!(served(pool, stack).await, Some(200)); + assert_eq!(served_version(pool, stack, "1.1.0").await, Some(200)); + + // Promoting again is a no-op, not an error: the harness may retry. + assert_eq!(bake_registry::promote(pool, b).await.expect("repeat"), None); + + // The next bake waits too; once promoted, the old latest becomes stable. + let c = baked(pool, stack, "1.2.0", 300).await; + assert_eq!(served(pool, stack).await, Some(200)); + bake_registry::promote(pool, c).await.expect("promote c"); + let state = bake_registry::state(pool, stack, PROVIDER).await.unwrap(); + assert_eq!((state.latest, state.stable), (Some(c), Some(b))); + assert_eq!(served(pool, stack).await, Some(300)); + assert_eq!( + served_version(pool, stack, "1.1.0").await, + Some(200), + "stable stays deployable by its version" + ); +} + +#[tokio::test] +async fn a_bake_that_was_never_promoted_stays_hidden_after_the_next_one() { + let Some(app) = common::spawn_app().await else { + return; + }; + let pool = &app.db_pool; + let stack = "chan-failed-smoke"; + + let failed = baked(pool, stack, "1.0.0", 100).await; + let next = baked(pool, stack, "1.0.0", 200).await; + + assert_eq!( + served(pool, stack).await, + None, + "losing the unstable pointer must not make the failed bake servable" + ); + assert!( + bake_registry::promote(pool, failed).await.is_err(), + "only the current unstable bake can be promoted" + ); + bake_registry::promote(pool, next) + .await + .expect("promote next"); + assert_eq!(served(pool, stack).await, Some(200)); +} + +#[tokio::test] +async fn a_legacy_snapshot_cannot_be_promoted() { + let Some(app) = common::spawn_app().await else { + return; + }; + let pool = &app.db_pool; + let id = legacy(pool, "chan-legacy", "1.0.0", 100).await; + assert!(bake_registry::promote(pool, id).await.is_err()); + assert!( + bake_registry::promote(pool, 999_999).await.is_err(), + "unknown id" + ); + assert_eq!(served(pool, "chan-legacy").await, Some(100)); +} + +fn admin_jwt() -> String { + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + let header = URL_SAFE_NO_PAD.encode(json!({"alg": "HS256", "typ": "JWT"}).to_string()); + let payload = URL_SAFE_NO_PAD.encode( + json!({ + "role": "admin_service", + "email": "ops@test.com", + "exp": (Utc::now() + Duration::minutes(30)).timestamp(), + }) + .to_string(), + ); + let signature = common::sign_test_jwt(&header, &payload); + format!("{header}.{payload}.{signature}") +} + +async fn promote_job(app: &common::TestApp, id: &str, token: &str) -> (u16, Value) { + let resp = reqwest::Client::new() + .post(format!("{}/api/admin/bake/{id}/promote", app.address)) + .bearer_auth(token) + .send() + .await + .expect("request"); + let status = resp.status().as_u16(); + (status, resp.json().await.unwrap_or(Value::Null)) +} + +#[tokio::test] +async fn the_harness_promotes_a_finished_bake_by_its_job_id() { + let Some(app) = common::spawn_app().await else { + return; + }; + let pool = &app.db_pool; + let stack = "chan-endpoint"; + let job = bake_job::insert( + pool, + bake_job::NewBakeJob { + stack, + version: "1.0.0", + deployment_id: 1, + server_id: 1, + server_ip: "203.0.113.9", + created_by: "ops@test.com", + }, + ) + .await + .expect("job"); + let id = job.id.to_string(); + + let (status, body) = promote_job(&app, &id, &admin_jwt()).await; + assert_eq!(status, 409, "a running bake has no snapshot yet: {body}"); + + let snapshot = baked(pool, stack, "1.0.0", 100).await; + bake_job::mark_done(pool, job.id, 100, snapshot) + .await + .expect("done"); + + let (status, body) = promote_job(&app, &id, common::USER_A_TOKEN).await; + assert!(matches!(status, 401 | 403), "not an admin: {status} {body}"); + assert_eq!(served(pool, stack).await, None); + + let (status, body) = promote_job(&app, &id, &admin_jwt()).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["item"]["latest"], snapshot, "{body}"); + assert_eq!(served(pool, stack).await, Some(100)); + + let (status, body) = promote_job(&app, &id, &admin_jwt()).await; + assert_eq!(status, 200, "a repeated promote is a no-op: {body}"); + + let (status, _) = promote_job(&app, &uuid::Uuid::new_v4().to_string(), &admin_jwt()).await; + assert_eq!(status, 404); +} From 6a07988690fb26908dca9f5b2eba7747536178b5 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Wed, 28 Oct 2026 10:40:11 +0200 Subject: [PATCH 2/2] Bake channels: the smoke clone reaches the unpromoted bake The smoke clone goes through POST /api/v1/deploy/clone, which serves only latest/stable. A fresh bake sits on unstable, so the smoke could never find it and nothing would ever be promoted. clone takes an optional `channel: "unstable"`, for group_admin only (403 otherwise, 400 for any other channel). It resolves exactly the bake the unstable pointer holds, with no fallback to a released snapshot. Buyers who omit it are unaffected. Co-Authored-By: Claude Opus 5.5 --- src/db/baked_snapshot.rs | 25 +++++++ src/routes/oneclick_deploy/clone.rs | 26 ++++++- tests/bake_channels.rs | 102 ++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/src/db/baked_snapshot.rs b/src/db/baked_snapshot.rs index 8a043da0..7d9435c8 100644 --- a/src/db/baked_snapshot.rs +++ b/src/db/baked_snapshot.rs @@ -55,6 +55,31 @@ pub async fn resolve_latest( .map_err(|e| format!("Failed to resolve latest baked snapshot: {e}")) } +/// The bake a stack's `unstable` channel points at: what the smoke clone checks +/// before promotion. `version`, when given, must be that bake's version; there +/// is no fallback to a released snapshot. +pub async fn resolve_unstable( + pool: &PgPool, + stack: &str, + version: Option<&str>, + provider: &str, +) -> Result, String> { + sqlx::query_as::<_, BakedSnapshot>( + r#" + SELECT s.* FROM baked_snapshot_channels c + JOIN baked_snapshots s ON s.id = c.snapshot_id + WHERE c.stack = $1 AND c.provider = $2 AND c.channel = 'unstable' + AND s.healthy = TRUE AND ($3::TEXT IS NULL OR s.version = $3) + "#, + ) + .bind(stack) + .bind(provider) + .bind(version) + .fetch_optional(pool) + .await + .map_err(|e| format!("Failed to resolve the unstable baked snapshot: {e}")) +} + /// Record a published bake. Returns the new row. The snapshot is served at /// once (ungated): the `bake` binary's path. #[allow(clippy::too_many_arguments)] diff --git a/src/routes/oneclick_deploy/clone.rs b/src/routes/oneclick_deploy/clone.rs index e2cd07eb..9a1baea8 100644 --- a/src/routes/oneclick_deploy/clone.rs +++ b/src/routes/oneclick_deploy/clone.rs @@ -45,6 +45,10 @@ pub struct CloneRequest { pub admin_email: String, #[serde(default)] pub env: BTreeMap, + /// Only `unstable`, and only for an admin: the bake harness's smoke clone of + /// a bake not yet promoted. Buyers omit it and get `latest`/`stable`. + #[serde(default)] + pub channel: Option, } fn default_provider() -> String { @@ -120,7 +124,27 @@ pub async fn clone_server( } // Resolve the baked snapshot image_id. - let snapshot = match if let Some(version) = &form.version { + let snapshot = match if let Some(channel) = &form.channel { + if channel != crate::helpers::bake_registry::CHANNEL_UNSTABLE { + return HttpResponse::BadRequest().json(json!({ + "error": "Unknown channel", + "details": format!("channel '{channel}' cannot be cloned; omit it, or use 'unstable'"), + })); + } + if user.role != "group_admin" { + return HttpResponse::Forbidden().json(json!({ + "error": "Forbidden", + "details": "only an admin can clone a bake that has not been promoted", + })); + } + crate::db::baked_snapshot::resolve_unstable( + &pg_pool, + &form.stack, + form.version.as_deref(), + &form.provider, + ) + .await + } else if let Some(version) = &form.version { crate::db::baked_snapshot::resolve(&pg_pool, &form.stack, version, &form.provider).await } else { crate::db::baked_snapshot::resolve_latest(&pg_pool, &form.stack, &form.provider).await diff --git a/tests/bake_channels.rs b/tests/bake_channels.rs index 556749a5..ac03107d 100644 --- a/tests/bake_channels.rs +++ b/tests/bake_channels.rs @@ -206,3 +206,105 @@ async fn the_harness_promotes_a_finished_bake_by_its_job_id() { let (status, _) = promote_job(&app, &uuid::Uuid::new_v4().to_string(), &admin_jwt()).await; assert_eq!(status, 404); } + +// The smoke clone has to reach the bake it checks, which buyers cannot see yet. + +#[tokio::test] +async fn the_smoke_clone_finds_the_unpromoted_bake() { + let Some(app) = common::spawn_app().await else { + return; + }; + let pool = &app.db_pool; + let stack = "chan-smoke-resolve"; + legacy(pool, stack, "1.0.0", 100).await; + baked(pool, stack, "1.1.0", 200).await; + + let unstable = |version: Option<&'static str>| async move { + baked_snapshot::resolve_unstable(pool, stack, version, PROVIDER) + .await + .expect("resolve unstable") + .map(|s| s.image_id) + }; + assert_eq!(unstable(None).await, Some(200)); + assert_eq!(unstable(Some("1.1.0")).await, Some(200)); + assert_eq!( + unstable(Some("1.0.0")).await, + None, + "a version that is not the unstable bake is not a fallback" + ); + assert_eq!( + served(pool, stack).await, + Some(100), + "buyers still get 1.0.0" + ); +} + +async fn spawn_admin_app() -> Option { + use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate}; + let auth = MockServer::start().await; + let user = stacker::forms::user::User { + id: common::USER_A_ID.to_string(), + email: common::USER_A_EMAIL.to_string(), + role: "group_admin".to_string(), + email_confirmed: true, + ..Default::default() + }; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200).set_body_json(stacker::forms::user::UserForm { user }), + ) + .mount(&auth) + .await; + let mut configuration = + stacker::configuration::get_configuration().expect("Failed to get configuration"); + configuration.auth_url = format!("{}/me", auth.uri()); + // The mock must outlive the app, which lives for the rest of the test. + std::mem::forget(auth); + common::spawn_app_with_configuration(configuration).await +} + +async fn clone_on_channel(app: &common::TestApp, stack: &str, channel: &str) -> (u16, Value) { + let resp = reqwest::Client::new() + .post(format!("{}/api/v1/deploy/clone", app.address)) + .bearer_auth("user-token") + .json(&json!({ + "stack": stack, + "domain": format!("{stack}-smoke.example.com"), + "channel": channel, + })) + .send() + .await + .expect("request"); + let status = resp.status().as_u16(); + (status, resp.json().await.unwrap_or(Value::Null)) +} + +#[tokio::test] +async fn only_an_admin_may_clone_an_unpromoted_bake() { + let Some(app) = common::spawn_app().await else { + return; + }; + let stack = "chan-smoke-buyer"; + baked(&app.db_pool, stack, "1.0.0", 100).await; + + let (status, body) = clone_on_channel(&app, stack, "unstable").await; + assert_eq!( + status, 403, + "a buyer cannot clone an unreleased bake: {body}" + ); +} + +#[tokio::test] +async fn an_admin_smoke_clone_does_not_fall_back_to_the_released_snapshot() { + let Some(app) = spawn_admin_app().await else { + return; + }; + let stack = "chan-smoke-admin"; + legacy(&app.db_pool, stack, "1.0.0", 100).await; + + let (status, body) = clone_on_channel(&app, stack, "unstable").await; + assert_eq!(status, 404, "no unstable bake, so nothing to smoke: {body}"); + + let (status, body) = clone_on_channel(&app, stack, "nightly").await; + assert_eq!(status, 400, "an unknown channel is refused: {body}"); +}