M1-73: Compose smoke test¶
Status: ready | Depends on: M1-69 | Milestone: M1
Goal¶
make portability starts the server core from the real bothy-server image with the
installation Compose file plus a test overlay (plan P-06), and proves the path from first setup to
live delivery over WebSocket: secrets, migrations, first owner, enrollment with device keys and
DPoP, space, channel, thread and messages, ordered live delivery, reconnect with catch-up, step-up
and device revocation closing the connection with 4012. A canary run through the same stack
proves that no content, token or principal ID leaves the collector or the container logs. This is
exit criterion 8 of milestone M1 and the only end-to-end proof that the image, the Compose contract
and the server modules work together.
Read first¶
Plan and quality:
docs/PLAN.md: section "M1 Foundation" (ticket row M1-73, "Out of scope", "Exit criteria" 8, "Red team" last paragraph "smoke test stack"), "Way of working per ticket", "Test strategy", "Decisions of this plan" P-06, P-09, P-10, P-11; ticket row M7-14 (what stays for M7).docs/spec/23-quality.md"Component contract" (row "Portability": executableinfra/compose/smoke.sh);docs/quality/gates.md(release-onlyportability);docs/quality/red-team-gate.md.Makefiletargetsportability,release-build,trivy,image(M1-23),ops-check(M1-04).
Specification:
docs/spec/16-operations.md: "Basic rules" and "Two listeners" (4.1, 4.2), "Configuration", "Secret provider", "Secrets in the Compose stack" (5.1 to 5.3), "Environments" (6.1, usestaging), "Roles" (7.1), "Upgrade" point 1 (an empty database needs no recent backup), "Portability test in CI" (11.1, the full test is M7-14), "Configuration values of this chapter".docs/spec/08-identity.md: "Enrollment code" (3.2), "Initial installation" (4), "Registration" (5.1), "Two keys per native device" (6.1), "Step-up flow" (6.2), "Access token" (7.2), "DPoP" (7.3), "Tests" (ID-T06, ID-T07).docs/spec/04-sync-protocol.md: "Envelope" (2.2), "Frames on the WebSocket" (2.3), "Messages:client_msg_id" (5.1), "All other writes:Idempotency-Key" (5.2), "Endpoint and version", "Ops", "Lifecycle", "Close codes" (6.1 to 6.4), "Catch-up" (7.1 to 7.4), "Revocation of a session, device or principal" (8.5), sequence diagrams "Send a message, online" (16.1) and "Reconnect with catch-up" (16.3).docs/spec/05-actions.md: action rowsinstance.discovery.read,auth.login.begin,auth.login.complete,auth.step_up,self.read,space.create,channel.create,thread.create,message.create,device.revoke(step-up required),events.catch_up,gateway.connect; section "Durable event types and receive action" rowsspace.created,channel.created,thread.created,message.created,session.revoked; section "WebSocket ops and action".docs/spec/06-api.md: "DPoP" (3.1, nonce retry exactly once), "Errors", "Idempotency".docs/spec/02-architecture.md: "Start" (15.1), "Health" (15.2), "Shutdown" (15.3).docs/spec/17-observability.md: "Privacy" (4), "Collector and backends" (5), "Tests that hold these rules" (10), R-OB-02, R-OB-03.docs/spec/19-security.md: SEC-TLS-1, SEC-TLS-5, SEC-CORS-3, SEC-LOG-1 to SEC-LOG-6, SEC-T10.docs/spec/20-privacy.md: DP-T09.docs/observability/privacy.mdsection 6 "Canary test (gate)";docs/observability/collector.md.- ADRs 0012 (images, Compose), 0020 (device binding, DPoP, step-up), 0034 (collector), 0038 (local gates), 0039 (release class), 0053 (no JSON null).
API (docs/api/openapi.yaml, server prefix /api/v1) by operationId: getDiscovery,
redeemEnrollmentCode (enrollment with device keys without passkey, 08 3.2), getMe, createSpace, createChannel, createThread,
createMessage, listEvents, createStepUpChallenge, completeStepUpWithDeviceKey,
revokeDevice. Schemas: DeviceRegistration, DeviceKeyRegistration, TokenResponse,
Channel (main_thread_id), ThreadCreate (thread from a main-thread message via
parent_message_id), MessageCreate, StepUpChallenge, StepUpDeviceKeyRequest, ReasonBody,
Discovery (setup_required). Gateway (docs/api/asyncapi.yaml, address /gateway): ops
hello, identify, ready, heartbeat, heartbeat_ack, dispatch, cursor,
catchup.complete, resync_required; schemas GatewayHello, GatewayIdentify, GatewayReady,
GatewayCursor, GatewayCatchupComplete.
Data tables: none are written directly; state is checked through the API only.
Existing files and outputs of the dependencies:
infra/compose/compose.yaml,infra/compose/README.md,infra/compose/.env.example.infra/otel/collector.yaml(forward connectors,tail_sampling,batch, redaction allowlist andblocked_values) andinfra/otel/exporters/*.yaml.docs/operations/configuration.mdsections 4 and 5 (key catalogue, secrets catalogue).services/server/Dockerfile,.dockerignoreand theimagetarget (M1-23).services/server/internal/identity/README.mdandbothy setup --help(M1-37; 08 4: the code is the only line on stdout, the command usesbothy_app, a running server needs no restart; M1-35: step-up JWS format; M1-33: DPoP rules andkernel/jose, ADR 0067).services/server/internal/platform/otel/README.md(M1-10:service.name, log exporter).services/server/internal/platform/pg/pgtest(M1-12: the PostgreSQL 18 image reference).services/server/test/canary(M1-69) andarchitecture/modules.yaml(M1-05:test/**is the componenttestsupport).tools/opscheck(M1-04: list of collector configurations it validates).
Fixed requirements that apply¶
Owner build rules (verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: every behavior has a test that was red before the code (record the red output).
- No en or em dashes in any text (
uv run --project tools --locked --quiet python tools/hooks/text_rules.py <file>exits 0). White label: people are "Alex", domainsexample.com; no company, host or personal names, no paths of a personal machine. SPDX headerApache-2.0in every new source, script and YAML file. - Server configuration only through named keys from
docs/operations/configuration.md(naming rulea.b_ctoBOTHY_A__B_C, 16 "Configuration"). Variables of the smoke harness are not server configuration; they start withSMOKE_and are documented ininfra/compose/test/README.md.
Product requirements implemented here:
- The smoke test uses the real server image, the installation Compose file unchanged and a test
overlay with the official PostgreSQL 18 image pinned by digest and without the
backupservice (P-06). - Missing image, Docker runtime, too old Docker Engine or too old Compose fail with exit code 2 and a message that names the fix; nothing is skipped (P-09; plan ticket row "without an image a clear error").
- No host port is published and nothing binds outside
127.0.0.1(P-10; 16 4.1). - Plaintext HTTP reaches the server only from loopback (SEC-TLS-1); WebSocket clients send no
foreign
Origin(SEC-CORS-3). - Setup path:
503 setup_requiredbefore setup (ID-T06), first owner only viabothy setup bootstrap-owner, a second run aborts without change (ID-T07), the enrollment code confirms the first device (08 3.2, 4). - DPoP on every request with the server nonce and one retry on
use_dpop_nonce(06 3.1, 08 7.3); on the gateway the proof sits inidentifywith the nonce fromhello(04 6.3). - Live delivery in
seqorder withclient_msg_idecho; reconnect withidentify,cursorandsync_epochdelivers the missed events in order, thencatchup.complete(04 7.2, 16.3). device.revokeneeds step-up and closes the device's connection with4012(05, 04 6.4, 8.5).- Canary: one random secret word per run in every content field; neither it, nor any token, nor the enrollment code, nor a principal ID appears in collector exports or container logs (privacy.md section 6; SEC-T10; DP-T09; R-OB-03).
Scope¶
All paths are repository-relative. Go paths below services/server/ follow the module layout of
M1-05.
infra/compose/smoke.sh(new, executable, bash,set -euo pipefail, SPDX header). Each step is announced assmoke: <step>on stderr; on failure it prints the step,compose ps --alland the lastSMOKE_LOG_TAILlines ofapi,worker,migrate,otel-collector, then exits 1:- Inputs.
SMOKE_SERVER_IMAGE(default: the local tag thatmake imagewrites, taken from theMakefilevariable M1-23 defines),SMOKE_KEEP(1keeps the stack and the temp dir and prints how to remove them),SMOKE_WAIT_TIMEOUT_S,SMOKE_TELEMETRY_SETTLE_S,SMOKE_LOG_TAIL. Defaults live only insmoke.sh. - Sanitize. After reading the inputs, unset every exported variable whose name starts with
BOTHY_,COMPOSE_orOTEL_, so a developer's shell cannot change the stack; always pass--env-file infra/compose/test/smoke.env, soinfra/compose/.envof a real installation is never read. - Preflight (exit 2 each):
dockeronPATH;docker inforeachable; Docker Engine at least 26.0.0 (docker version --format '{{.Server.Version}}', needed for volumesubpath); Compose at least 2.26.0 (docker compose version --short, strip a leadingv, compare numerically; needed for!reset,!override,configs); the image exists locally (docker image inspect), elsesmoke: server image '<ref>' not found locally; build it with 'make image' or pull it, then set SMOKE_SERVER_IMAGE. No implicit pull or build of the server image. - Workspace.
tmp=$(mktemp -d); subdirectoriessecrets-input(empty, mode 0700: no owner-supplied secrets),out,otel,logs; run ID of 8 random hex characters; project namebothy-smoke-<run ID>; run canarysmkplus 24 random lowercase hex characters from/dev/urandom;trap cleanup EXIT. ExportSMOKE_RUN_ID,SMOKE_SECRETS_INPUT,SMOKE_OUT($tmp/out),SMOKE_TMP,SMOKE_CANARYfor the overlay. All compose calls go through one function:docker compose --project-name "$project" --project-directory infra/compose --file infra/compose/compose.yaml --file infra/compose/test/compose.smoke.yaml --env-file infra/compose/test/smoke.env "$@"(paths resolved from the script's own directory). - Validate.
compose config --quiet. - Build and pull.
compose build smoke-probe(the image is shared withsmoke-scan);compose pull --quiet postgres otel-collector otel-out-init. - Start.
compose up --detach --wait --wait-timeout "$SMOKE_WAIT_TIMEOUT_S" api worker otel-collector; dependencies runsecrets-init(bothy secrets init),postgres,migrate(bothy migrate up --bootstrap) andotel-out-init. Then assert exit code 0 of the one-shot containerssecrets-initandmigrate(docker inspect --format '{{.State.ExitCode}}'oncompose ps --all --quiet <service>). - Idempotence.
compose run --rm --no-deps secrets-initandcompose run --rm --no-deps migrateexit 0 a second time. - Precheck.
compose run --rm --no-deps --user "$(id -u):$(id -g)" smoke-probe precheck. - Bootstrap.
compose exec -T api bothy setup bootstrap-owner --handle alex --display-name "Alex <canary>"(the path ofdocs/operations/install-compose.md), stdout to$tmp/out/setup.out(mode 0600, never printed, never scanned), stderr to$tmp/logs/setup.err(scanned). A second identical call must exit non-zero; its stdout goes to$tmp/out/setup-second.out, its stderr is appended tosetup.err. No restart: the server reads the setup state per request until it is set (02 15.1 step 5). - Probe.
compose run --rm --no-deps --user "$(id -u):$(id -g)" smoke-probe run(see 5). Its stdout and stderr are shown; it never prints values. - Flush.
compose stop api worker(graceful shutdown flushes telemetry, 02 15.3), waitSMOKE_TELEMETRY_SETTLE_S, thencompose stop otel-collector. - Collect.
compose logs --no-color --timestamps secrets-init postgres migrate otel-out-init api worker otel-collector > $tmp/logs/compose.log;compose cp otel-collector:/var/lib/otelcol-smoke/. "$tmp/otel/". - Scan.
compose run --rm --no-deps --user "$(id -u):$(id -g)" smoke-scan scan(see 5). - Cleanup (trap): unless
SMOKE_KEEP=1,compose down --volumes --remove-orphans --rmi local,docker image rmof the custom-tagged probe image, then remove$tmp(guard: the variable is non-empty and names the directorymktemp -dcreated). Afterwards no container, volume or network with the project label remains (docker ps -a --filter label=com.docker.compose.project=<project>is empty; assert it). - Exit codes: 0 pass, 1 smoke failure, 2 precondition missing.
- Inputs.
infra/compose/test/compose.smoke.yaml(new overlay; header comment: test only, never for an installation, plan P-06). Relative paths resolve againstinfra/compose(the project directory). Contents:- An anchor
x-smoke-envwith the installation'sx-server-envminus the keys the M1 server rejects (expected: everyBOTHY_FILE__*key; files arrive in M3). Determine the set by runningdocker run --rm <image> config checkwith the installation values; list every dropped key in a comment with the milestone that introduces it. secrets-init,migrate,api,worker:image: ${SMOKE_SERVER_IMAGE:?set by smoke.sh},pull_policy: never;environment: !overridebuilt fromx-smoke-envplus the service's own installation overrides (migrate:BOTHY_DATABASE__USER,BOTHY_DATABASE__PASSWORD_REF);apiadditionallyBOTHY_SERVER__LISTEN_ADDR: "127.0.0.1:8080"andports: !reset [].secrets-init:volumes: !overridewith${SMOKE_SECRETS_INPUT:?set by smoke.sh}:/input:roand thesecretsandsigning-keysvolumes as in the installation.apiandworkerkeep the installation'ssigning-keysvolume (ADR 0066: the server writes its signing keys there).postgres:image:exactly the reference thatinternal/platform/pg/pgtestuses (M1-12, officialpostgres:18.xmulti-arch index digest); everything else from the installation.backup:profiles: [smoke-disabled](never activated).otel-collector:command: !overridewith--config=/etc/otelcol/collector.yamland--config=/etc/otelcol-smoke/file.yaml; additional volumes./test/otel:/etc/otelcol-smoke:roandotel-smoke-out:/var/lib/otelcol-smoke;ports: !reset []; additionaldepends_onotel-out-init: {condition: service_completed_successfully}. The image's own user stays.otel-out-init: the same pinned PostgreSQL image (already pulled, has a shell),user: "0:0",network_mode: none,restart: "no",entrypoint: ["/bin/sh", "-c"],command: ["chmod 1777 /out"], volumeotel-smoke-out:/out.smoke-probe: profilesmoke,build: {context: ../../services/server, dockerfile: Dockerfile, target: smoke},image: bothy-smoke:${SMOKE_RUN_ID:?set by smoke.sh},network_mode: service:api,read_only: true,cap_drop: [ALL],security_opt: ["no-new-privileges:true"], environmentSMOKE_PUBLIC_URL: ${BOTHY_PUBLIC_URL}andSMOKE_CANARY(passed through, no value in the file), volume${SMOKE_OUT:?}:/out.smoke-scan: profilesmoke, the sameimage,pull_policy: never,network_mode: none, the same hardening, volume${SMOKE_TMP:?}:/in:ro.volumes: {otel-smoke-out: {}}.
- An anchor
infra/compose/test/smoke.env(new; no secrets):COMPOSE_PROFILES=direct,smoke,BOTHY_REGISTRY=registry.invalidandBOTHY_VERSION=smoke(only to satisfy the:?checks of the installation file; every server-image service is overridden),BOTHY_PUBLIC_URL=https://chat.example.com,BOTHY_ENVIRONMENT=staging,BOTHY_OTEL_EXPORTER=none,BOTHY_OTEL_EXPORTER_2=none,BOTHY_TRACE_SAMPLE_PERCENT=100,BOTHY_OTEL_METRIC_EXPORT_INTERVAL_MS=1000. These are Compose interpolation variables of the installation file, not server keys.infra/compose/test/otel/file.yaml(new collector overlay "file", test only): exportersfile/traces,file/metrics,file/logs(format: json,flush_interval: 1s, paths/var/lib/otelcol-smoke/{traces,metrics,logs}.jsonl, no rotation, no compression); pipelinestraces/smoke,metrics/smoke,logs/smokefed fromforward/traces,forward/metrics,forward/logs; processor overridestail_sampling.decision_wait: 1sandbatch.timeout: 1s(map keys merge into the base; the policies stay). Add this file to the configurations thattools/opscheckvalidates withotelcol-contrib validatetogether with the base, plus a negative fixture in the style of M1-04.- Go package
services/server/test/smoke(new, packagesmoke,doc.gowith the package doc) and commandservices/server/test/smoke/cmd/bothy-smoke(main.go). The command has three subcommands; flags with defaults:--base-url http://127.0.0.1:8080,--public-url $SMOKE_PUBLIC_URL,--out /out,--in /in.precheck:getDiscoveryreturns 200 withsetup_required: true;GET /api/v1/mewithout token returns503with problem typesetup_required.run, in this order (each step logsok <step>to stderr, never a value):getDiscovery:setup_requiredis nottrue.- Read the enrollment code from
/out/setup.out(see Design notes "Enrollment code"). - Generate two ECDSA P-256 keys in memory:
dpopandstep_up;device_ida fresh UUIDv7; devicename"smoke <canary>",platformmacos(the smoke client stands in for a native app; aclidevice has no step-up key, 08 "6.4"),algES256.redeemEnrollmentCodewith the code, the device registration and a DPoP proof withoutath; expect201,token_type: DPoP,device.state: active. Keep access token, refresh token,session.id,session.user_id,device.id. getMe: handlealex.- Gateway connection A: dial
ws://127.0.0.1:8080/api/v1/gatewaywithoutOrigin; expecthello; sendidentify(protocol_versionfromhello.protocol_versions,access_token,dpop,device_id,client); expectready; keepsync_epoch; run the heartbeat loop perheartbeat_interval_mswith a random first delay (04 6.3 step 5). createSpace(name"Research <canary>"),createChannel(kind: text,name"smoke-<canary>"),createMessageinto the channel'smain_thread_id,createThreadwithparent_message_idof that message andtitle"<canary> thread", thenliveMessagesmessages in the new thread (content_md"<canary> message <i>"), each with a fresh UUIDv7client_msg_id. Every other POST carries anIdempotency-Key(UUIDv7).- On connection A expect, as an ordered subsequence of
dispatchframes:space.created,channel.created,thread.created, then themessage.createdevents of the new thread in send order with matchingclient_msg_idandresource.id; every dispatchedseqis strictly greater than the previous one. - Close A with
1000. Cursor = highestseqseen indispatchorcursorframes. - Send
awayMessagesmessages over REST while disconnected. listEvents?after=<cursor>, followingnext_cursoruntilhead_seq: contains exactly thesemessage.createdevents of the thread in send order (REST variant, 04 7.4).- Gateway connection B:
hello,identifywithcursorandsync_epoch,ready; expect the missedmessage.createdevents in send order, none withseq <= cursor, thencatchup.completewithhead_seqat least the last seenseq; then send one live message over REST and expect its dispatch aftercatchup.complete. createStepUpChallenge; sign the step-up JWS over{challenge, sid, iat}with thestep_upkey in the format M1-35 documents;completeStepUpWithDeviceKeyreturns 200.revokeDevicefor the own device (reason"smoke <canary>",Idempotency-Key): 200; the server closes connection B with code4012withinrevokeCloseWait; nodispatcharrives after the revocation response.getMewith the old access token and a fresh proof returns401.- Write
/out/canaries.txt(mode 0600), onelabel<TAB>valueper line:canary,access_token,refresh_token,enrollment_code,principal_id(the user ID).
scan: reads/in/out/canaries.txtand every regular file below/in/oteland/in/logs(nothing else below/in/out). Fails (exit 1) when:- the canary list is empty, lacks the label
canary, or a value is shorter thanminCanaryLen; - the positive control fails: before scanning, the scanner must find the first canary in a built-in control text, so a broken scanner cannot pass;
- any value occurs in any scanned file; each hit is reported as
<file>:<line>: <label>, never with the value; - the OTLP JSON in
/in/otellacks, for the server'sservice.name(M1-10), at least one span whosehttp.routeis the route pattern ofcreateMessage, at least one log record and at least one metric data point (an empty export cannot pass).
- the canary list is empty, lacks the label
- Package layout (every exported symbol with a doc comment):
dpop.go:type Key(ES256 private key withPublicJWK()andThumbprint()per RFC 7638),func NewProof(k Key, method, htu, nonce, accessToken string, now time.Time) (string, error)(headertyp: dpop+jwt,alg: ES256, publicjwkonly; claimsjtirandom,htm,htuwithout query and fragment,iat,noncewhen set,ath= base64url SHA-256 of the token when set). Build the compact JWS with the standard library (crypto/ecdsa, signature asR || S,encoding/base64without padding); the product has no JOSE library (ADR 0067) and this black-box harness imports nointernal/...package.htu.go:func GatewayHTU(publicURL string) (string, error): the HTTPS form of<public URL>/api/v1/gateway(04 6.3);func RESTHTU(publicURL, path string) (string, error). The server checkshtuagainst its public URL (08 7.3), not against the loopback address the probe dials; follow the rule M1-33 documents.rest.go:type RESTinterface with one method per operation used above, request and response types from the generated packageapi/rest(M1-17), no hand-written JSON structs;type HTTPClientimplements it withnet/http(dials only--base-url), DPoP on every request,DPoP-Noncetracking, exactly one retry on401 use_dpop_nonce.gateway.go:type Gatewayinterface (Dial,Identify,Next(ctx) (Frame, error),Close(code),CloseCode()), implementation withgithub.com/coder/websocket.scenario.go:func Precheck(ctx, REST) error,func Run(ctx, Config, REST, func() Gateway) (Canaries, error)with the steps above; constantsliveMessages,awayMessages,revokeCloseWait,frameWaitwith doc comments that give the reason.scan.go:func Scan(canaries Canaries, roots []string) ([]Hit, error),func CheckTelemetry(otelDir, serviceName, route string) error.enroll.go:func ReadEnrollmentCode(stdout []byte) (string, error).
- The package lies under
test/and belongs to the componenttestsupport(M1-05). It is a black box: it imports onlyapi/restandgithub.com/coder/websocket, never aninternal/...package; a test withgo list -depsholds this.
services/server/Dockerfile(change): two stages before the production stage, which stays the last stage and unchanged:smoke-build(from the existing builder stage, builds./test/smoke/cmd/bothy-smokewithCGO_ENABLED=0and-trimpath) andsmoke(from the same runtime base reference as production, same non-root user,ENTRYPOINT ["/usr/local/bin/bothy-smoke"]). If.dockerignoreexcludestest/, re-include onlytest/smoke/.tools/ci/tests/test_compose_smoke.py(new) with the tests in "Tests" below.Makefile:release-buildexcludesinfra/compose/testfrom the bundle (git archivepathspec:(exclude)infra/compose/test); the help text ofportabilitynamesSMOKE_SERVER_IMAGE.renovate.json: putinfra/compose/test/compose.smoke.yamlinto the same group as thepgtestimage pin (create the group "PostgreSQL test image" if M1-12 did not), so both pins move in one pull request;make lintvalidates the file.- Documentation:
infra/compose/test/README.md(new, English likeinfra/compose/README.md): purpose and P-06, prerequisites (Docker Engine 26, Compose 2.26, the image frommake image), theSMOKE_*variables, the steps, what is proven and what is not (see "Out of scope"), how to debug withSMOKE_KEEP=1, why the probe shares the network namespace ofapi.infra/compose/README.md: table "Files" getstest/andsmoke.sh; section "Checked" states what the smoke test now proves (server part of the stack) and what still waits for M7.docs/observability/collector.md: the test overlayfileunderinfra/compose/test/otel/exists only for the smoke test;docs/observability/privacy.mdsection 6: name the implementation (infra/compose/smoke.sh, overlayfile) and that the probe's own output is not scanned.docs/security/threat-model.mdsection 5.14 "Operations and backups": one row for the smoke stack (threat: the test overlay or its settings end up in an installation; mitigations: overlay only underinfra/compose/test, excluded fromrelease-build, own project name, no published ports,stagingenvironment), and a register entry in 6.5 with a new ID following the register's scheme and the testtest_release_bundle_excludes_smoke_overlay.docs/DECISIONS.md: rulings for design notes D1, D2, D4, D6 and D7 (next free IDs, existing format).CHANGELOG.md(root,[Unreleased],### Added): the smoke test of the Compose stack.
Out of scope¶
- The full portability test (profiles
scanands3, files and EICAR, search, status page, backup and restore test, amd64 and arm64 runners,portability.ymlwiring with released images and its failure-log step): M7-14. - Making the installation stack startable (
bothy-postgres,backup,bothy-backupd) and the bootstrap instructions indocs/operations/install-compose.md: M7. - The in-process canary harness for every server path, error bodies and foreign events: M1-69.
- Agents, runs, adapters, uploads (M2, M3). Load and chaos tests (M1-72).
- Changing
infra/compose/compose.yaml. Exception: a value or command there contradicts a contract already merged in M1 (renamed key, renamed subcommand, secrets layout); then fix it incompose.yaml, chapter 16 andinfra/compose/README.mdin the same change (specification first) instead of masking it in the overlay. - Server behavior. Exception: the scan finds a real leak; fix it at the source with the smallest
change, add the case to
test/canary(M1-69), and name it in the report. If the fix needs a specification decision, stop withBLOCKED.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-73-compose-smoke. - Conventional Commits with DCO sign-off (
git commit -s), scopescompose,server,cias fitting; squash to meaningful commits before the merge. - Never push, tag, open pull requests or create remote repositories.
- Do not modify files outside the scope list except the documentation and changelog entries named there.
- Follow the host rules supplied by the launcher. Stop only processes and Compose projects you
started (by PID, or by your own
bothy-smoke-*project name); never touch other containers, volumes, networks or Compose projects on the host. Temporary directories only viamktemp -d, removed at the end. - Never read or print secrets: the
secretsvolume,setup.outandcanaries.txtare never printed; the report names labels, never values. - No network calls except package and image downloads of pinned versions (Go modules, the pinned PostgreSQL, collector and builder images).
Design notes¶
- D1 Probe in the network namespace of
api. Inside the container the server sees a published port's traffic from the bridge gateway, not from loopback; SEC-TLS-1 would reject it, and wideningserver.trusted_proxiesfor a test would weaken the rule under test. The probe shares the namespace (network_mode: service:api) and dials127.0.0.1:8080, like thetailscalesidecar pattern. No host port is published (P-10). - D2 Overlay changes are removals or the listed overrides only. Keys of later milestones are
dropped (P-06; the installation file is reconciled with each milestone's schema in M7-14).
database.sslmode=disableon the networkinternaland plaintext OTLP to the collector on the Compose network are allowed (19 SEC-TLS-5). Any other value the M1 server rejects is not changed in the overlay: stop withBLOCKEDand quote theconfig checkmessage, because it needs a specification decision. - D3 Isolation of each run. Own project name, explicit
--env-file, sanitized environment, empty owner-secrets input directory: the smoke test can neither read nor change a real installation on the same host, and parallel worktrees do not collide. - D4 Collector overlay
file. It lives underinfra/compose/test/otel/because it needs the writable volume of the overlay and has no use in an installation.tail_samplingandbatchare shortened there andBOTHY_TRACE_SAMPLE_PERCENT=100, so every trace of the run is exported and the wait after the flush stays short. The collector keeps the image's own user; the one-shototel-out-initprepares the output directory, so a permission defect of the collector's secrets is not hidden by running it as root. - D5 Canary format.
smkplus lowercase hex: JSON-safe (no escaping can hide it) and matching none of the collector'sblocked_valuespatterns, so a leak shows up as plain text instead of being replaced by an HMAC. Principal IDs are canaries too (R-OB-03, privacy.md section 6 item 5). - D6 A passing scan must prove something. Positive control in the scanner and the telemetry
presence check (span of the
createMessageroute, a log record, a metric point).SMOKE_TELEMETRY_SETTLE_Smust be at leastdecision_waitplusbatch.timeoutplusflush_intervalfromfile.yaml; a test holds this. - D7 Probe image as a Dockerfile target. Same builder and runtime base as the server image, so no second pin can drift; the production stage stays last, so default builds and releases are unchanged. Only Docker is needed on the host (16 11.1).
- D8 Telemetry flush. Graceful stop of
apiandworkerflushes the SDK (02 15.3); then the settle wait; then a graceful stop of the collector; thencompose cp(works on stopped containers with named volumes, unlike tmpfs). - Readiness before setup.
/readyzdoes not depend on setup (02 15.1 step 5), so--waitworks before the bootstrap step. - Enrollment code. The code is the only line on stdout; logs go to stderr (08 4, 02 3.3).
- Setup lock. Before setup every operation except Discovery answers
503 setup_required(08 4); the probe checks this through the public listener, so ID-T06 is proven end to end. - IDs and keys.
device_id,client_msg_idandIdempotency-Keyare UUIDv7 (RFC 9562). The probe never sends JSONnull(ADR 0053). - Timeouts. Every wait in the probe has a context deadline from a named constant; every wait in
smoke.shusesSMOKE_WAIT_TIMEOUT_S; nothing waits unbounded. - Language. Code, comments,
smoke:messages, the Go package docs,infra/compose/test/README.mdand the other docs are English.
Tests (write them first)¶
Go unit tests in services/server/test/smoke (run with go test -race ./test/smoke/...; only
127.0.0.1 and port 0 through httptest):
TestNewProofClaims: header and claims as specified;jtidiffers between two proofs;athpresent only with a token; no private JWK member.TestNewProofVerifiesWithPublicKey: the proof verifies with the public JWK.TestThumbprintRFC7638: the thumbprint equals SHA-256 over the canonical JSON{"crv","kty","x","y"}computed independently in the test.TestGatewayHTU(table:https://chat.example.comgiveshttps://chat.example.com/api/v1/gateway; trailing slash; a path prefix; a query is rejected).TestHTTPClientRetriesOnceOnUseDPoPNonce,TestHTTPClientStopsAfterSecondNonceError,TestHTTPClientTracksLatestNonce.TestPrecheck(setup requiredtruepasses;falsefails;/menot 503 fails).TestRunHappyPathwith fakeRESTandGateway.TestRunFailsOnOutOfOrderDispatch,TestRunFailsOnMissingClientMsgIDEcho,TestRunFailsOnEventAtOrBelowCursor,TestRunFailsWithoutCatchupComplete,TestRunFailsWhenLiveEventArrivesBeforeCatchupComplete,TestRunFailsWithoutClose4012,TestRunFailsOnDispatchAfterRevocation,TestRunFailsWhenOldTokenStillWorks.TestScanFindsCanaryAndReportsLabelNotValue,TestScanPositiveControl,TestScanRejectsShortOrMissingCanaries,TestScanIgnoresProbeOutputDir.TestCheckTelemetry(fixtures of OTLP JSON lines: complete passes; no span of the route, no log record, no metric point, anotherservice.nameeach fail).TestReadEnrollmentCode(documented format passes; empty output, several candidates, log noise on stdout fail).
Python tests in tools/ci/tests/test_compose_smoke.py (cd tools && uv run --locked pytest
ci/tests/test_compose_smoke.py -q):
test_missing_image_fails_with_hint: a fakedockeronPATH(intmp_path) answersinfo,versionandcompose versionand failsimage inspect;smoke.shexits 2, stderr names the image andmake image, and the fake saw nocompose up.test_missing_docker_fails,test_old_compose_fails,test_old_engine_fails(exit 2 each).test_overlay_server_services_use_smoke_image(imageandpull_policy: never).test_overlay_postgres_pin_equals_pgtest(samepostgres:<tag>@sha256:<64 hex>asinternal/platform/pg/pgtest).test_overlay_publishes_no_ports(apiandotel-collectorresetports; no other service adds ports).test_overlay_env_only_removes_keys(each overlay environment equals the installation'sx-server-envplus the service's installation overrides, minus the documented dropped keys, plus onlyBOTHY_SERVER__LISTEN_ADDRforapi).test_backup_is_never_started(its profile is not inCOMPOSE_PROFILESofsmoke.env).test_smoke_env_has_no_secrets_and_staging.test_settle_covers_collector_waits(D6).test_dockerfile_production_stage_last_and_smoke_target_exists.test_release_bundle_excludes_smoke_overlay:make release-build VERSION=<content of VERSION> OUT=<tmp_path>; the archive has no member belowinfra/compose/test/; the test first asserts thatinfra/compose/test/compose.smoke.yamlis tracked, so it cannot pass vacuously.- YAML with
!resetand!overrideis loaded with a loader that registers both tags.
Commands that must pass:
cd services/server && go test -race ./test/smoke/...cd tools && uv run --locked pytest ci/tests/test_compose_smoke.py -qmake ops-check(collector overlayfilevalidated),make trivy(Dockerfile),make lint,make arch,make codegen-checkdocker compose --project-directory infra/compose -f infra/compose/compose.yaml -f infra/compose/test/compose.smoke.yaml --env-file infra/compose/test/smoke.env config --quiet(with everySMOKE_*variable the overlay interpolates set, paths inside amktemp -ddirectory)make image, thenmake portability(green; record its fullsmoke:step log in the report), and onceSMOKE_SERVER_IMAGE=bothy-smoke-missing:none make portability(exit 2 with the hint)make check
Acceptance criteria¶
-
make portabilityis green with the image frommake imageand runs, in order: secrets init,migrate up --bootstrap, idempotent second runs, precheck503 setup_required,setup bootstrap-owner(second run refused), enrollment with device keys and DPoP, space, channel, thread and messages, ordered live delivery, REST catch-up, reconnect with catch-up andcatchup.complete, step-up,revokeDeviceclosing the connection with4012, old token401, canary scan. - Without the image, Docker, Engine 26 or Compose 2.26,
make portabilityexits 2 with a message that names the fix; nothing is pulled or built for the server image. - The scan finds no canary, token, enrollment code or principal ID in the collector export or the container logs; its positive control and the telemetry presence check pass.
- No host port is published; the server's public listener is
127.0.0.1:8080inside its namespace; the probe sends noOrigin. - After the run no container, volume, network or image of the project remains (unless
SMOKE_KEEP=1), and the temp directory is gone. -
infra/compose/compose.yamlis unchanged, or changed only under the stated exception with chapter 16 updated in the same commit. -
make release-buildoutput contains no file belowinfra/compose/test/. - The production stage of
services/server/Dockerfileis unchanged and last;make trivyis green. - All Go and Python tests listed above exist, were red first, and pass;
make checkis green. - Documentation, threat model row and register entry, rulings and changelog entry are in place.
Definition of done¶
Code, tests and docs: doc.go explains the probe and every exported symbol has a doc comment
(purpose, parameters, return value, errors); comments explain why; the harness is documented in
infra/compose/test/README.md (add services/server/test/smoke/README.md as well if M1-69 set
that convention for test/canary); infra/compose/README.md, collector.md, privacy.md, the
threat model and DECISIONS.md updated; no ADR (no new architecture decision; P-06 and ADR 0012
hold); CHANGELOG.md entry under Unreleased. make check green and make portability green.
Independent review (make review plus a code review over the diff) and the red team gate
(docs/quality/red-team-gate.md) over the diff with the verdict line recorded; merge locally into
the base branch only if the review is clean, the verdict is JA and both commands are green; then
git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits (SHA and subject), files with line counts, test results with counts (red runs and green
runs), the step log of make portability (labels only, no values), the Docker Engine and Compose
versions used, the keys dropped by the overlay, any leak fixed at the source, what is not proven
(the architecture that was not run, profiles and services left to M7-14), and follow-ups that
could not be done in scope with the reason. Then write {{DONE_PATH}} with
exit=0 sha=<merge sha> or exit=1 <reason>.