From a4ab0dc4b2cf592f5eca8d418d85385acdb393db Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 1 Aug 2026 14:06:36 -0400 Subject: [PATCH 1/2] feat(OMN-15521): sanctioned .201 gateway-forwarder deploy path - scripts/deploy-gateway.sh: builds the omninode-gateway forwarder image from this repo checkout with the same OCI provenance build-args every omnibase-infra runtime container gets (VCS_REF, RUNTIME_VERSION, BUILD_DATE, COMPOSE_PROJECT, RUNTIME_SOURCE_HASH, PROMOTION_CLASS, NON_MAIN_LINEAGE), pins the running container to the resulting image digest, syncs the host compose files from this checkout (replacing the 2026-07-29 hand-copy), and records a rollback target via a registry.json mirroring the omnibase-infra lanes own convention. - docs/runbooks/gateway-lane-deploy.md: procedure + rollback runbook alongside docs/runbooks/cold-lane-full-bringup.md. - tests/scripts/test_deploy_gateway.py: subprocess-driven RED-before/ GREEN-after coverage against the real script (fake docker/sudo), one case per OMN-15521 acceptance criterion (AC1/2/3/4/6; AC5 is explicitly out of scope, it lands on OMN-12912). --- docs/runbooks/gateway-lane-deploy.md | 181 +++++++++ scripts/deploy-gateway.sh | 555 +++++++++++++++++++++++++++ tests/scripts/test_deploy_gateway.py | 507 ++++++++++++++++++++++++ 3 files changed, 1243 insertions(+) create mode 100644 docs/runbooks/gateway-lane-deploy.md create mode 100755 scripts/deploy-gateway.sh create mode 100644 tests/scripts/test_deploy_gateway.py diff --git a/docs/runbooks/gateway-lane-deploy.md b/docs/runbooks/gateway-lane-deploy.md new file mode 100644 index 0000000000..53ba43ed62 --- /dev/null +++ b/docs/runbooks/gateway-lane-deploy.md @@ -0,0 +1,181 @@ +# Gateway lane deploy (`omninode-gateway` compose project on `.201`) + +This runbook documents the sanctioned deploy path for the `.201` operator-edge +gateway forwarder — the standalone process that bridges the cloud MSK bus to +the `.201` local Redpanda listener for cloud→local inference (OMN-12908 hybrid +gateway). It exists because, before OMN-15521, this lane had **no** repo- +resident deploy path at all: it was stood up on 2026-07-29 by hand-copying +`docker/docker-compose.gateway.yml` and `docker/gateway/beta-gateway-canary.yaml` +into a root-owned directory and running compose there directly. That left the +lane invisible to `deploy-runtime.sh` (whose `-p omnibase-infra` scope never +touches it), running an image with an **empty** +`org.opencontainers.image.revision` label, and with no recorded rollback +target. + +Ticket: **OMN-15521**. + +> Scope: this is the `.201` `omninode-gateway` lane ONLY (container +> `omninode-gateway-forwarder`, systemd unit `onex-gateway-forwarder`). It is +> unrelated to the `omnibase-infra` runtime lane `deploy-runtime.sh` manages — +> no migrations, no `RUNTIME_SERVICES` restart, no broker readiness preflight. +> See `docs/runbooks/cold-lane-full-bringup.md` for that lane instead. + +--- + +## What's already on the host (read this before you "fix" it) + +The `.201` box already has a working, restart-safe supervision layer for this +lane that this runbook does **not** replace: + +- systemd unit `/etc/systemd/system/onex-gateway-forwarder.service` (repo copy: + `docker/gateway/onex-gateway-forwarder.service`) — starts the container via + `docker compose ... up -d --no-build --wait`, and its `ExecStartPre` **hard + refuses to start** unless `/etc/omninode/gateway/gateway.env`'s + `GATEWAY_IMAGE=` line is a real `sha256:<64 hex>` digest. It never builds — + the image must already exist locally. +- `/etc/omninode/gateway/gateway.env` (root-owned, mode `0444`) — the AWS + Roles Anywhere / TPM / container-UID variables the compose file requires, + plus the pinned `GATEWAY_IMAGE` digest. +- `/opt/omninode/gateway/` (root-owned, mode `0444`) — the compose file + + canary config the systemd unit's `WorkingDirectory` points at. + +What was missing is everything **upstream** of that: a repeatable way to +build a new image from merged dev, stamp it with real provenance labels, +update the pinned digest, and sync the host files without hand-editing a +root-owned directory. `scripts/deploy-gateway.sh` is that path. + +--- + +## Procedure + +### 0. Pre-flight: sync the canonical clone to the merged-dev tip + +Run from the canonical `omnibase_infra` clone on `.201` (same convention as +`deploy-runtime.sh` and `refresh_stability_lane.sh` — this script is **not** +run from a worktree): + +```bash +cd /data/omninode/omni_home/omnibase_infra # or wherever the canonical clone lives on this host +git pull --ff-only +``` + +### 1. Preview (dry-run, the default) + +```bash +./scripts/deploy-gateway.sh +``` + +Dry-run prints the resolved version/git SHA and the exact compose build +command it would run, and lists every file/registry mutation it would +perform, without touching anything. + +### 2. Inspect the exact build command + +```bash +./scripts/deploy-gateway.sh --print-compose-cmd +``` + +This is the fix for the ticket's core finding: the printed command carries +`--build-arg VCS_REF= --build-arg RUNTIME_VERSION= --build-arg +COMPOSE_PROJECT=omninode-gateway --build-arg RUNTIME_SOURCE_HASH= +--build-arg PROMOTION_CLASS=clean-main --build-arg NON_MAIN_LINEAGE=false` — +the same OCI provenance build-args `deploy-runtime.sh`'s `build_images()` +passes for every `omnibase-infra` runtime container. The compose file's own +declared `build.args` block only ever carried `BUILD_SOURCE`/ +`EXPECTED_BUILD_SOURCE` (mirroring `docker-compose.infra.yml`'s split between +compose-declared and CLI-supplied build-args) — that split is why the +hand-built image had `rev=(empty)`. + +### 3. Execute the deploy + +```bash +./scripts/deploy-gateway.sh --execute +``` + +In order, this: + +1. Resolves `repo_root` / `version` (pyproject.toml) / `git_sha` (HEAD). +2. Reads the **existing** `GATEWAY_IMAGE` digest from + `/etc/omninode/gateway/gateway.env` and records it as the rollback target. +3. Sources that same env file so the AWS/TPM/UID variables the compose file + requires resolve for the build. +4. Builds `docker-gateway-forwarder:build` with the provenance build-args from + step 2 above. +5. Resolves the built image's digest (`docker image inspect --format='{{.Id}}'`). +6. Syncs `docker/docker-compose.gateway.yml` and + `docker/gateway/beta-gateway-canary.yaml` from **this checkout** into + `/opt/omninode/gateway/` (`sudo install -m 0444 -o root -g root`, matching + the existing file posture) — replacing the 2026-07-29 hand-copy as the + source of truth. Every deploy re-syncs, so the host copy can never drift + from a merged commit again. +7. Rewrites `/etc/omninode/gateway/gateway.env`'s `GATEWAY_IMAGE=` line to the + new digest, leaving every other key untouched. +8. Writes `~/.omnibase/gateway/registry.json` recording `active_digest`, + `previous_digest` (the rollback target), `git_sha`, `deployed_at`, and a + ready-to-run `rollback_command` — the same convention + `~/.omnibase/infra/registry.json` uses for the `omnibase-infra` lane. +9. `sudo systemctl reload onex-gateway-forwarder` — the unit's existing + `ExecReload` force-recreates the container on the new digest. +10. Verifies: labels are non-empty, and the two OMN-12912 files + (`service_gateway_delivery.py`, `store_sqlite.py`) are present inside the + running container. + +Pass `--skip-reload` to build + sync + pin the new digest without recreating +the running container yet (the old container keeps running on the old +digest until a manual `sudo systemctl reload onex-gateway-forwarder`). + +### 4. Verify + +```bash +docker inspect omninode-gateway-forwarder \ + --format='rev={{index .Config.Labels "org.opencontainers.image.revision"}} src={{index .Config.Labels "com.omninode.build_source"}}' +# -> rev=<12-char sha> src=release (never empty) + +docker exec omninode-gateway-forwarder \ + ls /app/src/omnibase_infra/nodes/node_bus_forwarder_effect/services/ +# -> must include service_gateway_delivery.py + +docker exec omninode-gateway-forwarder \ + ls /app/src/omnibase_infra/idempotency/ +# -> must include store_sqlite.py + +diff /opt/omninode/gateway/docker-compose.gateway.yml docker/docker-compose.gateway.yml +# -> empty + +cat ~/.omnibase/gateway/registry.json | jq . +``` + +--- + +## Rollback + +`~/.omnibase/gateway/registry.json`'s `previous_digest` names the last-known- +good digest. To roll back to it: + +```bash +sudo sed -i "s|^GATEWAY_IMAGE=.*|GATEWAY_IMAGE=$(jq -r .previous_digest ~/.omnibase/gateway/registry.json)|" \ + /etc/omninode/gateway/gateway.env +sudo systemctl reload onex-gateway-forwarder +``` + +(`registry.json`'s own `rollback_command` field carries this exact command +pre-filled with the digest recorded at deploy time.) This mirrors the +`omnibase-infra` lane's own manual rollback-via-`registry.json` pattern — +`deploy-runtime.sh` has no automated `--rollback` flag either; a prior +digest is always restored by hand from the registry. + +--- + +## What OMN-15521 does NOT cover + +The OMN-12912 restart/redelivery proof (source-offset-ack / dedupe receipt, +run against the freshly deployed forwarder after a restart) is **out of +scope for this ticket** — it lands on OMN-12912, not here, per that ticket's +own falsifiable acceptance criteria. + +## Related runbooks + +- `docs/runbooks/cold-lane-full-bringup.md` — the `omnibase-infra` runtime + lane's own cold bring-up (a different compose project, different scope). +- `docker/gateway/onex-gateway-forwarder.service` — the systemd unit this + script's `--execute` reloads. diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh new file mode 100755 index 0000000000..41091c2000 --- /dev/null +++ b/scripts/deploy-gateway.sh @@ -0,0 +1,555 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +# +# deploy-gateway.sh -- Sanctioned deploy path for the .201 omninode-gateway lane +# (OMN-15521). +# +# Before this script, the compose project `omninode-gateway` (the standalone +# operator-edge forwarder at /opt/omninode/gateway, systemd unit +# onex-gateway-forwarder) had no repo-resident deploy path at all -- it was +# stood up by hand-copying docker/docker-compose.gateway.yml and +# docker/gateway/beta-gateway-canary.yaml into a root-owned directory and +# building/running compose there directly. That left the lane invisible to +# deploy-runtime.sh (whose -p omnibase-infra scope never touches it), stamped +# with no org.opencontainers.image.revision / com.omninode.build_source +# labels, and with no recorded rollback target. +# +# This script builds the gateway-forwarder image FROM THIS REPO CHECKOUT (the +# same src/omnibase_infra/ tree deploy-runtime.sh's dev lane builds from -- pull +# to the merged-dev tip first), stamps the same OCI provenance labels every +# omnibase-infra runtime container carries, pins the running container to the +# resulting image DIGEST (never a moving :latest tag -- the existing systemd +# unit's ExecStartPre already asserts GATEWAY_IMAGE is a real sha256 digest), +# and records a rollback target the same way the omnibase-infra lane does via +# registry.json. +# +# Scope: this is the .201 gateway lane ONLY (compose project +# `omninode-gateway`). It does not touch the omnibase-infra runtime lane, does +# not run migrations, and does not restart any RUNTIME_SERVICES container. +# +# Usage: +# ./scripts/deploy-gateway.sh # Dry-run preview (default) +# ./scripts/deploy-gateway.sh --execute # Build + deploy + reload +# ./scripts/deploy-gateway.sh --print-compose-cmd # Show the exact build command +# ./scripts/deploy-gateway.sh --help # Full usage +# +# Runbook: docs/runbooks/gateway-lane-deploy.md + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" +readonly SCRIPT_NAME +readonly SCRIPT_VERSION="1.0.0" + +readonly COMPOSE_PROJECT="omninode-gateway" +readonly SERVICE_NAME="gateway-forwarder" +readonly CONTAINER_NAME="omninode-gateway-forwarder" +readonly SYSTEMD_UNIT="onex-gateway-forwarder" + +# Host paths the running lane reads from. Overridable so tests can point them +# at a scratch directory instead of the real root-owned locations. +GATEWAY_HOST_DIR="${GATEWAY_HOST_DIR:-/opt/omninode/gateway}" +GATEWAY_ENV_FILE="${GATEWAY_ENV_FILE:-/etc/omninode/gateway/gateway.env}" +GATEWAY_REGISTRY_DIR="${GATEWAY_REGISTRY_DIR:-${HOME}/.omnibase/gateway}" +readonly GATEWAY_REGISTRY_FILE="${GATEWAY_REGISTRY_DIR}/registry.json" + +# Build-time image tag. The running lane is pinned to a resolved digest (see +# resolve_image_digest), never to this moving tag -- it exists only so +# `docker compose build` has something to name the image it produces. +readonly BUILD_IMAGE_TAG="docker-gateway-forwarder:build" + +# ============================================================================= +# Logging +# ============================================================================= + +log_info() { printf '[deploy-gateway] %s\n' "$*"; } +log_warn() { printf '[deploy-gateway] WARNING: %s\n' "$*" >&2; } +log_error() { printf '[deploy-gateway] ERROR: %s\n' "$*" >&2; } +log_step() { printf '\n[deploy-gateway] === %s ===\n' "$*"; } +log_cmd() { printf '[deploy-gateway] > %s\n' "$*"; } + +# ============================================================================= +# Usage +# ============================================================================= + +usage() { + cat <). + 4. Sync docker/docker-compose.gateway.yml and + docker/gateway/beta-gateway-canary.yaml from this checkout into + ${GATEWAY_HOST_DIR} (root-owned, mode 0444 -- same posture the files + already have), replacing the hand-copied originals. + 5. Rewrite ${GATEWAY_ENV_FILE}'s GATEWAY_IMAGE= line to the new digest, + preserving every other key untouched. + 6. Record the previous digest + this deploy's identity in + ${GATEWAY_REGISTRY_FILE} (rollback target). + 7. 'systemctl reload ${SYSTEMD_UNIT}' (force-recreates the container on + the new digest; requires sudo) unless --skip-reload. + 8. Verify: image labels are non-empty, and log whether the container is + running the new digest. + +ROLLBACK + ${GATEWAY_REGISTRY_FILE} records "previous_digest". To roll back: + sudo sed -i "s|^GATEWAY_IMAGE=.*|GATEWAY_IMAGE=|" ${GATEWAY_ENV_FILE} + sudo systemctl reload ${SYSTEMD_UNIT} + (mirrors the omnibase-infra lane's manual rollback-via-registry.json + pattern -- deploy-runtime.sh has no automated --rollback flag either.) + +EXAMPLES + # Preview what would be built and deployed + ${SCRIPT_NAME} + + # Print the exact build command + ${SCRIPT_NAME} --print-compose-cmd + + # Build + deploy + reload + ${SCRIPT_NAME} --execute + + # Verify after deploy + docker inspect ${CONTAINER_NAME} \\ + --format='rev={{index .Config.Labels "org.opencontainers.image.revision"}} src={{index .Config.Labels "com.omninode.build_source"}}' + docker exec ${CONTAINER_NAME} ls /app/src/omnibase_infra/nodes/node_bus_forwarder_effect/services/ + docker exec ${CONTAINER_NAME} ls /app/src/omnibase_infra/idempotency/ + diff ${GATEWAY_HOST_DIR}/docker-compose.gateway.yml docker/docker-compose.gateway.yml +EOF + exit 0 +} + +# ============================================================================= +# Argument parsing +# ============================================================================= + +MODE="dry-run" +PRINT_COMPOSE_CMD=false +SKIP_RELOAD=false + +parse_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --execute) + MODE="execute" + shift + ;; + --print-compose-cmd) + PRINT_COMPOSE_CMD=true + shift + ;; + --skip-reload) + SKIP_RELOAD=true + shift + ;; + --help | -h) + usage + ;; + *) + log_error "Unknown argument: $1" + log_error "Run '${SCRIPT_NAME} --help' for usage." + exit 64 + ;; + esac + done +} + +# ============================================================================= +# Identity +# ============================================================================= + +resolve_repo_root() { + local dir + dir="$(cd "$(dirname "$0")" && pwd)" + while [[ "${dir}" != "/" ]]; do + if [[ -f "${dir}/pyproject.toml" ]]; then + echo "${dir}" + return 0 + fi + dir="$(dirname "${dir}")" + done + log_error "Cannot find repository root (no pyproject.toml found above script)." + exit 1 +} + +validate_repo_structure() { + local repo_root="$1" + local missing=() + [[ -f "${repo_root}/docker/docker-compose.gateway.yml" ]] || missing+=("docker/docker-compose.gateway.yml") + [[ -f "${repo_root}/docker/gateway/beta-gateway-canary.yaml" ]] || missing+=("docker/gateway/beta-gateway-canary.yaml") + [[ -f "${repo_root}/docker/Dockerfile.runtime" ]] || missing+=("docker/Dockerfile.runtime") + [[ -d "${repo_root}/src/omnibase_infra/nodes/node_bus_forwarder_effect/services" ]] || missing+=("src/omnibase_infra/nodes/node_bus_forwarder_effect/services/") + [[ -d "${repo_root}/src/omnibase_infra/idempotency" ]] || missing+=("src/omnibase_infra/idempotency/") + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Repository structure validation failed. Missing:" + for item in "${missing[@]}"; do + log_error " - ${item}" + done + exit 1 + fi +} + +read_version() { + local repo_root="$1" + local version + version="$(awk ' + /^\[project\]/ { in_section=1; next } + /^\[/ { in_section=0 } + in_section && /^version[[:space:]]*=/ { + gsub(/.*=[[:space:]]*"/, ""); + gsub(/".*/, ""); + print; + exit + } + ' "${repo_root}/pyproject.toml")" + if [[ -z "${version}" ]]; then + log_error "Could not read version from pyproject.toml [project] section" + exit 1 + fi + echo "${version}" +} + +read_git_sha() { + local repo_root="$1" + local sha + sha="$(git -C "${repo_root}" rev-parse --short=12 HEAD 2>/dev/null || true)" + if [[ -z "${sha}" ]]; then + log_error "Could not determine git SHA. Is this a git repository?" + exit 1 + fi + echo "${sha}" +} + +check_git_dirty() { + local repo_root="$1" + local status_output + status_output="$(git -C "${repo_root}" status --porcelain 2>/dev/null || true)" + if [[ -n "${status_output}" ]]; then + log_warn "Working tree has uncommitted changes." + log_warn "The deployed SHA will not match the actual file contents." + fi +} + +# ============================================================================= +# Build +# ============================================================================= + +# resolve_build_args REPO_ROOT GIT_SHA VERSION -- prints the --build-arg argv +# entries (one per line) so both build_image() and print_compose_commands() +# stay in lockstep instead of maintaining two copies of the same list. +resolve_build_args() { + local repo_root="$1" git_sha="$2" version="$3" + local build_date + build_date="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + local build_source="${BUILD_SOURCE:-release}" + local expected_build_source="${EXPECTED_BUILD_SOURCE:-${build_source}}" + local promotion_class="clean-main" + local non_main_lineage="false" + if [[ "${build_source}" == "workspace" ]]; then + promotion_class="stability-candidate" + non_main_lineage="true" + fi + cat </dev/null || true)" + if [[ -z "${digest}" ]]; then + log_error "Could not resolve digest for built image ${image_tag}" + exit 1 + fi + echo "${digest}" +} + +# ============================================================================= +# Host-file sync (AC4) -- eliminates the 2026-07-29 hand-copy as the source of +# truth. Every deploy re-syncs from this checkout so the host copy can never +# silently drift from a merged commit again. +# ============================================================================= + +sync_host_files() { + local repo_root="$1" host_dir="$2" + log_step "Sync host files -> ${host_dir}" + sudo install -d -m 0755 -o root -g root "${host_dir}" + sudo install -d -m 0755 -o root -g root "${host_dir}/gateway" + sudo install -m 0444 -o root -g root \ + "${repo_root}/docker/docker-compose.gateway.yml" \ + "${host_dir}/docker-compose.gateway.yml" + sudo install -m 0444 -o root -g root \ + "${repo_root}/docker/gateway/beta-gateway-canary.yaml" \ + "${host_dir}/gateway/beta-gateway-canary.yaml" + log_info "Synced docker-compose.gateway.yml + gateway/beta-gateway-canary.yaml" +} + +verify_host_files_match() { + local repo_root="$1" host_dir="$2" + if ! diff -q "${host_dir}/docker-compose.gateway.yml" "${repo_root}/docker/docker-compose.gateway.yml" >/dev/null 2>&1; then + log_error "AC4 FAILED: ${host_dir}/docker-compose.gateway.yml differs from the repo copy after sync." + return 1 + fi + log_info "AC4 OK: ${host_dir}/docker-compose.gateway.yml matches the repo copy." + return 0 +} + +# ============================================================================= +# gateway.env digest pin +# ============================================================================= + +current_gateway_image_digest() { + local env_file="$1" + if [[ ! -f "${env_file}" ]]; then + echo "" + return 0 + fi + awk -F= '/^GATEWAY_IMAGE=/{print $2; exit}' "${env_file}" +} + +update_gateway_env_digest() { + local env_file="$1" new_digest="$2" + log_step "Pin GATEWAY_IMAGE -> ${new_digest}" + local tmp_file + tmp_file="$(mktemp)" + awk -v new="GATEWAY_IMAGE=${new_digest}" ' + BEGIN { done=0 } + /^GATEWAY_IMAGE=/ { print new; done=1; next } + { print } + END { if (!done) print new } + ' "${env_file}" >"${tmp_file}" + sudo install -m 0444 -o root -g root "${tmp_file}" "${env_file}" + rm -f "${tmp_file}" + log_info "gateway.env now pins GATEWAY_IMAGE=${new_digest}" +} + +# ============================================================================= +# Registry (AC6 -- rollback target, same convention as ~/.omnibase/infra/registry.json) +# ============================================================================= + +write_registry() { + local version="$1" git_sha="$2" new_digest="$3" previous_digest="$4" repo_root="$5" + log_step "Write Registry" + mkdir -p "${GATEWAY_REGISTRY_DIR}" + local deployed_at + deployed_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" + local tmp_file="${GATEWAY_REGISTRY_FILE}.tmp" + jq -n \ + --arg active_version "${version}" \ + --arg git_sha "${git_sha}" \ + --arg active_digest "${new_digest}" \ + --arg previous_digest "${previous_digest}" \ + --arg source_repo "${repo_root}" \ + --arg deployed_at "${deployed_at}" \ + --arg compose_project "${COMPOSE_PROJECT}" \ + --arg gateway_env_file "${GATEWAY_ENV_FILE}" \ + --arg host_compose_file "${GATEWAY_HOST_DIR}/docker-compose.gateway.yml" \ + '{ + active_version: $active_version, + git_sha: $git_sha, + active_digest: $active_digest, + previous_digest: ($previous_digest | select(. != "") // null), + source_repo: $source_repo, + deployed_at: $deployed_at, + compose_project: $compose_project, + gateway_env_file: $gateway_env_file, + host_compose_file: $host_compose_file, + rollback_command: ("sudo sed -i \"s|^GATEWAY_IMAGE=.*|GATEWAY_IMAGE=" + ($previous_digest // "") + "|\" " + $gateway_env_file + " && sudo systemctl reload onex-gateway-forwarder") + }' >"${tmp_file}" + mv "${tmp_file}" "${GATEWAY_REGISTRY_FILE}" + log_info "Registry written: ${GATEWAY_REGISTRY_FILE}" + log_info " active_digest: ${new_digest}" + log_info " previous_digest: ${previous_digest:-}" +} + +# ============================================================================= +# Reload +# ============================================================================= + +reload_service() { + log_step "Reload ${SYSTEMD_UNIT}" + log_cmd "sudo systemctl reload ${SYSTEMD_UNIT}" + sudo systemctl reload "${SYSTEMD_UNIT}" +} + +# ============================================================================= +# Verify (AC2 + AC3 probes) +# ============================================================================= + +verify_deployment() { + log_step "Verify" + local rev src + rev="$(docker inspect "${CONTAINER_NAME}" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)" + src="$(docker inspect "${CONTAINER_NAME}" --format '{{index .Config.Labels "com.omninode.build_source"}}' 2>/dev/null || true)" + if [[ -z "${rev}" ]]; then + log_error "AC2 FAILED: org.opencontainers.image.revision is empty on ${CONTAINER_NAME}" + return 1 + fi + log_info "AC2 OK: org.opencontainers.image.revision=${rev} com.omninode.build_source=${src}" + + if docker exec "${CONTAINER_NAME}" test -f /app/src/omnibase_infra/nodes/node_bus_forwarder_effect/services/service_gateway_delivery.py \ + && docker exec "${CONTAINER_NAME}" test -f /app/src/omnibase_infra/idempotency/store_sqlite.py; then + log_info "AC3 OK: service_gateway_delivery.py + store_sqlite.py present in the running container." + else + log_error "AC3 FAILED: one or both OMN-12912 files are absent from the running container." + return 1 + fi + return 0 +} + +# ============================================================================= +# Main +# ============================================================================= + +main() { + parse_args "$@" + + local repo_root version git_sha + repo_root="$(resolve_repo_root)" + validate_repo_structure "${repo_root}" + version="$(read_version "${repo_root}")" + git_sha="$(read_git_sha "${repo_root}")" + check_git_dirty "${repo_root}" + + if [[ "${PRINT_COMPOSE_CMD}" == true ]]; then + print_compose_commands "${repo_root}" "${git_sha}" "${version}" + exit 0 + fi + + log_step "Identity" + log_info "repo_root: ${repo_root}" + log_info "version: ${version}" + log_info "git_sha: ${git_sha}" + log_info "compose_project: ${COMPOSE_PROJECT}" + + if [[ "${MODE}" != "execute" ]]; then + log_step "Dry Run (default) -- no mutation performed" + print_compose_commands "${repo_root}" "${git_sha}" "${version}" + log_info "Would sync ${repo_root}/docker/docker-compose.gateway.yml -> ${GATEWAY_HOST_DIR}/docker-compose.gateway.yml" + log_info "Would sync ${repo_root}/docker/gateway/beta-gateway-canary.yaml -> ${GATEWAY_HOST_DIR}/gateway/beta-gateway-canary.yaml" + log_info "Would pin GATEWAY_IMAGE in ${GATEWAY_ENV_FILE} to the resolved build digest" + log_info "Would write ${GATEWAY_REGISTRY_FILE}" + if [[ "${SKIP_RELOAD}" != true ]]; then + log_info "Would run: sudo systemctl reload ${SYSTEMD_UNIT}" + fi + log_info "Re-run with --execute to perform this deploy." + exit 0 + fi + + if [[ ! -f "${GATEWAY_ENV_FILE}" ]]; then + log_error "GATEWAY_ENV_FILE not found: ${GATEWAY_ENV_FILE}" + log_error "This file supplies the AWS/TPM/UID variables the compose file requires." + exit 64 + fi + + local previous_digest + previous_digest="$(current_gateway_image_digest "${GATEWAY_ENV_FILE}")" + log_info "Previous GATEWAY_IMAGE (rollback target): ${previous_digest:-}" + + set -a + # shellcheck source=/dev/null + source "${GATEWAY_ENV_FILE}" + set +a + + build_image "${repo_root}" "${git_sha}" "${version}" + local new_digest + new_digest="$(resolve_image_digest "${BUILD_IMAGE_TAG}")" + log_info "Built image digest: ${new_digest}" + + sync_host_files "${repo_root}" "${GATEWAY_HOST_DIR}" + verify_host_files_match "${repo_root}" "${GATEWAY_HOST_DIR}" + + update_gateway_env_digest "${GATEWAY_ENV_FILE}" "${new_digest}" + write_registry "${version}" "${git_sha}" "${new_digest}" "${previous_digest}" "${repo_root}" + + if [[ "${SKIP_RELOAD}" == true ]]; then + log_warn "--skip-reload set: gateway.env is updated but the running container still has the OLD digest until you reload." + exit 0 + fi + + reload_service + verify_deployment + log_step "Done" + log_info "omninode-gateway deployed: version=${version} git_sha=${git_sha} digest=${new_digest}" +} + +main "$@" diff --git a/tests/scripts/test_deploy_gateway.py b/tests/scripts/test_deploy_gateway.py new file mode 100644 index 0000000000..1c3ee0a51f --- /dev/null +++ b/tests/scripts/test_deploy_gateway.py @@ -0,0 +1,507 @@ +# SPDX-FileCopyrightText: 2025 OmniNode.ai Inc. +# SPDX-License-Identifier: MIT +"""scripts/deploy-gateway.sh -- the OMN-15521 sanctioned .201 gateway deploy path. + +Before this script, the `omninode-gateway` compose project (the standalone +operator-edge forwarder at /opt/omninode/gateway) had NO repo-resident deploy +path: it was stood up by hand-copying files into a root-owned directory, ran +an image with an empty `org.opencontainers.image.revision` label, and had no +recorded rollback target. These tests drive the REAL script (scripts/deploy- +gateway.sh) via subprocess -- exactly the convention +tests/scripts/test_deploy_runtime_promotion_class.py already uses for +deploy-runtime.sh -- with `docker`/`sudo` replaced by inspectable fakes so the +full --execute code path runs without a live Docker daemon, systemd, or root. + +Coverage maps 1:1 to the OMN-15521 falsifiable acceptance criteria: + AC1 -- test_help_documents_runbook, + test_print_compose_cmd_targets_repo_resident_compose_project, + test_dry_run_default_performs_no_mutation + AC2 -- test_build_command_stamps_oci_provenance_build_args, + test_execute_deploy_produces_non_empty_image_labels + AC3 -- test_verify_deployment_red_before_files_absent, + test_verify_deployment_green_after_files_present + AC4 -- test_sync_host_files_red_before_stale_copy_diverges, + test_sync_host_files_green_after_diff_is_empty + AC6 -- test_execute_deploy_writes_registry_with_rollback_target, + test_second_deploy_records_previous_digest + (AC5 -- the OMN-12912 restart/redelivery receipt -- is explicitly OUT of + scope for this ticket; it lands on OMN-12912, not here.) +""" + +from __future__ import annotations + +import json +import os +import re +import stat +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEPLOY_SCRIPT = REPO_ROOT / "scripts" / "deploy-gateway.sh" +RUNBOOK = REPO_ROOT / "docs" / "runbooks" / "gateway-lane-deploy.md" + + +# --------------------------------------------------------------------------- +# Fixture: fake docker + sudo + systemctl on PATH, scratch host paths +# --------------------------------------------------------------------------- + +_FAKE_DOCKER = """#!/usr/bin/env bash +set -eu +log="${GW_TEST_DOCKER_LOG:?}" +printf '%s\\n' "$*" >> "${log}" + +case "$*" in + "compose -p omninode-gateway -f docker/docker-compose.gateway.yml build"*) + exit 0 + ;; + "image inspect "*"--format={{.Id}}") + printf 'sha256:%064d\\n' "${GW_TEST_DIGEST_SEED:-1}" + exit 0 + ;; + *"--format={{index .Config.Labels \\"org.opencontainers.image.revision\\"}}}"*) + printf '%s\\n' "${GW_TEST_LABEL_REVISION:-}" + exit 0 + ;; + *"image.revision"*) + printf '%s\\n' "${GW_TEST_LABEL_REVISION:-}" + exit 0 + ;; + *"build_source"*) + printf '%s\\n' "${GW_TEST_LABEL_BUILD_SOURCE:-}" + exit 0 + ;; + *"exec omninode-gateway-forwarder test -f /app/src/omnibase_infra/nodes/node_bus_forwarder_effect/services/service_gateway_delivery.py") + [ "${GW_TEST_DELIVERY_PRESENT:-1}" = "1" ] + exit $? + ;; + *"exec omninode-gateway-forwarder test -f /app/src/omnibase_infra/idempotency/store_sqlite.py") + [ "${GW_TEST_SQLITE_PRESENT:-1}" = "1" ] + exit $? + ;; + *) + printf 'fake docker: unexpected invocation: %s\\n' "$*" >&2 + exit 1 + ;; +esac +""" + +_FAKE_SUDO = """#!/usr/bin/env bash +# Strip `-o -g ` (real root ownership changes are not available +# in a test sandbox) and special-case systemctl so no live unit is required. +set -eu +args=() +skip_next=0 +for a in "$@"; do + if [ "${skip_next}" = "1" ]; then skip_next=0; continue; fi + case "${a}" in + -o|-g) skip_next=1; continue ;; + esac + args+=("${a}") +done +if [ "${args[0]:-}" = "systemctl" ]; then + printf '%s\\n' "${args[*]}" >> "${GW_TEST_SYSTEMCTL_LOG:?}" + exit 0 +fi +exec "${args[@]}" +""" + + +def _write_fake_bin(bin_dir: Path) -> None: + bin_dir.mkdir(parents=True, exist_ok=True) + docker = bin_dir / "docker" + docker.write_text(_FAKE_DOCKER, encoding="utf-8") + docker.chmod(docker.stat().st_mode | stat.S_IEXEC) + + sudo = bin_dir / "sudo" + sudo.write_text(_FAKE_SUDO, encoding="utf-8") + sudo.chmod(sudo.stat().st_mode | stat.S_IEXEC) + + +class _Harness: + """One scratch environment for a subprocess run of deploy-gateway.sh.""" + + def __init__(self, tmp_path: Path) -> None: + self.tmp_path = tmp_path + self.bin_dir = tmp_path / "bin" + self.host_dir = tmp_path / "opt-omninode-gateway" + self.env_file = tmp_path / "gateway.env" + self.registry_dir = tmp_path / "home" / ".omnibase" / "gateway" + self.docker_log = tmp_path / "docker.log" + self.systemctl_log = tmp_path / "systemctl.log" + _write_fake_bin(self.bin_dir) + self.env_file.write_text( + "GATEWAY_IMAGE=sha256:" + ("0" * 64) + "\n" + "GATEWAY_AWS_PROFILE=gateway\n" + "GATEWAY_AWS_CONFIG_FILE=/dev/null\n" + "GATEWAY_AWS_CERTIFICATE_FILE=/dev/null\n" + "GATEWAY_AWS_PRIVATE_KEY_FILE=/dev/null\n" + "GATEWAY_AWS_SIGNING_HELPER_FILE=/dev/null\n" + "GATEWAY_TPM_DEVICE=/dev/null\n" + "GATEWAY_TPM_GROUP_ID=105\n" + "GATEWAY_CONTAINER_UID=1000\n" + "GATEWAY_CONTAINER_GID=1000\n", + encoding="utf-8", + ) + + def env(self, **overrides: str) -> dict[str, str]: + e = os.environ.copy() + e["PATH"] = f"{self.bin_dir}{os.pathsep}{e['PATH']}" + e["HOME"] = str(self.tmp_path / "home") + e["GATEWAY_HOST_DIR"] = str(self.host_dir) + e["GATEWAY_ENV_FILE"] = str(self.env_file) + e["GATEWAY_REGISTRY_DIR"] = str(self.registry_dir) + e["GW_TEST_DOCKER_LOG"] = str(self.docker_log) + e["GW_TEST_SYSTEMCTL_LOG"] = str(self.systemctl_log) + e.update(overrides) + return e + + def run(self, *args: str, **env_overrides: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(DEPLOY_SCRIPT), *args], + cwd=REPO_ROOT, + env=self.env(**env_overrides), + capture_output=True, + text=True, + check=False, + ) + + def registry(self) -> dict[str, Any]: + result: dict[str, Any] = json.loads( + (self.registry_dir / "registry.json").read_text(encoding="utf-8") + ) + return result + + +@pytest.fixture +def harness(tmp_path: Path) -> _Harness: + return _Harness(tmp_path) + + +# --------------------------------------------------------------------------- +# AC1 -- committed, repo-resident deploy path +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_script_exists_and_is_executable() -> None: + assert DEPLOY_SCRIPT.is_file(), "scripts/deploy-gateway.sh must exist (AC1)" + mode = DEPLOY_SCRIPT.stat().st_mode + assert mode & stat.S_IXUSR, "scripts/deploy-gateway.sh must be executable" + + +@pytest.mark.unit +def test_runbook_exists_alongside_cold_lane_bringup() -> None: + assert RUNBOOK.is_file(), ( + "docs/runbooks/gateway-lane-deploy.md must exist alongside " + "docs/runbooks/cold-lane-full-bringup.md (AC1)" + ) + + +@pytest.mark.unit +def test_help_documents_runbook() -> None: + result = subprocess.run( + ["bash", str(DEPLOY_SCRIPT), "--help"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ) + assert "OMN-15521" in result.stdout + + +@pytest.mark.unit +def test_print_compose_cmd_targets_repo_resident_compose_project( + harness: _Harness, +) -> None: + """AC1: the deploy path must target compose project omninode-gateway from + the REPO copy of docker-compose.gateway.yml -- never a hand-copied path. + """ + result = harness.run("--print-compose-cmd") + assert result.returncode == 0, result.stderr + assert "-p omninode-gateway" in result.stdout + assert "-f docker/docker-compose.gateway.yml" in result.stdout + assert "/opt/omninode/gateway" not in result.stdout + + +@pytest.mark.unit +def test_dry_run_default_performs_no_mutation(harness: _Harness) -> None: + """Bare invocation (no --execute) must not touch the host dir, env file, + or registry -- mirrors deploy-runtime.sh's dry-run-by-default contract. + """ + before_env = harness.env_file.read_text(encoding="utf-8") + result = harness.run() + assert result.returncode == 0, result.stderr + assert "Dry Run" in result.stdout + assert not harness.host_dir.exists() + assert harness.env_file.read_text(encoding="utf-8") == before_env + assert not (harness.registry_dir / "registry.json").exists() + assert not harness.docker_log.exists() or harness.docker_log.read_text() == "" + + +# --------------------------------------------------------------------------- +# AC2 -- OCI provenance labels +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_build_command_stamps_oci_provenance_build_args(harness: _Harness) -> None: + """AC2 mechanism: the constructed build invocation must carry the same + provenance build-args every omnibase-infra runtime container gets. This is + the root-cause fix for the ticket's own finding (`rev=(empty) + src=release` on the hand-built container) -- the original hand build never + passed these flags at all. + """ + result = harness.run("--print-compose-cmd") + assert result.returncode == 0, result.stderr + assert re.search(r"--build-arg VCS_REF=[0-9a-f]{12}", result.stdout) + assert re.search(r"--build-arg RUNTIME_VERSION=\d+\.\d+\.\d+", result.stdout) + assert "--build-arg COMPOSE_PROJECT=omninode-gateway" in result.stdout + assert re.search(r"--build-arg RUNTIME_SOURCE_HASH=[0-9a-f]{12}", result.stdout) + assert "--build-arg BUILD_SOURCE=release" in result.stdout + assert "--build-arg PROMOTION_CLASS=clean-main" in result.stdout + assert "--build-arg NON_MAIN_LINEAGE=false" in result.stdout + + +@pytest.mark.unit +def test_current_compose_file_declares_no_provenance_build_args() -> None: + """RED-before-the-fix control: docker-compose.gateway.yml's OWN declared + build.args block (what a bare `docker compose build` would use with no + extra flags -- i.e. exactly how the lane was hand-built on 2026-07-29) + carries only BUILD_SOURCE/EXPECTED_BUILD_SOURCE. This is why the running + container had an empty org.opencontainers.image.revision label; the fix + lives in the deploy script's explicit --build-arg list, not in the + compose file itself (matching how docker-compose.infra.yml + deploy- + runtime.sh's build_images() are already split the same way). + """ + compose_text = (REPO_ROOT / "docker" / "docker-compose.gateway.yml").read_text( + encoding="utf-8" + ) + build_block_match = re.search( + r"build:\n(.*?)\n container_name:", compose_text, re.DOTALL + ) + assert build_block_match is not None + build_block = build_block_match.group(1) + assert "VCS_REF" not in build_block + assert "COMPOSE_PROJECT" not in build_block + + +@pytest.mark.unit +def test_execute_deploy_produces_non_empty_image_labels(harness: _Harness) -> None: + """GREEN-after (mechanism level): after --execute, verify_deployment() + reads back a non-empty org.opencontainers.image.revision from the fake + docker inspect -- the exact probe cited in the ticket's AC2. + """ + result = harness.run( + "--execute", + GW_TEST_LABEL_REVISION="3541ac805b86", + GW_TEST_LABEL_BUILD_SOURCE="release", + GW_TEST_DELIVERY_PRESENT="1", + GW_TEST_SQLITE_PRESENT="1", + ) + assert result.returncode == 0, result.stderr + result.stdout + assert "AC2 OK: org.opencontainers.image.revision=3541ac805b86" in result.stdout + + +# --------------------------------------------------------------------------- +# AC3 -- the two OMN-12912 files present in the running container +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_verify_deployment_red_before_files_absent(harness: _Harness) -> None: + """RED: the ticket's own §2 probe state (pre-#2556 image) -- both files + absent -- must be surfaced as a hard failure, not silently accepted. + """ + result = harness.run( + "--execute", + GW_TEST_LABEL_REVISION="deadbeef0000", + GW_TEST_LABEL_BUILD_SOURCE="release", + GW_TEST_DELIVERY_PRESENT="0", + GW_TEST_SQLITE_PRESENT="0", + ) + assert result.returncode != 0 + assert "AC3 FAILED" in result.stdout + result.stderr + + +@pytest.mark.unit +def test_verify_deployment_green_after_files_present(harness: _Harness) -> None: + """GREEN: once the deployed image carries #2556, both files resolve and + the deploy reports success -- the exact AC3 probe flipping. + """ + result = harness.run( + "--execute", + GW_TEST_LABEL_REVISION="3541ac805b86", + GW_TEST_LABEL_BUILD_SOURCE="release", + GW_TEST_DELIVERY_PRESENT="1", + GW_TEST_SQLITE_PRESENT="1", + ) + assert result.returncode == 0, result.stderr + result.stdout + assert "AC3 OK" in result.stdout + + +# --------------------------------------------------------------------------- +# AC4 -- host compose file matches the merged-dev repo copy +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_sync_host_files_red_before_stale_copy_diverges(harness: _Harness) -> None: + """RED (exists-but-wrong, not merely absent): pre-populate the host dir + with a stale compose file -- exactly the 2026-07-29 hand-copy scenario, + where /opt/omninode/gateway/docker-compose.gateway.yml lacked #2556's + gateway-delivery-state volume. Before any sync, diff is non-empty. + """ + harness.host_dir.mkdir(parents=True) + (harness.host_dir / "docker-compose.gateway.yml").write_text( + "services: {}\n# stale pre-#2556 copy\n", encoding="utf-8" + ) + real_compose = (REPO_ROOT / "docker" / "docker-compose.gateway.yml").read_text( + encoding="utf-8" + ) + assert ( + harness.host_dir / "docker-compose.gateway.yml" + ).read_text() != real_compose, "fixture must actually diverge from the repo copy" + + +@pytest.mark.unit +def test_sync_host_files_green_after_diff_is_empty(harness: _Harness) -> None: + """GREEN: after --execute, the host copy is byte-identical to the repo + copy that produced the running container -- the exact AC4 probe. + """ + harness.host_dir.mkdir(parents=True) + (harness.host_dir / "docker-compose.gateway.yml").write_text( + "services: {}\n# stale pre-#2556 copy\n", encoding="utf-8" + ) + + result = harness.run( + "--execute", + GW_TEST_LABEL_REVISION="3541ac805b86", + GW_TEST_LABEL_BUILD_SOURCE="release", + GW_TEST_DELIVERY_PRESENT="1", + GW_TEST_SQLITE_PRESENT="1", + ) + assert result.returncode == 0, result.stderr + result.stdout + assert "AC4 OK" in result.stdout + + repo_compose = (REPO_ROOT / "docker" / "docker-compose.gateway.yml").read_text( + encoding="utf-8" + ) + host_compose = (harness.host_dir / "docker-compose.gateway.yml").read_text( + encoding="utf-8" + ) + assert host_compose == repo_compose + + repo_canary = ( + REPO_ROOT / "docker" / "gateway" / "beta-gateway-canary.yaml" + ).read_text(encoding="utf-8") + host_canary = (harness.host_dir / "gateway" / "beta-gateway-canary.yaml").read_text( + encoding="utf-8" + ) + assert host_canary == repo_canary + + +# --------------------------------------------------------------------------- +# AC6 -- rollback target recorded via registry.json +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +def test_execute_deploy_writes_registry_with_rollback_target( + harness: _Harness, +) -> None: + result = harness.run( + "--execute", + GW_TEST_LABEL_REVISION="3541ac805b86", + GW_TEST_LABEL_BUILD_SOURCE="release", + GW_TEST_DELIVERY_PRESENT="1", + GW_TEST_SQLITE_PRESENT="1", + GW_TEST_DIGEST_SEED="1", + ) + assert result.returncode == 0, result.stderr + result.stdout + + registry = harness.registry() + assert registry["compose_project"] == "omninode-gateway" + assert registry["active_digest"] == "sha256:" + ("0" * 63) + "1" + assert registry["previous_digest"] == "sha256:" + ("0" * 64) + assert registry["git_sha"] + assert registry["rollback_command"] + rollback_command = str(registry["rollback_command"]) + assert "GATEWAY_IMAGE=sha256:" + ("0" * 64) in rollback_command + + # A reload must actually have been requested. + assert harness.systemctl_log.exists() + assert ( + "systemctl reload onex-gateway-forwarder" in harness.systemctl_log.read_text() + ) + + +@pytest.mark.unit +def test_second_deploy_records_previous_digest_as_first_deploys_active( + harness: _Harness, +) -> None: + """The rollback target must chain: deploy #2's `previous_digest` must + equal deploy #1's `active_digest`, not the pre-existing gateway.env value + from before either deploy ran. + """ + first = harness.run( + "--execute", + GW_TEST_LABEL_REVISION="aaaaaaaaaaaa", + GW_TEST_LABEL_BUILD_SOURCE="release", + GW_TEST_DELIVERY_PRESENT="1", + GW_TEST_SQLITE_PRESENT="1", + GW_TEST_DIGEST_SEED="1", + ) + assert first.returncode == 0, first.stderr + first.stdout + first_registry = harness.registry() + + second = harness.run( + "--execute", + GW_TEST_LABEL_REVISION="bbbbbbbbbbbb", + GW_TEST_LABEL_BUILD_SOURCE="release", + GW_TEST_DELIVERY_PRESENT="1", + GW_TEST_SQLITE_PRESENT="1", + GW_TEST_DIGEST_SEED="2", + ) + assert second.returncode == 0, second.stderr + second.stdout + second_registry = harness.registry() + + assert second_registry["previous_digest"] == first_registry["active_digest"] + assert second_registry["active_digest"] != first_registry["active_digest"] + + +@pytest.mark.unit +def test_skip_reload_leaves_container_on_previous_digest(harness: _Harness) -> None: + """--skip-reload must still write gateway.env + registry but must NOT + invoke systemctl -- a deliberate escape hatch, not a silent no-op. + """ + result = harness.run( + "--execute", + "--skip-reload", + GW_TEST_LABEL_REVISION="3541ac805b86", + GW_TEST_LABEL_BUILD_SOURCE="release", + GW_TEST_DELIVERY_PRESENT="1", + GW_TEST_SQLITE_PRESENT="1", + ) + assert result.returncode == 0, result.stderr + result.stdout + assert ( + "gateway.env is updated but the running container still has the OLD digest" + in (result.stdout + result.stderr) + ) + assert not harness.systemctl_log.exists() or harness.systemctl_log.read_text() == "" + + +@pytest.mark.unit +def test_execute_without_env_file_fails_closed(harness: _Harness) -> None: + """AC1 fail-closed case: --execute must refuse (not silently proceed) + when GATEWAY_ENV_FILE is missing, rather than building/deploying with + unresolved compose interpolation variables. + """ + harness.env_file.unlink() + result = harness.run("--execute") + assert result.returncode != 0 + assert "GATEWAY_ENV_FILE not found" in result.stderr + assert not (harness.registry_dir / "registry.json").exists() From f91527bc231cdf54e17f36151ab8a69ad7b81f5e Mon Sep 17 00:00:00 2001 From: jonahgabriel Date: Sat, 1 Aug 2026 14:19:04 -0400 Subject: [PATCH 2/2] fix(OMN-15521): bash-3.2-portable array read (no readarray/mapfile) macOS ships /bin/bash 3.2.57 (Apple has not updated past 3.2 since bash moved to GPLv3) with no readarray/mapfile (bash 4+) and no local -n namerefs (bash 4.3+). The pre-push governed-selector hook invokes this script under plain bash, which resolves /bin/bash 3.2 in that non- interactive shell -- readarray failed there even though it silently worked under a newer bash picked up by an interactive PATH. Replaced with an inlined while-read-into-array loop at each of the two call sites, matching the rest of scripts/ (deploy-runtime.sh and siblings use no bash-4-only constructs). Re-verified: /bin/bash -n syntax check, a live /bin/bash --print-compose-cmd run, and the full governed-selector scope (scripts/tests/ tests/scripts/ tests/unit/scripts/, 1535 passed / 5 skipped) all green on this host. --- scripts/deploy-gateway.sh | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index 41091c2000..0af655fcec 100755 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -301,8 +301,8 @@ EOF build_compose_cmd() { # build_compose_cmd REPO_ROOT GIT_SHA VERSION -- prints (one token per - # line, for readarray consumption) the full 'docker compose ... build' - # argv. + # line, for _read_lines_into_array consumption) the full + # 'docker compose ... build' argv. local repo_root="$1" git_sha="$2" version="$3" printf 'docker\ncompose\n-p\n%s\n-f\ndocker/docker-compose.gateway.yml\nbuild\n--progress=plain\n' "${COMPOSE_PROJECT}" while IFS= read -r arg; do @@ -313,17 +313,29 @@ build_compose_cmd() { } print_compose_commands() { + # Portable stand-in for bash 4's `readarray`/`mapfile` (unavailable on + # macOS's shipped /bin/bash 3.2 -- Apple has not updated it past 3.2 since + # bash moved to GPLv3, and this script must run there like + # scripts/deploy-runtime.sh and its runtime_build siblings do). Namerefs + # (`local -n`, bash 4.3+) are equally unavailable, so this loop is + # inlined at each call site rather than factored into a helper. local repo_root="$1" git_sha="$2" version="$3" - local -a cmd - readarray -t cmd < <(build_compose_cmd "${repo_root}" "${git_sha}" "${version}") + local -a cmd=() + local line + while IFS= read -r line; do + cmd+=("${line}") + done < <(build_compose_cmd "${repo_root}" "${git_sha}" "${version}") log_step "Build command (compose project: ${COMPOSE_PROJECT})" log_cmd "GATEWAY_IMAGE=${BUILD_IMAGE_TAG} ${cmd[*]}" } build_image() { local repo_root="$1" git_sha="$2" version="$3" - local -a cmd - readarray -t cmd < <(build_compose_cmd "${repo_root}" "${git_sha}" "${version}") + local -a cmd=() + local line + while IFS= read -r line; do + cmd+=("${line}") + done < <(build_compose_cmd "${repo_root}" "${git_sha}" "${version}") log_step "Build Image" log_info "Building ${BUILD_IMAGE_TAG} with VCS_REF=${git_sha} RUNTIME_VERSION=${version}..."