Skip to content

Commit 9def341

Browse files
committed
fix(showcase): align bin/railway GraphQL with Railway public schema
The tool was written from a spec but never exercised against live Railway, so several queries reference fields that do not exist in the public schema. Live runs (including the CI lint-prod step) failed with errors like `Cannot query field "domains" on type "Project"`. Unit tests passed because they only covered parsing and IO, not the GraphQL shape. Verified the live schema via introspection (2026-05) and corrected every mismatch: - Project has no `domains` field. The previous `customDomains: domains { customDomains { ... } }` block on the Project selection is gone. Custom domains now come from `serviceInstance.domains.customDomains` for the env we are inspecting. - Service has no `serviceInstances` field. We can no longer enumerate per-env instances by nesting under Service. The new flow is: 1. SERVICES_LIST_QUERY -> list services in the project 2. SERVICE_INSTANCE_QUERY -> per (service, env), fetch source, startCommand, latestDeployment, domains 3. ENVIRONMENT_VARIABLES_QUERY -> all variables in the env, then group keys by Variable.serviceId for per-service env_keys - `serviceInstanceDeployV2` does not accept an `image` argument; its signature is (commitSha, environmentId, serviceId). To pin a service to a specific image we now use serviceInstanceUpdate(input: { source: { image } }) followed by serviceInstanceRedeploy. RestoreCommand exposes a pin_and_redeploy class method that PromoteCommand and PinCommand reuse. - `deploymentRollback` returns scalar Boolean, so the previous `deploymentRollback(id: $id) { id }` was invalid GraphQL — selection sets are not allowed on scalars. Dropped the selection set. - Auth.token now reads `user.accessToken` from ~/.railway/config.json first. The legacy `user.token` field is a short CLI session token (4 chars on a fresh login) that does not authenticate against the public GraphQL API and was producing silent "Not Authorized" errors. Added spec/test_snapshot_graphql.rb with a FakeGQL that returns realistic shapes, plus regression guards that fail the build if anyone reintroduces `Project.domains`, `Service.serviceInstances`, `serviceInstanceDeployV2` with an image arg, or a selection set on `deploymentRollback`. Smoke-tested live (read-only) against the showcase project: - lint-prod: "OK: all production services digest-pinned." (27 services) - snapshot --env staging: full YAML with images, startCommands, env_keys - env-diff staging production: 32 drift findings (expected, staging is not digest-pinned) - resolve-digest ghcr.io/copilotkit/showcase-aimock:latest: digest returned successfully No mutating subcommands (restore, rollback, promote, pin) were run live.
1 parent 79ef098 commit 9def341

2 files changed

Lines changed: 303 additions & 72 deletions

File tree

showcase/bin/railway

Lines changed: 123 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,14 @@ module Railway
108108
if File.exist?(cfg)
109109
begin
110110
data = JSON.parse(File.read(cfg))
111-
# Common shapes: {"user":{"token":"..."}} or {"token":"..."}
112-
candidate = data["token"] || data.dig("user", "token") ||
111+
# Railway CLI stores the bearer in `user.accessToken` (43+ chars).
112+
# `user.token` is a short legacy CLI session token that does
113+
# NOT authenticate to the public GraphQL API. Prefer accessToken.
114+
candidate =
115+
data.dig("user", "accessToken") ||
116+
data["accessToken"] ||
117+
data.dig("user", "token") ||
118+
data["token"] ||
113119
data.dig("projects", PROJECT_ID, "token")
114120
return candidate.strip if candidate.is_a?(String) && !candidate.strip.empty?
115121
rescue JSON::ParserError
@@ -398,42 +404,70 @@ module Railway
398404

399405
# ── Service Inventory ──────────────────────────────────────────────────────
400406
#
401-
# GraphQL fragment to enumerate services + service instances for one env.
402-
SERVICES_QUERY = <<~GQL
403-
query ProjectServices($projectId: String!, $envId: String!) {
407+
# GraphQL fragments used by snapshot/env-diff/promote/lint-prod.
408+
#
409+
# Railway's public schema notes (verified via introspection 2026-05):
410+
# * Project has NO `domains` field. Custom domains are reached either via
411+
# top-level `domains(projectId, environmentId, serviceId)` returning
412+
# `AllDomains { customDomains, serviceDomains }`, or via
413+
# `serviceInstance.domains` (same shape).
414+
# * Service has NO `serviceInstances` field. To get an instance's
415+
# image/startCommand/etc. for a given env, use
416+
# `serviceInstance(serviceId, environmentId)` directly.
417+
# * `serviceInstanceDeployV2` takes (serviceId, environmentId, commitSha)
418+
# ONLY — no `image` arg. To pin a service to a specific image, use
419+
# `serviceInstanceUpdate(serviceId, environmentId, input: { source: { image } })`
420+
# followed by `serviceInstanceRedeploy`.
421+
# * `Environment.variables` returns an `EnvironmentVariablesConnection`
422+
# whose edges include `node.serviceId` — we filter by service id to get
423+
# per-service env-key sets.
424+
#
425+
# The query below enumerates all services in the project and, for each one,
426+
# the per-environment serviceInstance and that env's variables (keys only).
427+
# We use GraphQL field aliases to fetch all per-service data in a single
428+
# round-trip rather than N+1.
429+
430+
SERVICES_LIST_QUERY = <<~GQL
431+
query ProjectServices($projectId: String!) {
404432
project(id: $projectId) {
405433
id
406434
name
407435
services {
408436
edges {
409-
node {
410-
id
411-
name
412-
serviceInstances {
413-
edges {
414-
node {
415-
environmentId
416-
source { image }
417-
startCommand
418-
latestDeployment { id status meta }
419-
}
420-
}
421-
}
422-
}
437+
node { id name }
423438
}
424439
}
425-
environments {
440+
}
441+
}
442+
GQL
443+
444+
SERVICE_INSTANCE_QUERY = <<~GQL
445+
query ServiceInstance($serviceId: String!, $envId: String!) {
446+
serviceInstance(serviceId: $serviceId, environmentId: $envId) {
447+
id
448+
serviceId
449+
environmentId
450+
startCommand
451+
source { image repo }
452+
latestDeployment { id status }
453+
domains {
454+
customDomains { id domain }
455+
serviceDomains { id domain }
456+
}
457+
}
458+
}
459+
GQL
460+
461+
ENVIRONMENT_VARIABLES_QUERY = <<~GQL
462+
query EnvVariables($envId: String!) {
463+
environment(id: $envId) {
464+
id
465+
name
466+
variables(first: 1000) {
426467
edges {
427-
node {
428-
id
429-
name
430-
variables { edges { node { name } } }
431-
}
468+
node { name serviceId isSealed }
432469
}
433470
}
434-
customDomains: domains {
435-
customDomains { id domain environmentId serviceId }
436-
}
437471
}
438472
}
439473
GQL
@@ -523,33 +557,37 @@ module Railway
523557
end
524558

525559
def build_snapshot(env_id)
526-
data = gql.query(SERVICES_QUERY,
527-
projectId: PROJECT_ID, envId: env_id)
528-
project = data["project"] || {}
560+
# 1. List all services in the project.
561+
list = gql.query(SERVICES_LIST_QUERY, projectId: PROJECT_ID)
562+
service_nodes = (list.dig("project", "services", "edges") || []).map { |e| e["node"] }
563+
564+
# 2. Fetch the env's variables once, group keys by serviceId.
565+
env_data = gql.query(ENVIRONMENT_VARIABLES_QUERY, envId: env_id)
566+
keys_by_service = Hash.new { |h, k| h[k] = [] }
567+
(env_data.dig("environment", "variables", "edges") || []).each do |edge|
568+
n = edge["node"]
569+
next unless n && n["serviceId"]
570+
keys_by_service[n["serviceId"]] << n["name"]
571+
end
529572

573+
# 3. For each service, fetch its serviceInstance for this env.
574+
# Some services may not exist in this env (returns nil); skip them.
530575
services = []
531-
(project.dig("services", "edges") || []).each do |edge|
532-
node = edge["node"]
533-
instance = (node.dig("serviceInstances", "edges") || []).find do |e|
534-
e.dig("node", "environmentId") == env_id
535-
end
536-
next unless instance
576+
service_nodes.each do |node|
577+
instance_data = gql.query(SERVICE_INSTANCE_QUERY,
578+
serviceId: node["id"], envId: env_id)
579+
inst = instance_data["serviceInstance"]
580+
next if inst.nil?
537581

538-
inst = instance["node"]
539582
image_ref = inst.dig("source", "image")
540583
digest = nil
541584
tag_ref = image_ref
542585
if image_ref&.include?("@sha256:")
543586
tag_ref, digest = image_ref.split("@", 2)
544587
end
545588

546-
env_keys = []
547-
(project.dig("environments", "edges") || []).each do |env_edge|
548-
next unless env_edge.dig("node", "id") == env_id
549-
(env_edge.dig("node", "variables", "edges") || []).each do |v|
550-
env_keys << v.dig("node", "name")
551-
end
552-
end
589+
custom_domains = (inst.dig("domains", "customDomains") || [])
590+
.map { |d| d["domain"] }.compact.sort
553591

554592
services << {
555593
"name" => node["name"],
@@ -560,8 +598,8 @@ module Railway
560598
"start_command" => inst["startCommand"],
561599
"auto_updates_disabled" => nil,
562600
"latest_deployment_id" => inst.dig("latestDeployment", "id"),
563-
"env_keys" => env_keys.sort.uniq,
564-
"custom_domains" => domains_for(project, env_id, node["id"]),
601+
"env_keys" => (keys_by_service[node["id"]] || []).sort.uniq,
602+
"custom_domains" => custom_domains,
565603
}
566604
end
567605

@@ -573,28 +611,44 @@ module Railway
573611
"services" => services.sort_by { |s| s["name"].to_s },
574612
}
575613
end
576-
577-
def domains_for(project, env_id, service_id)
578-
(project.dig("customDomains", "customDomains") || [])
579-
.select { |d| d["environmentId"] == env_id && d["serviceId"] == service_id }
580-
.map { |d| d["domain"] }
581-
.sort
582-
end
583614
end
584615

585616
# restore — given a snapshot YAML, force-redeploy each service to its
586-
# captured digest via serviceInstanceDeployV2.
617+
# captured digest via serviceInstanceUpdate(source.image) + redeploy.
618+
#
619+
# Railway's `serviceInstanceDeployV2` mutation does NOT accept an `image`
620+
# arg — its signature is (serviceId, environmentId, commitSha). To pin a
621+
# service to a specific image digest we must:
622+
# 1. update the service instance's source.image to the desired ref
623+
# 2. trigger a redeploy
587624
class RestoreCommand < BaseCommand
625+
UPDATE_IMAGE_MUTATION = <<~GQL
626+
mutation UpdateImage($serviceId: String!, $envId: String!, $image: String!) {
627+
serviceInstanceUpdate(
628+
serviceId: $serviceId
629+
environmentId: $envId
630+
input: { source: { image: $image } }
631+
)
632+
}
633+
GQL
634+
588635
REDEPLOY_MUTATION = <<~GQL
589-
mutation Deploy($serviceId: String!, $envId: String!, $image: String!) {
590-
serviceInstanceDeployV2(
636+
mutation Redeploy($serviceId: String!, $envId: String!) {
637+
serviceInstanceRedeploy(
591638
serviceId: $serviceId
592639
environmentId: $envId
593-
image: $image
594640
)
595641
}
596642
GQL
597643

644+
# Helper used by RestoreCommand, PinCommand, PromoteCommand: pin then redeploy.
645+
def self.pin_and_redeploy(gql, service_id:, env_id:, image:)
646+
gql.query(UPDATE_IMAGE_MUTATION,
647+
serviceId: service_id, envId: env_id, image: image)
648+
gql.query(REDEPLOY_MUTATION,
649+
serviceId: service_id, envId: env_id)
650+
end
651+
598652
def parser
599653
OptionParser.new do |o|
600654
o.banner = <<~BANNER
@@ -644,10 +698,8 @@ module Railway
644698
next
645699
end
646700

647-
gql.query(REDEPLOY_MUTATION,
648-
serviceId: svc["service_id"],
649-
envId: env_id,
650-
image: image)
701+
RestoreCommand.pin_and_redeploy(gql,
702+
service_id: svc["service_id"], env_id: env_id, image: image)
651703
puts "redeployed #{svc['name']} -> #{image}"
652704
end
653705
0
@@ -665,8 +717,9 @@ module Railway
665717
}
666718
GQL
667719

720+
# deploymentRollback returns a scalar Boolean — no selection set.
668721
ROLLBACK_MUTATION = <<~GQL
669-
mutation Rollback($id: String!) { deploymentRollback(id: $id) { id } }
722+
mutation Rollback($id: String!) { deploymentRollback(id: $id) }
670723
GQL
671724

672725
def parser
@@ -716,8 +769,8 @@ module Railway
716769
0
717770
end
718771

719-
def resolve_service_id(env_id, name)
720-
data = gql.query(SERVICES_QUERY, projectId: PROJECT_ID, envId: env_id)
772+
def resolve_service_id(_env_id, name)
773+
data = gql.query(SERVICES_LIST_QUERY, projectId: PROJECT_ID)
721774
(data.dig("project", "services", "edges") || []).each do |e|
722775
node = e["node"]
723776
return node["id"] if node["name"] == name
@@ -879,9 +932,9 @@ module Railway
879932
next
880933
end
881934

882-
gql.query(RestoreCommand::REDEPLOY_MUTATION,
883-
serviceId: pmatch["service_id"],
884-
envId: PRODUCTION_ENV_ID,
935+
RestoreCommand.pin_and_redeploy(gql,
936+
service_id: pmatch["service_id"],
937+
env_id: PRODUCTION_ENV_ID,
885938
image: image)
886939
puts "promoted #{svc['name']} -> #{image}"
887940
end
@@ -940,10 +993,8 @@ module Railway
940993
return 0
941994
end
942995

943-
gql.query(RestoreCommand::REDEPLOY_MUTATION,
944-
serviceId: service_id,
945-
envId: env_id,
946-
image: image)
996+
RestoreCommand.pin_and_redeploy(gql,
997+
service_id: service_id, env_id: env_id, image: image)
947998
puts "pinned #{options[:service]} -> #{image}"
948999
0
9491000
end

0 commit comments

Comments
 (0)