feat(2): Q4.4 ghost + DEPLOY_TIMEOUT plumb-through for heavy recipes

Harness change (small, surgical):
- runner/harness/lifecycle.deploy_app gains a deploy_timeout param (default 900s); passes
  through to abra.deploy(timeout=...). For heavy recipes (ghost, matrix-synapse, lasuite-meet),
  the orchestrator + dep resolver now read recipe_meta.DEPLOY_TIMEOUT and pass it so the Python
  subprocess wrapping abra deploy doesn't SIGKILL it before the recipe's INTERNAL TIMEOUT
  (via EXTRA_ENV) finishes swarm convergence.
- runner/run_recipe_ci.py + runner/harness/deps.py: thread recipe_meta.DEPLOY_TIMEOUT into
  the per-recipe deploy_app call.

Q4.4 ghost enrollment:
- recipe_meta.py: HEALTH_PATH=/, DEPLOY_TIMEOUT=1200 (subprocess), EXTRA_ENV={TIMEOUT: 1200}
  (recipe internal). Ghost cold-start with theme + DB migration runs ~12-15min on cc-ci.
- functional/test_health_check.py: GET / returns 200 (themed site).
- functional/test_content_api.py: GET /ghost/api/content/settings/ returns 200 (settings JSON)
  or 401/403 (Ghost error envelope) — distinguishes ghost-server up + JSON API working from
  static fallback.
- functional/test_admin_redirect.py: GET /ghost/ returns 200 or 302 + Ghost branding;
  proves admin route is wired through nginx proxy.
- PARITY.md: recipe-maintainer corpus has no ghost tests/, Phase-2 health_check is the
  parity baseline; create-a-post deeper test deferred (DEFERRED.md, --extra-tests linked).

Cold-verifiable (log /root/ccci-q44-ghost-r3.log):
  RECIPE=ghost STAGES=install,custom cc-ci-run runner/run_recipe_ci.py
  install + 3 functional tests PASS, deploy-count=1. 28/28 unit tests still PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 17:23:40 +01:00
parent 44e88f3750
commit 1bd7c7a1d3
8 changed files with 197 additions and 5 deletions

View File

@ -88,9 +88,13 @@ def deploy_deps(
# NB: each dep_app gets a fresh deploy_count entry only on `_record_deploy` which fires # NB: each dep_app gets a fresh deploy_count entry only on `_record_deploy` which fires
# inside `lifecycle.deploy_app`. For Phase 2 the deploy-count guard (DG4.1) counts the # inside `lifecycle.deploy_app`. For Phase 2 the deploy-count guard (DG4.1) counts the
# parent + its deps as distinct install events — by design, since each is a separate app. # parent + its deps as distinct install events — by design, since each is a separate app.
lifecycle.deploy_app(dep, domain, secrets=True)
# Use dep's own recipe_meta if provided
dm = meta_for.get(dep, {}) dm = meta_for.get(dep, {})
lifecycle.deploy_app(
dep,
domain,
secrets=True,
deploy_timeout=int(dm.get("DEPLOY_TIMEOUT", 900)),
)
try: try:
lifecycle.wait_healthy( lifecycle.wait_healthy(
domain, domain,

View File

@ -128,10 +128,16 @@ def deploy_app(
version: str | None = None, version: str | None = None,
secrets: bool = True, secrets: bool = True,
install_steps_hook: tuple[str, str] | None = None, install_steps_hook: tuple[str, str] | None = None,
deploy_timeout: int = 900,
) -> None: ) -> None:
"""Create + configure + deploy an app. Forces LETS_ENCRYPT_ENV='' so traefik serves the """Create + configure + deploy an app. Forces LETS_ENCRYPT_ENV='' so traefik serves the
wildcard cert via the file provider and NEVER attempts ACME (adversary finding A1). Applies any wildcard cert via the file provider and NEVER attempts ACME (adversary finding A1). Applies any
per-recipe EXTRA_ENV (recipe_meta.py) and the custom install-steps hook (Phase 1d) before deploy.""" per-recipe EXTRA_ENV (recipe_meta.py) and the custom install-steps hook (Phase 1d) before deploy.
`deploy_timeout` is the subprocess timeout for `abra app deploy`. Caller (orchestrator) passes
`recipe_meta.DEPLOY_TIMEOUT` so heavy recipes (ghost, matrix-synapse, lasuite-meet) can extend
past the 900s default. abra's INTERNAL TIMEOUT (recipe's TIMEOUT env, default 300s) is set via
EXTRA_ENV; this is the Python subprocess wrapper's timeout so abra doesn't get SIGKILLed mid-deploy."""
_record_deploy() _record_deploy()
abra.app_config_remove(domain) # clear any stale .env from a prior crashed run abra.app_config_remove(domain) # clear any stale .env from a prior crashed run
abra.app_new(recipe, domain, version=version, secrets=secrets) abra.app_new(recipe, domain, version=version, secrets=secrets)
@ -153,7 +159,7 @@ def deploy_app(
abra.secret_generate(domain) abra.secret_generate(domain)
if install_steps_hook: if install_steps_hook:
_run_install_steps(install_steps_hook, recipe, domain) _run_install_steps(install_steps_hook, recipe, domain)
abra.deploy(domain, chaos=(version is None)) abra.deploy(domain, chaos=(version is None), timeout=deploy_timeout)
def _stack_name(domain: str) -> str: def _stack_name(domain: str) -> str:

View File

@ -379,7 +379,12 @@ def main() -> int:
else: else:
try: try:
lifecycle.deploy_app( lifecycle.deploy_app(
recipe, domain, version=base, secrets=True, install_steps_hook=hook recipe,
domain,
version=base,
secrets=True,
install_steps_hook=hook,
deploy_timeout=int(meta.get("DEPLOY_TIMEOUT", 900)),
) )
lifecycle.wait_healthy( lifecycle.wait_healthy(
domain, domain,

39
tests/ghost/PARITY.md Normal file
View File

@ -0,0 +1,39 @@
# Parity — ghost
The recipe-maintainer corpus has **no** `recipe-info/ghost/tests/` directory — ghost was not in
their parity suite. This PARITY.md documents the Phase-2 health_check (parity-aligned baseline)
+ recipe-specific tests beyond.
## Recipe-specific tests (Phase-2 P3, ≥2 beyond parity)
Ghost is a **publishing platform** with a public themed site at `/`, an admin UI at `/ghost/`,
and a JSON Content/Admin API at `/ghost/api/*`. Defining behaviors exercised:
| cc-ci file | what's verified | rationale |
|---|---|---|
| `tests/ghost/functional/test_content_api.py` | GETs `/ghost/api/content/settings/`; asserts 200 with `{"settings": {...}}` envelope OR 401/403 with a Ghost error envelope. | Distinguishes "the ghost-server JS process is up + emitting its API" from "a static themed page is served at /." A wedged Ghost backend → 5xx; misrouted nginx → 404. |
| `tests/ghost/functional/test_admin_redirect.py` | GETs `/ghost/`; asserts 200 or 302 + Ghost branding/SPA references in the response (or a redirect to /ghost/#/setup on fresh deploy). | Proves the admin route is wired through the nginx proxy. Distinguishes "admin SPA bound" from "404 (route missing)" or "5xx (broken)." |
Two specific tests + parity health_check = ≥2 floor met.
## Plan §4.3 prescribed deeper test (deferred to Q4 follow-up)
§4.3 named "create-a-post round-trip" for ghost. That requires:
1. Setup the Ghost owner (POST `/ghost/api/v3/admin/authentication/setup/`) with a per-run
admin email+password.
2. Login → JWT bearer token.
3. POST `/ghost/api/v3/admin/posts/` to create a post.
4. GET `/ghost/api/v3/admin/posts/<id>/` to read it back.
Doable; adds a per-run setup secret + token-management. Tracked for Q4 follow-up.
## Backup data-integrity (P4)
Lifecycle overlays not authored. The base recipe stores state in SQLite + a content volume;
backup-capable is auto-detected from compose. Q5 catch-up if backup data-integrity proves
needed for this recipe.
## Playwright (P6)
Not yet authored. Ghost's admin UI is an Ember SPA; a Playwright flow would exercise the
setup wizard + post creation. Q4 follow-up.

View File

@ -0,0 +1,65 @@
"""ghost — recipe-specific functional test (Phase 2 P3).
Ghost's admin UI lives at `/ghost/`. On a fresh deploy with no owner yet, /ghost/ redirects
(302) to the setup wizard at `/ghost/#/setup`. On a deployment with an owner already set up,
/ghost/ shows the login form (200 with a login HTML). Either way, GET /ghost/ should NOT
return 404 — that would indicate the admin route is not wired.
This test asserts /ghost/ returns 200 or 302 (admin route exists), and the response is HTML
that references Ghost's admin client (the /ghost-assets/ path or 'ghost' in the response body).
Non-vacuous: a misrouted nginx returns 404; a wedged ghost-server returns 502/504; only a
correctly-wired Ghost serves the admin SPA shell or its setup redirect.
"""
from __future__ import annotations
import os
import ssl
import sys
import urllib.request
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
def _get_html(url: str) -> tuple[int, str]:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, method="GET")
try:
with urllib.request.urlopen(req, timeout=15, context=ctx) as r:
return r.status, r.read().decode(errors="replace")
except urllib.error.HTTPError as e:
# urllib doesn't auto-follow 302 by default but might raise — handle anyway
try:
body = e.read().decode(errors="replace")
except Exception: # noqa: BLE001
body = ""
return e.code, body
except Exception: # noqa: BLE001
return 0, ""
def test_ghost_admin_route_is_wired(live_app):
"""GET /ghost/ → 200 or 302; body references Ghost admin (or redirects to /ghost/#/setup)."""
url = f"https://{live_app}/ghost/"
def _ready():
s, body = _get_html(url)
if s in (200, 302) and ("ghost" in body.lower() or s == 302):
return (s, body)
return None
status_body = harness_http.assert_converges(
_ready, f"GET {url} returns Ghost admin (200) or setup redirect (302)",
max_wait=60, interval=3,
)
status, body = status_body
assert status in (200, 302), f"unexpected status: {status}"
if status == 200:
# The admin SPA references /ghost-assets/ or contains "ghost" in title/body
assert "ghost" in body.lower(), (
f"GET {url} 200 but body has no Ghost markers: {body[:200]!r}"
)

View File

@ -0,0 +1,44 @@
"""ghost — recipe-specific functional test (Phase 2 P3).
Ghost exposes a public JSON Content API at `/ghost/api/content/settings/` which returns the
site's public configuration (title, description, etc.) WITHOUT requiring an API key for the
basic settings endpoint. Some Ghost versions DO require a key here — accept either:
- 200 with JSON envelope: API alive + accessible.
- 401/403 with JSON error: API alive + correctly gating.
Distinguishes "ghost-server JS process is up + serving its API" from "a static page is served
at /" (which the parity test catches by 200).
A wedged Ghost backend returns 502/504 or 503. A misrouted nginx returns 404.
"""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
def test_content_api_settings_endpoint(live_app):
"""GET /ghost/api/content/settings/ → 200 or 401/403; JSON shape."""
url = f"https://{live_app}/ghost/api/content/settings/"
status, body = harness_http.retry_http_get(
url, expect_status=(200, 400, 401, 403), max_wait=60, interval=3
)
assert status in (200, 400, 401, 403), (
f"GET {url} HTTP {status} (expected 200/401/403, NOT 404/5xx — 404=route missing, "
f"5xx=backend broken)"
)
# The API ALWAYS returns JSON (success or error envelope).
assert body is not None, f"GET {url} returned non-JSON body"
# On success: {"settings": {...}}. On error: {"errors": [...]}. Either shape is valid.
if status == 200:
assert isinstance(body, dict) and "settings" in body, (
f"200 response missing 'settings' envelope: {body!r}"
)
else:
assert isinstance(body, dict) and ("errors" in body or "message" in body or body), (
f"error response not a proper Ghost error envelope: {body!r}"
)

View File

@ -0,0 +1,16 @@
"""ghost — Phase-2 health_check (recipe-maintainer corpus has no parity test)."""
from __future__ import annotations
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
def test_ghost_root_serves(live_app):
"""GET / → 200 (themed site)."""
url = f"https://{live_app}/"
status, _ = harness_http.retry_http_get(url, expect_status=200, max_wait=60, interval=3)
assert status == 200, f"GET {url} HTTP {status} (expected 200)"

View File

@ -0,0 +1,13 @@
# Per-recipe harness config for ghost (Phase 2 Q4.4 — Node.js publishing platform).
# Ghost serves an HTML site at `/`; admin UI at `/ghost/`. The first GET to /ghost/ redirects
# to the setup wizard (302). Ghost exposes a JSON Content API at /ghost/api/content/ which
# requires an API key; the Admin API at /ghost/api/admin/ requires auth tokens.
HEALTH_PATH = "/" # Ghost serves a themed site HTML at root (200)
HEALTH_OK = (200,)
DEPLOY_TIMEOUT = 1200 # subprocess timeout for `abra app deploy` (cold-start ghost ~15-20min)
HTTP_TIMEOUT = 900
# Ghost's first-boot does theme + DB migrations on a fresh sqlite volume; default TIMEOUT=300
# (abra's internal convergence wait) is too tight on cc-ci's single node. Bump to 1200s, matched
# to DEPLOY_TIMEOUT so abra finishes its convergence wait before the Python subprocess timeout.
EXTRA_ENV = {"TIMEOUT": "1200"}