mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-21 00:02:30 +00:00
fix: chain redeploys onto a retired path's version history
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KMhCSFeWSezY61g6woQiz2
This commit is contained in:
co-authored by
Claude Opus 5
parent
946756ae83
commit
17ae0d6b8d
@@ -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<String> {
|
||||
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<Option<String>> {
|
||||
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<Postgres>,
|
||||
) -> 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(())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user