chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,164 @@
|
||||
# `bin/railway`
|
||||
|
||||
Tagline: Ruby CLI for showcase Railway day-to-day ops — snapshot, restore,
|
||||
rollback, promote, pin, env-diff, resolve-digest, lint-prod. Mutating
|
||||
subcommands require typed `production` confirmation. For fleet auto-update
|
||||
config / new-service provisioning, see [`../RAILWAY.md`](../RAILWAY.md).
|
||||
|
||||
Single-file Ruby tooling for showcase Railway operations.
|
||||
|
||||
## Why
|
||||
|
||||
The Showcase platform lives on Railway across two environments (staging and
|
||||
production). Day-to-day operations — promoting staging to production, pinning
|
||||
services to immutable image digests, rolling a bad deploy back, auditing drift
|
||||
between envs — used to require ad-hoc shell + GraphQL recipes. `bin/railway`
|
||||
makes those operations first-class CLI subcommands with consistent flags,
|
||||
exit codes, and production protection.
|
||||
|
||||
## Install
|
||||
|
||||
None. Requires system Ruby 3.x (stdlib only — no Bundler, no Gemfile).
|
||||
|
||||
```sh
|
||||
showcase/bin/railway --help
|
||||
```
|
||||
|
||||
## Auth
|
||||
|
||||
The tool reads a Railway API token from (in order):
|
||||
|
||||
1. `RAILWAY_TOKEN` environment variable
|
||||
2. `~/.railway/config.json` (the `token` field, or `user.token`)
|
||||
|
||||
It never invokes `railway login`, `railway logout`, or `op`. If neither source
|
||||
yields a token, it exits with code 2 and a clear error.
|
||||
|
||||
For GHCR digest resolution (`resolve-digest`, `pin`), set `GHCR_TOKEN` if you
|
||||
need to read private packages; public packages work anonymously via the GHCR
|
||||
`/token` endpoint.
|
||||
|
||||
## Subcommands
|
||||
|
||||
| Subcommand | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `snapshot` | Capture an env's services + config into a YAML snapshot. |
|
||||
| `restore` | Restore an env to a snapshot (force-redeploy each service). |
|
||||
| `rollback` | Roll a single service back one deploy (or to a specific deployment id with `--to`). |
|
||||
| `rollback-commit` | Restore an env to the snapshot committed at a given git SHA. |
|
||||
| `promote` | Promote staging digests to production with prechecks. |
|
||||
| `pin` | Pin a service to a specific image digest. |
|
||||
| `env-diff` | Diff two envs; exits 1 on drift. |
|
||||
| `resolve-digest` | Resolve an image tag (e.g. `:latest`) to its `sha256:` digest. |
|
||||
| `lint-prod` | CI gate (advisory): warn if any prod service is not digest-pinned. `--exit-zero` for advisory mode, `--format json` for machine-readable output. |
|
||||
|
||||
Run any subcommand with `--help` for full flag list.
|
||||
|
||||
## Production protection
|
||||
|
||||
Every subcommand that mutates state requires both:
|
||||
|
||||
- `--yes` flag, **and**
|
||||
- typed confirmation of the literal string `production` on stdin
|
||||
|
||||
…before any production mutation runs. `--non-interactive` skips the prompt
|
||||
but still requires `--yes`. There is no way to mutate production without an
|
||||
explicit acknowledgement.
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
| ---- | ------------------------------------------------------------------------ |
|
||||
| 0 | Clean / success |
|
||||
| 1 | Drift detected, findings reported, or promote refused for policy reasons |
|
||||
| 2 | Error (auth, network, GraphQL schema, refused confirmation, etc.) |
|
||||
|
||||
## Worked example: promote staging → production
|
||||
|
||||
```sh
|
||||
# 1. Audit drift first (read-only).
|
||||
showcase/bin/railway env-diff staging production
|
||||
# DRIFT: 3 finding(s)
|
||||
# service showcase-shell: digest sha256:abc != sha256:def
|
||||
# ...
|
||||
|
||||
# 2. Lint prod to confirm baseline is pinned.
|
||||
showcase/bin/railway lint-prod
|
||||
# OK: all production services digest-pinned.
|
||||
|
||||
# 3. Capture a "before" snapshot in case we need to roll back.
|
||||
showcase/bin/railway snapshot --env production --output before-promote.yaml
|
||||
|
||||
# 4. Run the promote with prechecks. Production confirmation prompt fires here.
|
||||
showcase/bin/railway promote --yes
|
||||
# Type 'production' to confirm promote: production
|
||||
# promoted showcase-shell -> ghcr.io/copilotkit/showcase-shell@sha256:def...
|
||||
# ...
|
||||
|
||||
# If anything goes sideways:
|
||||
showcase/bin/railway restore --env production --snapshot before-promote.yaml --yes
|
||||
```
|
||||
|
||||
## CI integration
|
||||
|
||||
`.github/workflows/showcase_lint_prod.yml` runs `bin/railway lint-prod` on
|
||||
every PR that touches `showcase/**`.
|
||||
|
||||
**Currently advisory** — the workflow passes `--exit-zero`, so findings print
|
||||
to the job log but do not fail the PR. This lets us soak the check against
|
||||
real production state before turning it into a hard gate. Once we've built
|
||||
confidence the findings are clean, remove `--exit-zero` from the workflow to
|
||||
flip the check to enforcing (exit 1 on drift).
|
||||
|
||||
Long-term contract: every production service must be pinned to an immutable
|
||||
`ghcr.io/...@sha256:...` digest, and the lint job will fail any PR that
|
||||
drifts away from that.
|
||||
|
||||
### Visibility surfaces
|
||||
|
||||
Two human-facing surfaces render the audit result every run:
|
||||
|
||||
1. **Workflow step summary** — the workflow writes a structured markdown
|
||||
block to `$GITHUB_STEP_SUMMARY` so the audit shows at the top of every
|
||||
run page (every event: `pull_request`, `push`, `workflow_dispatch`).
|
||||
2. **Sticky PR comment** — on `pull_request` events, the workflow posts (or
|
||||
updates) a single comment per PR. The comment is keyed by the HTML marker
|
||||
`<!-- lint-prod-sticky-comment -->` so re-runs update the same comment
|
||||
instead of creating duplicates.
|
||||
|
||||
Both surfaces show the same content: a one-line status, a table of the
|
||||
unpinned services (only — `pinned` services are not enumerated), and a
|
||||
Pacific-time run timestamp with the finding count.
|
||||
|
||||
### Machine-readable output
|
||||
|
||||
`lint-prod --format json` emits:
|
||||
|
||||
```json
|
||||
{
|
||||
"services": [
|
||||
{ "name": "...", "source": "...", "status": "pinned|mutable-tag" }
|
||||
],
|
||||
"findings": 3,
|
||||
"timestamp": "2026-05-27T18:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
The CI workflow uses this shape to render the step summary and PR comment.
|
||||
The `findings` count is also written to `$GITHUB_OUTPUT` so downstream jobs
|
||||
(e.g. a future Slack alert step) can compare against prior runs.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
ruby showcase/bin/spec/all_tests.rb
|
||||
```
|
||||
|
||||
Tests are minitest (stdlib). They cover:
|
||||
|
||||
- argv parsing per subcommand
|
||||
- snapshot YAML round-trip
|
||||
- GHCR digest-resolution decision tree (mocked HTTP)
|
||||
- production-protection prompt behavior
|
||||
|
||||
No Railway / GHCR network calls are made during tests.
|
||||
Executable
+3032
File diff suppressed because it is too large
Load Diff
Executable
+218
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env bash
|
||||
# showcase — unified CLI for the CopilotKit showcase platform.
|
||||
#
|
||||
# Dispatches to built-in compose commands (up, down, build, ps, ports, logs)
|
||||
# and to plugin commands defined in scripts/cli/cmd-*.sh files.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Bootstrap ────────────────────────────────────────────────────────────────
|
||||
|
||||
BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SHOWCASE_ROOT="$(cd "$BIN_DIR/.." && pwd)"
|
||||
export SHOWCASE_ROOT
|
||||
|
||||
# shellcheck source=../scripts/cli/_common.sh
|
||||
source "$SHOWCASE_ROOT/scripts/cli/_common.sh"
|
||||
|
||||
# ── Auto-discover plugin commands (cmd-*.sh) ─────────────────────────────────
|
||||
|
||||
# Plugin names and their descriptions, stored as parallel arrays for bash 3
|
||||
# compatibility (no associative arrays on macOS default bash).
|
||||
_plugin_names=()
|
||||
_plugin_descs=()
|
||||
|
||||
for cmd_file in "$SHOWCASE_ROOT"/scripts/cli/cmd-*.sh; do
|
||||
[ -f "$cmd_file" ] || continue
|
||||
# shellcheck disable=SC1090
|
||||
source "$cmd_file"
|
||||
# Extract command name: cmd-foo-bar.sh → foo-bar
|
||||
_name="$(basename "$cmd_file" .sh)"
|
||||
_name="${_name#cmd-}"
|
||||
_plugin_names+=("$_name")
|
||||
# Each cmd file should define CMD_<NAME>_DESC (dashes→underscores, uppercased)
|
||||
# e.g. cmd-aimock-rebuild.sh defines CMD_AIMOCK_REBUILD_DESC
|
||||
_desc_var="CMD_${_name//-/_}"
|
||||
_desc_var="$(echo "$_desc_var" | tr '[:lower:]' '[:upper:]')_DESC"
|
||||
_plugin_descs+=("${!_desc_var:-}")
|
||||
done
|
||||
|
||||
# ── Built-in commands ────────────────────────────────────────────────────────
|
||||
|
||||
cmd_up() {
|
||||
require_env
|
||||
trap restore_symlinks EXIT
|
||||
stage_shared
|
||||
|
||||
# Fleet topology: bring up the SAME shape prod runs — the control-plane
|
||||
# container + worker container(s) + PocketBase + demos + aimock — NOT an
|
||||
# in-process pool. The harness control-plane and pool worker(s) live in the
|
||||
# `infra` profile (alongside aimock/pocketbase/dashboard), so always include
|
||||
# `--profile infra`.
|
||||
#
|
||||
# Demo slugs (bare args like `langgraph-python`) select that demo's compose
|
||||
# PROFILE — they must become `--profile <slug>` flags, NOT positional service
|
||||
# names. A positional service arg restricts `up` to only that service + its
|
||||
# deps, which (a) leaves the demo's own profile disabled and (b) makes
|
||||
# `--scale harness-pool-worker=N` fail with "no such service:
|
||||
# harness-pool-worker: disabled" because the worker is no longer in the active
|
||||
# set. Raw compose flags (anything starting with `-`, e.g. `--no-build`) are
|
||||
# passed through verbatim.
|
||||
#
|
||||
# HARNESS_POOL_COUNT drives the worker fleet size. Local default is 1 (a
|
||||
# single worker container); staging/prod export HARNESS_POOL_COUNT=2. The
|
||||
# compose `harness-pool-worker` service is scaled to that count via
|
||||
# `--scale` so the same compose file scales from local (1) to prod (N)
|
||||
# purely by the env var — no per-environment compose edits.
|
||||
local pool_count="${HARNESS_POOL_COUNT:-1}"
|
||||
export HARNESS_POOL_COUNT="$pool_count"
|
||||
|
||||
# Split args: bare slugs → `--profile <slug>` + build target; flags (-*) →
|
||||
# pass through. `--dev` is intercepted (not passed to compose): it layers
|
||||
# the dev overlay (docker-compose.dev.yml) which bind-mounts integration
|
||||
# source + overrides the run command to a hot-reload entrypoint — fast
|
||||
# iteration without an image rebuild. The built-image mode (no --dev) stays
|
||||
# the faithful default.
|
||||
local profile_args=()
|
||||
local build_targets=()
|
||||
local passthrough_args=()
|
||||
local dev_mode=false
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
if [[ "$arg" == "--dev" ]]; then
|
||||
dev_mode=true
|
||||
elif [[ "$arg" == -* ]]; then
|
||||
passthrough_args+=("$arg")
|
||||
else
|
||||
profile_args+=(--profile "$arg")
|
||||
build_targets+=("$arg")
|
||||
fi
|
||||
done
|
||||
|
||||
local compose_cmd="$COMPOSE_CMD"
|
||||
if $dev_mode; then
|
||||
compose_cmd="$compose_cmd -f $SHOWCASE_ROOT/docker-compose.dev.yml"
|
||||
info "DEV MODE: bind-mounting integration source + hot reload (NOT the faithful built-image path)"
|
||||
fi
|
||||
|
||||
info "Launching fleet topology: control-plane + ${pool_count} worker(s) + PocketBase + demos + aimock (HARNESS_POOL_COUNT=${pool_count})"
|
||||
# Two-call strategy to preserve A21's BuildKit-contention fix (target-only
|
||||
# rebuild) WITHOUT regressing infra startup that A21 inadvertently dropped
|
||||
# (A21b, issue #5495).
|
||||
#
|
||||
# docker compose semantics: positional service names after `up` restrict
|
||||
# WHICH services start to the named ones + their `depends_on` chain — not
|
||||
# just which ones get `--build`-rebuilt. So a single
|
||||
# `up -d --build <slug>` only brings up the slug + its depends_on (aimock),
|
||||
# leaving the rest of the infra profile (pocketbase/dashboard/harness/
|
||||
# harness-pool-worker) down. Under `--isolate` with a sibling stack on the
|
||||
# same host ports, health checks then cross onto foreign containers and
|
||||
# cells silently misroute → 0.0s red.
|
||||
#
|
||||
# Fix: when slugs are present, emit TWO compose calls:
|
||||
# 1. <profiles> up -d (no --build, no positional services) — start all
|
||||
# services in the active profiles using cached images.
|
||||
# 2. <profiles> up -d --build <slug...> — force-rebuild ONLY the named
|
||||
# services and ensure they're up. Other services from call (1) are
|
||||
# no-ops.
|
||||
# When no slugs are present (infra-only bring-up), keep the single blanket
|
||||
# `--build` call so first-time bootstrap still builds missing images.
|
||||
#
|
||||
# bash 3.2 (macOS default) errors on `${arr[@]}` for an EMPTY array under
|
||||
# `set -u`; the `${arr[@]+...}` guard expands to nothing when unset/empty.
|
||||
if [[ ${#build_targets[@]} -gt 0 ]]; then
|
||||
# Call 1: bring up ALL services in active profiles, no build.
|
||||
$compose_cmd --profile infra ${profile_args[@]+"${profile_args[@]}"} up -d \
|
||||
--scale harness-pool-worker="$pool_count" ${passthrough_args[@]+"${passthrough_args[@]}"}
|
||||
# Call 2: rebuild ONLY the targeted slug(s) and ensure they're up.
|
||||
$compose_cmd --profile infra ${profile_args[@]+"${profile_args[@]}"} up -d --build \
|
||||
--scale harness-pool-worker="$pool_count" ${passthrough_args[@]+"${passthrough_args[@]}"} \
|
||||
"${build_targets[@]}"
|
||||
else
|
||||
# Infra-only: single call with blanket --build for first-time bootstrap.
|
||||
$compose_cmd --profile infra up -d --build \
|
||||
--scale harness-pool-worker="$pool_count" ${passthrough_args[@]+"${passthrough_args[@]}"}
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_down() {
|
||||
$COMPOSE_CMD down "$@"
|
||||
}
|
||||
|
||||
cmd_build() {
|
||||
trap restore_symlinks EXIT
|
||||
stage_shared
|
||||
$COMPOSE_CMD build "$@"
|
||||
}
|
||||
|
||||
cmd_ps() {
|
||||
$COMPOSE_CMD ps "$@"
|
||||
}
|
||||
|
||||
cmd_ports() {
|
||||
if command -v jq &>/dev/null; then
|
||||
jq -r 'to_entries[] | "\(.key)\t→ localhost:\(.value)"' "$PORTS_FILE"
|
||||
else
|
||||
cat "$PORTS_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Usage ────────────────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
cat <<'HEADER'
|
||||
Usage: showcase <command> [options]
|
||||
|
||||
Core commands:
|
||||
up [slug...] Start containers (rebuilds if source changed)
|
||||
--dev bind-mount integration source + hot reload
|
||||
(fast iteration; NOT the faithful built-image path)
|
||||
down [slug...] Stop containers
|
||||
build [slug...] Build Docker images
|
||||
ps Show running containers
|
||||
ports Print slug → host port mapping
|
||||
HEADER
|
||||
|
||||
# Print plugin commands if any are loaded
|
||||
if [ ${#_plugin_names[@]} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "Plugin commands:"
|
||||
local i
|
||||
for i in "${!_plugin_names[@]}"; do
|
||||
printf " %-17s %s\n" "${_plugin_names[$i]}" "${_plugin_descs[$i]}"
|
||||
done
|
||||
fi
|
||||
|
||||
cat <<'FOOTER'
|
||||
|
||||
Run 'showcase <command> --help' for details on a specific command.
|
||||
FOOTER
|
||||
}
|
||||
|
||||
# ── Dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
subcmd="${1:-}"
|
||||
shift || true
|
||||
|
||||
case "$subcmd" in
|
||||
""|"-h"|"--help"|"help")
|
||||
usage
|
||||
[ -z "$subcmd" ] && exit 1
|
||||
exit 0
|
||||
;;
|
||||
eval)
|
||||
exec npx tsx "$SHOWCASE_ROOT/harness/src/cli.ts" eval "$@"
|
||||
;;
|
||||
*)
|
||||
# Convert dashes to underscores for function lookup: foo-bar → cmd_foo_bar
|
||||
func_name="cmd_${subcmd//-/_}"
|
||||
if type "$func_name" 2>/dev/null | head -1 | grep -q 'function'; then
|
||||
"$func_name" "$@"
|
||||
else
|
||||
echo "Unknown command: $subcmd" >&2
|
||||
echo ""
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Entry point for bin/railway minitest suite. Discovers and runs every test_*.rb
|
||||
# in this directory.
|
||||
|
||||
$LOAD_PATH.unshift(File.expand_path("..", __dir__))
|
||||
$LOAD_PATH.unshift(__dir__)
|
||||
|
||||
require "minitest/autorun"
|
||||
|
||||
Dir.glob(File.join(__dir__, "test_*.rb")).sort.each { |f| require f }
|
||||
@@ -0,0 +1,11 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Helper that loads bin/railway as a library (so we can access Railway:: classes
|
||||
# without invoking the CLI).
|
||||
|
||||
require "minitest/autorun"
|
||||
|
||||
# Stub $PROGRAM_NAME so the bottom-of-file invocation guard is skipped.
|
||||
unless defined?(::Railway)
|
||||
load File.expand_path("../railway", __dir__)
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
require "stringio"
|
||||
|
||||
class CLIParsingTest < Minitest::Test
|
||||
def test_usage_lists_all_nine_subcommands
|
||||
out = Railway.usage
|
||||
%w[snapshot restore rollback rollback-commit promote pin env-diff
|
||||
resolve-digest lint-prod].each do |sub|
|
||||
assert_includes out, sub, "usage missing subcommand: #{sub}"
|
||||
end
|
||||
end
|
||||
|
||||
def test_help_returns_zero
|
||||
rc = nil
|
||||
silence_io { rc = Railway.run(["--help"]) }
|
||||
assert_equal 0, rc
|
||||
end
|
||||
|
||||
def test_version_returns_zero
|
||||
rc = nil
|
||||
silence_io { rc = Railway.run(["--version"]) }
|
||||
assert_equal 0, rc
|
||||
end
|
||||
|
||||
def test_unknown_subcommand_returns_2
|
||||
rc = nil
|
||||
silence_io { rc = Railway.run(["definitely-not-a-cmd"]) }
|
||||
assert_equal 2, rc
|
||||
end
|
||||
|
||||
def test_snapshot_command_parses_env_and_output
|
||||
c = Railway::SnapshotCommand.new(["--env", "staging", "--output", "/tmp/x.yaml"])
|
||||
c.parser.parse!(c.argv)
|
||||
assert_equal "staging", c.options[:env]
|
||||
assert_equal "/tmp/x.yaml", c.options[:output]
|
||||
end
|
||||
|
||||
def test_restore_command_requires_env_and_snapshot
|
||||
c = Railway::RestoreCommand.new([])
|
||||
ex = nil
|
||||
silence_io { ex = assert_raises(SystemExit) { c.run } }
|
||||
assert_equal 2, ex.status
|
||||
end
|
||||
|
||||
def test_rollback_command_parses_to_flag
|
||||
c = Railway::RollbackCommand.new(["--env", "staging", "--service", "showcase-shell", "--to", "dep-123"])
|
||||
c.parser.parse!(c.argv)
|
||||
assert_equal "dep-123", c.options[:to]
|
||||
end
|
||||
|
||||
def test_envdiff_requires_two_args
|
||||
c = Railway::EnvDiffCommand.new(["staging"])
|
||||
ex = nil
|
||||
silence_io { ex = assert_raises(SystemExit) { c.run } }
|
||||
assert_equal 2, ex.status
|
||||
end
|
||||
|
||||
def test_promote_flags_parse
|
||||
c = Railway::PromoteCommand.new(["--confirm-divergence", "--yes", "--dry-run"])
|
||||
c.parser.parse!(c.argv)
|
||||
assert c.options[:confirm_divergence]
|
||||
assert c.options[:yes]
|
||||
assert c.options[:dry_run]
|
||||
end
|
||||
|
||||
def test_resolve_digest_requires_arg
|
||||
c = Railway::ResolveDigestCommand.new([])
|
||||
ex = nil
|
||||
silence_io { ex = assert_raises(SystemExit) { c.run } }
|
||||
assert_equal 2, ex.status
|
||||
end
|
||||
|
||||
def test_lint_prod_parses_format_and_exit_zero
|
||||
c = Railway::LintProdCommand.new(["--exit-zero", "--format", "json"])
|
||||
c.parser.parse!(c.argv)
|
||||
assert_equal true, c.instance_variable_get(:@exit_zero)
|
||||
assert_equal "json", c.instance_variable_get(:@format)
|
||||
end
|
||||
|
||||
def test_lint_prod_rejects_invalid_format
|
||||
c = Railway::LintProdCommand.new(["--format", "yaml"])
|
||||
assert_raises(OptionParser::InvalidArgument) { c.parser.parse!(c.argv) }
|
||||
end
|
||||
|
||||
def test_lint_prod_defaults_format_to_text
|
||||
c = Railway::LintProdCommand.new([])
|
||||
c.parser.parse!(c.argv)
|
||||
assert_equal "text", c.instance_variable_get(:@format)
|
||||
assert_equal false, c.instance_variable_get(:@exit_zero)
|
||||
end
|
||||
|
||||
def test_env_id_for_resolves_aliases
|
||||
assert_equal Railway::PRODUCTION_ENV_ID, Railway.env_id_for("production")
|
||||
assert_equal Railway::PRODUCTION_ENV_ID, Railway.env_id_for("prod")
|
||||
assert_equal Railway::STAGING_ENV_ID, Railway.env_id_for("staging")
|
||||
assert_equal Railway::STAGING_ENV_ID, Railway.env_id_for("stage")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def silence_io
|
||||
orig_stdout, orig_stderr = $stdout, $stderr
|
||||
$stdout = StringIO.new
|
||||
$stderr = StringIO.new
|
||||
yield
|
||||
ensure
|
||||
$stdout = orig_stdout
|
||||
$stderr = orig_stderr
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,172 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# EnvDiffCommand compares two env snapshots and reports drift. Historically it
|
||||
# diffed digest, startCommand, and env-var KEY sets — but the banner also
|
||||
# advertised "custom domains", which the diff loop never actually compared.
|
||||
# These tests pin the custom-domain comparison so the banner is honest:
|
||||
#
|
||||
# 1. Identical custom_domains across both envs => no drift line.
|
||||
# 2. Differing custom_domains => a drift line that names the service and the
|
||||
# offending domains, in both directions (missing-in-a / missing-in-b).
|
||||
#
|
||||
# We exercise the pure diff helper (diff_services) directly so the test does
|
||||
# not need to stand up Railway GraphQL — the helper takes two already-built
|
||||
# snapshots and returns the drift array.
|
||||
class EnvDiffTest < Minitest::Test
|
||||
def snapshot_with(domains_a: [], domains_b: [])
|
||||
# Two single-service snapshots that agree on everything EXCEPT the
|
||||
# custom_domains set, so any drift surfaced is attributable to domains.
|
||||
snap_a = {
|
||||
"services" => [
|
||||
{
|
||||
"name" => "showcase-shell",
|
||||
"digest" => "sha256:abc",
|
||||
"start_command" => nil,
|
||||
"env_keys" => %w[PORT NODE_ENV],
|
||||
"custom_domains" => domains_a,
|
||||
},
|
||||
],
|
||||
}
|
||||
snap_b = {
|
||||
"services" => [
|
||||
{
|
||||
"name" => "showcase-shell",
|
||||
"digest" => "sha256:abc",
|
||||
"start_command" => nil,
|
||||
"env_keys" => %w[PORT NODE_ENV],
|
||||
"custom_domains" => domains_b,
|
||||
},
|
||||
],
|
||||
}
|
||||
[snap_a, snap_b]
|
||||
end
|
||||
|
||||
def test_identical_custom_domains_no_drift
|
||||
snap_a, snap_b = snapshot_with(
|
||||
domains_a: ["shell.copilotkit.ai"],
|
||||
domains_b: ["shell.copilotkit.ai"],
|
||||
)
|
||||
cmd = Railway::EnvDiffCommand.new(%w[staging production])
|
||||
drift = cmd.diff_services(snap_a, snap_b, "staging", "production")
|
||||
assert_empty drift
|
||||
end
|
||||
|
||||
def test_differing_custom_domains_reported_both_directions
|
||||
snap_a, snap_b = snapshot_with(
|
||||
domains_a: ["only-in-staging.copilotkit.ai"],
|
||||
domains_b: ["only-in-prod.copilotkit.ai"],
|
||||
)
|
||||
cmd = Railway::EnvDiffCommand.new(%w[staging production])
|
||||
drift = cmd.diff_services(snap_a, snap_b, "staging", "production")
|
||||
|
||||
# One finding per direction, each naming the offending domain.
|
||||
missing_in_b = drift.find { |l| l.include?("custom domains missing in production") }
|
||||
missing_in_a = drift.find { |l| l.include?("custom domains missing in staging") }
|
||||
assert missing_in_b, "expected a 'missing in production' finding, got: #{drift.inspect}"
|
||||
assert missing_in_a, "expected a 'missing in staging' finding, got: #{drift.inspect}"
|
||||
assert_includes missing_in_b, "only-in-staging.copilotkit.ai"
|
||||
assert_includes missing_in_a, "only-in-prod.copilotkit.ai"
|
||||
end
|
||||
|
||||
# A service that exists in one snapshot but not the other must surface a
|
||||
# "missing in <env>" drift line. This also exercises the missing-service
|
||||
# branch that was previously untested.
|
||||
def test_service_missing_in_one_env_reported
|
||||
snap_a = {
|
||||
"services" => [
|
||||
{ "name" => "showcase-shell", "digest" => "sha256:abc",
|
||||
"start_command" => nil, "env_keys" => %w[PORT], "custom_domains" => [] },
|
||||
{ "name" => "extra-svc", "digest" => "sha256:def",
|
||||
"start_command" => nil, "env_keys" => %w[PORT], "custom_domains" => [] },
|
||||
],
|
||||
}
|
||||
snap_b = {
|
||||
"services" => [
|
||||
{ "name" => "showcase-shell", "digest" => "sha256:abc",
|
||||
"start_command" => nil, "env_keys" => %w[PORT], "custom_domains" => [] },
|
||||
],
|
||||
}
|
||||
cmd = Railway::EnvDiffCommand.new(%w[staging production])
|
||||
drift = cmd.diff_services(snap_a, snap_b, "staging", "production")
|
||||
missing = drift.find { |l| l.include?("extra-svc") && l.include?("missing in production") }
|
||||
assert missing, "expected 'extra-svc missing in production', got: #{drift.inspect}"
|
||||
end
|
||||
|
||||
# A snapshot lacking a "services" key must not raise NoMethodError. NOTE:
|
||||
# because the missing "services" key makes find_service return nil for the
|
||||
# one service that exists only in snap_b, this case exercises ONLY the
|
||||
# top-level `["services"] || []` guard and the missing-service branch — it
|
||||
# `next`s before ever reaching the env_keys / custom_domains comparison.
|
||||
# The env_keys/custom_domains accessors are pinned separately below.
|
||||
def test_snapshot_without_services_key_does_not_crash
|
||||
snap_a = {} # no "services" key at all
|
||||
snap_b = {
|
||||
"services" => [
|
||||
{ "name" => "showcase-shell", "digest" => "sha256:abc",
|
||||
"start_command" => nil, "env_keys" => %w[PORT], "custom_domains" => [] },
|
||||
],
|
||||
}
|
||||
cmd = Railway::EnvDiffCommand.new(%w[staging production])
|
||||
drift = cmd.diff_services(snap_a, snap_b, "staging", "production")
|
||||
missing = drift.find { |l| l.include?("showcase-shell") && l.include?("missing in staging") }
|
||||
assert missing, "expected 'showcase-shell missing in staging', got: #{drift.inspect}"
|
||||
end
|
||||
|
||||
# A service present in BOTH snapshots where one side OMITS "env_keys"
|
||||
# (e.g. a v1/partial/hand-edited snapshot read from a git SHA) must not
|
||||
# raise NoMethodError. This genuinely reaches the env_keys comparison
|
||||
# branch (find_service returns the service on both sides, so the method
|
||||
# does NOT `next`). Without an `|| []` guard, `sa["env_keys"] - sb[...]`
|
||||
# raises `undefined method '-' for nil`.
|
||||
def test_service_without_env_keys_does_not_crash
|
||||
snap_a = {
|
||||
"services" => [
|
||||
# No "env_keys" key at all on this side.
|
||||
{ "name" => "showcase-shell", "digest" => "sha256:abc",
|
||||
"start_command" => nil, "custom_domains" => [] },
|
||||
],
|
||||
}
|
||||
snap_b = {
|
||||
"services" => [
|
||||
{ "name" => "showcase-shell", "digest" => "sha256:abc",
|
||||
"start_command" => nil, "env_keys" => %w[PORT NODE_ENV],
|
||||
"custom_domains" => [] },
|
||||
],
|
||||
}
|
||||
cmd = Railway::EnvDiffCommand.new(%w[staging production])
|
||||
drift = cmd.diff_services(snap_a, snap_b, "staging", "production")
|
||||
# snap_a is missing both keys present in snap_b, so they must be
|
||||
# reported as "missing in staging" (the a-side env).
|
||||
missing_in_a = drift.find { |l| l.include?("env keys missing in staging") }
|
||||
assert missing_in_a, "expected an 'env keys missing in staging' finding, got: #{drift.inspect}"
|
||||
assert_includes missing_in_a, "PORT"
|
||||
assert_includes missing_in_a, "NODE_ENV"
|
||||
end
|
||||
|
||||
# Symmetric to the env_keys case: a service present in BOTH snapshots where
|
||||
# one side OMITS "custom_domains" must not raise NoMethodError when the
|
||||
# other side has domains to diff against. Pins the custom_domains guard at
|
||||
# the comparison branch (not just the missing-service branch).
|
||||
def test_service_without_custom_domains_does_not_crash
|
||||
snap_a = {
|
||||
"services" => [
|
||||
{ "name" => "showcase-shell", "digest" => "sha256:abc",
|
||||
"start_command" => nil, "env_keys" => %w[PORT] },
|
||||
],
|
||||
}
|
||||
snap_b = {
|
||||
"services" => [
|
||||
{ "name" => "showcase-shell", "digest" => "sha256:abc",
|
||||
"start_command" => nil, "env_keys" => %w[PORT],
|
||||
"custom_domains" => ["shell.copilotkit.ai"] },
|
||||
],
|
||||
}
|
||||
cmd = Railway::EnvDiffCommand.new(%w[staging production])
|
||||
drift = cmd.diff_services(snap_a, snap_b, "staging", "production")
|
||||
missing_in_a = drift.find { |l| l.include?("custom domains missing in staging") }
|
||||
assert missing_in_a, "expected a 'custom domains missing in staging' finding, got: #{drift.inspect}"
|
||||
assert_includes missing_in_a, "shell.copilotkit.ai"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,53 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
require "json"
|
||||
|
||||
# Parity test: Ruby's Railway::EXPECTED_DOMAINS (derived at load time from
|
||||
# showcase/scripts/railway-envs.generated.json) MUST equal the public-host
|
||||
# subset computed directly from the same TS SSOT artifact. If this test fails,
|
||||
# either the JSON artifact is stale or bin/railway's derivation logic drifted
|
||||
# from the TS SSOT shape.
|
||||
class ExpectedDomainsParityTest < Minitest::Test
|
||||
SSOT_JSON = File.expand_path("../../scripts/railway-envs.generated.json", __dir__)
|
||||
|
||||
def test_generated_json_exists
|
||||
assert File.exist?(SSOT_JSON),
|
||||
"expected SSOT artifact at #{SSOT_JSON} — run " \
|
||||
"`npx tsx showcase/scripts/emit-railway-envs-json.ts`"
|
||||
end
|
||||
|
||||
def test_ruby_expected_domains_matches_ts_ssot_public_hosts
|
||||
data = JSON.parse(File.read(SSOT_JSON))
|
||||
prod_env_id = data.fetch("envIds").fetch("prod")
|
||||
staging_env_id = data.fetch("envIds").fetch("staging")
|
||||
|
||||
expected_prod = data.fetch("services")
|
||||
.map { |s| s.fetch("domains").fetch("prod") }
|
||||
.reject { |h| h.end_with?(".up.railway.app") }
|
||||
.sort
|
||||
.uniq
|
||||
expected_staging = data.fetch("services")
|
||||
.map { |s| s.fetch("domains").fetch("staging") }
|
||||
.reject { |h| h.end_with?(".up.railway.app") }
|
||||
.sort
|
||||
.uniq
|
||||
|
||||
# Sanity: the env-id constants in the Ruby file must match the SSOT.
|
||||
assert_equal prod_env_id, Railway::PRODUCTION_ENV_ID,
|
||||
"PRODUCTION_ENV_ID drifted from SSOT envIds.prod"
|
||||
assert_equal staging_env_id, Railway::STAGING_ENV_ID,
|
||||
"STAGING_ENV_ID drifted from SSOT envIds.staging"
|
||||
|
||||
actual = Railway::EXPECTED_DOMAINS
|
||||
assert_equal expected_prod, actual.fetch(prod_env_id).sort,
|
||||
"Ruby EXPECTED_DOMAINS[prod] != TS SSOT public prod hosts"
|
||||
assert_equal expected_staging, actual.fetch(staging_env_id).sort,
|
||||
"Ruby EXPECTED_DOMAINS[staging] != TS SSOT public staging hosts"
|
||||
end
|
||||
|
||||
def test_expected_domains_keys_are_only_prod_and_staging_env_ids
|
||||
keys = Railway::EXPECTED_DOMAINS.keys.sort
|
||||
assert_equal [Railway::PRODUCTION_ENV_ID, Railway::STAGING_ENV_ID].sort, keys
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,123 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# Proves bearer_for ALWAYS performs the GHCR /token exchange instead of
|
||||
# returning a raw GitHub token. GHCR's OCI manifest endpoint rejects a raw
|
||||
# GitHub Actions token with HTTP 403 — only a bearer minted via the /token
|
||||
# exchange is accepted. When a token is present the exchange MUST authenticate
|
||||
# with Basic auth (base64("x-access-token:<token>")); for public packages the
|
||||
# exchange also succeeds anonymously.
|
||||
class GHCRBearerTest < Minitest::Test
|
||||
# Recording fake HTTP layer: captures every (method, url, headers) call so
|
||||
# tests can assert what was actually sent on the wire.
|
||||
class RecordingHTTP
|
||||
attr_reader :calls
|
||||
|
||||
def initialize(responses)
|
||||
@responses = responses
|
||||
@calls = []
|
||||
end
|
||||
|
||||
def call(method:, url:, headers: {})
|
||||
@calls << { method: method, url: url, headers: headers }
|
||||
r = @responses[[method, url]] || @responses[url]
|
||||
raise "no fake response for #{[method, url].inspect}" unless r
|
||||
r
|
||||
end
|
||||
end
|
||||
|
||||
DIGEST = "sha256:cafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
|
||||
REF = "ghcr.io/copilotkit/showcase-shell@#{DIGEST}"
|
||||
MANIFEST = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/#{DIGEST}"
|
||||
TOKEN_URL = "https://ghcr.io/token?service=ghcr.io&scope=repository:copilotkit/showcase-shell:pull"
|
||||
RAW_TOKEN = "ghs_rawGitHubActionsToken"
|
||||
MINTED = "minted-bearer-from-exchange"
|
||||
|
||||
def fakes(extra = {})
|
||||
RecordingHTTP.new({
|
||||
TOKEN_URL => { status: 200, headers: {}, body: %({"token":"#{MINTED}"}) },
|
||||
MANIFEST => { status: 200, headers: {}, body: "" },
|
||||
}.merge(extra))
|
||||
end
|
||||
|
||||
def test_token_present_performs_basic_auth_exchange
|
||||
http = fakes
|
||||
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
|
||||
assert_equal :exists, g.manifest_exists(REF)
|
||||
|
||||
token_call = http.calls.find { |c| c[:method] == :get && c[:url] == TOKEN_URL }
|
||||
refute_nil token_call, "bearer_for must hit the GHCR /token exchange even when a token is present"
|
||||
|
||||
expected_basic = "Basic " + ["x-access-token:#{RAW_TOKEN}"].pack("m0")
|
||||
auth = token_call[:headers]["Authorization"]
|
||||
assert_equal expected_basic, auth,
|
||||
"token exchange must authenticate with Basic base64(x-access-token:<token>)"
|
||||
end
|
||||
|
||||
def test_manifest_read_uses_minted_bearer_not_raw_token
|
||||
http = fakes
|
||||
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
|
||||
g.manifest_exists(REF)
|
||||
|
||||
manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST }
|
||||
refute_nil manifest_call
|
||||
assert_equal "Bearer #{MINTED}", manifest_call[:headers]["Authorization"],
|
||||
"manifest read must use the minted bearer, never the raw GitHub token"
|
||||
refute_equal "Bearer #{RAW_TOKEN}", manifest_call[:headers]["Authorization"],
|
||||
"sending the raw GitHub token as a Bearer is exactly the bug that 403s"
|
||||
end
|
||||
|
||||
def test_anonymous_exchange_still_works_for_public_packages
|
||||
# No token: the exchange must still run, anonymously (no Authorization
|
||||
# header on the /token request), and the minted token used downstream.
|
||||
http = fakes
|
||||
g = Railway::GHCR.new(token: nil, http: http)
|
||||
assert_equal :exists, g.manifest_exists(REF)
|
||||
|
||||
token_call = http.calls.find { |c| c[:method] == :get && c[:url] == TOKEN_URL }
|
||||
refute_nil token_call, "anonymous path must still mint a bearer via /token"
|
||||
assert_nil token_call[:headers]["Authorization"],
|
||||
"anonymous exchange must not send an Authorization header"
|
||||
|
||||
manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST }
|
||||
assert_equal "Bearer #{MINTED}", manifest_call[:headers]["Authorization"]
|
||||
end
|
||||
|
||||
# When a token WAS supplied and the /token exchange returns a non-2xx,
|
||||
# that is a real failure (bad/insufficient token), NOT a license to fall
|
||||
# back to an anonymous manifest read. bearer_for must RAISE GHCR::Error so
|
||||
# manifest_exists's documented raise-on-failure contract holds.
|
||||
def test_token_present_exchange_401_raises
|
||||
http = fakes(TOKEN_URL => { status: 401, headers: {}, body: "unauthorized" })
|
||||
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
|
||||
|
||||
err = assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) }
|
||||
assert_match(/token exchange failed/i, err.message)
|
||||
end
|
||||
|
||||
# A 200 with a body that is not parseable JSON is a broken exchange, not a
|
||||
# usable bearer. bearer_for must RAISE rather than swallow JSON::ParserError.
|
||||
def test_token_present_malformed_body_raises
|
||||
http = fakes(TOKEN_URL => { status: 200, headers: {}, body: "<html>not json</html>" })
|
||||
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
|
||||
|
||||
err = assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) }
|
||||
assert_match(/unparseable/i, err.message)
|
||||
end
|
||||
|
||||
# Regression guard: when a token was supplied and the exchange failed, the
|
||||
# manifest HEAD must NEVER be sent — and certainly never anonymously. The
|
||||
# old swallow-to-nil behavior issued an anonymous HEAD, conflating "no
|
||||
# token" with "supplied token failed to exchange".
|
||||
def test_token_present_exchange_failure_never_does_anonymous_manifest_read
|
||||
http = fakes(TOKEN_URL => { status: 403, headers: {}, body: "forbidden" })
|
||||
g = Railway::GHCR.new(token: RAW_TOKEN, http: http)
|
||||
|
||||
assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) }
|
||||
|
||||
manifest_call = http.calls.find { |c| c[:method] == :head && c[:url] == MANIFEST }
|
||||
assert_nil manifest_call,
|
||||
"no manifest HEAD must be issued when a supplied token fails the /token exchange"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class GHCRDigestTest < Minitest::Test
|
||||
# Fake HTTP layer for the GHCR client.
|
||||
class FakeHTTP
|
||||
def initialize(responses)
|
||||
@responses = responses
|
||||
end
|
||||
|
||||
def call(method:, url:, headers: {})
|
||||
key = [method, url]
|
||||
r = @responses[key] || @responses[url]
|
||||
raise "no fake response for #{key.inspect}" unless r
|
||||
r
|
||||
end
|
||||
end
|
||||
|
||||
def test_parse_image_ref_handles_all_shapes
|
||||
g = Railway::GHCR.new
|
||||
p1 = g.parse_image_ref("ghcr.io/copilotkit/showcase-shell:latest")
|
||||
assert_equal "ghcr.io", p1[:registry]
|
||||
assert_equal "copilotkit", p1[:org]
|
||||
assert_equal "showcase-shell", p1[:name]
|
||||
assert_equal "latest", p1[:tag]
|
||||
assert_nil p1[:digest]
|
||||
|
||||
p2 = g.parse_image_ref("ghcr.io/copilotkit/showcase-shell@sha256:abc")
|
||||
assert_equal "sha256:abc", p2[:digest]
|
||||
|
||||
p3 = g.parse_image_ref("ghcr.io/copilotkit/showcase-shell:latest@sha256:def")
|
||||
assert_equal "latest", p3[:tag]
|
||||
assert_equal "sha256:def", p3[:digest]
|
||||
end
|
||||
|
||||
# bearer_for ALWAYS performs the /token exchange now, so every fake that
|
||||
# reaches a manifest HEAD must also model a successful exchange.
|
||||
TOKEN_URL = "https://ghcr.io/token?service=ghcr.io&scope=repository:copilotkit/showcase-shell:pull"
|
||||
|
||||
def token_ok
|
||||
{ TOKEN_URL => { status: 200, headers: {}, body: %({"token":"minted"}) } }
|
||||
end
|
||||
|
||||
def test_resolve_digest_returns_digest_from_header
|
||||
url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/latest"
|
||||
fake = FakeHTTP.new(token_ok.merge(
|
||||
url => { status: 200, headers: { "docker-content-digest" => "sha256:beefcafe" }, body: "" },
|
||||
))
|
||||
g = Railway::GHCR.new(token: "x", http: fake)
|
||||
assert_equal "sha256:beefcafe", g.resolve_digest("ghcr.io/copilotkit/showcase-shell:latest")
|
||||
end
|
||||
|
||||
def test_resolve_digest_returns_existing_digest_immediately
|
||||
# When the ref already has @sha256:..., we don't hit the network at all.
|
||||
g = Railway::GHCR.new(token: "x", http: nil)
|
||||
assert_equal "sha256:abc",
|
||||
g.resolve_digest("ghcr.io/copilotkit/showcase-shell@sha256:abc")
|
||||
end
|
||||
|
||||
def test_resolve_digest_returns_nil_on_404
|
||||
url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/nope"
|
||||
fake = FakeHTTP.new(token_ok.merge(url => { status: 404, headers: {}, body: "" }))
|
||||
g = Railway::GHCR.new(token: "x", http: fake)
|
||||
assert_nil g.resolve_digest("ghcr.io/copilotkit/showcase-shell:nope")
|
||||
end
|
||||
|
||||
def test_resolve_digest_raises_on_5xx
|
||||
url = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/latest"
|
||||
fake = FakeHTTP.new(token_ok.merge(url => { status: 500, headers: {}, body: "boom" }))
|
||||
g = Railway::GHCR.new(token: "x", http: fake)
|
||||
assert_raises(Railway::GHCR::Error) do
|
||||
g.resolve_digest("ghcr.io/copilotkit/showcase-shell:latest")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class GHCRManifestExistsTest < Minitest::Test
|
||||
class FakeHTTP
|
||||
def initialize(responses)
|
||||
@responses = responses
|
||||
end
|
||||
|
||||
def call(method:, url:, headers: {})
|
||||
key = [method, url]
|
||||
r = @responses[key] || @responses[url]
|
||||
raise "no fake response for #{key.inspect}" unless r
|
||||
r
|
||||
end
|
||||
end
|
||||
|
||||
DIGEST = "sha256:cafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00dcafef00d"
|
||||
REF = "ghcr.io/copilotkit/showcase-shell@#{DIGEST}"
|
||||
URL = "https://ghcr.io/v2/copilotkit/showcase-shell/manifests/#{DIGEST}"
|
||||
TOKEN_URL = "https://ghcr.io/token?service=ghcr.io&scope=repository:copilotkit/showcase-shell:pull"
|
||||
|
||||
# bearer_for ALWAYS performs the /token exchange now, so every fake that
|
||||
# reaches a manifest HEAD must also model a successful exchange.
|
||||
def token_ok
|
||||
{ TOKEN_URL => { status: 200, headers: {}, body: %({"token":"minted"}) } }
|
||||
end
|
||||
|
||||
def test_manifest_exists_returns_exists_on_200
|
||||
fake = FakeHTTP.new(token_ok.merge(URL => { status: 200, headers: {}, body: "" }))
|
||||
g = Railway::GHCR.new(token: "x", http: fake)
|
||||
assert_equal :exists, g.manifest_exists(REF)
|
||||
end
|
||||
|
||||
def test_manifest_exists_returns_missing_on_404
|
||||
fake = FakeHTTP.new(token_ok.merge(URL => { status: 404, headers: {}, body: "" }))
|
||||
g = Railway::GHCR.new(token: "x", http: fake)
|
||||
assert_equal :missing, g.manifest_exists(REF)
|
||||
end
|
||||
|
||||
def test_manifest_exists_returns_auth_failed_on_401_403
|
||||
fake401 = FakeHTTP.new(token_ok.merge(URL => { status: 401, headers: {}, body: "" }))
|
||||
fake403 = FakeHTTP.new(token_ok.merge(URL => { status: 403, headers: {}, body: "" }))
|
||||
assert_equal :auth_failed, Railway::GHCR.new(token: "x", http: fake401).manifest_exists(REF)
|
||||
assert_equal :auth_failed, Railway::GHCR.new(token: "x", http: fake403).manifest_exists(REF)
|
||||
end
|
||||
|
||||
def test_manifest_exists_raises_on_5xx
|
||||
fake = FakeHTTP.new(token_ok.merge(URL => { status: 500, headers: {}, body: "boom" }))
|
||||
g = Railway::GHCR.new(token: "x", http: fake)
|
||||
assert_raises(Railway::GHCR::Error) { g.manifest_exists(REF) }
|
||||
end
|
||||
|
||||
def test_manifest_exists_requires_pinned_digest
|
||||
# An unpinned tag is a programmer error here — we are verifying the
|
||||
# CONCRETE digest we are about to promote, not resolving a tag.
|
||||
g = Railway::GHCR.new(token: "x")
|
||||
assert_raises(ArgumentError) do
|
||||
g.manifest_exists("ghcr.io/copilotkit/showcase-shell:latest")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class GHCRTokenTest < Minitest::Test
|
||||
def setup
|
||||
@prior_github = ENV.delete("GITHUB_TOKEN")
|
||||
@prior_ghcr = ENV.delete("GHCR_TOKEN")
|
||||
@prior_railway = ENV.delete("RAILWAY_TOKEN")
|
||||
end
|
||||
|
||||
def teardown
|
||||
# Unconditionally delete any test-set values so they don't leak across
|
||||
# tests, THEN re-set the saved priors if those were present.
|
||||
ENV.delete("GITHUB_TOKEN")
|
||||
ENV.delete("GHCR_TOKEN")
|
||||
ENV.delete("RAILWAY_TOKEN")
|
||||
ENV["GITHUB_TOKEN"] = @prior_github if @prior_github
|
||||
ENV["GHCR_TOKEN"] = @prior_ghcr if @prior_ghcr
|
||||
ENV["RAILWAY_TOKEN"] = @prior_railway if @prior_railway
|
||||
end
|
||||
|
||||
def test_prefers_explicit_ghcr_token
|
||||
ENV["GITHUB_TOKEN"] = "ci-token"
|
||||
ENV["GHCR_TOKEN"] = "explicit-pat"
|
||||
assert_equal "explicit-pat", Railway::Auth.ghcr_token
|
||||
end
|
||||
|
||||
def test_falls_back_to_github_token_in_ci
|
||||
ENV["GITHUB_TOKEN"] = "ci-token"
|
||||
assert_equal "ci-token", Railway::Auth.ghcr_token
|
||||
end
|
||||
|
||||
def test_returns_nil_when_no_token_available
|
||||
# No GH_AUTH_TOKEN shim, no env vars: nil (caller decides to refuse).
|
||||
assert_nil Railway::Auth.ghcr_token
|
||||
end
|
||||
|
||||
def test_does_not_return_railway_token
|
||||
ENV["RAILWAY_TOKEN"] = "railway-bearer"
|
||||
assert_nil Railway::Auth.ghcr_token
|
||||
ensure
|
||||
ENV.delete("RAILWAY_TOKEN")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,99 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Port-safe image-ref colon splitting.
|
||||
#
|
||||
# An image ref may carry a registry PORT (`host:PORT/org/img:tag`). Stripping
|
||||
# the tag with a FIRST-colon split (`split(":", 2)`) cuts at the PORT colon and
|
||||
# corrupts the ref — `localhost:5000/copilotkit/img:latest` collapses to base
|
||||
# `localhost`. The canonical `ghcr.io/...:tag` refs (no port) are unaffected,
|
||||
# but the LAST-colon helper (`String#rsplit_colon`, also used by
|
||||
# `GHCR#parse_image_ref`) is correct for BOTH shapes. These tests pin that the
|
||||
# tag-stripping call sites (`PromoteCommand#image_shape`, `PinCommand#run`'s
|
||||
# digest rewrite) use the last-colon semantics consistently.
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class ImageRefColonSplitTest < Minitest::Test
|
||||
PORT_REF = "localhost:5000/copilotkit/showcase-shell:latest"
|
||||
PORT_REF_BASE = "localhost:5000/copilotkit/showcase-shell"
|
||||
CANONICAL_REF = "ghcr.io/copilotkit/showcase-shell:latest"
|
||||
CANONICAL_BASE = "ghcr.io/copilotkit/showcase-shell"
|
||||
|
||||
# rsplit_colon is the shared helper — sanity-pin its last-colon semantics
|
||||
# for the port-bearing ref both fixes rely on.
|
||||
def test_rsplit_colon_splits_on_last_colon_for_port_ref
|
||||
base, tag = PORT_REF.rsplit_colon
|
||||
assert_equal PORT_REF_BASE, base
|
||||
assert_equal "latest", tag
|
||||
end
|
||||
|
||||
# ── image_shape: a port-bearing tag ref must classify as :tag AND the tag
|
||||
# detection must read the LAST-colon segment. A first-colon split returns
|
||||
# the port-and-rest segment ("5000/copilotkit/showcase-shell:latest"),
|
||||
# which happens to still classify :tag here, so we additionally assert the
|
||||
# trailing-colon (blank-tag) port ref is NOT misclassified as :tag — that
|
||||
# case is GREEN only with last-colon semantics.
|
||||
def test_image_shape_tags_port_ref
|
||||
c = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
assert_equal :tag, c.send(:image_shape, PORT_REF)
|
||||
assert_equal :tag, c.send(:image_shape, CANONICAL_REF)
|
||||
end
|
||||
|
||||
# A port ref with an EMPTY tag (trailing colon) has no real tag. With a
|
||||
# first-colon split the tail is "5000/.../img:" (non-blank) → wrongly :tag.
|
||||
# Last-colon split yields a blank tail → correctly :other. RED before the
|
||||
# fix, GREEN after.
|
||||
def test_image_shape_does_not_tag_port_ref_with_empty_tag
|
||||
c = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
assert_equal :other, c.send(:image_shape, "localhost:5000/copilotkit/showcase-shell:")
|
||||
end
|
||||
|
||||
# ── PinCommand#run: when given a tag ref, it resolves the digest then
|
||||
# rewrites the ref as "<base>@<digest>". The <base> MUST retain the full
|
||||
# registry:port/org/img — a first-colon split drops everything after the
|
||||
# port colon, producing the corrupt pin "localhost@sha256:...". RED before
|
||||
# the fix, GREEN after.
|
||||
def test_pin_preserves_port_base_when_rewriting_to_digest
|
||||
out = run_pin_dry_run(PORT_REF)
|
||||
assert_match(/#{Regexp.escape("#{PORT_REF_BASE}@sha256:deadbeef")}/, out,
|
||||
"pin must preserve the full registry:port/org/img base when " \
|
||||
"rewriting a tag ref to a digest; got:\n#{out}")
|
||||
refute_match(/localhost@sha256:/, out,
|
||||
"first-colon split corrupts the base to just the registry host")
|
||||
end
|
||||
|
||||
# Canonical (no-port) refs must keep working identically through the fix.
|
||||
def test_pin_preserves_canonical_base_when_rewriting_to_digest
|
||||
out = run_pin_dry_run(CANONICAL_REF)
|
||||
assert_match(/#{Regexp.escape("#{CANONICAL_BASE}@sha256:deadbeef")}/, out,
|
||||
"canonical ref base must be unchanged by the fix; got:\n#{out}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Drive PinCommand#run through its tag→digest rewrite under --dry-run while
|
||||
# keeping the test HERMETIC. PinCommand#run resolves the service id via a
|
||||
# fresh `RollbackCommand.new([]).resolve_service_id(...)` BEFORE the dry-run
|
||||
# early-return, which would otherwise issue a real GraphQL call (and `die!`
|
||||
# → exit on a tokenless CI runner, aborting the whole suite). Stub GHCR
|
||||
# digest resolution on the command instance, and stub service-id resolution
|
||||
# at the class level so no network is touched.
|
||||
def run_pin_dry_run(image_ref)
|
||||
cmd = Railway::PinCommand.new(
|
||||
["--env", "production", "--service", "shell",
|
||||
"--image", image_ref, "--non-interactive", "--yes", "--dry-run"],
|
||||
)
|
||||
cmd.instance_variable_set(:@ghcr, Object.new.tap do |o|
|
||||
def o.resolve_digest(_ref); "sha256:deadbeef"; end
|
||||
end)
|
||||
|
||||
original = Railway::RollbackCommand.instance_method(:resolve_service_id)
|
||||
Railway::RollbackCommand.define_method(:resolve_service_id) { |_env_id, _name| "svc-stub" }
|
||||
begin
|
||||
out, = capture_io { cmd.run }
|
||||
out
|
||||
ensure
|
||||
Railway::RollbackCommand.define_method(:resolve_service_id, original)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,145 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# bin/railway lint-prod — the starter-* fleet is UNDER the gate (S2).
|
||||
#
|
||||
# Background: S1 folded the 12 starter-<slug> services into the railway-envs
|
||||
# SSOT but kept them gate-INERT (gateIgnore:true / ciBuilt:false / probeDriver
|
||||
# "shell"), and verify-railway-image-refs.ts + this CLI's promote parity scope
|
||||
# carved them out as "decoupled / staging-only". S2 reverses that fence: the
|
||||
# starters are now full dual-env, gateValidated, ciBuilt SSOT entries that must
|
||||
# receive the SAME pinned-prod treatment as a showcase-* agent.
|
||||
#
|
||||
# lint-prod asserts every PROD service is pinned to an immutable @sha256
|
||||
# digest. It reads the FULL live prod snapshot with NO per-service skip, so its
|
||||
# coverage is exactly "every service live in prod". The point of S2 is that the
|
||||
# 12 starters — which ARE live in prod — are within that coverage: a starter
|
||||
# floating on a mutable :latest tag in prod is DRIFT and lint-prod must flag it.
|
||||
#
|
||||
# Red-green for THIS change:
|
||||
# - The 12 starter names are derived from the SSOT (railway-envs.generated.json
|
||||
# STARTER_PROD_SERVICES below) — under S1 the starters were modeled as
|
||||
# staging-only / gate-exempt, so a "lint-prod covers starters" assertion had
|
||||
# no SSOT basis (the prod set excluded them). After S2 the SSOT places all
|
||||
# 12 in prod, and lint-prod flags each mutable-tag starter as drift.
|
||||
# - Regression: a digest-PINNED starter in prod is NOT flagged (the gate
|
||||
# accepts the canonical shape), exactly like any showcase-* service.
|
||||
|
||||
require_relative "spec_helper"
|
||||
require "stringio"
|
||||
|
||||
class LintProdCoversStartersTest < Minitest::Test
|
||||
# The 12 starter Railway service names, derived from the SSOT
|
||||
# (railway-envs.generated.json) rather than re-hardcoded here, so this test
|
||||
# moves with the SSOT and can never silently drift from it.
|
||||
STARTER_PROD_SERVICES = Railway::SSOT_DATA
|
||||
.fetch("services")
|
||||
.select { |s| s.fetch("name").start_with?("starter-") }
|
||||
.map { |s| s.fetch("name") }
|
||||
.sort
|
||||
.freeze
|
||||
|
||||
# A couple of showcase services kept in the fixture so the starter coverage
|
||||
# is exercised ALONGSIDE the existing fleet (not in isolation).
|
||||
SHOWCASE_SAMPLE = %w[showcase-mastra aimock].freeze
|
||||
|
||||
# Install a fake prod snapshot onto SnapshotCommand#build_snapshot for the
|
||||
# duration of the block. LintProdCommand#run constructs its OWN
|
||||
# SnapshotCommand internally, so we stub at the class level (the only seam),
|
||||
# restoring the original method in `ensure`.
|
||||
def with_prod_snapshot(services)
|
||||
snapshot = { "services" => services }
|
||||
original = Railway::SnapshotCommand.instance_method(:build_snapshot)
|
||||
Railway::SnapshotCommand.send(:define_method, :build_snapshot) do |_env_id|
|
||||
snapshot
|
||||
end
|
||||
yield
|
||||
ensure
|
||||
Railway::SnapshotCommand.send(:define_method, :build_snapshot, original)
|
||||
end
|
||||
|
||||
def starter_svc(name, image:)
|
||||
{ "name" => name, "service_id" => "prod-#{name}", "image" => image }
|
||||
end
|
||||
|
||||
# Red-green anchor (S2 source change, as Ruby sees it): the generated.json
|
||||
# that bin/railway reads via SSOT_DATA now marks all 12 starters
|
||||
# ciBuilt:true + gateValidated:true. Under S1 these were false (gate-inert);
|
||||
# S2 flips them — this is the exact byte-level change lint-prod's "starters
|
||||
# are gate-covered" guarantee rests on. RED before S2 (false), GREEN after.
|
||||
def test_ssot_marks_all_starters_ci_built_and_gate_validated
|
||||
starters = Railway::SSOT_DATA.fetch("services")
|
||||
.select { |s| s.fetch("name").start_with?("starter-") }
|
||||
assert_equal 12, starters.length
|
||||
|
||||
starters.each do |s|
|
||||
name = s.fetch("name")
|
||||
assert_equal true, s["ciBuilt"],
|
||||
"#{name} must be ciBuilt (built by showcase_build.yml build-starters)"
|
||||
assert_equal true, s["gateValidated"],
|
||||
"#{name} must be gateValidated (image-ref gate validates its shape)"
|
||||
assert_equal name, s["dispatchName"],
|
||||
"#{name} dispatchName must equal its SSOT key (starter dispatch value)"
|
||||
# No repoNameOverride: service name === GHCR repo name.
|
||||
assert_nil s["repoNameOverride"],
|
||||
"#{name} must carry no repoNameOverride (name === GHCR repo)"
|
||||
assert_equal "starter", s.dig("probe", "driver"),
|
||||
"#{name} probe driver must be the S3 'starter' axis contract"
|
||||
end
|
||||
end
|
||||
|
||||
# GREEN (post-S2): the 12 starters sit in prod on a mutable :latest tag.
|
||||
# lint-prod must COVER them — i.e. flag every one as not-digest-pinned drift.
|
||||
def test_lint_prod_flags_mutable_tag_starters_in_prod
|
||||
# Sanity: the SSOT actually carries the full 12-starter prod fleet.
|
||||
assert_equal 12, STARTER_PROD_SERVICES.length,
|
||||
"expected 12 starter-* prod services in the SSOT; got " \
|
||||
"#{STARTER_PROD_SERVICES.inspect}"
|
||||
|
||||
services =
|
||||
SHOWCASE_SAMPLE.map { |n| starter_svc(n, image: "ghcr.io/copilotkit/#{n}@sha256:abc") } +
|
||||
STARTER_PROD_SERVICES.map { |n| starter_svc(n, image: "ghcr.io/copilotkit/#{n}:latest") }
|
||||
|
||||
rc = nil
|
||||
out = nil
|
||||
with_prod_snapshot(services) do
|
||||
cmd = Railway::LintProdCommand.new([])
|
||||
out, _ = capture_io { rc = cmd.run }
|
||||
end
|
||||
|
||||
refute_equal 0, rc,
|
||||
"lint-prod must FAIL when prod starters float on :latest (they are " \
|
||||
"now gate-covered); got rc=#{rc.inspect}\nout=#{out}"
|
||||
|
||||
# Every one of the 12 starters must be named in the drift report —
|
||||
# proving lint-prod's coverage INCLUDES the starter fleet, not just the
|
||||
# showcase services.
|
||||
STARTER_PROD_SERVICES.each do |name|
|
||||
assert_match(/#{Regexp.escape(name)}: not digest-pinned/, out,
|
||||
"lint-prod must flag mutable-tag starter #{name} as drift")
|
||||
end
|
||||
assert_match(/DRIFT: 12 production service\(s\) not digest-pinned/, out,
|
||||
"exactly the 12 starters should be flagged (showcase sample is pinned)")
|
||||
end
|
||||
|
||||
# Regression: a digest-PINNED starter in prod is accepted, exactly like any
|
||||
# showcase-* service. (Confirms the coverage is the CANONICAL shape check,
|
||||
# not a blanket starter REFUSE.)
|
||||
def test_lint_prod_accepts_digest_pinned_starters
|
||||
services =
|
||||
(SHOWCASE_SAMPLE + STARTER_PROD_SERVICES).map do |n|
|
||||
starter_svc(n, image: "ghcr.io/copilotkit/#{n}@sha256:#{'a' * 64}")
|
||||
end
|
||||
|
||||
rc = nil
|
||||
out = nil
|
||||
with_prod_snapshot(services) do
|
||||
cmd = Railway::LintProdCommand.new([])
|
||||
out, _ = capture_io { rc = cmd.run }
|
||||
end
|
||||
|
||||
assert_equal 0, rc,
|
||||
"lint-prod must PASS when every prod service (starters included) is " \
|
||||
"digest-pinned; got rc=#{rc.inspect}\nout=#{out}"
|
||||
assert_match(/OK: all production services digest-pinned/, out)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
require "stringio"
|
||||
|
||||
class ProductionProtectionTest < Minitest::Test
|
||||
def test_staging_does_not_require_confirmation
|
||||
# staging is never a production env, so this returns true without prompting.
|
||||
assert_equal true, Railway.confirm_destructive!(
|
||||
env_label: "staging", action: "restore", yes: false, non_interactive: true,
|
||||
)
|
||||
end
|
||||
|
||||
def test_production_without_yes_aborts
|
||||
ex = nil
|
||||
capture_stderr do
|
||||
ex = assert_raises(SystemExit) do
|
||||
Railway.confirm_destructive!(env_label: "production", action: "restore",
|
||||
yes: false, non_interactive: true)
|
||||
end
|
||||
end
|
||||
assert_equal 2, ex.status
|
||||
end
|
||||
|
||||
def test_production_with_yes_and_non_interactive_proceeds
|
||||
# --yes + --non-interactive proceeds without prompting.
|
||||
result = capture_stderr do
|
||||
assert_equal true, Railway.confirm_destructive!(
|
||||
env_label: "production", action: "restore",
|
||||
yes: true, non_interactive: true,
|
||||
)
|
||||
end
|
||||
assert_includes result, "non-interactive"
|
||||
end
|
||||
|
||||
def test_production_with_yes_prompts_and_accepts_typed_phrase
|
||||
# Simulate the user typing 'production' on stdin.
|
||||
original_stdin = $stdin
|
||||
$stdin = StringIO.new("production\n")
|
||||
capture_stderr do
|
||||
assert_equal true, Railway.confirm_destructive!(
|
||||
env_label: "production", action: "restore",
|
||||
yes: true, non_interactive: false,
|
||||
)
|
||||
end
|
||||
ensure
|
||||
$stdin = original_stdin
|
||||
end
|
||||
|
||||
def test_production_with_yes_rejects_wrong_phrase
|
||||
original_stdin = $stdin
|
||||
$stdin = StringIO.new("yes\n")
|
||||
ex = nil
|
||||
capture_stderr do
|
||||
ex = assert_raises(SystemExit) do
|
||||
Railway.confirm_destructive!(env_label: "production",
|
||||
action: "restore",
|
||||
yes: true, non_interactive: false)
|
||||
end
|
||||
end
|
||||
assert_equal 2, ex.status
|
||||
ensure
|
||||
$stdin = original_stdin
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def capture_stderr
|
||||
original = $stderr
|
||||
$stderr = StringIO.new
|
||||
yield
|
||||
$stderr.string
|
||||
ensure
|
||||
$stderr = original
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,422 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# Covers nine CR fixes against PromoteCommand:
|
||||
# FIX-1: @promote_refs is RESET per check_p1_ghcr_digests run (not memoized
|
||||
# across A→B preflight invocations), and execute_promotion HARD-GUARDS
|
||||
# against a nil @promote_refs.
|
||||
# FIX-2: P2 in-flight race compares deployed_digest vs the digest portion of
|
||||
# @promote_refs[name] (not the snapshot's tag-form nil `digest`).
|
||||
# Also: JSON-string `meta` is parsed (not WARN-skipped); and
|
||||
# fetch_latest_staging_deployments sorts createdAt-desc so `.first`
|
||||
# is genuinely latest.
|
||||
# FIX-3: execute_promotion pre-validates ALL prod-matched services have a
|
||||
# digest-shaped @promote_refs entry BEFORE pinning anything.
|
||||
# FIX-4: execute_promotion rescue broadens to MutationError + GraphQL::Error
|
||||
# + StandardError, retains PARTIAL-PROMOTION report, and drops the
|
||||
# duplicate `warn e.message`.
|
||||
# FIX-5: check_p1_ghcr_digests emits REFUSE: P1 ... "no image" when staging
|
||||
# service has nil/empty image (instead of silent skip).
|
||||
# FIX-6: P1 per-service rescue broadens to StandardError so non-GHCR errors
|
||||
# don't bypass the loop.
|
||||
# FIX-7: pin_and_verify raises ArgumentError immediately when called with a
|
||||
# tag-form (non-digest) image.
|
||||
# FIX-8: pin_and_verify timestamp gate is non-vacuous: a nil observed
|
||||
# updatedAt does NOT declare success, even when pre_update_ts is nil.
|
||||
# FIX-9: run_staging_probe rescues launch failures (Errno::ENOENT, etc.)
|
||||
# and returns ok:false with a descriptive summary.
|
||||
class PromoteCRFixesTest < Minitest::Test
|
||||
# ----- shared fakes -----
|
||||
|
||||
class NullGQL
|
||||
def query(*); {}; end
|
||||
end
|
||||
|
||||
# GQL that records calls and returns canned responses for P2/preflight only
|
||||
# (no mutations).
|
||||
class P2GQL
|
||||
attr_reader :calls
|
||||
def initialize(deployments_by_svc)
|
||||
@deployments_by_svc = deployments_by_svc
|
||||
@calls = []
|
||||
end
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
if q.include?("query Deployments")
|
||||
edges = (@deployments_by_svc[vars[:serviceId]] || []).map { |n| { "node" => n } }
|
||||
return { "deployments" => { "edges" => edges } }
|
||||
end
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# GHCR fake that always reports :exists and resolves any :latest -> a digest.
|
||||
class PassGHCR
|
||||
def initialize(resolve_map: {}); @resolve_map = resolve_map; end
|
||||
def resolve_digest(ref)
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
@resolve_map[ref] || "sha256:resolved_for_#{ref}"
|
||||
end
|
||||
def manifest_exists(_); :exists; end
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
def make_svc(name, image:)
|
||||
{
|
||||
"name" => name, "service_id" => "svc-stg-#{name}",
|
||||
"image" => image,
|
||||
"env_keys" => [],
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}
|
||||
end
|
||||
|
||||
# =================== FIX-1: @promote_refs is reset, not memoized ===================
|
||||
|
||||
def test_fix1_promote_refs_resets_between_p1_runs
|
||||
# Reuse a single PromoteCommand instance across two distinct snapshots
|
||||
# (snapshot A and snapshot B). After the second P1 run, @promote_refs
|
||||
# must reflect ONLY snapshot B's services — no stale A entries.
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.instance_variable_set(:@ghcr, PassGHCR.new)
|
||||
# resolved_prod_image now pins staging's RUNNING digest (meta.imageDigest
|
||||
# from the latest SUCCESS deployment), not resolve_digest(:latest). Stub
|
||||
# the deployment lookup so a digest is resolvable for each tag-form svc.
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |svc_id|
|
||||
name = svc_id.sub("svc-stg-", "")
|
||||
[{ "id" => "d", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/#{name}:latest",
|
||||
"imageDigest" => "sha256:running_#{name}" } }]
|
||||
end
|
||||
|
||||
snapshot_a = { "services" => [make_svc("alpha", image: "ghcr.io/copilotkit/alpha:latest")] }
|
||||
snapshot_b = { "services" => [make_svc("beta", image: "ghcr.io/copilotkit/beta:latest")] }
|
||||
|
||||
cmd.send(:check_p1_ghcr_digests, snapshot_a)
|
||||
refs_after_a = cmd.instance_variable_get(:@promote_refs).keys.sort
|
||||
assert_equal ["alpha"], refs_after_a
|
||||
|
||||
cmd.send(:check_p1_ghcr_digests, snapshot_b)
|
||||
refs_after_b = cmd.instance_variable_get(:@promote_refs).keys.sort
|
||||
assert_equal ["beta"], refs_after_b,
|
||||
"stale entries from snapshot A must be cleared; got #{refs_after_b.inspect}"
|
||||
end
|
||||
|
||||
def test_fix1_execute_promotion_raises_when_promote_refs_nil
|
||||
# execute_promotion called WITHOUT a prior preflight must raise an
|
||||
# internal-error exception (not silently treat refs as empty).
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.instance_variable_set(:@ghcr, PassGHCR.new)
|
||||
# Deliberately do NOT call check_p1_ghcr_digests; @promote_refs stays nil.
|
||||
staging = { "services" => [make_svc("x", image: "ghcr.io/copilotkit/x:latest")] }
|
||||
prod = { "services" => [{ "name" => "x", "service_id" => "svc-prod-x",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:OLD" }] }
|
||||
err = assert_raises(RuntimeError) do
|
||||
cmd.send(:execute_promotion, staging, prod)
|
||||
end
|
||||
assert_match(/internal error.*execute_promotion.*preflight/i, err.message)
|
||||
end
|
||||
|
||||
# =================== FIX-2: P2 race-check is alive ===================
|
||||
|
||||
def test_fix2_p2_race_check_uses_promote_refs_not_snapshot_digest
|
||||
# Staging service is tag-form (so svc["digest"] is nil — pre-fix the
|
||||
# race-check was DEAD CODE). After fix: P2 compares against the digest
|
||||
# captured in @promote_refs[name].
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.instance_variable_set(:@promote_refs, {
|
||||
"x" => "ghcr.io/copilotkit/x@sha256:YYY",
|
||||
})
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |_svc_id|
|
||||
[{ "id" => "d1", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/x@sha256:XXX" },
|
||||
"createdAt" => "2026-05-28T01:00:00Z" }]
|
||||
end
|
||||
staging = { "services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x:latest", # tag-form; no "digest"
|
||||
"env_keys" => [],
|
||||
}] }
|
||||
findings = cmd.send(:check_p2_staging_deployments, staging)
|
||||
assert(findings.any? { |f| f =~ /REFUSE: P2 \(x\).*in-flight.*sha256:XXX.*sha256:YYY/ },
|
||||
"expected REFUSE: P2 comparing deployed XXX vs P1-resolved YYY; got: #{findings.inspect}")
|
||||
end
|
||||
|
||||
def test_fix2_p2_parses_meta_when_it_is_a_json_string
|
||||
# Some Railway responses deserialize Deployment.meta as a JSON String
|
||||
# (not a Hash). P2 must parse it before falling back to the WARN branch.
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.instance_variable_set(:@promote_refs, {
|
||||
"x" => "ghcr.io/copilotkit/x@sha256:abc",
|
||||
})
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |_svc_id|
|
||||
[{ "id" => "d1", "status" => "SUCCESS",
|
||||
"meta" => '{"image":"ghcr.io/copilotkit/x@sha256:abc"}',
|
||||
"createdAt" => "2026-05-28T01:00:00Z" }]
|
||||
end
|
||||
staging = { "services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x:latest",
|
||||
"env_keys" => [],
|
||||
}] }
|
||||
findings = cmd.send(:check_p2_staging_deployments, staging)
|
||||
refute(findings.any? { |f| f =~ /WARN: P2 \(x\)/ },
|
||||
"JSON-string meta must be parsed (not WARN-skipped); got: #{findings.inspect}")
|
||||
refute(findings.any? { |f| f =~ /REFUSE: P2/ },
|
||||
"matching digest in parsed meta must not REFUSE; got: #{findings.inspect}")
|
||||
end
|
||||
|
||||
def test_fix2_fetch_latest_staging_deployments_sorts_newest_first
|
||||
# Stub gql.query to return deployments in OLDEST-first order — the
|
||||
# helper must sort by createdAt DESC so `.first` is the newest.
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
nodes = [
|
||||
{ "id" => "d-old", "status" => "SUCCESS", "createdAt" => "2026-05-01T00:00:00Z" },
|
||||
{ "id" => "d-new", "status" => "SUCCESS", "createdAt" => "2026-05-28T00:00:00Z" },
|
||||
{ "id" => "d-mid", "status" => "FAILED", "createdAt" => "2026-05-15T00:00:00Z" },
|
||||
]
|
||||
edges = nodes.map { |n| { "node" => n } }
|
||||
fake_gql = Object.new
|
||||
fake_gql.define_singleton_method(:query) do |_q, _vars = {}|
|
||||
{ "deployments" => { "edges" => edges } }
|
||||
end
|
||||
cmd.instance_variable_set(:@gql, fake_gql)
|
||||
deployments = cmd.send(:fetch_latest_staging_deployments, "svc-1")
|
||||
assert_equal "d-new", deployments.first["id"],
|
||||
"fetch_latest_staging_deployments must sort newest-first; got: #{deployments.map { |d| d['id'] }.inspect}"
|
||||
end
|
||||
|
||||
# =================== FIX-3: execute_promotion pre-validation ===================
|
||||
|
||||
def test_fix3_execute_promotion_pre_validates_all_refs_before_any_pin
|
||||
# Two prod-matched services; ONE missing from @promote_refs. The
|
||||
# pre-validation must REFUSE+return 1 BEFORE any serviceInstanceUpdate
|
||||
# mutation is issued.
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
recorded = []
|
||||
fake_gql = Object.new
|
||||
fake_gql.define_singleton_method(:query) do |q, vars = {}|
|
||||
recorded << [q, vars]
|
||||
{ "serviceInstanceUpdate" => true, "serviceInstanceDeployV2" => "dep-new" }
|
||||
end
|
||||
cmd.instance_variable_set(:@gql, fake_gql)
|
||||
cmd.instance_variable_set(:@promote_refs, {
|
||||
"a" => "ghcr.io/copilotkit/a@sha256:aaa",
|
||||
# "b" is MISSING
|
||||
})
|
||||
staging = { "services" => [make_svc("a", image: "ghcr.io/copilotkit/a:latest"),
|
||||
make_svc("b", image: "ghcr.io/copilotkit/b:latest")] }
|
||||
prod = { "services" => [
|
||||
{ "name" => "a", "service_id" => "svc-prod-a", "image" => "ghcr.io/copilotkit/a@sha256:OLD" },
|
||||
{ "name" => "b", "service_id" => "svc-prod-b", "image" => "ghcr.io/copilotkit/b@sha256:OLD" },
|
||||
] }
|
||||
_out, _err = capture_io { @rc = cmd.send(:execute_promotion, staging, prod) }
|
||||
assert_equal 1, @rc, "execute_promotion must return 1 when a ref is missing"
|
||||
assert(recorded.none? { |q, _| q.include?("serviceInstanceUpdate") },
|
||||
"no serviceInstanceUpdate mutations should be issued; got: #{recorded.map { |q, _| q[0, 30] }.inspect}")
|
||||
end
|
||||
|
||||
# =================== FIX-4: broadened rescue + partial-promotion report ===================
|
||||
|
||||
def test_fix4_execute_promotion_rescues_graphql_error_with_partial_report
|
||||
# First service pins successfully; second raises Railway::GraphQL::Error
|
||||
# on its serviceInstanceUpdate mutation. The broadened rescue must
|
||||
# catch it and still emit the PARTIAL-PROMOTION report.
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
call_count = 0
|
||||
# FakeGQL that records calls and raises GraphQL::Error on the SECOND
|
||||
# serviceInstanceUpdate mutation.
|
||||
fake_gql = Object.new
|
||||
@pinned = nil
|
||||
@pre_ts = "2026-05-28T00:00:00Z"
|
||||
pinned_ref = nil
|
||||
fake_gql.define_singleton_method(:query) do |q, vars = {}|
|
||||
if q.include?("serviceInstanceUpdate")
|
||||
call_count += 1
|
||||
raise Railway::GraphQL::Error, "boom on update #2" if call_count == 2
|
||||
pinned_ref = vars.dig(:input, :source, :image)
|
||||
{ "serviceInstanceUpdate" => true }
|
||||
elsif q.include?("serviceInstanceDeployV2")
|
||||
{ "serviceInstanceDeployV2" => "dep-new" }
|
||||
elsif q.include?("ServiceInstanceRecheck")
|
||||
if pinned_ref
|
||||
{ "serviceInstance" => { "id" => "i",
|
||||
"source" => { "image" => pinned_ref },
|
||||
"updatedAt" => "2026-05-29T00:00:01Z",
|
||||
"latestDeployment" => {
|
||||
"id" => "dep-new", "status" => "SUCCESS",
|
||||
"meta" => { "imageDigest" => (pinned_ref.include?("@") ? pinned_ref.split("@", 2).last : nil) },
|
||||
} } }
|
||||
else
|
||||
{ "serviceInstance" => { "id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/x@sha256:OLD" },
|
||||
"updatedAt" => "2026-05-28T00:00:00Z" } }
|
||||
end
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
cmd.instance_variable_set(:@gql, fake_gql)
|
||||
cmd.instance_variable_set(:@promote_refs, {
|
||||
"a" => "ghcr.io/copilotkit/a@sha256:aaa",
|
||||
"b" => "ghcr.io/copilotkit/b@sha256:bbb",
|
||||
})
|
||||
# Silence pin_and_verify retries.
|
||||
original = Railway::PromoteCommand.const_get(:RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, 0)
|
||||
begin
|
||||
staging = { "services" => [make_svc("a", image: "ghcr.io/copilotkit/a:latest"),
|
||||
make_svc("b", image: "ghcr.io/copilotkit/b:latest")] }
|
||||
prod = { "services" => [
|
||||
{ "name" => "a", "service_id" => "svc-prod-a", "image" => "ghcr.io/copilotkit/a@sha256:OLD" },
|
||||
{ "name" => "b", "service_id" => "svc-prod-b", "image" => "ghcr.io/copilotkit/b@sha256:OLD" },
|
||||
] }
|
||||
out, err = capture_io { @rc = cmd.send(:execute_promotion, staging, prod) }
|
||||
combined = out + err
|
||||
assert_equal 1, @rc
|
||||
assert_match(/PARTIAL PROMOTION/i, combined,
|
||||
"must emit partial-promotion report on GraphQL::Error; combined=#{combined}")
|
||||
assert_match(/already pinned.*\ba\b/m, combined,
|
||||
"report must name 'a' as already-pinned; combined=#{combined}")
|
||||
assert_match(/FAILED on b/, combined,
|
||||
"report must name 'b' as failed; combined=#{combined}")
|
||||
# Dedup check: the inner e.message should appear ONLY inside the
|
||||
# composed PARTIAL-PROMOTION line, not on its own line as well.
|
||||
assert_equal 1, combined.scan(/boom on update #2/).size,
|
||||
"duplicate `warn e.message` line must be removed; combined=#{combined}"
|
||||
ensure
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, original)
|
||||
end
|
||||
end
|
||||
|
||||
# =================== FIX-5: P1 REFUSE on imageless service ===================
|
||||
|
||||
def test_fix5_p1_refuses_when_staging_service_has_no_image
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.instance_variable_set(:@ghcr, PassGHCR.new)
|
||||
staging = { "services" => [
|
||||
{ "name" => "x", "service_id" => "svc-1", "image" => nil,
|
||||
"env_keys" => [] },
|
||||
] }
|
||||
findings = cmd.send(:check_p1_ghcr_digests, staging)
|
||||
assert(findings.any? { |f| f =~ /REFUSE: P1 \(x\).*no image/i },
|
||||
"expected REFUSE: P1 (x) about missing image; got: #{findings.inspect}")
|
||||
end
|
||||
|
||||
# =================== FIX-6: P1 per-service rescue broadens to StandardError ===================
|
||||
|
||||
class ArgumentErrorGHCR
|
||||
def resolve_digest(_); "sha256:fake"; end
|
||||
def manifest_exists(_); raise ArgumentError, "non-ghcr error"; end
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
def test_fix6_p1_rescue_catches_non_ghcr_errors
|
||||
# ArgumentError raised inside manifest_exists must be caught by the
|
||||
# per-service rescue (broadened to StandardError) — the loop must
|
||||
# continue and the service must get a per-service REFUSE.
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.instance_variable_set(:@ghcr, ArgumentErrorGHCR.new)
|
||||
# Stub the running-digest lookup so resolved_prod_image succeeds and the
|
||||
# flow reaches manifest_exists (which raises the ArgumentError under test).
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |svc_id|
|
||||
name = svc_id.sub("svc-stg-", "")
|
||||
[{ "id" => "d", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/#{name}:latest",
|
||||
"imageDigest" => "sha256:running_#{name}" } }]
|
||||
end
|
||||
staging = { "services" => [
|
||||
make_svc("a", image: "ghcr.io/copilotkit/a:latest"),
|
||||
make_svc("b", image: "ghcr.io/copilotkit/b:latest"),
|
||||
] }
|
||||
findings = cmd.send(:check_p1_ghcr_digests, staging)
|
||||
# Two services, each one should fail with ArgumentError-bearing REFUSE.
|
||||
assert(findings.any? { |f| f =~ /REFUSE: P1 \(a\).*ArgumentError.*non-ghcr error/ },
|
||||
"service 'a' must record a per-service REFUSE for ArgumentError; got: #{findings.inspect}")
|
||||
assert(findings.any? { |f| f =~ /REFUSE: P1 \(b\).*ArgumentError.*non-ghcr error/ },
|
||||
"service 'b' must record a per-service REFUSE for ArgumentError; got: #{findings.inspect}")
|
||||
end
|
||||
|
||||
# =================== FIX-7: pin_and_verify upfront digest guard ===================
|
||||
|
||||
def test_fix7_pin_and_verify_raises_arg_error_on_tag_form_image
|
||||
# Pre-fix: pin_and_verify would dutifully attempt N retries before
|
||||
# raising a misleading MutationError. After fix: ArgumentError fires
|
||||
# immediately, no retries.
|
||||
gql = Object.new
|
||||
gql.define_singleton_method(:query) { |*| raise "no query should be issued" }
|
||||
err = assert_raises(ArgumentError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "svc-x", env_id: "env-prod",
|
||||
image: "ghcr.io/copilotkit/x:latest",
|
||||
sleeper: ->(_) {})
|
||||
end
|
||||
assert_match(/pin_and_verify.*@sha256.*pinned/i, err.message,
|
||||
"ArgumentError message must explain the digest requirement; got: #{err.message}")
|
||||
end
|
||||
|
||||
# =================== FIX-8: pin_and_verify ts gate is non-vacuous ===================
|
||||
|
||||
def test_fix8_pin_and_verify_requires_non_nil_updated_at_even_when_pre_ts_nil
|
||||
# pre_update_ts is nil (new prod instance). Recheck returns matching
|
||||
# digest but a nil updatedAt. Pre-fix: ts_ok was vacuously true → success.
|
||||
# After fix: ts_ok requires actual_ts to be non-nil → kept retrying →
|
||||
# MutationError after RETRY_COUNT attempts.
|
||||
gql = Object.new
|
||||
pinned = nil
|
||||
gql.define_singleton_method(:query) do |q, vars = {}|
|
||||
if q.include?("serviceInstanceUpdate")
|
||||
pinned = vars.dig(:input, :source, :image)
|
||||
{ "serviceInstanceUpdate" => true }
|
||||
elsif q.include?("serviceInstanceDeployV2")
|
||||
{ "serviceInstanceDeployV2" => "dep-new" }
|
||||
elsif q.include?("ServiceInstanceRecheck")
|
||||
if pinned.nil?
|
||||
# Pre-update: brand-new instance — both fields are nil.
|
||||
{ "serviceInstance" => nil }
|
||||
else
|
||||
{ "serviceInstance" => { "id" => "i",
|
||||
"source" => { "image" => pinned },
|
||||
"updatedAt" => nil } }
|
||||
end
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
err = assert_raises(Railway::PromoteCommand::MutationError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "svc-x", env_id: "env-prod",
|
||||
image: "ghcr.io/copilotkit/x@sha256:abc",
|
||||
sleeper: ->(_) {})
|
||||
end
|
||||
assert_match(/did not observe image advance/i, err.message,
|
||||
"expected timeout-style MutationError; got: #{err.message}")
|
||||
end
|
||||
|
||||
# =================== FIX-9: run_staging_probe rescue on launch failure ===================
|
||||
|
||||
def test_fix9_run_staging_probe_returns_clean_failure_on_io_popen_error
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
# The probe binary must APPEAR present so we reach the IO.popen call.
|
||||
# (Skip the File.exist? early-return.)
|
||||
original_exist = File.method(:exist?)
|
||||
File.define_singleton_method(:exist?) { |_path| true }
|
||||
# Stub IO.popen to raise Errno::ENOENT (npx missing on PATH).
|
||||
original_popen = IO.method(:popen)
|
||||
IO.define_singleton_method(:popen) do |*_args, **_kw, &_blk|
|
||||
raise Errno::ENOENT, "npx"
|
||||
end
|
||||
begin
|
||||
result = cmd.send(:run_staging_probe, services: ["x"])
|
||||
assert_equal false, result[:ok], "must return ok:false on launch failure"
|
||||
assert_match(/staging probe failed to launch.*ENOENT/i, result[:summary],
|
||||
"summary must describe the launch failure; got: #{result[:summary].inspect}")
|
||||
ensure
|
||||
IO.define_singleton_method(:popen, &original_popen)
|
||||
File.define_singleton_method(:exist?, &original_exist)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,195 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# execute_promotion must resolve any staging tag (e.g. ":latest") to its
|
||||
# concrete GHCR digest and pin THAT to prod — never a mutable tag. This is
|
||||
# the core invariant of the showcase deploy model (P6 enforces shape).
|
||||
class PromoteExecuteTest < Minitest::Test
|
||||
# A FakeGQL that returns the most-recently-pinned image on recheck,
|
||||
# so pin_and_verify sees the advance. Records all calls for assertions.
|
||||
class RecordingGQL
|
||||
def initialize(pre_ts: "2026-05-28T00:00:00Z")
|
||||
@calls = []
|
||||
@pinned_image = nil
|
||||
@pre_ts = pre_ts
|
||||
end
|
||||
attr_reader :calls
|
||||
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
if q.include?("serviceInstanceUpdate")
|
||||
@pinned_image = vars.dig(:input, :source, :image)
|
||||
{ "serviceInstanceUpdate" => true }
|
||||
elsif q.include?("serviceInstanceDeployV2")
|
||||
# New deployment spawned; returns the new deployment id.
|
||||
{ "serviceInstanceDeployV2" => "dep-new" }
|
||||
elsif q.include?("ServiceInstanceRecheck")
|
||||
if @pinned_image.nil?
|
||||
# Pre-update snapshot.
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/x@sha256:OLD" },
|
||||
"updatedAt" => @pre_ts,
|
||||
},
|
||||
}
|
||||
else
|
||||
# Post-update: config advanced AND the new deployment
|
||||
# (dep-new) has SUCCEEDED serving the pinned digest, so both
|
||||
# the config recheck and the bug-#2 serving gate pass.
|
||||
pinned_digest = @pinned_image.include?("@") ? @pinned_image.split("@", 2).last : nil
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => @pinned_image },
|
||||
"updatedAt" => "2026-05-29T00:00:01Z",
|
||||
"latestDeployment" => {
|
||||
"id" => "dep-new", "status" => "SUCCESS",
|
||||
"meta" => { "imageDigest" => pinned_digest },
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# Find the `image:` arg passed to the serviceInstanceUpdate mutation.
|
||||
def pinned_image
|
||||
row = @calls.find { |q, _| q.include?("serviceInstanceUpdate") }
|
||||
row && row[1].dig(:input, :source, :image)
|
||||
end
|
||||
end
|
||||
|
||||
# FakeGHCR that maps tag-form refs to a digest, and reports :exists for
|
||||
# the corresponding digest-pinned ref so P1 passes.
|
||||
class FakeGHCR
|
||||
# `resolve_map` is { "ghcr.io/org/name:tag" => "sha256:abc..." } or nil for unresolvable.
|
||||
# `exists_set` is the set of digest-pinned refs that report :exists.
|
||||
def initialize(resolve_map: {}, exists_set: nil)
|
||||
@resolve_map = resolve_map
|
||||
@exists_set = exists_set
|
||||
end
|
||||
|
||||
def resolve_digest(ref)
|
||||
# Pass-through for already-pinned refs.
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
@resolve_map[ref]
|
||||
end
|
||||
|
||||
def manifest_exists(ref)
|
||||
return :missing if @exists_set && !@exists_set.include?(ref)
|
||||
:exists
|
||||
end
|
||||
|
||||
# Delegate to the real GHCR parser — pure function, no I/O.
|
||||
def parse_image_ref(ref)
|
||||
Railway::GHCR.allocate.parse_image_ref(ref)
|
||||
end
|
||||
end
|
||||
|
||||
# Build a command with staging-tag service, snapshot the prod target, and
|
||||
# inject fakes. Returns [cmd, gql].
|
||||
def build_cmd(staging_image:, resolve_map:, exists_set: nil)
|
||||
# --confirm-divergence: retained as a harmless no-op (the only finding,
|
||||
# missing EXPECTED_DOMAINS, is now ADVISORY and never blocks); we are
|
||||
# testing pin behavior. All CRITICAL_ENV_KEYS present so the (now
|
||||
# unconditional) critical env-key presence assertion does not fire.
|
||||
cmd = Railway::PromoteCommand.new(["--non-interactive", "--yes", "--confirm-divergence"])
|
||||
cmd.parser.parse!(cmd.argv)
|
||||
cmd.instance_variable_set(:@staging_snapshot, {
|
||||
"services" => [{
|
||||
"name" => "x", "service_id" => "svc-staging",
|
||||
"image" => staging_image,
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}],
|
||||
})
|
||||
cmd.instance_variable_set(:@prod_snapshot, {
|
||||
"services" => [{
|
||||
"name" => "x", "service_id" => "svc-prod",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:OLD",
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}],
|
||||
})
|
||||
gql = RecordingGQL.new
|
||||
cmd.instance_variable_set(:@gql, gql)
|
||||
cmd.instance_variable_set(:@ghcr, FakeGHCR.new(resolve_map: resolve_map, exists_set: exists_set))
|
||||
# resolved_prod_image pins staging's RUNNING digest, sourced from the
|
||||
# latest SUCCESS deployment's meta.imageDigest (image-drift.ts mechanism).
|
||||
# Map the staging RUNNING digest from resolve_map (the test's notion of
|
||||
# the resolvable digest) or, for an already-digest-pinned staging image,
|
||||
# the embedded digest. When resolve_map is EMPTY *and* the image is
|
||||
# tag-only, the deployment carries NO imageDigest — modelling the
|
||||
# "no running digest resolvable" REFUSE path (replaces the old
|
||||
# "GHCR :latest unresolvable" REFUSE).
|
||||
running_digest = resolve_map.values.first ||
|
||||
(staging_image.include?("@") ? staging_image.split("@", 2).last : nil)
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |_svc_id|
|
||||
meta = { "image" => "ghcr.io/copilotkit/x:latest" }
|
||||
meta["imageDigest"] = running_digest if running_digest
|
||||
[{ "id" => "d", "status" => "SUCCESS", "meta" => meta }]
|
||||
end
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
[cmd, gql]
|
||||
end
|
||||
|
||||
# Silence pin_and_verify's 10s-per-retry waits. RETRY_DELAY_SEC is a
|
||||
# constant on PromoteCommand; remap to 0 around the test body and restore.
|
||||
def with_fast_sleeper
|
||||
original = Railway::PromoteCommand.const_get(:RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, 0)
|
||||
yield
|
||||
ensure
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, original)
|
||||
end
|
||||
|
||||
def test_resolves_staging_tag_to_digest_and_pins_digest_to_prod
|
||||
# (a) staging image is `:latest`; resolve_map maps it to a digest.
|
||||
cmd, gql = build_cmd(
|
||||
staging_image: "ghcr.io/copilotkit/x:latest",
|
||||
resolve_map: { "ghcr.io/copilotkit/x:latest" => "sha256:abc123" },
|
||||
)
|
||||
out, _ = with_fast_sleeper { capture_io { @rc = cmd.run_with_preflight_only } }
|
||||
assert_equal 0, @rc, "promote should succeed when staging tag resolves cleanly; got out=#{out}"
|
||||
pinned = gql.pinned_image
|
||||
assert_equal "ghcr.io/copilotkit/x@sha256:abc123", pinned,
|
||||
"must pin the DIGEST-form ref, not the :latest tag; pinned=#{pinned.inspect}"
|
||||
refute_includes pinned.to_s, ":latest", "must not pin a mutable tag"
|
||||
assert_match(/promoted x -> ghcr\.io\/copilotkit\/x@sha256:abc123/, out)
|
||||
end
|
||||
|
||||
def test_refuses_when_staging_tag_cannot_be_resolved_to_digest
|
||||
# (b) staging :latest that GHCR cannot resolve (resolve_digest returns nil)
|
||||
# → REFUSE; serviceInstanceUpdate is NEVER called.
|
||||
cmd, gql = build_cmd(
|
||||
staging_image: "ghcr.io/copilotkit/x:latest",
|
||||
resolve_map: {}, # unresolvable
|
||||
)
|
||||
out, _ = with_fast_sleeper { capture_io { @rc = cmd.run_with_preflight_only } }
|
||||
assert_equal 1, @rc, "must refuse when staging tag is unresolvable"
|
||||
assert_match(/cannot resolve .*:latest.* GHCR digest/i, out)
|
||||
refuses_update = gql.calls.any? { |q, _| q.include?("serviceInstanceUpdate") }
|
||||
refute refuses_update, "must NOT call serviceInstanceUpdate when refusing on unresolvable tag"
|
||||
end
|
||||
|
||||
def test_already_digest_pinned_staging_image_is_used_as_is
|
||||
# (c) Unit test of the resolved-prod-image helper. A staging service
|
||||
# whose image is already digest-pinned must be passed through unchanged
|
||||
# (no GHCR tag lookup needed, no rewrite). (This shape is irregular for
|
||||
# showcase staging — P6 would normally REFUSE staging != :tag — but the
|
||||
# helper itself must be safe and idempotent.)
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.instance_variable_set(:@ghcr, FakeGHCR.new(resolve_map: {}))
|
||||
svc = { "name" => "x", "image" => "ghcr.io/copilotkit/x@sha256:def456" }
|
||||
assert_equal "ghcr.io/copilotkit/x@sha256:def456",
|
||||
cmd.send(:resolved_prod_image, svc)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,286 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# bin/railway promote — fleet_*/target_* accessor invariant regression.
|
||||
#
|
||||
# This is the PINNING test for the convention enforced by the
|
||||
# fleet_staging/fleet_prod/target_staging/target_prod accessors. The
|
||||
# accessors only exist because TWO prior bugs were caused by a
|
||||
# fleet-scoped invariant reading the post-narrowing snapshot ivar:
|
||||
#
|
||||
# (1) check_expected_prod_domains read the narrowed prod → fleet-wide
|
||||
# public hosts looked "missing" on every single-service promote →
|
||||
# spurious WARN → workflow refused without --confirm-divergence.
|
||||
# (2) check_service_set_parity diffed narrowed staging vs narrowed prod
|
||||
# (always equal post-narrow) → the invariant became a tautology
|
||||
# and the "target-absent-from-prod" silent-skip path was no longer
|
||||
# gated by a REFUSE.
|
||||
#
|
||||
# The fix replaced raw ivar reads in run_with_preflight_only with calls
|
||||
# to fleet_*/target_* accessors. This test pins the contract that those
|
||||
# accessors must point at DIFFERENT views when a single-service narrow
|
||||
# has been applied — i.e. flipping a fleet-scoped read to the target
|
||||
# view (or vice versa) must produce a regression we can see.
|
||||
#
|
||||
# Concretely: with a healthy fleet narrowed to a single domain-less
|
||||
# service, the promote must succeed silently; but if we monkey-patch
|
||||
# fleet_prod to return target_prod (the WRONG view), the same fixture
|
||||
# must produce the historic spurious "WARN: production missing expected
|
||||
# custom domains" finding. Symmetrically, swapping fleet_staging to
|
||||
# target_staging makes the service-set-parity check tautological — we
|
||||
# pin that the un-swapped version still catches a real fleet-shape
|
||||
# divergence (target-absent-from-prod surfaces as REFUSE).
|
||||
#
|
||||
# Sister spec: test_promote_single_service_fleet_invariants.rb pins the
|
||||
# behavioral CONTRACT (rc=0 on healthy fleet, REFUSE on absent target).
|
||||
# This spec pins that the accessor INDIRECTION is what enforces that
|
||||
# contract, so a future "simplify back to raw ivars" rewrite trips a
|
||||
# red light.
|
||||
|
||||
require_relative "spec_helper"
|
||||
require "stringio"
|
||||
|
||||
class PromoteFleetTargetInvariantTest < Minitest::Test
|
||||
# Reuse the inline fakes pattern from
|
||||
# test_promote_single_service_fleet_invariants.rb — kept inline so
|
||||
# this spec is self-contained and unaffected by changes in sibling
|
||||
# fixtures.
|
||||
class FakeGQLBenign
|
||||
attr_reader :calls
|
||||
def initialize
|
||||
@calls = []
|
||||
@pinned_by_service = {}
|
||||
@ts_counter = 0
|
||||
end
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
sid = vars[:serviceId]
|
||||
if q.include?("serviceInstanceUpdate")
|
||||
@pinned_by_service[sid] = vars.dig(:input, :source, :image)
|
||||
{ "serviceInstanceUpdate" => true }
|
||||
elsif q.include?("serviceInstanceDeployV2")
|
||||
{ "serviceInstanceDeployV2" => "dep-#{sid}" }
|
||||
elsif q.include?("ServiceInstanceRecheck")
|
||||
pinned = @pinned_by_service[sid]
|
||||
if pinned.nil?
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/old@sha256:OLD" },
|
||||
"updatedAt" => "2026-05-28T00:00:00Z",
|
||||
},
|
||||
}
|
||||
else
|
||||
@ts_counter += 1
|
||||
pinned_digest = pinned.include?("@") ? pinned.split("@", 2).last : nil
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => pinned },
|
||||
"updatedAt" => "2026-05-29T00:00:#{format('%02d', @ts_counter)}Z",
|
||||
"latestDeployment" => {
|
||||
"id" => "dep-#{sid}", "status" => "SUCCESS",
|
||||
"meta" => { "imageDigest" => pinned_digest },
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
else
|
||||
{ "deployments" => { "edges" => [] } }
|
||||
end
|
||||
end
|
||||
|
||||
def pinned_services
|
||||
@calls.select { |q, _| q.include?("serviceInstanceUpdate") }
|
||||
.map { |_, vars| [vars[:serviceId], vars.dig(:input, :source, :image)] }
|
||||
end
|
||||
end
|
||||
|
||||
class FakeGHCR
|
||||
def initialize(resolve_map: {})
|
||||
@resolve_map = resolve_map
|
||||
end
|
||||
def resolve_digest(ref)
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
@resolve_map[ref] || "sha256:default_digest_for_#{ref.sub(/[^a-z0-9]/i, '_')[0, 16]}"
|
||||
end
|
||||
def manifest_exists(_ref); :exists; end
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
def make_staging_service(name)
|
||||
{
|
||||
"name" => name, "service_id" => "svc-#{name}",
|
||||
"image" => "ghcr.io/copilotkit/#{name}:latest",
|
||||
# All CRITICAL_ENV_KEYS present so the (now unconditional) critical
|
||||
# env-key presence assertion does not fire — this spec isolates the
|
||||
# fleet/target accessor invariant, not env-key parity.
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}
|
||||
end
|
||||
|
||||
def make_prod_service(name, custom_domains: [])
|
||||
{
|
||||
"name" => name, "service_id" => "prod-#{name}",
|
||||
"image" => "ghcr.io/copilotkit/#{name}@sha256:OLD#{name.gsub(/[^a-z0-9]/i, '')}",
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
"custom_domains" => custom_domains,
|
||||
}
|
||||
end
|
||||
|
||||
# A healthy fleet whose UNION of prod custom_domains covers the
|
||||
# SSOT-published EXPECTED_DOMAINS[PRODUCTION_ENV_ID] set. The
|
||||
# targeted service ("aimock") is intentionally domain-less so that
|
||||
# the NARROWED prod view (target_prod) does NOT carry the public
|
||||
# hosts — the post-narrowing view is precisely the broken view a
|
||||
# fleet-scoped check must NOT see.
|
||||
def install_fleet_fixture(cmd, gql, ghcr, target: "aimock")
|
||||
domain_services_prod = [
|
||||
make_prod_service("dashboard", custom_domains: ["dashboard.showcase.copilotkit.ai"]),
|
||||
make_prod_service("docs", custom_domains: ["docs.copilotkit.ai"]),
|
||||
make_prod_service("dojo", custom_domains: ["dojo.showcase.copilotkit.ai"]),
|
||||
make_prod_service("webhooks", custom_domains: ["hooks.showcase.copilotkit.ai"]),
|
||||
make_prod_service("shell", custom_domains: ["showcase.copilotkit.ai"]),
|
||||
make_prod_service(target),
|
||||
make_prod_service("harness"),
|
||||
]
|
||||
staging_services = (domain_services_prod.map { |s| s["name"] }).uniq.map { |n| make_staging_service(n) }
|
||||
|
||||
cmd.instance_variable_set(:@staging_snapshot, { "services" => staging_services })
|
||||
cmd.instance_variable_set(:@prod_snapshot, { "services" => domain_services_prod })
|
||||
cmd.instance_variable_set(:@gql, gql)
|
||||
cmd.instance_variable_set(:@ghcr, ghcr)
|
||||
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |service_id|
|
||||
name = service_id.sub(/^svc-/, "")
|
||||
ghcr_obj = instance_variable_get(:@ghcr)
|
||||
digest = ghcr_obj.resolve_digest("ghcr.io/copilotkit/#{name}:latest")
|
||||
[{ "id" => "d", "status" => "SUCCESS", "meta" => { "image" => "ghcr.io/copilotkit/#{name}@#{digest}" } }]
|
||||
end
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
end
|
||||
|
||||
def build_cmd(argv)
|
||||
# CRITICAL: mirror real workflow — no --confirm-divergence.
|
||||
Railway::PromoteCommand.new(argv + ["--non-interactive", "--yes"])
|
||||
end
|
||||
|
||||
def with_fast_sleeper
|
||||
original = Railway::PromoteCommand.const_get(:RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, 0)
|
||||
yield
|
||||
ensure
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, original)
|
||||
end
|
||||
|
||||
# ── Positive: with the CORRECT accessor wiring, a healthy
|
||||
# single-service promote produces neither spurious domain WARN nor
|
||||
# spurious set-parity REFUSE. This is the "green" half of the gate.
|
||||
def test_correct_accessors_produce_no_spurious_fleet_findings
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: { "ghcr.io/copilotkit/aimock:latest" => "sha256:NEW_AIMOCK" })
|
||||
cmd = build_cmd(["aimock"])
|
||||
install_fleet_fixture(cmd, gql, ghcr, target: "aimock")
|
||||
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
|
||||
assert_equal 0, rc,
|
||||
"healthy fleet, single-service promote, correct accessors → rc=0. " \
|
||||
"Got rc=#{rc.inspect}; out=\n#{out}"
|
||||
refute_match(/WARN: production missing expected custom domains/, out,
|
||||
"fleet_prod must see the FULL prod fleet — no spurious 'missing " \
|
||||
"fleet domains' WARN should fire when the fleet legitimately " \
|
||||
"carries every EXPECTED_DOMAINS host")
|
||||
refute_match(/REFUSE: services in (?:staging|prod) not in (?:prod|staging)/, out,
|
||||
"fleet_staging/fleet_prod must see the FULL fleet — set-parity " \
|
||||
"must not surface a spurious REFUSE when the fleet is in sync")
|
||||
end
|
||||
|
||||
# ── Gate: if a future refactor flips check_expected_prod_domains to
|
||||
# read the NARROWED view (i.e. target_prod instead of fleet_prod),
|
||||
# the same fixture must produce the historic spurious WARN. We
|
||||
# simulate that flip by monkey-patching fleet_prod on the instance
|
||||
# to return target_prod (the wrong view), then assert the WARN
|
||||
# reappears. This makes the accessor distinction load-bearing.
|
||||
def test_swapping_fleet_prod_to_target_prod_recreates_spurious_domain_finding
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: { "ghcr.io/copilotkit/aimock:latest" => "sha256:NEW_AIMOCK" })
|
||||
cmd = build_cmd(["aimock"])
|
||||
install_fleet_fixture(cmd, gql, ghcr, target: "aimock")
|
||||
|
||||
# Flip fleet_prod → target_prod (the broken pre-fix wiring).
|
||||
# Use a singleton method that delegates to target_prod so the
|
||||
# narrowing applied by `run` still drives the result.
|
||||
#
|
||||
# We also flip fleet_staging in lockstep so the SET-PARITY
|
||||
# check stays clean (full-fleet staging vs narrowed prod would
|
||||
# mismatch on every other service name and surface as a REFUSE
|
||||
# that short-circuits the finding). Isolating the BUG #1 symptom
|
||||
# requires both reads to be uniformly broken — which is exactly
|
||||
# the regression we're pinning against (a sweeping refactor
|
||||
# that rewires both accessors at once).
|
||||
cmd.define_singleton_method(:fleet_prod) { send(:target_prod) }
|
||||
cmd.define_singleton_method(:fleet_staging) { send(:target_staging) }
|
||||
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
|
||||
# Per the 2026-06-22 prod↔staging comparison policy, missing expected
|
||||
# prod domains is now an ADVISORY (report-only) finding, not a blocking
|
||||
# WARN. The accessor distinction is STILL load-bearing: reading the
|
||||
# narrowed view surfaces the spurious "missing domains" finding that the
|
||||
# full-fleet view would not. We pin that the finding REAPPEARS (proving
|
||||
# the accessor wiring matters) — but because it is advisory it no longer
|
||||
# blocks the promote.
|
||||
assert_match(/ADVISORY: production missing expected custom domains/, out,
|
||||
"swapping fleet_prod to target_prod must reproduce the original " \
|
||||
"BUG #1 symptom (spurious 'missing fleet domains' finding on a " \
|
||||
"healthy fleet narrowed to a domain-less service). If this " \
|
||||
"assertion fails, the accessor distinction is no longer " \
|
||||
"load-bearing — check_expected_prod_domains may have been " \
|
||||
"moved or its argument source changed."
|
||||
)
|
||||
assert_equal 0, rc,
|
||||
"the spurious domain finding is now ADVISORY, so it must NOT " \
|
||||
"block the promote (the historic blocking WARN was demoted)"
|
||||
end
|
||||
|
||||
# ── Symmetric gate: if a future refactor flips
|
||||
# check_service_set_parity to read the narrowed staging/prod, the
|
||||
# invariant becomes tautological and the target-absent-from-prod
|
||||
# case stops surfacing a REFUSE. Pin that the un-swapped version
|
||||
# catches a real fleet-shape divergence (target only in staging).
|
||||
def test_correct_accessors_catch_target_absent_from_prod
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: { "ghcr.io/copilotkit/aimock:latest" => "sha256:NEW_AIMOCK" })
|
||||
cmd = build_cmd(["aimock"])
|
||||
install_fleet_fixture(cmd, gql, ghcr, target: "aimock")
|
||||
|
||||
# Surgically remove "aimock" from prod ONLY (full fleet keeps
|
||||
# all other services + all expected domains). Staging keeps
|
||||
# "aimock". After narrowing both snapshots to "aimock", a check
|
||||
# reading the narrowed view would see [aimock] vs [] — which
|
||||
# superficially seems to also catch the divergence — but the
|
||||
# POINT of using fleet_* is so that the SAME check fires
|
||||
# regardless of whether the run is full-fleet or single-service.
|
||||
full_prod = cmd.instance_variable_get(:@prod_snapshot)
|
||||
full_prod = full_prod.merge("services" => full_prod["services"].reject { |s| s["name"] == "aimock" })
|
||||
cmd.instance_variable_set(:@prod_snapshot, full_prod)
|
||||
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
|
||||
refute_equal 0, rc,
|
||||
"target absent from prod must FAIL LOUD (nonzero rc). Got " \
|
||||
"rc=#{rc.inspect}; out=\n#{out}"
|
||||
assert_match(/REFUSE: services in staging not in prod/, out,
|
||||
"fleet_staging vs fleet_prod must surface the staging-only " \
|
||||
"target as a clear REFUSE")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,107 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# Re-assertion of the SSOT healthcheckPath on the promote pin (the durable fix
|
||||
# for the aimock silent-null incident). pin_and_verify must:
|
||||
# - INCLUDE healthcheckPath in the serviceInstanceUpdate input when the SSOT
|
||||
# declares a path for the service+env (e.g. aimock -> /health), and
|
||||
# - OMIT it entirely (never send `healthcheckPath: null`) when the SSOT tracks
|
||||
# none (a live-null service like docs), so a null-live service is never
|
||||
# accidentally cleared OR set to a wrong path.
|
||||
class PromoteHealthcheckReassertTest < Minitest::Test
|
||||
class FakeGQL
|
||||
def initialize(plan); @plan = plan; @calls = []; end
|
||||
attr_reader :calls
|
||||
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
step = @plan.shift
|
||||
raise "fake exhausted at call ##{@calls.size}: #{q[0, 40]}" unless step
|
||||
raise step[:raise] if step[:raise]
|
||||
step[:data]
|
||||
end
|
||||
end
|
||||
|
||||
NEW_DEPLOY_ID = "dep-new"
|
||||
|
||||
def pre(ts) = { data: { "serviceInstance" => { "id" => "i", "source" => { "image" => "ghcr.io/copilotkit/x@sha256:OLD" }, "updatedAt" => ts } } }
|
||||
def post(image:, ts:) = { data: { "serviceInstance" => { "id" => "i", "source" => { "image" => image }, "updatedAt" => ts } } }
|
||||
def deploy_ok(id = NEW_DEPLOY_ID) = { data: { "serviceInstanceDeployV2" => id } }
|
||||
|
||||
def serving(digest:, status: "SUCCESS", deploy_id: NEW_DEPLOY_ID)
|
||||
{
|
||||
data: {
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/x@#{digest}" },
|
||||
"updatedAt" => "2026-05-28T03:00:00Z",
|
||||
"latestDeployment" => {
|
||||
"id" => deploy_id, "status" => status,
|
||||
"meta" => { "imageDigest" => digest },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def happy_plan
|
||||
[
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
post(image: "ghcr.io/copilotkit/x@sha256:NEW", ts: "2026-05-28T01:00:00Z"),
|
||||
serving(digest: "sha256:NEW"),
|
||||
]
|
||||
end
|
||||
|
||||
# The serviceInstanceUpdate is always the 2nd GQL call (after the pre-update
|
||||
# snapshot). Returns [query_string, vars] for that call.
|
||||
def update_call(gql) = gql.calls[1]
|
||||
|
||||
def test_includes_healthcheck_path_when_ssot_declares_one
|
||||
gql = FakeGQL.new(happy_plan)
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
healthcheck_path: "/health", sleeper: ->(_n) {})
|
||||
|
||||
query, vars = update_call(gql)
|
||||
# The whole input rides as a single $input: ServiceInstanceUpdateInput!
|
||||
# variable; healthcheckPath is a NESTED key of that input (Railway infers
|
||||
# its type from the input schema). See deploy-to-railway.ts /
|
||||
# provision-starter-fleet.ts, which set healthcheckPath the same way.
|
||||
assert_match(/\$input:\s*ServiceInstanceUpdateInput!/, query,
|
||||
"update mutation should pass a single $input: ServiceInstanceUpdateInput! variable")
|
||||
assert_match(/input:\s*\$input/, query,
|
||||
"serviceInstanceUpdate should receive the input via $input")
|
||||
assert_equal "/health", vars.dig(:input, :healthcheckPath),
|
||||
"input.healthcheckPath should carry the SSOT healthcheckPath"
|
||||
end
|
||||
|
||||
def test_omits_healthcheck_path_when_ssot_has_none
|
||||
gql = FakeGQL.new(happy_plan)
|
||||
# nil healthcheck_path == a live-null service (e.g. docs).
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
healthcheck_path: nil, sleeper: ->(_n) {})
|
||||
|
||||
query, vars = update_call(gql)
|
||||
# The image-only mutation must be used: NO healthcheckPath anywhere — we
|
||||
# must NEVER send `healthcheckPath: null`, which would clear it.
|
||||
refute_match(/healthcheckPath/, query,
|
||||
"image-only mutation must not mention healthcheckPath")
|
||||
refute vars.dig(:input)&.key?(:healthcheckPath),
|
||||
"input must omit healthcheckPath for a live-null service"
|
||||
end
|
||||
|
||||
def test_empty_string_path_is_treated_as_omitted
|
||||
gql = FakeGQL.new(happy_plan)
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
healthcheck_path: "", sleeper: ->(_n) {})
|
||||
|
||||
query, vars = update_call(gql)
|
||||
refute_match(/healthcheckPath/, query)
|
||||
refute vars.dig(:input)&.key?(:healthcheckPath)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class PromoteP1Test < Minitest::Test
|
||||
# Stub gql + ghcr clients used by PromoteCommand.
|
||||
# Preflight checks accumulate findings before any short-circuit, so even
|
||||
# on a P1 REFUSE the P2 deployments query is still issued. Provide a
|
||||
# benign empty-deployments shape so the test focuses solely on P1.
|
||||
class FakeGQLEmpty
|
||||
def query(*); { "deployments" => { "edges" => [] } }; end
|
||||
end
|
||||
|
||||
class FakeGHCR
|
||||
def initialize(result); @result = result; end
|
||||
def manifest_exists(_ref); @result; end
|
||||
# Tag refs resolve to a synthetic digest; digest refs pass through.
|
||||
# Returning a stable digest makes the resolved ref deterministic so
|
||||
# the existing P1 tests (which staged digest-pinned images) continue
|
||||
# to verify the same code path.
|
||||
def resolve_digest(ref)
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
"sha256:fake_resolved_digest"
|
||||
end
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
def test_refuses_when_digest_missing_in_ghcr
|
||||
cmd = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
# Inject deterministic precondition input — bypass live snapshot capture.
|
||||
cmd.instance_variable_set(:@staging_snapshot, {
|
||||
"services" => [{
|
||||
"name" => "showcase-shell",
|
||||
"service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/showcase-shell@sha256:deadbeef",
|
||||
"image_tag" => "ghcr.io/copilotkit/showcase-shell:latest",
|
||||
"digest" => "sha256:deadbeef",
|
||||
"env_keys" => [],
|
||||
}],
|
||||
})
|
||||
cmd.instance_variable_set(:@prod_snapshot, { "services" => [] })
|
||||
cmd.instance_variable_set(:@gql, FakeGQLEmpty.new)
|
||||
cmd.instance_variable_set(:@ghcr, FakeGHCR.new(:missing))
|
||||
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
out, _err = capture_io { @rc = cmd.run_with_preflight_only }
|
||||
assert_equal 1, @rc, "promote must exit 1 on REFUSE"
|
||||
assert_match(/REFUSE: P1.*ghcr\.io.*not found in GHCR/i, out)
|
||||
end
|
||||
|
||||
def test_refuses_with_clear_message_on_auth_failed
|
||||
cmd = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
cmd.instance_variable_set(:@staging_snapshot, {
|
||||
"services" => [{ "name" => "x", "service_id" => "s", "image" => "ghcr.io/copilotkit/x@sha256:abc", "env_keys" => [] }],
|
||||
})
|
||||
cmd.instance_variable_set(:@prod_snapshot, { "services" => [] })
|
||||
# All preflight checks accumulate findings before any short-circuit;
|
||||
# P2 will still query gql, so use the benign empty fake.
|
||||
cmd.instance_variable_set(:@gql, FakeGQLEmpty.new)
|
||||
cmd.instance_variable_set(:@ghcr, FakeGHCR.new(:auth_failed))
|
||||
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
out, _err = capture_io { @rc = cmd.run_with_preflight_only }
|
||||
assert_equal 1, @rc
|
||||
assert_match(/REFUSE: P1.*GHCR.*auth/i, out)
|
||||
assert_match(/GHCR_TOKEN.*or.*GITHUB_TOKEN/i, out)
|
||||
end
|
||||
|
||||
def test_passes_p1_when_digest_exists
|
||||
cmd = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
cmd.instance_variable_set(:@staging_snapshot, {
|
||||
"services" => [{ "name" => "x", "service_id" => "s", "image" => "ghcr.io/copilotkit/x@sha256:abc", "env_keys" => [] }],
|
||||
})
|
||||
cmd.instance_variable_set(:@prod_snapshot, { "services" => [] })
|
||||
# P1 passes here so P2 will run — use the benign empty fake so
|
||||
# the deployments query returns [].
|
||||
cmd.instance_variable_set(:@gql, FakeGQLEmpty.new)
|
||||
cmd.instance_variable_set(:@ghcr, FakeGHCR.new(:exists))
|
||||
|
||||
# P1 alone should not refuse; later checks (service-set parity) will,
|
||||
# but P1's own gate is clean for this fixture.
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
out, _err = capture_io { cmd.run_with_preflight_only }
|
||||
refute_match(/REFUSE: P1/, out)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,161 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class PromoteP2Test < Minitest::Test
|
||||
class FakeGQL
|
||||
def initialize(deployments_by_svc)
|
||||
@deployments_by_svc = deployments_by_svc
|
||||
@calls = []
|
||||
end
|
||||
|
||||
attr_reader :calls
|
||||
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
if q.include?("query Deployments")
|
||||
edges = (@deployments_by_svc[vars[:serviceId]] || []).map { |n| { "node" => n } }
|
||||
return { "deployments" => { "edges" => edges } }
|
||||
end
|
||||
raise "unexpected query: #{q[0,40]}"
|
||||
end
|
||||
end
|
||||
|
||||
def cmd_with(staging:, deployments:)
|
||||
c = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
c.instance_variable_set(:@staging_snapshot, staging)
|
||||
c.instance_variable_set(:@prod_snapshot, { "services" => [] })
|
||||
c.instance_variable_set(:@gql, FakeGQL.new(deployments))
|
||||
c.instance_variable_set(:@ghcr, Object.new.tap do |o|
|
||||
def o.manifest_exists(_); :exists; end
|
||||
def o.resolve_digest(ref); ref.include?("@sha256:") ? ref.split("@", 2).last : "sha256:fake"; end
|
||||
def o.parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end)
|
||||
# Stub P3 probe so the test does not shell out to verify-deploy.ts.
|
||||
c.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
c
|
||||
end
|
||||
|
||||
def test_refuses_when_latest_deployment_not_success
|
||||
staging = { "services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:abc", "digest" => "sha256:abc",
|
||||
"env_keys" => [],
|
||||
}] }
|
||||
deps = { "svc-1" => [
|
||||
{ "id" => "d2", "status" => "FAILED", "meta" => { "image" => "ghcr.io/copilotkit/x@sha256:abc" }, "createdAt" => "2026-05-28T01:00:00Z" },
|
||||
{ "id" => "d1", "status" => "SUCCESS", "meta" => { "image" => "ghcr.io/copilotkit/x@sha256:abc" }, "createdAt" => "2026-05-28T00:00:00Z" },
|
||||
] }
|
||||
out, err = capture_io { @rc = cmd_with(staging: staging, deployments: deps).run_with_preflight_only }
|
||||
combined = out + err
|
||||
assert_equal 1, @rc
|
||||
assert_match(/REFUSE: P2.*x.*latest staging deployment.*FAILED/i, combined)
|
||||
end
|
||||
|
||||
def test_refuses_when_latest_success_image_does_not_match
|
||||
# In-flight race: newer build with a different digest is the latest.
|
||||
staging = { "services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:OLD", "digest" => "sha256:OLD",
|
||||
"env_keys" => [],
|
||||
}] }
|
||||
deps = { "svc-1" => [
|
||||
{ "id" => "d2", "status" => "SUCCESS", "meta" => { "image" => "ghcr.io/copilotkit/x@sha256:NEW" }, "createdAt" => "2026-05-28T01:00:00Z" },
|
||||
] }
|
||||
out, err = capture_io { @rc = cmd_with(staging: staging, deployments: deps).run_with_preflight_only }
|
||||
combined = out + err
|
||||
assert_equal 1, @rc
|
||||
assert_match(/REFUSE: P2.*in-flight.*sha256:NEW.*sha256:OLD/i, combined)
|
||||
end
|
||||
|
||||
def test_does_not_crash_when_deployment_meta_is_a_string
|
||||
# Railway's Deployment.meta is a JSON scalar that can come back as a
|
||||
# String (not a Hash). After FIX-2 we PARSE it first; a non-JSON
|
||||
# string falls back to a WARN, but MUST NOT crash and MUST NOT
|
||||
# produce a P2 REFUSE.
|
||||
staging = { "services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:abc", "digest" => "sha256:abc",
|
||||
"env_keys" => [],
|
||||
}] }
|
||||
deps = { "svc-1" => [
|
||||
{ "id" => "d1", "status" => "SUCCESS",
|
||||
"meta" => "raw-string-not-a-hash",
|
||||
"createdAt" => "2026-05-28T01:00:00Z" },
|
||||
] }
|
||||
out, err = capture_io { cmd_with(staging: staging, deployments: deps).run_with_preflight_only }
|
||||
combined = out + err
|
||||
# MUST NOT crash. P2 race-check is best-effort; SUCCESS is the real gate.
|
||||
refute_match(/REFUSE: P2/, combined)
|
||||
refute_match(/NoMethodError/, combined)
|
||||
end
|
||||
|
||||
def test_passes_p2_when_latest_is_success_and_image_matches
|
||||
staging = { "services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:abc", "digest" => "sha256:abc",
|
||||
"env_keys" => [],
|
||||
}] }
|
||||
deps = { "svc-1" => [
|
||||
{ "id" => "d1", "status" => "SUCCESS", "meta" => { "image" => "ghcr.io/copilotkit/x@sha256:abc" }, "createdAt" => "2026-05-28T01:00:00Z" },
|
||||
] }
|
||||
out, err = capture_io { cmd_with(staging: staging, deployments: deps).run_with_preflight_only }
|
||||
combined = out + err
|
||||
refute_match(/REFUSE: P2/, combined)
|
||||
end
|
||||
|
||||
def test_refuses_when_running_digest_from_meta_imageDigest_does_not_match
|
||||
# REAL staging shape: the staging ref is TAG-FORM (`…:latest`), so the
|
||||
# running digest is NOT in meta.image (which is also tag-form). It lives
|
||||
# in meta.imageDigest. P1's resolved_prod_image pins staging's running
|
||||
# digest (svc-1 below resolves to sha256:OLD via staging_running_digest),
|
||||
# while the LATEST deployment is now running sha256:NEW — an in-flight
|
||||
# race. Before the fix, deployed_digest read meta.image (no `@`) → nil →
|
||||
# the guard was DEAD and never REFUSEd. Now it reads meta.imageDigest.
|
||||
staging = { "services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x:latest", "digest" => nil,
|
||||
"env_keys" => [],
|
||||
}] }
|
||||
deps = { "svc-1" => [
|
||||
{ "id" => "d2", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/x:latest", "imageDigest" => "sha256:NEW" },
|
||||
"createdAt" => "2026-05-28T02:00:00Z" },
|
||||
{ "id" => "d1", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/x:latest", "imageDigest" => "sha256:OLD" },
|
||||
"createdAt" => "2026-05-28T01:00:00Z" },
|
||||
] }
|
||||
# Pin @promote_refs as if P1 resolved the OLDER running digest, so the
|
||||
# latest deployment (sha256:NEW) is a genuine in-flight race.
|
||||
c = cmd_with(staging: staging, deployments: deps)
|
||||
c.instance_variable_set(:@promote_refs, { "x" => "ghcr.io/copilotkit/x@sha256:OLD" })
|
||||
findings = c.send(:check_p2_staging_deployments, staging)
|
||||
combined = findings.join("\n")
|
||||
assert_match(/REFUSE: P2.*in-flight.*sha256:NEW.*sha256:OLD/i, combined)
|
||||
end
|
||||
|
||||
def test_digest_override_skips_p2_race_check
|
||||
# --digest override: operator deliberately pinned an explicit digest for
|
||||
# this single service. @promote_refs then holds the operator's chosen
|
||||
# digest (sha256:CHOSEN), which legitimately differs from staging's
|
||||
# running digest (sha256:NEW). The P2 race comparison MUST be skipped —
|
||||
# otherwise it would emit a spurious REFUSE for the exact case --digest
|
||||
# exists to support.
|
||||
staging = { "services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x:latest", "digest" => nil,
|
||||
"env_keys" => [],
|
||||
}] }
|
||||
deps = { "svc-1" => [
|
||||
{ "id" => "d1", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/x:latest", "imageDigest" => "sha256:NEW" },
|
||||
"createdAt" => "2026-05-28T01:00:00Z" },
|
||||
] }
|
||||
c = cmd_with(staging: staging, deployments: deps)
|
||||
c.instance_variable_set(:@options, c.options.merge(digest: "ghcr.io/copilotkit/x@sha256:CHOSEN", service: "x"))
|
||||
c.instance_variable_set(:@promote_refs, { "x" => "ghcr.io/copilotkit/x@sha256:CHOSEN" })
|
||||
findings = c.send(:check_p2_staging_deployments, staging)
|
||||
combined = findings.join("\n")
|
||||
refute_match(/REFUSE: P2/, combined)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class PromoteP3Test < Minitest::Test
|
||||
def make_cmd(probe_result:, flag:)
|
||||
argv = ["--non-interactive", "--yes"]
|
||||
argv << flag if flag
|
||||
c = Railway::PromoteCommand.new(argv)
|
||||
# run_with_preflight_only skips parser.parse!; parse eagerly so the
|
||||
# --no-require-staging-green / --confirm-divergence flags land.
|
||||
c.parser.parse!(c.argv)
|
||||
c.instance_variable_set(:@staging_snapshot, {
|
||||
"services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x:latest", "digest" => "sha256:abc",
|
||||
"env_keys" => [],
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}],
|
||||
})
|
||||
c.instance_variable_set(:@prod_snapshot, {
|
||||
"services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:abc", "digest" => "sha256:abc",
|
||||
"env_keys" => [],
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}],
|
||||
})
|
||||
c.instance_variable_set(:@gql, Object.new.tap { |o| def o.query(*); { "deployments" => { "edges" => [{ "node" => { "id" => "d", "status" => "SUCCESS", "meta" => { "image" => "ghcr.io/copilotkit/x@sha256:abc" } } }] } }; end })
|
||||
c.instance_variable_set(:@ghcr, Object.new.tap do |o|
|
||||
def o.manifest_exists(_); :exists; end
|
||||
def o.resolve_digest(ref); ref.include?("@sha256:") ? ref.split("@", 2).last : "sha256:abc"; end
|
||||
def o.parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end)
|
||||
# Inject the probe result as a stub.
|
||||
c.define_singleton_method(:run_staging_probe) { |services:| probe_result }
|
||||
c
|
||||
end
|
||||
|
||||
def test_refuses_on_red_probe_when_flag_default_on
|
||||
cmd = make_cmd(probe_result: { ok: false, summary: "x: HTTP 502 from docs.staging.copilotkit.ai" }, flag: nil)
|
||||
out, _ = capture_io { @rc = cmd.run_with_preflight_only }
|
||||
assert_equal 1, @rc
|
||||
assert_match(/REFUSE: P3.*staging.*not green.*HTTP 502/i, out)
|
||||
end
|
||||
|
||||
def test_skips_probe_when_no_require_staging_green
|
||||
cmd = make_cmd(probe_result: { ok: false, summary: "would have failed" }, flag: "--no-require-staging-green")
|
||||
# Fail LOUD if the skip path ever calls the probe — the probe stub
|
||||
# must be unreachable under --no-require-staging-green.
|
||||
cmd.define_singleton_method(:run_staging_probe) do |services:|
|
||||
raise "probe must not run under --no-require-staging-green"
|
||||
end
|
||||
out, _ = capture_io { cmd.run_with_preflight_only }
|
||||
refute_match(/REFUSE: P3/, out)
|
||||
assert_match(/P3 SKIPPED.*--no-require-staging-green/, out)
|
||||
end
|
||||
|
||||
def test_passes_p3_on_green_probe
|
||||
cmd = make_cmd(probe_result: { ok: true, summary: "all green" }, flag: nil)
|
||||
out, _ = capture_io { cmd.run_with_preflight_only }
|
||||
refute_match(/REFUSE: P3/, out)
|
||||
end
|
||||
|
||||
# A service the SSOT marks probe.staging=false (harness-workers) must be
|
||||
# treated as N/A by P3 — NOT handed to the probe (which crashes on a
|
||||
# not-probe-eligible name) and NOT a REFUSE. Before the fix this REFUSEd
|
||||
# via "staging is not green ... not probe-eligible", gating later tiers.
|
||||
def test_ineligible_service_is_skipped_not_refused
|
||||
skip "no probe.staging=false service in SSOT" if Railway::STAGING_PROBE_INELIGIBLE.empty?
|
||||
ineligible = Railway::STAGING_PROBE_INELIGIBLE.first
|
||||
cmd = make_cmd(probe_result: { ok: true, summary: "unused" }, flag: nil)
|
||||
# Fail LOUD if P3 ever hands an ineligible-only set to the probe.
|
||||
cmd.define_singleton_method(:run_staging_probe) do |services:|
|
||||
raise "probe must not run for an ineligible-only set (#{services.inspect})"
|
||||
end
|
||||
staging = { "services" => [{ "name" => ineligible }] }
|
||||
out, _ = capture_io { @findings = cmd.check_p3_staging_live_green(staging) }
|
||||
assert_empty @findings, "P3 must produce no REFUSE for an ineligible-only service"
|
||||
assert_match(/P3 N\/A \(#{Regexp.escape(ineligible)}\).*not staging-probe-eligible/, out)
|
||||
end
|
||||
|
||||
# A mixed set (ineligible + eligible) must skip the ineligible one but
|
||||
# STILL probe — and still gate on — the eligible one.
|
||||
def test_mixed_set_still_probes_eligible
|
||||
skip "no probe.staging=false service in SSOT" if Railway::STAGING_PROBE_INELIGIBLE.empty?
|
||||
ineligible = Railway::STAGING_PROBE_INELIGIBLE.first
|
||||
eligible = Railway::STAGING_SERVICES.find { |n| !Railway::STAGING_PROBE_INELIGIBLE.include?(n) }
|
||||
cmd = make_cmd(probe_result: { ok: false, summary: "#{eligible}: HTTP 502" }, flag: nil)
|
||||
probed = nil
|
||||
cmd.define_singleton_method(:run_staging_probe) do |services:|
|
||||
probed = services
|
||||
{ ok: false, summary: "#{eligible}: HTTP 502" }
|
||||
end
|
||||
staging = { "services" => [{ "name" => ineligible }, { "name" => eligible }] }
|
||||
out, _ = capture_io { @findings = cmd.check_p3_staging_live_green(staging) }
|
||||
assert_equal [eligible], probed, "P3 must probe only the eligible service"
|
||||
assert_match(/P3 N\/A \(#{Regexp.escape(ineligible)}\)/, out)
|
||||
assert_equal 1, @findings.size
|
||||
assert_match(/REFUSE: P3.*#{Regexp.escape(eligible)}.*HTTP 502/, @findings.first)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,195 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class PromoteP5Test < Minitest::Test
|
||||
class FakeGQL
|
||||
def initialize(plan); @plan = plan; @calls = []; end
|
||||
attr_reader :calls
|
||||
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
step = @plan.shift
|
||||
raise "fake exhausted at call ##{@calls.size}: #{q[0,40]}" unless step
|
||||
raise step[:raise] if step[:raise]
|
||||
step[:data] # inner data hash, matching GraphQL#query's return shape
|
||||
end
|
||||
end
|
||||
|
||||
NEW_DEPLOY_ID = "dep-new"
|
||||
|
||||
# Helper: a "pre-update snapshot" GQL response with a given updatedAt.
|
||||
def pre(ts) = { data: { "serviceInstance" => { "id" => "i", "source" => { "image" => "ghcr.io/copilotkit/x@sha256:OLD" }, "updatedAt" => ts } } }
|
||||
|
||||
# Helper: a "post-update re-query" (config recheck) response.
|
||||
def post(image:, ts:) = { data: { "serviceInstance" => { "id" => "i", "source" => { "image" => image }, "updatedAt" => ts } } }
|
||||
|
||||
# Helper: a successful DeployV2 mutation returning a new deployment id.
|
||||
def deploy_ok(id = NEW_DEPLOY_ID) = { data: { "serviceInstanceDeployV2" => id } }
|
||||
|
||||
# Helper: a serving-digest recheck response. The NEW deployment has reached
|
||||
# the given status and serves the given digest. Defaults to the SUCCESS +
|
||||
# pinned-digest happy path.
|
||||
def serving(digest:, status: "SUCCESS", deploy_id: NEW_DEPLOY_ID)
|
||||
{
|
||||
data: {
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/x@#{digest}" },
|
||||
"updatedAt" => "2026-05-28T03:00:00Z",
|
||||
"latestDeployment" => {
|
||||
"id" => deploy_id, "status" => status,
|
||||
"meta" => { "imageDigest" => digest },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def test_refuses_when_update_returns_false
|
||||
# Order: pre-update snapshot, then update (false) — refuse before deploy.
|
||||
gql = FakeGQL.new([
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => false } },
|
||||
])
|
||||
assert_raises(Railway::PromoteCommand::MutationError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
end
|
||||
end
|
||||
|
||||
def test_refuses_when_deploy_v2_returns_no_id
|
||||
# serviceInstanceDeployV2 must return a non-empty deployment id String.
|
||||
gql = FakeGQL.new([
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
{ data: { "serviceInstanceDeployV2" => nil } },
|
||||
])
|
||||
assert_raises(Railway::PromoteCommand::MutationError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
end
|
||||
end
|
||||
|
||||
def test_verifies_image_AND_updatedAt_advanced_then_serving_digest
|
||||
gql = FakeGQL.new([
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
post(image: "ghcr.io/copilotkit/x@sha256:NEW", ts: "2026-05-28T01:00:00Z"),
|
||||
serving(digest: "sha256:NEW"),
|
||||
])
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
assert_equal 5, gql.calls.size
|
||||
end
|
||||
|
||||
def test_refuses_when_new_deployment_serves_wrong_digest
|
||||
# Bug #2: config advanced + DeployV2 spawned, but the NEW deployment
|
||||
# succeeded SERVING a stale digest. Must fail loud.
|
||||
gql = FakeGQL.new([
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
post(image: "ghcr.io/copilotkit/x@sha256:NEW", ts: "2026-05-28T01:00:00Z"),
|
||||
serving(digest: "sha256:STALE"), # new deploy SUCCESS but wrong digest
|
||||
])
|
||||
err = assert_raises(Railway::PromoteCommand::MutationError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
end
|
||||
assert_match(/serving a stale image|SERVES/, err.message)
|
||||
end
|
||||
|
||||
def test_refuses_when_new_deployment_crashes
|
||||
gql = FakeGQL.new([
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
post(image: "ghcr.io/copilotkit/x@sha256:NEW", ts: "2026-05-28T01:00:00Z"),
|
||||
serving(digest: "sha256:NEW", status: "CRASHED"),
|
||||
])
|
||||
err = assert_raises(Railway::PromoteCommand::MutationError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
end
|
||||
assert_match(/terminal status/, err.message)
|
||||
end
|
||||
|
||||
def test_keeps_polling_when_stale_old_deployment_is_terminal
|
||||
# Bug (false-abort): right after DeployV2 spawns new_deployment_id,
|
||||
# latestDeployment may still briefly point to the OLD deployment, whose
|
||||
# status flips to REMOVED as it's superseded. The terminal-status check
|
||||
# must NOT raise on this stale old deployment (deploy.id != new id) — it
|
||||
# must keep polling. On a later poll the NEW deployment becomes latest
|
||||
# with SUCCESS + the pinned digest, so verify_serving_digest! converges.
|
||||
gql = FakeGQL.new([
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
post(image: "ghcr.io/copilotkit/x@sha256:NEW", ts: "2026-05-28T01:00:00Z"),
|
||||
# Early poll: OLD deployment is still latest, now being torn down.
|
||||
serving(digest: "sha256:OLD", status: "REMOVED", deploy_id: "dep-old"),
|
||||
# Later poll: NEW deployment is latest, SUCCESS, serving the pin.
|
||||
serving(digest: "sha256:NEW"),
|
||||
])
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
end
|
||||
|
||||
def test_refuses_when_image_advanced_but_updatedAt_did_NOT_advance
|
||||
# P5 guard for the no-op re-pin / cache-shaped race: image-equality
|
||||
# alone is insufficient. updatedAt MUST strictly advance past
|
||||
# pre_update_ts; if not, three retries then refuse.
|
||||
pre_ts = "2026-05-27T00:00:00Z"
|
||||
stale_ts_post = post(image: "ghcr.io/copilotkit/x@sha256:NEW", ts: pre_ts)
|
||||
gql = FakeGQL.new([
|
||||
pre(pre_ts),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
stale_ts_post, stale_ts_post, stale_ts_post,
|
||||
])
|
||||
assert_raises(Railway::PromoteCommand::MutationError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
end
|
||||
end
|
||||
|
||||
def test_retries_then_refuses_on_stale_image
|
||||
# Three re-queries all report stale image.
|
||||
stale = post(image: "ghcr.io/copilotkit/x@sha256:OLD", ts: "2026-05-27T00:00:00Z")
|
||||
gql = FakeGQL.new([
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
stale, stale, stale,
|
||||
])
|
||||
assert_raises(Railway::PromoteCommand::MutationError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
end
|
||||
end
|
||||
|
||||
def test_accepts_when_third_requery_shows_advance
|
||||
new_ok = post(image: "ghcr.io/copilotkit/x@sha256:NEW", ts: "2026-05-28T02:00:00Z")
|
||||
stale = post(image: "ghcr.io/copilotkit/x@sha256:OLD", ts: "2026-05-27T00:00:00Z")
|
||||
gql = FakeGQL.new([
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
stale, stale, new_ok,
|
||||
serving(digest: "sha256:NEW"),
|
||||
])
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
sleeper: ->(_n) {})
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,162 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class PromoteP6Test < Minitest::Test
|
||||
def cmd_with(staging, prod, flag: nil)
|
||||
argv = ["--non-interactive", "--yes"]
|
||||
argv << flag if flag
|
||||
c = Railway::PromoteCommand.new(argv)
|
||||
# run_with_preflight_only skips the parser.parse!() that #run normally
|
||||
# invokes; parse flags eagerly so --confirm-divergence etc. land in
|
||||
# options[].
|
||||
c.parser.parse!(c.argv)
|
||||
c.instance_variable_set(:@staging_snapshot, staging)
|
||||
c.instance_variable_set(:@prod_snapshot, prod)
|
||||
c.instance_variable_set(:@gql, Object.new.tap { |o| def o.query(*); { "deployments" => { "edges" => [{ "node" => { "id" => "d", "status" => "SUCCESS", "meta" => { "image" => "ghcr.io/copilotkit/x@sha256:abc" } } }] } }; end })
|
||||
c.instance_variable_set(:@ghcr, Object.new.tap do |o|
|
||||
def o.manifest_exists(_); :exists; end
|
||||
def o.resolve_digest(ref); ref.include?("@sha256:") ? ref.split("@", 2).last : "sha256:abc"; end
|
||||
def o.parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end)
|
||||
c.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
c
|
||||
end
|
||||
|
||||
# Defaults represent the "good" parity baseline: staging tag-floating,
|
||||
# prod digest-pinned, all other fields identical. Tests override fields
|
||||
# with the specific divergence under test.
|
||||
def staging_svc(over = {})
|
||||
{
|
||||
"name" => "x", "service_id" => "s", "image" => "ghcr.io/copilotkit/x:latest",
|
||||
"digest" => "sha256:abc", "env_keys" => [],
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}.merge(over)
|
||||
end
|
||||
|
||||
def prod_svc(over = {})
|
||||
{
|
||||
"name" => "x", "service_id" => "s", "image" => "ghcr.io/copilotkit/x@sha256:abc",
|
||||
"digest" => "sha256:abc", "env_keys" => [],
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}.merge(over)
|
||||
end
|
||||
|
||||
def test_refuses_on_start_command_divergence
|
||||
st = { "services" => [staging_svc("start_command" => "node staging.js")] }
|
||||
pr = { "services" => [prod_svc("start_command" => "node prod.js")] }
|
||||
out, _ = capture_io { @rc = cmd_with(st, pr).run_with_preflight_only }
|
||||
assert_equal 1, @rc
|
||||
assert_match(/REFUSE: P6.*x.*startCommand/i, out)
|
||||
end
|
||||
|
||||
def test_refuses_on_healthcheck_path_divergence
|
||||
st = { "services" => [staging_svc("healthcheck_path" => "/health")] }
|
||||
pr = { "services" => [prod_svc("healthcheck_path" => "/healthz")] }
|
||||
out, _ = capture_io { @rc = cmd_with(st, pr).run_with_preflight_only }
|
||||
assert_equal 1, @rc
|
||||
assert_match(/REFUSE: P6.*x.*healthcheckPath/i, out)
|
||||
end
|
||||
|
||||
def test_refuses_on_image_shape_divergence
|
||||
# staging digest-pinned (wrong; expected tag), prod tag-floating
|
||||
# (wrong; expected digest). Both shapes invert the parity.
|
||||
st = { "services" => [staging_svc("image" => "ghcr.io/copilotkit/x@sha256:abc")] }
|
||||
pr = { "services" => [prod_svc("image" => "ghcr.io/copilotkit/x:latest")] }
|
||||
out, _ = capture_io { @rc = cmd_with(st, pr).run_with_preflight_only }
|
||||
assert_equal 1, @rc
|
||||
assert_match(/REFUSE: P6.*x.*image shape/i, out)
|
||||
end
|
||||
|
||||
def test_region_replicas_restartpolicy_are_advisory_non_blocking
|
||||
# region/replicas/restartPolicy are ADVISORY: reported but never block,
|
||||
# with NO --confirm-divergence. env-var key-set diff is DROPPED entirely.
|
||||
# All CRITICAL_ENV_KEYS present so the only findings are advisory.
|
||||
crit = Railway::CRITICAL_ENV_KEYS
|
||||
st = { "services" => [staging_svc("region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE", "env_keys" => crit + ["B"])] }
|
||||
pr = { "services" => [prod_svc( "region" => "us-east", "replicas" => 3, "restart_policy" => "ALWAYS", "env_keys" => crit + ["C"])] }
|
||||
c = cmd_with(st, pr)
|
||||
c.define_singleton_method(:execute_promotion) { |_st, _pr| 0 }
|
||||
out, _ = capture_io { @rc = c.run_with_preflight_only }
|
||||
assert_equal 0, @rc, "ADVISORY findings must NOT block without --confirm-divergence"
|
||||
assert_match(/ADVISORY.*x.*region/i, out)
|
||||
assert_match(/ADVISORY.*x.*replicas/i, out)
|
||||
assert_match(/ADVISORY.*x.*restartPolicy/i, out)
|
||||
# env-var key-set diff is dropped: B/C divergence produces no finding.
|
||||
refute_match(/env key set/i, out)
|
||||
end
|
||||
|
||||
def test_env_var_values_never_compared_message_printed_every_run
|
||||
# Carry CRITICAL_ENV_KEYS so the critical-key parity check passes and the
|
||||
# run reaches the clean-promote path; stub execute_promotion to return 0.
|
||||
# This proves the NOTE prints on a real (rc=0) promote, not just up-front
|
||||
# before an early REFUSE.
|
||||
crit = Railway::CRITICAL_ENV_KEYS
|
||||
st = { "services" => [staging_svc("env_keys" => crit)] }
|
||||
pr = { "services" => [prod_svc("env_keys" => crit)] }
|
||||
c = cmd_with(st, pr)
|
||||
c.define_singleton_method(:execute_promotion) { |_st, _pr| 0 }
|
||||
out, _ = capture_io { @rc = c.run_with_preflight_only }
|
||||
assert_equal 0, @rc, "clean-promote path must be reached (rc=0)"
|
||||
assert_match(/env var VALUES are not compared/i, out)
|
||||
end
|
||||
|
||||
# ── Whitelist parity policy (2026-06-22 prod↔staging comparison policy) ──
|
||||
|
||||
# (a) A prod-only extra env key (the NODE_ENV case) must NOT block after the
|
||||
# env-key-set-diff WARN is dropped. Staging lacks it, prod carries it; the
|
||||
# set diff used to flag this as a blocking WARN. No --confirm-divergence,
|
||||
# so the run must exit 0 (no blocking finding) and emit no env-key-set WARN.
|
||||
def test_prod_only_env_key_does_not_block
|
||||
crit = Railway::CRITICAL_ENV_KEYS
|
||||
st = { "services" => [staging_svc("env_keys" => crit + %w[A])] }
|
||||
pr = { "services" => [prod_svc( "env_keys" => crit + %w[A NODE_ENV])] }
|
||||
c = cmd_with(st, pr)
|
||||
c.define_singleton_method(:execute_promotion) { |_st, _pr| 0 }
|
||||
out, _ = capture_io { @rc = c.run_with_preflight_only }
|
||||
assert_equal 0, @rc, "prod-only env key (NODE_ENV) must not block without --confirm-divergence"
|
||||
refute_match(/env key set divergence/i, out)
|
||||
end
|
||||
|
||||
# (b1) Staging-gated contract: a CRITICAL_ENV_KEYS member present in STAGING
|
||||
# but MISSING from PROD is a real, fixable divergence and must REFUSE.
|
||||
def test_critical_key_in_staging_missing_in_prod_refuses
|
||||
# OPENAI_API_KEY is a CRITICAL_ENV_KEYS member: present in staging, absent from prod.
|
||||
st = { "services" => [staging_svc("env_keys" => %w[A OPENAI_API_KEY])] }
|
||||
pr = { "services" => [prod_svc( "env_keys" => %w[A])] }
|
||||
out, _ = capture_io { @rc = cmd_with(st, pr).run_with_preflight_only }
|
||||
assert_equal 1, @rc, "critical key in staging but missing from prod must REFUSE"
|
||||
assert_match(/REFUSE.*critical env keys missing in prod.*OPENAI_API_KEY/i, out)
|
||||
end
|
||||
|
||||
# (b2) Infra-token tolerance: a CRITICAL_ENV_KEYS member absent from BOTH
|
||||
# staging AND prod (operator/CI/infra tokens like RAILWAY_TOKEN that no
|
||||
# application container carries) must NOT drive the run to REFUSE.
|
||||
def test_critical_key_absent_from_both_envs_does_not_refuse
|
||||
# OPENAI_API_KEY is a CRITICAL_ENV_KEYS member; absent from staging AND prod.
|
||||
st = { "services" => [staging_svc("env_keys" => %w[A])] }
|
||||
pr = { "services" => [prod_svc( "env_keys" => %w[A])] }
|
||||
c = cmd_with(st, pr)
|
||||
c.define_singleton_method(:execute_promotion) { |_st, _pr| 0 }
|
||||
out, _ = capture_io { @rc = c.run_with_preflight_only }
|
||||
assert_equal 0, @rc, "critical key absent from BOTH envs (infra token) must NOT refuse"
|
||||
refute_match(/REFUSE.*critical env keys missing in prod/i, out)
|
||||
end
|
||||
|
||||
# (c) Region/replicas divergence is ADVISORY (report-only, never blocks)
|
||||
# after the change. Today it blocks as a WARN unless --confirm-divergence.
|
||||
# All CRITICAL_ENV_KEYS present in prod so the only findings are advisory.
|
||||
def test_region_replicas_divergence_is_advisory_non_blocking
|
||||
crit = Railway::CRITICAL_ENV_KEYS
|
||||
st = { "services" => [staging_svc("region" => "us-west", "replicas" => 1, "env_keys" => crit)] }
|
||||
pr = { "services" => [prod_svc( "region" => "us-east", "replicas" => 3, "env_keys" => crit)] }
|
||||
c = cmd_with(st, pr)
|
||||
c.define_singleton_method(:execute_promotion) { |_st, _pr| 0 }
|
||||
out, _ = capture_io { @rc = c.run_with_preflight_only }
|
||||
assert_equal 0, @rc, "region/replicas divergence must be ADVISORY (non-blocking) without --confirm-divergence"
|
||||
assert_match(/ADVISORY.*x.*region/i, out)
|
||||
assert_match(/ADVISORY.*x.*replicas/i, out)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,168 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# Re-assertion of the SSOT multiRegionConfig replica count on the promote pin
|
||||
# (the durable fix for the harness-workers -> 1 de-scale incident). A promote
|
||||
# that issues serviceInstanceUpdate WITHOUT a multiRegionConfig key lets Railway
|
||||
# fall back to its default region (us-west1) at 1 replica on the subsequent
|
||||
# serviceInstanceDeployV2 — collapsing the staged
|
||||
# `multiRegionConfig.us-west2.numReplicas = 6`. pin_and_verify must:
|
||||
# - INCLUDE multiRegionConfig in the serviceInstanceUpdate input when the SSOT
|
||||
# declares a replica override for the service+env (harness-workers -> 6 in
|
||||
# us-west2), and
|
||||
# - OMIT it entirely (never send a multiRegionConfig key) when the SSOT tracks
|
||||
# no replica override, so a normal single-replica service is never touched.
|
||||
class PromoteReplicasReassertTest < Minitest::Test
|
||||
class FakeGQL
|
||||
def initialize(plan); @plan = plan; @calls = []; end
|
||||
attr_reader :calls
|
||||
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
step = @plan.shift
|
||||
raise "fake exhausted at call ##{@calls.size}: #{q[0, 40]}" unless step
|
||||
raise step[:raise] if step[:raise]
|
||||
step[:data]
|
||||
end
|
||||
end
|
||||
|
||||
NEW_DEPLOY_ID = "dep-new"
|
||||
|
||||
def pre(ts) = { data: { "serviceInstance" => { "id" => "i", "source" => { "image" => "ghcr.io/copilotkit/x@sha256:OLD" }, "updatedAt" => ts } } }
|
||||
def post(image:, ts:) = { data: { "serviceInstance" => { "id" => "i", "source" => { "image" => image }, "updatedAt" => ts } } }
|
||||
def deploy_ok(id = NEW_DEPLOY_ID) = { data: { "serviceInstanceDeployV2" => id } }
|
||||
|
||||
def serving(digest:, status: "SUCCESS", deploy_id: NEW_DEPLOY_ID)
|
||||
{
|
||||
data: {
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/x@#{digest}" },
|
||||
"updatedAt" => "2026-05-28T03:00:00Z",
|
||||
"latestDeployment" => {
|
||||
"id" => deploy_id, "status" => status,
|
||||
"meta" => { "imageDigest" => digest },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def happy_plan
|
||||
[
|
||||
pre("2026-05-27T00:00:00Z"),
|
||||
{ data: { "serviceInstanceUpdate" => true } },
|
||||
deploy_ok,
|
||||
post(image: "ghcr.io/copilotkit/x@sha256:NEW", ts: "2026-05-28T01:00:00Z"),
|
||||
serving(digest: "sha256:NEW"),
|
||||
]
|
||||
end
|
||||
|
||||
# The serviceInstanceUpdate is always the 2nd GQL call (after the pre-update
|
||||
# snapshot). Returns [query_string, vars] for that call.
|
||||
def update_call(gql) = gql.calls[1]
|
||||
|
||||
def test_includes_multiregion_replicas_when_ssot_declares_override
|
||||
gql = FakeGQL.new(happy_plan)
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
replica_config: { "us-west2" => 6 }, sleeper: ->(_n) {})
|
||||
|
||||
query, vars = update_call(gql)
|
||||
# The mutation passes the WHOLE input object as a single $input variable
|
||||
# typed ServiceInstanceUpdateInput! (the type Railway actually defines).
|
||||
# multiRegionConfig is a NESTED key of that input — Railway infers its
|
||||
# type from the ServiceInstanceUpdateInput schema, so we must NOT name a
|
||||
# standalone `ServiceMultiRegionConfigInput` type (it DOES NOT EXIST and
|
||||
# made the live promote 400). See deploy-to-railway.ts / provision-
|
||||
# starter-fleet.ts, which set healthcheckPath/region/etc the same way.
|
||||
refute_match(/ServiceMultiRegionConfigInput/, query,
|
||||
"must not reference the nonexistent ServiceMultiRegionConfigInput type")
|
||||
assert_match(/\$input:\s*ServiceInstanceUpdateInput!/, query,
|
||||
"update mutation should pass a single $input: ServiceInstanceUpdateInput! variable")
|
||||
assert_match(/input:\s*\$input/, query,
|
||||
"serviceInstanceUpdate should receive the input via $input")
|
||||
# The replica map rides INSIDE the input hash as the multiRegionConfig key.
|
||||
assert_equal({ "us-west2" => { numReplicas: 6 } },
|
||||
vars.dig(:input, :multiRegionConfig),
|
||||
"input.multiRegionConfig should carry the SSOT region -> { numReplicas } map")
|
||||
assert_equal({ image: "ghcr.io/copilotkit/x@sha256:NEW" },
|
||||
vars.dig(:input, :source),
|
||||
"input.source.image should always pin the target image")
|
||||
end
|
||||
|
||||
def test_omits_multiregion_when_ssot_has_no_override
|
||||
gql = FakeGQL.new(happy_plan)
|
||||
# nil replica_config == a normal service with no replica override.
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
replica_config: nil, sleeper: ->(_n) {})
|
||||
|
||||
query, vars = update_call(gql)
|
||||
# NO multiRegionConfig anywhere — we must NEVER send a multiRegionConfig
|
||||
# key for a service without an override (would risk clobbering config).
|
||||
refute_match(/multiRegionConfig/, query,
|
||||
"image-only mutation must not mention multiRegionConfig")
|
||||
refute vars.dig(:input)&.key?(:multiRegionConfig),
|
||||
"input must omit multiRegionConfig for a non-override service"
|
||||
end
|
||||
|
||||
def test_empty_replica_config_is_treated_as_omitted
|
||||
gql = FakeGQL.new(happy_plan)
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
replica_config: {}, sleeper: ->(_n) {})
|
||||
|
||||
query, vars = update_call(gql)
|
||||
refute_match(/multiRegionConfig/, query)
|
||||
refute vars.dig(:input)&.key?(:multiRegionConfig)
|
||||
end
|
||||
|
||||
# multiRegionConfig and healthcheckPath are INDEPENDENT re-assertion
|
||||
# dimensions: a service can declare both (harness-workers tracks /health AND
|
||||
# 6 replicas). Both must ride in the same serviceInstanceUpdate input.
|
||||
def test_includes_both_healthcheck_and_replicas
|
||||
gql = FakeGQL.new(happy_plan)
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "s", env_id: "e", image: "ghcr.io/copilotkit/x@sha256:NEW",
|
||||
healthcheck_path: "/health", replica_config: { "us-west2" => 6 },
|
||||
sleeper: ->(_n) {})
|
||||
|
||||
query, vars = update_call(gql)
|
||||
# Both ride as nested keys inside the single $input object.
|
||||
assert_match(/\$input:\s*ServiceInstanceUpdateInput!/, query)
|
||||
refute_match(/ServiceMultiRegionConfigInput/, query)
|
||||
assert_equal "/health", vars.dig(:input, :healthcheckPath)
|
||||
assert_equal({ "us-west2" => { numReplicas: 6 } },
|
||||
vars.dig(:input, :multiRegionConfig))
|
||||
end
|
||||
|
||||
# SSOT resolution: the promote loop pulls replica intent from the REAL SSOT
|
||||
# (railway-envs.generated.json) via ssot_replica_config. harness-workers
|
||||
# carries workerProvisioning.prod.effectiveReplicas=6 -> { us-west2 => 6 };
|
||||
# every other service tracks no override -> nil (so multiRegionConfig is
|
||||
# omitted and their live config is never touched).
|
||||
def cmd
|
||||
c = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
c.parser.parse!(c.argv)
|
||||
c
|
||||
end
|
||||
|
||||
def test_ssot_resolves_harness_workers_to_six_replicas_in_us_west2
|
||||
assert_equal({ "us-west2" => 6 },
|
||||
cmd.ssot_replica_config("harness-workers", "prod"),
|
||||
"harness-workers prod must resolve to 6 replicas in us-west2 from SSOT")
|
||||
end
|
||||
|
||||
def test_ssot_replica_config_is_nil_for_a_non_override_service
|
||||
# Pick any non-worker service the SSOT tracks; it must have NO override.
|
||||
sample = (Railway::SSOT_DATA["services"] || [])
|
||||
.map { |s| s["name"] }
|
||||
.reject { |n| n == "harness-workers" }
|
||||
.first
|
||||
refute_nil sample, "expected at least one non-worker service in the SSOT"
|
||||
assert_nil cmd.ssot_replica_config(sample, "prod"),
|
||||
"#{sample} has no replica override -> ssot_replica_config must be nil"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,317 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# Covers five CR fixes against PromoteCommand:
|
||||
# FIX-1: digest is resolved EXACTLY ONCE per staging service across the
|
||||
# whole preflight+execute, and the SAME ref P1 verified is what
|
||||
# gets pinned by execute_promotion (no TOCTOU).
|
||||
# FIX-2: a Railway::GHCR::Error raised mid-loop in P1 produces a per-
|
||||
# service REFUSE finding and the loop continues for remaining
|
||||
# services — earlier findings are NOT discarded.
|
||||
# FIX-3: pin_and_verify asserts the serviceInstanceDeployV2 result is a
|
||||
# non-empty deployment id (symmetric with the serviceInstanceUpdate
|
||||
# result check).
|
||||
# FIX-4: P2 emits a WARN finding when deployment meta is not a Hash, so
|
||||
# the silent skip of the in-flight race-check is visible.
|
||||
# FIX-5: when pin_and_verify raises mid-loop in execute_promotion, a
|
||||
# loud PARTIAL-PROMOTION report names the already-pinned services.
|
||||
class PromoteResolveOnceTest < Minitest::Test
|
||||
# FakeGHCR that counts resolve_digest calls per ref.
|
||||
class CountingGHCR
|
||||
attr_reader :resolve_calls
|
||||
|
||||
def initialize(resolve_map:, exists_set: nil)
|
||||
@resolve_map = resolve_map
|
||||
@exists_set = exists_set
|
||||
@resolve_calls = Hash.new(0)
|
||||
end
|
||||
|
||||
def resolve_digest(ref)
|
||||
@resolve_calls[ref] += 1
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
@resolve_map[ref]
|
||||
end
|
||||
|
||||
def manifest_exists(ref)
|
||||
return :missing if @exists_set && !@exists_set.include?(ref)
|
||||
:exists
|
||||
end
|
||||
|
||||
def parse_image_ref(ref)
|
||||
Railway::GHCR.allocate.parse_image_ref(ref)
|
||||
end
|
||||
end
|
||||
|
||||
# GQL fake reusing the pattern from PromoteExecuteTest::RecordingGQL,
|
||||
# but with optional knobs for FIX-3 (redeploy returns false) and FIX-5
|
||||
# (per-service update failure).
|
||||
class RecordingGQL
|
||||
def initialize(redeploy_result: true, update_fail_for: nil)
|
||||
@calls = []
|
||||
# Per-service post-update state so multi-service test cases don't
|
||||
# leak each other's pinned image into pre-update rechecks. Keyed by
|
||||
# serviceId. Value is { image:, post_ts: }. Pre-update returns the
|
||||
# "OLD" snapshot (with an early ts) for any service not yet pinned.
|
||||
@post = {}
|
||||
@ts_counter = 0
|
||||
@redeploy_result = redeploy_result
|
||||
@update_fail_for = update_fail_for # service_id whose update returns false
|
||||
end
|
||||
attr_reader :calls
|
||||
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
sid = vars[:serviceId]
|
||||
if q.include?("serviceInstanceUpdate")
|
||||
if @update_fail_for && sid == @update_fail_for
|
||||
return { "serviceInstanceUpdate" => false }
|
||||
end
|
||||
@ts_counter += 1
|
||||
@post[sid] = { image: vars.dig(:input, :source, :image), ts: "2026-05-29T00:00:%02dZ" % @ts_counter }
|
||||
{ "serviceInstanceUpdate" => true }
|
||||
elsif q.include?("serviceInstanceDeployV2")
|
||||
# @redeploy_result false → return nil id so the DeployV2 guard
|
||||
# fails loud (mirrors the prior redeploy-false fail path).
|
||||
{ "serviceInstanceDeployV2" => (@redeploy_result ? "dep-#{sid}" : nil) }
|
||||
elsif q.include?("ServiceInstanceRecheck")
|
||||
if (entry = @post[sid])
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => entry[:image] },
|
||||
"updatedAt" => entry[:ts],
|
||||
"latestDeployment" => {
|
||||
"id" => "dep-#{sid}", "status" => "SUCCESS",
|
||||
"meta" => {
|
||||
"imageDigest" => (entry[:image].include?("@") ? entry[:image].split("@", 2).last : nil),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
else
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/x@sha256:OLD" },
|
||||
"updatedAt" => "2026-05-28T00:00:00Z",
|
||||
},
|
||||
}
|
||||
end
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def pinned_images
|
||||
@calls.select { |q, _| q.include?("serviceInstanceUpdate") }.map { |_, v| v.dig(:input, :source, :image) }
|
||||
end
|
||||
end
|
||||
|
||||
# Silence pin_and_verify's 10s-per-retry waits.
|
||||
def with_fast_sleeper
|
||||
original = Railway::PromoteCommand.const_get(:RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, 0)
|
||||
yield
|
||||
ensure
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, original)
|
||||
end
|
||||
|
||||
def make_svc(name, image:)
|
||||
{
|
||||
"name" => name, "service_id" => "svc-stg-#{name}",
|
||||
"image" => image,
|
||||
# All CRITICAL_ENV_KEYS present so the (now unconditional) critical
|
||||
# env-key presence assertion does not fire — these tests isolate
|
||||
# the resolve-once / pin behavior, not env-key parity.
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}
|
||||
end
|
||||
|
||||
def make_prod_svc(name)
|
||||
{
|
||||
"name" => name, "service_id" => "svc-prod-#{name}",
|
||||
"image" => "ghcr.io/copilotkit/#{name}@sha256:OLD",
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}
|
||||
end
|
||||
|
||||
# =================== FIX-1 ===================
|
||||
|
||||
def test_fix1_resolve_digest_called_exactly_once_per_service
|
||||
# Single :latest staging service. After full preflight + execute, the
|
||||
# ghcr.resolve_digest call counter MUST be exactly 1 for that ref —
|
||||
# not 2 (which was the pre-fix TOCTOU duplicate-resolve behavior).
|
||||
ghcr = CountingGHCR.new(resolve_map: { "ghcr.io/copilotkit/x:latest" => "sha256:abc123" })
|
||||
gql = RecordingGQL.new
|
||||
|
||||
cmd = Railway::PromoteCommand.new(["--non-interactive", "--yes", "--confirm-divergence"])
|
||||
cmd.parser.parse!(cmd.argv)
|
||||
cmd.instance_variable_set(:@staging_snapshot, {
|
||||
"services" => [make_svc("x", image: "ghcr.io/copilotkit/x:latest")],
|
||||
})
|
||||
cmd.instance_variable_set(:@prod_snapshot, { "services" => [make_prod_svc("x")] })
|
||||
cmd.instance_variable_set(:@gql, gql)
|
||||
cmd.instance_variable_set(:@ghcr, ghcr)
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |_svc_id|
|
||||
[{ "id" => "d", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/x@sha256:abc123" } }]
|
||||
end
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
|
||||
out, _ = with_fast_sleeper { capture_io { @rc = cmd.run_with_preflight_only } }
|
||||
assert_equal 0, @rc, "promote should succeed; got out=#{out}"
|
||||
|
||||
# The exact ref pinned MUST be what P1 verified (resolve-once).
|
||||
assert_equal ["ghcr.io/copilotkit/x@sha256:abc123"], gql.pinned_images,
|
||||
"must pin the SAME digest-form ref P1 verified"
|
||||
|
||||
# The :latest tag must have been resolved EXACTLY ONCE — not twice.
|
||||
assert_equal 1, ghcr.resolve_calls["ghcr.io/copilotkit/x:latest"],
|
||||
"resolve_digest must be called exactly once for the staging tag; calls=#{ghcr.resolve_calls.inspect}"
|
||||
end
|
||||
|
||||
# =================== FIX-2 ===================
|
||||
|
||||
# GHCR fake whose manifest_exists raises Railway::GHCR::Error on the
|
||||
# SECOND service. The first service's findings must survive.
|
||||
class RaisingOnSecondGHCR
|
||||
def initialize
|
||||
@manifest_calls = 0
|
||||
end
|
||||
|
||||
def resolve_digest(ref)
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
"sha256:resolved_for_#{ref}"
|
||||
end
|
||||
|
||||
def manifest_exists(_ref)
|
||||
@manifest_calls += 1
|
||||
raise Railway::GHCR::Error, "boom on call ##{@manifest_calls}" if @manifest_calls == 2
|
||||
:exists
|
||||
end
|
||||
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
def test_fix2_p1_rescue_is_per_service_not_method_level
|
||||
# Two services. The SECOND triggers a GHCR::Error in manifest_exists.
|
||||
# Before the fix: method-level rescue replaced ALL findings with a
|
||||
# single "REFUSE: P1: GHCR check raised ..." entry, dropping the
|
||||
# first service's clean record AND scoping the error to "P1" with no
|
||||
# service name. After the fix: the first service has no finding
|
||||
# (it passed), the second service has a per-service REFUSE finding
|
||||
# that names the service.
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.instance_variable_set(:@ghcr, RaisingOnSecondGHCR.new)
|
||||
# resolved_prod_image now pins staging's RUNNING digest (meta.imageDigest);
|
||||
# stub the deployment lookup so the flow reaches manifest_exists (which
|
||||
# raises on the 2nd service under test).
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |svc_id|
|
||||
name = svc_id.sub("svc-stg-", "")
|
||||
[{ "id" => "d", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/#{name}:latest",
|
||||
"imageDigest" => "sha256:running_#{name}" } }]
|
||||
end
|
||||
staging = {
|
||||
"services" => [
|
||||
make_svc("a", image: "ghcr.io/copilotkit/a:latest"),
|
||||
make_svc("b", image: "ghcr.io/copilotkit/b:latest"),
|
||||
],
|
||||
}
|
||||
findings = cmd.send(:check_p1_ghcr_digests, staging)
|
||||
|
||||
refute(findings.any? { |f| f =~ /REFUSE: P1 \(a\)/ },
|
||||
"service 'a' passed P1 and must have no REFUSE; findings=#{findings.inspect}")
|
||||
assert(findings.any? { |f| f =~ /REFUSE: P1 \(b\).*boom on call #2/ },
|
||||
"service 'b' must have a per-service P1 REFUSE naming it; findings=#{findings.inspect}")
|
||||
end
|
||||
|
||||
# =================== FIX-3 ===================
|
||||
|
||||
def test_fix3_pin_and_verify_raises_when_deploy_returns_no_id
|
||||
gql = RecordingGQL.new(redeploy_result: false)
|
||||
err = assert_raises(Railway::PromoteCommand::MutationError) do
|
||||
Railway::PromoteCommand.pin_and_verify(gql,
|
||||
service_id: "svc-x", env_id: "env-prod",
|
||||
image: "ghcr.io/copilotkit/x@sha256:abc",
|
||||
sleeper: ->(_) {})
|
||||
end
|
||||
assert_match(/serviceInstanceDeployV2/i, err.message,
|
||||
"MutationError must reference the deploy mutation; got: #{err.message}")
|
||||
end
|
||||
|
||||
# =================== FIX-4 ===================
|
||||
|
||||
def test_fix4_p2_warns_when_meta_is_a_string
|
||||
# P2 already guards meta.is_a?(Hash) (no crash), but the silent skip
|
||||
# of the race-check should produce a WARN finding so it's visible.
|
||||
cmd = Railway::PromoteCommand.new([])
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |_svc_id|
|
||||
[{ "id" => "d", "status" => "SUCCESS",
|
||||
"meta" => "raw-string-not-a-hash" }]
|
||||
end
|
||||
staging = {
|
||||
"services" => [{
|
||||
"name" => "x", "service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:abc", "digest" => "sha256:abc",
|
||||
"env_keys" => [],
|
||||
}],
|
||||
}
|
||||
findings = cmd.send(:check_p2_staging_deployments, staging)
|
||||
assert(findings.any? { |f| f =~ /WARN: P2 \(x\).*meta.*String.*Hash/ },
|
||||
"expected WARN finding for non-Hash meta; got: #{findings.inspect}")
|
||||
end
|
||||
|
||||
# =================== FIX-5 ===================
|
||||
|
||||
def test_fix5_partial_promotion_report_on_midloop_failure
|
||||
# Three staging services [a, b, c]; pin succeeds for a and b, fails
|
||||
# for c (serviceInstanceUpdate returns false → MutationError).
|
||||
# The output must name a and b as already-pinned and c as failed.
|
||||
ghcr = CountingGHCR.new(resolve_map: {
|
||||
"ghcr.io/copilotkit/a:latest" => "sha256:aaa",
|
||||
"ghcr.io/copilotkit/b:latest" => "sha256:bbb",
|
||||
"ghcr.io/copilotkit/c:latest" => "sha256:ccc",
|
||||
})
|
||||
gql = RecordingGQL.new(update_fail_for: "svc-prod-c")
|
||||
|
||||
cmd = Railway::PromoteCommand.new(["--non-interactive", "--yes", "--confirm-divergence"])
|
||||
cmd.parser.parse!(cmd.argv)
|
||||
cmd.instance_variable_set(:@staging_snapshot, {
|
||||
"services" => [
|
||||
make_svc("a", image: "ghcr.io/copilotkit/a:latest"),
|
||||
make_svc("b", image: "ghcr.io/copilotkit/b:latest"),
|
||||
make_svc("c", image: "ghcr.io/copilotkit/c:latest"),
|
||||
],
|
||||
})
|
||||
cmd.instance_variable_set(:@prod_snapshot, {
|
||||
"services" => [make_prod_svc("a"), make_prod_svc("b"), make_prod_svc("c")],
|
||||
})
|
||||
cmd.instance_variable_set(:@gql, gql)
|
||||
cmd.instance_variable_set(:@ghcr, ghcr)
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |svc_id|
|
||||
svc = svc_id.sub("svc-stg-", "")
|
||||
digest = { "a" => "sha256:aaa", "b" => "sha256:bbb", "c" => "sha256:ccc" }.fetch(svc)
|
||||
[{ "id" => "d", "status" => "SUCCESS",
|
||||
"meta" => { "image" => "ghcr.io/copilotkit/#{svc}@#{digest}" } }]
|
||||
end
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
|
||||
out, err = with_fast_sleeper { capture_io { @rc = cmd.run_with_preflight_only } }
|
||||
assert_equal 1, @rc, "execute_promotion must return 1 when a mid-loop pin fails"
|
||||
combined = out + err
|
||||
assert_match(/PARTIAL PROMOTION/i, combined,
|
||||
"must emit a loud PARTIAL PROMOTION report; output=#{combined}")
|
||||
assert_match(/already pinned.*\ba\b.*\bb\b/m, combined,
|
||||
"report must name the already-pinned services a and b; output=#{combined}")
|
||||
assert_match(/FAILED on c/, combined,
|
||||
"report must name the failing service c; output=#{combined}")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,277 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# bin/railway promote — single-service positional + --digest support.
|
||||
#
|
||||
# Context: showcase_promote.yml loops services and invokes
|
||||
# bin/railway promote "$svc" [--digest "$DIGEST"]
|
||||
# but the original PromoteCommand parser accepts NEITHER. The positional
|
||||
# was silently discarded (promoting the ENTIRE fleet per iteration) and
|
||||
# --digest raised OptionParser::InvalidOption (the workflow aborts under
|
||||
# set -euo pipefail). These tests pin the fix contract.
|
||||
|
||||
require_relative "spec_helper"
|
||||
require "stringio"
|
||||
|
||||
class PromoteSingleServiceTest < Minitest::Test
|
||||
# Minimal benign GQL fake (P2 deployments query, etc.). The promotion
|
||||
# tests below stub fetch_latest_staging_deployments and run_staging_probe
|
||||
# so this is only used as a fallback.
|
||||
class FakeGQLBenign
|
||||
attr_reader :calls
|
||||
def initialize
|
||||
@calls = []
|
||||
@pinned_by_service = {} # serviceId => image; tracks per-service pin
|
||||
@ts_counter = 0
|
||||
end
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
sid = vars[:serviceId]
|
||||
if q.include?("serviceInstanceUpdate")
|
||||
@pinned_by_service[sid] = vars.dig(:input, :source, :image)
|
||||
{ "serviceInstanceUpdate" => true }
|
||||
elsif q.include?("serviceInstanceDeployV2")
|
||||
{ "serviceInstanceDeployV2" => "dep-#{sid}" }
|
||||
elsif q.include?("ServiceInstanceRecheck")
|
||||
pinned = @pinned_by_service[sid]
|
||||
if pinned.nil?
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/old@sha256:OLD" },
|
||||
"updatedAt" => "2026-05-28T00:00:00Z",
|
||||
},
|
||||
}
|
||||
else
|
||||
@ts_counter += 1
|
||||
pinned_digest = pinned.include?("@") ? pinned.split("@", 2).last : nil
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => pinned },
|
||||
"updatedAt" => "2026-05-29T00:00:#{format('%02d', @ts_counter)}Z",
|
||||
"latestDeployment" => {
|
||||
"id" => "dep-#{sid}", "status" => "SUCCESS",
|
||||
"meta" => { "imageDigest" => pinned_digest },
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
else
|
||||
{ "deployments" => { "edges" => [] } }
|
||||
end
|
||||
end
|
||||
|
||||
def pinned_services
|
||||
@calls.select { |q, _| q.include?("serviceInstanceUpdate") }
|
||||
.map { |_, vars| [vars[:serviceId], vars.dig(:input, :source, :image)] }
|
||||
end
|
||||
|
||||
def pinned_image_for(service_id)
|
||||
row = @calls.find { |q, vars| q.include?("serviceInstanceUpdate") && vars[:serviceId] == service_id }
|
||||
row && row[1].dig(:input, :source, :image)
|
||||
end
|
||||
end
|
||||
|
||||
class FakeGHCR
|
||||
def initialize(resolve_map: {})
|
||||
@resolve_map = resolve_map
|
||||
end
|
||||
def resolve_digest(ref)
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
@resolve_map[ref] || "sha256:default_digest_for_#{ref.sub(/[^a-z0-9]/i, '_')[0, 16]}"
|
||||
end
|
||||
def manifest_exists(_ref); :exists; end
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
def make_service(name, prod_image: "ghcr.io/copilotkit/#{name}@sha256:OLD#{name.gsub('-', '')}")
|
||||
{
|
||||
"name" => name, "service_id" => "svc-#{name}",
|
||||
"image" => "ghcr.io/copilotkit/#{name}:latest",
|
||||
# All CRITICAL_ENV_KEYS present so the (now unconditional) critical
|
||||
# env-key presence assertion does not fire — this spec isolates the
|
||||
# single-service narrowing behavior, not env-key parity.
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}
|
||||
end
|
||||
|
||||
def make_prod(name)
|
||||
{
|
||||
"name" => name, "service_id" => "prod-#{name}",
|
||||
"image" => "ghcr.io/copilotkit/#{name}@sha256:OLD#{name.gsub('-', '')}",
|
||||
# All CRITICAL_ENV_KEYS present so the (now unconditional) critical
|
||||
# env-key presence assertion does not fire — this spec isolates the
|
||||
# single-service narrowing behavior, not env-key parity.
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}
|
||||
end
|
||||
|
||||
def build_cmd(argv)
|
||||
cmd = Railway::PromoteCommand.new(argv + ["--non-interactive", "--yes", "--confirm-divergence"])
|
||||
# IMPORTANT: do NOT call parser.parse! here — tests must exercise the
|
||||
# full `run` path so argv parsing (positional + --digest) is covered.
|
||||
cmd
|
||||
end
|
||||
|
||||
def install_two_service_fixture(cmd, gql, ghcr)
|
||||
# Two staging services — "aimock" and "harness" — both also in prod.
|
||||
staging_svcs = [make_service("aimock"), make_service("harness")]
|
||||
prod_svcs = [make_prod("aimock"), make_prod("harness")]
|
||||
cmd.instance_variable_set(:@staging_snapshot, { "services" => staging_svcs })
|
||||
cmd.instance_variable_set(:@prod_snapshot, { "services" => prod_svcs })
|
||||
cmd.instance_variable_set(:@gql, gql)
|
||||
cmd.instance_variable_set(:@ghcr, ghcr)
|
||||
# Skip P2 race-check: stub deployments to SUCCESS with the digest the
|
||||
# promote will actually pin (which, when --digest is set, is the
|
||||
# override — NOT the GHCR-resolved one). Read options[:digest] off the
|
||||
# command at call-time so this works for both default and override
|
||||
# paths without test-side branching.
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |service_id|
|
||||
name = service_id.sub(/^svc-/, "")
|
||||
override = options[:digest]
|
||||
if override && options[:service] == name && override.include?("@")
|
||||
ref = override
|
||||
else
|
||||
ghcr_obj = instance_variable_get(:@ghcr)
|
||||
digest = ghcr_obj.resolve_digest("ghcr.io/copilotkit/#{name}:latest")
|
||||
ref = "ghcr.io/copilotkit/#{name}@#{digest}"
|
||||
end
|
||||
[{ "id" => "d", "status" => "SUCCESS", "meta" => { "image" => ref } }]
|
||||
end
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
end
|
||||
|
||||
def with_fast_sleeper
|
||||
original = Railway::PromoteCommand.const_get(:RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, 0)
|
||||
yield
|
||||
ensure
|
||||
Railway::PromoteCommand.send(:remove_const, :RETRY_DELAY_SEC)
|
||||
Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, original)
|
||||
end
|
||||
|
||||
# ── (a) Positional service: only that service is promoted. ────────────
|
||||
|
||||
def test_positional_service_promotes_only_that_service
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: {
|
||||
"ghcr.io/copilotkit/aimock:latest" => "sha256:NEW_AIMOCK",
|
||||
"ghcr.io/copilotkit/harness:latest" => "sha256:NEW_HARNESS",
|
||||
})
|
||||
cmd = build_cmd(["aimock"])
|
||||
install_two_service_fixture(cmd, gql, ghcr)
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
assert_equal 0, rc, "single-service promote should succeed; got out=#{out}"
|
||||
|
||||
pinned = gql.pinned_services
|
||||
assert_equal 1, pinned.size, "must pin exactly ONE service when positional is given (got #{pinned.inspect})"
|
||||
sid, image = pinned.first
|
||||
assert_equal "prod-aimock", sid, "must target prod-aimock, not the whole fleet"
|
||||
assert_equal "ghcr.io/copilotkit/aimock@sha256:NEW_AIMOCK", image
|
||||
# Loud regression guard against silent fleet-wide pin.
|
||||
refute gql.pinned_image_for("prod-harness"), "harness must NOT be touched when only aimock was requested"
|
||||
end
|
||||
|
||||
# ── (b) Positional + --digest: digest overrides resolved ref. ─────────
|
||||
|
||||
def test_positional_service_with_digest_pins_that_digest
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: {
|
||||
"ghcr.io/copilotkit/aimock:latest" => "sha256:NEW_AIMOCK",
|
||||
"ghcr.io/copilotkit/harness:latest" => "sha256:NEW_HARNESS",
|
||||
})
|
||||
override = "ghcr.io/copilotkit/aimock@sha256:OVERRIDE_DIGEST"
|
||||
cmd = build_cmd(["aimock", "--digest", override])
|
||||
install_two_service_fixture(cmd, gql, ghcr)
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
assert_equal 0, rc, "promote with --digest should succeed; got out=#{out}"
|
||||
|
||||
pinned = gql.pinned_services
|
||||
assert_equal 1, pinned.size, "--digest must still pin only the named service"
|
||||
sid, image = pinned.first
|
||||
assert_equal "prod-aimock", sid
|
||||
assert_equal override, image,
|
||||
"must pin the EXPLICIT --digest ref, not the GHCR-resolved one"
|
||||
end
|
||||
|
||||
# ── (c) --digest without positional → fail fast. ──────────────────────
|
||||
|
||||
def test_digest_without_service_fails_fast
|
||||
cmd = build_cmd(["--digest", "ghcr.io/copilotkit/x@sha256:abc"])
|
||||
ghcr = FakeGHCR.new
|
||||
gql = FakeGQLBenign.new
|
||||
install_two_service_fixture(cmd, gql, ghcr)
|
||||
ex = nil
|
||||
# die! calls Kernel#exit which raises SystemExit; capture it so the
|
||||
# test can assert the exit code AND the error message together.
|
||||
out, err = capture_io { ex = assert_raises(SystemExit) { cmd.run } }
|
||||
refute_equal 0, ex.status, "--digest without a service must NOT promote the fleet"
|
||||
combined = out + err
|
||||
assert_match(/--digest.*requires.*service|--digest.*without.*service|service.*required.*--digest/i,
|
||||
combined, "must surface a clear error explaining --digest needs a positional service")
|
||||
# And it must NOT have issued any pin mutations.
|
||||
assert_empty gql.pinned_services, "no pin mutations should run on the fail-fast path"
|
||||
end
|
||||
|
||||
# ── (d) Unknown positional service → fail fast with valid names. ──────
|
||||
|
||||
def test_unknown_positional_service_fails_with_valid_names_listed
|
||||
cmd = build_cmd(["this-service-does-not-exist"])
|
||||
ghcr = FakeGHCR.new
|
||||
gql = FakeGQLBenign.new
|
||||
install_two_service_fixture(cmd, gql, ghcr)
|
||||
ex = nil
|
||||
out, err = capture_io { ex = assert_raises(SystemExit) { cmd.run } }
|
||||
refute_equal 0, ex.status, "unknown service must fail, not silently no-op or fall through to fleet"
|
||||
combined = out + err
|
||||
assert_match(/unknown.*service|not.*known|invalid.*service/i, combined)
|
||||
# The error must list at least one canonical staging name (e.g. aimock)
|
||||
# so the operator can self-correct.
|
||||
assert_match(/aimock/, combined,
|
||||
"valid-names enumeration must include canonical staging services")
|
||||
assert_empty gql.pinned_services, "no pin mutations on unknown-service error path"
|
||||
end
|
||||
|
||||
# ── (e) No positional → preserve full-fleet behavior (regression). ────
|
||||
|
||||
def test_no_positional_preserves_full_fleet_promote
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: {
|
||||
"ghcr.io/copilotkit/aimock:latest" => "sha256:NEW_AIMOCK",
|
||||
"ghcr.io/copilotkit/harness:latest" => "sha256:NEW_HARNESS",
|
||||
})
|
||||
cmd = build_cmd([])
|
||||
install_two_service_fixture(cmd, gql, ghcr)
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
assert_equal 0, rc, "no-arg promote must keep working for operators; got out=#{out}"
|
||||
|
||||
pinned_ids = gql.pinned_services.map(&:first).sort
|
||||
assert_equal ["prod-aimock", "prod-harness"], pinned_ids,
|
||||
"no-arg promote must continue to pin the whole fleet"
|
||||
end
|
||||
|
||||
# ── Parser-level coverage for --digest flag and positional acceptance. ─
|
||||
|
||||
def test_parser_accepts_digest_flag
|
||||
c = Railway::PromoteCommand.new(["--digest", "ghcr.io/copilotkit/x@sha256:abc"])
|
||||
# Must NOT raise InvalidOption.
|
||||
c.parser.parse!(c.argv)
|
||||
assert_equal "ghcr.io/copilotkit/x@sha256:abc", c.options[:digest]
|
||||
end
|
||||
|
||||
def test_parser_leaves_positional_in_argv
|
||||
c = Railway::PromoteCommand.new(["aimock", "--yes"])
|
||||
c.parser.parse!(c.argv)
|
||||
# OptionParser#parse! consumes known flags; the positional must remain.
|
||||
assert_includes c.argv, "aimock"
|
||||
assert c.options[:yes]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,510 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# bin/railway promote — fleet-scoped invariants must read the FULL
|
||||
# un-narrowed snapshots, while set-parity is target-scoped for a
|
||||
# single-service promote.
|
||||
#
|
||||
# Background: the per-service `promote <svc>` feature narrows the
|
||||
# target_*/per-service views to one service for the P1..P3/P6 and
|
||||
# critical-env-key checks, but the FLEET-SHAPE checks
|
||||
# (check_expected_prod_domains, check_service_set_parity) read the FULL
|
||||
# un-narrowed fleet_* snapshots — they describe the env, not the promote
|
||||
# target. (#5322 removed the earlier co-narrowing of these checks; do NOT
|
||||
# reintroduce snapshot narrowing for fleet-scoped checks.)
|
||||
#
|
||||
# Mechanism under test:
|
||||
#
|
||||
# (1) check_expected_prod_domains compares the FLEET-WIDE public-host set
|
||||
# (EXPECTED_DOMAINS[PRODUCTION_ENV_ID]) against the union of
|
||||
# custom_domains across the FULL prod snapshot. The fixture's full
|
||||
# prod fleet carries the full public-host set (every host in
|
||||
# EXPECTED_DOMAINS[PRODUCTION_ENV_ID], currently 5); a narrowed
|
||||
# single-service view of the target owns 0 of them, so reading the
|
||||
# narrowed view would make the entire set look "missing" → spurious
|
||||
# WARN → and because the
|
||||
# real workflow (.github/workflows/showcase_promote.yml) does NOT
|
||||
# pass --confirm-divergence, promote would refuse. Reading the FULL
|
||||
# fleet avoids that.
|
||||
#
|
||||
# (2) check_service_set_parity reads the FULL un-narrowed fleet_staging /
|
||||
# fleet_prod snapshots and applies `& [target]` scoping to BOTH the
|
||||
# staging-only and prod-only arms when a single service is targeted.
|
||||
# So a single-service promote REFUSEs only on parity violations
|
||||
# involving the TARGET itself, and tolerates unrelated single-env
|
||||
# services (staging-only harness-workers / starter-* demos, or a
|
||||
# deprecated prod-only service). Full-fleet promotes (target nil)
|
||||
# keep both arms at full fleet strictness.
|
||||
#
|
||||
# Red-green coverage for THIS PR is provided by the two tolerance tests:
|
||||
# - test_single_service_promote_tolerates_unrelated_staging_only_services
|
||||
# - test_single_service_promote_tolerates_unrelated_prod_only_service
|
||||
# The test_healthy_* and test_target_absent_* tests are #5322 regression
|
||||
# guards (they pin that fleet-scoped checks read the FULL snapshots and
|
||||
# that an absent target still fails loud), not red-green for this change.
|
||||
|
||||
require_relative "spec_helper"
|
||||
require "stringio"
|
||||
|
||||
class PromoteSingleServiceFleetInvariantsTest < Minitest::Test
|
||||
# Reuse the same minimal benign GQL/GHCR fakes as
|
||||
# test_promote_single_service.rb (parallel fixture style — kept inline
|
||||
# here so this spec is self-contained).
|
||||
class FakeGQLBenign
|
||||
attr_reader :calls
|
||||
def initialize
|
||||
@calls = []
|
||||
@pinned_by_service = {}
|
||||
@ts_counter = 0
|
||||
end
|
||||
def query(q, vars = {})
|
||||
@calls << [q, vars]
|
||||
sid = vars[:serviceId]
|
||||
if q.include?("serviceInstanceUpdate")
|
||||
@pinned_by_service[sid] = vars.dig(:input, :source, :image)
|
||||
{ "serviceInstanceUpdate" => true }
|
||||
elsif q.include?("serviceInstanceDeployV2")
|
||||
{ "serviceInstanceDeployV2" => "dep-#{sid}" }
|
||||
elsif q.include?("ServiceInstanceRecheck")
|
||||
pinned = @pinned_by_service[sid]
|
||||
if pinned.nil?
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => "ghcr.io/copilotkit/old@sha256:OLD" },
|
||||
"updatedAt" => "2026-05-28T00:00:00Z",
|
||||
},
|
||||
}
|
||||
else
|
||||
@ts_counter += 1
|
||||
pinned_digest = pinned.include?("@") ? pinned.split("@", 2).last : nil
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "i",
|
||||
"source" => { "image" => pinned },
|
||||
"updatedAt" => "2026-05-29T00:00:#{format('%02d', @ts_counter)}Z",
|
||||
"latestDeployment" => {
|
||||
"id" => "dep-#{sid}", "status" => "SUCCESS",
|
||||
"meta" => { "imageDigest" => pinned_digest },
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
else
|
||||
{ "deployments" => { "edges" => [] } }
|
||||
end
|
||||
end
|
||||
|
||||
def pinned_services
|
||||
@calls.select { |q, _| q.include?("serviceInstanceUpdate") }
|
||||
.map { |_, vars| [vars[:serviceId], vars.dig(:input, :source, :image)] }
|
||||
end
|
||||
|
||||
def pinned_image_for(service_id)
|
||||
row = @calls.find { |q, vars| q.include?("serviceInstanceUpdate") && vars[:serviceId] == service_id }
|
||||
row && row[1].dig(:input, :source, :image)
|
||||
end
|
||||
end
|
||||
|
||||
class FakeGHCR
|
||||
def initialize(resolve_map: {})
|
||||
@resolve_map = resolve_map
|
||||
end
|
||||
def resolve_digest(ref)
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
@resolve_map[ref] || "sha256:default_digest_for_#{ref.sub(/[^a-z0-9]/i, '_')[0, 16]}"
|
||||
end
|
||||
def manifest_exists(_ref); :exists; end
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
def make_staging_service(name)
|
||||
{
|
||||
"name" => name, "service_id" => "svc-#{name}",
|
||||
"image" => "ghcr.io/copilotkit/#{name}:latest",
|
||||
# All CRITICAL_ENV_KEYS present so the (now unconditional) critical
|
||||
# env-key presence assertion does not fire — this spec isolates the
|
||||
# fleet-shape invariants, not env-key parity.
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
}
|
||||
end
|
||||
|
||||
def make_prod_service(name, custom_domains: [])
|
||||
{
|
||||
"name" => name, "service_id" => "prod-#{name}",
|
||||
"image" => "ghcr.io/copilotkit/#{name}@sha256:OLD#{name.gsub(/[^a-z0-9]/i, '')}",
|
||||
# All CRITICAL_ENV_KEYS present so the (now unconditional) critical
|
||||
# env-key presence assertion does not fire — this spec isolates the
|
||||
# fleet-shape invariants, not env-key parity.
|
||||
"env_keys" => Railway::CRITICAL_ENV_KEYS.dup,
|
||||
"start_command" => "node server.js", "healthcheck_path" => "/health",
|
||||
"region" => "us-west", "replicas" => 1, "restart_policy" => "ON_FAILURE",
|
||||
"custom_domains" => custom_domains,
|
||||
}
|
||||
end
|
||||
|
||||
# Build a FLEET-SHAPED snapshot pair. The full prod snapshot's union of
|
||||
# custom_domains across services EXACTLY satisfies the fleet-wide
|
||||
# EXPECTED_DOMAINS[PRODUCTION_ENV_ID] set, so a healthy fleet must NOT
|
||||
# produce a domain WARN. Two of those services ("aimock", "harness")
|
||||
# also exist in staging — promote will target one of them.
|
||||
#
|
||||
# `staging_only_extras:` injects services into the STAGING fleet that
|
||||
# have NO prod counterpart — modeling the live staging-only services
|
||||
# (harness-workers + the 12 starter-* demos). They land in the full
|
||||
# (pre-narrow) staging snapshot, exactly where the fleet-scoped
|
||||
# set-parity check reads. They are legitimately staging-only and a
|
||||
# single-service promote must tolerate them.
|
||||
#
|
||||
# `prod_only_extras:` is the mirror: it injects services into the FULL
|
||||
# prod snapshot that have NO staging counterpart — modeling a
|
||||
# deprecated/prod-only service. They land in the full (pre-narrow) prod
|
||||
# snapshot where the prod-only arm of set-parity reads. They are
|
||||
# legitimately prod-only and a single-service promote must tolerate them.
|
||||
def install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "aimock",
|
||||
staging_only_extras: [], prod_only_extras: [])
|
||||
# DERIVE-FROM-SSOT INVARIANT: the "domain-bearing" prod services and
|
||||
# their public hosts are derived from the SAME constant the prod code
|
||||
# reads — Railway::EXPECTED_DOMAINS[PRODUCTION_ENV_ID] — rather than
|
||||
# re-hardcoding host literals here. This keeps the fixture coupled to
|
||||
# the SSOT (showcase/scripts/railway-envs.generated.json): when the
|
||||
# SSOT public-host set changes, this fixture moves with it instead of
|
||||
# synthesizing phantom-domain WARNs or breaking the parity die!.
|
||||
#
|
||||
# Each public host is mapped back to its owning SSOT service name so
|
||||
# the fixture's prod fleet legitimately carries all public prod hosts,
|
||||
# even when promote is narrowed to a service that owns no public host.
|
||||
public_hosts = Railway::EXPECTED_DOMAINS[Railway::PRODUCTION_ENV_ID]
|
||||
ssot_services = Railway::SSOT_DATA.fetch("services")
|
||||
domain_services_prod = public_hosts.map do |host|
|
||||
owner = ssot_services.find { |s| s.dig("domains", "prod") == host }
|
||||
make_prod_service(owner.fetch("name"), custom_domains: [host])
|
||||
end
|
||||
# The promote target must be a real SSOT staging service (this is what
|
||||
# the prod code validates against), so assert it rather than trusting a
|
||||
# bare literal that could silently drift from the SSOT.
|
||||
assert_includes Railway::STAGING_SERVICES, target,
|
||||
"fixture target #{target.inspect} must be a real SSOT staging service"
|
||||
# Plus the targeted service (no public domain of its own — e.g. an
|
||||
# internal aimock). Optionally OMIT it from prod to exercise BUG #2.
|
||||
#
|
||||
# GUARD: if `target` already owns a public prod host it is ALREADY in
|
||||
# `domain_services_prod` above. Appending make_prod_service(target)
|
||||
# again would produce a malformed fleet with the SAME prod service
|
||||
# (service_id "prod-#{target}") listed twice — once domain-bearing,
|
||||
# once with custom_domains:[] — contradicting this helper's own
|
||||
# "no public domain of its own" contract. So only append the bare
|
||||
# target when it is NOT already a derived domain owner.
|
||||
derived_owner_names = domain_services_prod.map { |s| s["name"] }
|
||||
if prod_includes_target && !derived_owner_names.include?(target)
|
||||
domain_services_prod << make_prod_service(target)
|
||||
end
|
||||
# And a non-targeted sibling that exists in both staging and prod —
|
||||
# picked from STAGING_SERVICES as a real service that owns no public
|
||||
# prod host, so it stays distinct from the domain-bearing set above.
|
||||
domain_owner_names = domain_services_prod.map { |s| s["name"] }
|
||||
sibling = Railway::STAGING_SERVICES.find do |n|
|
||||
!domain_owner_names.include?(n) && n != target &&
|
||||
ssot_services.find { |s| s["name"] == n }&.dig("domains", "prod")&.end_with?(".up.railway.app")
|
||||
end
|
||||
domain_services_prod << make_prod_service(sibling)
|
||||
|
||||
# Staging mirrors prod's service NAMES (set parity) — every prod
|
||||
# service has a corresponding staging entry. (For BUG #2, target is
|
||||
# in staging but absent from prod by design.) `prod_only_extras` are
|
||||
# deliberately NOT mirrored into staging, so they land in the full
|
||||
# prod snapshot with no staging counterpart.
|
||||
staging_names = (domain_services_prod.map { |s| s["name"] } + [target] + staging_only_extras).uniq
|
||||
staging_services = staging_names.map { |n| make_staging_service(n) }
|
||||
|
||||
# Inject prod-only extras into the FULL prod snapshot AFTER deriving
|
||||
# staging_names, so they have no staging counterpart.
|
||||
prod_only_extras.each { |n| domain_services_prod << make_prod_service(n) }
|
||||
|
||||
cmd.instance_variable_set(:@staging_snapshot, { "services" => staging_services })
|
||||
cmd.instance_variable_set(:@prod_snapshot, { "services" => domain_services_prod })
|
||||
cmd.instance_variable_set(:@gql, gql)
|
||||
cmd.instance_variable_set(:@ghcr, ghcr)
|
||||
|
||||
# P2 race-check stub — see test_promote_single_service.rb for the
|
||||
# same pattern. Mirror the pin the promote will actually issue.
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |service_id|
|
||||
name = service_id.sub(/^svc-/, "")
|
||||
override = options[:digest]
|
||||
if override && options[:service] == name && override.include?("@")
|
||||
ref = override
|
||||
else
|
||||
ghcr_obj = instance_variable_get(:@ghcr)
|
||||
digest = ghcr_obj.resolve_digest("ghcr.io/copilotkit/#{name}:latest")
|
||||
ref = "ghcr.io/copilotkit/#{name}@#{digest}"
|
||||
end
|
||||
[{ "id" => "d", "status" => "SUCCESS", "meta" => { "image" => ref } }]
|
||||
end
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
end
|
||||
|
||||
def build_cmd(argv)
|
||||
# CRITICAL: this test MUST mirror the real workflow invocation —
|
||||
# showcase_promote.yml calls `promote <svc>` WITHOUT
|
||||
# --confirm-divergence. Do NOT add --confirm-divergence here.
|
||||
Railway::PromoteCommand.new(argv + ["--non-interactive", "--yes"])
|
||||
end
|
||||
|
||||
# Zero the promote retry back-off for the duration of the block so the 3x
|
||||
# eventual-consistency retry loop in pin_and_verify doesn't add real wall
|
||||
# time. RETRY_DELAY_SEC is a process-global constant, so this is a global
|
||||
# mutation; the clean seam would be pin_and_verify(sleeper:), but the
|
||||
# cmd.run → pin loop constructs that call internally with no injection
|
||||
# point we can reach without changing bin/railway production logic. So we
|
||||
# keep the swap but make it BULLETPROOF against run-order state leakage:
|
||||
#
|
||||
# - capture `original` BEFORE any mutation;
|
||||
# - track whether the swap actually happened (`swapped`) so a failure
|
||||
# between capture and set never leaves the const removed/zeroed;
|
||||
# - restore in `ensure` (runs even if the block raises);
|
||||
# - swap via const_set with warnings silenced (no remove_const churn /
|
||||
# "already initialized constant" warning), and restore the EXACT prior
|
||||
# value so no perturbed state can leak into a later test in the
|
||||
# randomized run order.
|
||||
def with_fast_sleeper
|
||||
original = Railway::PromoteCommand.const_get(:RETRY_DELAY_SEC)
|
||||
swapped = false
|
||||
silence_const_redefinition { Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, 0) }
|
||||
swapped = true
|
||||
yield
|
||||
ensure
|
||||
silence_const_redefinition { Railway::PromoteCommand.const_set(:RETRY_DELAY_SEC, original) } if swapped
|
||||
end
|
||||
|
||||
# const_set over an existing constant emits a Ruby "already initialized
|
||||
# constant" warning; suppress it locally so the suite output stays clean
|
||||
# without touching the process-global $VERBOSE beyond this block.
|
||||
def silence_const_redefinition
|
||||
prev = $VERBOSE
|
||||
$VERBOSE = nil
|
||||
yield
|
||||
ensure
|
||||
$VERBOSE = prev
|
||||
end
|
||||
|
||||
# ── #5322 regression guard: healthy fleet, single-service promote, NO
|
||||
# --confirm-divergence must succeed. check_expected_prod_domains reads the
|
||||
# FULL prod snapshot (the full EXPECTED_DOMAINS[PRODUCTION_ENV_ID]
|
||||
# public-host set present), so no phantom domain
|
||||
# WARN is synthesized and the promote proceeds: rc=0, target pinned,
|
||||
# siblings untouched. (This pins that fleet-scoped checks read the full
|
||||
# snapshot; it is a guard, not red-green for the current parity change.)
|
||||
def test_healthy_fleet_single_service_promote_without_confirm_divergence_succeeds
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: {
|
||||
"ghcr.io/copilotkit/aimock:latest" => "sha256:NEW_AIMOCK",
|
||||
})
|
||||
cmd = build_cmd(["aimock"])
|
||||
install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "aimock")
|
||||
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
|
||||
assert_equal 0, rc,
|
||||
"single-service promote against a HEALTHY fleet must NOT be " \
|
||||
"refused for missing fleet-wide domains (workflow doesn't pass " \
|
||||
"--confirm-divergence). Got rc=#{rc.inspect}; out=\n#{out}"
|
||||
|
||||
# Spurious-WARN smoke check: we must NOT have emitted a fleet-domains
|
||||
# WARN since the un-narrowed fleet satisfies EXPECTED_DOMAINS.
|
||||
refute_match(/WARN: production missing expected custom domains/, out,
|
||||
"fleet-domain check should evaluate the FULL prod snapshot; the " \
|
||||
"narrowed view must not synthesize a phantom 'missing' WARN")
|
||||
|
||||
pinned = gql.pinned_services
|
||||
assert_equal 1, pinned.size,
|
||||
"exactly one service should be promoted (target only); got #{pinned.inspect}"
|
||||
sid, image = pinned.first
|
||||
assert_equal "prod-aimock", sid
|
||||
assert_equal "ghcr.io/copilotkit/aimock@sha256:NEW_AIMOCK", image
|
||||
|
||||
# Siblings must remain untouched.
|
||||
refute gql.pinned_image_for("prod-harness"),
|
||||
"harness must not be pinned when only aimock was promoted"
|
||||
refute gql.pinned_image_for("prod-dashboard"),
|
||||
"dashboard must not be pinned when only aimock was promoted"
|
||||
end
|
||||
|
||||
# ── #5322 regression guard: target in staging but ABSENT from prod must
|
||||
# FAIL LOUD. check_service_set_parity reads the FULL un-narrowed
|
||||
# fleet_staging / fleet_prod and applies `& [target]` scoping: the target
|
||||
# is in the staging-only set, so the staging-not-in-prod REFUSE survives
|
||||
# scoping and surfaces (never a silent rc=0 with no pin). To prove the
|
||||
# check truly reads the FULL fleet AND applies target-scoping (not merely
|
||||
# a trivially-narrowed pair), the fixture also carries an UNRELATED
|
||||
# staging-only sibling: it must be IGNORED while the target REFUSE fires —
|
||||
# the REFUSE names ONLY the target.
|
||||
def test_target_absent_from_prod_fails_loud
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: {
|
||||
"ghcr.io/copilotkit/aimock:latest" => "sha256:NEW_AIMOCK",
|
||||
})
|
||||
cmd = build_cmd(["aimock"])
|
||||
# prod_includes_target=false → "aimock" exists in staging but NOT
|
||||
# in the full prod snapshot. An unrelated staging-only sibling
|
||||
# ("harness-workers") is injected too: the full fleet thus has TWO
|
||||
# staging-only names, but target-scoping must REFUSE on ONLY the
|
||||
# target. This distinguishes the fix from the buggy full-vs-narrowed
|
||||
# behavior — a check that ignored target-scoping would also name the
|
||||
# sibling.
|
||||
install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: false, target: "aimock",
|
||||
staging_only_extras: %w[harness-workers])
|
||||
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
|
||||
refute_equal 0, rc,
|
||||
"target service present in staging but ABSENT from prod must " \
|
||||
"FAIL LOUD (nonzero rc), not silently succeed with no pin. " \
|
||||
"Got rc=#{rc.inspect}; out=\n#{out}"
|
||||
|
||||
# The error message must be informative — surface the parity
|
||||
# violation rather than a generic / opaque failure. AND it must name
|
||||
# ONLY the target: target-scoping means the unrelated staging-only
|
||||
# sibling is excluded from the REFUSE even though it is in the FULL
|
||||
# fleet's staging-only set. (A check that read the full fleet without
|
||||
# target-scoping would also list "harness-workers" here.)
|
||||
assert_match(/REFUSE: services in staging not in prod: aimock\b/, out,
|
||||
"must surface a clear REFUSE naming the target missing from prod")
|
||||
refute_match(/harness-workers/, out,
|
||||
"target-scoping must exclude unrelated staging-only siblings from " \
|
||||
"the REFUSE — only the target should be named")
|
||||
|
||||
assert_empty gql.pinned_services,
|
||||
"no pin mutations may be issued when target is absent from prod"
|
||||
end
|
||||
|
||||
# ── Single-service promote must TOLERATE unrelated staging-only services.
|
||||
# The live staging fleet legitimately carries services with no prod
|
||||
# counterpart — harness-workers (SSOT-modeled staging-only) and the 12
|
||||
# starter-* demos (not in SSOT). For a single-service promote of a
|
||||
# healthy target (present in both staging and prod) these are irrelevant.
|
||||
#
|
||||
# Red-green for the STAGING-only arm of the target-scoping fix (#5324):
|
||||
# before scoping, check_service_set_parity diffs the FULL staging fleet vs
|
||||
# the FULL prod fleet and REFUSEs because the extras are "in staging not
|
||||
# in prod" — blocking an otherwise-clean single-service promote. After
|
||||
# the fix, the staging-only REFUSE is target-scoped, so the unrelated
|
||||
# extras are ignored; rc=0, target pinned, no REFUSE.
|
||||
def test_single_service_promote_tolerates_unrelated_staging_only_services
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: {
|
||||
"ghcr.io/copilotkit/docs:latest" => "sha256:NEW_DOCS",
|
||||
})
|
||||
cmd = build_cmd(["docs"])
|
||||
# Target "docs" is present in BOTH staging and prod (the domain-
|
||||
# bearing prod fixture already includes a "docs" service, mirrored
|
||||
# into staging). Inject unrelated staging-only siblings.
|
||||
install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "docs",
|
||||
staging_only_extras: %w[harness-workers starter-adk starter-langgraph-python])
|
||||
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
|
||||
assert_equal 0, rc,
|
||||
"a single-service promote of a healthy target must NOT be refused " \
|
||||
"because UNRELATED staging-only services (harness-workers, " \
|
||||
"starter-* demos) exist in staging but not prod. " \
|
||||
"Got rc=#{rc.inspect}; out=\n#{out}"
|
||||
|
||||
refute_match(/REFUSE: services in staging not in prod/, out,
|
||||
"staging-only services unrelated to the promote target must not " \
|
||||
"trigger the set-parity REFUSE on a single-service promote")
|
||||
|
||||
pinned = gql.pinned_services
|
||||
assert_equal 1, pinned.size,
|
||||
"exactly one service should be promoted (target only); got #{pinned.inspect}"
|
||||
sid, image = pinned.first
|
||||
assert_equal "prod-docs", sid
|
||||
assert_equal "ghcr.io/copilotkit/docs@sha256:NEW_DOCS", image
|
||||
end
|
||||
|
||||
# ── Single-service promote must TOLERATE unrelated prod-only services.
|
||||
# This is the MIRROR of the staging-only tolerance test and the red-green
|
||||
# for the PROD-only arm of the target-scoping fix (#5324). A prod-only
|
||||
# service (e.g. a deprecated "deprecated-prod-only-svc" still present in
|
||||
# prod but removed from staging) has no bearing on a single-service promote of an
|
||||
# unrelated healthy target.
|
||||
#
|
||||
# Red (before this PR's source change): the prod-only arm of
|
||||
# check_service_set_parity was computed over the FULL fleet
|
||||
# (`(p_names - s_names)`) with NO target scoping, so ANY prod-only service
|
||||
# REFUSEs EVERY single-service promote — "services in prod not in staging"
|
||||
# fires and rc=1. Green (after): the prod-only arm is target-scoped too,
|
||||
# so the unrelated prod-only service is ignored; rc=0, target pinned, no
|
||||
# "in prod not in staging" REFUSE.
|
||||
def test_single_service_promote_tolerates_unrelated_prod_only_service
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new(resolve_map: {
|
||||
"ghcr.io/copilotkit/docs:latest" => "sha256:NEW_DOCS",
|
||||
})
|
||||
cmd = build_cmd(["docs"])
|
||||
# Target "docs" is present in BOTH staging and prod. Inject an
|
||||
# unrelated prod-only sibling (no staging counterpart).
|
||||
install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "docs",
|
||||
prod_only_extras: %w[deprecated-prod-only-svc])
|
||||
|
||||
rc = nil
|
||||
out, _ = with_fast_sleeper { capture_io { rc = cmd.run } }
|
||||
|
||||
assert_equal 0, rc,
|
||||
"a single-service promote of a healthy target must NOT be refused " \
|
||||
"because an UNRELATED prod-only service (deprecated-prod-only-svc) exists in " \
|
||||
"prod but not staging. Got rc=#{rc.inspect}; out=\n#{out}"
|
||||
|
||||
refute_match(/REFUSE: services in prod not in staging/, out,
|
||||
"prod-only services unrelated to the promote target must not " \
|
||||
"trigger the set-parity REFUSE on a single-service promote")
|
||||
|
||||
pinned = gql.pinned_services
|
||||
assert_equal 1, pinned.size,
|
||||
"exactly one service should be promoted (target only); got #{pinned.inspect}"
|
||||
sid, image = pinned.first
|
||||
assert_equal "prod-docs", sid
|
||||
assert_equal "ghcr.io/copilotkit/docs@sha256:NEW_DOCS", image
|
||||
end
|
||||
|
||||
# ── Fixture well-formedness: the derived prod snapshot must NEVER list the
|
||||
# same prod service twice. When the promote target already owns a public
|
||||
# prod host (e.g. "docs" owns docs.copilotkit.ai), the SSOT-derivation
|
||||
# already includes it among the domain-bearing services; the helper must
|
||||
# not ALSO append a bare make_prod_service(target), which would yield two
|
||||
# services with the same name + service_id ("prod-docs") — one
|
||||
# domain-bearing, one with custom_domains:[] — a malformed fleet shape that
|
||||
# contradicts the helper's "no public domain of its own" contract and is
|
||||
# only masked downstream by find_service first-match + .uniq. We assert the
|
||||
# invariant directly against the snapshot the fixture installs.
|
||||
def test_install_fleet_fixture_produces_no_duplicate_prod_services
|
||||
gql = FakeGQLBenign.new
|
||||
ghcr = FakeGHCR.new
|
||||
cmd = build_cmd(["docs"])
|
||||
# "docs" owns a public prod host, so it is the case that previously
|
||||
# produced a duplicate prod-docs entry.
|
||||
install_fleet_fixture(cmd, gql, ghcr, prod_includes_target: true, target: "docs")
|
||||
|
||||
prod = cmd.instance_variable_get(:@prod_snapshot)
|
||||
names = (prod["services"] || []).map { |s| s["name"] }
|
||||
dups = names.tally.select { |_, count| count > 1 }.keys
|
||||
assert_empty dups,
|
||||
"derived prod snapshot must not list any service name twice; " \
|
||||
"duplicates=#{dups.inspect} in #{names.inspect}"
|
||||
|
||||
ids = (prod["services"] || []).map { |s| s["service_id"] }
|
||||
id_dups = ids.tally.select { |_, count| count > 1 }.keys
|
||||
assert_empty id_dups,
|
||||
"derived prod snapshot must not list any service_id twice; " \
|
||||
"duplicate ids=#{id_dups.inspect} in #{ids.inspect}"
|
||||
|
||||
# The target must still be present in prod exactly once (a healthy
|
||||
# single-service promote target), so the tolerance tests above keep
|
||||
# genuinely exercising a present-in-both target.
|
||||
assert_equal 1, names.count("docs"),
|
||||
"the promote target must appear in the prod fleet exactly once"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,241 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# Covers the staging-running-digest pin + drift warning:
|
||||
# CASE-A: staging RUNNING digest == current :latest -> pin it, NO warning.
|
||||
# CASE-B: staging RUNNING digest != current :latest -> pin the RUNNING
|
||||
# digest (NOT :latest) + emit the loud STAGING DRIFT warning.
|
||||
# CASE-C: resolved_prod_image pins meta.imageDigest, not resolve_digest(tag).
|
||||
# CASE-D: no SUCCESS staging deployment -> resolved_prod_image returns nil
|
||||
# (caller REFUSEs rather than pin a mutable tag).
|
||||
class PromoteStagingRunningDigestTest < Minitest::Test
|
||||
# GHCR fake: resolve_digest(:latest-tag) returns a configurable "current
|
||||
# latest" digest; manifest_exists is always :exists. Counts resolve_digest
|
||||
# calls so we can assert the tag is NOT used to pick the prod pin.
|
||||
class FakeGHCR
|
||||
attr_reader :resolve_calls
|
||||
|
||||
def initialize(current_latest:)
|
||||
@current_latest = current_latest
|
||||
@resolve_calls = Hash.new(0)
|
||||
end
|
||||
|
||||
def resolve_digest(ref)
|
||||
@resolve_calls[ref] += 1
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
@current_latest
|
||||
end
|
||||
|
||||
def manifest_exists(_ref); :exists; end
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
# Benign GQL: P2 issues a deployments query; return empty so P2 is a no-op.
|
||||
class FakeGQLEmpty
|
||||
def query(*); { "deployments" => { "edges" => [] } }; end
|
||||
end
|
||||
|
||||
def make_cmd(staging_services:, prod_services: [], current_latest:, running_meta:)
|
||||
cmd = Railway::PromoteCommand.new(["--non-interactive", "--yes", "--confirm-divergence"])
|
||||
cmd.parser.parse!(cmd.argv)
|
||||
cmd.instance_variable_set(:@staging_snapshot, { "services" => staging_services })
|
||||
cmd.instance_variable_set(:@prod_snapshot, { "services" => prod_services })
|
||||
cmd.instance_variable_set(:@gql, FakeGQLEmpty.new)
|
||||
cmd.instance_variable_set(:@ghcr, FakeGHCR.new(current_latest: current_latest))
|
||||
# The RUNNING digest source: latest SUCCESS staging deployment's
|
||||
# meta.imageDigest. Keyed implicitly by the single service under test.
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) do |_svc_id|
|
||||
[{ "id" => "d", "status" => "SUCCESS", "meta" => running_meta }]
|
||||
end
|
||||
cmd.define_singleton_method(:run_staging_probe) { |services:| { ok: true, summary: "" } }
|
||||
cmd
|
||||
end
|
||||
|
||||
STG = lambda do |name|
|
||||
{
|
||||
"name" => name, "service_id" => "svc-stg-#{name}",
|
||||
"image" => "ghcr.io/copilotkit/#{name}:latest",
|
||||
"env_keys" => [],
|
||||
}
|
||||
end
|
||||
|
||||
# =================== CASE-C: pins meta.imageDigest, not :latest ===========
|
||||
|
||||
def test_resolved_prod_image_pins_running_digest_not_latest
|
||||
cmd = make_cmd(
|
||||
staging_services: [STG.call("x")],
|
||||
current_latest: "sha256:261ccdef", # the WRONG (drifted) :latest
|
||||
running_meta: { "image" => "ghcr.io/copilotkit/x:latest",
|
||||
"imageDigest" => "sha256:f9454e79" }, # the RUNNING digest
|
||||
)
|
||||
resolved = cmd.send(:resolved_prod_image, STG.call("x"))
|
||||
assert_equal "ghcr.io/copilotkit/x@sha256:f9454e79", resolved,
|
||||
"must pin staging RUNNING digest (meta.imageDigest), NOT current :latest"
|
||||
# resolve_digest(:latest-tag) must NOT be what picks the prod pin.
|
||||
refute_includes resolved, "261ccdef",
|
||||
"prod pin must not be the current :latest digest"
|
||||
end
|
||||
|
||||
# =================== CASE-D: no SUCCESS deploy -> nil =====================
|
||||
|
||||
def test_resolved_prod_image_nil_when_no_running_digest
|
||||
cmd = make_cmd(
|
||||
staging_services: [STG.call("x")],
|
||||
current_latest: "sha256:261ccdef",
|
||||
running_meta: { "image" => "ghcr.io/copilotkit/x:latest" }, # no imageDigest
|
||||
)
|
||||
# Drop the SUCCESS deployment entirely.
|
||||
cmd.define_singleton_method(:fetch_latest_staging_deployments) { |_| [] }
|
||||
assert_nil cmd.send(:resolved_prod_image, STG.call("x")),
|
||||
"must return nil (caller REFUSEs) when no running digest is resolvable"
|
||||
end
|
||||
|
||||
# =================== CASE-A: running == :latest -> no warning ============
|
||||
|
||||
def test_no_drift_warning_when_running_equals_latest
|
||||
same = "sha256:cafebabe"
|
||||
cmd = make_cmd(
|
||||
staging_services: [STG.call("x")],
|
||||
prod_services: [{ "name" => "x", "service_id" => "svc-prod-x",
|
||||
"image" => "ghcr.io/copilotkit/x@sha256:OLD", "env_keys" => [] }],
|
||||
current_latest: same,
|
||||
running_meta: { "image" => "ghcr.io/copilotkit/x:latest", "imageDigest" => same },
|
||||
)
|
||||
out, _err = capture_io { cmd.send(:check_p1_ghcr_digests, cmd.instance_variable_get(:@staging_snapshot)) }
|
||||
cmd.send(:emit_staging_drift_warnings)
|
||||
out2, _ = capture_io { cmd.send(:emit_staging_drift_warnings) }
|
||||
refute_match(/STAGING DRIFT/, out + out2,
|
||||
"no drift warning when staging RUNNING == current :latest")
|
||||
# And the pinned ref is still the running digest.
|
||||
assert_equal "ghcr.io/copilotkit/x@sha256:cafebabe",
|
||||
cmd.instance_variable_get(:@promote_refs)["x"]
|
||||
end
|
||||
|
||||
# =================== CASE-B: running != :latest -> warning ===============
|
||||
|
||||
def test_drift_warning_when_running_differs_from_latest
|
||||
cmd = make_cmd(
|
||||
staging_services: [STG.call("ms-agent-dotnet")],
|
||||
prod_services: [{ "name" => "ms-agent-dotnet", "service_id" => "svc-prod-d",
|
||||
"image" => "ghcr.io/copilotkit/ms-agent-dotnet@sha256:OLD", "env_keys" => [] }],
|
||||
current_latest: "sha256:261ccdef3f9a", # drifted :latest
|
||||
running_meta: { "image" => "ghcr.io/copilotkit/ms-agent-dotnet:latest",
|
||||
"imageDigest" => "sha256:f9454e79fbf5" }, # RUNNING
|
||||
)
|
||||
# Run P1 (populates @promote_refs + @staging_drift) then emit the warning.
|
||||
capture_io { cmd.send(:check_p1_ghcr_digests, cmd.instance_variable_get(:@staging_snapshot)) }
|
||||
out, _err = capture_io { cmd.send(:emit_staging_drift_warnings) }
|
||||
|
||||
assert_match(/STAGING DRIFT/, out, "must emit the loud drift block")
|
||||
assert_match(/ms-agent-dotnet/, out, "must name the drifted service")
|
||||
assert_match(/f9454e79/, out, "must name the staging RUNNING digest")
|
||||
assert_match(/261ccdef/, out, "must name the current :latest digest")
|
||||
# Promote still pins the RUNNING digest (drift is a warning, not a refuse).
|
||||
assert_equal "ghcr.io/copilotkit/ms-agent-dotnet@sha256:f9454e79fbf5",
|
||||
cmd.instance_variable_get(:@promote_refs)["ms-agent-dotnet"],
|
||||
"promote must STILL pin the staging RUNNING digest despite drift"
|
||||
end
|
||||
|
||||
# =================== CASE-F: --digest override -> NO drift signal ========
|
||||
|
||||
# When the operator supplies an explicit --digest for a single service, the
|
||||
# pinned ref is the operator's CHOSEN override (not staging's running
|
||||
# digest), so "drift from :latest" is meaningless. detect_staging_drift must
|
||||
# skip — even when the override digest differs from current :latest — and
|
||||
# emit_staging_drift_warnings must say nothing for it.
|
||||
def test_no_drift_signal_when_digest_override_active
|
||||
override_ref = "ghcr.io/copilotkit/x@sha256:0verr1de00"
|
||||
cmd = make_cmd(
|
||||
staging_services: [STG.call("x")],
|
||||
current_latest: "sha256:261ccdef", # differs from the override digest
|
||||
running_meta: { "image" => "ghcr.io/copilotkit/x:latest",
|
||||
"imageDigest" => "sha256:f9454e79" },
|
||||
)
|
||||
# Operator pinned an explicit digest for this single service (mirrors the
|
||||
# `run`-time options after `promote x --digest <ref>`).
|
||||
cmd.options[:service] = "x"
|
||||
cmd.options[:digest] = override_ref
|
||||
|
||||
capture_io { cmd.send(:check_p1_ghcr_digests, cmd.instance_variable_get(:@staging_snapshot)) }
|
||||
|
||||
# No drift entry recorded — the override is a deliberate choice, not drift.
|
||||
assert_empty Array(cmd.instance_variable_get(:@staging_drift)),
|
||||
"a --digest override must NOT be recorded as staging drift"
|
||||
|
||||
# And the loud warning emits nothing for it.
|
||||
out, _ = capture_io { cmd.send(:emit_staging_drift_warnings) }
|
||||
refute_match(/STAGING DRIFT/, out,
|
||||
"a --digest override must not produce a (spurious) drift warning")
|
||||
refute_match(/STAGING_DRIFT_MARKER/, out,
|
||||
"a --digest override must not emit a drift marker")
|
||||
|
||||
# The override digest is what got pinned (verbatim).
|
||||
assert_equal override_ref, cmd.instance_variable_get(:@promote_refs)["x"],
|
||||
"promote must pin the operator's chosen override digest"
|
||||
end
|
||||
|
||||
# =================== CASE-E: GHCR resolve fails -> loud WARN, no abort ====
|
||||
|
||||
# GHCR fake whose tag resolution RAISES (auth/transport failure), but digest
|
||||
# refs (@sha256:...) still resolve — so resolved_prod_image's running-digest
|
||||
# pin succeeds while detect_staging_drift's :latest comparison blows up.
|
||||
class FakeGHCRRaisingTag
|
||||
def resolve_digest(ref)
|
||||
return ref.split("@", 2).last if ref.include?("@sha256:")
|
||||
raise StandardError, "GHCR auth failed (boom)"
|
||||
end
|
||||
|
||||
def manifest_exists(_ref); :exists; end
|
||||
def parse_image_ref(ref); Railway::GHCR.allocate.parse_image_ref(ref); end
|
||||
end
|
||||
|
||||
def test_drift_check_failure_warns_loudly_and_does_not_abort
|
||||
cmd = make_cmd(
|
||||
staging_services: [STG.call("x")],
|
||||
current_latest: "sha256:unused",
|
||||
running_meta: { "image" => "ghcr.io/copilotkit/x:latest",
|
||||
"imageDigest" => "sha256:f9454e79" },
|
||||
)
|
||||
cmd.instance_variable_set(:@ghcr, FakeGHCRRaisingTag.new)
|
||||
|
||||
out, err = capture_io do
|
||||
cmd.send(:check_p1_ghcr_digests, cmd.instance_variable_get(:@staging_snapshot))
|
||||
end
|
||||
|
||||
# Fail loud: the drift-check failure must surface, with the error message.
|
||||
assert_match(/WARN: staging drift check failed for x/, out + err,
|
||||
"must emit a loud WARN when the GHCR :latest resolve fails")
|
||||
assert_match(/GHCR auth failed \(boom\)/, out + err,
|
||||
"WARN must carry the underlying error message")
|
||||
|
||||
# Promote PROCEEDS: the running-digest pin still happened (no abort).
|
||||
assert_equal "ghcr.io/copilotkit/x@sha256:f9454e79",
|
||||
cmd.instance_variable_get(:@promote_refs)["x"],
|
||||
"promote must STILL pin the running digest despite the drift-check failure"
|
||||
|
||||
# No drift entry recorded (the check could not be performed).
|
||||
assert_empty Array(cmd.instance_variable_get(:@staging_drift)),
|
||||
"a check FAILURE must not be recorded as a drift"
|
||||
out2, _ = capture_io { cmd.send(:emit_staging_drift_warnings) }
|
||||
refute_match(/STAGING DRIFT/, out2,
|
||||
"a check failure must not produce a (false) drift warning")
|
||||
end
|
||||
|
||||
# =================== CASE-B (machine marker for Slack payload) ===========
|
||||
|
||||
def test_drift_emits_machine_readable_marker
|
||||
# The bordered block is for humans; promote-fleet.sh greps the
|
||||
# STAGING_DRIFT_MARKER: line and aggregates it into the Slack payload.
|
||||
cmd = make_cmd(
|
||||
staging_services: [STG.call("x")],
|
||||
current_latest: "sha256:aaaa1111",
|
||||
running_meta: { "image" => "ghcr.io/copilotkit/x:latest", "imageDigest" => "sha256:bbbb2222" },
|
||||
)
|
||||
capture_io { cmd.send(:check_p1_ghcr_digests, cmd.instance_variable_get(:@staging_snapshot)) }
|
||||
out, _ = capture_io { cmd.send(:emit_staging_drift_warnings) }
|
||||
assert_match(/^STAGING_DRIFT_MARKER: /, out, "must emit a machine-readable drift marker line")
|
||||
assert_match(/running=bbbb2222/, out, "marker must carry the RUNNING digest")
|
||||
assert_match(/latest=aaaa1111/, out, "marker must carry the :latest digest")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,198 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# U5 (spec §5) — Ruby env + service-ref + resource preflight (Stage 2).
|
||||
#
|
||||
# §5.2 service-ref assertion (REFUSE): each promote target carrying
|
||||
# `serviceRefs` must have its prod env var (e.g. OPENAI_BASE_URL) point at
|
||||
# the env-LOCAL target host (prod->prod aimock). A prod ref pointing at
|
||||
# the STAGING target host => REFUSE (the ms-agent-dotnet class). ASSERT,
|
||||
# never copy.
|
||||
# §5.3.1 replicate-class env-write (NEW capability): for declared replicate
|
||||
# keys, copy staging->prod value via the variable*Upsert family, verified
|
||||
# by re-read. Default replicate set is EMPTY (opt-in per service); the
|
||||
# MECHANISM + assertion path lands even with no opt-in keys.
|
||||
# §5.3.2/5.3.3 assert prod-specific keys present + prod-shaped (NEVER copy);
|
||||
# assert secret KEY presence (never value).
|
||||
# §5.4 concurrency DETECT-and-WARN: concurrency env vars (e.g.
|
||||
# BROWSER_POOL_SIZE) join the WARN-class divergence set
|
||||
# (region/replicas/restartPolicy already WARN). DETECT ONLY — never set.
|
||||
# (A limitOverride CPU/memory-cap WARN was dropped: Railway exposes no
|
||||
# readable limit field on ServiceInstance, so it was unimplementable
|
||||
# dead code — see test_snapshot_graphql.rb regression guard.)
|
||||
#
|
||||
# RISK R-A (HIGH — env clobber): a prod-specific key (e.g. a domain like
|
||||
# NEXT_PUBLIC_POCKETBASE_URL) must be ASSERTED, NEVER written/copied. The
|
||||
# fourth test below proves no upsert is issued for a prod-specific mismatch.
|
||||
class PromoteU5EnvServiceRefTest < Minitest::Test
|
||||
# A fake gql that records every mutation issued and answers variable reads
|
||||
# from an in-memory per-(serviceId,envId) value map. Tests seed values and
|
||||
# assert on @mutations to prove "asserted, never copied".
|
||||
class FakeGql
|
||||
attr_reader :mutations
|
||||
|
||||
# values: { [service_id, env_id] => { "KEY" => "value", ... } }
|
||||
def initialize(values: {})
|
||||
@values = values
|
||||
@mutations = []
|
||||
end
|
||||
|
||||
def query(query, variables = {})
|
||||
if query.include?("variableUpsert") || query.include?("variableCollectionUpsert")
|
||||
@mutations << { kind: :upsert, variables: variables }
|
||||
sid = variables[:serviceId] || variables["serviceId"]
|
||||
eid = variables[:envId] || variables["environmentId"] || variables["envId"]
|
||||
name = variables[:name] || variables["name"]
|
||||
val = variables[:value] || variables["value"]
|
||||
(@values[[sid, eid]] ||= {})[name] = val if name
|
||||
return { "variableUpsert" => true }
|
||||
end
|
||||
|
||||
if query.include?("query EnvServiceVariables")
|
||||
sid = variables[:serviceId] || variables["serviceId"]
|
||||
eid = variables[:envId] || variables["environmentId"] || variables["envId"]
|
||||
# Railway returns a flat JSON scalar map { "NAME" => "value" }.
|
||||
return { "variables" => (@values[[sid, eid]] || {}) }
|
||||
end
|
||||
|
||||
@mutations << { kind: :other, query: query, variables: variables }
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def cmd
|
||||
c = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
c.parser.parse!(c.argv)
|
||||
c
|
||||
end
|
||||
|
||||
PROD_ENV = Railway::PRODUCTION_ENV_ID
|
||||
STG_ENV = Railway::STAGING_ENV_ID
|
||||
|
||||
# ── §5.2 service-ref assertion (REFUSE) ─────────────────────────────────
|
||||
|
||||
def test_serviceref_prod_pointing_at_staging_aimock_refuses
|
||||
# showcase-ag2 carries serviceRefs:[{key:OPENAI_BASE_URL,target:aimock}].
|
||||
# aimock prod host = showcase-aimock-production.up.railway.app.
|
||||
c = cmd
|
||||
c.instance_variable_set(:@gql, FakeGql.new(values: {
|
||||
["ag2-prod", PROD_ENV] => {
|
||||
# WRONG: prod points at the STAGING aimock host.
|
||||
"OPENAI_BASE_URL" => "https://aimock-staging.up.railway.app",
|
||||
},
|
||||
}))
|
||||
staging = { "services" => [{ "name" => "showcase-ag2", "service_id" => "ag2-stg" }] }
|
||||
prod = { "services" => [{ "name" => "showcase-ag2", "service_id" => "ag2-prod" }] }
|
||||
findings = c.check_service_refs(staging, prod)
|
||||
assert(findings.any? { |f| f.start_with?("REFUSE") && f =~ /OPENAI_BASE_URL/ },
|
||||
"expected REFUSE for prod serviceRef pointing at staging host, got #{findings.inspect}")
|
||||
assert(c.instance_variable_get(:@gql).mutations.none? { |m| m[:kind] == :upsert },
|
||||
"service-ref check must ASSERT, never upsert")
|
||||
end
|
||||
|
||||
def test_serviceref_prod_pointing_at_prod_aimock_passes
|
||||
c = cmd
|
||||
c.instance_variable_set(:@gql, FakeGql.new(values: {
|
||||
["ag2-prod", PROD_ENV] => {
|
||||
"OPENAI_BASE_URL" => "https://showcase-aimock-production.up.railway.app",
|
||||
},
|
||||
}))
|
||||
staging = { "services" => [{ "name" => "showcase-ag2", "service_id" => "ag2-stg" }] }
|
||||
prod = { "services" => [{ "name" => "showcase-ag2", "service_id" => "ag2-prod" }] }
|
||||
findings = c.check_service_refs(staging, prod)
|
||||
assert_empty findings.select { |f| f.start_with?("REFUSE") },
|
||||
"prod->prod aimock ref must not REFUSE, got #{findings.inspect}"
|
||||
end
|
||||
|
||||
# ── §5.3.1 replicate-class env-write (NEW capability) ───────────────────
|
||||
|
||||
def test_replicate_key_value_diff_upserts_and_reread_green
|
||||
# Inject a replicate set (default is EMPTY) so the mechanism is exercised.
|
||||
c = cmd
|
||||
fake = FakeGql.new(values: {
|
||||
["x-stg", STG_ENV] => { "FEATURE_FLAG" => "on" },
|
||||
["x-prod", PROD_ENV] => { "FEATURE_FLAG" => "off" },
|
||||
})
|
||||
c.instance_variable_set(:@gql, fake)
|
||||
staging = { "services" => [{ "name" => "x", "service_id" => "x-stg" }] }
|
||||
prod = { "services" => [{ "name" => "x", "service_id" => "x-prod" }] }
|
||||
findings = c.replicate_env_keys(staging, prod, replicate_keys: %w[FEATURE_FLAG])
|
||||
# mechanism issued the upsert to prod with staging's value
|
||||
upserts = fake.mutations.select { |m| m[:kind] == :upsert }
|
||||
assert_equal 1, upserts.size, "expected exactly one upsert, got #{fake.mutations.inspect}"
|
||||
up = upserts.first[:variables]
|
||||
assert_equal "FEATURE_FLAG", (up[:name] || up["name"])
|
||||
assert_equal "on", (up[:value] || up["value"])
|
||||
assert_equal PROD_ENV, (up[:envId] || up["environmentId"] || up["envId"])
|
||||
# re-read confirms prod now carries staging's value (verified-by-re-read)
|
||||
assert(findings.any? { |f| f =~ /replicat/i && f =~ /FEATURE_FLAG/ },
|
||||
"expected a replicate report line, got #{findings.inspect}")
|
||||
refute(findings.any? { |f| f.start_with?("REFUSE") }, "replicate must not REFUSE on success")
|
||||
end
|
||||
|
||||
def test_default_replicate_set_is_empty_no_writes
|
||||
c = cmd
|
||||
fake = FakeGql.new(values: {
|
||||
["x-stg", STG_ENV] => { "FEATURE_FLAG" => "on" },
|
||||
["x-prod", PROD_ENV] => { "FEATURE_FLAG" => "off" },
|
||||
})
|
||||
c.instance_variable_set(:@gql, fake)
|
||||
staging = { "services" => [{ "name" => "x", "service_id" => "x-stg" }] }
|
||||
prod = { "services" => [{ "name" => "x", "service_id" => "x-prod" }] }
|
||||
# default (no replicate_keys argument) => empty set => no writes
|
||||
c.replicate_env_keys(staging, prod)
|
||||
assert(fake.mutations.none? { |m| m[:kind] == :upsert },
|
||||
"default replicate set must be EMPTY (no upserts), got #{fake.mutations.inspect}")
|
||||
end
|
||||
|
||||
# ── R-A (HIGH): prod-specific key ASSERTED, NEVER written ───────────────
|
||||
|
||||
def test_prod_specific_key_mismatch_refuses_and_never_writes
|
||||
c = cmd
|
||||
fake = FakeGql.new(values: {
|
||||
["dash-stg", STG_ENV] => { "NEXT_PUBLIC_POCKETBASE_URL" => "https://pb.staging.copilotkit.ai" },
|
||||
# prod MISSING the prod-specific key entirely => mismatch.
|
||||
["dash-prod", PROD_ENV] => {},
|
||||
})
|
||||
c.instance_variable_set(:@gql, fake)
|
||||
staging = { "services" => [{ "name" => "dashboard", "service_id" => "dash-stg" }] }
|
||||
prod = { "services" => [{ "name" => "dashboard", "service_id" => "dash-prod" }] }
|
||||
findings = c.assert_prod_specific_keys(staging, prod, prod_specific_keys: %w[NEXT_PUBLIC_POCKETBASE_URL])
|
||||
assert(findings.any? { |f| f.start_with?("REFUSE") && f =~ /NEXT_PUBLIC_POCKETBASE_URL/ },
|
||||
"expected REFUSE for missing prod-specific key, got #{findings.inspect}")
|
||||
# CRITICAL R-A: no upsert/write issued for the prod-specific key.
|
||||
assert(fake.mutations.none? { |m| m[:kind] == :upsert },
|
||||
"prod-specific key must be ASSERTED, NEVER copied/written, got #{fake.mutations.inspect}")
|
||||
end
|
||||
|
||||
# ── §5.4 resource/concurrency DETECT-and-WARN (never REFUSE) ────────────
|
||||
|
||||
# NOTE: a limitOverride (CPU/memory cap) divergence WARN was originally
|
||||
# specified for check_resource_divergence, but Railway's GraphQL schema
|
||||
# exposes no readable limit field on ServiceInstance (verified by
|
||||
# introspection — the only related surface is the write-only
|
||||
# serviceInstanceLimitsUpdate mutation). build_snapshot therefore cannot
|
||||
# capture the cap, so the WARN was dead code and has been dropped. See
|
||||
# test_snapshot_graphql.rb#test_limit_override_warn_is_unimplementable_via_real_snapshot
|
||||
# for the regression guard proving it through the real snapshot path.
|
||||
|
||||
def test_concurrency_env_divergence_is_advisory_not_refuses
|
||||
# Concurrency knobs (BROWSER_POOL_SIZE) are a RESOURCE/scaling signal,
|
||||
# not a functional contract — divergence is ADVISORY (report-only,
|
||||
# never blocks) per the 2026-06-22 prod↔staging comparison policy.
|
||||
c = cmd
|
||||
staging = { "services" => [svc("x", "x-stg", "env_keys" => %w[BROWSER_POOL_SIZE])] }
|
||||
prod = { "services" => [svc("x", "x-prod", "env_keys" => [])] }
|
||||
findings = c.check_resource_divergence(staging, prod)
|
||||
assert(findings.any? { |f| f.start_with?("ADVISORY") && f =~ /BROWSER_POOL_SIZE/ },
|
||||
"expected ADVISORY for concurrency env divergence, got #{findings.inspect}")
|
||||
assert(findings.none? { |f| f.start_with?("REFUSE") || f.start_with?("WARN") })
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def svc(name, sid, over = {})
|
||||
{ "name" => name, "service_id" => sid }.merge(over)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,206 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# Covers `bin/railway reconcile-prod` — the prod-vs-staging drift comparator
|
||||
# (Lever 1 of the promote-reliability hardening plan).
|
||||
#
|
||||
# Contract: for every prod-eligible (`probe.prod == true`) service, compare the
|
||||
# prod SERVING digest (the immutable `@sha256:` prod is pinned to) against the
|
||||
# staging RUNNING digest (staging's latest SUCCESS deployment's
|
||||
# meta.imageDigest — the same source PromoteCommand#staging_running_digest
|
||||
# reads). Classify each service:
|
||||
#
|
||||
# green — prod digest == staging running digest (in sync).
|
||||
# stale — prod digest != staging running digest AND staging IS resolvable
|
||||
# (prod has drifted behind a green staging — the thing we alert on).
|
||||
# gray — staging running digest not resolvable (no SUCCESS deploy / no
|
||||
# imageDigest) — informational, NOT stale (we can't prove drift).
|
||||
#
|
||||
# Exit code contract (the whole point of the gate):
|
||||
# exit 0 — no `stale` services (all green, or only green+gray).
|
||||
# exit 1 — at least one `stale` service (prod drifted behind green staging).
|
||||
#
|
||||
# Read-only: NO promotes / mutations. --json emits machine output.
|
||||
#
|
||||
# The test injects fakes the same way the promote suite does
|
||||
# (test_promote_staging_running_digest.rb): instance_variable_set the prod +
|
||||
# staging snapshots and a fake that resolves the staging running digest.
|
||||
class ReconcileProdTest < Minitest::Test
|
||||
# Build a ReconcileProdCommand with injected prod + staging snapshots and a
|
||||
# per-service staging-running-digest map (sid => "sha256:..." or nil).
|
||||
#
|
||||
# `eligible` is the list of SSOT-eligible service descriptors the command
|
||||
# iterates: each is { "name" =>, "service_id" => }. We inject it directly so
|
||||
# the test does not depend on the real generated SSOT JSON.
|
||||
def make_cmd(eligible:, prod_services:, running_by_sid:, argv: [])
|
||||
cmd = Railway::ReconcileProdCommand.new(argv)
|
||||
cmd.parser.parse!(cmd.argv)
|
||||
# Inject the prod snapshot the comparator reads (LintProd path).
|
||||
cmd.define_singleton_method(:build_prod_snapshot) do
|
||||
{ "services" => prod_services }
|
||||
end
|
||||
# Inject the prod-eligible service set (normally derived from the SSOT
|
||||
# generated.json by probe.prod == true).
|
||||
cmd.define_singleton_method(:eligible_services) { eligible }
|
||||
# Inject the staging running digest lookup (normally
|
||||
# PromoteCommand#staging_running_digest reading Railway deployments).
|
||||
cmd.define_singleton_method(:staging_running_digest_for) do |svc|
|
||||
running_by_sid[svc["service_id"]]
|
||||
end
|
||||
cmd
|
||||
end
|
||||
|
||||
PROD = lambda do |name, sid, digest|
|
||||
# A prod snapshot service is pinned to an immutable digest:
|
||||
# image = ghcr.io/org/name@sha256:..., digest = sha256:...
|
||||
{
|
||||
"name" => name,
|
||||
"service_id" => sid,
|
||||
"image" => "ghcr.io/copilotkit/#{name}@#{digest}",
|
||||
"digest" => digest,
|
||||
}
|
||||
end
|
||||
|
||||
ELIG = lambda do |name, sid|
|
||||
{ "name" => name, "service_id" => sid }
|
||||
end
|
||||
|
||||
# ===================== RED-anchor: STALE => exit 1 ========================
|
||||
# Prod is pinned to digest A; staging is RUNNING green digest B. prod !=
|
||||
# staging-green => STALE. The comparator MUST classify it stale and exit 1.
|
||||
def test_stale_when_prod_differs_from_green_staging
|
||||
cmd = make_cmd(
|
||||
eligible: [ELIG.call("shell", "sid-shell")],
|
||||
prod_services: [PROD.call("shell", "sid-shell", "sha256:aaaa1111")],
|
||||
running_by_sid: { "sid-shell" => "sha256:bbbb2222" }, # green staging, DIFFERENT
|
||||
)
|
||||
rows = cmd.classify_all
|
||||
row = rows.find { |r| r["name"] == "shell" }
|
||||
assert_equal "stale", row["status"],
|
||||
"prod digest != staging green digest must classify STALE"
|
||||
assert_equal 1, cmd.run_classification(rows),
|
||||
"any stale service must exit non-zero (1)"
|
||||
end
|
||||
|
||||
# ===================== GREEN => exit 0 ====================================
|
||||
def test_green_when_prod_matches_staging
|
||||
cmd = make_cmd(
|
||||
eligible: [ELIG.call("shell", "sid-shell"),
|
||||
ELIG.call("docs", "sid-docs")],
|
||||
prod_services: [PROD.call("shell", "sid-shell", "sha256:aaaa1111"),
|
||||
PROD.call("docs", "sid-docs", "sha256:cccc3333")],
|
||||
running_by_sid: { "sid-shell" => "sha256:aaaa1111",
|
||||
"sid-docs" => "sha256:cccc3333" },
|
||||
)
|
||||
rows = cmd.classify_all
|
||||
assert(rows.all? { |r| r["status"] == "green" },
|
||||
"all matching => all green, got #{rows.map { |r| r['status'] }.inspect}")
|
||||
assert_equal 0, cmd.run_classification(rows),
|
||||
"no stale service => exit 0"
|
||||
end
|
||||
|
||||
# ===================== GRAY (staging not green) => exit 0 =================
|
||||
# Staging has no resolvable running digest (nil). That is NOT drift we can
|
||||
# prove — classify gray (informational), NOT stale. Must NOT red the run.
|
||||
def test_gray_when_staging_not_resolvable
|
||||
cmd = make_cmd(
|
||||
eligible: [ELIG.call("shell", "sid-shell")],
|
||||
prod_services: [PROD.call("shell", "sid-shell", "sha256:aaaa1111")],
|
||||
running_by_sid: { "sid-shell" => nil }, # staging not green/resolvable
|
||||
)
|
||||
rows = cmd.classify_all
|
||||
row = rows.find { |r| r["name"] == "shell" }
|
||||
assert_equal "gray", row["status"],
|
||||
"unresolvable staging digest must be gray, not stale"
|
||||
assert_equal 0, cmd.run_classification(rows),
|
||||
"gray (not stale) must NOT red the run"
|
||||
end
|
||||
|
||||
# ===================== mixed: one stale among green/gray => exit 1 ========
|
||||
def test_mixed_with_one_stale_exits_nonzero
|
||||
cmd = make_cmd(
|
||||
eligible: [ELIG.call("shell", "sid-shell"),
|
||||
ELIG.call("docs", "sid-docs"),
|
||||
ELIG.call("dojo", "sid-dojo")],
|
||||
prod_services: [PROD.call("shell", "sid-shell", "sha256:aaaa1111"),
|
||||
PROD.call("docs", "sid-docs", "sha256:cccc3333"),
|
||||
PROD.call("dojo", "sid-dojo", "sha256:dddd4444")],
|
||||
running_by_sid: { "sid-shell" => "sha256:aaaa1111", # green
|
||||
"sid-docs" => "sha256:9999ffff", # STALE
|
||||
"sid-dojo" => nil }, # gray
|
||||
)
|
||||
rows = cmd.classify_all
|
||||
by_name = rows.each_with_object({}) { |r, h| h[r["name"]] = r["status"] }
|
||||
assert_equal "green", by_name["shell"]
|
||||
assert_equal "stale", by_name["docs"]
|
||||
assert_equal "gray", by_name["dojo"]
|
||||
assert_equal 1, cmd.run_classification(rows),
|
||||
"one stale among green/gray => exit 1"
|
||||
end
|
||||
|
||||
# ===================== --json machine output =============================
|
||||
def test_json_output_shape
|
||||
cmd = make_cmd(
|
||||
eligible: [ELIG.call("shell", "sid-shell")],
|
||||
prod_services: [PROD.call("shell", "sid-shell", "sha256:aaaa1111")],
|
||||
running_by_sid: { "sid-shell" => "sha256:bbbb2222" },
|
||||
argv: ["--json"],
|
||||
)
|
||||
out, = capture_io { cmd.run }
|
||||
payload = JSON.parse(out)
|
||||
assert_equal 1, payload["stale"], "stale count surfaced in JSON"
|
||||
svc = payload["services"].find { |s| s["name"] == "shell" }
|
||||
assert_equal "stale", svc["status"]
|
||||
assert_equal "sha256:aaaa1111", svc["prod"]
|
||||
assert_equal "sha256:bbbb2222", svc["staging"]
|
||||
end
|
||||
|
||||
# ===================== run() end-to-end exit code =========================
|
||||
def test_run_exits_nonzero_on_stale
|
||||
cmd = make_cmd(
|
||||
eligible: [ELIG.call("shell", "sid-shell")],
|
||||
prod_services: [PROD.call("shell", "sid-shell", "sha256:aaaa1111")],
|
||||
running_by_sid: { "sid-shell" => "sha256:bbbb2222" },
|
||||
)
|
||||
rc = nil
|
||||
capture_io { rc = cmd.run }
|
||||
assert_equal 1, rc, "run() must exit 1 when a service is stale"
|
||||
end
|
||||
|
||||
def test_run_exits_zero_when_all_green
|
||||
cmd = make_cmd(
|
||||
eligible: [ELIG.call("shell", "sid-shell")],
|
||||
prod_services: [PROD.call("shell", "sid-shell", "sha256:aaaa1111")],
|
||||
running_by_sid: { "sid-shell" => "sha256:aaaa1111" },
|
||||
)
|
||||
rc = nil
|
||||
capture_io { rc = cmd.run }
|
||||
assert_equal 0, rc, "run() must exit 0 when all services green"
|
||||
end
|
||||
|
||||
# ===================== prod service missing from snapshot =================
|
||||
# A prod-eligible service that has NO prod snapshot entry (never deployed to
|
||||
# prod) has no prod digest to compare. It must NOT be classified stale
|
||||
# (we can't prove drift) — classify gray (informational).
|
||||
def test_missing_prod_service_is_gray_not_stale
|
||||
cmd = make_cmd(
|
||||
eligible: [ELIG.call("newsvc", "sid-new")],
|
||||
prod_services: [], # newsvc not in prod yet
|
||||
running_by_sid: { "sid-new" => "sha256:bbbb2222" },
|
||||
)
|
||||
rows = cmd.classify_all
|
||||
row = rows.find { |r| r["name"] == "newsvc" }
|
||||
assert_equal "gray", row["status"],
|
||||
"prod-eligible service absent from prod snapshot must be gray, not stale"
|
||||
assert_equal 0, cmd.run_classification(rows)
|
||||
end
|
||||
|
||||
# The dispatcher must register the subcommand.
|
||||
def test_subcommand_registered
|
||||
assert Railway::SUBCOMMANDS.key?("reconcile-prod"),
|
||||
"reconcile-prod must be registered in the dispatcher"
|
||||
assert_equal Railway::ReconcileProdCommand,
|
||||
Railway::SUBCOMMANDS["reconcile-prod"]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,350 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# RollbackCommitCommand takes attacker-influenced operator input (--sha, --env)
|
||||
# and used to interpolate both into shell strings (`git ls-tree ... #{sha}` and
|
||||
# `git show #{sha}:#{path}`). A malformed --sha like "abc; touch /tmp/pwn"
|
||||
# would be parsed by the shell. These tests pin the invariant:
|
||||
#
|
||||
# 1. Happy path: a valid hex --sha + known --env locates the snapshot via
|
||||
# IO.popen (argv form, no shell) and hands off to RestoreCommand.
|
||||
# 2. Malformed --sha is rejected BEFORE any subprocess is spawned.
|
||||
# 3. Unknown --env is rejected BEFORE any subprocess is spawned.
|
||||
class RollbackCommitInjectionTest < Minitest::Test
|
||||
# Capture every IO.popen invocation issued during the test so we can both
|
||||
# stub out git AND assert that injection attempts never reach a subprocess.
|
||||
#
|
||||
# Hardening notes:
|
||||
# * `expected_subcmds` is the set of git subcommands the test
|
||||
# explicitly configured (via PopenSpy.responses or PopenSpy.exits).
|
||||
# Any `git <subcmd>` call NOT in that set raises UnexpectedPopen
|
||||
# instead of silently returning nil — so a future code path that
|
||||
# starts shelling out to e.g. `git rev-parse` is caught loudly
|
||||
# at the boundary rather than producing a confusing downstream
|
||||
# YAML.safe_load failure on an empty string.
|
||||
# * `exits` yields an honest `$?.exitstatus` for the configured code
|
||||
# by shelling to a tiny `ruby -e "exit N"` — `true`/`false` only
|
||||
# produce 0/1 and so failed to surface bugs sensitive to the
|
||||
# specific code (e.g. git's 128 for "bad object").
|
||||
class UnexpectedPopen < StandardError; end
|
||||
|
||||
module PopenSpy
|
||||
@calls = []
|
||||
@responses = {}
|
||||
@exits = {}
|
||||
|
||||
class << self
|
||||
attr_reader :calls
|
||||
attr_accessor :responses, :exits
|
||||
|
||||
def reset!
|
||||
@calls = []
|
||||
@responses = {}
|
||||
@exits = {}
|
||||
end
|
||||
|
||||
def record(args)
|
||||
@calls << args
|
||||
end
|
||||
|
||||
# Subcommands the current test has explicitly accounted for.
|
||||
# A response of "" or an exit of 0 counts as an explicit
|
||||
# opt-in: the test author has thought about that subcmd.
|
||||
def expected_subcmds
|
||||
(@responses.keys + @exits.keys).uniq
|
||||
end
|
||||
|
||||
# Set $?.exitstatus to `code` by running a real, short-lived
|
||||
# subprocess that exits with that code. Using `system("true")`
|
||||
# / `system("false")` only ever yields 0 or 1 — too lossy for
|
||||
# bug-fidelity assertions (e.g. git's exit 128 on bad object).
|
||||
def stamp_exit_status!(code)
|
||||
# `ruby -e "exit N"` is portable across CI runners and
|
||||
# avoids relying on shell builtins. Suppress stderr just
|
||||
# in case (shouldn't print anything, but defensive).
|
||||
# Fail loud if the spawn itself fails: `system` returns
|
||||
# `nil` on exec failure (command-not-found / interpreter
|
||||
# unresolvable), in which case `$?` reflects a
|
||||
# ~127 exec failure rather than the configured code and
|
||||
# silently corrupts the spy contract. `false` (the
|
||||
# child ran and exited non-zero with the configured
|
||||
# code) is the happy path here and must NOT raise.
|
||||
result = system(RbConfig.ruby, "-e", "exit #{Integer(code)}", out: File::NULL, err: File::NULL)
|
||||
raise "stamp_exit_status! failed to spawn #{RbConfig.ruby}" if result.nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# A stand-in for RestoreCommand.run so we can detect successful hand-off
|
||||
# without touching Railway's GraphQL API.
|
||||
class FakeRestore
|
||||
@last_argv = nil
|
||||
@ran = false
|
||||
class << self
|
||||
attr_accessor :last_argv, :ran
|
||||
def reset!
|
||||
@last_argv = nil
|
||||
@ran = false
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(argv)
|
||||
@argv = argv
|
||||
end
|
||||
|
||||
def run
|
||||
FakeRestore.last_argv = @argv
|
||||
FakeRestore.ran = true
|
||||
end
|
||||
end
|
||||
|
||||
def setup
|
||||
PopenSpy.reset!
|
||||
FakeRestore.reset!
|
||||
|
||||
# Monkey-patch IO.popen ONLY for the duration of each test.
|
||||
# The real RollbackCommitCommand uses the argv-array form:
|
||||
# IO.popen(["git", "ls-tree", ...], err: [:child, :out]) { |io| io.read }
|
||||
# We intercept that and return canned output keyed by the first non-git
|
||||
# subcommand ("ls-tree" or "show").
|
||||
#
|
||||
# Hardening: any `git <subcmd>` NOT in PopenSpy.expected_subcmds
|
||||
# raises UnexpectedPopen. That makes "a new subprocess shows up
|
||||
# in the code path" a loud failure instead of a silent nil read.
|
||||
# Non-git popens fall through to the real implementation (we
|
||||
# want to keep e.g. minitest's own bookkeeping intact, though
|
||||
# nothing currently relies on it).
|
||||
@original_popen = IO.method(:popen)
|
||||
spy = PopenSpy
|
||||
IO.singleton_class.send(:define_method, :popen) do |*args, **kwargs, &block|
|
||||
spy.record(args)
|
||||
argv = args.first
|
||||
if argv.is_a?(Array) && argv.first == "git"
|
||||
subcmd = argv[1]
|
||||
unless spy.expected_subcmds.include?(subcmd)
|
||||
raise UnexpectedPopen,
|
||||
"PopenSpy received an UNEXPECTED `git #{subcmd}` invocation. " \
|
||||
"The test only configured: #{spy.expected_subcmds.inspect}. " \
|
||||
"If this is a legitimate new subprocess, opt in by setting " \
|
||||
"PopenSpy.responses[#{subcmd.inspect}] (and/or exits) in " \
|
||||
"the test setup. Full argv: #{argv.inspect}"
|
||||
end
|
||||
response = spy.responses[subcmd] || ""
|
||||
# Stamp $?.exitstatus with the configured code (default 0).
|
||||
# Critical for tests asserting on the *specific* exit code
|
||||
# (e.g. git's 128 for "fatal: bad object") rather than a
|
||||
# generic 0/1 success/fail.
|
||||
exit_code = spy.exits[subcmd] || 0
|
||||
spy.stamp_exit_status!(exit_code)
|
||||
# Mimic the block form used by the production code.
|
||||
if block
|
||||
require "stringio"
|
||||
block.call(StringIO.new(response))
|
||||
else
|
||||
response
|
||||
end
|
||||
else
|
||||
# Defer to the real implementation for anything we don't expect.
|
||||
spy.instance_variable_get(:@original_popen)&.call(*args, **kwargs, &block)
|
||||
end
|
||||
end
|
||||
|
||||
# Stub RestoreCommand so the integration boundary never tries to hit
|
||||
# Railway. We swap the constant and restore in teardown.
|
||||
@original_restore = Railway::RestoreCommand
|
||||
Railway.send(:remove_const, :RestoreCommand)
|
||||
Railway.const_set(:RestoreCommand, FakeRestore)
|
||||
end
|
||||
|
||||
def teardown
|
||||
# Restore IO.popen.
|
||||
original = @original_popen
|
||||
IO.singleton_class.send(:define_method, :popen) do |*args, **kwargs, &block|
|
||||
original.call(*args, **kwargs, &block)
|
||||
end
|
||||
# Restore RestoreCommand.
|
||||
Railway.send(:remove_const, :RestoreCommand)
|
||||
Railway.const_set(:RestoreCommand, @original_restore)
|
||||
end
|
||||
|
||||
# ── Happy path ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_valid_sha_and_env_invokes_git_via_argv_and_hands_off_to_restore
|
||||
PopenSpy.responses["ls-tree"] = "showcase/.railway-snapshots/20260101T000000Z-staging.yaml\n"
|
||||
PopenSpy.responses["show"] = "schema_version: 1\nservices: []\n"
|
||||
|
||||
cmd = Railway::RollbackCommitCommand.new(
|
||||
["--env", "staging", "--sha", "abc1234", "--yes", "--non-interactive", "--dry-run"]
|
||||
)
|
||||
cmd.run
|
||||
|
||||
# 1. RestoreCommand got the expected argv (proves hand-off happened).
|
||||
assert FakeRestore.ran, "expected RestoreCommand to be invoked on happy path"
|
||||
assert_includes FakeRestore.last_argv, "--env"
|
||||
assert_includes FakeRestore.last_argv, "staging"
|
||||
assert_includes FakeRestore.last_argv, "--snapshot"
|
||||
assert_includes FakeRestore.last_argv, "--dry-run"
|
||||
|
||||
# 2. Every git invocation used the argv-array form (no shell string).
|
||||
git_calls = PopenSpy.calls.map(&:first).select { |a| a.is_a?(Array) && a.first == "git" }
|
||||
refute_empty git_calls, "expected at least one git subprocess via IO.popen argv-array"
|
||||
git_calls.each do |argv|
|
||||
assert argv.is_a?(Array), "git call must be an argv array, got #{argv.inspect}"
|
||||
assert argv.all? { |a| a.is_a?(String) }, "all argv elements must be strings"
|
||||
end
|
||||
|
||||
# 3. The sha appears as a literal argv element somewhere (not glued).
|
||||
ls_call = git_calls.find { |a| a[1] == "ls-tree" }
|
||||
assert ls_call, "expected a `git ls-tree` invocation"
|
||||
assert_includes ls_call, "abc1234"
|
||||
|
||||
show_call = git_calls.find { |a| a[1] == "show" }
|
||||
assert show_call, "expected a `git show` invocation"
|
||||
# `git show` takes sha:path as a single token by design; ensure it's
|
||||
# the FULL token (not concatenated with anything else like `; rm -rf`).
|
||||
assert_includes show_call, "abc1234:showcase/.railway-snapshots/20260101T000000Z-staging.yaml"
|
||||
end
|
||||
|
||||
# ── Rejection: malformed --sha ─────────────────────────────────────────
|
||||
|
||||
def test_malformed_sha_is_rejected_before_any_subprocess
|
||||
malicious = "abc; touch /tmp/pwn"
|
||||
cmd = Railway::RollbackCommitCommand.new(["--env", "staging", "--sha", malicious])
|
||||
|
||||
exited = assert_raises(SystemExit) { cmd.run }
|
||||
refute_equal 0, exited.status, "rejection must exit nonzero"
|
||||
|
||||
# No subprocess of any kind should have been launched.
|
||||
assert_empty PopenSpy.calls.select { |c| c.first.is_a?(Array) && c.first.first == "git" },
|
||||
"no git subprocess should be spawned for malformed --sha; got #{PopenSpy.calls.inspect}"
|
||||
refute FakeRestore.ran, "RestoreCommand must not run when --sha is rejected"
|
||||
end
|
||||
|
||||
def test_sha_with_uppercase_is_rejected
|
||||
cmd = Railway::RollbackCommitCommand.new(["--env", "staging", "--sha", "ABC1234"])
|
||||
exited = assert_raises(SystemExit) { cmd.run }
|
||||
refute_equal 0, exited.status, "rejection must exit nonzero"
|
||||
assert_empty PopenSpy.calls.select { |c| c.first.is_a?(Array) && c.first.first == "git" },
|
||||
"no git subprocess should be spawned for uppercase --sha"
|
||||
refute FakeRestore.ran, "RestoreCommand must not run when --sha is rejected"
|
||||
end
|
||||
|
||||
def test_sha_too_short_is_rejected
|
||||
cmd = Railway::RollbackCommitCommand.new(["--env", "staging", "--sha", "abc12"])
|
||||
exited = assert_raises(SystemExit) { cmd.run }
|
||||
refute_equal 0, exited.status, "rejection must exit nonzero"
|
||||
assert_empty PopenSpy.calls.select { |c| c.first.is_a?(Array) && c.first.first == "git" },
|
||||
"no git subprocess should be spawned for too-short --sha"
|
||||
refute FakeRestore.ran, "RestoreCommand must not run when --sha is rejected"
|
||||
end
|
||||
|
||||
# ── Subprocess-failure gating ──────────────────────────────────────────
|
||||
|
||||
# Regression: a failed `git show` previously slipped past the nil/empty
|
||||
# guard because `err: [:child, :out]` merges stderr into stdout, so a
|
||||
# non-zero exit produces a non-empty `yaml` containing git's error text
|
||||
# which then flowed into YAML.safe_load. The fix gates on $?.exitstatus.
|
||||
def test_git_show_nonzero_exit_dies_before_yaml_parse
|
||||
PopenSpy.responses["ls-tree"] = "showcase/.railway-snapshots/20260101T000000Z-staging.yaml\n"
|
||||
# Simulate a corrupt/missing blob: git prints an error to stderr
|
||||
# (merged into stdout via err: [:child, :out]) and exits non-zero.
|
||||
PopenSpy.responses["show"] = "fatal: bad object abc1234:showcase/.railway-snapshots/20260101T000000Z-staging.yaml\n"
|
||||
PopenSpy.exits["show"] = 128
|
||||
|
||||
cmd = Railway::RollbackCommitCommand.new(
|
||||
["--env", "staging", "--sha", "abc1234", "--yes", "--non-interactive", "--dry-run"]
|
||||
)
|
||||
|
||||
exited = assert_raises(SystemExit) { cmd.run }
|
||||
refute_equal 0, exited.status, "git-show failure must exit nonzero"
|
||||
refute FakeRestore.ran, "RestoreCommand must not run when git show fails"
|
||||
end
|
||||
|
||||
def test_empty_snapshot_listing_dies_before_git_show
|
||||
# ls-tree succeeds but returns no entries → die before any git show.
|
||||
PopenSpy.responses["ls-tree"] = ""
|
||||
|
||||
cmd = Railway::RollbackCommitCommand.new(
|
||||
["--env", "staging", "--sha", "abc1234", "--yes", "--non-interactive", "--dry-run"]
|
||||
)
|
||||
|
||||
exited = assert_raises(SystemExit) { cmd.run }
|
||||
refute_equal 0, exited.status, "no-snapshot must exit nonzero"
|
||||
refute FakeRestore.ran, "RestoreCommand must not run when no snapshot found"
|
||||
|
||||
# No `git show` should have been spawned.
|
||||
show_calls = PopenSpy.calls.select do |c|
|
||||
c.first.is_a?(Array) && c.first.first == "git" && c.first[1] == "show"
|
||||
end
|
||||
assert_empty show_calls, "no git show should run when ls-tree returns empty"
|
||||
end
|
||||
|
||||
# ── Rejection: unknown --env ───────────────────────────────────────────
|
||||
|
||||
def test_unknown_env_is_rejected_before_any_subprocess
|
||||
cmd = Railway::RollbackCommitCommand.new(["--env", "evil; rm -rf ~", "--sha", "abc1234"])
|
||||
|
||||
exited = assert_raises(SystemExit) { cmd.run }
|
||||
refute_equal 0, exited.status, "rejection must exit nonzero"
|
||||
|
||||
assert_empty PopenSpy.calls.select { |c| c.first.is_a?(Array) && c.first.first == "git" },
|
||||
"no git subprocess should be spawned for unknown --env"
|
||||
refute FakeRestore.ran
|
||||
end
|
||||
|
||||
# ── PopenSpy self-test: hardening guarantees ──────────────────────────
|
||||
#
|
||||
# These tests pin the spy's own contract so it can't silently rot.
|
||||
# The spy is the only thing standing between a future subprocess
|
||||
# addition and a test that "passes" with a wrong answer.
|
||||
|
||||
# If the production code adds a NEW git subprocess (e.g. rev-parse)
|
||||
# without the test opting in, the spy must raise — not silently
|
||||
# return nil/"" which would corrupt downstream assertions.
|
||||
def test_popen_spy_raises_on_unexpected_git_subcommand
|
||||
# Only "ls-tree" and "show" are configured here.
|
||||
PopenSpy.responses["ls-tree"] = ""
|
||||
PopenSpy.responses["show"] = ""
|
||||
|
||||
# Direct invocation simulates the "new subprocess slipped in"
|
||||
# scenario without needing to add a real call site to railway.
|
||||
err = assert_raises(UnexpectedPopen) do
|
||||
IO.popen(["git", "rev-parse", "HEAD"], err: [:child, :out]) { |io| io.read }
|
||||
end
|
||||
assert_match(/UNEXPECTED `git rev-parse`/, err.message,
|
||||
"spy must name the offending subcommand in its error")
|
||||
assert_match(/ls-tree/, err.message,
|
||||
"spy must list the configured subcommands so the operator " \
|
||||
"can decide whether to opt the new one in")
|
||||
end
|
||||
|
||||
# Exit-code fidelity: configuring `exits["show"] = 128` must yield
|
||||
# an honest `$?.exitstatus == 128`, not a generic 1. The git-show
|
||||
# failure-gate test depends on this fidelity to be a meaningful
|
||||
# regression test of the gate (a gate keyed on `!= 0` would pass
|
||||
# against a fake 1, but a gate keyed on `== 128` would not).
|
||||
def test_popen_spy_stamps_real_exit_status_for_configured_code
|
||||
PopenSpy.responses["show"] = "fatal: whatever\n"
|
||||
PopenSpy.exits["show"] = 128
|
||||
|
||||
IO.popen(["git", "show", "abc1234:foo"], err: [:child, :out]) { |io| io.read }
|
||||
assert_equal 128, $?.exitstatus,
|
||||
"PopenSpy.stamp_exit_status! must reflect the *configured* " \
|
||||
"exit code in $?.exitstatus (got #{$?.exitstatus.inspect}). " \
|
||||
"Without this, tests asserting on specific git exit codes " \
|
||||
"(e.g. 128 for bad object) are vacuous."
|
||||
end
|
||||
|
||||
# Default behaviour: when `exits[subcmd]` is unset, the call must
|
||||
# behave like a successful git invocation (`$?.exitstatus == 0`).
|
||||
def test_popen_spy_defaults_to_exit_zero_when_exits_unset
|
||||
PopenSpy.responses["ls-tree"] = "snap.yaml\n"
|
||||
|
||||
IO.popen(["git", "ls-tree", "abc1234"], err: [:child, :out]) { |io| io.read }
|
||||
assert_equal 0, $?.exitstatus,
|
||||
"default exit status for an un-configured subcmd response " \
|
||||
"must be 0 (success), matching how real git behaves on a " \
|
||||
"successful call"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# RollbackCommand#find_previous_deployment must pick the newest SUCCESS
|
||||
# deployment STRICTLY OLDER than the current HEAD deploy (by createdAt) — i.e.
|
||||
# the last-known-good deploy to roll back to. Railway's GraphQL `deployments`
|
||||
# connection returns nodes in an arbitrary order, so the method MUST sort by
|
||||
# createdAt descending before selecting, exactly as the sibling
|
||||
# fetch_latest_staging_deployments documents and does.
|
||||
#
|
||||
# Selection logic: sort newest-first by createdAt, drop the HEAD (index 0),
|
||||
# then take the first SUCCESS in the remainder. This is correct in BOTH
|
||||
# directions:
|
||||
# - head=SUCCESS -> the previous SUCCESS (one deploy back)
|
||||
# - head=FAILED -> the newest SUCCESS below the failed head (the
|
||||
# last-known-good — NOT one good deploy too far)
|
||||
#
|
||||
# Truncation hazard: the query only fetches `first: N` deployments in arbitrary
|
||||
# order, so a >N-deploy service may not contain the true previous within the
|
||||
# window. When no target is found AND the window is saturated (returned count
|
||||
# >= N), the method must die! loud rather than return nil — otherwise rollback
|
||||
# becomes a confusing no-op or rolls to the wrong place.
|
||||
class RollbackSortTest < Minitest::Test
|
||||
# Mirror the production query's `first:` limit so the saturated-window test
|
||||
# stays in lockstep with bin/railway.
|
||||
WINDOW = Railway::RollbackCommand::DEPLOYMENTS_WINDOW
|
||||
|
||||
# Minimal fake GraphQL client: returns canned DEPLOYMENTS_QUERY edges.
|
||||
class FakeGQL
|
||||
def initialize(nodes)
|
||||
@nodes = nodes
|
||||
end
|
||||
|
||||
def query(_query_str, _variables = {})
|
||||
{ "deployments" => { "edges" => @nodes.map { |n| { "node" => n } } } }
|
||||
end
|
||||
end
|
||||
|
||||
def cmd_for(nodes)
|
||||
cmd = Railway::RollbackCommand.new([])
|
||||
cmd.instance_variable_set(:@gql, FakeGQL.new(nodes))
|
||||
cmd
|
||||
end
|
||||
|
||||
def test_head_success_picks_previous_success
|
||||
# Edge order is deliberately scrambled (NOT chronological). By createdAt
|
||||
# the SUCCESS deployments are, newest-first:
|
||||
# dep-newest (T5) > dep-prev (T3) > dep-old (T1)
|
||||
# HEAD is dep-newest (SUCCESS), so the rollback target is the previous
|
||||
# SUCCESS, dep-prev. A FAILED deploy at T4 must be ignored.
|
||||
nodes = [
|
||||
{ "id" => "dep-old", "status" => "SUCCESS", "createdAt" => "2026-01-01T00:00:00Z" },
|
||||
{ "id" => "dep-newest", "status" => "SUCCESS", "createdAt" => "2026-01-05T00:00:00Z" },
|
||||
{ "id" => "dep-failed", "status" => "FAILED", "createdAt" => "2026-01-04T00:00:00Z" },
|
||||
{ "id" => "dep-prev", "status" => "SUCCESS", "createdAt" => "2026-01-03T00:00:00Z" },
|
||||
]
|
||||
|
||||
result = cmd_for(nodes).find_previous_deployment("svc-1", "env-1")
|
||||
assert_equal "dep-prev", result,
|
||||
"expected the previous SUCCESS below the SUCCESS head (dep-prev), got #{result.inspect}"
|
||||
end
|
||||
|
||||
def test_head_failed_picks_newest_success_below_head
|
||||
# HEAD is FAILED (this is exactly when rollback is invoked). The target
|
||||
# MUST be the newest SUCCESS strictly below the failed head — SUCCESS_A
|
||||
# at T4 — NOT SUCCESS_B at T3 (the old `successes[1]` behavior would
|
||||
# skip a good deploy and roll back one too far).
|
||||
nodes = [
|
||||
{ "id" => "head-failed", "status" => "FAILED", "createdAt" => "2026-01-05T00:00:00Z" },
|
||||
{ "id" => "success-a", "status" => "SUCCESS", "createdAt" => "2026-01-04T00:00:00Z" },
|
||||
{ "id" => "success-b", "status" => "SUCCESS", "createdAt" => "2026-01-03T00:00:00Z" },
|
||||
]
|
||||
|
||||
result = cmd_for(nodes).find_previous_deployment("svc-1", "env-1")
|
||||
assert_equal "success-a", result,
|
||||
"head=FAILED must roll back to the newest SUCCESS below it (success-a), " \
|
||||
"not skip it to success-b; got #{result.inspect}"
|
||||
end
|
||||
|
||||
def test_returns_nil_when_no_success_below_head_and_window_not_saturated
|
||||
# Fewer than WINDOW deployments returned => genuine "no previous",
|
||||
# returning nil is correct (the caller die!s with a clear message).
|
||||
nodes = [
|
||||
{ "id" => "dep-only", "status" => "SUCCESS", "createdAt" => "2026-01-05T00:00:00Z" },
|
||||
{ "id" => "dep-failed", "status" => "FAILED", "createdAt" => "2026-01-04T00:00:00Z" },
|
||||
]
|
||||
assert_nil cmd_for(nodes).find_previous_deployment("svc-1", "env-1")
|
||||
end
|
||||
|
||||
def test_raises_when_window_saturated_and_no_target_found
|
||||
# Exactly WINDOW deployments returned with no SUCCESS below the head =>
|
||||
# the true previous may have been truncated out of the window. The
|
||||
# method must die! (SystemExit) rather than silently return nil.
|
||||
head = { "id" => "head", "status" => "FAILED", "createdAt" => "2026-02-#{WINDOW + 1}T00:00:00Z" }
|
||||
rest = (1...WINDOW).map do |i|
|
||||
{ "id" => "crashed-#{i}", "status" => "CRASHED", "createdAt" => format("2026-02-%02dT00:00:00Z", i) }
|
||||
end
|
||||
nodes = [head] + rest
|
||||
assert_equal WINDOW, nodes.size, "test must return exactly WINDOW deployments"
|
||||
|
||||
assert_raises(SystemExit) do
|
||||
cmd_for(nodes).find_previous_deployment("svc-1", "env-1")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,369 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
# Verifies the GraphQL queries used by SnapshotCommand match Railway's
|
||||
# public schema shape (as of 2026-05). The mocks here mirror real Railway
|
||||
# responses; any drift between this file and the live schema means the
|
||||
# tool will fail at runtime — which is exactly the bug this PR fixes.
|
||||
class SnapshotGraphqlTest < Minitest::Test
|
||||
# Fake GraphQL client that returns canned responses keyed by query name.
|
||||
class FakeGQL
|
||||
def initialize(responses)
|
||||
@responses = responses
|
||||
@calls = []
|
||||
end
|
||||
|
||||
attr_reader :calls
|
||||
|
||||
def query(query_str, variables = {})
|
||||
@calls << [query_str, variables]
|
||||
# Match on the operation name (the line after `query `) so we can
|
||||
# serve different mocks for SERVICES_LIST_QUERY,
|
||||
# SERVICE_INSTANCE_QUERY, ENVIRONMENT_VARIABLES_QUERY.
|
||||
op = query_str[/query\s+(\w+)/, 1]
|
||||
response = @responses[op] || @responses[:default]
|
||||
raise "no fake response for op=#{op.inspect}" unless response
|
||||
response.respond_to?(:call) ? response.call(variables) : response
|
||||
end
|
||||
end
|
||||
|
||||
def services_list_response
|
||||
{
|
||||
"project" => {
|
||||
"id" => Railway::PROJECT_ID,
|
||||
"name" => "showcase",
|
||||
"services" => {
|
||||
"edges" => [
|
||||
{ "node" => { "id" => "svc-aimock", "name" => "aimock" } },
|
||||
{ "node" => { "id" => "svc-shell", "name" => "shell" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def env_vars_response_with_per_service_keys
|
||||
{
|
||||
"environment" => {
|
||||
"id" => Railway::PRODUCTION_ENV_ID,
|
||||
"name" => "production",
|
||||
"variables" => {
|
||||
"edges" => [
|
||||
{ "node" => { "name" => "PORT", "serviceId" => "svc-aimock", "isSealed" => false } },
|
||||
{ "node" => { "name" => "NODE_OPTIONS","serviceId" => "svc-aimock", "isSealed" => false } },
|
||||
{ "node" => { "name" => "API_KEY", "serviceId" => "svc-shell", "isSealed" => true } },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def service_instance_response(image:, start_cmd: nil, domains: [],
|
||||
healthcheck_path: nil, region: nil,
|
||||
num_replicas: nil, restart_policy: nil)
|
||||
{
|
||||
"serviceInstance" => {
|
||||
"id" => "inst-#{image[/sha256:[a-f0-9]+/] || 'tag'}",
|
||||
"serviceId" => "svc-aimock",
|
||||
"environmentId" => Railway::PRODUCTION_ENV_ID,
|
||||
"startCommand" => start_cmd,
|
||||
"healthcheckPath" => healthcheck_path,
|
||||
"region" => region,
|
||||
"numReplicas" => num_replicas,
|
||||
"restartPolicyType" => restart_policy,
|
||||
"source" => { "image" => image, "repo" => nil },
|
||||
"latestDeployment" => { "id" => "dep-1", "status" => "SUCCESS" },
|
||||
"domains" => {
|
||||
"customDomains" => domains.map { |d| { "id" => "cd-#{d}", "domain" => d } },
|
||||
"serviceDomains" => [],
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
def test_limit_override_warn_is_unimplementable_via_real_snapshot
|
||||
# Regression guard for the DROPPED limitOverride WARN. A
|
||||
# limitOverride (CPU/memory cap) divergence WARN was originally
|
||||
# specified for check_resource_divergence, but it was dead code: it
|
||||
# compared snapshot["limit_override"] values that build_snapshot never
|
||||
# captures, because Railway's GraphQL ServiceInstance type exposes NO
|
||||
# readable limit field (verified by introspection 2026-06 — the only
|
||||
# related surface is the write-only serviceInstanceLimitsUpdate mutation
|
||||
# taking ServiceInstanceLimitsUpdateInput{memoryGB,vCPUs}; the
|
||||
# ServiceInstanceLimit type is an opaque scalar with no readable fields).
|
||||
#
|
||||
# This guard drives a divergence through the REAL capture path
|
||||
# (build_snapshot) — even injecting a hypothetical limit into the fake
|
||||
# serviceInstance response — and asserts: (a) build_snapshot drops it
|
||||
# (no limit_override key survives), and (b) check_resource_divergence
|
||||
# emits NO limitOverride WARN. If Railway ever adds a readable limit
|
||||
# field and someone wires it through SERVICE_INSTANCE_QUERY + the
|
||||
# snapshot hash, this guard flips and signals the WARN can be re-added.
|
||||
build = lambda do |limit|
|
||||
fake = FakeGQL.new(
|
||||
"ProjectServices" => {
|
||||
"project" => { "id" => Railway::PROJECT_ID, "name" => "showcase",
|
||||
"services" => { "edges" => [{ "node" => { "id" => "svc-x", "name" => "x" } }] } },
|
||||
},
|
||||
"EnvVariables" => { "environment" => { "id" => "e", "name" => "n",
|
||||
"variables" => { "edges" => [] } } },
|
||||
"ServiceInstance" => service_instance_response(
|
||||
image: "ghcr.io/copilotkit/x@sha256:cafef00d",
|
||||
).tap { |r| r["serviceInstance"]["serviceLimitOverride"] = limit },
|
||||
)
|
||||
cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"])
|
||||
cmd.instance_variable_set(:@gql, fake)
|
||||
cmd.build_snapshot(Railway::PRODUCTION_ENV_ID)
|
||||
end
|
||||
staging = build.call("memoryGB" => 4.0, "vCPUs" => 2.0)
|
||||
prod = build.call("memoryGB" => 1.0, "vCPUs" => 1.0)
|
||||
|
||||
# (a) the real snapshot path captures no limit field at all.
|
||||
staging["services"].each { |s| assert_nil s["limit_override"] }
|
||||
prod["services"].each { |s| assert_nil s["limit_override"] }
|
||||
|
||||
# (b) consequently the dropped WARN cannot (and does not) fire.
|
||||
c = Railway::PromoteCommand.new(["--non-interactive", "--yes"])
|
||||
c.parser.parse!(c.argv)
|
||||
findings = c.check_resource_divergence(staging, prod)
|
||||
assert(findings.none? { |f| f =~ /limitOverride/i },
|
||||
"limitOverride WARN was dropped as unimplementable dead code; it must " \
|
||||
"not reappear unless build_snapshot genuinely captures a limit field — " \
|
||||
"got #{findings.inspect}")
|
||||
end
|
||||
|
||||
def test_build_snapshot_uses_corrected_field_names_and_produces_pinned_entries
|
||||
fake = FakeGQL.new(
|
||||
"ProjectServices" => services_list_response,
|
||||
"EnvVariables" => env_vars_response_with_per_service_keys,
|
||||
"ServiceInstance" => lambda do |vars|
|
||||
if vars[:serviceId] == "svc-aimock"
|
||||
service_instance_response(
|
||||
image: "ghcr.io/copilotkit/showcase-aimock@sha256:cafef00d",
|
||||
start_cmd: "node /app/dist/cli.js",
|
||||
domains: ["aimock.showcase.copilotkit.ai"],
|
||||
)
|
||||
else
|
||||
service_instance_response(
|
||||
image: "ghcr.io/copilotkit/showcase-shell@sha256:beef1234",
|
||||
domains: [],
|
||||
)
|
||||
end
|
||||
end,
|
||||
)
|
||||
|
||||
cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"])
|
||||
cmd.instance_variable_set(:@gql, fake)
|
||||
|
||||
snap = cmd.build_snapshot(Railway::PRODUCTION_ENV_ID)
|
||||
|
||||
assert_equal 2, snap["version"]
|
||||
assert_equal 2, snap["services"].length
|
||||
|
||||
aimock = snap["services"].find { |s| s["name"] == "aimock" }
|
||||
assert_equal "ghcr.io/copilotkit/showcase-aimock@sha256:cafef00d", aimock["image"]
|
||||
assert_equal "sha256:cafef00d", aimock["digest"]
|
||||
assert_equal "ghcr.io/copilotkit/showcase-aimock", aimock["image_tag"]
|
||||
assert_equal "node /app/dist/cli.js", aimock["start_command"]
|
||||
assert_equal ["aimock.showcase.copilotkit.ai"], aimock["custom_domains"]
|
||||
assert_equal %w[NODE_OPTIONS PORT], aimock["env_keys"]
|
||||
assert_equal "dep-1", aimock["latest_deployment_id"]
|
||||
|
||||
shell = snap["services"].find { |s| s["name"] == "shell" }
|
||||
assert_equal ["API_KEY"], shell["env_keys"]
|
||||
assert_equal [], shell["custom_domains"]
|
||||
end
|
||||
|
||||
def test_lint_prod_marks_mutable_tag_when_image_is_unpinned
|
||||
fake = FakeGQL.new(
|
||||
"ProjectServices" => services_list_response,
|
||||
"EnvVariables" => env_vars_response_with_per_service_keys,
|
||||
"ServiceInstance" => lambda do |vars|
|
||||
# Both return an unpinned :latest tag.
|
||||
service_instance_response(
|
||||
image: "ghcr.io/copilotkit/#{vars[:serviceId]}:latest",
|
||||
)
|
||||
end,
|
||||
)
|
||||
snap_cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"])
|
||||
snap_cmd.instance_variable_set(:@gql, fake)
|
||||
snap = snap_cmd.build_snapshot(Railway::PRODUCTION_ENV_ID)
|
||||
|
||||
# All services are mutable-tag because none have @sha256:.
|
||||
snap["services"].each do |svc|
|
||||
refute svc["image"].include?("@sha256:"), "expected mutable tag for #{svc['name']}"
|
||||
assert_nil svc["digest"], "expected nil digest for #{svc['name']}"
|
||||
end
|
||||
end
|
||||
|
||||
def test_build_snapshot_queries_use_only_supported_fields
|
||||
# Regression guard: the previous version of this tool referenced
|
||||
# `Project.domains` and `Service.serviceInstances`, both of which do
|
||||
# NOT exist in Railway's public GraphQL schema. Ensure those tokens
|
||||
# never reappear in the query constants.
|
||||
sources = [
|
||||
Railway::SERVICES_LIST_QUERY,
|
||||
Railway::SERVICE_INSTANCE_QUERY,
|
||||
Railway::ENVIRONMENT_VARIABLES_QUERY,
|
||||
]
|
||||
sources.each do |q|
|
||||
refute_match(/project\s*\([^)]*\)\s*\{[^}]*\bdomains\b/m, q,
|
||||
"Project has no `domains` field — use serviceInstance.domains or the top-level domains query.")
|
||||
refute_match(/\bserviceInstances\b/m, q,
|
||||
"Service has no `serviceInstances` field — use serviceInstance(serviceId, environmentId) directly.")
|
||||
end
|
||||
end
|
||||
|
||||
def test_deploy_mutation_uses_serviceInstanceDeployV2_not_redeploy
|
||||
# Bug #2: serviceInstanceRedeploy replays the EXISTING deployment's
|
||||
# snapshot (its OLD image), so a freshly pinned source.image never
|
||||
# reaches the running container. Pinning must go through
|
||||
# serviceInstanceUpdate (source.image) + serviceInstanceDeployV2, which
|
||||
# spawns a NEW deployment that PULLS the updated source.image.
|
||||
#
|
||||
# serviceInstanceDeployV2 has signature (serviceId, environmentId,
|
||||
# commitSha?) and does NOT accept an `image` arg — the image is carried
|
||||
# by the preceding serviceInstanceUpdate.
|
||||
refute_match(/serviceInstanceDeployV2\s*\([^)]*\bimage\b/m,
|
||||
Railway::RestoreCommand::DEPLOY_V2_MUTATION,
|
||||
"DeployV2 must not be called with an image arg.")
|
||||
assert_match(/serviceInstanceUpdate\s*\(/, Railway::RestoreCommand::UPDATE_IMAGE_MUTATION)
|
||||
assert_match(/source:\s*\{\s*image:/, Railway::RestoreCommand::UPDATE_IMAGE_MUTATION)
|
||||
assert_match(/serviceInstanceDeployV2\s*\(/, Railway::RestoreCommand::DEPLOY_V2_MUTATION)
|
||||
# The bug-#2 trap: serviceInstanceRedeploy must NOT be the deploy path.
|
||||
refute Railway::RestoreCommand.const_defined?(:REDEPLOY_MUTATION),
|
||||
"serviceInstanceRedeploy must be removed — it replays the old image (bug #2)."
|
||||
end
|
||||
|
||||
def test_snapshot_v2_captures_healthcheck_region_replicas_restart_policy
|
||||
# P6 parity-matrix needs these four fields on every snapshot service.
|
||||
# Snapshot schema is v2; SnapshotCommand#build_snapshot must map them
|
||||
# from the new SERVICE_INSTANCE_QUERY selection set.
|
||||
fake = FakeGQL.new(
|
||||
"ProjectServices" => services_list_response,
|
||||
"EnvVariables" => env_vars_response_with_per_service_keys,
|
||||
"ServiceInstance" => lambda do |vars|
|
||||
if vars[:serviceId] == "svc-aimock"
|
||||
service_instance_response(
|
||||
image: "ghcr.io/copilotkit/showcase-aimock@sha256:cafef00d",
|
||||
start_cmd: "node /app/dist/cli.js",
|
||||
domains: ["aimock.showcase.copilotkit.ai"],
|
||||
healthcheck_path: "/healthz",
|
||||
region: "us-west2",
|
||||
num_replicas: 2,
|
||||
restart_policy: "ON_FAILURE",
|
||||
)
|
||||
else
|
||||
service_instance_response(
|
||||
image: "ghcr.io/copilotkit/showcase-shell@sha256:beef1234",
|
||||
healthcheck_path: "/health",
|
||||
region: "us-east1",
|
||||
num_replicas: 1,
|
||||
restart_policy: "ALWAYS",
|
||||
)
|
||||
end
|
||||
end,
|
||||
)
|
||||
|
||||
cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"])
|
||||
cmd.instance_variable_set(:@gql, fake)
|
||||
snap = cmd.build_snapshot(Railway::PRODUCTION_ENV_ID)
|
||||
|
||||
aimock = snap["services"].find { |s| s["name"] == "aimock" }
|
||||
assert_equal "/healthz", aimock["healthcheck_path"]
|
||||
assert_equal "us-west2", aimock["region"]
|
||||
assert_equal 2, aimock["replicas"]
|
||||
assert_equal "ON_FAILURE", aimock["restart_policy"]
|
||||
|
||||
shell = snap["services"].find { |s| s["name"] == "shell" }
|
||||
assert_equal "/health", shell["healthcheck_path"]
|
||||
assert_equal "us-east1", shell["region"]
|
||||
assert_equal 1, shell["replicas"]
|
||||
assert_equal "ALWAYS", shell["restart_policy"]
|
||||
end
|
||||
|
||||
def test_snapshot_io_read_accepts_v1_and_v2_snapshots
|
||||
# rollback-commit replays historical snapshots from arbitrary git SHAs,
|
||||
# so SnapshotIO.read MUST stay backwards-compat with v1 even though
|
||||
# all NEW snapshots are written as v2.
|
||||
require "tempfile"
|
||||
[1, 2].each do |ver|
|
||||
Tempfile.create(["snap-v#{ver}", ".yaml"]) do |f|
|
||||
f.write(YAML.dump("version" => ver, "services" => []))
|
||||
f.flush
|
||||
snap = Railway::SnapshotIO.read(f.path)
|
||||
assert_equal ver, snap["version"]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def test_deploymentRollback_mutation_has_no_selection_set_because_it_returns_boolean
|
||||
# deploymentRollback's return type is Boolean (scalar). GraphQL
|
||||
# forbids a selection set on scalar fields.
|
||||
refute_match(/deploymentRollback\s*\([^)]*\)\s*\{/m,
|
||||
Railway::RollbackCommand::ROLLBACK_MUTATION,
|
||||
"deploymentRollback returns Boolean; no selection set allowed.")
|
||||
end
|
||||
|
||||
def test_build_snapshot_skips_service_whose_instance_throws_not_found_instead_of_aborting
|
||||
# Regression: run 27144525566. A single odd/half-deleted service in the
|
||||
# project list threw `GraphQL: ServiceInstance not found` from the
|
||||
# per-service serviceInstance query. The only guard was `next if
|
||||
# inst.nil?` — it handled a NULL result but not a THROWN error, so the
|
||||
# error bubbled to Railway.run's top-level `rescue GraphQL::Error` and
|
||||
# aborted the ENTIRE promote (opaque exit 2) before any preflight ran.
|
||||
#
|
||||
# build_snapshot must tolerate a thrown per-service `ServiceInstance not
|
||||
# found` the same way it tolerates nil: skip that one service and keep
|
||||
# going, so the healthy services still produce a usable snapshot.
|
||||
fake = FakeGQL.new(
|
||||
"ProjectServices" => services_list_response,
|
||||
"EnvVariables" => env_vars_response_with_per_service_keys,
|
||||
"ServiceInstance" => lambda do |vars|
|
||||
if vars[:serviceId] == "svc-aimock"
|
||||
# Half-deleted / instance-less service: Railway throws this
|
||||
# exact GraphQL error rather than returning null.
|
||||
raise Railway::GraphQL::Error, "GraphQL: ServiceInstance not found"
|
||||
else
|
||||
service_instance_response(
|
||||
image: "ghcr.io/copilotkit/showcase-shell@sha256:beef1234",
|
||||
domains: [],
|
||||
)
|
||||
end
|
||||
end,
|
||||
)
|
||||
|
||||
cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"])
|
||||
cmd.instance_variable_set(:@gql, fake)
|
||||
|
||||
snap = cmd.build_snapshot(Railway::PRODUCTION_ENV_ID)
|
||||
|
||||
# The thrown service is skipped; the healthy one survives.
|
||||
names = snap["services"].map { |s| s["name"] }
|
||||
assert_equal ["shell"], names, "the instance-less service must be skipped, not abort the snapshot"
|
||||
shell = snap["services"].find { |s| s["name"] == "shell" }
|
||||
assert_equal "ghcr.io/copilotkit/showcase-shell@sha256:beef1234", shell["image"]
|
||||
end
|
||||
|
||||
def test_build_snapshot_does_not_swallow_unrelated_graphql_errors
|
||||
# The per-service tolerance MUST be narrow: only a `ServiceInstance not
|
||||
# found` error for an individual service is skippable. Any OTHER GraphQL
|
||||
# failure (auth, rate-limit, schema drift, transient 5xx surfaced as a
|
||||
# GraphQL error) must still propagate fail-loud — never silently hidden.
|
||||
fake = FakeGQL.new(
|
||||
"ProjectServices" => services_list_response,
|
||||
"EnvVariables" => env_vars_response_with_per_service_keys,
|
||||
"ServiceInstance" => lambda do |_vars|
|
||||
raise Railway::GraphQL::Error, "GraphQL: Not Authorized"
|
||||
end,
|
||||
)
|
||||
|
||||
cmd = Railway::SnapshotCommand.new(["--env", "production", "--dry-run"])
|
||||
cmd.instance_variable_set(:@gql, fake)
|
||||
|
||||
assert_raises(Railway::GraphQL::Error) do
|
||||
cmd.build_snapshot(Railway::PRODUCTION_ENV_ID)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,132 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Snapshot-ivar enforcement lint.
|
||||
#
|
||||
# Background: PromoteCommand has two snapshot "views" — the FULL un-narrowed
|
||||
# fleet snapshot and the (optionally) target-narrowed snapshot. Two prior
|
||||
# regressions came from a `check_*` method reading the wrong raw ivar
|
||||
# (`@staging_snapshot` / `@prod_snapshot`) inside a fleet-scoped invariant
|
||||
# and accidentally evaluating it against the narrowed view, producing
|
||||
# spurious WARN/REFUSE findings on single-service promotes.
|
||||
#
|
||||
# The fix introduced four accessors — `fleet_staging`, `fleet_prod`,
|
||||
# `target_staging`, `target_prod` — and the convention is that ALL reads of
|
||||
# the four backing ivars go through one of those accessors. This lint test
|
||||
# pins the convention as an executable invariant: any direct read of
|
||||
# `@staging_snapshot`, `@prod_snapshot`, `@full_staging_snapshot`, or
|
||||
# `@full_prod_snapshot` outside the explicit allowlist below FAILS the
|
||||
# suite.
|
||||
#
|
||||
# The allowlist is keyed by `<line-number>:<exact-line-content>` and the
|
||||
# check requires BOTH to match. NOTE: the allowlist is keyed by both line
|
||||
# number and stripped content. Any line shift in bin/railway above the
|
||||
# allowlisted region requires renumbering every entry by hand; the
|
||||
# `test_allowlist_entries_match_current_file_content` self-check fails
|
||||
# loud when this drifts. Nothing refreshes the allowlist mechanically —
|
||||
# this dual self-check + offender-sweep is intentional, so a new
|
||||
# offender (even one with identical surrounding text) fails the suite.
|
||||
#
|
||||
# To intentionally add a NEW legitimate write/accessor site, also add its
|
||||
# `<line>:<content>` entry to ALLOWED_LINES.
|
||||
|
||||
require_relative "spec_helper"
|
||||
|
||||
class SnapshotIvarLintTest < Minitest::Test
|
||||
RAILWAY_PATH = File.expand_path("../railway", __dir__)
|
||||
|
||||
# The four protected ivars. Anything matching one of these names is a
|
||||
# candidate offender unless it appears on an allowlisted line.
|
||||
IVAR_PATTERN = /@(?:full_)?(?:staging|prod)_snapshot\b/.freeze
|
||||
|
||||
# Allowlist: every legitimate site that mentions one of the four
|
||||
# ivars. Format = "<1-indexed line>:<stripped line content>". When the
|
||||
# railway file legitimately changes, update this list to match.
|
||||
#
|
||||
# Categories (must remain in sync with bin/railway):
|
||||
# - run : initial @full_*_snapshot capture at promote start
|
||||
# - capture_snapshots
|
||||
# : the single test-seam assignment site
|
||||
# - narrow_snapshots_to_single_service!
|
||||
# : the narrowing reads + writes of @{staging,prod}_snapshot
|
||||
# - fleet_staging / fleet_prod / target_staging / target_prod
|
||||
# : the four accessors themselves (the ONLY sanctioned reads)
|
||||
# - comments : block/inline comments that name the ivar in
|
||||
# prose (do not perform a read)
|
||||
ALLOWED_LINES = [
|
||||
# `run` — capture full-fleet view before optional narrowing.
|
||||
'1504:@full_staging_snapshot = @staging_snapshot',
|
||||
'1505:@full_prod_snapshot = @prod_snapshot',
|
||||
|
||||
# Doc comment above narrow_snapshots_to_single_service!.
|
||||
'1513:# Narrow @staging_snapshot and @prod_snapshot to only the named',
|
||||
|
||||
# narrow_snapshots_to_single_service! — the WRITE site.
|
||||
'1521:staging_match = (@staging_snapshot["services"] || []).select { |s| s["name"] == name }',
|
||||
'1526:@staging_snapshot = @staging_snapshot.merge("services" => staging_match)',
|
||||
'1527:prod_match = (@prod_snapshot["services"] || []).select { |s| s["name"] == name }',
|
||||
'1528:@prod_snapshot = @prod_snapshot.merge("services" => prod_match)',
|
||||
|
||||
# Doc comment above capture_snapshots.
|
||||
'1532:# @staging_snapshot / @prod_snapshot directly.',
|
||||
|
||||
# capture_snapshots — single test-seam assignment site.
|
||||
'1534:@staging_snapshot ||= SnapshotCommand.new(["--env", "staging", "--dry-run"]).build_snapshot(STAGING_ENV_ID)',
|
||||
'1535:@prod_snapshot ||= SnapshotCommand.new(["--env", "production", "--dry-run"]).build_snapshot(PRODUCTION_ENV_ID)',
|
||||
|
||||
# Doc comment above the accessor block (explains test seam).
|
||||
'1559:# promote tests stub @staging_snapshot/@prod_snapshot directly',
|
||||
|
||||
# The four accessor bodies — the ONLY sanctioned reads.
|
||||
'1571:@full_staging_snapshot || @staging_snapshot',
|
||||
'1575:@full_prod_snapshot || @prod_snapshot',
|
||||
'1579:@staging_snapshot',
|
||||
'1583:@prod_snapshot',
|
||||
].freeze
|
||||
|
||||
def setup
|
||||
@lines = File.readlines(RAILWAY_PATH).each_with_index.map { |l, i| [i + 1, l.chomp] }
|
||||
@allowed = ALLOWED_LINES.each_with_object({}) do |entry, h|
|
||||
lineno, content = entry.split(":", 2)
|
||||
h[Integer(lineno)] = content
|
||||
end
|
||||
end
|
||||
|
||||
def test_allowlist_entries_match_current_file_content
|
||||
# Defensive: prove the allowlist itself is correct. If someone
|
||||
# reformats bin/railway and the allowlist drifts, surface that as
|
||||
# a clear assertion rather than a spurious lint failure later.
|
||||
@allowed.each do |lineno, expected|
|
||||
actual = @lines.find { |n, _| n == lineno }
|
||||
assert actual, "allowlist references line #{lineno} but railway has no such line"
|
||||
assert_equal expected, actual[1].strip,
|
||||
"allowlist content for line #{lineno} does not match railway file " \
|
||||
"(allowlist=#{expected.inspect}, file=#{actual[1].strip.inspect}). " \
|
||||
"If the file legitimately changed, update ALLOWED_LINES."
|
||||
end
|
||||
end
|
||||
|
||||
def test_no_direct_ivar_reads_outside_allowlist
|
||||
offenders = []
|
||||
@lines.each do |lineno, content|
|
||||
next unless content =~ IVAR_PATTERN
|
||||
next if @allowed.key?(lineno) && @allowed[lineno] == content.strip
|
||||
offenders << " #{RAILWAY_PATH}:#{lineno}: #{content.strip}"
|
||||
end
|
||||
|
||||
assert_empty offenders, <<~MSG
|
||||
Direct read of @{,full_}{staging,prod}_snapshot found outside the
|
||||
sanctioned write/accessor sites. ALL reads must go through one
|
||||
of the four accessors:
|
||||
|
||||
fleet_staging / fleet_prod — FLEET-shape invariants
|
||||
target_staging / target_prod — per-service checks
|
||||
|
||||
Offenders:
|
||||
#{offenders.join("\n")}
|
||||
|
||||
If this site is a legitimate new write or accessor, add its
|
||||
"<line>:<stripped content>" entry to ALLOWED_LINES in
|
||||
#{__FILE__} and document why.
|
||||
MSG
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative "spec_helper"
|
||||
require "tempfile"
|
||||
require "stringio"
|
||||
|
||||
class SnapshotRoundtripTest < Minitest::Test
|
||||
def sample_snapshot
|
||||
{
|
||||
"version" => 1,
|
||||
"captured_at" => "2026-01-01T00:00:00Z",
|
||||
"project_id" => Railway::PROJECT_ID,
|
||||
"environment" => { "id" => Railway::STAGING_ENV_ID, "name" => "staging" },
|
||||
"services" => [
|
||||
{
|
||||
"name" => "showcase-shell",
|
||||
"service_id" => "svc-1",
|
||||
"image" => "ghcr.io/copilotkit/showcase-shell@sha256:abc",
|
||||
"image_tag" => "ghcr.io/copilotkit/showcase-shell",
|
||||
"digest" => "sha256:abc",
|
||||
"start_command" => "node server.js",
|
||||
"auto_updates_disabled" => nil,
|
||||
"latest_deployment_id" => "dep-1",
|
||||
"env_keys" => %w[KEY1 KEY2],
|
||||
"custom_domains" => ["showcase.staging.copilotkit.ai"],
|
||||
},
|
||||
],
|
||||
}
|
||||
end
|
||||
|
||||
def test_write_and_read_roundtrip
|
||||
snap = sample_snapshot
|
||||
Tempfile.create(["snap", ".yaml"]) do |f|
|
||||
Railway::SnapshotIO.write(f.path, snap)
|
||||
loaded = Railway::SnapshotIO.read(f.path)
|
||||
assert_equal snap["version"], loaded["version"]
|
||||
assert_equal "showcase-shell", loaded["services"][0]["name"]
|
||||
assert_equal "sha256:abc", loaded["services"][0]["digest"]
|
||||
end
|
||||
end
|
||||
|
||||
def test_read_rejects_wrong_schema_version
|
||||
bad = sample_snapshot
|
||||
bad["version"] = 999
|
||||
Tempfile.create(["snap", ".yaml"]) do |f|
|
||||
File.write(f.path, YAML.dump(bad))
|
||||
orig = $stderr
|
||||
$stderr = StringIO.new
|
||||
ex = assert_raises(SystemExit) { Railway::SnapshotIO.read(f.path) }
|
||||
$stderr = orig
|
||||
assert_equal 2, ex.status
|
||||
end
|
||||
end
|
||||
|
||||
def test_find_service_by_name
|
||||
snap = sample_snapshot
|
||||
svc = Railway.find_service(snap, "showcase-shell")
|
||||
assert_equal "svc-1", svc["service_id"]
|
||||
assert_nil Railway.find_service(snap, "does-not-exist")
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user