Files
2026-07-13 12:58:18 +08:00

423 lines
21 KiB
Ruby

# 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