2 Commits

Author SHA1 Message Date
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
12 changed files with 205 additions and 30 deletions
+30 -3
View File
@@ -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:
+1 -1
View File
@@ -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
+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/');
+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
+2 -2
View File
@@ -141,7 +141,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 +202,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
+48 -3
View File
@@ -134,6 +134,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 +157,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 +219,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(
@@ -326,6 +351,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 +413,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 +474,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:
+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
@@ -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,6 +202,8 @@ 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"'})