Skip to content

fix(deploy): detect critical stacks when com.compose.tier is quoted - #99

Merged
owine merged 3 commits into
mainfrom
fix/critical-stack-detection-quoted-labels
Oct 10, 2026
Merged

owine merged 3 commits into
mainfrom
fix/critical-stack-detection-quoted-labels

Conversation

@owine

@owine owine commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Update: the health-check blocker is fixed in this PR (commit 3). docker compose ps/logs/down now address stacks by project name (-p <stack>, run from /) everywhere they run without op run — health check, all three dump_failed_stack copies, failure teardowns, and per-stack/whole-tree rollback down. The health check also fails a critical stack with zero containers. Verified by running the extracted health-check script on both Pis without env: piwine beszel,monitoring,traefik → healthy (previously crashed on traefik), unknown stack → fails with "no containers", piwine-office dozzle → healthy. Safe to merge; then workflow_dispatch a deploy in each consumer repo and confirm detected critical stacks + status=healthy.

Warning

Do not merge on its own. Turning detection on exposes a separate bug in the health-check job: the gate step will crash on docker-piwine's traefik stack, and every docker-piwine deploy will then do a whole-tree rollback. Details are under "Impact analysis" below. The gate needs a follow-up fix first, or in the same change.

Bug

scripts/deployment/detect-critical-stacks.sh matched the literal string com.compose.tier: infrastructure. Both consumer repos quote label values (com.compose.tier: "infrastructure", the house "Option C" style), so the grep never matched. critical_stacks was always [], and the health-check job looped over nothing. The critical-stack gate has been effectively disabled in docker-piwine and docker-piwine-office.

Method 2 (com.compose.critical: (true|"true")) had the same problem. It was unanchored, so it matched commented-out lines and missed 'true' and list form.

Fix

Both labels now go through one has_label <file> <key> <value> helper, which uses an anchored ERE:

^[[:space:]]*(-[[:space:]]*)?["']?KEY["']?[[:space:]]*[:=][[:space:]]*["']?VALUE["']?[[:space:]]*(#.*)?$
  • Accepts: unquoted, "double" and 'single' quoted values; map form (key: value); list form (- key=value, - "key=value"); a quoted key; extra whitespace; a trailing # comment; CRLF line endings
  • Rejects: commented-out lines (# com.compose.tier: …), other values (application, infrastructure-lite, infrastructure#x), unpaired or mismatched quotes, key:value with no space after the colon, longer keys (com.compose.tierx), and the value word appearing inside some other key
  • Known limitation: matching is line-based, so flow-style label maps (labels: {com.compose.tier: …}) are not recognised. Neither consumer repo uses them

The script's logging and output contract are unchanged. CLAUDE.md now documents the quoted form and the new test.

Tests

  • New scripts/testing/test-detect-critical-stacks.sh: 30 cases, all passing. 14 of the original 25 fail against the old script.
  • shellcheck is clean on both changed scripts.
  • Existing harnesses still pass: test-classify-rollback-scope.sh (19/19), test-detect-stack-changes.sh (8/8). test-workflow.sh --dry-run and validate-compose.sh also pass, but they don't exercise detection.

Impact analysis

Detection run against each consumer's origin/main:

Repo Stack Critical before → after Gate today: selector on container state Gate today: the actual step as written
docker-piwine beszel no → yes PASS (2/2 healthy) PASS
docker-piwine monitoring no → yes PASS (4/4 healthy) PASS
docker-piwine traefik no → yes PASS (5 healthy; traefik-plugin-fetch exited 0, which the selector allows) FAIL: step crashes
docker-piwine-office dozzle no → yes PASS (2/2 healthy) PASS

Why traefik crashes: the gate runs cd $LIVE_REPO_PATH/$stack && docker compose ps -a --format json | jq -s . with no op run and no env. In that state, ${DOMAIN} etc. interpolate to "", and traefik's extra_hosts: "${DOMAIN}:172.30.250.2" fails to load:

'services[traefik-cloudflared].extra_hosts' bad host name ''

docker compose ps exits 1. Under set -euo pipefail that kills the step before failed_stacks is written. health-check fails, and the rollback job gets HEALTH_FAILED=[], which means zero culprits and therefore a whole-tree rollback. I reproduced this read-only on piwine with the exact loop; it exits 1 at traefik. Run 38019353982's un-op run teardown shows "variable is not set" warnings, which confirms the runner has no DOMAIN/TZ/… in its env.

Suggested follow-up for the gate (not in this PR):

  • Address the project by name with docker compose -p "$stack" ps -a --format json, and run it from a directory with no compose file such as $RUNNER_TEMP. Do the same for dump_failed_stack's ps/logs. This reads container labels only and needs no env. On piwine it returns all 6 traefik containers with exit 0.
  • Treat an empty result as a failure. ps -p <unknown> exits 0 with no output, which would otherwise pass vacuously.

Other notes:

  • health-check takes one snapshot, with no retry/wait and timeout-minutes: 5. Stacks this deploy changed have already passed up --wait (--wait-timeout = 90% of service-startup-timeout). A container whose health is still starting while running passes the selector, so slow starters (Traefik's ACME-gated healthcheck with start_period: 180s, Authelia) are not flaky here.
  • The gate checks every critical stack, not just the changed ones. A transient unhealthy in an untouched critical stack (for example Traefik's healthcheck greps acme.json for the wildcard cert, so it goes unhealthy if renewal breaks it) fails the deploy. The C1 rule then forces a whole-tree rollback.

Rollout

Consumers call deploy.yml@main, so this affects the next deploy in both repos. Once the gate follow-up has landed: run workflow_dispatch on a deploy in each repo, then check that the prepare log shows Detected N critical stack(s) (piwine: beszel, monitoring, traefik; office: dozzle) and that health-check reports status=healthy.

Summary by Sourcery

Restore reliable critical-stack detection and make deployment health checks and cleanup robust when Compose files require unavailable environment variables.

Bug Fixes:

  • Detect critical Compose stacks when tier or critical labels use quoted, list, or other supported YAML label forms, while rejecting commented-out and invalid matches.
  • Prevent deployment cleanup, diagnostics, and health checks from parsing Compose files that require unavailable environment variables, and fail the critical-stack gate on Compose errors or empty results.

Enhancements:

  • Use project-name-based Docker Compose operations consistently for teardown and failure diagnostics.
  • Document supported critical-label syntax and add comprehensive matcher coverage.

Deployment:

  • Harden deployment teardown, diagnostics, and critical-stack health checks against Compose interpolation failures.

Documentation:

  • Document quoted and list-form critical labels and the associated detection test.

Tests:

  • Add 30-case automated coverage for critical-stack label detection, including valid formats, invalid matches, comments, CRLF, and aggregation.

detect-critical-stacks.sh grepped for the literal string
'com.compose.tier: infrastructure', but every consumer repo writes label
values double-quoted (com.compose.tier: "infrastructure"), so no stack
was ever auto-detected as critical and the health-check gate never ran.

Match both labels through one anchored has_label helper that accepts
unquoted, double- and single-quoted values, map or list (key=value)
form, surrounding whitespace and a trailing comment, while rejecting
commented-out lines and other values. Adds
scripts/testing/test-detect-critical-stacks.sh (25 cases).
@sourcery-ai

sourcery-ai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Critical-stack detection now uses a shared, line-anchored ERE matcher that correctly handles quoted Compose labels and list/map syntax while rejecting comments and near-matches. A standalone 25-case regression harness and updated documentation cover the behavior; reviewers should also verify the separately identified health-check gate failure before merging.

Sequence diagram for critical-stack detection in deployment preparation

sequenceDiagram
    participant Prepare
    participant Detector
    participant ComposeFiles
    participant CriticalStacks
    Prepare->>Detector: detect-critical-stacks.sh
    Detector->>ComposeFiles: Scan each compose file
    Detector->>Detector: has_label(file, key, value)
    alt tier=infrastructure or critical=true
        Detector->>CriticalStacks: Add stack
    else No anchored label match
        Detector->>CriticalStacks: Skip stack
    end
    Detector-->>Prepare: Output critical stack JSON
Loading

Flow diagram for critical-stack label detection

flowchart TD
    A[Compose file] --> B[has_label]
    B --> C{Anchored label match}
    C -->|tier=infrastructure| D[Mark stack critical]
    C -->|critical=true| D
    C -->|No match| E[Leave stack non-critical]
    B --> F[Accept quoted or unquoted map/list forms]
    B --> G[Reject comments and near-matches]
    D --> H[Add stack to CRITICAL_STACKS]
Loading

File-Level Changes

Change Details Files
Replace ad hoc critical-label grep checks with one anchored matcher that recognizes supported Compose label syntaxes without false positives.
  • Add has_label for quoted or unquoted keys and values, map or list forms, whitespace, comments, and CRLF endings.
  • Use the helper for both com.compose.tier=infrastructure and com.compose.critical=true.
  • Preserve the detector’s logging and critical_stacks output contract.
scripts/deployment/detect-critical-stacks.sh
Add comprehensive regression coverage for critical-stack detection behavior.
  • Exercise 25 positive, negative, formatting, aggregation, and missing-file cases through the script’s GitHub output.
  • Assert both emitted JSON and successful exit status without requiring Docker or real repositories.
scripts/testing/test-detect-critical-stacks.sh
Document the accepted quoted and alternate label forms and register the new test in the repository guidance.
  • Update critical-label examples to use quoted values and describe supported syntax and rejection rules.
  • Add the detector test to the documented testing tree.
CLAUDE.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="scripts/deployment/detect-critical-stacks.sh" line_range="84" />
<code_context>

   # Method 1: Check for tier=infrastructure label
-  if grep -q "com.compose.tier: infrastructure" "$COMPOSE_FILE" 2>/dev/null; then
+  if has_label "$COMPOSE_FILE" 'com\.compose\.tier' 'infrastructure'; then
     IS_CRITICAL=true
   fi
</code_context>
<issue_to_address>
**Deploys take whole-tree rollback**

When a detected critical stack requires Compose interpolation variables that the health-check does not load, the health-check's `docker compose ps` exits nonzero under `set -e`, so `failed_stacks` is never written. Rollback receives no culprit and selects a whole-tree rollback.

Run the health check with the stack's required Compose interpolation environment and ensure it records the failed stack.
</issue_to_address>

### Comment 2
<location path="scripts/deployment/detect-critical-stacks.sh" line_range="57" />
<code_context>
+has_label() {
+  local file="$1" key="$2" value="$3"
+  local q="[\"']?"
+  grep -qE "^[[:space:]]*(-[[:space:]]*)?${q}${key}${q}[[:space:]]*[:=][[:space:]]*${q}${value}${q}[[:space:]]*(#.*)?$" \
+    "$file" 2>/dev/null
+}
</code_context>
<issue_to_address>
**Critical stack gate gets wrong set**

When a Compose file uses flow-style labels or has a matching key/value outside a service's `labels` field, `has_label` scans text lines instead of parsing service labels, so it misses valid flow-style declarations and accepts matching text in unrelated sections or lists. `CRITICAL_STACKS` is then wrong, causing a real critical stack to miss the health gate or a noncritical stack to enter it.

Parse the Compose YAML and inspect each service's labels, including flow-style maps and lists.

Also at `scripts/deployment/detect-critical-stacks.sh:58`.
</issue_to_address>

### Comment 3
<location path="scripts/deployment/detect-critical-stacks.sh" line_range="57" />
<code_context>
+has_label() {
+  local file="$1" key="$2" value="$3"
+  local q="[\"']?"
+  grep -qE "^[[:space:]]*(-[[:space:]]*)?${q}${key}${q}[[:space:]]*[:=][[:space:]]*${q}${value}${q}[[:space:]]*(#.*)?$" \
+    "$file" 2>/dev/null
+}
</code_context>
<issue_to_address>
**Nonmatching labels become critical**

When a plain label value contains the target text followed immediately by `#` or a quote character, `has_label` treats `#` as a comment without requiring preceding whitespace and makes opening and closing quotes independently optional. A value such as `infrastructure#suffix` or `infrastructure'` therefore matches the target exactly, and the stack is incorrectly marked critical.

Recognize YAML scalar boundaries correctly: pair quote delimiters and treat `#` as a comment only when YAML permits it.

Also at `scripts/deployment/detect-critical-stacks.sh:58`.
</issue_to_address>

### Comment 4
<location path="scripts/deployment/detect-critical-stacks.sh" line_range="84" />
<code_context>

   # Method 1: Check for tier=infrastructure label
-  if grep -q "com.compose.tier: infrastructure" "$COMPOSE_FILE" 2>/dev/null; then
+  if has_label "$COMPOSE_FILE" 'com\.compose\.tier' 'infrastructure'; then
     IS_CRITICAL=true
   fi
</code_context>
<issue_to_address>
**Empty stacks pass the health gate**

When a detected critical stack has no containers returned by Compose, such as when its containers were never created or the project has no matching containers, `docker compose ps -a` can succeed with no containers, after which the `jq` filter counts zero unhealthy services and the step writes `status=healthy`. A critical stack with no containers therefore passes without any service being checked.

Treat an empty container list as a health-check failure and include that stack in `failed_stacks`.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 4 findings to address first, and a matcher mistake could omit a critical stack from health-check rollback gating or gate a noncritical one, changing deployment behavior and potentially allowing an unhealthy production stack to remain deployed or triggering an unnecessary rollback. Reverting prevents future misclassification, but any outage or deployment action that already occurred would require separate remediation.

Blocking findings: scripts/deployment/detect-critical-stacks.sh:84, scripts/deployment/detect-critical-stacks.sh:57, scripts/deployment/detect-critical-stacks.sh:57, scripts/deployment/detect-critical-stacks.sh:84


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread scripts/deployment/detect-critical-stacks.sh
Comment thread scripts/deployment/detect-critical-stacks.sh Outdated
Comment thread scripts/deployment/detect-critical-stacks.sh Outdated
Comment thread scripts/deployment/detect-critical-stacks.sh
owine added 2 commits October 9, 2026 22:52
Review follow-up: `infrastructure#x` and `infrastructure'` matched
because the trailing comment did not require preceding whitespace and the
opening/closing quotes were independently optional. Build explicit
map/list alternatives with paired quotes, require whitespace after the
map colon and before `#`, and document that flow-style label maps are
not recognised. Five more negative cases (30 total).
…ics and teardown

Run docker compose ps/logs/down as `-p <stack>` from / instead of inside the
stack dir. These calls run without `op run`, so in-dir they parsed the compose
file with empty ${VARS}; a file whose syntax depends on a var (traefik's
extra_hosts "${DOMAIN}:...") failed to parse. That crashed the critical-stack
health check before failed_stacks was written (whole-tree rollback), blanked
dump_failed_stack diagnostics, and broke teardown/rollback `down` for that stack.
The health check also now fails a critical stack with zero containers instead
of passing vacuously.
@owine
owine merged commit b293264 into main Oct 10, 2026
3 checks passed
@owine
owine deleted the fix/critical-stack-detection-quoted-labels branch October 10, 2026 04:00
owine added a commit that referenced this pull request Oct 10, 2026
… calls (#101)

* fix(deploy): resolve compose project from container labels for no-env calls

Follow-up to #99 review. ps/logs/down that run without `op run` now go
through a compose_p helper (installed per job into $RUNNER_TEMP) that reads
the project name from the containers' com.docker.compose.project.working_dir
label, falling back to the stack name. `-p "$stack"` silently missed when
Compose normalises the dir name (dots, uppercase, leading _/-) or a compose
file sets a top-level name:, leaving teardown/rollback a no-op (exit 0) and
orphaning containers.

Also: teardowns no longer require the compose file to exist (down targets the
project label; missing file is now a notice), the health check validates stack
names like the other loops, redundant export -f removed, and
ensure-shared-networks documents that label-based down removes a
project-labelled network even after it is marked external.

* fix(deploy): compose_p acts on every project found under the stack dir

Sourcery review: when containers from more than one Compose project share a
stack's working_dir label (e.g. leftovers after a project rename),
compose_project returned only the first, so health checks missed the others
and down left them running. compose_projects now returns all distinct
matches and compose_p runs the command for each (stdin from /dev/null so
compose can't eat the loop input), failing if any project's command fails.

* fix(deploy): never fall back to a same-named project from another dir; review nits

Independent review of #101: with no containers in the stack dir, compose_p
fell back to `-p <stack>`, which matches a same-named project running from
any directory — teardown (now ungated on the compose file) could down it and
the health check could pass on its containers. compose_projects now returns
nothing in that case (down no-op, health check fails as "no containers").
The foreign check captures docker output before grep -q to avoid SIGPIPE
under pipefail.

Nits: update the stale project-name comment, drop the unused helper source in
Deploy dockge, and build the health-check failed_stacks JSON with jq.
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