@catalyst-cloud/cli 0.16.23 → 0.16.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,19 +20,21 @@ import { onboardStateRoot, } from "./onboard.js";
20
20
  /** The Compose project name the vendored file declares. */
21
21
  export const RUNNER_PROJECT = "catalyst-host";
22
22
  export const RUNNER_SESSION_NETWORK = "catalyst-session-v1";
23
- /** Catalyst's approved host release, generation 2 (hr-a19f7b0859a98147-9a96426c93e32173-63a9b20c3be3dc3e):
24
- * runner, supervisor and watchdog all built from catalyst-cloud 2ec4a551. A machine set up from these
23
+ /** Catalyst's approved host release, generation 48 (hr-598fd0ef2675bfd8-69310962e37ad7f2-2d264b793edb843e):
24
+ * the runner built from catalyst-cloud caf921a4, the supervisor and watchdog from a4165555, the
25
+ * first supervisor that serves several teams (CTC-4455). A machine set up from these
25
26
  * pins then follows newer approved releases itself (`catalyst runner update`, CTC-4646).
26
27
  * Explicit environment values override these pins; a saved runner image is preserved. */
27
28
  export const RUNNER_HOST_IMAGES = {
28
- supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:9a96426c93e32173a67e849aa5a462e9f5466b872e4117f9a8a7df1972b2a136",
29
- watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:63a9b20c3be3dc3ea328d77aa68dece942a40be46cb3126d9a7868e082828feb",
30
- runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:a19f7b0859a9814740935f7aadd8fe5c5c9c5974f9a5956a057b983ada16ea01",
29
+ supervisor: "ghcr.io/coalesce-labs/catalyst-supervisor@sha256:69310962e37ad7f21ccab5c131b0734f106a6b0cc9fb163cb5d8917c9d7a2fc1",
30
+ watchdog: "ghcr.io/coalesce-labs/catalyst-deadline-watchdog@sha256:2d264b793edb843e47b4c5a86fa6133dd2bd0b5ecdc0b7e997368893de074b28",
31
+ runner: "ghcr.io/coalesce-labs/catalyst-runner@sha256:598fd0ef2675bfd8e0665f70ef4ed8bf30706fba25fa2c39fc0df0801512a423",
31
32
  };
32
33
  export const IMAGE_REF = /^[a-z0-9][a-z0-9._:/-]{0,255}@sha256:[0-9a-f]{64}$/;
33
34
  const HOST_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/;
34
35
  const JOIN_TOKEN = /^[A-Za-z0-9._~+/=-]{16,1024}$/;
35
- const ENROLLMENTS = "/api/v1/hosts/enrollments";
36
+ const HOSTS = "/api/v1/hosts";
37
+ const ENROLLMENTS = `${HOSTS}/enrollments`;
36
38
  const JOIN_TOKENS = "/api/v1/hosts/join-tokens";
37
39
  const CAPACITY = "/api/v1/me/runner-capacity";
38
40
  const RUNNER_UID = "10001:10001";
@@ -549,6 +551,7 @@ export function dockerRunnerEngine(deps = {}) {
549
551
  },
550
552
  };
551
553
  }
554
+ const serves = (host, teamKey) => host.teams.includes(teamKey);
552
555
  const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
553
556
  ? value
554
557
  : null;
@@ -565,6 +568,8 @@ function parseHosts(body) {
565
568
  typeof host.hostId !== "string" ||
566
569
  typeof host.hostName !== "string" ||
567
570
  typeof host.team !== "string" ||
571
+ (host.teams !== undefined &&
572
+ (!Array.isArray(host.teams) || !host.teams.every((team) => typeof team === "string"))) ||
568
573
  !(host.revokedAtMs === null || typeof host.revokedAtMs === "number") ||
569
574
  (host.capability !== null &&
570
575
  (!capability ||
@@ -578,6 +583,9 @@ function parseHosts(body) {
578
583
  hostId: host.hostId,
579
584
  hostName: host.hostName,
580
585
  team: host.team,
586
+ teams: Array.isArray(host.teams) && host.teams.includes(host.team)
587
+ ? host.teams
588
+ : [host.team],
581
589
  revoked: host.revokedAtMs !== null,
582
590
  capacity: capability ? capability.runtimeLive
583
591
  ? capability.placeableCapacity
@@ -755,8 +763,8 @@ export function onboardRunnerAdapter(input = {}) {
755
763
  const teamKey = teamId ? await liveTeamKey(ctx, teamId, signal ?? new AbortController().signal) : null;
756
764
  if (!teamId || !teamKey)
757
765
  return waiting("runner_context_unverified", selected);
758
- if (credential && !isUnenrolled(credential) && credential.team !== teamKey)
759
- return waiting("runner_enrolled_for_other_team", selected);
766
+ // An installation enrolled for another team serves this one too once setup adds it (CTC-4455).
767
+ const otherTeamHost = credential && !isUnenrolled(credential) && credential.team !== teamKey ? credential.hostId : undefined;
760
768
  const runnerSupport = await verifyRunnerRoutes(ctx, journal, [{ method: "GET", path: "/api/v1/agent/runner-admission" }], signal);
761
769
  if ("reason" in runnerSupport && runnerSupport.reason !== "cloud_capability_unavailable")
762
770
  return waiting(runnerSupport.reason, selected);
@@ -773,7 +781,8 @@ export function onboardRunnerAdapter(input = {}) {
773
781
  return waiting("runner_native_egress_setup_required", selected);
774
782
  return { account, baseUrl: normalizeBaseUrl(cfg.baseUrl), teamKey, teamId, cloud, dir, info, images,
775
783
  ...(installation && installation !== "missing" ? { installedName: installation.hostName,
776
- installedSupervisorImage: installation.supervisorImage } : {}) };
784
+ installedSupervisorImage: installation.supervisorImage } : {}),
785
+ ...(otherTeamHost ? { otherTeamHost } : {}) };
777
786
  }
778
787
  async function desiredEnv(p, ctx, joinToken) {
779
788
  const saved = readEnvFile(join(p.dir, ".env")) ?? {};
@@ -857,6 +866,58 @@ export function onboardRunnerAdapter(input = {}) {
857
866
  return waiting(signal?.aborted ? "interrupted" : "runner_join_token_unavailable", selected);
858
867
  }
859
868
  }
869
+ /**
870
+ * CTC-4455: adds the selected team to this machine's enrollment. The cloud refuses until the
871
+ * machine's supervisor advertises that it serves a set of teams. Before setup starts the new
872
+ * supervisor that refusal is `deferred`; after, it is retried for as long as setup waits for the
873
+ * supervisor. An older cloud without the route answers 404, and setup refuses as it used to.
874
+ */
875
+ async function addTeam(ctx, p, hostId, retry, signal) {
876
+ const evidence = { ...selected, hostId };
877
+ const cfg = loadConfig(ctx.home);
878
+ const expiry = cfg?.auth ? Date.parse(cfg.auth.expiresAt) - ctx.now().getTime() : 0;
879
+ const bearer = cfg?.key ||
880
+ (cfg?.auth && Number.isFinite(expiry) && expiry > 30_000 ? cfg.auth.accessToken : undefined);
881
+ if (!bearer)
882
+ return waiting("runner_login_refresh_required", evidence);
883
+ for (let attempt = 1;; attempt++) {
884
+ let status;
885
+ let body;
886
+ try {
887
+ const response = await ctx.fetch(`${p.baseUrl}${HOSTS}/${encodeURIComponent(hostId)}/teams?account=${encodeURIComponent(p.account)}`, {
888
+ method: "POST",
889
+ redirect: "error",
890
+ signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000),
891
+ headers: {
892
+ authorization: `Bearer ${bearer}`,
893
+ "content-type": "application/json",
894
+ accept: "application/json",
895
+ },
896
+ body: JSON.stringify({ version: 1, team: p.teamKey }),
897
+ });
898
+ status = response.status;
899
+ body = object(await response.json().catch(() => null));
900
+ }
901
+ catch {
902
+ return waiting(signal?.aborted ? "interrupted" : "runner_team_add_failed", evidence);
903
+ }
904
+ if (status === 200 && body?.ok === true && Array.isArray(body.teams) && body.teams.includes(p.teamKey))
905
+ return true;
906
+ if (status === 404 && body?.error !== "unknown_host")
907
+ return waiting("runner_enrolled_for_other_team", evidence);
908
+ if (status === 401 || status === 403)
909
+ return waiting("runner_identity_unverified", evidence);
910
+ if (body?.error === "team_limit")
911
+ return waiting("runner_team_limit", evidence);
912
+ if (body?.error !== "supervisor_outdated")
913
+ return waiting("runner_team_add_failed", evidence);
914
+ if (!retry)
915
+ return "deferred";
916
+ if (attempt >= Math.max(1, Math.ceil(waitMs / pollMs)) || signal?.aborted)
917
+ return waiting("runner_supervisor_outdated", evidence);
918
+ await sleep(pollMs);
919
+ }
920
+ }
860
921
  function orgKeyFromFile(ctx) {
861
922
  const path = ctx.env.CATALYST_RUNNER_ORG_KEY_FILE;
862
923
  if (!path)
@@ -887,13 +948,13 @@ export function onboardRunnerAdapter(input = {}) {
887
948
  if (p.info.vm && persistedCustody(p.dir, saved) !== "installed")
888
949
  return mayAct && !afterAct ? { state: "pending" } : waiting(mayAct ? "runner_thoughts_custody_unavailable" : "runner_needs_runner_flag", { ...selected, hostName: name });
889
950
  let enrolled = null;
890
- const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && host.team === p.teamKey && !host.revoked);
951
+ const live = (hosts) => hosts?.find((host) => host.hostId === enrolled?.hostId && serves(host, p.teamKey) && !host.revoked);
891
952
  let hosts = await listHosts(ctx, p, signal);
892
953
  if (!hosts)
893
954
  return waiting("runner_enrollment_unavailable", selected);
894
955
  const existingCredential = await engine.enrollment(p.dir, signal);
895
956
  if ((!existingCredential || isUnenrolled(existingCredential)) &&
896
- hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
957
+ hosts.some((host) => host.hostName === name && !host.revoked && !serves(host, p.teamKey)))
897
958
  return waiting("runner_enrolled_for_other_team", {
898
959
  ...selected,
899
960
  hostName: name,
@@ -902,8 +963,11 @@ export function onboardRunnerAdapter(input = {}) {
902
963
  !isUnenrolled(existingCredential) &&
903
964
  hosts.some((host) => host.hostId === existingCredential.hostId &&
904
965
  !host.revoked &&
905
- host.team !== p.teamKey))
906
- return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
966
+ !serves(host, p.teamKey)))
967
+ // CTC-4455: setup's act adds the selected team to a runner enrolled for another one.
968
+ return mayAct && !afterAct
969
+ ? { state: "pending" }
970
+ : waiting(mayAct ? "runner_team_not_added" : "runner_needs_runner_flag", { ...selected, hostName: name });
907
971
  const running = await engine.composeRunning(p.dir, signal);
908
972
  let composeMatches = false;
909
973
  try {
@@ -931,13 +995,11 @@ export function onboardRunnerAdapter(input = {}) {
931
995
  return waiting("runner_enrollment_unavailable", hostEvidence);
932
996
  if (enrolled && (enrolled.tenant !== p.account || enrolled.enrollmentKind !== "self_hosted"))
933
997
  return waiting("runner_identity_unverified", hostEvidence);
934
- if (enrolled && enrolled.team !== p.teamKey)
935
- return waiting("runner_enrolled_for_other_team", hostEvidence);
936
998
  if (enrolled) {
937
999
  const exact = hosts.find((host) => host.hostId === enrolled.hostId);
938
1000
  if (exact?.revoked)
939
1001
  return waiting("runner_enrollment_revoked", { ...hostEvidence, hostId: enrolled.hostId });
940
- if (!exact || exact.team !== p.teamKey)
1002
+ if (!exact || !serves(exact, p.teamKey))
941
1003
  return waiting("runner_enrollment_unverified", hostEvidence);
942
1004
  }
943
1005
  const host = live(hosts);
@@ -986,6 +1048,23 @@ export function onboardRunnerAdapter(input = {}) {
986
1048
  return p;
987
1049
  if (!mayAct)
988
1050
  return waiting("runner_needs_runner_flag", selected);
1051
+ // Settle a team the installed runner does not serve before anything on this machine changes.
1052
+ let addTeamTo = null;
1053
+ if (p.otherTeamHost) {
1054
+ const hosts = await listHosts(ctx, p, signal);
1055
+ if (!hosts)
1056
+ return waiting("runner_enrollment_unavailable", selected);
1057
+ const exact = hosts.find((host) => host.hostId === p.otherTeamHost && !host.revoked);
1058
+ if (!exact)
1059
+ return waiting("runner_enrolled_for_other_team", selected);
1060
+ if (!serves(exact, p.teamKey)) {
1061
+ const added = await addTeam(ctx, p, exact.hostId, false, signal);
1062
+ if (added === "deferred")
1063
+ addTeamTo = exact.hostId;
1064
+ else if (added !== true)
1065
+ return added;
1066
+ }
1067
+ }
989
1068
  let replacingContainers = false;
990
1069
  try {
991
1070
  mkdirSync(p.dir, { recursive: true, mode: 0o700 });
@@ -1104,20 +1183,25 @@ export function onboardRunnerAdapter(input = {}) {
1104
1183
  if (!hosts)
1105
1184
  return waiting("runner_enrollment_unavailable", selected);
1106
1185
  if (!enrolled &&
1107
- hosts.some((host) => host.hostName === name && !host.revoked && host.team !== p.teamKey))
1186
+ hosts.some((host) => host.hostName === name && !host.revoked && !serves(host, p.teamKey)))
1108
1187
  return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
1109
1188
  if (enrolled && (enrolled.tenant !== p.account || enrolled.enrollmentKind !== "self_hosted"))
1110
1189
  return waiting("runner_identity_unverified", { ...selected, hostName: name });
1111
- if (enrolled && enrolled.team !== p.teamKey)
1112
- return waiting("runner_enrolled_for_other_team", { ...selected, hostName: name });
1113
1190
  if (hasCredential && !isUnenrolled(credential)) {
1114
1191
  if (!enrolled)
1115
1192
  return waiting("runner_credential_unverified", { ...selected, hostName: name });
1116
1193
  const exact = hosts.find((host) => host.hostId === enrolled.hostId);
1117
1194
  if (exact?.revoked)
1118
1195
  return waiting("runner_enrollment_revoked", { ...selected, hostName: name, hostId: enrolled.hostId });
1119
- if (!exact || exact.team !== p.teamKey)
1196
+ if (!exact)
1120
1197
  return waiting("runner_enrollment_unverified", { ...selected, hostName: name });
1198
+ if (!serves(exact, p.teamKey) && addTeamTo === null) {
1199
+ const added = await addTeam(ctx, p, exact.hostId, false, signal);
1200
+ if (added === "deferred")
1201
+ addTeamTo = exact.hostId;
1202
+ else if (added !== true)
1203
+ return added;
1204
+ }
1121
1205
  }
1122
1206
  // A lost volume also lost the secret that redeemed the old token. Mint a new token and match
1123
1207
  // the new host id from the supervisor's credential, never an old advertisement with this name.
@@ -1184,6 +1268,14 @@ export function onboardRunnerAdapter(input = {}) {
1184
1268
  if (!up.ok)
1185
1269
  return { state: "failed", reason: "runner_compose_failed", evidence: { ...selected,
1186
1270
  project: RUNNER_PROJECT, ...(up.error ? { composeError: up.error } : {}) } };
1271
+ // The supervisor just started can serve a set of teams; the cloud accepts the team once it says so.
1272
+ if (addTeamTo) {
1273
+ const added = await addTeam(ctx, p, addTeamTo, true, signal);
1274
+ if (added === "deferred")
1275
+ return waiting("runner_supervisor_outdated", selected);
1276
+ if (added !== true)
1277
+ return added;
1278
+ }
1187
1279
  acted = true;
1188
1280
  if (input.autoUpdate) {
1189
1281
  try {
package/dist/prompt.js CHANGED
@@ -1,10 +1,23 @@
1
- // prompt.ts — the one interactive read in the CLI: asking for the personal key on a terminal without
2
- // echoing it. The streams are parameters so the tests can drive it without a real TTY.
1
+ // prompt.ts — the CLI's interactive reads: asking for the personal key on a terminal without
2
+ // echoing it, and one [y/N] question. The streams are parameters so the tests can drive it without a real TTY.
3
3
  import { createInterface } from "node:readline";
4
+ import { createInterface as createPromiseInterface } from "node:readline/promises";
4
5
  /** True when there is a terminal to prompt on. Both ends matter: the question needs somewhere to go. */
5
6
  export function stdinIsTty() {
6
7
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
7
8
  }
9
+ /** Ask a `[y/N]` question on stderr and read the answer. False without a terminal, and for anything but y/yes. */
10
+ export async function confirmYesNo(question) {
11
+ if (!stdinIsTty())
12
+ return false;
13
+ const prompt = createPromiseInterface({ input: process.stdin, output: process.stderr });
14
+ try {
15
+ return /^(y|yes)$/i.test((await prompt.question(question)).trim());
16
+ }
17
+ finally {
18
+ prompt.close();
19
+ }
20
+ }
8
21
  /**
9
22
  * Read one line without echoing it. Everything readline would write after the question itself goes
10
23
  * to a muted stream, so the key never reaches the screen or a scrollback buffer; the caller still
@@ -3,7 +3,8 @@ import { flagBool, flagString, positionals } from "./args.js";
3
3
  import { UsageError } from "./errors.js";
4
4
  import { autoUpdateStatus, disableAutoUpdate, enableAutoUpdate, scheduledPath, } from "./runner-auto-update.js";
5
5
  import { dockerRunnerUpdateEngine, readReleaseState, runnerDir, runRunnerUpdate, updateResultLines, } from "./runner-update.js";
6
- const USAGE = "catalyst runner update [--check] [--dir <runner directory>] | runner auto-update [status|on|off]";
6
+ import { dockerFleetUpdateEngine, runFleetUpdate } from "./runner-update-fleet.js";
7
+ const USAGE = "catalyst runner update [--check] [--dir <runner directory>] [--fleet] | runner auto-update [status|on|off]";
7
8
  function schedulerDeps(ctx, deps) {
8
9
  const cli = deps.scheduler?.cli ?? resolve(process.argv[1] ?? "catalyst");
9
10
  return {
@@ -47,8 +48,12 @@ export async function cmdRunner(args, ctx, deps = {}) {
47
48
  if (action === "update" && parts.length === 1) {
48
49
  const check = flagBool(args, "check");
49
50
  const auto = flagBool(args, "auto");
50
- const engine = deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env });
51
- const result = await runRunnerUpdate(engine, { dir: dir(), check });
51
+ const fleet = flagBool(args, "fleet");
52
+ if (fleet && dirFlag === undefined)
53
+ throw new UsageError("--fleet needs --dir, the directory that holds compose.json");
54
+ const result = fleet
55
+ ? await runFleetUpdate(deps.fleetEngine ?? dockerFleetUpdateEngine({ env: ctx.env }), { dir: dir(), check })
56
+ : await runRunnerUpdate(deps.engine ?? dockerRunnerUpdateEngine({ env: ctx.env }), { dir: dir(), check });
52
57
  if (args.json)
53
58
  ctx.stdout(JSON.stringify(result));
54
59
  else if (auto)