mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
2ec1863340
* test(git-sync): regression tests for secondary promotion repos Adds two integration tests that reproduce the bug where a second promotion-mode repo's deployment callback was silently dropped via debounce-key collision, plus the EE ref bump that includes the fix. Updates the two existing promotion-mode debounce-key tests to expect the new repo-namespaced key shape. * test(git-sync): drop redundant distinct-debounce-keys test The behavior test (`test_two_promotion_repos_both_enqueue_callback`) already covers the same regression one layer up: if the debounce keys collide, one callback gets marked skipped, which the behavior test catches. * chore: update ee-repo-ref to 7a32388adaa37eb1dd1820b40e140ff1877110f2 This commit updates the EE repository reference after PR #572 was merged in windmill-ee-private. Previous ee-repo-ref: dbf26f5e4c01c0de536f606679be46eb316aaf31 New ee-repo-ref: 7a32388adaa37eb1dd1820b40e140ff1877110f2 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
799 lines
27 KiB
Rust
799 lines
27 KiB
Rust
/*!
|
|
* Integration tests for workspace dependencies git sync.
|
|
*
|
|
* These tests verify that creating, archiving, and deleting workspace dependencies
|
|
* triggers deployment callback jobs with the correct arguments for git sync.
|
|
*
|
|
* Run with enterprise features:
|
|
* ```bash
|
|
* cargo test --test workspace_dependencies_git_sync --features enterprise,private
|
|
* ```
|
|
*/
|
|
|
|
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
use std::time::Duration;
|
|
|
|
#[allow(unused_imports)]
|
|
use windmill_test_utils::*;
|
|
|
|
/// Row shape for querying deployment callback jobs from v2_job_queue
|
|
#[derive(Debug)]
|
|
#[allow(dead_code)]
|
|
struct DeploymentCallbackJob {
|
|
id: uuid::Uuid,
|
|
runnable_path: Option<String>,
|
|
args: Option<serde_json::Value>,
|
|
kind: String,
|
|
}
|
|
|
|
/// Poll for deployment callback jobs in the queue for a given script path
|
|
#[allow(dead_code)]
|
|
async fn get_deployment_callback_jobs(
|
|
db: &Pool<Postgres>,
|
|
script_path: &str,
|
|
timeout: Duration,
|
|
) -> anyhow::Result<Vec<DeploymentCallbackJob>> {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
let rows = sqlx::query_as!(
|
|
DeploymentCallbackJob,
|
|
r#"
|
|
SELECT j.id, j.runnable_path, j.args, j.kind::text AS "kind!"
|
|
FROM v2_job j
|
|
JOIN v2_job_queue q ON j.id = q.id
|
|
WHERE j.runnable_path = $1
|
|
AND j.kind = 'deploymentcallback'
|
|
ORDER BY j.created_at DESC
|
|
"#,
|
|
script_path,
|
|
)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
if !rows.is_empty() {
|
|
return Ok(rows);
|
|
}
|
|
|
|
if tokio::time::Instant::now() >= deadline {
|
|
// Return empty if timeout - caller will handle assertion
|
|
return Ok(vec![]);
|
|
}
|
|
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
/// Configure git sync for the test workspace with workspace dependencies enabled
|
|
#[allow(dead_code)]
|
|
async fn setup_git_sync_config(db: &Pool<Postgres>, sync_script_path: &str) -> anyhow::Result<()> {
|
|
let git_sync_config = json!({
|
|
"include_type": ["workspacedependencies"],
|
|
"include_path": ["**"],
|
|
"repositories": [{
|
|
"script_path": sync_script_path,
|
|
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
|
|
"use_individual_branch": false,
|
|
"group_by_folder": false
|
|
}]
|
|
});
|
|
|
|
sqlx::query!(
|
|
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
|
git_sync_config,
|
|
"test-workspace"
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a git repository resource for testing
|
|
#[allow(dead_code)]
|
|
async fn create_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
|
|
VALUES ('test-workspace', 'u/test-user/test_git_repo', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user')
|
|
ON CONFLICT (workspace_id, path) DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(json!({
|
|
"url": "https://github.com/test/test.git",
|
|
"branch": "main",
|
|
"token": "test-token"
|
|
}))
|
|
.execute(db)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a dummy sync script for testing (with version >= 28103 for debouncing support)
|
|
#[allow(dead_code)]
|
|
async fn create_sync_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
|
|
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO script (workspace_id, hash, path, summary, description, content,
|
|
created_by, language, kind, lock)
|
|
VALUES ('test-workspace', $1, $2, 'sync script', '',
|
|
'export function main(items: any[]) { return { synced: items.length }; }',
|
|
'test-user', 'bun', 'script', '')
|
|
"#,
|
|
)
|
|
.bind(hash)
|
|
.bind(path)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(hash)
|
|
}
|
|
|
|
/// Create a folder for the versioned script path
|
|
#[allow(dead_code)]
|
|
async fn create_folder(db: &Pool<Postgres>, name: &str) -> anyhow::Result<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by)
|
|
VALUES ('test-workspace', $1, $1, ARRAY['u/test-user'], '{}'::jsonb, 'test-user')
|
|
ON CONFLICT (workspace_id, name) DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(name)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tests
|
|
// ============================================================================
|
|
|
|
/// Test that creating a workspace dependency triggers a git sync deployment callback
|
|
/// with the correct path_type and path arguments.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_create_workspace_dependencies_triggers_git_sync(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
// Setup: Create folder, git repo resource, sync script, and configure git sync
|
|
create_folder(&db, "28103").await?;
|
|
create_git_repo_resource(&db).await?;
|
|
let sync_script_path = "f/28103/test_sync_script";
|
|
create_sync_script(&db, sync_script_path).await?;
|
|
setup_git_sync_config(&db, sync_script_path).await?;
|
|
|
|
// Start API server
|
|
let (client, _port, _server) = init_client(db.clone()).await;
|
|
|
|
// Create workspace dependency via API
|
|
let response = client
|
|
.client()
|
|
.post(format!(
|
|
"{}/w/test-workspace/workspace_dependencies/create",
|
|
client.baseurl()
|
|
))
|
|
.json(&json!({
|
|
"workspace_id": "test-workspace",
|
|
"language": "python3",
|
|
"name": "test-deps",
|
|
"content": "requests==2.28.0\nnumpy==1.24.0"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
assert!(
|
|
response.status().is_success(),
|
|
"Failed to create workspace dependency: {:?}",
|
|
response.text().await
|
|
);
|
|
|
|
// Wait for deployment callback job to be created
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
// Query for deployment callback jobs
|
|
let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?;
|
|
|
|
assert!(
|
|
!jobs.is_empty(),
|
|
"Expected at least one deployment callback job to be created"
|
|
);
|
|
|
|
// Verify the job arguments
|
|
let job = &jobs[0];
|
|
let args = job.args.as_ref().expect("Job should have args");
|
|
|
|
// Check that path_type is "workspace_dependencies" (or check items array)
|
|
// The exact structure depends on whether debouncing is enabled
|
|
if let Some(items) = args.get("items") {
|
|
// Debounced format: items is an array
|
|
let items_arr = items.as_array().expect("items should be an array");
|
|
assert!(!items_arr.is_empty(), "items array should not be empty");
|
|
|
|
let item = &items_arr[0];
|
|
assert_eq!(
|
|
item.get("path_type").and_then(|v| v.as_str()),
|
|
Some("workspace_dependencies"),
|
|
"path_type should be 'workspace_dependencies'"
|
|
);
|
|
|
|
// Path should be "dependencies/test-deps.requirements.in" or similar
|
|
let path = item.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
|
assert!(
|
|
path.contains("dependencies") || path.contains("requirements"),
|
|
"path should contain dependencies or requirements: got {}",
|
|
path
|
|
);
|
|
} else if let Some(path_type) = args.get("path_type") {
|
|
// Non-debounced format: path_type is a direct field
|
|
assert_eq!(
|
|
path_type.as_str(),
|
|
Some("workspace_dependencies"),
|
|
"path_type should be 'workspace_dependencies'"
|
|
);
|
|
} else {
|
|
panic!(
|
|
"Job args should contain either 'items' array or 'path_type' field: {:?}",
|
|
args
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Test that archiving a workspace dependency triggers a git sync deployment callback
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_archive_workspace_dependencies_triggers_git_sync(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
// Setup
|
|
create_folder(&db, "28103").await?;
|
|
create_git_repo_resource(&db).await?;
|
|
let sync_script_path = "f/28103/test_sync_script_archive";
|
|
create_sync_script(&db, sync_script_path).await?;
|
|
setup_git_sync_config(&db, sync_script_path).await?;
|
|
|
|
let (client, _port, _server) = init_client(db.clone()).await;
|
|
|
|
// First create a workspace dependency
|
|
let create_response = client
|
|
.client()
|
|
.post(format!(
|
|
"{}/w/test-workspace/workspace_dependencies/create",
|
|
client.baseurl()
|
|
))
|
|
.json(&json!({
|
|
"workspace_id": "test-workspace",
|
|
"language": "python3",
|
|
"name": "archive-test-deps",
|
|
"content": "flask==2.0.0"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
assert!(create_response.status().is_success());
|
|
|
|
// Wait a bit for the create job to be processed
|
|
tokio::time::sleep(Duration::from_millis(300)).await;
|
|
|
|
// Now archive it
|
|
let archive_response = client
|
|
.client()
|
|
.post(format!(
|
|
"{}/w/test-workspace/workspace_dependencies/archive/python3?name=archive-test-deps",
|
|
client.baseurl()
|
|
))
|
|
.send()
|
|
.await?;
|
|
|
|
assert!(
|
|
archive_response.status().is_success(),
|
|
"Failed to archive workspace dependency: {:?}",
|
|
archive_response.text().await
|
|
);
|
|
|
|
// Wait for deployment callback job
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
// Verify at least one deployment callback job exists
|
|
// (create test already validates create triggers git sync; this test validates archive does too)
|
|
let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?;
|
|
|
|
assert!(
|
|
!jobs.is_empty(),
|
|
"Expected at least one deployment callback job after archive"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Test that workspace dependencies are NOT synced when workspacedependencies type is excluded
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_workspace_dependencies_respects_include_type_filter(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
// Setup with git sync that EXCLUDES workspacedependencies
|
|
create_folder(&db, "28103").await?;
|
|
create_git_repo_resource(&db).await?;
|
|
let sync_script_path = "f/28103/test_sync_script_filter";
|
|
create_sync_script(&db, sync_script_path).await?;
|
|
|
|
// Configure git sync to only include scripts (not workspace dependencies)
|
|
let git_sync_config = json!({
|
|
"include_type": ["script"], // Note: workspacedependencies is NOT included
|
|
"include_path": ["**"],
|
|
"repositories": [{
|
|
"script_path": sync_script_path,
|
|
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
|
|
"use_individual_branch": false,
|
|
"group_by_folder": false
|
|
}]
|
|
});
|
|
|
|
sqlx::query!(
|
|
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
|
git_sync_config,
|
|
"test-workspace"
|
|
)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let (client, _port, _server) = init_client(db.clone()).await;
|
|
|
|
// Create workspace dependency
|
|
let response = client
|
|
.client()
|
|
.post(format!(
|
|
"{}/w/test-workspace/workspace_dependencies/create",
|
|
client.baseurl()
|
|
))
|
|
.json(&json!({
|
|
"workspace_id": "test-workspace",
|
|
"language": "python3",
|
|
"name": "filtered-deps",
|
|
"content": "django==4.0.0"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
assert!(response.status().is_success());
|
|
|
|
// Wait a bit
|
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
|
|
// Should NOT have any deployment callback jobs because workspacedependencies is filtered out
|
|
let jobs =
|
|
get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(500)).await?;
|
|
|
|
assert!(
|
|
jobs.is_empty(),
|
|
"Expected NO deployment callback jobs when workspacedependencies is not in include_type, got {}",
|
|
jobs.len()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Test that the commit message is correctly generated for workspace dependencies
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_workspace_dependencies_commit_message(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
// Setup
|
|
create_folder(&db, "28103").await?;
|
|
create_git_repo_resource(&db).await?;
|
|
let sync_script_path = "f/28103/test_sync_script_msg";
|
|
create_sync_script(&db, sync_script_path).await?;
|
|
setup_git_sync_config(&db, sync_script_path).await?;
|
|
|
|
let (client, _port, _server) = init_client(db.clone()).await;
|
|
|
|
// Create workspace dependency
|
|
let response = client
|
|
.client()
|
|
.post(format!(
|
|
"{}/w/test-workspace/workspace_dependencies/create",
|
|
client.baseurl()
|
|
))
|
|
.json(&json!({
|
|
"workspace_id": "test-workspace",
|
|
"language": "bun",
|
|
"name": null, // unnamed/default dependency
|
|
"content": "lodash: ^4.17.21"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
|
|
assert!(response.status().is_success());
|
|
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
let jobs = get_deployment_callback_jobs(&db, sync_script_path, Duration::from_secs(5)).await?;
|
|
assert!(!jobs.is_empty());
|
|
|
|
let job = &jobs[0];
|
|
let args = job.args.as_ref().expect("Job should have args");
|
|
|
|
// Check commit message format
|
|
if let Some(items) = args.get("items") {
|
|
let items_arr = items.as_array().expect("items should be an array");
|
|
if !items_arr.is_empty() {
|
|
let commit_msg = items_arr[0]
|
|
.get("commit_msg")
|
|
.and_then(|v| v.as_str())
|
|
.unwrap_or("");
|
|
|
|
assert!(
|
|
commit_msg.contains("[WM]"),
|
|
"Commit message should contain '[WM]' prefix: {}",
|
|
commit_msg
|
|
);
|
|
assert!(
|
|
commit_msg.to_lowercase().contains("workspace")
|
|
|| commit_msg.to_lowercase().contains("dependency")
|
|
|| commit_msg.to_lowercase().contains("deployed"),
|
|
"Commit message should mention workspace dependency or deployed: {}",
|
|
commit_msg
|
|
);
|
|
}
|
|
} else if let Some(commit_msg) = args.get("commit_msg").and_then(|v| v.as_str()) {
|
|
assert!(
|
|
commit_msg.contains("[WM]"),
|
|
"Commit message should contain '[WM]' prefix: {}",
|
|
commit_msg
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Promotion-mode debounce-key tests
|
|
// ============================================================================
|
|
|
|
/// Configure git sync in promotion mode (one branch per object), with explicit
|
|
/// include_type and include_path lists so script deploys fire the callback.
|
|
#[allow(dead_code)]
|
|
async fn setup_promotion_git_sync_config(
|
|
db: &Pool<Postgres>,
|
|
sync_script_path: &str,
|
|
group_by_folder: bool,
|
|
) -> anyhow::Result<()> {
|
|
let git_sync_config = json!({
|
|
"include_type": ["script"],
|
|
"include_path": ["**"],
|
|
"repositories": [{
|
|
"script_path": sync_script_path,
|
|
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
|
|
"use_individual_branch": true,
|
|
"group_by_folder": group_by_folder
|
|
}]
|
|
});
|
|
|
|
sqlx::query!(
|
|
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
|
git_sync_config,
|
|
"test-workspace"
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a script via the API (triggering handle_deployment_metadata).
|
|
#[allow(dead_code)]
|
|
async fn create_test_script(
|
|
client: &windmill_api_client::Client,
|
|
path: &str,
|
|
) -> anyhow::Result<()> {
|
|
let resp = client
|
|
.client()
|
|
.post(format!(
|
|
"{}/w/test-workspace/scripts/create",
|
|
client.baseurl()
|
|
))
|
|
.json(&json!({
|
|
"path": path,
|
|
"summary": "",
|
|
"description": "",
|
|
// bash has no lock step, so handle_deployment_metadata runs
|
|
"content": "echo hi",
|
|
"language": "bash"
|
|
}))
|
|
.send()
|
|
.await?;
|
|
let status = resp.status();
|
|
let body = resp.text().await.unwrap_or_default();
|
|
anyhow::ensure!(
|
|
status.is_success(),
|
|
"failed to create script {}: {} {}",
|
|
path,
|
|
status,
|
|
body
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Poll the `debounce_key` table until `expected` appears, or timeout.
|
|
#[allow(dead_code)]
|
|
async fn wait_for_debounce_key(
|
|
db: &Pool<Postgres>,
|
|
expected: &str,
|
|
timeout: Duration,
|
|
) -> anyhow::Result<Vec<String>> {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
let keys: Vec<String> =
|
|
sqlx::query_scalar!("SELECT key FROM debounce_key WHERE key LIKE 'git_sync:%'")
|
|
.fetch_all(db)
|
|
.await?;
|
|
if keys.iter().any(|k| k == expected) {
|
|
return Ok(keys);
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return Ok(keys);
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
/// Promotion mode with one branch per object: the debounce key must scope to
|
|
/// (path_type, path) so rapid re-edits of the same script collapse while
|
|
/// different scripts run in parallel.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_promotion_individual_branch_debounces_per_path(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
create_folder(&db, "28103").await?;
|
|
create_folder(&db, "target").await?;
|
|
create_git_repo_resource(&db).await?;
|
|
let sync_script_path = "f/28103/test_sync_individual_branch";
|
|
create_sync_script(&db, sync_script_path).await?;
|
|
setup_promotion_git_sync_config(&db, sync_script_path, false).await?;
|
|
|
|
let (client, _port, _server) = init_client(db.clone()).await;
|
|
|
|
create_test_script(&client, "f/target/alpha").await?;
|
|
create_test_script(&client, "f/target/beta").await?;
|
|
|
|
// Wait for both deployment callbacks to resolve a debounce key. The
|
|
// alpha key is polled first as a warm-up, then we also wait for beta
|
|
// so the assertions don't race the second spawned callback.
|
|
// Keys are namespaced by the repo's resource path so multiple promotion
|
|
// repos don't collide on the same key.
|
|
let expected_alpha = "git_sync:$res:u/test-user/test_git_repo:script:f/target/alpha";
|
|
let expected_beta = "git_sync:$res:u/test-user/test_git_repo:script:f/target/beta";
|
|
let _ = wait_for_debounce_key(&db, expected_alpha, Duration::from_secs(5)).await?;
|
|
let keys = wait_for_debounce_key(&db, expected_beta, Duration::from_secs(5)).await?;
|
|
assert!(
|
|
keys.iter().any(|k| k == expected_alpha),
|
|
"expected per-path debounce key {expected_alpha} in {keys:?}"
|
|
);
|
|
assert!(
|
|
keys.iter().any(|k| k == expected_beta),
|
|
"expected per-path debounce key {expected_beta} in {keys:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Create a second git repository resource for multi-repo tests.
|
|
#[allow(dead_code)]
|
|
async fn create_second_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by)
|
|
VALUES ('test-workspace', 'u/test-user/test_git_repo_2', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user')
|
|
ON CONFLICT (workspace_id, path) DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(json!({
|
|
"url": "https://github.com/test/test2.git",
|
|
"branch": "main",
|
|
"token": "test-token-2"
|
|
}))
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Configure git sync with TWO promotion-mode repositories pointing at distinct
|
|
/// git repo resources. Both repos use the same sync script and the same item
|
|
/// filters — they only differ in the repo they target.
|
|
#[allow(dead_code)]
|
|
async fn setup_two_promotion_repos_config(
|
|
db: &Pool<Postgres>,
|
|
sync_script_path: &str,
|
|
group_by_folder: bool,
|
|
) -> anyhow::Result<()> {
|
|
let git_sync_config = json!({
|
|
"include_type": ["script"],
|
|
"include_path": ["**"],
|
|
"repositories": [
|
|
{
|
|
"script_path": sync_script_path,
|
|
"git_repo_resource_path": "$res:u/test-user/test_git_repo",
|
|
"use_individual_branch": true,
|
|
"group_by_folder": group_by_folder
|
|
},
|
|
{
|
|
"script_path": sync_script_path,
|
|
"git_repo_resource_path": "$res:u/test-user/test_git_repo_2",
|
|
"use_individual_branch": true,
|
|
"group_by_folder": group_by_folder
|
|
}
|
|
]
|
|
});
|
|
|
|
sqlx::query!(
|
|
"UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2",
|
|
git_sync_config,
|
|
"test-workspace"
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Regression test for: when two promotion-mode repos are configured with the
|
|
/// same `use_individual_branch=true` settings (i.e. a primary and a secondary
|
|
/// promotion repo), deploying a single script must enqueue ONE deployment
|
|
/// callback per repo. Both callbacks must remain in the queue — neither may
|
|
/// be debounced into oblivion by the other.
|
|
///
|
|
/// The bug this guards against: the debounce key for promotion mode was
|
|
/// derived only from (path_type, path) and omitted any per-repo identifier,
|
|
/// so the second repo's push hit ON CONFLICT in `upsert_debounce_key` and
|
|
/// `complete_debounced_job` flagged the first repo's job as `status='skipped'`
|
|
/// — silently dropping one of the two pushes.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_two_promotion_repos_both_enqueue_callback(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
create_folder(&db, "28103").await?;
|
|
create_folder(&db, "target").await?;
|
|
create_git_repo_resource(&db).await?;
|
|
create_second_git_repo_resource(&db).await?;
|
|
let sync_script_path = "f/28103/test_sync_two_promotion_repos";
|
|
create_sync_script(&db, sync_script_path).await?;
|
|
setup_two_promotion_repos_config(&db, sync_script_path, false).await?;
|
|
|
|
let (client, _port, _server) = init_client(db.clone()).await;
|
|
|
|
// Deploy a single script — handle_deployment_metadata should iterate
|
|
// both repos and create one callback job per repo.
|
|
create_test_script(&client, "f/target/alpha").await?;
|
|
|
|
// Both callbacks should reach the queue. With the bug, only one survives
|
|
// (the other is moved to v2_job_completed with status='skipped').
|
|
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
|
let mut last_jobs: Vec<DeploymentCallbackJob> = vec![];
|
|
loop {
|
|
last_jobs =
|
|
get_deployment_callback_jobs(&db, sync_script_path, Duration::from_millis(200)).await?;
|
|
if last_jobs.len() >= 2 {
|
|
break;
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
|
|
// Inspect what landed in v2_job_completed so failure messages explain why.
|
|
let skipped: Vec<(uuid::Uuid, String)> = sqlx::query_as(
|
|
r#"
|
|
SELECT c.id, c.status::text
|
|
FROM v2_job_completed c
|
|
JOIN v2_job j ON j.id = c.id
|
|
WHERE j.runnable_path = $1 AND j.kind = 'deploymentcallback'
|
|
"#,
|
|
)
|
|
.bind(sync_script_path)
|
|
.fetch_all(&db)
|
|
.await?;
|
|
|
|
assert_eq!(
|
|
last_jobs.len(),
|
|
2,
|
|
"expected 2 deployment callback jobs in v2_job_queue (one per promotion repo), got {} queued + {:?} completed",
|
|
last_jobs.len(),
|
|
skipped,
|
|
);
|
|
|
|
// Per-repo args sanity check: the two jobs must target different repos.
|
|
let mut repo_paths: Vec<String> = last_jobs
|
|
.iter()
|
|
.filter_map(|j| {
|
|
j.args
|
|
.as_ref()
|
|
.and_then(|a| a.get("repo_url_resource_path"))
|
|
.and_then(|v| v.as_str())
|
|
.map(|s| s.to_string())
|
|
})
|
|
.collect();
|
|
repo_paths.sort();
|
|
repo_paths.dedup();
|
|
assert_eq!(
|
|
repo_paths.len(),
|
|
2,
|
|
"expected callbacks to target two distinct repos, got: {:?}",
|
|
last_jobs.iter().map(|j| &j.args).collect::<Vec<_>>()
|
|
);
|
|
|
|
// No callback should have been silently skipped via debouncing collision.
|
|
assert!(
|
|
skipped.iter().all(|(_, s)| s != "skipped"),
|
|
"no deployment callback should be marked skipped, got: {:?}",
|
|
skipped,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Promotion mode with group_by_folder: items destined for the same per-folder
|
|
/// branch must share one debounce key so they accumulate into a single sync
|
|
/// job; scripts in different folders must get distinct keys.
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_promotion_group_by_folder_debounces_per_folder(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
|
|
create_folder(&db, "28103").await?;
|
|
create_folder(&db, "grouped").await?;
|
|
create_folder(&db, "other").await?;
|
|
create_git_repo_resource(&db).await?;
|
|
let sync_script_path = "f/28103/test_sync_group_by_folder";
|
|
create_sync_script(&db, sync_script_path).await?;
|
|
setup_promotion_git_sync_config(&db, sync_script_path, true).await?;
|
|
|
|
let (client, _port, _server) = init_client(db.clone()).await;
|
|
|
|
// Two scripts in the same folder — should share one debounce key.
|
|
create_test_script(&client, "f/grouped/alpha").await?;
|
|
create_test_script(&client, "f/grouped/beta").await?;
|
|
// One in a different folder — should get its own key.
|
|
create_test_script(&client, "f/other/gamma").await?;
|
|
|
|
// Keys are namespaced by the repo's resource path.
|
|
let expected_grouped = "git_sync:$res:u/test-user/test_git_repo:folder:f/grouped";
|
|
let expected_other = "git_sync:$res:u/test-user/test_git_repo:folder:f/other";
|
|
// Wait for BOTH folder keys to appear, not just the first one.
|
|
let keys = wait_for_debounce_key(&db, expected_other, Duration::from_secs(5)).await?;
|
|
assert!(
|
|
keys.iter().any(|k| k == expected_grouped),
|
|
"expected per-folder debounce key {expected_grouped} in {keys:?}"
|
|
);
|
|
assert!(
|
|
keys.iter().any(|k| k == expected_other),
|
|
"expected per-folder debounce key {expected_other} in {keys:?}"
|
|
);
|
|
// Paths within the same folder must NOT leak as their own keys.
|
|
assert!(
|
|
!keys
|
|
.iter()
|
|
.any(|k| k.contains(":script:f/grouped/") || k.contains(":script:f/other/")),
|
|
"group_by_folder mode should not emit per-path keys, got: {keys:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|