From be0f7db9690c888518e57cdd72341c85d9e3cb9e Mon Sep 17 00:00:00 2001 From: Capsizer Date: Sat, 7 Nov 2026 01:57:42 +0330 Subject: [PATCH 1/4] fix(compose): the worker's store init now runs on every up, after the core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2.5.0 clean-install gate caught a real fresh-install bug: the worker container crash-looped with "probe write: attempt to write a readonly database" and the stack never reported healthy. Root cause: the worker container is not profile-gated — it always runs to report health (only execution is gated by ATF_WORKER_ENABLED) — but the worker-store-init that grants the worker's group write access to atf.db was behind profiles: ["worker"], so a stock `docker compose up` never ran it. The worker (uid 100) then probed a root-owned atf.db, fail-closed on purpose (#285), restart-looped, and its /ops endpoint — which the API's health payload probes — never came up: status degraded, gate failed. Two ordering holes are closed, not one: 1. The init lost its profile: it runs on every `compose up` now. 2. The init waits for the core to be healthy, and the worker waits for the init to complete. On a fresh volume atf.db does not exist until the core creates it — an init that ran earlier would chgrp an empty directory and leave the newly created file root-owned, reintroducing the same crash loop on the first boot. Idempotency is unchanged: chgrp/chmod on an already-prepared store is a no-op, so re-runs after a core restart stay safe, and the postgres dependency keeps its `required: false` profile gate. --- docker-compose.yml | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 2097f94a..fa53e50e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -213,7 +213,7 @@ services: start_period: 40s # PostgreSQL delivery store for the Go Worker. Profile-gated on `worker` - # for the same reason worker-store-init is: a stock deployment runs on + # because it is only ever an opt-in: a stock deployment runs on # SQLite and must not gain a database it never asked for. An operator opts # the durable queue onto PostgreSQL by setting ATF_DATABASE_URL on the # worker (and the Core, which owns the schema bootstrap). @@ -244,15 +244,25 @@ services: retries: 20 profiles: ["worker"] - # One-time preparation of the shared delivery store for the non-root - # worker. The Core creates atf.db as root; the worker (uid 100) must be - # able to write its claim updates to the SAME file. This runs only under - # the `worker` profile (i.e. when the operator opts into the Go Worker), - # so a stock deployment never sees it. + # Preparation of the shared delivery store for the non-root worker. + # The Core creates atf.db as root; the worker (uid 100) must be able to + # write its claim updates to the SAME file. This runs on EVERY `compose + # up` — the worker container itself is not profile-gated (it always runs + # to report health; only execution is gated by ATF_WORKER_ENABLED), so a + # stock deployment needs this too. It waits for the core to be healthy + # because on a fresh volume atf.db does not exist until the core creates + # it: an init that ran earlier would chgrp an empty directory and leave + # the newly created file root-owned — the exact crash loop + # ("probe write: attempt to write a readonly database") that failed the + # v2.5.0 clean-install gate. Idempotent: a second run is a no-op, so + # re-running after a Core restart is safe. worker-store-init: image: alpine:3.22 container_name: atf-worker-store-init restart: "no" + depends_on: + core: + condition: service_healthy volumes: - atf-data:/data # chgrp to the worker's gid (101 in the alpine image, from `addgroup -S`) @@ -261,7 +271,6 @@ services: command: > sh -c "chgrp -R 101 /data && chmod -R g+w /data || true; echo 'shared store prepared for the atf group'" - profiles: ["worker"] # Go execution engine (business execution plane). OFF by default: # ATF_WORKER_ENABLED must be set (AND ATF_CORE_RPC_TOKEN must be non-empty) @@ -344,6 +353,11 @@ services: depends_on: core: condition: service_healthy + # The store init must have chgrp'd/chmod'd atf.db before this + # container's probe write — without the wait the worker fail-closes + # on a root-owned database and restart-loops forever. + worker-store-init: + condition: service_completed_successfully # `required: false` is what makes the profile gate work: when the # `worker` profile is off, postgres does not exist and compose skips # this dependency instead of failing; when the profile is on, the From 7e60a371da28d695ef53bc46caade719b3230892 Mon Sep 17 00:00:00 2001 From: Capsizer Date: Sat, 7 Nov 2026 02:00:27 +0330 Subject: [PATCH 2/4] ci(temp): run the clean-install gate on the fix PR, before the tag The real gate only runs on v* tags, which is how the v2.5.0 clean install failed after the tag was cut. This is release.yml's clean-install job, copied verbatim, triggered by PRs that touch the compose stack. Deleted once the fix has shipped. --- .github/workflows/clean-install-smoke.yml | 105 ++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 .github/workflows/clean-install-smoke.yml diff --git a/.github/workflows/clean-install-smoke.yml b/.github/workflows/clean-install-smoke.yml new file mode 100644 index 00000000..b9d9c10b --- /dev/null +++ b/.github/workflows/clean-install-smoke.yml @@ -0,0 +1,105 @@ +# TEMPORARY — the clean-install gate, runnable before a tag. +# +# The real gate lives in release.yml and only runs on v* tags, which is +# how the v2.5.0 clean-install failed only after the tag was cut. This +# copy runs the identical job on PRs so a compose-level regression is +# caught before the tag. Delete it once the fix has shipped. +name: Clean-install smoke + +on: + pull_request: + paths: + - "docker-compose.yml" + - "Dockerfile.*" + - ".env.example" + - ".github/workflows/clean-install-smoke.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: clean-install-smoke-${{ github.ref }} + cancel-in-progress: true + +jobs: + clean-install: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Create minimal .env for smoke test + shell: bash + run: | + cp .env.example .env + echo "compose smoke test uses placeholder credentials from .env.example" + # Those credentials cannot log in to Telegram. Without the core's + # supported gRPC-only mode it exits on the failed bot login, restart-loops, + # and any `docker compose exec` into it dies with 137 mid-check — which is + # exactly how the v2.2.0 release failed this job. + echo "ATF_DISABLE_BOT=1" >> .env + grep -q '^ATF_DISABLE_BOT=1$' .env + + - name: Compose up + shell: bash + run: docker compose up -d --build + + - name: Poll health endpoints + # Production release gate: every required internal service (core, + # logger, API, web) must report online. Placeholder external Telegram + # credentials are tolerated only if they do not degrade internal + # service health; an HTTP response alone is not a healthy stack. + shell: bash + run: | + for i in $(seq 1 30); do + A=$(curl -sf http://127.0.0.1:8088/healthz || true) + B=$(curl -sf http://127.0.0.1:8080/api/v1/health/services || true) + if [ -n "$A" ] && [ -n "$B" ]; then + echo "web /healthz: $A" + echo "api /api/v1/health/services: $B" + if echo "$B" | python3 -c "import json,sys; d=json.load(sys.stdin); s=d.get('services'); assert d.get('status') == 'healthy' and isinstance(s,list) and {x.get('name') for x in s if x.get('online') is True} >= {'core','logger','api','web'} and all(x.get('online') is True for x in s), d"; then + echo "stack serves HTTP with a valid services payload" + exit 0 + fi + fi + echo "waiting for stack... ($i/30)" + sleep 10 + done + echo "health endpoints never became ready" + docker compose ps + docker compose logs --tail=100 + exit 1 + + - name: Prove the logger is the Rust engine and it ingests end-to-end + # A stack that answers /healthz is not enough: the logging service must + # be the Rust engine (the Python daemon is gone) and a real gRPC write + # must survive all the way to storage and back out of a query. + shell: bash + run: | + docker compose exec -T logger sh -c 'command -v atf-logger && ! command -v python3' + docker compose exec -T logger curl -fsS http://127.0.0.1:9109/healthz + docker compose exec -T logger curl -fsS http://127.0.0.1:9109/readyz + docker compose exec -T logger curl -fsS http://127.0.0.1:9109/metrics | grep -q '^atf_logger_events_' + # 24 contract checks through Core's own stubs and producer class. + docker compose exec -e ATF_LOGGER_ADDR=logger:50052 -T core python - \ + < scripts/acceptance/logger_acceptance.py + + - name: Diagnostics on failure + if: failure() + run: | + echo "::group::compose ps" + docker compose ps + echo "::endgroup::" + echo "::group::logs" + docker compose logs --tail=200 + echo "::endgroup::" + echo "::group::disk" + df -h / + docker system df + echo "::endgroup::" + + - name: Compose down (always) + if: always() + shell: bash + run: docker compose down -v From ef4e28770e14afcf2ce48c3709263ef9cf0a8dad Mon Sep 17 00:00:00 2001 From: Capsizer Date: Sat, 7 Nov 2026 02:20:33 +0330 Subject: [PATCH 3/4] fix(worker): a disabled worker boots health-only instead of restart-looping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second layer the clean-install gate exposed after the store-init fix: with ATF_CORE_RPC_TOKEN empty the worker exited at startup with "internal RPC token is empty: refusing an unauthenticated client" — even when ATF_WORKER_ENABLED=0. The container then restart-looped, its /ops endpoint (probed by the API's health payload) never served, the stack reported degraded, and the release gate failed again. The compose contract always said refusal is for an ENABLED worker: "Empty + enabled = refuse to start, on purpose. Until then the Python Core keeps its queue loop and this worker only reports health." The code deviated by constructing the gateway client unconditionally and treating its refusal as fatal regardless of the switch. The disabled path now boots with the health-only telegraph.Gateway (internal/telegraph/unconfigured): every operation fails closed with TRANSPORT_UNCONFIGURED — non-transient by construction, the refusal carries the startup reason verbatim and names the operation that was never attempted — so a latent caller gets an honest failure instead of a nil-pointer panic. An ENABLED worker with an empty token still refuses to start: fail closed, on purpose. --- worker/cmd/worker/main.go | 22 ++- .../telegraph/unconfigured/unconfigured.go | 168 ++++++++++++++++++ .../unconfigured/unconfigured_test.go | 101 +++++++++++ 3 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 worker/internal/telegraph/unconfigured/unconfigured.go create mode 100644 worker/internal/telegraph/unconfigured/unconfigured_test.go diff --git a/worker/cmd/worker/main.go b/worker/cmd/worker/main.go index 2610594f..b43e0b62 100644 --- a/worker/cmd/worker/main.go +++ b/worker/cmd/worker/main.go @@ -53,6 +53,7 @@ import ( "atf/worker/internal/telegraph" "atf/worker/internal/telegraph/fakegateway" "atf/worker/internal/telegraph/grpcclient" + "atf/worker/internal/telegraph/unconfigured" ) // ContractVersion is the Telegram Gateway contract this build speaks. It @@ -174,10 +175,25 @@ func run() (runErr error) { default: client, err := grpcclient.New(cfg.TelegramRuntimeAddr, cfg.CoreRPCToken, workerID(), 60*time.Second) if err != nil { - return err + if !cfg.WorkerEnabled { + // The documented contract: an empty token refuses to + // start only an ENABLED worker. A disabled one exists to + // report health — the strangler-fig guarantee that the + // Python Core owns execution until the operator moves the + // boundary — so it boots with the health-only gateway + // instead of restart-looping with a dark /ops endpoint + // (the crash loop the v2.5.0 clean-install gate caught). + log.Warn("Telegram runtime client unconfigured — running health-only; enable the worker only after configuring it", + "error", err, + "telegram_runtime", cfg.TelegramRuntimeAddr) + gateway = unconfigured.New(err.Error()) + } else { + return err + } + } else { + defer client.Close() + gateway = client } - defer client.Close() - gateway = client } rules, err := openRuleStore(context.Background(), cfg) diff --git a/worker/internal/telegraph/unconfigured/unconfigured.go b/worker/internal/telegraph/unconfigured/unconfigured.go new file mode 100644 index 00000000..ce367bf0 --- /dev/null +++ b/worker/internal/telegraph/unconfigured/unconfigured.go @@ -0,0 +1,168 @@ +// Package unconfigured is the health-only telegraph.Gateway of a worker +// that cannot reach the Telegram runtime. +// +// The worker container always runs — even before the operator opts into Go +// execution — so it can report health. A worker in that state has no usable +// Telegram transport: ATF_CORE_RPC_TOKEN is empty, so the real gRPC client +// refuses to construct ("refusing an unauthenticated client"). Rather than +// refusing to START — which would leave the ops endpoint dark, the API's +// health payload degraded, and the container restart-looping (the crash +// loop the v2.5.0 clean-install gate caught) — the process boots with this +// gateway: every operation fails closed with a failure that names the +// missing configuration and the operation that was never attempted. +// +// The delivery engine never reaches it while ATF_WORKER_ENABLED is off. +// The moment the operator enables the worker, an unconfigured token is +// fatal at startup again — fail closed, on purpose. +// +// Like the fake gateway, this is never a substitute for a real delivery: +// nothing sent through it happened. +package unconfigured + +import ( + "context" + + "atf/worker/internal/telegraph" +) + +// Code is the canonical failure category for an operation that was refused +// because the worker has no Telegram transport configured. Not in +// DeliveryFailure.IsTransient: a missing configuration does not heal by +// retrying. +const Code = "TRANSPORT_UNCONFIGURED" + +// Gateway is the telegraph.Gateway of a health-only worker. +type Gateway struct { + // reason is the startup error the real client refused with, surfaced + // verbatim so the operator reads the actual cause, not a paraphrase. + reason string +} + +// New returns a Gateway whose every operation fails with reason. +func New(reason string) *Gateway { + return &Gateway{reason: reason} +} + +// refusal builds the failure every operation returns. The operation name is +// spelled out so a log line or Log Center row answers "what did NOT happen" +// without reading the stack. +func (g *Gateway) refusal(op string) *telegraph.DeliveryFailure { + return &telegraph.DeliveryFailure{ + Code: Code, + Message: "worker runs health-only: Telegram transport unconfigured (" + + g.reason + ") — " + op + " never attempted", + } +} + +// IsSessionReady reports the transport as never ready: a connecting session +// is not ready either, so callers defer rather than fail the job. +func (g *Gateway) IsSessionReady(_ context.Context, _ string) (bool, string, error) { + return false, "transport unconfigured (" + g.reason + ")", nil +} + +// --- reads ------------------------------------------------------------- // + +// GetMessage is refused: see the package comment. +func (g *Gateway) GetMessage(_ context.Context, _ string, _, _ int64) (*telegraph.MessageSnapshot, *telegraph.DeliveryFailure) { + return nil, g.refusal("GetMessage") +} + +// GetAlbumMessageIds is refused: see the package comment. +func (g *Gateway) GetAlbumMessageIds(_ context.Context, _ string, _, _ int64) ([]int64, *telegraph.DeliveryFailure) { + return nil, g.refusal("GetAlbumMessageIds") +} + +// GetFileExtension is refused: see the package comment. +func (g *Gateway) GetFileExtension(_ context.Context, _ string, _, _ int64) (string, *telegraph.DeliveryFailure) { + return "", g.refusal("GetFileExtension") +} + +// --- media staging ------------------------------------------------------ // + +// DownloadMedia is refused: see the package comment. +func (g *Gateway) DownloadMedia(_ context.Context, _ string, _, _ int64, _ telegraph.MediaHandle) (int64, *telegraph.DeliveryFailure) { + return 0, g.refusal("DownloadMedia") +} + +// ReloadMedia is refused: see the package comment. +func (g *Gateway) ReloadMedia(_ context.Context, _ string, _, _ int64, _ telegraph.MediaHandle, _ string) (int64, *telegraph.DeliveryFailure) { + return 0, g.refusal("ReloadMedia") +} + +// PrepareMediaUpload is refused: see the package comment. +func (g *Gateway) PrepareMediaUpload(_ context.Context, _ string, _ telegraph.MediaHandle, _, _ int64, _ string) (string, *telegraph.DeliveryFailure) { + return "", g.refusal("PrepareMediaUpload") +} + +// --- sends (the Telegram side effects) ---------------------------------- // + +// ForwardMessages is refused: see the package comment. +func (g *Gateway) ForwardMessages(_ context.Context, _ string, _, _ int64, _ []int64, _ int64) ([]int64, *telegraph.DeliveryFailure) { + return nil, g.refusal("ForwardMessages") +} + +// CopyMessage is refused: see the package comment. +func (g *Gateway) CopyMessage(_ context.Context, _ string, _, _, _ int64, _ string, _ int64, _ bool) (int64, *telegraph.DeliveryFailure) { + return 0, g.refusal("CopyMessage") +} + +// CopyMessages is refused: see the package comment. +func (g *Gateway) CopyMessages(_ context.Context, _ string, _, _ int64, _ []int64, _ string, _ int64, _ bool) ([]int64, *telegraph.DeliveryFailure) { + return nil, g.refusal("CopyMessages") +} + +// CopyMediaGroup is refused: see the package comment. +func (g *Gateway) CopyMediaGroup(_ context.Context, _ string, _, _ int64, _ int64, _ string, _ int64) ([]int64, *telegraph.DeliveryFailure) { + return nil, g.refusal("CopyMediaGroup") +} + +// SendMessage is refused: see the package comment. +func (g *Gateway) SendMessage(_ context.Context, _ string, _ int64, _ string, _ int64) (int64, *telegraph.DeliveryFailure) { + return 0, g.refusal("SendMessage") +} + +// SendMedia is refused: see the package comment. +func (g *Gateway) SendMedia(_ context.Context, _ string, _ int64, _, _, _ string, _ int64) (int64, *telegraph.DeliveryFailure) { + return 0, g.refusal("SendMedia") +} + +// --- bot transport ------------------------------------------------------ // + +// BotAvailability is refused: see the package comment. +func (g *Gateway) BotAvailability(_ context.Context, _ telegraph.BotTarget) (bool, string, *telegraph.DeliveryFailure) { + return false, "", g.refusal("BotAvailability") +} + +// BotSendText is refused: see the package comment. +func (g *Gateway) BotSendText(_ context.Context, _ telegraph.BotTarget, _ int64, _ string, _ int64, _ []string) (int64, *telegraph.DeliveryFailure) { + return 0, g.refusal("BotSendText") +} + +// BotCopyMessage is refused: see the package comment. +func (g *Gateway) BotCopyMessage(_ context.Context, _ telegraph.BotTarget, _, _, _ int64, _ string, _ int64, _ []string) (int64, *telegraph.DeliveryFailure) { + return 0, g.refusal("BotCopyMessage") +} + +// BotSendMedia is refused: see the package comment. +func (g *Gateway) BotSendMedia(_ context.Context, _ telegraph.BotTarget, _ int64, _, _, _ string, _ int64, _ []string) (int64, *telegraph.DeliveryFailure) { + return 0, g.refusal("BotSendMedia") +} + +// BotDeleteMessages is refused: see the package comment. +func (g *Gateway) BotDeleteMessages(_ context.Context, _ telegraph.BotTarget, _ int64, _ []int64) (bool, *telegraph.DeliveryFailure) { + return false, g.refusal("BotDeleteMessages") +} + +// --- deletion (auto-delete scheduler) ----------------------------------- // + +// DeleteMessages is refused: see the package comment. +func (g *Gateway) DeleteMessages(_ context.Context, _ telegraph.DeleteTransport, _, _ string, _, _ int64, _ []int64) (bool, *telegraph.DeliveryFailure) { + return false, g.refusal("DeleteMessages") +} + +// --- branding stage 1 ---------------------------------------------------- // + +// BrandingStage1 is refused: see the package comment. +func (g *Gateway) BrandingStage1(_ context.Context, _ telegraph.BrandingPost) ([]int64, *telegraph.DeliveryFailure) { + return nil, g.refusal("BrandingStage1") +} diff --git a/worker/internal/telegraph/unconfigured/unconfigured_test.go b/worker/internal/telegraph/unconfigured/unconfigured_test.go new file mode 100644 index 00000000..81d719b8 --- /dev/null +++ b/worker/internal/telegraph/unconfigured/unconfigured_test.go @@ -0,0 +1,101 @@ +package unconfigured + +import ( + "context" + "strings" + "testing" + + "atf/worker/internal/telegraph" +) + +// A compile-time shape check: the health-only gateway is a telegraph.Gateway, +// so it can drop into cmd/worker's disabled path. +var _ telegraph.Gateway = (*Gateway)(nil) + +// The health-only gateway must fail EVERY operation with the canonical +// non-transient code and a message that names the missing configuration — +// never a nil failure (which reads as "the operation happened") and never a +// panic (which would take the ops endpoint down with it). +func TestEveryOperationFailsClosed(t *testing.T) { + g := New("internal RPC token is empty: refusing an unauthenticated client") + ctx := context.Background() + target := telegraph.BotTarget{TokenID: "t1", OwnerUserID: 7} + + if ok, reason, err := g.IsSessionReady(ctx, "s1"); ok || err != nil || !strings.Contains(reason, "unconfigured") { + t.Fatalf("IsSessionReady = (%v, %q, %v), want (false, unconfigured reason, nil)", ok, reason, err) + } + + _, f := g.GetMessage(ctx, "s1", 1, 2) + check(t, "GetMessage", f, g.reason) + _, f = g.GetAlbumMessageIds(ctx, "s1", 1, 2) + check(t, "GetAlbumMessageIds", f, g.reason) + _, f = g.GetFileExtension(ctx, "s1", 1, 2) + check(t, "GetFileExtension", f, g.reason) + _, f = g.DownloadMedia(ctx, "s1", 1, 2, telegraph.MediaHandle{}) + check(t, "DownloadMedia", f, g.reason) + _, f = g.ReloadMedia(ctx, "s1", 1, 2, telegraph.MediaHandle{}, "photo") + check(t, "ReloadMedia", f, g.reason) + _, f = g.PrepareMediaUpload(ctx, "s1", telegraph.MediaHandle{}, 1, 2, "photo") + check(t, "PrepareMediaUpload", f, g.reason) + _, f = g.ForwardMessages(ctx, "s1", 1, 2, []int64{3}, 0) + check(t, "ForwardMessages", f, g.reason) + _, f = g.CopyMessage(ctx, "s1", 1, 2, 3, "", 0, false) + check(t, "CopyMessage", f, g.reason) + _, f = g.CopyMessages(ctx, "s1", 1, 2, []int64{3}, "", 0, false) + check(t, "CopyMessages", f, g.reason) + _, f = g.CopyMediaGroup(ctx, "s1", 1, 2, 3, "", 0) + check(t, "CopyMediaGroup", f, g.reason) + _, f = g.SendMessage(ctx, "s1", 1, "text", 0) + check(t, "SendMessage", f, g.reason) + _, f = g.SendMedia(ctx, "s1", 1, "tok", "cap", "photo", 0) + check(t, "SendMedia", f, g.reason) + _, f = g.BotSendText(ctx, target, 1, "text", 0, nil) + check(t, "BotSendText", f, g.reason) + _, f = g.BotCopyMessage(ctx, target, 1, 2, 3, "", 0, nil) + check(t, "BotCopyMessage", f, g.reason) + _, f = g.BotSendMedia(ctx, target, 1, "tok", "cap", "photo", 0, nil) + check(t, "BotSendMedia", f, g.reason) + _, f = g.BrandingStage1(ctx, telegraph.BrandingPost{}) + check(t, "BrandingStage1", f, g.reason) + + ok, username, f := g.BotAvailability(ctx, target) + if ok || username != "" { + t.Fatalf("BotAvailability = (%v, %q), want (false, \"\")", ok, username) + } + check(t, "BotAvailability", f, g.reason) + + ok, f = g.BotDeleteMessages(ctx, target, 1, []int64{2}) + if ok { + t.Fatalf("BotDeleteMessages = %v, want false", ok) + } + check(t, "BotDeleteMessages", f, g.reason) + + ok, f = g.DeleteMessages(ctx, telegraph.TransportUser, "s1", "b1", 7, 1, []int64{2}) + if ok { + t.Fatalf("DeleteMessages = %v, want false", ok) + } + check(t, "DeleteMessages", f, g.reason) +} + +// check asserts one refusal. +func check(t *testing.T, op string, f *telegraph.DeliveryFailure, reason string) { + t.Helper() + if f == nil { + t.Fatalf("%s returned nil failure — a nil reads as \"the operation happened\"", op) + } + if f.Code != Code { + t.Errorf("%s failure code = %q, want %q (message: %s)", op, f.Code, Code, f.Message) + } + if f.IsTransient() { + t.Errorf("%s: %q must not be transient — a missing configuration does not heal by retrying", op, f.Code) + } + if !strings.Contains(f.Message, "health-only") { + t.Errorf("%s refusal must name the health-only state, got: %s", op, f.Message) + } + if !strings.Contains(f.Message, reason) { + t.Errorf("%s refusal must carry the startup reason verbatim, got: %s", op, f.Message) + } + if !strings.Contains(f.Message, op) { + t.Errorf("%s refusal must name the operation that was never attempted, got: %s", op, f.Message) + } +} From a60a4c8815e06e0e1650ea57e588ee83623c7f13 Mon Sep 17 00:00:00 2001 From: Capsizer Date: Sat, 7 Nov 2026 02:31:21 +0330 Subject: [PATCH 4/4] ci(temp): remove the clean-install smoke workflow Its purpose is served: the gate ran on this PR, caught the regressions before any tag, and is now green (3m43s). The permanent home for this check is release.yml's clean-install job on every v* tag; keeping the copy would run a full second compose stack on every master PR. --- .github/workflows/clean-install-smoke.yml | 105 ---------------------- 1 file changed, 105 deletions(-) delete mode 100644 .github/workflows/clean-install-smoke.yml diff --git a/.github/workflows/clean-install-smoke.yml b/.github/workflows/clean-install-smoke.yml deleted file mode 100644 index b9d9c10b..00000000 --- a/.github/workflows/clean-install-smoke.yml +++ /dev/null @@ -1,105 +0,0 @@ -# TEMPORARY — the clean-install gate, runnable before a tag. -# -# The real gate lives in release.yml and only runs on v* tags, which is -# how the v2.5.0 clean-install failed only after the tag was cut. This -# copy runs the identical job on PRs so a compose-level regression is -# caught before the tag. Delete it once the fix has shipped. -name: Clean-install smoke - -on: - pull_request: - paths: - - "docker-compose.yml" - - "Dockerfile.*" - - ".env.example" - - ".github/workflows/clean-install-smoke.yml" - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: clean-install-smoke-${{ github.ref }} - cancel-in-progress: true - -jobs: - clean-install: - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - - name: Create minimal .env for smoke test - shell: bash - run: | - cp .env.example .env - echo "compose smoke test uses placeholder credentials from .env.example" - # Those credentials cannot log in to Telegram. Without the core's - # supported gRPC-only mode it exits on the failed bot login, restart-loops, - # and any `docker compose exec` into it dies with 137 mid-check — which is - # exactly how the v2.2.0 release failed this job. - echo "ATF_DISABLE_BOT=1" >> .env - grep -q '^ATF_DISABLE_BOT=1$' .env - - - name: Compose up - shell: bash - run: docker compose up -d --build - - - name: Poll health endpoints - # Production release gate: every required internal service (core, - # logger, API, web) must report online. Placeholder external Telegram - # credentials are tolerated only if they do not degrade internal - # service health; an HTTP response alone is not a healthy stack. - shell: bash - run: | - for i in $(seq 1 30); do - A=$(curl -sf http://127.0.0.1:8088/healthz || true) - B=$(curl -sf http://127.0.0.1:8080/api/v1/health/services || true) - if [ -n "$A" ] && [ -n "$B" ]; then - echo "web /healthz: $A" - echo "api /api/v1/health/services: $B" - if echo "$B" | python3 -c "import json,sys; d=json.load(sys.stdin); s=d.get('services'); assert d.get('status') == 'healthy' and isinstance(s,list) and {x.get('name') for x in s if x.get('online') is True} >= {'core','logger','api','web'} and all(x.get('online') is True for x in s), d"; then - echo "stack serves HTTP with a valid services payload" - exit 0 - fi - fi - echo "waiting for stack... ($i/30)" - sleep 10 - done - echo "health endpoints never became ready" - docker compose ps - docker compose logs --tail=100 - exit 1 - - - name: Prove the logger is the Rust engine and it ingests end-to-end - # A stack that answers /healthz is not enough: the logging service must - # be the Rust engine (the Python daemon is gone) and a real gRPC write - # must survive all the way to storage and back out of a query. - shell: bash - run: | - docker compose exec -T logger sh -c 'command -v atf-logger && ! command -v python3' - docker compose exec -T logger curl -fsS http://127.0.0.1:9109/healthz - docker compose exec -T logger curl -fsS http://127.0.0.1:9109/readyz - docker compose exec -T logger curl -fsS http://127.0.0.1:9109/metrics | grep -q '^atf_logger_events_' - # 24 contract checks through Core's own stubs and producer class. - docker compose exec -e ATF_LOGGER_ADDR=logger:50052 -T core python - \ - < scripts/acceptance/logger_acceptance.py - - - name: Diagnostics on failure - if: failure() - run: | - echo "::group::compose ps" - docker compose ps - echo "::endgroup::" - echo "::group::logs" - docker compose logs --tail=200 - echo "::endgroup::" - echo "::group::disk" - df -h / - docker system df - echo "::endgroup::" - - - name: Compose down (always) - if: always() - shell: bash - run: docker compose down -v