diff --git a/backend/tests/redeploy_archived_script.rs b/backend/tests/redeploy_archived_script.rs new file mode 100644 index 0000000000..a530da59d5 --- /dev/null +++ b/backend/tests/redeploy_archived_script.rs @@ -0,0 +1,116 @@ +//! Pins that a path holding no live version can be redeployed with the content it +//! already holds in its history. +//! +//! A script hash is derived from (path, parent, content, metadata), so a deploy that +//! resolves to no parent re-derives the hash of the version that started the lineage +//! and lands on that row. `wmill sync push` walks straight into it: a locally deleted +//! file archives the path, and the push then redeploys it unchanged. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn new_script(path: &str) -> serde_json::Value { + json!({ + "path": path, + "summary": "", + "description": "", + "content": "export async function main() { return 1; }", + "language": "deno", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "required": [] + } + }) +} + +async fn create(port: u16, script: serde_json::Value) -> anyhow::Result { + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create?skip_if_noop=true" + ))) + .json(&script) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 201, "script create should succeed: {body}"); + Ok(body) +} + +async fn post(port: u16, route: &str) -> anyhow::Result<()> { + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/{route}" + ))) + .send() + .await?; + assert!(resp.status().is_success(), "{route} should succeed"); + Ok(()) +} + +/// The live version at `path`, or `None` when the path holds none. +async fn live_hash(port: u16, path: &str) -> anyhow::Result> { + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/get/p/{path}" + ))) + .send() + .await?; + if !resp.status().is_success() { + return Ok(None); + } + let script: serde_json::Value = resp.json().await?; + Ok(Some(script["hash"].as_str().unwrap().to_string())) +} + +#[sqlx::test(fixtures("base"))] +async fn test_redeploy_unchanged_content_onto_retired_path( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The CLI's own shape: it names the version it read and asks the server to re-resolve + // the parent, which finds nothing live once the archive landed. + let archived_path = "u/test-user/retired_auto_parent"; + let v1 = create(port, new_script(archived_path)).await?; + post(port, &format!("scripts/archive/p/{archived_path}")).await?; + let mut redeploy = new_script(archived_path); + redeploy["parent_hash"] = json!(v1); + redeploy["auto_parent"] = json!(true); + let v2 = create(port, redeploy).await?; + assert_ne!( + v1, v2, + "the redeploy must be a new version, not the archived one" + ); + assert_eq!( + live_hash(port, archived_path).await?, + Some(v2), + "the redeployed script must be the live version at its path" + ); + + // A retried push sends no parent at all: the archived path is absent from the + // listing it diffed against, so it deploys as if the path were new. + let parentless_path = "u/test-user/retired_parentless"; + create(port, new_script(parentless_path)).await?; + post(port, &format!("scripts/archive/p/{parentless_path}")).await?; + create(port, new_script(parentless_path)).await?; + + // A deleted version keeps its row, and its hash with it. + let deleted_path = "u/test-user/retired_deleted"; + let deleted = create(port, new_script(deleted_path)).await?; + post(port, &format!("scripts/delete/h/{deleted}")).await?; + create(port, new_script(deleted_path)).await?; + + Ok(()) +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 97e3019835..3d06b982af 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -1318,6 +1318,30 @@ async fn create_script_internal<'c>( } } + // A path whose versions are all archived or deleted still owns them — the rows stay — + // so a deploy there is the path's next version, not its first, and must carry the + // lineage to be hashed apart from it. Resolving to no parent instead re-derives the + // hash the lineage started from, which the guard below then rejects as a duplicate of + // a version nothing can reach. `wmill sync push` walks into exactly that: a locally + // deleted file archives the path, and the push redeploys it unchanged. + // + // Guarded on nothing being live at the path, so a parentless deploy onto a live one + // still meets the path-conflict refusal below; and on the tip having no descendant, + // so the linear-lineage check cannot fire on it. + if ns.parent_hash.is_none() && clashing_script.is_none() { + ns.parent_hash = sqlx::query_scalar::<_, i64>( + "SELECT hash FROM script s WHERE s.path = $1 AND s.workspace_id = $2 \ + AND NOT EXISTS (SELECT 1 FROM script c WHERE c.workspace_id = $2 \ + AND c.parent_hashes[1] = s.hash) \ + ORDER BY created_at DESC LIMIT 1", + ) + .bind(&ns.path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await? + .map(ScriptHash); + } + // Must stay below the parent resolution above: an auto_parent deploy hashed before // it carries a first deploy's lineage, so redeploying content the path has held // before collides with that archived version instead of superseding it. The