Skip to content

Commit 5b643ff

Browse files
committed
fix(showcase): make starter-fleet provisioner converge on re-run, deploy images, validate argv
Harden the committed starter-fleet Railway provisioner against the partial-failure / mistyped-flag / un-deployed-image failure modes surfaced in CR: - Domain idempotency: a serviceDomainCreate that Railway rejects with an "already exists" error (start-of-run snapshot missed the domain due to eventual consistency, or a prior run died mid-fleet) is now caught as a benign no-op (marked "existing", logged) so a re-run converges instead of aborting the entire remaining fleet. A genuine non-transient error still aborts. - Explicit redeploy: serviceCreate + serviceInstanceUpdate(source.image) only PINS the image; it does not start a deployment, and Railway's image auto-updates fire only on a NEW digest push. Added serviceInstanceRedeploy after the instance update on BOTH the create and update paths so the pinned image actually runs (and starter_smoke can find the service up). Mirrors the documented update+redeploy pattern in bin/railway and the explicit redeploy showcase_deploy.yml issues after each GHCR push. - argv validation: parseArgs() now rejects any unrecognized argument (e.g. a mistyped --dry-rn) with a usage hint before any provisioning, instead of silently ignoring it and proceeding to REAL live provisioning. - Fail-fast safety: validate the Railway token AND registry credentials up front in main() (token resolution no longer process.exit()s deep in the GraphQL boundary; main().catch owns the exit). Broadened the withRetry transient predicate to the domain/instance eventual-consistency class via an overridable per-call predicate. Dry-run now reports a new service's domain as "would-create" for a faithful preview.
1 parent a04d734 commit 5b643ff

2 files changed

Lines changed: 410 additions & 53 deletions

File tree

showcase/scripts/__tests__/provision-starter-fleet.test.ts

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { describe, it, expect, vi } from "vitest";
1515
import {
1616
deriveStarterTargets,
1717
fetchExistingServices,
18+
parseArgs,
1819
provisionStarterFleet,
1920
resolveRegistryCredentials,
2021
STARTER_FLEET_PREFIX,
@@ -193,6 +194,10 @@ function makeMockGql(
193194
return { serviceInstanceUpdate: true } as T;
194195
}
195196

197+
if (query.includes("serviceInstanceRedeploy(")) {
198+
return { serviceInstanceRedeploy: true } as T;
199+
}
200+
196201
if (query.includes("serviceDomainCreate(")) {
197202
const input = variables.input as { serviceId: string };
198203
return {
@@ -409,6 +414,9 @@ describe("provisionStarterFleet — create path", () => {
409414
}
410415
return { serviceInstanceUpdate: true } as T;
411416
}
417+
if (query.includes("serviceInstanceRedeploy(")) {
418+
return { serviceInstanceRedeploy: true } as T;
419+
}
412420
if (query.includes("serviceDomainCreate(")) {
413421
return {
414422
serviceDomainCreate: { domain: "new-1.up.railway.app" },
@@ -558,6 +566,228 @@ describe("provisionStarterFleet — dry run", () => {
558566
expect(
559567
calls.filter((c) => c.query.includes("serviceInstanceUpdate(")),
560568
).toHaveLength(0);
569+
expect(
570+
calls.filter((c) => c.query.includes("serviceInstanceRedeploy(")),
571+
).toHaveLength(0);
572+
expect(summary.records).toHaveLength(2);
573+
});
574+
575+
it("reports a NEW service's domain as 'would-create' (faithful preview, not 'skipped')", async () => {
576+
// A real run creates a domain for a new service, so dry-run must preview
577+
// that as "would-create" rather than the misleading "skipped".
578+
const { gql } = makeMockGql();
579+
const summary = await provisionStarterFleet({
580+
gql,
581+
projectId: PROJECT,
582+
stagingEnvId: STAGING_ENV_ID,
583+
targets: TWO_TARGETS,
584+
dryRun: true,
585+
});
586+
expect(
587+
summary.records.every((r) => r.domainAction === "would-create"),
588+
).toBe(true);
589+
});
590+
591+
it("reports an EXISTING already-domained service as 'existing' in dry-run", async () => {
592+
const { gql } = makeMockGql([
593+
{
594+
id: "existing-mastra",
595+
name: "starter-mastra",
596+
stagingDomain: "starter-mastra.up.railway.app",
597+
},
598+
]);
599+
const summary = await provisionStarterFleet({
600+
gql,
601+
projectId: PROJECT,
602+
stagingEnvId: STAGING_ENV_ID,
603+
targets: [TWO_TARGETS[0]],
604+
dryRun: true,
605+
});
606+
expect(summary.records[0].domainAction).toBe("existing");
607+
});
608+
});
609+
610+
// ── provisionStarterFleet: redeploy (image must actually run) ────────────
611+
612+
describe("provisionStarterFleet — redeploy", () => {
613+
it("triggers serviceInstanceRedeploy after configuring a NEW service so the image actually runs", async () => {
614+
// serviceCreate + serviceInstanceUpdate(source.image) pins the image but
615+
// does NOT start a deployment (auto-updates only fire on a NEW digest
616+
// push; bin/railway documents update+redeploy as the canonical pattern).
617+
// Without an explicit redeploy the service never runs and starter_smoke
618+
// would never find it up.
619+
const { gql, calls } = makeMockGql();
620+
await provisionStarterFleet({
621+
gql,
622+
projectId: PROJECT,
623+
stagingEnvId: STAGING_ENV_ID,
624+
targets: TWO_TARGETS,
625+
});
626+
const redeploys = calls.filter((c) =>
627+
c.query.includes("serviceInstanceRedeploy("),
628+
);
629+
expect(redeploys).toHaveLength(2);
630+
for (const r of redeploys) {
631+
expect(r.variables.environmentId).toBe(STAGING_ENV_ID);
632+
expect(r.variables.environmentId).not.toBe(PRODUCTION_ENV_ID);
633+
}
634+
});
635+
636+
it("triggers serviceInstanceRedeploy on the update path (re-asserted image must run)", async () => {
637+
const { gql, calls } = makeMockGql([
638+
{
639+
id: "existing-mastra",
640+
name: "starter-mastra",
641+
stagingDomain: "starter-mastra.up.railway.app",
642+
},
643+
]);
644+
await provisionStarterFleet({
645+
gql,
646+
projectId: PROJECT,
647+
stagingEnvId: STAGING_ENV_ID,
648+
targets: [TWO_TARGETS[0]],
649+
});
650+
const redeploys = calls.filter((c) =>
651+
c.query.includes("serviceInstanceRedeploy("),
652+
);
653+
expect(redeploys).toHaveLength(1);
654+
expect(redeploys[0].variables.serviceId).toBe("existing-mastra");
655+
});
656+
657+
it("sends NO redeploy in dry-run mode", async () => {
658+
const { gql, calls } = makeMockGql();
659+
await provisionStarterFleet({
660+
gql,
661+
projectId: PROJECT,
662+
stagingEnvId: STAGING_ENV_ID,
663+
targets: TWO_TARGETS,
664+
dryRun: true,
665+
});
666+
expect(
667+
calls.filter((c) => c.query.includes("serviceInstanceRedeploy(")),
668+
).toHaveLength(0);
669+
});
670+
});
671+
672+
// ── provisionStarterFleet: idempotent domain re-create (partial failure) ─
673+
674+
describe("provisionStarterFleet — domain already-exists is a benign no-op", () => {
675+
it("treats a 'domain already exists' error as existing and CONTINUES the fleet (does not abort)", async () => {
676+
// Partial-failure re-run: the start-of-run snapshot missed the domain
677+
// (Railway eventual consistency / a run that died mid-fleet), so the
678+
// provisioner re-issues serviceDomainCreate and Railway rejects it with a
679+
// NON-transient "already exists" error. This must converge (mark existing,
680+
// keep going), not abort the remaining fleet.
681+
let domainAttempts = 0;
682+
const gql: RailwayGqlFn = vi.fn(
683+
async <T = unknown>(
684+
query: string,
685+
variables: Record<string, unknown> = {},
686+
): Promise<T> => {
687+
if (query.includes("project(id:")) {
688+
return { project: { services: { edges: [] } } } as T;
689+
}
690+
if (query.includes("serviceCreate(")) {
691+
const input = variables.input as { name: string };
692+
return {
693+
serviceCreate: { id: `svc-${input.name}`, name: input.name },
694+
} as T;
695+
}
696+
if (query.includes("serviceInstanceUpdate(")) {
697+
return { serviceInstanceUpdate: true } as T;
698+
}
699+
if (query.includes("serviceInstanceRedeploy(")) {
700+
return { serviceInstanceRedeploy: true } as T;
701+
}
702+
if (query.includes("serviceDomainCreate(")) {
703+
domainAttempts += 1;
704+
// First target's domain create rejects as already-existing.
705+
if (domainAttempts === 1) {
706+
throw new Error(
707+
"Railway GraphQL errors:\n - A domain already exists for this service",
708+
);
709+
}
710+
return {
711+
serviceDomainCreate: { domain: "svc.up.railway.app" },
712+
} as T;
713+
}
714+
throw new Error(`unexpected query:\n${query}`);
715+
},
716+
) as RailwayGqlFn;
717+
718+
const summary = await provisionStarterFleet({
719+
gql,
720+
projectId: PROJECT,
721+
stagingEnvId: STAGING_ENV_ID,
722+
targets: TWO_TARGETS,
723+
sleepMs: NO_SLEEP,
724+
});
725+
// Both targets processed — the fleet did NOT abort.
561726
expect(summary.records).toHaveLength(2);
727+
// The duplicate-domain target is marked existing (benign no-op).
728+
expect(summary.records[0].domainAction).toBe("existing");
729+
// The second target's domain succeeded normally.
730+
expect(summary.records[1].domainAction).toBe("created");
731+
});
732+
733+
it("still ABORTS on a genuine non-transient domain-create error", async () => {
734+
const gql: RailwayGqlFn = vi.fn(
735+
async <T = unknown>(query: string): Promise<T> => {
736+
if (query.includes("project(id:")) {
737+
return { project: { services: { edges: [] } } } as T;
738+
}
739+
if (query.includes("serviceCreate(")) {
740+
return {
741+
serviceCreate: { id: "svc-1", name: "starter-mastra" },
742+
} as T;
743+
}
744+
if (query.includes("serviceInstanceUpdate(")) {
745+
return { serviceInstanceUpdate: true } as T;
746+
}
747+
if (query.includes("serviceInstanceRedeploy(")) {
748+
return { serviceInstanceRedeploy: true } as T;
749+
}
750+
if (query.includes("serviceDomainCreate(")) {
751+
throw new Error("Railway GraphQL errors:\n - Unauthorized");
752+
}
753+
throw new Error(`unexpected query:\n${query}`);
754+
},
755+
) as RailwayGqlFn;
756+
757+
await expect(
758+
provisionStarterFleet({
759+
gql,
760+
projectId: PROJECT,
761+
stagingEnvId: STAGING_ENV_ID,
762+
targets: [TWO_TARGETS[0]],
763+
sleepMs: NO_SLEEP,
764+
}),
765+
).rejects.toThrow(/Unauthorized/);
766+
});
767+
});
768+
769+
// ── parseArgs: argv validation ───────────────────────────────────────────
770+
771+
describe("parseArgs", () => {
772+
it("parses known flags", () => {
773+
expect(parseArgs(["--dry-run"])).toEqual({
774+
help: false,
775+
list: false,
776+
dryRun: true,
777+
});
778+
expect(parseArgs(["--list"])).toMatchObject({ list: true });
779+
expect(parseArgs(["--help"])).toMatchObject({ help: true });
780+
expect(parseArgs([])).toEqual({ help: false, list: false, dryRun: false });
781+
});
782+
783+
it("REJECTS an unrecognized flag (mistyped --dry-rn must not silently run live)", () => {
784+
expect(() => parseArgs(["--dry-rn"])).toThrow(/unknown|unrecognized/i);
785+
});
786+
787+
it("rejects any unrecognized dash-prefixed argument", () => {
788+
expect(() => parseArgs(["--list", "--bogus"])).toThrow(
789+
/unknown|unrecognized/i,
790+
);
791+
expect(() => parseArgs(["-x"])).toThrow(/unknown|unrecognized/i);
562792
});
563793
});

0 commit comments

Comments
 (0)