12 Commits

Author SHA1 Message Date
bsodfather 083974c27f fix(smoke): report sanitized operation failure diagnostics
CI / Rust Checks (push) Successful in 14m15s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 1m15s
CI / Frontend E2E (push) Successful in 4m54s
CI / Deploy (push) Successful in 1m18s
2026-09-05 14:00:39 +03:00
bsodfather bbb9394911 fix(migrations): normalize legacy column order
CI / Rust Checks (push) Successful in 14m21s
CI / UI Checks (push) Successful in 6s
CI / Community Image Smoke (push) Successful in 6m14s
CI / Frontend E2E (push) Successful in 7m10s
CI / Deploy (push) Successful in 1m12s
2026-09-02 01:01:58 +03:00
bsodfather 2759fde81f fix(migrations): adopt published ledgerless baseline
CI / Rust Checks (push) Successful in 13m55s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 6m14s
CI / Frontend E2E (push) Successful in 7m11s
CI / Deploy (push) Failing after 44s
2026-09-01 23:01:33 +03:00
bsodfather 527efb510f fix(deploy): distinguish core migration drift
CI / Rust Checks (push) Successful in 12m35s
CI / UI Checks (push) Successful in 6s
CI / Community Image Smoke (push) Successful in 6m14s
CI / Frontend E2E (push) Successful in 7m6s
CI / Deploy (push) Failing after 40s
2026-09-01 20:30:09 +03:00
bsodfather 66b9bfd875 fix(ci): scope OpenAPI evidence to required tests
CI / Rust Checks (push) Successful in 12m40s
CI / UI Checks (push) Successful in 16m21s
CI / Community Image Smoke (push) Successful in 1m15s
CI / Frontend E2E (push) Successful in 5m8s
CI / Deploy (push) Failing after 46s
2026-09-01 12:59:04 +03:00
bsodfather 6c5d0c62d0 fix(deploy): back up artifacts after failed rollout
CI / Rust Checks (push) Successful in 12m34s
CI / UI Checks (push) Successful in 6s
CI / Community Image Smoke (push) Successful in 1m12s
CI / Frontend E2E (push) Failing after 5m42s
CI / Deploy (push) Has been skipped
2026-09-01 01:38:15 +03:00
bsodfather dd5dbba1f6 fix(deploy): preserve rollback diagnostics
CI / Rust Checks (push) Successful in 12m38s
CI / UI Checks (push) Successful in 6s
CI / Community Image Smoke (push) Successful in 1m12s
CI / Frontend E2E (push) Successful in 4m55s
CI / Deploy (push) Failing after 31s
2026-09-01 00:13:27 +03:00
bsodfather 5818255cf5 fix(deploy): skip bundled postgres for external databases
CI / Rust Checks (push) Successful in 12m39s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 1m11s
CI / Frontend E2E (push) Successful in 4m53s
CI / Deploy (push) Failing after 46s
2026-08-31 23:33:48 +03:00
bsodfather 343bb99bff fix(security): refresh community UI runtime
CI / Rust Checks (push) Successful in 12m46s
CI / UI Checks (push) Successful in 5s
CI / Community Image Smoke (push) Successful in 6m17s
CI / Frontend E2E (push) Successful in 7m12s
CI / Deploy (push) Failing after 1m10s
2026-08-31 23:06:16 +03:00
bsodfather dc8171d052 fix(ci): align OpenAPI evidence test title
CI / Rust Checks (push) Successful in 12m33s
CI / UI Checks (push) Successful in 6s
CI / Community Image Smoke (push) Successful in 1m11s
CI / Frontend E2E (push) Successful in 4m52s
CI / Deploy (push) Failing after 28s
2026-08-31 22:42:20 +03:00
bsodfather d92f817e73 fix(ci): harden community image smoke
CI / Rust Checks (push) Successful in 12m30s
CI / UI Checks (push) Successful in 5s
CI / Frontend E2E (push) Failing after 9m30s
CI / Community Image Smoke (push) Successful in 11m18s
CI / Deploy (push) Has been skipped
2026-08-30 22:22:13 +03:00
bsodfather ad8390e297 fix(ui): keep onboarding clear of wizard actions 2026-08-30 22:22:05 +03:00
23 changed files with 1468 additions and 82 deletions
+31 -4
View File
@@ -232,7 +232,7 @@ jobs:
--required-test 'operations page imports OpenAPI methods as drafts' \
--required-test 'OpenAPI upload rejects invalid files locally and restores focus after Escape' \
--required-test 'OpenAPI upload recovers from pagehide and a preview server error' \
--required-test 'OpenAPI upload invalidates active draft creation after language or workspace changes' \
--required-test 'OpenAPI apply preserves job authority after language or workspace changes' \
--required-test 'OpenAPI upload only renders the latest selected file and clears reset or close races' \
--required-test 'OpenAPI upload ignores a stale failure and renders only correlation identifiers'
python3 ../../scripts/validate-capability-run.py \
@@ -286,17 +286,44 @@ jobs:
CRANK_SESSION_SECRET=ci-session-secret
CRANK_PASSWORD_PEPPER=ci-password-pepper
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
CRANK_BOOTSTRAP_ADMIN_PASSWORD=ci-admin-password
CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME=CI Owner
CRANK_BASE_URL=http://127.0.0.1
CRANK_ENVIRONMENT=ci
CRANK_OUTBOUND_ALLOWED_HOSTS=admin-api
CRANK_PUBLISH_BIND=127.0.0.1
CRANK_ADMIN_PUBLISH_PORT=0
CRANK_MCP_PUBLISH_PORT=0
CRANK_UI_PUBLISH_PORT=0
CRANK_DEMO_SEED=true
EOF
docker compose -f deploy/community/docker-compose.images.yml \
--env-file .tmp/community-smoke.env --profile local-db up -d --wait
compose=(docker compose -f deploy/community/docker-compose.images.yml \
--env-file .tmp/community-smoke.env --profile local-db)
"${compose[@]}" up -d --wait postgres
"${compose[@]}" run --rm migrate
bootstrap_json="$("${compose[@]}" run --rm --no-deps \
--entrypoint crank-migrate migrate admin-auth bootstrap-create \
--email owner@crank.test --display-name 'CI Owner')"
bootstrap_token="$(python3 -c \
'import json,sys; print(json.loads(sys.stdin.read())["bootstrap_token"])' \
<<<"$bootstrap_json")"
install -d -m 700 .tmp/community-bootstrap
printf '%s' "$bootstrap_token" > .tmp/community-bootstrap/token
printf '%s' 'ci-admin-password' > .tmp/community-bootstrap/password
printf '%s' 'ci-password-pepper' > .tmp/community-bootstrap/password-pepper
chmod 600 .tmp/community-bootstrap/token \
.tmp/community-bootstrap/password \
.tmp/community-bootstrap/password-pepper
"${compose[@]}" run --rm --no-deps \
-v "$PWD/.tmp/community-bootstrap:/run/bootstrap:ro" \
--entrypoint crank-migrate migrate admin-auth bootstrap-complete \
--token-file /run/bootstrap/token \
--password-file /run/bootstrap/password \
--password-pepper-file /run/bootstrap/password-pepper
rm -f .tmp/community-bootstrap/token \
.tmp/community-bootstrap/password \
.tmp/community-bootstrap/password-pepper
rmdir .tmp/community-bootstrap
"${compose[@]}" up -d --wait
- name: Run authenticated Community image smoke
env:
+2 -2
View File
@@ -139,7 +139,7 @@ jobs:
--required-test 'operations page imports OpenAPI methods as drafts' \
--required-test 'OpenAPI upload rejects invalid files locally and restores focus after Escape' \
--required-test 'OpenAPI upload recovers from pagehide and a preview server error' \
--required-test 'OpenAPI upload invalidates active draft creation after language or workspace changes' \
--required-test 'OpenAPI apply preserves job authority after language or workspace changes' \
--required-test 'OpenAPI upload only renders the latest selected file and clears reset or close races' \
--required-test 'OpenAPI upload ignores a stale failure and renders only correlation identifiers'
python3 ../../scripts/validate-capability-run.py \
@@ -217,8 +217,8 @@ jobs:
CRANK_SESSION_SECRET=release-smoke-session
CRANK_PASSWORD_PEPPER=release-smoke-pepper
CRANK_BOOTSTRAP_ADMIN_EMAIL=owner@crank.test
CRANK_BOOTSTRAP_ADMIN_PASSWORD=release-smoke-password
CRANK_BASE_URL=http://127.0.0.1
CRANK_ENVIRONMENT=release-smoke
CRANK_PUBLISH_BIND=127.0.0.1
CRANK_ADMIN_PUBLISH_PORT=0
CRANK_MCP_PUBLISH_PORT=0
+3 -9
View File
@@ -18,21 +18,15 @@ FROM debian:bookworm-slim
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN test -s /etc/ssl/certs/ca-certificates.crt \
&& sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
&& grep -q '^URIs: https://deb.debian.org/' /etc/apt/sources.list.d/debian.sources \
&& ! grep -Eq '^[[:space:]]*URIs:[[:space:]]+http://' /etc/apt/sources.list.d/debian.sources \
&& apt-get -o Acquire::Retries=3 update \
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /tmp/admin-api /usr/local/bin/admin-api
COPY --from=builder /tmp/crank-migrate /usr/local/bin/crank-migrate
COPY apps/admin-api/docker-entrypoint.sh /usr/local/bin/crank-admin-entrypoint
COPY scripts/docker-http-healthcheck.sh /usr/local/bin/crank-http-healthcheck
RUN chmod 0755 /usr/local/bin/crank-admin-entrypoint
RUN test -s /etc/ssl/certs/ca-certificates.crt \
&& chmod 0755 /usr/local/bin/crank-admin-entrypoint /usr/local/bin/crank-http-healthcheck
ENV CRANK_ADMIN_BIND=0.0.0.0:3001
ENV CRANK_STORAGE_ROOT=/var/lib/crank/storage
+4 -8
View File
@@ -55,17 +55,13 @@ FROM debian:bookworm-slim
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
RUN test -s /etc/ssl/certs/ca-certificates.crt \
&& sed -i 's|http://deb.debian.org|https://deb.debian.org|g' /etc/apt/sources.list.d/debian.sources \
&& grep -q '^URIs: https://deb.debian.org/' /etc/apt/sources.list.d/debian.sources \
&& ! grep -Eq '^[[:space:]]*URIs:[[:space:]]+http://' /etc/apt/sources.list.d/debian.sources \
&& apt-get -o Acquire::Retries=3 update \
&& apt-get -o Acquire::Retries=3 install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /tmp/mcp-server /usr/local/bin/mcp-server
COPY scripts/docker-http-healthcheck.sh /usr/local/bin/crank-http-healthcheck
RUN test -s /etc/ssl/certs/ca-certificates.crt \
&& chmod 0755 /usr/local/bin/crank-http-healthcheck
ENV CRANK_MCP_BIND=0.0.0.0:3002
+5 -1
View File
@@ -10,7 +10,11 @@ COPY crank-community.png ./crank-community.png
RUN npm run build
FROM nginx:1.27-alpine
FROM nginx:1.30.4-alpine3.24-slim
RUN apk add --no-cache --upgrade \
'libcrypto3>=3.5.8-r0' \
'libssl3>=3.5.8-r0'
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
+11
View File
@@ -72,6 +72,17 @@
body:has(.drawer.open) .onboarding-trigger,
body:has(.drawer.open) .onboarding-panel { z-index: 149; }
/* The wizard owns a fixed bottom action bar. Keep this optional helper above
it so the primary Continue action remains both visible and clickable. */
body.wizard-page .onboarding-trigger {
bottom: calc(96px + env(safe-area-inset-bottom, 0px));
}
body.wizard-page .onboarding-panel {
bottom: calc(150px + env(safe-area-inset-bottom, 0px));
max-height: min(700px, calc(100vh - 178px));
}
@media (max-width: 640px) {
.onboarding-trigger { right: 14px; bottom: 14px; }
.onboarding-panel { right: 14px; bottom: 68px; }
+32
View File
@@ -1,6 +1,25 @@
const { test, expect } = require('@playwright/test');
const { getCurrentWorkspace, login, localized } = require('./helpers');
async function expectOnboardingOutsideWizardNavigation(page) {
await expect(page.getByTestId('onboarding-trigger')).toBeVisible();
const overlapsContinue = await page.locator('#btn-continue').evaluate((continueButton) => {
const trigger = document.querySelector('[data-testid="onboarding-trigger"]');
if (!trigger) return false;
const continueRect = continueButton.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
return !(
triggerRect.right <= continueRect.left
|| triggerRect.left >= continueRect.right
|| triggerRect.bottom <= continueRect.top
|| triggerRect.top >= continueRect.bottom
);
});
expect(overlapsContinue).toBe(false);
}
test('mobile wizard progress connector crosses the indicator centers', async ({ page }) => {
await page.setViewportSize({ width: 720, height: 900 });
await login(page);
@@ -24,6 +43,19 @@ test('mobile wizard progress connector crosses the indicator centers', async ({
expect(geometry.topDelta).toBeLessThan(1);
});
test('onboarding helper does not cover wizard navigation', async ({ page }) => {
await login(page);
await page.goto('/wizard/');
await expectOnboardingOutsideWizardNavigation(page);
});
test('onboarding helper does not cover wizard navigation on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.goto('/wizard/');
await expectOnboardingOutsideWizardNavigation(page);
});
test('wizard loads and protocol selection updates flow', async ({ page }) => {
await login(page);
await page.goto('/wizard/');
+1
View File
@@ -11,6 +11,7 @@ mod onboarding_product_events_v11;
mod owned_relations;
mod platform_key_name_reuse_v6;
mod schema_guard;
mod schema_guard_legacy_v1;
mod schema_guard_v10;
mod schema_guard_v11;
mod schema_guard_v12;
@@ -11,6 +11,9 @@ use super::platform_key_name_reuse_v6;
use super::schema_guard::{
OWNED_RELATIONS, relation_exists, validate_required_relations, validate_schema_fingerprint,
};
use super::schema_guard_legacy_v1::{
validate_ledgerless_baseline_fingerprint, validate_ledgerless_optional_fingerprints,
};
use super::{BASELINE_CHECKSUM, BASELINE_VERSION, apply_baseline};
use crate::ext::ExtensionMigration;
use sqlx::{PgConnection, PgPool, Row, Transaction, query};
@@ -389,6 +392,19 @@ impl MigrationAuthority {
if from < 13 {
artifact_cleanup_indexes_v13::apply(&mut transaction, &Self::sequence()[12]).await?;
}
match inspect(&mut transaction).await? {
MigrationPreflight::Current {
version: CURRENT_VERSION,
} => {}
_ => {
return Err(MigrationError::new(
"apply_failed",
"apply.postflight",
Some(CURRENT_VERSION),
"restore_known_good_backup",
));
}
}
transaction
.commit()
.await
@@ -408,9 +424,15 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
owned_exists |= relation_exists(connection, relation).await?;
}
if owned_exists {
if is_ledgerless_legacy_baseline(connection, canonical_exists).await? {
return Ok(MigrationPreflight::MigrationRequired {
current: 0,
target: CURRENT_VERSION,
});
}
return Err(MigrationError::new(
"partial_sequence",
"preflight.core",
"preflight.core_missing",
None,
"restore_known_good_backup",
));
@@ -541,6 +563,47 @@ async fn inspect(connection: &mut PgConnection) -> Result<MigrationPreflight, Mi
Ok(MigrationPreflight::Current { version: current })
}
}
async fn is_ledgerless_legacy_baseline(
connection: &mut PgConnection,
canonical_exists: bool,
) -> Result<bool, MigrationError> {
if canonical_exists {
return Ok(false);
}
for relation in [
"__crank_migration_legacy_audit",
"master_key_identities",
"master_key_rotations",
"admin_bootstrap_contracts",
"admin_login_backoff",
"admin_security_audit_events",
"product_events",
"product_event_daily_rollups",
"onboarding_selections",
"artifact_blobs",
"artifact_sources",
] {
if relation_exists(connection, relation).await? {
return Ok(false);
}
}
for relation in owned_relations::BASELINE {
if !relation_exists(connection, relation).await? {
return Ok(false);
}
}
validate_required_relations(connection, owned_relations::BASELINE, 1).await?;
validate_ledgerless_baseline_fingerprint(connection).await?;
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
if mcp_ledger {
return Ok(false);
}
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
let extension_ledger = relation_exists(connection, "__crank_ext_migrations").await?;
validate_ledgerless_optional_fingerprints(connection, mcp_sessions, extension_ledger).await?;
inspect_optional_legacy_for_ledgerless_baseline(connection).await?;
Ok(true)
}
async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), MigrationError> {
let rows = query("select version, description, checksum from __crank_core_migrations order by version limit 2")
.fetch_all(connection)
@@ -549,7 +612,7 @@ async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), Migra
if rows.len() != 1 {
return Err(MigrationError::new(
"partial_sequence",
"preflight.core",
"preflight.core_cardinality",
None,
"restore_known_good_backup",
));
@@ -577,9 +640,21 @@ async fn validate_core_ledger(connection: &mut PgConnection) -> Result<(), Migra
Ok(())
}
async fn inspect_optional_legacy(connection: &mut PgConnection) -> Result<(), MigrationError> {
inspect_optional_legacy_with_policy(connection, false).await
}
async fn inspect_optional_legacy_for_ledgerless_baseline(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
inspect_optional_legacy_with_policy(connection, true).await
}
async fn inspect_optional_legacy_with_policy(
connection: &mut PgConnection,
allow_sessions_without_ledger: bool,
) -> Result<(), MigrationError> {
let mcp_ledger = relation_exists(connection, "__crank_mcp_migrations").await?;
let mcp_sessions = relation_exists(connection, "mcp_transport_sessions").await?;
if mcp_ledger != mcp_sessions {
if mcp_ledger != mcp_sessions && !(allow_sessions_without_ledger && !mcp_ledger && mcp_sessions)
{
return Err(MigrationError::new(
"legacy_conflict",
"preflight.legacy_mcp",
@@ -325,6 +325,15 @@ pub(super) async fn validate_schema_fingerprint(
.iter()
.filter_map(|row| row.try_get::<String, _>("column_name").ok())
.collect::<Vec<_>>();
let required = required
.iter()
.copied()
.filter(|column| {
!(current_version == 1
&& *table == "__crank_ext_migrations"
&& *column == "checksum")
})
.collect::<Vec<_>>();
if actual.len() != required.len()
|| required
.iter()
@@ -337,6 +346,9 @@ pub(super) async fn validate_schema_fingerprint(
if !relation_exists(connection, table).await? {
continue;
}
if current_version == 1 && *table == "__crank_ext_migrations" && *column == "checksum" {
continue;
}
let row = query(
"select data_type, is_nullable from information_schema.columns
where table_schema = current_schema() and table_name = $1 and column_name = $2",
@@ -424,6 +436,7 @@ pub(super) async fn validate_schema_fingerprint(
return Err(schema_error(current_version));
}
}
if relation_exists(connection, "mcp_transport_sessions").await? {
let required_indexes = [
"mcp_transport_sessions_workspace_agent_idx",
"mcp_transport_sessions_expires_at_idx",
@@ -443,6 +456,7 @@ pub(super) async fn validate_schema_fingerprint(
return Err(schema_error(current_version));
}
}
}
if current_version >= 3 {
validate_index(
connection,
@@ -0,0 +1,196 @@
use sha2::{Digest, Sha256};
use sqlx::PgConnection;
use super::authority::MigrationError;
// Exact PostgreSQL 16 catalog contract produced by the last published
// pre-ledger Community schema (commit 8318e4b).
const LEDGERLESS_BASELINE_FINGERPRINT_SHA256: &str =
"36624ca42a28c1388f9c50e6d6c489a8e5ca1f73441a894d9af89e5e115b542c";
const LEDGERLESS_MCP_FINGERPRINTS_SHA256: &[&str] = &[
// Initial published session table.
"9240c3d85dbc9eeddb1cd99661ec8f7d8d6ece0e62ec486151dcc951a7f3c81c",
// Published session table after supports_elicitation was added.
"ac9f99a7e667552a07480d5d45379dd0a8b529b00ba98855ce6725535198249f",
];
const LEDGERLESS_EXTENSION_FINGERPRINT_SHA256: &str =
"0809a80c0bb6e80f68d0557006c4b62f2e63556f61f6fdedc5abc324950e2587";
const BASELINE_RELATIONS: &[&str] = &[
"workspaces",
"users",
"memberships",
"user_sessions",
"invitation_tokens",
"platform_api_keys",
"operations",
"operation_versions",
"published_operations",
"operation_samples",
"descriptors",
"agents",
"agent_versions",
"published_agents",
"agent_operation_bindings",
"secrets",
"secret_versions",
"auth_profiles",
"workspace_upstreams",
"yaml_import_jobs",
"import_jobs",
"approval_requests",
"invocation_logs",
"usage_rollups",
];
pub(super) async fn validate_ledgerless_baseline_fingerprint(
connection: &mut PgConnection,
) -> Result<(), MigrationError> {
let actual = catalog_fingerprint(connection, BASELINE_RELATIONS).await?;
if actual == LEDGERLESS_BASELINE_FINGERPRINT_SHA256 {
Ok(())
} else {
Err(fingerprint_error())
}
}
pub(super) async fn validate_ledgerless_optional_fingerprints(
connection: &mut PgConnection,
has_mcp_sessions: bool,
has_extension_ledger: bool,
) -> Result<(), MigrationError> {
if has_mcp_sessions {
let actual = catalog_fingerprint(connection, &["mcp_transport_sessions"]).await?;
if !LEDGERLESS_MCP_FINGERPRINTS_SHA256.contains(&actual.as_str()) {
return Err(fingerprint_error());
}
}
if has_extension_ledger {
let actual = catalog_fingerprint(connection, &["__crank_ext_migrations"]).await?;
if actual != LEDGERLESS_EXTENSION_FINGERPRINT_SHA256 {
return Err(fingerprint_error());
}
}
Ok(())
}
async fn catalog_fingerprint(
connection: &mut PgConnection,
relations: &[&str],
) -> Result<String, MigrationError> {
let relations = relations
.iter()
.map(|value| (*value).to_owned())
.collect::<Vec<_>>();
let unsafe_catalog_state = sqlx::query_scalar::<_, bool>(
"with selected(table_name) as (select unnest($1::text[]))
select
exists (
select 1 from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
join selected s on s.table_name = c.relname
where n.nspname = current_schema()
and (c.relpersistence <> 'p' or c.relrowsecurity
or c.relforcerowsecurity or c.relreplident <> 'd')
)
or exists (
select 1 from pg_catalog.pg_index i
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join selected s on s.table_name = t.relname
where n.nspname = current_schema()
and (not i.indisvalid or not i.indisready or not i.indislive)
)
or exists (
select 1 from pg_catalog.pg_policy p
join pg_catalog.pg_class t on t.oid = p.polrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join selected s on s.table_name = t.relname
where n.nspname = current_schema()
)
or exists (
select 1 from pg_catalog.pg_trigger tg
join pg_catalog.pg_class t on t.oid = tg.tgrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join selected s on s.table_name = t.relname
where n.nspname = current_schema() and tg.tgenabled <> 'O'
)",
)
.bind(relations.clone())
.fetch_one(&mut *connection)
.await
.map_err(|_| MigrationError::storage("preflight.legacy_fingerprint"))?;
if unsafe_catalog_state {
return Err(fingerprint_error());
}
let fingerprint = sqlx::query_scalar::<_, String>(
"with baseline(table_name) as (select unnest($1::text[])), relation_rows as (
select jsonb_build_array('relation', c.relname, c.relkind::text) item
from pg_catalog.pg_class c
join pg_catalog.pg_namespace n on n.oid = c.relnamespace
join baseline b on b.table_name = c.relname
where n.nspname = current_schema()
), column_rows as (
select jsonb_build_array(
'column', c.table_name, c.column_name,
c.data_type, c.udt_name, c.is_nullable, coalesce(c.column_default, '')
) item
from information_schema.columns c
join baseline b using (table_name)
where c.table_schema = current_schema()
), constraint_rows as (
select jsonb_build_array(
'constraint', t.relname, c.conname, c.contype::text,
c.convalidated, pg_get_constraintdef(c.oid, true)
) item
from pg_catalog.pg_constraint c
join pg_catalog.pg_class t on t.oid = c.conrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join baseline b on b.table_name = t.relname
where n.nspname = current_schema()
), index_rows as (
select jsonb_build_array(
'index', t.relname, idx.relname,
replace(pg_get_indexdef(i.indexrelid), format('%I.', current_schema()), '')
) item
from pg_catalog.pg_index i
join pg_catalog.pg_class idx on idx.oid = i.indexrelid
join pg_catalog.pg_class t on t.oid = i.indrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join baseline b on b.table_name = t.relname
where n.nspname = current_schema()
), trigger_rows as (
select jsonb_build_array(
'trigger', t.relname, tg.tgname,
replace(pg_get_triggerdef(tg.oid, true), format('%I.', current_schema()), '')
) item
from pg_catalog.pg_trigger tg
join pg_catalog.pg_class t on t.oid = tg.tgrelid
join pg_catalog.pg_namespace n on n.oid = t.relnamespace
join baseline b on b.table_name = t.relname
where n.nspname = current_schema() and not tg.tgisinternal
), all_rows as (
select item from relation_rows
union all select item from column_rows
union all select item from constraint_rows
union all select item from index_rows
union all select item from trigger_rows
)
select coalesce(jsonb_agg(item order by item::text), '[]'::jsonb)::text
from all_rows",
)
.bind(relations)
.fetch_one(connection)
.await
.map_err(|_| MigrationError::storage("preflight.legacy_fingerprint"))?;
Ok(format!("{:x}", Sha256::digest(fingerprint.as_bytes())))
}
fn fingerprint_error() -> MigrationError {
MigrationError::new(
"partial_sequence",
"preflight.legacy_fingerprint",
Some(1),
"restore_known_good_backup",
)
}
@@ -2,6 +2,7 @@ use crank_registry::{MigrationAuthority, MigrationPreflight, PostgresRegistry};
use sqlx::Row;
mod artifact_metadata;
mod legacy_adoption;
mod rollback;
static EVENT_TRIGGER_TEST_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
@@ -153,7 +154,7 @@ async fn changed_checksum_fails_closed_without_repair() {
);
}
#[tokio::test]
async fn legacy_core_baseline_is_consolidated_without_data_loss() {
async fn ledgerless_legacy_baseline_is_consolidated_without_data_loss() {
let database_url = crank_test_support::postgres_schema_url("test_legacy_core").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
@@ -221,7 +222,8 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
}
sqlx::query(
"drop table __crank_migrations, __crank_migration_legacy_audit,
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations",
__crank_mcp_migrations, mcp_transport_sessions, __crank_ext_migrations,
__crank_core_migrations",
)
.execute(&pool)
.await
@@ -229,7 +231,7 @@ async fn legacy_core_baseline_is_consolidated_without_data_loss() {
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 1,
current: 0,
target: 13,
}
);
@@ -937,6 +939,20 @@ async fn any_owned_relation_without_core_ledger_is_partial() {
.unwrap();
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.core_missing");
}
#[tokio::test]
async fn empty_core_ledger_is_reported_separately() {
let database_url = crank_test_support::postgres_schema_url("test_empty_core_ledger").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
MigrationAuthority::apply(&pool).await.unwrap();
sqlx::query("delete from __crank_core_migrations")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::preflight(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.core_cardinality");
}
#[tokio::test]
async fn current_ledger_with_structural_drift_fails_closed() {
@@ -0,0 +1,439 @@
use super::*;
async fn ledgerless_v1(pool: &sqlx::PgPool) {
MigrationAuthority::apply(pool).await.unwrap();
remove_v4_schema(pool).await;
remove_v3_schema(pool).await;
sqlx::raw_sql(
"drop table __crank_migrations, __crank_migration_legacy_audit;
drop table __crank_mcp_migrations;
drop index mcp_transport_sessions_expires_at_idx;
drop table __crank_ext_migrations;
create table __crank_ext_migrations (
extension_name text not null,
version integer not null,
applied_at timestamptz not null default now(),
primary key (extension_name, version)
);
drop table __crank_core_migrations;",
)
.execute(pool)
.await
.unwrap();
}
#[tokio::test]
async fn ledgerless_baseline_with_optional_index_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_index").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::raw_sql(
"drop index mcp_transport_sessions_workspace_agent_idx;
create index mcp_transport_sessions_workspace_agent_idx
on mcp_transport_sessions(id);",
)
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_disabled_integrity_triggers_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_triggers").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table memberships disable trigger all")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn published_ledgerless_baseline_upgrades_without_data_loss() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_baseline").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query(
"insert into operations
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
values ('op_ledgerless', 'ws_default', 'ledgerless', 'Ledgerless', 'rest', 'draft', now(), now())",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 0,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
let operation_count: i64 =
sqlx::query_scalar("select count(*) from operations where id = 'op_ledgerless'")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(operation_count, 1);
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test]
async fn published_ledgerless_historical_column_layout_upgrades_without_data_loss() {
let database_url =
crank_test_support::postgres_schema_url("test_ledgerless_historical_layout").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::raw_sql(
"alter table platform_api_keys
drop column key_kind,
drop column expires_at,
drop column allowed_origins_json;
alter table platform_api_keys
add column key_kind text not null default 'mcp_client',
add column expires_at timestamptz null,
add column allowed_origins_json jsonb not null default '[]'::jsonb;
drop index approval_requests_pending_fingerprint_idx;
alter table approval_requests
drop column execution_started_at,
drop column execution_attempts,
drop column request_fingerprint,
add column confirmation_title text not null default '',
add column confirmation_body text not null default '';
alter table approval_requests
alter column confirmation_title drop default,
alter column confirmation_body drop default,
drop column confirmation_title,
drop column confirmation_body,
add column execution_started_at timestamptz null,
add column execution_attempts integer not null default 0,
add column request_fingerprint text null;
create unique index approval_requests_pending_fingerprint_idx
on approval_requests(agent_id, operation_id, operation_version, request_fingerprint)
where status = 'pending' and request_fingerprint is not null;
insert into operations
(id, workspace_id, name, display_name, protocol, status, created_at, updated_at)
values ('op_historical_layout', 'ws_default', 'historical-layout', 'Historical layout', 'rest', 'draft', now(), now());
insert into agents
(id, workspace_id, slug, display_name, description, status, created_at, updated_at)
values ('agent_historical_layout', 'ws_default', 'historical-layout', 'Historical layout', '', 'draft', now(), now());
insert into platform_api_keys
(id, workspace_id, agent_id, name, prefix, secret_hash, key_kind, scopes_json, status,
created_at, expires_at, allowed_origins_json)
values ('key_historical_layout', 'ws_default', 'agent_historical_layout', 'Historical layout',
'cp_', 'hash', 'admin', '[]'::jsonb, 'active', now(),
'2030-01-02 03:04:05+00'::timestamptz, '[\"https://example.test\"]'::jsonb);
insert into approval_requests
(id, workspace_id, agent_id, operation_id, operation_version, status, risk_level,
request_payload_json, created_at, expires_at, execution_started_at,
execution_attempts, request_fingerprint)
values ('approval_historical_layout', 'ws_default', 'agent_historical_layout', 'op_historical_layout', 1,
'pending', 'high', '{\"layout\":\"historical\"}'::jsonb, now(), now() + interval '1 hour',
'2029-02-03 04:05:06+00'::timestamptz, 3, 'historical-fingerprint');",
)
.execute(&pool)
.await
.unwrap();
let historical_ordinals: Vec<(String, i32)> = sqlx::query_as(
"select table_name || '.' || column_name, ordinal_position
from information_schema.columns
where table_schema = current_schema()
and ((table_name = 'platform_api_keys'
and column_name in ('key_kind', 'expires_at', 'allowed_origins_json'))
or (table_name = 'approval_requests'
and column_name in ('execution_started_at', 'execution_attempts', 'request_fingerprint')))
order by table_name, ordinal_position",
)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(
historical_ordinals,
vec![
("approval_requests.execution_started_at".to_owned(), 22),
("approval_requests.execution_attempts".to_owned(), 23),
("approval_requests.request_fingerprint".to_owned(), 24),
("platform_api_keys.key_kind".to_owned(), 15),
("platform_api_keys.expires_at".to_owned(), 16),
("platform_api_keys.allowed_origins_json".to_owned(), 17),
],
"fixture must reproduce the published in-place column layout",
);
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 0,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
let key_count: i64 = sqlx::query_scalar(
"select count(*) from platform_api_keys
where id = 'key_historical_layout'
and key_kind = 'admin'
and expires_at = '2030-01-02 03:04:05+00'::timestamptz
and allowed_origins_json = '[\"https://example.test\"]'::jsonb",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
key_count, 1,
"platform key row must survive the layout upgrade"
);
let approval_count: i64 = sqlx::query_scalar(
"select count(*) from approval_requests
where id = 'approval_historical_layout'
and request_payload_json = '{\"layout\":\"historical\"}'::jsonb
and execution_started_at = '2029-02-03 04:05:06+00'::timestamptz
and execution_attempts = 3
and request_fingerprint = 'historical-fingerprint'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(
approval_count, 1,
"approval row must survive the layout upgrade"
);
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test]
async fn published_initial_mcp_layout_is_accepted() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_initial_mcp").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::raw_sql(
"drop table mcp_transport_sessions;
create table mcp_transport_sessions (
id text primary key,
protocol_version text not null,
initialized boolean not null default false,
workspace_slug text not null,
agent_slug text not null,
created_at timestamptz not null,
updated_at timestamptz not null,
expires_at timestamptz null
);
create index mcp_transport_sessions_workspace_agent_idx
on mcp_transport_sessions(workspace_slug, agent_slug, updated_at desc);",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 0,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
let supports_elicitation: bool = sqlx::query_scalar(
"select exists (
select 1 from information_schema.columns
where table_schema = current_schema()
and table_name = 'mcp_transport_sessions'
and column_name = 'supports_elicitation'
)",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(supports_elicitation);
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test]
async fn published_in_place_mcp_upgrade_layout_is_accepted() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_mcp_upgrade").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::raw_sql(
"drop table mcp_transport_sessions;
create table mcp_transport_sessions (
id text primary key,
protocol_version text not null,
initialized boolean not null default false,
workspace_slug text not null,
agent_slug text not null,
created_at timestamptz not null,
updated_at timestamptz not null,
expires_at timestamptz null
);
create index mcp_transport_sessions_workspace_agent_idx
on mcp_transport_sessions(workspace_slug, agent_slug, updated_at desc);
alter table mcp_transport_sessions
add column supports_elicitation boolean not null default false;",
)
.execute(&pool)
.await
.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::MigrationRequired {
current: 0,
target: 13,
}
);
MigrationAuthority::apply(&pool).await.unwrap();
assert_eq!(
MigrationAuthority::preflight(&pool).await.unwrap(),
MigrationPreflight::Current { version: 13 }
);
}
#[tokio::test]
async fn ledgerless_baseline_with_future_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_drift").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table invocation_logs add column trace_id text")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_default_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_default").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table workspaces alter column status set default 'active'")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_constraint_drift_is_rejected_without_writes() {
let database_url =
crank_test_support::postgres_schema_url("test_ledgerless_constraint_definition").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query(
"alter table workspaces
add constraint workspaces_status_nonempty check (status <> '')",
)
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_rls_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_rls").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table workspaces enable row level security")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
#[tokio::test]
async fn ledgerless_baseline_with_nullability_drift_is_rejected_without_writes() {
let database_url = crank_test_support::postgres_schema_url("test_ledgerless_constraint").await;
let pool = sqlx::PgPool::connect(&database_url).await.unwrap();
ledgerless_v1(&pool).await;
sqlx::query("alter table workspaces alter column status drop not null")
.execute(&pool)
.await
.unwrap();
let error = MigrationAuthority::apply(&pool).await.unwrap_err();
assert_eq!(error.code(), "partial_sequence");
assert_eq!(error.stage(), "preflight.legacy_fingerprint");
let core_exists: bool = sqlx::query_scalar(
"select to_regclass(format('%I.%I', current_schema(), '__crank_core_migrations')) is not null",
)
.fetch_one(&pool)
.await
.unwrap();
assert!(!core_exists, "rejected adoption must remain read-only");
}
+2 -2
View File
@@ -138,7 +138,7 @@ services:
ports:
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_ADMIN_PUBLISH_PORT:-3001}:3001"
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"]
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"]
interval: 15s
timeout: 5s
retries: 5
@@ -199,7 +199,7 @@ services:
ports:
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_MCP_PUBLISH_PORT:-3002}:3002"
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/ready"]
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/ready"]
interval: 15s
timeout: 5s
retries: 5
+3 -2
View File
@@ -42,6 +42,7 @@ services:
depends_on:
postgres:
condition: service_healthy
required: false
environment:
POSTGRES_HOST: ${POSTGRES_HOST:-postgres}
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
@@ -141,7 +142,7 @@ services:
ports:
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_ADMIN_PUBLISH_PORT:-3001}:3001"
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"]
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"]
interval: 15s
timeout: 5s
retries: 5
@@ -202,7 +203,7 @@ services:
ports:
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:${CRANK_MCP_PUBLISH_PORT:-3002}:3002"
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/ready"]
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/ready"]
interval: 15s
timeout: 5s
retries: 5
+2 -2
View File
@@ -127,7 +127,7 @@ services:
ports:
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:3001:3001"
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3001/ready"]
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3001", "/ready"]
interval: 15s
timeout: 5s
retries: 5
@@ -190,7 +190,7 @@ services:
ports:
- "${CRANK_PUBLISH_BIND:-127.0.0.1}:3002:3002"
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3002/health"]
test: ["CMD", "/usr/local/bin/crank-http-healthcheck", "3002", "/health"]
interval: 15s
timeout: 5s
retries: 5
+95 -5
View File
@@ -21,10 +21,17 @@ class SmokeError(RuntimeError):
pass
def safe_error(stage: str, code: str, status: int | None = None) -> SmokeError:
def safe_error(
stage: str,
code: str,
status: int | None = None,
trace_id: str | None = None,
) -> SmokeError:
message = f"stage={stage[:64]} code={code[:96]}"
if status is not None:
message += f" status={status}"
if trace_id is not None:
message += f" trace_id={trace_id}"
return SmokeError(message[:256])
@@ -134,6 +141,7 @@ class Client:
def __init__(self, base_url: str, timeout_seconds: int) -> None:
self.base_url = base_url.rstrip("/")
self.timeout_seconds = timeout_seconds
self.csrf_token: str | None = None
cookie_jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(cookie_jar)
@@ -156,6 +164,21 @@ class Client:
request_headers = {"Accept": "application/json"}
if headers:
request_headers.update(headers)
if (
self.csrf_token
and method not in ("GET", "HEAD", "OPTIONS")
and (
path_or_url.startswith("/api/admin/")
or path_or_url.startswith("/api/auth/")
)
and path_or_url
not in (
"/api/auth/login",
"/api/auth/bootstrap/complete",
"/api/auth/session/csrf",
)
):
request_headers.setdefault("x-csrf-token", self.csrf_token)
if payload is not None:
data = json.dumps(payload).encode("utf-8")
request_headers["Content-Type"] = "application/json"
@@ -203,12 +226,21 @@ def admin_path(workspace_id: str, suffix: str) -> str:
def login(client: Client, email: str, password: str) -> None:
client.request_json(
response = client.request_json(
"POST",
"/api/auth/login",
{"email": email, "password": password},
expected=(200,),
)
session = require_object(response.body, "login")
csrf_token = session.get("csrf_token")
if (
not isinstance(csrf_token, str)
or not 32 <= len(csrf_token) <= 256
or not all(character.isalnum() or character in "-_." for character in csrf_token)
):
raise safe_error("login", "invalid_csrf_token")
client.csrf_token = csrf_token
def resolve_workspace(
@@ -261,6 +293,29 @@ def create_operation(
raise safe_error("operation_create", "invalid_response") from error
def is_safe_diagnostic_identifier(value: Any) -> bool:
return (
isinstance(value, str)
and 1 <= len(value) <= 64
and value[0].islower()
and value[0].isascii()
and all(
character.isascii()
and (character.islower() or character.isdigit() or character == "_")
for character in value
)
)
def is_safe_trace_id(value: Any) -> bool:
return (
isinstance(value, str)
and len(value) == 32
and value != "0" * 32
and all(character in "0123456789abcdef" for character in value)
)
def run_operation_test(
client: Client,
workspace_id: str,
@@ -272,7 +327,22 @@ def run_operation_test(
admin_path(workspace_id, f"/operations/{operation_id}/test-runs"),
{"version": operation_version, "input": {"probe": "ok"}},
).body
if not isinstance(result, dict) or result.get("ok") is not True:
if isinstance(result, dict) and result.get("ok") is True:
return
if not isinstance(result, dict) or result.get("ok") is not False:
raise safe_error("operation_test", "outcome_not_ok")
errors = result.get("errors")
failure = errors[0] if isinstance(errors, list) and errors else None
code = failure.get("code") if isinstance(failure, dict) else None
stage = failure.get("stage") if isinstance(failure, dict) else None
trace_id = result.get("trace_id")
if (
is_safe_diagnostic_identifier(code)
and is_safe_diagnostic_identifier(stage)
and is_safe_trace_id(trace_id)
):
raise safe_error(stage, code, trace_id=trace_id)
raise safe_error("operation_test", "outcome_not_ok")
@@ -326,6 +396,17 @@ def create_agent(client: Client, workspace_id: str, agent_slug: str) -> tuple[st
raise safe_error("agent_create", "invalid_response") from error
def agent_etag(client: Client, workspace_id: str, agent_id: str) -> str:
response = client.request_json(
"GET",
admin_path(workspace_id, f"/agents/{agent_id}"),
)
etag = response.headers.get("ETag") if response.headers is not None else None
if not isinstance(etag, str) or len(etag) > 128 or not etag.startswith('"') or not etag.endswith('"'):
raise safe_error("agent_precondition", "invalid_response")
return etag
def edit_and_archive_operation(
client: Client,
workspace_id: str,
@@ -377,11 +458,13 @@ def bind_and_publish_agent(
"enabled": True,
}
],
headers={"If-Match": agent_etag(client, workspace_id, agent_id)},
)
published = client.request_json(
"POST",
admin_path(workspace_id, f"/agents/{agent_id}/publish"),
{"version": agent_version},
headers={"If-Match": agent_etag(client, workspace_id, agent_id)},
).body
try:
published_version = int(published["published_version"])
@@ -436,8 +519,15 @@ def cleanup_smoke_assets(
if agent_id:
try:
client.request_json(
"DELETE",
admin_path(workspace_id, f"/agents/{agent_id}"),
"POST",
admin_path(workspace_id, f"/agents/{agent_id}/unpublish"),
headers={"If-Match": agent_etag(client, workspace_id, agent_id)},
expected=(200, 404),
)
client.request_json(
"POST",
admin_path(workspace_id, f"/agents/{agent_id}/archive"),
headers={"If-Match": agent_etag(client, workspace_id, agent_id)},
expected=(200, 404),
)
except SmokeError as error:
+36 -10
View File
@@ -90,26 +90,52 @@ def playwright_verdict(report: dict[str, Any], required_titles: list[str]) -> tu
return "fail", counts
tests = list(iter_tests(report))
if not tests:
if required_titles:
counts["failed"] = len(set(required_titles))
return "fail", counts
counts["not_run"] = 1
return "not_run", counts
required = {title: False for title in required_titles}
for test, title in tests:
if required and title not in required:
continue
status = test.get("status")
results = test.get("results") if isinstance(test.get("results"), list) else []
result_statuses = [result.get("status") for result in results if isinstance(result, dict)]
retries = [result.get("retry", 0) for result in results if isinstance(result, dict)]
if len(result_statuses) != len(results):
results = test.get("results")
if not isinstance(results, list):
counts["not_run"] += 1
continue
if status == "flaky" or any(isinstance(retry, int) and retry > 0 for retry in retries):
result_statuses: list[Any] = []
retries: list[int] = []
malformed = False
for result in results:
if not isinstance(result, dict):
malformed = True
break
result_status = result.get("status")
retry = result.get("retry", 0)
if result_status not in ("passed", "failed", "timedOut", "skipped", "interrupted") \
or type(retry) is not int or retry < 0:
malformed = True
break
result_statuses.append(result_status)
retries.append(retry)
if malformed:
counts["not_run"] += 1
continue
final_status = result_statuses[-1] if result_statuses else None
if status in ("unexpected", "failed", "timedOut", "interrupted") \
or final_status in ("failed", "timedOut", "interrupted"):
counts["failed"] += 1
elif status == "flaky":
if final_status == "passed" and any(retry > 0 for retry in retries):
counts["flaky"] += 1
else:
counts["not_run"] += 1
elif status == "skipped" or (not results and status in ("skipped", "expected")):
counts["skipped"] += 1
elif status in ("unexpected", "failed", "timedOut", "interrupted") or any(
result_status in ("failed", "timedOut", "interrupted") for result_status in result_statuses
):
counts["failed"] += 1
elif results and all(result_status == "passed" for result_status in result_statuses):
elif any(retry > 0 for retry in retries):
counts["flaky"] += 1
elif status == "expected" and results and all(result_status == "passed" for result_status in result_statuses):
counts["passed"] += 1
if title in required:
required[title] = True
+120 -7
View File
@@ -26,13 +26,28 @@ compose_profiles=""
if [ "$cache_backend" = "valkey" ] || [ "$cache_backend" = "redis" ]; then
compose_profiles="--profile cache"
fi
compose() {
# Intentional word splitting: compose_profiles is either empty or two arguments.
# shellcheck disable=SC2086
docker compose $compose_profiles "$@"
}
compose_up() {
postgres_scale=""
if [ "$(env_value POSTGRES_HOST postgres)" != "postgres" ] \
&& compose config --services | grep -Fxq postgres; then
postgres_scale="--scale postgres=0"
fi
# Intentional word splitting: postgres_scale is either empty or two arguments.
# shellcheck disable=SC2086
compose up -d --remove-orphans $postgres_scale
}
show_failure_diagnostics() {
compose ps >&2 || true
compose logs --no-color migrate >&2 || true
}
wait_for_stack() {
readiness_path="$1"
attempt=1
@@ -48,6 +63,34 @@ wait_for_stack() {
return 1
}
find_artifact_container() {
expected_root="$1"
expected_mount="$(printf 'volume\t%s' "$expected_root")"
for service in admin-api artifact-storage-init; do
candidates="$(compose ps -aq "$service" 2>/dev/null || true)"
# Intentional word splitting: Docker container IDs cannot contain whitespace.
# shellcheck disable=SC2086
set -- $candidates
if [ "$#" -gt 1 ]; then
echo "Artifact backup found multiple $service containers; refusing an ambiguous volume source" >&2
return 1
fi
if [ "$#" -eq 1 ]; then
candidate="$1"
oneoff="$(docker inspect --format '{{index .Config.Labels "com.docker.compose.oneoff"}}' "$candidate" 2>/dev/null || true)"
mounts="$(docker inspect --format '{{range .Mounts}}{{printf "%s\t%s\n" .Type .Destination}}{{end}}' "$candidate" 2>/dev/null || true)"
if [ "$oneoff" != "True" ] && [ "$oneoff" != "true" ] \
&& printf '%s\n' "$mounts" | grep -Fxq "$expected_mount"; then
printf '%s' "$candidate"
return 0
fi
fi
done
return 1
}
create_backup() {
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
backup_dir="$(pwd)/backups/${timestamp}"
@@ -86,10 +129,9 @@ create_backup() {
--username "$postgres_user" --dbname "$postgres_db" \
--format custom --file /backup/postgres.dump
admin_container="$(compose ps -q admin-api 2>/dev/null || true)"
if [ -n "$admin_container" ]; then
storage_root="$(env_value_from "$backup_env_file" CRANK_STORAGE_ROOT /var/lib/crank/storage)"
docker run --rm --volumes-from "$admin_container" \
if artifact_container="$(find_artifact_container "$storage_root")"; then
docker run --rm --volumes-from "$artifact_container:ro" \
-v "$backup_dir:/backup" alpine:3.21 \
tar -C "$storage_root" -czf /backup/artifacts.tar.gz .
elif [ "$previous_deployment" = true ]; then
@@ -104,6 +146,76 @@ create_backup() {
| sort -nr | awk 'NR > 5 { print $2 }' | xargs -r rm -rf
}
validate_backup_migration() {
admin_image="$(env_value CRANK_ADMIN_API_IMAGE)"
if [ -z "$admin_image" ]; then
echo "CRANK_ADMIN_API_IMAGE is required for shadow migration validation" >&2
return 1
fi
shadow_id="crank-migration-check-$$"
shadow_network="${shadow_id}-network"
shadow_postgres="${shadow_id}-postgres"
shadow_password="crank-shadow-migration-password"
shadow_admin="crank_shadow_admin"
shadow_user="crank_shadow_owner"
shadow_cleanup() {
docker rm -fv "$shadow_postgres" >/dev/null 2>&1 || true
docker network rm "$shadow_network" >/dev/null 2>&1 || true
}
trap shadow_cleanup EXIT
trap 'exit 130' HUP INT TERM
docker network create "$shadow_network" >/dev/null
docker run -d --name "$shadow_postgres" --network "$shadow_network" \
-e POSTGRES_USER="$shadow_admin" \
-e POSTGRES_PASSWORD="$shadow_password" \
-e POSTGRES_DB=crank \
postgres:16-alpine >/dev/null
shadow_ready=false
attempt=1
while [ "$attempt" -le 30 ]; do
if docker logs "$shadow_postgres" 2>&1 | grep -q 'PostgreSQL init process complete' \
&& docker exec "$shadow_postgres" pg_isready --username "$shadow_admin" --dbname crank >/dev/null 2>&1; then
shadow_ready=true
break
fi
sleep 1
attempt=$((attempt + 1))
done
if [ "$shadow_ready" != true ]; then
echo "Shadow PostgreSQL did not become ready" >&2
return 1
fi
docker exec "$shadow_postgres" psql --username "$shadow_admin" --dbname crank \
--set ON_ERROR_STOP=1 \
--command "create role ${shadow_user} login password '${shadow_password}' nosuperuser nocreatedb nocreaterole noreplication" \
--command "alter database crank owner to ${shadow_user}"
timeout 10m docker run --rm --network "$shadow_network" \
-e PGPASSWORD="$shadow_password" \
-v "$backup_dir:/backup:ro" \
postgres:16-alpine \
pg_restore --host "$shadow_postgres" --username "$shadow_user" --dbname crank \
--no-owner --no-privileges --single-transaction --exit-on-error \
/backup/postgres.dump
shadow_database_url="postgres://${shadow_user}:${shadow_password}@${shadow_postgres}:5432/crank"
timeout 10m docker run --rm --network "$shadow_network" \
-e CRANK_DATABASE_URL="$shadow_database_url" \
"$admin_image" crank-migrate apply
shadow_preflight="$(timeout 2m docker run --rm --network "$shadow_network" \
-e CRANK_DATABASE_URL="$shadow_database_url" \
"$admin_image" crank-migrate preflight)"
printf '%s\n' "$shadow_preflight" | grep -Fxq '{"status":"current","version":13}'
shadow_cleanup
trap - EXIT HUP INT TERM
echo "Shadow migration validation passed"
}
rollback() {
echo "New release failed readiness; restoring previous deployment" >&2
if [ ! -f .env.previous ] || [ ! -f docker-compose.previous.yml ]; then
@@ -119,16 +231,17 @@ rollback() {
if [ "$cache_backend" = "valkey" ] || [ "$cache_backend" = "redis" ]; then
compose_profiles="--profile cache"
fi
compose up -d --remove-orphans
compose_up
wait_for_stack health
}
compose config -q
create_backup
compose pull
validate_backup_migration
if ! compose up -d --remove-orphans || ! wait_for_stack ready; then
compose ps >&2 || true
if ! compose_up || ! wait_for_stack ready; then
show_failure_diagnostics
rollback
exit 1
fi
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
port="${1:?port is required}"
path="${2:?path is required}"
if [[ ! "$port" =~ ^[0-9]{1,5}$ ]] || (( port < 1 || port > 65535 )); then
exit 64
fi
if [[ "$path" != /* || "$path" == *$'\r'* || "$path" == *$'\n'* ]]; then
exit 64
fi
# Bash provides /dev/tcp without adding a network client package to the runtime
# image. Compose still applies its own five-second timeout to the whole probe.
exec 3<>"/dev/tcp/127.0.0.1/${port}"
printf 'GET %s HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n' "$path" >&3
IFS=$'\r' read -r -t 2 status <&3
case "$status" in
'HTTP/1.0 200 '*|'HTTP/1.1 200 '*) ;;
*) exit 1 ;;
esac
+16 -2
View File
@@ -32,6 +32,15 @@ compose() {
# shellcheck disable=SC2086
docker compose $compose_profiles "$@"
}
compose_up() {
postgres_scale=""
if [ "$(env_value POSTGRES_HOST postgres)" != "postgres" ] \
&& compose config --services | grep -Fxq postgres; then
postgres_scale="--scale postgres=0"
fi
# shellcheck disable=SC2086
compose up -d --remove-orphans $postgres_scale
}
compose stop admin-api mcp-server ui
@@ -40,13 +49,18 @@ postgres_port="$(env_value POSTGRES_PORT 5432)"
postgres_db="$(env_value POSTGRES_DB crank)"
postgres_user="$(env_value POSTGRES_USER crank)"
postgres_password="$(env_value POSTGRES_PASSWORD)"
docker run --rm \
-v "$backup_dir:/backup:ro" \
postgres:16-alpine \
pg_restore --list /backup/postgres.dump >/dev/null
docker run --rm --network host \
-e PGPASSWORD="$postgres_password" \
-v "$backup_dir:/backup:ro" \
postgres:16-alpine \
pg_restore --host "$postgres_host" --port "$postgres_port" \
--username "$postgres_user" --dbname "$postgres_db" \
--clean --if-exists --no-owner --no-privileges /backup/postgres.dump
--clean --if-exists --no-owner --no-privileges \
--single-transaction --exit-on-error /backup/postgres.dump
admin_container="$(compose ps -aq admin-api)"
storage_root="$(env_value CRANK_STORAGE_ROOT /var/lib/crank/storage)"
@@ -54,7 +68,7 @@ docker run --rm --volumes-from "$admin_container" \
-v "$backup_dir:/backup:ro" alpine:3.21 sh -eu -c \
"find '$storage_root' -mindepth 1 -delete; tar -C '$storage_root' -xzf /backup/artifacts.tar.gz"
compose up -d --remove-orphans
compose_up
attempt=1
while [ "$attempt" -le 45 ]; do
if curl --fail --silent http://127.0.0.1:3000/ >/dev/null \
+245 -2
View File
@@ -17,6 +17,48 @@ def load_smoke_module():
class AuthenticatedProductSmokeTests(unittest.TestCase):
def test_client_attaches_csrf_only_to_browser_api_mutations(self) -> None:
smoke = load_smoke_module()
class Response:
status = 200
headers = {}
def read(self, _limit):
return b"{}"
class Opener:
def __init__(self):
self.requests = []
def open(self, request, timeout):
self.requests.append((request, timeout))
return Response()
client = smoke.Client("http://crank.test", 5)
opener = Opener()
client.opener = opener
client.csrf_token = "a" * 32
client.request_json("POST", "/api/admin/workspaces/ws/operations", {})
client.request_json("POST", "http://mcp.test/v1/ws/agent", {})
self.assertEqual(opener.requests[0][0].get_header("X-csrf-token"), "a" * 32)
self.assertIsNone(opener.requests[1][0].get_header("X-csrf-token"))
def test_login_keeps_server_issued_csrf_token(self) -> None:
smoke = load_smoke_module()
class FakeClient:
csrf_token = None
def request_json(self, *args, **kwargs):
return smoke.JsonResponse(200, {}, {"csrf_token": "b" * 32})
client = FakeClient()
smoke.login(client, "owner@crank.test", "safe-password")
self.assertEqual(client.csrf_token, "b" * 32)
def test_operation_payload_uses_internal_upstream(self) -> None:
smoke = load_smoke_module()
@@ -136,6 +178,8 @@ class AuthenticatedProductSmokeTests(unittest.TestCase):
return smoke.JsonResponse(200, {}, {"operation_id": "op_safe", "version": 7})
if path.endswith("/operations/op_safe"):
return smoke.JsonResponse(200, {"ETag": '"safe-etag"'}, {"id": "op_safe"})
if path.endswith("/agents/agent_safe"):
return smoke.JsonResponse(200, {"ETag": '"safe-agent-etag"'}, {"id": "agent_safe"})
if path.endswith("/publish") and "/operations/" in path:
return smoke.JsonResponse(200, {}, {"published_version": 7})
if path.endswith("/agents"):
@@ -158,11 +202,14 @@ class AuthenticatedProductSmokeTests(unittest.TestCase):
self.assertEqual((agent_id, agent_version, published_agent_version), ("agent_safe", 3, 3))
binding = next(payload for _, path, payload, _ in client.requests if path.endswith("/bindings"))[0]
self.assertEqual(binding["operation_version"], 7)
binding_request = next(request for request in client.requests if request[1].endswith("/bindings"))
self.assertEqual(binding_request[3]["headers"], {"If-Match": '"safe-agent-etag"'})
publish = next(request for request in client.requests if request[1].endswith("/operations/op_safe/publish"))
self.assertEqual(publish[3]["headers"], {"If-Match": '"safe-etag"'})
def test_admin_test_run_uses_created_version_and_rejects_failed_outcome(self) -> None:
def test_admin_test_run_uses_created_version_and_reports_safe_typed_failure(self) -> None:
smoke = load_smoke_module()
trace_id = "0123456789abcdef0123456789abcdef"
class FakeClient:
def __init__(self, ok):
@@ -171,7 +218,22 @@ class AuthenticatedProductSmokeTests(unittest.TestCase):
def request_json(self, method, path, payload=None, **kwargs):
self.payload = payload
return smoke.JsonResponse(200, {}, {"ok": self.ok, "errors": [{"message": "secret-canary"}]})
return smoke.JsonResponse(
200,
{},
{
"ok": self.ok,
"trace_id": trace_id,
"errors": [
{
"code": "outbound_target_rejected",
"stage": "adapter",
"message": "secret-canary",
"context": {"url": "https://private.invalid/token-canary"},
}
],
},
)
passing = FakeClient(True)
smoke.run_operation_test(passing, "ws", "op", 9)
@@ -179,8 +241,189 @@ class AuthenticatedProductSmokeTests(unittest.TestCase):
with self.assertRaises(smoke.SmokeError) as raised:
smoke.run_operation_test(FakeClient(False), "ws", "op", 9)
self.assertEqual(
str(raised.exception),
"stage=adapter code=outbound_target_rejected trace_id=0123456789abcdef0123456789abcdef",
)
for forbidden in ("secret-canary", "private.invalid", "token-canary", "context"):
self.assertNotIn(forbidden, str(raised.exception))
def test_admin_test_run_fails_closed_for_malformed_diagnostics(self) -> None:
smoke = load_smoke_module()
valid_trace_id = "0123456789abcdef0123456789abcdef"
valid_failure = {"code": "upstream_timeout", "stage": "upstream"}
malformed_results = (
None,
[],
"secret-canary",
{"trace_id": valid_trace_id, "errors": [valid_failure]},
{"ok": None, "trace_id": valid_trace_id, "errors": [valid_failure]},
{"ok": 0, "trace_id": valid_trace_id, "errors": [valid_failure]},
{"ok": "false", "trace_id": valid_trace_id, "errors": [valid_failure]},
{"ok": False, "errors": []},
{"ok": False, "errors": {}},
{"ok": False, "trace_id": valid_trace_id, "errors": [None]},
{"ok": False, "errors": ["secret-canary"]},
{"ok": False, "errors": [valid_failure]},
{
"ok": False,
"trace_id": valid_trace_id[:-1],
"errors": [valid_failure],
},
{
"ok": False,
"trace_id": valid_trace_id + "0",
"errors": [valid_failure],
},
{
"ok": False,
"trace_id": "0" * 32,
"errors": [valid_failure],
},
{
"ok": False,
"trace_id": valid_trace_id.upper(),
"errors": [valid_failure],
},
{
"ok": False,
"trace_id": "a" * 31 + "١",
"errors": [valid_failure],
},
{
"ok": False,
"trace_id": "a" * 31 + "\n",
"errors": [valid_failure],
},
{
"ok": False,
"trace_id": 42,
"errors": [valid_failure],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "", "stage": "upstream"}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "a" * 65, "stage": "upstream"}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "Upstream_timeout", "stage": "upstream"}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "upstream_таймаут", "stage": "upstream"}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": 42, "stage": "upstream"}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "upstream_timeout", "stage": ""}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "upstream_timeout", "stage": "a" * 65}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "upstream_timeout", "stage": "Upstream"}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "upstream_timeout", "stage": "вверх"}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "upstream_timeout", "stage": 42}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "unsafe-value://secret-canary", "stage": "upstream"}],
},
{
"ok": False,
"trace_id": valid_trace_id,
"errors": [{"code": "upstream_timeout", "stage": "unsafe stage secret-canary"}],
},
{
"ok": False,
"trace_id": "not-a-trace-id-secret-canary",
"errors": [{"code": "upstream_timeout", "stage": "upstream"}],
},
)
class FakeClient:
def __init__(self, result):
self.result = result
def request_json(self, *args, **kwargs):
return smoke.JsonResponse(200, {}, self.result)
for result in malformed_results:
with self.subTest(result=result), self.assertRaises(smoke.SmokeError) as raised:
smoke.run_operation_test(FakeClient(result), "ws", "op", 9)
self.assertEqual(str(raised.exception), "stage=operation_test code=outcome_not_ok")
self.assertNotIn("secret-canary", str(raised.exception))
def test_admin_test_run_accepts_maximum_length_safe_diagnostics(self) -> None:
smoke = load_smoke_module()
identifier = "a" * 64
trace_id = "0123456789abcdef0123456789abcdef"
class FakeClient:
def request_json(self, *args, **kwargs):
return smoke.JsonResponse(
200,
{},
{
"ok": False,
"trace_id": trace_id,
"request_preview": {"credential": "secret-canary"},
"response_preview": {"token": "secret-canary"},
"errors": [{"code": identifier, "stage": identifier}],
},
)
with self.assertRaises(smoke.SmokeError) as raised:
smoke.run_operation_test(FakeClient(), "ws", "op", 9)
rendered = str(raised.exception)
self.assertEqual(rendered, f"stage={identifier} code={identifier} trace_id={trace_id}")
self.assertLessEqual(len(rendered.encode("ascii")), 256)
self.assertNotIn("secret-canary", rendered)
def test_admin_test_run_success_ignores_malformed_diagnostics(self) -> None:
smoke = load_smoke_module()
class FakeClient:
def request_json(self, *args, **kwargs):
return smoke.JsonResponse(
200,
{},
{
"ok": True,
"trace_id": "secret-canary",
"errors": "secret-canary",
"request_preview": {"token": "secret-canary"},
},
)
smoke.run_operation_test(FakeClient(), "ws", "op", 9)
def test_edit_and_archive_use_fresh_operation_preconditions(self) -> None:
smoke = load_smoke_module()
@@ -57,6 +57,7 @@ class CapabilityBaselineCollectorTests(unittest.TestCase):
def test_required_openapi_tests_fail_closed_when_missing_skipped_or_flaky(self) -> None:
required = ["OpenAPI required scenario"]
reports = [
{"suites": []},
{"suites": [{"specs": [{"title": "another scenario", "tests": [{"status": "expected", "results": [{"status": "passed", "retry": 0}]}]}]}]},
{"suites": [{"specs": [{"title": required[0], "tests": [{"status": "skipped", "results": []}]}]}]},
{"suites": [{"specs": [{"title": required[0], "tests": [{"status": "flaky", "results": [{"status": "failed", "retry": 0}, {"status": "passed", "retry": 1}]}]}]}]},
@@ -70,6 +71,73 @@ class CapabilityBaselineCollectorTests(unittest.TestCase):
self.assertEqual(candidate["execution_verdict"], "fail")
self.assertFalse(candidate["accepted"])
def test_required_scope_ignores_unrelated_flaky_tests(self) -> None:
required = "OpenAPI required scenario"
report = {
"suites": [
{
"specs": [
{
"title": required,
"tests": [{"status": "expected", "results": [{"status": "passed", "retry": 0}]}],
},
{
"title": "Unrelated wizard scenario",
"tests": [
{
"status": "flaky",
"results": [
{"status": "failed", "retry": 0},
{"status": "passed", "retry": 1},
],
}
],
},
{"title": "Unrelated failure", "tests": [{"status": "unexpected", "results": [{"status": "failed", "retry": 0}]}]},
{"title": "Unrelated skip", "tests": [{"status": "skipped", "results": []}]},
{"title": "Unrelated malformed", "tests": [{"status": "expected", "results": None}]},
]
}
]
}
result, output, temporary = self.run_playwright(report, [required])
self.addCleanup(temporary.cleanup)
self.assertEqual(result.returncode, 0, result.stderr)
candidate = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(candidate["execution_verdict"], "pass")
self.assertTrue(candidate["accepted"])
self.assertEqual(candidate["summary"], {"passed": 1, "failed": 0, "flaky": 0, "skipped": 0, "not_run": 0})
def test_final_failure_after_retry_is_failed_not_flaky(self) -> None:
for status in ("unexpected", "flaky"):
with self.subTest(status=status):
report = {
"suites": [
{
"specs": [
{
"tests": [
{
"status": status,
"results": [
{"status": "failed", "retry": 0},
{"status": "failed", "retry": 1},
],
}
]
}
]
}
]
}
result, output, temporary = self.run_playwright(report)
self.addCleanup(temporary.cleanup)
self.assertEqual(result.returncode, 0, result.stderr)
candidate = json.loads(output.read_text(encoding="utf-8"))
self.assertEqual(candidate["execution_verdict"], "fail")
self.assertEqual(candidate["summary"]["failed"], 1)
self.assertEqual(candidate["summary"]["flaky"], 0)
def test_raw_report_content_never_reaches_candidate_or_error(self) -> None:
canary = "Bearer secret-canary /home/private/workspace https://private.invalid?q=secret"
report = {"suites": [], "errors": [{"message": canary}], "stdout": [canary]}
@@ -84,6 +152,8 @@ class CapabilityBaselineCollectorTests(unittest.TestCase):
reports = [
{"errors": [{"message": "fatal"}], "suites": [{"specs": [{"tests": [{"status": "expected", "results": [{"status": "passed"}]}]}]}]},
{"suites": [{"specs": [{"tests": [{"status": "expected", "results": ["not-an-object"]}]}]}]},
{"suites": [{"specs": [{"tests": [{"status": "expected", "results": None}]}]}]},
{"suites": [{"specs": [{"tests": [{"status": "expected", "results": [{"status": "passed", "retry": True}]}]}]}]},
]
for report in reports:
with self.subTest(report=report):