@plexus-ms/cli-ci 0.110.0 → 0.111.0

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.
Files changed (2) hide show
  1. package/dist/plx-ci.js +1033 -768
  2. package/package.json +2 -2
package/dist/plx-ci.js CHANGED
@@ -7395,7 +7395,7 @@ function realIo(prompt) {
7395
7395
  stdout: process.stdout,
7396
7396
  stderr: process.stderr,
7397
7397
  prompt: prompt && isInteractive() ? prompt : void 0,
7398
- exec: (command, args, options = {}) => new Promise((resolve4, reject) => {
7398
+ exec: (command, args, options = {}) => new Promise((resolve5, reject) => {
7399
7399
  const child = spawn(command, args, {
7400
7400
  cwd: options.cwd,
7401
7401
  env: options.env,
@@ -7418,7 +7418,7 @@ function realIo(prompt) {
7418
7418
  child.on("error", reject);
7419
7419
  child.on("close", (code) => {
7420
7420
  if (code === 0)
7421
- resolve4(stdout);
7421
+ resolve5(stdout);
7422
7422
  else
7423
7423
  reject(new ExecError(render(command, args), code, stderr));
7424
7424
  });
@@ -7437,7 +7437,7 @@ function quote(arg) {
7437
7437
  return /^[\w./:@%+=,-]+$/.test(arg) ? arg : `'${arg.replaceAll("'", "'\\''")}'`;
7438
7438
  }
7439
7439
 
7440
- // ../lib/dist/plan.js
7440
+ // ../lib/dist/effects.js
7441
7441
  var run = (command, args = [], options = {}) => ({
7442
7442
  kind: "run",
7443
7443
  command,
@@ -7599,6 +7599,7 @@ var import_yaml3 = __toESM(require_dist(), 1);
7599
7599
 
7600
7600
  // ../lib/dist/deployTargets.js
7601
7601
  var import_yaml4 = __toESM(require_dist(), 1);
7602
+ import { join } from "node:path";
7602
7603
 
7603
7604
  // ../lib/dist/train.js
7604
7605
  var PRIMITIVES = "primitives";
@@ -7613,6 +7614,72 @@ var PHASES = [[PRIMITIVES], TRAIN_REPOS.filter((r) => r !== PRIMITIVES)];
7613
7614
  var UsageError = class extends Error {
7614
7615
  };
7615
7616
 
7617
+ // ../lib/dist/deployTargets.js
7618
+ var VARS_DIRS = ["group_vars", "host_vars"];
7619
+ var DEFAULT_ENVIRONMENT = "production";
7620
+ function parseInventoryFile(path2, text) {
7621
+ const data = (0, import_yaml4.parse)(text);
7622
+ const vars = data !== null && typeof data === "object" ? data : {};
7623
+ const target = typeof vars.plexus_deploy_target === "string" ? vars.plexus_deploy_target : void 0;
7624
+ const records = Array.isArray(vars.plexus_apps) ? vars.plexus_apps : [];
7625
+ const apps = records.flatMap((r) => {
7626
+ if (r === null || typeof r !== "object")
7627
+ return [];
7628
+ const { name, environment } = r;
7629
+ if (typeof name !== "string")
7630
+ return [];
7631
+ return [{ app: name, environment: typeof environment === "string" ? environment : DEFAULT_ENVIRONMENT }];
7632
+ });
7633
+ return { path: path2, target, apps };
7634
+ }
7635
+ function selectDeployTargets(files) {
7636
+ const targets = {};
7637
+ const declaredIn = {};
7638
+ for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
7639
+ if (file.target === void 0)
7640
+ continue;
7641
+ for (const { app, environment } of file.apps) {
7642
+ const key = `${app}@${environment}`;
7643
+ if (key in declaredIn)
7644
+ throw new Error(`app "${app}" in ${environment} is declared with a deploy target in both ${declaredIn[key]} and ${file.path}`);
7645
+ const envs = targets[app] ?? {};
7646
+ envs[environment] = file.target;
7647
+ targets[app] = envs;
7648
+ declaredIn[key] = file.path;
7649
+ }
7650
+ }
7651
+ return Object.fromEntries(Object.entries(targets).sort(([a], [b]) => a.localeCompare(b)).map(([app, envs]) => [app, Object.fromEntries(Object.entries(envs).sort(([a], [b]) => a.localeCompare(b)))]));
7652
+ }
7653
+ function targetsIn(targets, environment) {
7654
+ const flat = {};
7655
+ for (const [app, envs] of Object.entries(targets)) {
7656
+ const target = envs[environment];
7657
+ if (target !== void 0)
7658
+ flat[app] = target;
7659
+ }
7660
+ return flat;
7661
+ }
7662
+ async function readDeployTargets(ctx, inventory) {
7663
+ const files = [];
7664
+ let found = false;
7665
+ for (const sub of VARS_DIRS) {
7666
+ const dir = join(inventory, sub);
7667
+ const entries = await ctx.readDir(dir).catch(() => void 0);
7668
+ if (entries === void 0)
7669
+ continue;
7670
+ found = true;
7671
+ for (const e of entries) {
7672
+ if (e.directory || !/\.ya?ml$/.test(e.name))
7673
+ continue;
7674
+ const path2 = join(dir, e.name);
7675
+ files.push(parseInventoryFile(path2, await ctx.readFile(path2)));
7676
+ }
7677
+ }
7678
+ if (!found && await ctx.readDir(inventory).catch(() => void 0) === void 0)
7679
+ throw new UsageError(`no inventory directory at ${inventory}`);
7680
+ return selectDeployTargets(files);
7681
+ }
7682
+
7616
7683
  // ../lib/dist/ship.js
7617
7684
  var DEFAULT_WAIT = { interval: 15e3, timeout: 20 * 6e4 };
7618
7685
 
@@ -10997,7 +11064,7 @@ import { resolve } from "node:path";
10997
11064
  // src/core/build.ts
10998
11065
  import { randomBytes } from "node:crypto";
10999
11066
  import { tmpdir } from "node:os";
11000
- import { join } from "node:path";
11067
+ import { join as join2 } from "node:path";
11001
11068
 
11002
11069
  // src/core/exoscale.ts
11003
11070
  var Exo = class {
@@ -11044,7 +11111,7 @@ var Exo = class {
11044
11111
  async securityGroupId(name) {
11045
11112
  const data = await this.read("list-security-groups");
11046
11113
  const g = (data["security-groups"] ?? []).find((g2) => g2.name === name);
11047
- if (!g) throw new Error(`no security group "${name}"; it is created once, at setup (ci.md)`);
11114
+ if (!g) throw new Error(`no security group "${name}"; it is created once, at setup`);
11048
11115
  return g.id;
11049
11116
  }
11050
11117
  createInstance(request) {
@@ -11061,8 +11128,11 @@ var Exo = class {
11061
11128
  };
11062
11129
  return this.effect("create-instance", [], JSON.stringify(body2));
11063
11130
  }
11064
- /** Poll an operation to its end; the create completes within seconds. */
11065
- async waitOperation(id, attempts = 30) {
11131
+ /**
11132
+ * Poll an operation to its end, for three minutes: a create completes
11133
+ * within seconds as a rule and has taken more than one minute under load.
11134
+ */
11135
+ async waitOperation(id, attempts = 90) {
11066
11136
  let state = "pending";
11067
11137
  for (let i = 0; i < attempts && state === "pending"; i++) {
11068
11138
  if (i > 0) await sleep(2e3);
@@ -11076,33 +11146,9 @@ var Exo = class {
11076
11146
  stopInstance(id) {
11077
11147
  return this.effect("stop-instance", [id]);
11078
11148
  }
11079
- startInstance(id) {
11080
- return this.effect("start-instance", [id]);
11081
- }
11082
- rebootInstance(id) {
11083
- return this.effect("reboot-instance", [id]);
11084
- }
11085
- };
11086
- var Storage = class {
11087
- ctx;
11088
- env;
11089
- constructor(ctx, accessKey, secretKey) {
11090
- this.ctx = ctx;
11091
- this.env = { EXOSCALE_API_KEY: accessKey, EXOSCALE_API_SECRET: secretKey };
11092
- }
11093
- async bucketExists(bucket) {
11094
- try {
11095
- await this.ctx.read("exo", ["storage", "ls", `sos://${bucket}`], { env: { ...process.env, ...this.env } });
11096
- return true;
11097
- } catch {
11098
- return false;
11099
- }
11100
- }
11101
- makeBucket(bucket, zone) {
11102
- return this.ctx.apply([run("exo", ["storage", "mb", `sos://${bucket}`, "-z", zone], { env: this.env })]);
11103
- }
11104
- removeBucket(bucket) {
11105
- return this.ctx.apply([run("exo", ["storage", "rb", "-r", "-f", `sos://${bucket}`], { env: this.env })]);
11149
+ /** Rename and relabel: the whole label map is sent, since the API replaces it. */
11150
+ updateInstance(id, name, labels) {
11151
+ return this.effect("update-instance", [id], JSON.stringify({ name, labels }));
11106
11152
  }
11107
11153
  };
11108
11154
  function instanceOf(raw) {
@@ -11117,6 +11163,118 @@ function instanceOf(raw) {
11117
11163
  }
11118
11164
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11119
11165
 
11166
+ // src/core/s3.ts
11167
+ import { createHash } from "node:crypto";
11168
+ var sha256 = (text) => createHash("sha256").update(text).digest("hex");
11169
+ var md5 = (text) => createHash("md5").update(text).digest("base64");
11170
+ var unescapeXml = (text) => text.replaceAll("&lt;", "<").replaceAll("&gt;", ">").replaceAll("&quot;", '"').replaceAll("&apos;", "'").replaceAll("&amp;", "&");
11171
+ var escapeXml = (text) => text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
11172
+ function elements(xml, tag) {
11173
+ return [...xml.matchAll(new RegExp(`<${tag}>([^<]*)</${tag}>`, "g"))].map((m) => unescapeXml(m[1] ?? ""));
11174
+ }
11175
+ function uploads(xml) {
11176
+ return [...xml.matchAll(/<Upload>(.*?)<\/Upload>/gs)].flatMap((m) => {
11177
+ const key = elements(m[1] ?? "", "Key")[0];
11178
+ const id = elements(m[1] ?? "", "UploadId")[0];
11179
+ return key !== void 0 && id !== void 0 ? [{ key, id }] : [];
11180
+ });
11181
+ }
11182
+ function deleteBody(keys) {
11183
+ return `<Delete><Quiet>true</Quiet>${keys.map((k) => `<Object><Key>${escapeXml(k)}</Key></Object>`).join("")}</Delete>`;
11184
+ }
11185
+ var S3 = class {
11186
+ ctx;
11187
+ storage;
11188
+ constructor(ctx, storage) {
11189
+ this.ctx = ctx;
11190
+ this.storage = storage;
11191
+ }
11192
+ /** The curl config of one call: the URL, the signature's inputs, the body, and the status after the answer. */
11193
+ config(call) {
11194
+ const { endpoint, region, accessKey, secretKey } = this.storage;
11195
+ const url = `${endpoint.replace(/\/$/, "")}/${call.path}${call.query ? `?${call.query}` : ""}`;
11196
+ const body2 = call.body ?? "";
11197
+ const lines = [
11198
+ `url = ${JSON.stringify(url)}`,
11199
+ call.method === "HEAD" ? "head" : `request = "${call.method}"`,
11200
+ "silent",
11201
+ "show-error",
11202
+ "max-time = 60",
11203
+ `user = ${JSON.stringify(`${accessKey}:${secretKey}`)}`,
11204
+ `aws-sigv4 = "aws:amz:${region}:s3"`,
11205
+ // Stated, not left to curl: its versions differ in whether they send it, and S3 wants it signed.
11206
+ `header = "x-amz-content-sha256: ${sha256(body2)}"`,
11207
+ ...(call.headers ?? []).map((h) => `header = ${JSON.stringify(h)}`),
11208
+ ...call.body === void 0 ? [] : [`data-binary = ${JSON.stringify(body2)}`],
11209
+ 'write-out = "\\n%{http_code}"'
11210
+ ];
11211
+ return `${lines.join("\n")}
11212
+ `;
11213
+ }
11214
+ answer(out) {
11215
+ const at = out.lastIndexOf("\n");
11216
+ return { status: Number(out.slice(at + 1)), body: out.slice(0, at) };
11217
+ }
11218
+ async read(call) {
11219
+ return this.answer(await this.ctx.read("curl", ["-K", "-"], { input: this.config(call) }));
11220
+ }
11221
+ /** An effect: its answer, or undefined under --dry-run. A status outside 2xx is an error. */
11222
+ async effect(call, what) {
11223
+ const out = await this.ctx.effect(run("curl", ["-K", "-"], { input: this.config(call) }));
11224
+ if (out === void 0) return void 0;
11225
+ const answer = this.answer(out);
11226
+ if (answer.status < 200 || answer.status >= 300)
11227
+ throw new Error(`${what} answered ${answer.status}: ${answer.body.slice(0, 300)}`);
11228
+ return answer;
11229
+ }
11230
+ async bucketExists(bucket) {
11231
+ const { status } = await this.read({ method: "HEAD", path: bucket });
11232
+ if (status === 200) return true;
11233
+ if (status === 404) return false;
11234
+ throw new Error(`looking up the bucket ${bucket} answered ${status}`);
11235
+ }
11236
+ async makeBucket(bucket) {
11237
+ await this.effect({ method: "PUT", path: bucket }, `creating the bucket ${bucket}`);
11238
+ }
11239
+ /**
11240
+ * Delete a bucket whole: its objects a thousand at a time, the uploads
11241
+ * something left unfinished, then the bucket, which S3 refuses while
11242
+ * either remains. A dry run lists once and says what would go.
11243
+ */
11244
+ async removeBucket(bucket) {
11245
+ let removed = 0;
11246
+ for (; ; ) {
11247
+ const page = await this.read({ method: "GET", path: bucket, query: "list-type=2&max-keys=1000" });
11248
+ if (page.status !== 200) throw new Error(`listing the bucket ${bucket} answered ${page.status}`);
11249
+ const keys = elements(page.body, "Key");
11250
+ if (keys.length === 0) break;
11251
+ const body2 = deleteBody(keys);
11252
+ const answer = await this.effect(
11253
+ { method: "POST", path: bucket, query: "delete=", body: body2, headers: [`Content-MD5: ${md5(body2)}`] },
11254
+ `deleting ${keys.length} objects of ${bucket}`
11255
+ );
11256
+ if (answer === void 0) {
11257
+ this.ctx.say(` (the objects of ${bucket}, ${keys.length} in the first listing, then the bucket)`);
11258
+ return;
11259
+ }
11260
+ const failed = elements(answer.body, "Message");
11261
+ if (answer.body.includes("<Error>"))
11262
+ throw new Error(`deleting objects of ${bucket} failed: ${failed[0] ?? answer.body.slice(0, 300)}`);
11263
+ removed += keys.length;
11264
+ }
11265
+ const pending = await this.read({ method: "GET", path: bucket, query: "uploads=" });
11266
+ for (const upload of pending.status === 200 ? uploads(pending.body) : []) {
11267
+ const key = upload.key.split("/").map(encodeURIComponent).join("/");
11268
+ await this.effect(
11269
+ { method: "DELETE", path: `${bucket}/${key}`, query: `uploadId=${encodeURIComponent(upload.id)}` },
11270
+ `aborting an unfinished upload in ${bucket}`
11271
+ );
11272
+ }
11273
+ await this.effect({ method: "DELETE", path: bucket }, `deleting the bucket ${bucket}`);
11274
+ this.ctx.say(`removed the bucket ${bucket} and its ${removed} object(s)`);
11275
+ }
11276
+ };
11277
+
11120
11278
  // src/core/time.ts
11121
11279
  function iso(at) {
11122
11280
  return at.toISOString().replace(/\.\d{3}Z$/, "Z");
@@ -11134,6 +11292,10 @@ function registryBuckets(owner, now) {
11134
11292
  }
11135
11293
 
11136
11294
  // src/core/registry.ts
11295
+ var imageName = (prefix, app) => `${prefix.toLowerCase().replace(/\/$/, "")}/${app}`;
11296
+ function registryOf(kind, prefix) {
11297
+ return kind === "own" ? { kind } : { kind, prefix };
11298
+ }
11137
11299
  var CURRENT_PORT = 5e3;
11138
11300
  var PREVIOUS_PORT = 5001;
11139
11301
  var REGISTRY_IMAGE = "registry:2";
@@ -11188,7 +11350,7 @@ async function answers(ctx, port) {
11188
11350
  return false;
11189
11351
  }
11190
11352
  }
11191
- async function startSidecars(ctx, storage, owner, zone, now = /* @__PURE__ */ new Date()) {
11353
+ async function startSidecars(ctx, storage, owner, now = /* @__PURE__ */ new Date()) {
11192
11354
  const buckets = registryBuckets(owner, now);
11193
11355
  if (!ctx.dryRun && await answers(ctx, CURRENT_PORT)) {
11194
11356
  const previousAnswers2 = await answers(ctx, PREVIOUS_PORT);
@@ -11197,10 +11359,10 @@ async function startSidecars(ctx, storage, owner, zone, now = /* @__PURE__ */ ne
11197
11359
  }
11198
11360
  if (!storage)
11199
11361
  throw new Error("REGISTRY_ENDPOINT, REGISTRY_REGION, REGISTRY_ACCESS_KEY and REGISTRY_SECRET_KEY are needed");
11200
- const sos = new Storage(ctx, storage.accessKey, storage.secretKey);
11201
- if (!await sos.bucketExists(buckets.current)) {
11202
- ctx.say(`\u2192 creating the bucket ${buckets.current} in ${zone}`);
11203
- await sos.makeBucket(buckets.current, zone);
11362
+ const s3 = new S3(ctx, storage);
11363
+ if (!await s3.bucketExists(buckets.current)) {
11364
+ ctx.say(`\u2192 creating the bucket ${buckets.current}`);
11365
+ await s3.makeBucket(buckets.current);
11204
11366
  }
11205
11367
  await ctx.apply([
11206
11368
  run("docker", sidecarArgs("registry", CURRENT_PORT, buckets.current, storage, false)),
@@ -11267,14 +11429,247 @@ async function layers(ctx, app, tag, port = CURRENT_PORT) {
11267
11429
  return void 0;
11268
11430
  }
11269
11431
  }
11432
+ function candidateRef(registry, app, tag) {
11433
+ return registry.kind === "own" ? `localhost:${CURRENT_PORT}/${app}:${tag}` : `${imageName(registry.prefix, app)}:${tag}`;
11434
+ }
11435
+ async function craneDigest(ctx, ref) {
11436
+ try {
11437
+ return (await ctx.read("crane", ["digest", ref])).trim() || void 0;
11438
+ } catch {
11439
+ return void 0;
11440
+ }
11441
+ }
11442
+ async function findTag(ctx, registry, app, tag) {
11443
+ if (registry.kind === "own") {
11444
+ const port = await tagExists(ctx, app, tag);
11445
+ return port === void 0 ? void 0 : `localhost:${port}/${app}:${tag}`;
11446
+ }
11447
+ const ref = candidateRef(registry, app, tag);
11448
+ return await craneDigest(ctx, ref) === void 0 ? void 0 : ref;
11449
+ }
11450
+ function digestOf(ctx, registry, app, tag) {
11451
+ return registry.kind === "own" ? digest(ctx, app, tag) : craneDigest(ctx, candidateRef(registry, app, tag));
11452
+ }
11453
+ async function layersOf(ctx, registry, app, tag) {
11454
+ if (registry.kind === "own") return layers(ctx, app, tag);
11455
+ try {
11456
+ const data = JSON.parse(await ctx.read("crane", ["manifest", candidateRef(registry, app, tag)]));
11457
+ return (data.layers ?? []).map((l) => ({ digest: l.digest, size: l.size }));
11458
+ } catch {
11459
+ return void 0;
11460
+ }
11461
+ }
11462
+ async function loggedIn(ctx, host, env = process.env) {
11463
+ const file = `${env.DOCKER_CONFIG || `${env.HOME}/.docker`}/config.json`;
11464
+ try {
11465
+ const config = JSON.parse(await ctx.readFile(file));
11466
+ return Boolean(config.auths?.[host] || config.credHelpers?.[host] || config.credsStore);
11467
+ } catch {
11468
+ return false;
11469
+ }
11470
+ }
11471
+ async function rotateBuckets(ctx, storage, owner, now = /* @__PURE__ */ new Date()) {
11472
+ const buckets = registryBuckets(owner, now);
11473
+ const s3 = new S3(ctx, storage);
11474
+ const result = {};
11475
+ if (!await s3.bucketExists(buckets.current)) {
11476
+ ctx.say(`rotate: creating the bucket ${buckets.current}`);
11477
+ await s3.makeBucket(buckets.current);
11478
+ result.created = buckets.current;
11479
+ }
11480
+ if (await s3.bucketExists(buckets.expired)) {
11481
+ ctx.say(`rotate: the registry bucket ${buckets.expired} is two months old`);
11482
+ await s3.removeBucket(buckets.expired);
11483
+ result.removed = buckets.expired;
11484
+ }
11485
+ return result;
11486
+ }
11487
+
11488
+ // src/core/strategy.ts
11489
+ var STRATEGIES = ["github", "on-prem", "hybrid"];
11490
+ var REGISTRIES = ["ghcr", "own"];
11491
+ var OVERLAYS = ["none", "netbird"];
11492
+ function oneOf(name, value, allowed) {
11493
+ if (!allowed.includes(value))
11494
+ throw new UsageError(`${name} is "${value}"; it is one of ${allowed.join(", ")}`);
11495
+ return value;
11496
+ }
11497
+ function settingsOf(env = process.env, flag) {
11498
+ const strategy = oneOf("the strategy", flag || env.CI_STRATEGY || "github", STRATEGIES);
11499
+ const registry = oneOf("CI_REGISTRY", env.CI_REGISTRY || (strategy === "github" ? "ghcr" : "own"), REGISTRIES);
11500
+ const overlay = oneOf("CI_OVERLAY", env.CI_OVERLAY || "none", OVERLAYS);
11501
+ return { strategy, registry, overlay };
11502
+ }
11503
+ var sizeLabel = (size) => `ci-${size}`;
11504
+ function runnerOf(strategy, size, instance) {
11505
+ if (strategy === "github") return "ubuntu-latest";
11506
+ if (strategy === "hybrid" && size === "large") {
11507
+ if (!instance) throw new Error("a large job under hybrid needs its instance\u2019s label");
11508
+ return instance;
11509
+ }
11510
+ return sizeLabel(size);
11511
+ }
11512
+
11513
+ // src/core/plan.ts
11514
+ var NONEXISTENT = "<NONEXISTENT>";
11515
+ function hashesOf(dryRun, packages) {
11516
+ const data = JSON.parse(dryRun);
11517
+ const tasks = (data.tasks ?? []).filter((t) => t.command !== NONEXISTENT);
11518
+ return Object.entries(packages).flatMap(([app, name]) => {
11519
+ const build2 = tasks.find((t) => t.package === name && t.taskId.endsWith("#build"));
11520
+ if (!build2) return [];
11521
+ const test2 = tasks.find((t) => t.package === name && t.taskId.endsWith("#test"));
11522
+ return [{ app, hash: build2.hash, ...test2 ? { test: test2.hash } : {} }];
11523
+ });
11524
+ }
11525
+ async function packageName(ctx, dir, app) {
11526
+ const pkg = JSON.parse(await ctx.readFile(`${dir}/apps/${app}/package.json`));
11527
+ if (!pkg.name) throw new UsageError(`apps/${app}/package.json has no name`);
11528
+ return pkg.name;
11529
+ }
11530
+ async function turboOf(ctx, dir) {
11531
+ const pkg = JSON.parse(await ctx.readFile(`${dir}/package.json`));
11532
+ return `turbo@${pkg.devDependencies?.turbo ?? "latest"}`;
11533
+ }
11534
+ async function readHashes(ctx, dir, apps) {
11535
+ const packages = {};
11536
+ for (const app of apps) packages[app] = await packageName(ctx, dir, app);
11537
+ const out = await ctx.read("npx", ["--yes", await turboOf(ctx, dir), "run", "build", "test", "--dry-run=json"], {
11538
+ cwd: dir
11539
+ });
11540
+ return hashesOf(out, packages);
11541
+ }
11542
+ function legsOf(apps, hashes, mode, strategy, run2) {
11543
+ return apps.flatMap((app) => {
11544
+ const hash = hashes.find((h) => h.app === app)?.hash;
11545
+ if (!hash) throw new UsageError(`no build hash for ${app}; is it an app package of the workspace?`);
11546
+ const copies = mode === "repro" ? 2 : 1;
11547
+ return Array.from({ length: copies }, (_, i) => {
11548
+ const n = i + 1;
11549
+ const base = `exo-build-${run2.runId}-${app}-${run2.attempt}`;
11550
+ return { app, hash, runner: runnerOf(strategy, "large", mode === "repro" ? `${base}-${n}` : base), n };
11551
+ });
11552
+ });
11553
+ }
11554
+ function testLegsOf(apps, hashes, strategy, run2) {
11555
+ return apps.flatMap((app) => {
11556
+ const h = hashes.find((x) => x.app === app);
11557
+ if (!h?.test) return [];
11558
+ const runner = runnerOf(strategy, "large", `exo-test-${run2.runId}-${app}-${run2.attempt}`);
11559
+ return [{ app, hash: h.test, build: h.hash, runner }];
11560
+ });
11561
+ }
11562
+ function environmentOf(event, ref, given = "") {
11563
+ if (given) return given;
11564
+ if (event !== "push" && event !== "workflow_dispatch") return "";
11565
+ return ref === "main" ? "production" : ref === "develop" ? "staging" : "";
11566
+ }
11567
+ function deployLegsOf(apps, targets, prefix, sha) {
11568
+ const legs = [];
11569
+ const without = [];
11570
+ for (const app of apps) {
11571
+ const host = targets[app];
11572
+ if (host === void 0) without.push(app);
11573
+ else legs.push({ app, host, image: `${imageName(prefix, app)}:${sha}` });
11574
+ }
11575
+ return { legs, without };
11576
+ }
11577
+ async function plan(ctx, options) {
11578
+ const { apps, hashes, mode, strategy, registry, run: run2 } = options;
11579
+ const result = {
11580
+ legs: [],
11581
+ tests: [],
11582
+ apps: apps.map((app) => {
11583
+ const h = hashes.find((x) => x.app === app);
11584
+ if (!h) throw new UsageError(`no build hash for ${app}; is it an app package of the workspace?`);
11585
+ return h;
11586
+ }),
11587
+ environment: options.deploy?.environment ?? "",
11588
+ deploys: [],
11589
+ skipped: [],
11590
+ skippedTests: []
11591
+ };
11592
+ for (const leg of legsOf(apps, hashes, mode, strategy, run2)) {
11593
+ if (mode === "build") {
11594
+ const ref = await findTag(ctx, registry, leg.app, `build-${leg.hash}`);
11595
+ if (ref !== void 0) {
11596
+ ctx.say(`${leg.app}: ${ref} exists, nothing to build`);
11597
+ result.skipped.push({ app: leg.app, hash: leg.hash, ref });
11598
+ continue;
11599
+ }
11600
+ }
11601
+ result.legs.push(leg);
11602
+ }
11603
+ if (options.tests && mode !== "repro") {
11604
+ for (const leg of testLegsOf(apps, hashes, strategy, run2)) {
11605
+ if (mode === "build") {
11606
+ const ref = await findTag(ctx, registry, leg.app, `test-${leg.hash}`);
11607
+ if (ref !== void 0) {
11608
+ ctx.say(`${leg.app}: ${ref} exists, nothing to test`);
11609
+ result.skippedTests.push({ app: leg.app, hash: leg.hash, ref });
11610
+ continue;
11611
+ }
11612
+ }
11613
+ result.tests.push(leg);
11614
+ }
11615
+ }
11616
+ if (options.deploy?.environment) {
11617
+ const { environment, targets, prefix, sha } = options.deploy;
11618
+ const { legs, without } = deployLegsOf(apps, targets, prefix, sha);
11619
+ result.deploys = legs;
11620
+ for (const app of without) ctx.say(`${app} has no deployment in ${environment}; nothing to deploy`);
11621
+ }
11622
+ return result;
11623
+ }
11624
+ async function report(ctx, registry, legs, mode, runId) {
11625
+ const apps = [...new Set(legs.map((l) => l.app))].sort();
11626
+ const out = [];
11627
+ for (const app of apps) {
11628
+ const hash = legs.find((l) => l.app === app)?.hash ?? "";
11629
+ const c = { app, hash, digest: await digestOf(ctx, registry, app, `build-${hash}`) };
11630
+ ctx.say(`${app} build-${hash}: ${c.digest ?? "missing"}`);
11631
+ if (mode === "repro") {
11632
+ const [one, two] = await Promise.all([
11633
+ digestOf(ctx, registry, app, `repro-${runId}-1`),
11634
+ digestOf(ctx, registry, app, `repro-${runId}-2`)
11635
+ ]);
11636
+ const identical = one !== void 0 && one === two;
11637
+ const [l1, l2] = identical ? [[], []] : await Promise.all([
11638
+ layersOf(ctx, registry, app, `repro-${runId}-1`),
11639
+ layersOf(ctx, registry, app, `repro-${runId}-2`)
11640
+ ]);
11641
+ const differing = [];
11642
+ const a = l1 ?? [];
11643
+ const b = l2 ?? [];
11644
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
11645
+ if (a[i]?.digest !== b[i]?.digest)
11646
+ differing.push(
11647
+ `layer ${i + 1}: ${a[i]?.digest ?? "-"} (${a[i]?.size ?? "-"}) vs ${b[i]?.digest ?? "-"} (${b[i]?.size ?? "-"})`
11648
+ );
11649
+ }
11650
+ c.repro = { one, two, identical, layers: a.length, same: a.length - differing.length, differing };
11651
+ if (identical) ctx.say(` reproducible: both builds are ${one}`);
11652
+ else {
11653
+ ctx.say(
11654
+ ` not identical: ${one ?? "-"} vs ${two ?? "-"}; layers: ${a.length} in each, ${a.length - differing.length} identical`
11655
+ );
11656
+ for (const d of differing) ctx.say(` ${d}`);
11657
+ }
11658
+ }
11659
+ out.push(c);
11660
+ }
11661
+ return out;
11662
+ }
11270
11663
 
11271
11664
  // src/core/build.ts
11272
- var REGISTRY = `localhost:${CURRENT_PORT}`;
11273
11665
  var PREVIOUS_REGISTRY = `localhost:${PREVIOUS_PORT}`;
11666
+ function tagsOf(options) {
11667
+ return [`build-${options.hash}`, ...options.registry.kind === "own" ? [options.sha] : [], ...options.tags ?? []];
11668
+ }
11274
11669
  function buildArgs(options, context, metadataFile, previousAnswers) {
11275
- const { app, hash, sha } = options;
11276
- const ref = (tag) => `${REGISTRY}/${app}:${tag}`;
11277
- const tags = [`build-${hash}`, sha, ...options.tags ?? []].flatMap((t) => ["--tag", ref(t)]);
11670
+ const { app } = options;
11671
+ const ref = (tag) => candidateRef(options.registry, app, tag);
11672
+ const tags = tagsOf(options).flatMap((t) => ["--tag", ref(t)]);
11278
11673
  const cache = options.noCache ? ["--no-cache"] : [
11279
11674
  "--cache-from",
11280
11675
  `type=registry,ref=${ref("cache")}`,
@@ -11293,7 +11688,7 @@ function buildArgs(options, context, metadataFile, previousAnswers) {
11293
11688
  "--build-arg",
11294
11689
  `APP=${app}`,
11295
11690
  "--build-arg",
11296
- `BUILD_HASH=${hash}`,
11691
+ `BUILD_HASH=${options.hash}`,
11297
11692
  "--secret",
11298
11693
  "id=next-server-actions-encryption-key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
11299
11694
  ...cache,
@@ -11310,159 +11705,71 @@ function buildArgs(options, context, metadataFile, previousAnswers) {
11310
11705
  "plain"
11311
11706
  ];
11312
11707
  }
11708
+ async function ensureBuilder(ctx, name) {
11709
+ if (!ctx.dryRun) {
11710
+ try {
11711
+ await ctx.read("docker", ["buildx", "inspect", name]);
11712
+ return;
11713
+ } catch {
11714
+ }
11715
+ }
11716
+ await ctx.apply([
11717
+ run("docker", [
11718
+ "buildx",
11719
+ "create",
11720
+ "--name",
11721
+ name,
11722
+ "--driver",
11723
+ "docker-container",
11724
+ "--driver-opt",
11725
+ "network=host",
11726
+ "--bootstrap"
11727
+ ])
11728
+ ]);
11729
+ }
11313
11730
  async function build(ctx, options) {
11314
11731
  if (!options.encryptionKey) throw new UsageError("NEXT_SERVER_ACTIONS_ENCRYPTION_KEY is empty");
11315
11732
  const dir = options.dir ?? ".";
11316
- const out = join(tmpdir(), `plx-ci-build-${randomBytes(4).toString("hex")}`);
11317
- const context = join(out, "full");
11733
+ const out = join2(tmpdir(), `plx-ci-build-${randomBytes(4).toString("hex")}`);
11734
+ const context = join2(out, "full");
11735
+ const builder = options.builder ?? "ci";
11318
11736
  const env = {
11319
11737
  NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: options.encryptionKey,
11320
- BUILDX_BUILDER: options.builder ?? "ci",
11738
+ BUILDX_BUILDER: builder,
11321
11739
  SOURCE_DATE_EPOCH: "0"
11322
11740
  };
11323
- const pkg = JSON.parse(await ctx.readFile(join(dir, "package.json")));
11324
- const turbo = pkg.devDependencies?.turbo ?? "latest";
11741
+ const name = await packageName(ctx, dir, options.app);
11742
+ await ensureBuilder(ctx, builder);
11325
11743
  let t = Date.now();
11326
11744
  await ctx.apply([
11327
- run("npx", ["--yes", `turbo@${turbo}`, "prune", `${options.scope}/${options.app}`, "--docker", "--out-dir", out], {
11745
+ run("npx", ["--yes", await turboOf(ctx, dir), "prune", name, "--docker", "--out-dir", out], {
11328
11746
  cwd: dir
11329
11747
  }),
11330
- run("cp", [join(out, "pnpm-lock.yaml"), join(context, "pnpm-lock.yaml")]),
11331
- run("cp", [join(dir, ".dockerignore"), join(context, ".dockerignore")]),
11332
- run("mkdir", ["-p", join(context, "packages")])
11748
+ run("cp", [join2(out, "pnpm-lock.yaml"), join2(context, "pnpm-lock.yaml")]),
11749
+ run("cp", [join2(dir, ".dockerignore"), join2(context, ".dockerignore")]),
11750
+ run("mkdir", ["-p", join2(context, "packages")])
11333
11751
  ]);
11334
11752
  const pruneSeconds = Math.round((Date.now() - t) / 1e3);
11335
11753
  ctx.say(`build: prune ${pruneSeconds}s`);
11336
- const previousAnswers = ctx.dryRun ? true : await answers(ctx, PREVIOUS_PORT);
11337
- if (!previousAnswers) ctx.say(`build: no previous-month registry on ${PREVIOUS_REGISTRY}`);
11338
- const metadataFile = join(out, "metadata.json");
11754
+ const previousAnswers = options.registry.kind === "own" && (ctx.dryRun ? true : await answers(ctx, PREVIOUS_PORT));
11755
+ if (options.registry.kind === "own" && !previousAnswers)
11756
+ ctx.say(`build: no previous-month registry on ${PREVIOUS_REGISTRY}`);
11757
+ const metadataFile = join2(out, "metadata.json");
11339
11758
  t = Date.now();
11340
11759
  await ctx.apply([run("docker", buildArgs(options, context, metadataFile, previousAnswers), { env })]);
11341
11760
  const buildSeconds = Math.round((Date.now() - t) / 1e3);
11342
11761
  ctx.say(`build: build${options.noCache ? " no-cache" : ""} ${buildSeconds}s`);
11343
- const tags = [`build-${options.hash}`, options.sha, ...options.tags ?? []];
11762
+ const tags = tagsOf(options);
11344
11763
  const result = { app: options.app, tags, pruneSeconds, buildSeconds };
11345
11764
  if (!ctx.dryRun) {
11346
11765
  const meta = JSON.parse(await ctx.readFile(metadataFile));
11347
11766
  result.digest = meta["containerimage.digest"];
11348
- ctx.say(`build: ${options.app} build-${options.hash} ${options.sha} -> ${result.digest ?? "<no digest>"}`);
11767
+ ctx.say(`build: ${options.app} ${tags.join(" ")} -> ${result.digest ?? "<no digest>"}`);
11349
11768
  }
11350
11769
  await ctx.apply([run("rm", ["-rf", out])]);
11351
11770
  return result;
11352
11771
  }
11353
11772
 
11354
- // src/commands/build.ts
11355
- function register(program2) {
11356
- program2.command("build").description("build one app\u2019s image from its pruned context against the registry sidecars and push it").argument("<app>", "the app, apps/<app> in the checkout").argument("<hash>", "Turbo\u2019s hash of the app\u2019s build task").argument("<sha>", "the commit, a second tag").argument("[tags...]", "further tags on the same image").option("--scope <scope>", "the npm scope of the workspace packages", "@damage-ms").option("-C, --dir <dir>", "the checkout to read", ".").action(
11357
- async (app, hash, sha, tags, opts, command) => {
11358
- const ctx = contextOf(command);
11359
- const result = await build(ctx, {
11360
- app,
11361
- hash,
11362
- sha,
11363
- tags,
11364
- encryptionKey: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ?? "",
11365
- noCache: process.env.NO_CACHE === "1",
11366
- dir: resolve(opts.dir),
11367
- scope: opts.scope,
11368
- builder: process.env.BUILDX_BUILDER
11369
- });
11370
- ctx.result(result, (r) => r.digest ?? "");
11371
- if (process.env.GITHUB_OUTPUT && result.digest)
11372
- appendFileSync(process.env.GITHUB_OUTPUT, `digest=${result.digest}
11373
- `);
11374
- }
11375
- );
11376
- }
11377
-
11378
- // src/core/inspect.ts
11379
- import { join as join2 } from "node:path";
11380
- var LOGS = [
11381
- ["journal-previous-boot.log", "sudo journalctl -b -1 --no-pager -o short-iso"],
11382
- [
11383
- "journal-previous-boot-network.log",
11384
- 'sudo journalctl -b -1 --no-pager -o short-iso -u systemd-networkd -u cloud-init-local -u cloud-init -u "ifup@*" -u networking -k'
11385
- ],
11386
- ["cloud-init.log", "sudo cat /var/log/cloud-init.log"],
11387
- ["cloud-init-output.log", "sudo cat /var/log/cloud-init-output.log"],
11388
- [
11389
- "system.txt",
11390
- "sudo journalctl --list-boots --no-pager; echo; cat /etc/cloud/cloud.cfg.d/*.cfg 2>/dev/null; echo; ls -la /etc/network/interfaces.d/ /etc/systemd/network/ /run/systemd/network/ 2>/dev/null; echo; cat /etc/network/interfaces.d/* /etc/systemd/network/* /run/systemd/network/* 2>/dev/null; echo; ip -br link; ip -br addr"
11391
- ]
11392
- ];
11393
- function sshArgs(key, ip, command) {
11394
- return [
11395
- "-i",
11396
- key,
11397
- "-o",
11398
- "BatchMode=yes",
11399
- "-o",
11400
- "ConnectTimeout=5",
11401
- "-o",
11402
- "StrictHostKeyChecking=no",
11403
- "-o",
11404
- "UserKnownHostsFile=/dev/null",
11405
- "-o",
11406
- "LogLevel=ERROR",
11407
- `debian@${ip}`,
11408
- command
11409
- ];
11410
- }
11411
- async function inspect(ctx, exo, options) {
11412
- const instance = await exo.instance(options.id);
11413
- if (!instance) throw new UsageError(`no instance ${options.id} in ${exo.zone}`);
11414
- if (!instance.publicIp) throw new UsageError(`instance ${options.id} has no public address`);
11415
- const ip = instance.publicIp;
11416
- const out = options.out ?? `inspect-${options.id}`;
11417
- const ssh = (command) => ctx.read("ssh", sshArgs(options.sshKey, ip, command));
11418
- ctx.say(`inspect: ${options.id} at ${ip}, state ${instance.state}`);
11419
- if (instance.state === "stopped") {
11420
- ctx.say("inspect: starting (the launch stopped it when it kept it)");
11421
- await exo.startInstance(options.id);
11422
- } else {
11423
- const before = await ssh("true").then(
11424
- () => true,
11425
- () => false
11426
- );
11427
- ctx.say(
11428
- before ? " SSH answers before the reboot (not a stall?)" : " no SSH before the reboot, as expected of a stalled boot"
11429
- );
11430
- ctx.say("inspect: rebooting");
11431
- await exo.rebootInstance(options.id);
11432
- }
11433
- if (ctx.dryRun) {
11434
- ctx.say(` (then: wait for SSH, mask plexus-ci-runner and the timers, copy the previous boot's logs to ${out}/)`);
11435
- return out;
11436
- }
11437
- let reached = false;
11438
- for (let i = 0; i < 60 && !reached; i++) {
11439
- await sleep(2e3);
11440
- reached = await ssh("true").then(
11441
- () => true,
11442
- () => false
11443
- );
11444
- }
11445
- if (!reached) throw new Error("no SSH after the reboot");
11446
- ctx.say("inspect: in, masking the units");
11447
- await ssh(
11448
- "sudo systemctl mask --runtime --now plexus-ci-runner.service plexus-ci-no-job.timer plexus-ci-death.timer plexus-ci-poweroff.timer 2>&1 | tail -2; sudo systemctl stop plexus-ci-runner.service 2>/dev/null || true"
11449
- );
11450
- ctx.say("inspect: copying the logs");
11451
- await ctx.apply([run("mkdir", ["-p", out])]);
11452
- for (const [file, command] of LOGS) {
11453
- let text;
11454
- try {
11455
- text = await ssh(command);
11456
- } catch (error) {
11457
- text = error instanceof ExecError ? `${error.message}
11458
- ` : String(error);
11459
- }
11460
- await ctx.apply([write(join2(out, file), text)]);
11461
- }
11462
- ctx.say(`inspect: done; the instance ${options.id} is still running, delete it when finished`);
11463
- return out;
11464
- }
11465
-
11466
11773
  // src/core/github.ts
11467
11774
  var GitHubRunners = class {
11468
11775
  ctx;
@@ -11546,60 +11853,145 @@ function githubOf(ctx, env = process.env) {
11546
11853
  return env.RUNNER_TOKEN ? new GitHubRunners(ctx, need("GITHUB_REPOSITORY", env), env.RUNNER_TOKEN) : void 0;
11547
11854
  }
11548
11855
  var ownerOf = (repo) => repo.split("/")[0] ?? repo;
11549
-
11550
- // src/commands/inspect.ts
11551
- function register2(program2) {
11552
- program2.command("inspect").description("start or reboot a kept instance, mask its runner and timers, and copy the previous boot\u2019s logs here").argument("<id>", "the instance id").option("--ssh-key <path>", "the private half of the key the instances get", `${process.env.HOME}/.ssh/deploy`).option("-o, --out <dir>", "where the logs land (default: ./inspect-<id>)").action(async (id, opts, command) => {
11553
- const ctx = contextOf(command);
11554
- const out = await inspect(ctx, exoOf(ctx), { id, sshKey: opts.sshKey, out: opts.out });
11555
- ctx.result(out);
11556
- });
11856
+ function withStrategy(command) {
11857
+ return command.option("--strategy <strategy>", "github, on-prem or hybrid (default: CI_STRATEGY, else github)");
11858
+ }
11859
+ function settings(opts, env = process.env) {
11860
+ return settingsOf(env, opts.strategy);
11861
+ }
11862
+ function imagePrefix(env = process.env) {
11863
+ return env.IMAGE_PREFIX || `ghcr.io/${ownerOf(need("GITHUB_REPOSITORY", env))}`;
11864
+ }
11865
+ function registryFor(s, env = process.env) {
11866
+ return registryOf(s.registry, s.registry === "ghcr" ? imagePrefix(env) : "");
11557
11867
  }
11558
11868
 
11559
- // src/commands/launch.ts
11560
- import { appendFileSync as appendFileSync2 } from "node:fs";
11869
+ // src/commands/build.ts
11870
+ function register(program2) {
11871
+ withStrategy(program2.command("build")).description("build one app\u2019s image from its pruned context against the registry and push it").argument("<app>", "the app, apps/<app> in the checkout").argument("<hash>", "Turbo\u2019s hash of the app\u2019s build task").argument("<sha>", "the commit, a second tag").argument("[tags...]", "further tags on the same image").option("-C, --dir <dir>", "the checkout to read", ".").action(
11872
+ async (app, hash, sha, tags, opts, command) => {
11873
+ const ctx = contextOf(command);
11874
+ const result = await build(ctx, {
11875
+ app,
11876
+ hash,
11877
+ sha,
11878
+ tags,
11879
+ encryptionKey: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ?? "",
11880
+ noCache: process.env.NO_CACHE === "1",
11881
+ dir: resolve(opts.dir),
11882
+ registry: registryFor(settings(opts)),
11883
+ builder: process.env.BUILDX_BUILDER
11884
+ });
11885
+ ctx.result(result, (r) => r.digest ?? "");
11886
+ if (process.env.GITHUB_OUTPUT && result.digest)
11887
+ appendFileSync(process.env.GITHUB_OUTPUT, `digest=${result.digest}
11888
+ `);
11889
+ }
11890
+ );
11891
+ }
11561
11892
 
11562
- // src/core/launch.ts
11893
+ // src/core/createInstances.ts
11563
11894
  import { gzipSync } from "node:zlib";
11564
11895
 
11565
- // src/core/teardown.ts
11896
+ // src/core/destroyInstances.ts
11566
11897
  var repoLabel = (repo) => repo.replace("/", "-");
11567
- function selectRunInstances(instances, run2, keep2 = []) {
11898
+ var KEPT_LABEL = "kept";
11899
+ function selectRunInstances(instances, run2) {
11568
11900
  return instances.filter(
11569
- (i) => i.labels["plexus-ci"] === "true" && i.labels.repo === repoLabel(run2.repo) && i.labels.run === run2.runId && i.labels.attempt === run2.attempt && !keep2.includes(i.id)
11901
+ (i) => i.labels["plexus-ci"] === "true" && i.labels.repo === repoLabel(run2.repo) && i.labels.run === run2.runId && i.labels.attempt === run2.attempt && i.labels[KEPT_LABEL] === void 0
11570
11902
  );
11571
11903
  }
11572
11904
  function selectRunRegistrations(runners, run2) {
11573
11905
  const pattern = new RegExp(`^exo-[a-z]+-${run2.runId}(-[^-]+)*-${run2.attempt}(-[a-z0-9]+)?$`);
11574
11906
  return runners.filter((r) => !r.busy && pattern.test(r.name));
11575
11907
  }
11576
- async function teardown(ctx, exo, github, run2, keep2 = []) {
11908
+ async function destroyInstances(ctx, exo, github, run2, { retries = 12, wait = sleep } = {}) {
11577
11909
  const all = await exo.listInstances();
11578
- const mine = selectRunInstances(all, run2, keep2);
11579
- const kept = all.filter((i) => keep2.includes(i.id)).map((i) => i.name);
11580
- for (const name of kept) ctx.say(`${name}: kept for inspection`);
11581
- for (const i of mine) {
11582
- ctx.say(`${i.name} (${i.id}): ${i.state}`);
11583
- await exo.deleteInstance(i.id);
11584
- }
11585
- ctx.say(`teardown: ${mine.length} instance(s) of run ${run2.runId} attempt ${run2.attempt}`);
11586
- const registrations = [];
11587
- if (github) {
11910
+ const mine = selectRunInstances(all, run2);
11911
+ const kept = all.filter(
11912
+ (i) => i.labels["plexus-ci"] === "true" && i.labels[KEPT_LABEL] !== void 0 && i.labels.repo === repoLabel(run2.repo) && i.labels.run === run2.runId
11913
+ ).map((i) => i.name);
11914
+ for (const name of kept) ctx.say(`${name}: kept after a missed boot, left alone`);
11915
+ let left = mine;
11916
+ for (let round = 0; left.length > 0 && round <= retries; round++) {
11917
+ if (round > 0) await wait(1e4);
11918
+ const refused = [];
11919
+ for (const i of left) {
11920
+ if (round === 0) ctx.say(`${i.name} (${i.id}): ${i.state}`);
11921
+ try {
11922
+ await exo.deleteInstance(i.id);
11923
+ } catch (error) {
11924
+ if (round === retries) ctx.say(`\u2717 ${i.name} (${i.id}) is not deleted: ${error.message}`);
11925
+ refused.push(i);
11926
+ }
11927
+ }
11928
+ left = refused;
11929
+ }
11930
+ ctx.say(
11931
+ `destroy-instances: ${mine.length - left.length} of ${mine.length} instance(s) of run ${run2.runId} attempt ${run2.attempt} deleted`
11932
+ );
11933
+ const registrations = [];
11934
+ if (github) {
11588
11935
  for (const r of selectRunRegistrations(await github.list(), run2)) {
11589
11936
  ctx.say(`runner ${r.name} (${r.id}): ${r.status}`);
11590
11937
  await github.deleteRunner(r.id);
11591
11938
  registrations.push(r.name);
11592
11939
  }
11593
- ctx.say(`teardown: ${registrations.length} registration(s) removed`);
11940
+ ctx.say(`destroy-instances: ${registrations.length} registration(s) removed`);
11594
11941
  }
11942
+ if (left.length > 0)
11943
+ throw new Error(
11944
+ `${left.length} instance(s) could not be deleted: ${left.map((i) => i.name).join(", ")}; they power off on their own, and the sweep deletes them`
11945
+ );
11595
11946
  return { instances: mine.map((i) => i.name), kept, registrations };
11596
11947
  }
11597
11948
 
11949
+ // src/core/mail.ts
11950
+ import { tmpdir as tmpdir2 } from "node:os";
11951
+ import { join as join3 } from "node:path";
11952
+ function smtpFromEnv(env = process.env) {
11953
+ const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, MAIL_FROM, MAIL_TO } = env;
11954
+ if (!SMTP_HOST || !SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM || !MAIL_TO) return void 0;
11955
+ return {
11956
+ host: SMTP_HOST,
11957
+ port: SMTP_PORT || "587",
11958
+ user: SMTP_USER,
11959
+ password: SMTP_PASSWORD,
11960
+ from: MAIL_FROM,
11961
+ to: MAIL_TO
11962
+ };
11963
+ }
11964
+ function message(smtp, subject, text, date) {
11965
+ const body2 = text.replaceAll("\r\n", "\n").replaceAll("\n", "\r\n");
11966
+ return `From: plexus-ci <${smtp.from}>\r
11967
+ To: ${smtp.to}\r
11968
+ Subject: ${subject}\r
11969
+ Date: ${date.toUTCString()}\r
11970
+ \r
11971
+ ${body2}\r
11972
+ `;
11973
+ }
11974
+ async function sendMail(ctx, smtp, subject, text, date = /* @__PURE__ */ new Date()) {
11975
+ const scheme = smtp.port === "465" || smtp.port === "2465" ? "smtps" : "smtp";
11976
+ const file = join3(tmpdir2(), `plx-ci-mail-${process.pid}.eml`);
11977
+ const config = [
11978
+ `url = "${scheme}://${smtp.host}:${smtp.port}"`,
11979
+ "ssl-reqd",
11980
+ "silent",
11981
+ "show-error",
11982
+ `mail-from = "${smtp.from}"`,
11983
+ `mail-rcpt = "${smtp.to}"`,
11984
+ `user = ${JSON.stringify(`${smtp.user}:${smtp.password}`)}`,
11985
+ `upload-file = ${JSON.stringify(file)}`
11986
+ ].join("\n");
11987
+ await ctx.apply([write(file, message(smtp, subject, text, date)), run("curl", ["-K", "-"], { input: config })]);
11988
+ }
11989
+
11598
11990
  // src/core/userData.ts
11599
11991
  var RUNNER_USER_DATA = `#cloud-config
11600
- # One throwaway CI runner (ci.md, "Instances"). Rendered by launch.sh, which
11601
- # fills the placeholders. Every teardown layer is armed here, before the
11602
- # runner exists:
11992
+ # One throwaway CI runner (PLX-Delivery, \xA7 8). Rendered by plx-ci hybrid
11993
+ # create-instances, which fills the placeholders. Every teardown layer is
11994
+ # armed here, before the runner exists:
11603
11995
  #
11604
11996
  # layer 1 the runner exits after its one job, the instance deletes itself
11605
11997
  # layer 1b noJobTimeout, @@NO_JOB_MINUTES@@ minutes after boot with no job ever started,
@@ -11615,7 +12007,7 @@ var RUNNER_USER_DATA = `#cloud-config
11615
12007
  #
11616
12008
  # The self-delete key and the registry bucket's write key sit in
11617
12009
  # /etc/plexus-ci/env, readable by root; the job that runs here could read
11618
- # them anyway (ci.md, "Secrets").
12010
+ # them anyway (\xA7 8 PLX-Delivery).
11619
12011
  hostname: @@LABEL@@
11620
12012
 
11621
12013
  write_files:
@@ -11654,7 +12046,7 @@ write_files:
11654
12046
  # The env file is sourced exported (set -a): exo reads the key from the
11655
12047
  # environment, and a plain source would leave it shell-local. A timer's
11656
12048
  # service has no HOME, and exo refuses to run without one (layers 1b and
11657
- # 2b failed on it on 2026-11-04, ci.md); the runner's unit has a user.
12049
+ # 2b failed on it on 2026-11-04); the runner's unit has a user.
11658
12050
  - path: /usr/local/sbin/plexus-ci-exo
11659
12051
  permissions: "0755"
11660
12052
  content: |
@@ -11698,7 +12090,7 @@ write_files:
11698
12090
  set -u
11699
12091
  set -a; . /etc/plexus-ci/env; set +a
11700
12092
  # SELF_LAYERS=off (the launch's SELF_LAYERS, while the boot stall is
11701
- # investigated, ci.md): layers 1, 1b and 2b log and do nothing; the
12093
+ # investigated): layers 1, 1b and 2b log and do nothing; the
11702
12094
  # power-off, the teardown job and the sweep remain.
11703
12095
  [ "\${SELF_LAYERS:-on}" != off ] || { logger -t plexus-ci "self-delete ($1): layers off, not deleting"; exit 0; }
11704
12096
  id=$(curl -fsS --max-time 10 http://metadata.exoscale.com/latest/meta-data/instance-id) || id=
@@ -11712,7 +12104,7 @@ write_files:
11712
12104
  #!/bin/sh
11713
12105
  # Layer 1b: the runner spawns a Worker process for a job and nothing
11714
12106
  # else, and logs it as _diag/Worker_*.log; _work itself exists empty
11715
- # from the runner's start (seen 2026-11-04, ci.md). No Worker log by
12107
+ # from the runner's start (seen 2026-11-04). No Worker log by
11716
12108
  # now means no job ever came, and nothing will.
11717
12109
  set -u
11718
12110
  if ! ls /opt/runner/_diag/Worker_*.log >/dev/null 2>&1; then
@@ -11781,6 +12173,32 @@ write_files:
11781
12173
  [Install]
11782
12174
  WantedBy=timers.target
11783
12175
 
12176
+ # Frees the dpkg lock for the run commands. The image is weeks old, so
12177
+ # apt-daily-upgrade's persistent timer catches up seconds into the boot, and
12178
+ # unattended-upgrades then holds the lock for about a minute: every apt-get
12179
+ # here failed at once, there was no docker and no runner user, and the
12180
+ # launch kept the instance as a stalled boot. Waiting on the lock costs that
12181
+ # minute, so the instance, which lives under an hour, masks the apt timers
12182
+ # and services (they never start after this), kills whatever of them
12183
+ # already runs, dpkg children included (systemctl kill takes the unit's
12184
+ # whole cgroup), and lets dpkg finish what a kill interrupted. Masking alone
12185
+ # is not enough: the catch-up can start before any run command does, and
12186
+ # bootcmd is out (see above).
12187
+ - path: /usr/local/sbin/plexus-ci-apt-free
12188
+ permissions: "0755"
12189
+ content: |
12190
+ #!/bin/sh
12191
+ units='apt-daily.timer apt-daily-upgrade.timer apt-daily.service apt-daily-upgrade.service unattended-upgrades.service'
12192
+ systemctl mask $units >/dev/null 2>&1
12193
+ systemctl kill --signal=KILL apt-daily.service apt-daily-upgrade.service unattended-upgrades.service 2>/dev/null
12194
+ i=0
12195
+ while pgrep -x 'unattended-upgr|apt\\.systemd\\.dai|apt-get|apt|dpkg' >/dev/null && [ "$i" -lt 10 ]; do
12196
+ sleep 1; i=$((i + 1))
12197
+ done
12198
+ systemctl stop $units 2>/dev/null
12199
+ dpkg --configure -a
12200
+ logger -t plexus-ci "apt-free: apt timers masked, dpkg lock free after \${i}s"
12201
+
11784
12202
  # The runner, one job, then layer 1: ExecStopPost runs as root (the +) when
11785
12203
  # the runner exits for any reason.
11786
12204
  - path: /etc/systemd/system/plexus-ci-runner.service
@@ -11803,13 +12221,14 @@ runcmd:
11803
12221
  # Layers 1b, 2b and 2a first, before anything that can hang.
11804
12222
  - systemctl daemon-reload
11805
12223
  - systemctl start plexus-ci-no-job.timer plexus-ci-death.timer plexus-ci-poweroff.timer
12224
+ - plexus-ci-apt-free
11806
12225
  - export DEBIAN_FRONTEND=noninteractive; apt-get update -q && apt-get install -yq --no-install-recommends ca-certificates curl jq git tar unzip
11807
12226
  - curl -fsSL https://get.docker.com | sh
11808
12227
  - useradd -m -s /bin/bash -G docker runner && echo 'runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runner
11809
12228
  - cp /etc/plexus-ci/jitconfig /etc/plexus-ci/jitconfig.runner && chown runner:runner /etc/plexus-ci/jitconfig.runner && mv /etc/plexus-ci/jitconfig.runner /etc/plexus-ci/jitconfig
11810
12229
  - . /etc/plexus-ci/env && mkdir -p /opt/runner && cd /opt/runner && curl -fsSLo runner.tgz "https://github.com/actions/runner/releases/download/v\${RUNNER_VERSION}/actions-runner-linux-x64-\${RUNNER_VERSION}.tar.gz" && echo "\${RUNNER_SHA256} runner.tgz" | sha256sum -c - && tar -xzf runner.tgz && rm runner.tgz && ./bin/installdependencies.sh >/dev/null && chown -R runner:runner /opt/runner
11811
12230
  - plexus-ci-exo version >/dev/null
11812
- # The registry sidecars, when the launch named a bucket (ci.md, "The registry"): :5000 on this month's bucket, read-write; :5001 on the previous month's, read-only. The same two containers as .github/ci/registry.sh starts on a GitHub runner.
12231
+ # The registry sidecars, when the launch named a bucket (\xA7 8 PLX-Delivery): :5000 on this month's bucket, read-write; :5001 on the previous month's, read-only. The same two containers as .github/ci/registry.sh starts on a GitHub runner.
11813
12232
  - . /etc/plexus-ci/env && if [ -n "$REGISTRY_ENDPOINT" ]; then docker run -d --name registry --restart unless-stopped -p 127.0.0.1:5000:5000 -e REGISTRY_STORAGE=s3 -e REGISTRY_STORAGE_S3_REGIONENDPOINT="$REGISTRY_ENDPOINT" -e REGISTRY_STORAGE_S3_REGION="$REGISTRY_REGION" -e REGISTRY_STORAGE_S3_BUCKET="$REGISTRY_BUCKET" -e REGISTRY_STORAGE_S3_ACCESSKEY="$REGISTRY_ACCESS_KEY" -e REGISTRY_STORAGE_S3_SECRETKEY="$REGISTRY_SECRET_KEY" -e REGISTRY_STORAGE_S3_FORCEPATHSTYLE=true -e REGISTRY_STORAGE_DELETE_ENABLED=true registry:2 >/dev/null; fi
11814
12233
  - . /etc/plexus-ci/env && if [ -n "$REGISTRY_ENDPOINT" ]; then docker run -d --name registry-previous --restart on-failure:3 -p 127.0.0.1:5001:5000 -e REGISTRY_STORAGE=s3 -e REGISTRY_STORAGE_S3_REGIONENDPOINT="$REGISTRY_ENDPOINT" -e REGISTRY_STORAGE_S3_REGION="$REGISTRY_REGION" -e REGISTRY_STORAGE_S3_BUCKET="$REGISTRY_PREVIOUS_BUCKET" -e REGISTRY_STORAGE_S3_ACCESSKEY="$REGISTRY_ACCESS_KEY" -e REGISTRY_STORAGE_S3_SECRETKEY="$REGISTRY_SECRET_KEY" -e REGISTRY_STORAGE_S3_FORCEPATHSTYLE=true -e 'REGISTRY_STORAGE_MAINTENANCE_READONLY={"enabled":true}' registry:2 >/dev/null; fi
11815
12234
  # A builder: the docker driver cannot export a registry cache, so BuildKit runs in its own container on the host's network, where localhost:5000 is the sidecar; bootstrapped now so that the build does not wait for it.
@@ -11817,31 +12236,6 @@ runcmd:
11817
12236
  - . /etc/plexus-ci/env && if [ "$KIND" = test ]; then docker pull -q postgres:18.6 >/dev/null & docker pull -q rustfs/rustfs:1.0.4 >/dev/null & fi
11818
12237
  - systemctl start plexus-ci-runner.service
11819
12238
  `;
11820
- var BARE_USER_DATA = `#cloud-config
11821
- # The bisect probe (ci-dummy with break = bare): nothing but the power-off
11822
- # timer, so that a boot with this and a boot with user-data.yaml tell apart
11823
- # the image from the scripts. The sweep removes a powered-off instance.
11824
- write_files:
11825
- - path: /etc/systemd/system/plexus-ci-poweroff.service
11826
- content: |
11827
- [Unit]
11828
- Description=plexus-ci: power off at the hard deadline
11829
- [Service]
11830
- Type=oneshot
11831
- ExecStart=/usr/bin/systemctl poweroff
11832
- - path: /etc/systemd/system/plexus-ci-poweroff.timer
11833
- content: |
11834
- [Unit]
11835
- Description=plexus-ci: layer 2a
11836
- [Timer]
11837
- OnBootSec=@@POWEROFF_MINUTES@@min
11838
- AccuracySec=10s
11839
- [Install]
11840
- WantedBy=timers.target
11841
- runcmd:
11842
- - systemctl daemon-reload
11843
- - systemctl start plexus-ci-poweroff.timer
11844
- `;
11845
12239
  function placeholders(template) {
11846
12240
  return [...new Set([...template.matchAll(/@@([A-Z0-9_]+)@@/g)].map((m) => m[1] ?? ""))].filter((n) => n !== "");
11847
12241
  }
@@ -11852,7 +12246,7 @@ function render2(template, values) {
11852
12246
  return text;
11853
12247
  }
11854
12248
 
11855
- // src/core/launch.ts
12249
+ // src/core/createInstances.ts
11856
12250
  var DEFAULTS = {
11857
12251
  zone: "de-fra-1",
11858
12252
  instanceType: "cpu.extra-large",
@@ -11869,8 +12263,6 @@ var DEFAULTS = {
11869
12263
  userDataLimit: 32768
11870
12264
  };
11871
12265
  function renderUserData(options, jitconfig, now) {
11872
- if (options.kind === "bare")
11873
- return render2(BARE_USER_DATA, { POWEROFF_MINUTES: String(options.deadlines.poweroffMinutes) });
11874
12266
  const owner = options.run.repo.split("/")[0] ?? options.run.repo;
11875
12267
  const buckets = registryBuckets(owner, now);
11876
12268
  return render2(RUNNER_USER_DATA, {
@@ -11924,44 +12316,49 @@ function instanceLabels(options, noJobDeadline, deathDeadline) {
11924
12316
  }
11925
12317
  async function launch(ctx, exo, github, options) {
11926
12318
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
11927
- const { label, kind } = options;
12319
+ const { label } = options;
11928
12320
  if (options.deadlines.poweroffMinutes <= options.deadlines.deathMinutes)
11929
12321
  throw new UsageError("the power-off must come after the death deadline");
11930
12322
  const t0 = now();
11931
12323
  const noJobDeadline = deadline(t0, options.deadlines.noJobMinutes);
11932
12324
  const deathDeadline = deadline(t0, options.deadlines.deathMinutes);
11933
- const registration = kind === "bare" ? void 0 : await github.generateJitConfig(label, label);
11934
- const jitconfig = registration?.encodedJitConfig ?? "<jitconfig>";
11935
- const unregister = async () => {
11936
- if (!registration) return;
11937
- ctx.say(`launch failed, removing the runner registration ${label} (${registration.runnerId})`);
11938
- await github.deleteRunner(registration.runnerId).catch(() => void 0);
12325
+ const labels = instanceLabels(options, noJobDeadline, deathDeadline);
12326
+ const [templateId, instanceTypeId, securityGroupId] = await Promise.all([
12327
+ exo.templateId(options.template),
12328
+ exo.instanceTypeId(options.instanceType),
12329
+ exo.securityGroupId(options.securityGroup)
12330
+ ]);
12331
+ const result = {
12332
+ id: "<instance-id>",
12333
+ label,
12334
+ createdAt: iso(t0),
12335
+ noJobDeadline,
12336
+ deathDeadline,
12337
+ tries: 0,
12338
+ kept: []
11939
12339
  };
11940
- try {
11941
- const userData = encodeUserData(renderUserData(options, jitconfig, t0));
11942
- const [templateId, instanceTypeId, securityGroupId] = await Promise.all([
11943
- exo.templateId(options.template),
11944
- exo.instanceTypeId(options.instanceType),
11945
- exo.securityGroupId(options.securityGroup)
11946
- ]);
11947
- const request = {
11948
- name: label,
11949
- templateId,
11950
- instanceTypeId,
11951
- securityGroupId,
11952
- sshKey: options.sshKey,
11953
- diskSize: options.diskSize,
11954
- userDataBase64: userData,
11955
- labels: instanceLabels(options, noJobDeadline, deathDeadline)
12340
+ ctx.say(`\u2192 creating ${label} (${options.instanceType}, ${options.zone})`);
12341
+ for (let attempt = 1; attempt <= options.tries; attempt++) {
12342
+ const registration = await github.generateJitConfig(label, label);
12343
+ const unregister = async () => {
12344
+ if (registration) await github.deleteRunner(registration.runnerId).catch(() => void 0);
11956
12345
  };
11957
- const result = { id: "<instance-id>", label, createdAt: iso(t0), noJobDeadline, deathDeadline, tries: 0 };
11958
- ctx.say(`\u2192 creating ${label} (${options.instanceType}, ${options.zone})`);
11959
- for (let attempt = 1; attempt <= options.tries; attempt++) {
12346
+ try {
12347
+ const request = {
12348
+ name: label,
12349
+ templateId,
12350
+ instanceTypeId,
12351
+ securityGroupId,
12352
+ sshKey: options.sshKey,
12353
+ diskSize: options.diskSize,
12354
+ userDataBase64: encodeUserData(renderUserData(options, registration?.encodedJitConfig ?? "<jitconfig>", t0)),
12355
+ labels
12356
+ };
11960
12357
  const operation = await exo.createInstance(request);
11961
12358
  result.tries = attempt;
11962
12359
  if (operation === void 0) {
11963
12360
  ctx.say(
11964
- ` (then: wait up to ${options.onlineSeconds}s for the runner to show online, replace the instance on a miss)`
12361
+ ` (then: wait up to ${options.onlineSeconds}s for the runner to show online; on a miss keep the instance stopped as ${label}-kept, mail, and create one replacement)`
11965
12362
  );
11966
12363
  return result;
11967
12364
  }
@@ -11971,118 +12368,135 @@ async function launch(ctx, exo, github, options) {
11971
12368
  const state = await exo.waitOperation(operation.id);
11972
12369
  if (state !== "success") throw new Error(`create-instance ended in state ${state}`);
11973
12370
  ctx.say(`created ${label} (${result.id}) in ${options.zone}, try ${attempt}`);
11974
- if (kind === "bare") return result;
11975
- const waited = await waitOnline(github, label, options.onlineSeconds);
11976
- if (waited !== void 0) {
11977
- ctx.say(`runner ${label} online after ${waited}s`);
11978
- ctx.say(
11979
- `launched ${label} (${result.id}): no job by ${noJobDeadline}, dies at ${deathDeadline}, powers off ${options.deadlines.poweroffMinutes} minutes after creation`
11980
- );
11981
- return result;
11982
- }
11983
- ctx.say(`\u2717 ${label} (${result.id}) did not come online within ${options.onlineSeconds}s`);
11984
- if (options.diagSshKey) await diagnose2(ctx, exo, result.id, options.diagSshKey);
11985
- if (options.keepMissed && await keep(ctx, options.keepMissed, result.id)) {
11986
- await exo.stopInstance(result.id).catch(() => ctx.say(" stop-instance refused; the instance may delete itself"));
11987
- ctx.say(`kept ${label} (${result.id}) for inspection, stopped; run plx-ci inspect ${result.id}`);
11988
- result.kept = result.id;
11989
- } else {
11990
- ctx.say(`deleting ${label} (${result.id})`);
11991
- await exo.deleteInstance(result.id).catch(() => ctx.say(` delete of ${result.id} failed`));
11992
- }
12371
+ } catch (error) {
12372
+ ctx.say(`launch failed, removing the runner registration ${label}`);
12373
+ await unregister();
12374
+ throw error;
12375
+ }
12376
+ const waited = await waitOnline(github, label, options.onlineSeconds, options.sleep ?? sleep);
12377
+ if (waited !== void 0) {
12378
+ ctx.say(`runner ${label} online after ${waited}s`);
12379
+ ctx.say(
12380
+ `launched ${label} (${result.id}): no job by ${noJobDeadline}, dies at ${deathDeadline}, powers off ${options.deadlines.poweroffMinutes} minutes after creation`
12381
+ );
12382
+ return result;
11993
12383
  }
12384
+ ctx.say(`\u2717 ${label} (${result.id}) did not come online within ${options.onlineSeconds}s`);
12385
+ await keep(ctx, exo, options, result.id, labels, now());
12386
+ result.kept.push(result.id);
11994
12387
  await unregister();
11995
- throw new Error(`${options.tries} instance(s) of ${label} failed to boot, giving up`);
11996
- } catch (error) {
11997
- if (!(error instanceof Error && error.message.endsWith("giving up"))) await unregister();
11998
- throw error;
11999
12388
  }
12389
+ throw new Error(`${options.tries} instance(s) of ${label} failed to come online and are kept, giving up`);
12000
12390
  }
12001
- async function waitOnline(github, label, seconds) {
12391
+ async function waitOnline(github, label, seconds, wait) {
12002
12392
  for (let waited = 0; waited < seconds; waited += 5) {
12003
- await sleep(5e3);
12393
+ await wait(5e3);
12004
12394
  if (await github.status(label) === "online") return waited + 5;
12005
12395
  }
12006
12396
  return void 0;
12007
12397
  }
12008
- async function keep(ctx, file, id) {
12009
- try {
12010
- await ctx.stage(run("sh", ["-c", 'set -C && echo "$1" > "$2"', "keep", id, file]));
12011
- return true;
12012
- } catch (error) {
12013
- if (error instanceof ExecError) return false;
12014
- throw error;
12015
- }
12398
+ function keptMail(options, id) {
12399
+ const { label, run: run2 } = options;
12400
+ return {
12401
+ subject: `plexus-ci: ${label} did not come online and is kept`,
12402
+ text: [
12403
+ `The instance ${label} (${options.kind}, app ${options.app ?? "none"}) of ${run2.repo} run ${run2.runId} attempt ${run2.attempt} did not register its runner within ${options.onlineSeconds} seconds.`,
12404
+ "",
12405
+ `It is stopped and kept as ${label}-kept:`,
12406
+ ` id ${id}`,
12407
+ ` zone ${options.zone}`,
12408
+ "",
12409
+ "Its disk holds the boot it stalled in. Snapshot it to read it; the daily sweep mails again after seven days and deletes the instance after fourteen.",
12410
+ ""
12411
+ ].join("\n")
12412
+ };
12016
12413
  }
12017
- async function diagnose2(ctx, exo, id, sshKey) {
12018
- const ip = (await exo.instance(id))?.publicIp;
12019
- ctx.say(`diagnosis of ${id} at ${ip ?? "no ip"}:`);
12020
- if (!ip) return;
12021
- try {
12022
- const out = await ctx.read("ssh", [
12023
- "-i",
12024
- sshKey,
12025
- "-o",
12026
- "BatchMode=yes",
12027
- "-o",
12028
- "ConnectTimeout=10",
12029
- "-o",
12030
- "StrictHostKeyChecking=no",
12031
- "-o",
12032
- "UserKnownHostsFile=/dev/null",
12033
- "-o",
12034
- "LogLevel=ERROR",
12035
- `debian@${ip}`,
12036
- 'sudo cloud-init status --long 2>&1 | head -5; echo "-- runner unit"; sudo systemctl is-active plexus-ci-runner; sudo journalctl -u plexus-ci-runner --no-pager -o short-iso | tail -15; echo "-- plexus-ci"; sudo journalctl -t plexus-ci --no-pager -o short-iso | tail -5'
12037
- ]);
12038
- ctx.say(out.replace(/^/gm, " "));
12039
- } catch (error) {
12040
- ctx.say(` no SSH to ${ip}: ${error instanceof ExecError ? error.stderr.trim() : String(error)}`);
12414
+ async function keep(ctx, exo, options, id, labels, now) {
12415
+ const { label } = options;
12416
+ const stopped = await exo.stopInstance(id).then(
12417
+ () => true,
12418
+ () => false
12419
+ );
12420
+ if (!stopped) ctx.say(` stop-instance refused for ${id}; the instance may delete itself`);
12421
+ const marked = await exo.updateInstance(id, `${label}-kept`, { ...labels, [KEPT_LABEL]: iso(now) }).then(
12422
+ () => true,
12423
+ () => false
12424
+ );
12425
+ if (!marked) ctx.say(` update-instance refused for ${id}; unmarked, the cleanup will delete it`);
12426
+ ctx.say(`kept ${label} (${id}) as ${label}-kept${stopped ? ", stopped" : ""}`);
12427
+ if (!options.smtp) return;
12428
+ const mail = keptMail(options, id);
12429
+ await sendMail(ctx, options.smtp, mail.subject, mail.text, now).catch(
12430
+ () => ctx.say(` the mail about ${id} could not be sent`)
12431
+ );
12432
+ }
12433
+ async function createInstances(ctx, exo, github, legs, shared) {
12434
+ const launches = await Promise.allSettled(legs.map((leg) => launch(ctx, exo, github, { ...shared, ...leg })));
12435
+ const failed = launches.filter((l) => l.status === "rejected");
12436
+ if (failed.length > 0) {
12437
+ for (const f of failed) ctx.say(`\u2717 ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`);
12438
+ throw new Error(`${failed.length} launch(es) failed`);
12041
12439
  }
12440
+ return launches.flatMap((l) => l.status === "fulfilled" ? [l.value] : []);
12042
12441
  }
12043
12442
 
12044
- // src/core/mail.ts
12045
- import { tmpdir as tmpdir2 } from "node:os";
12046
- import { join as join3 } from "node:path";
12047
- function smtpFromEnv(env = process.env) {
12048
- const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, MAIL_FROM, MAIL_TO } = env;
12049
- if (!SMTP_HOST || !SMTP_USER || !SMTP_PASSWORD || !MAIL_FROM || !MAIL_TO) return void 0;
12050
- return {
12051
- host: SMTP_HOST,
12052
- port: SMTP_PORT || "587",
12053
- user: SMTP_USER,
12054
- password: SMTP_PASSWORD,
12055
- from: MAIL_FROM,
12056
- to: MAIL_TO
12057
- };
12443
+ // src/core/sweepInstances.ts
12444
+ var REMIND_AFTER_DAYS = 7;
12445
+ var DELETE_KEPT_AFTER_DAYS = 14;
12446
+ var DAY = 864e5;
12447
+ function verdictOn(instance, now) {
12448
+ const kept = instance.labels[KEPT_LABEL];
12449
+ if (kept !== void 0) {
12450
+ const days = (now.getTime() - Date.parse(kept)) / DAY;
12451
+ if (days >= DELETE_KEPT_AFTER_DAYS)
12452
+ return { action: "remove", reason: `kept since ${kept}, ${DELETE_KEPT_AFTER_DAYS} days` };
12453
+ if (days >= REMIND_AFTER_DAYS && days < REMIND_AFTER_DAYS + 1) return { action: "remind" };
12454
+ return { action: "stay" };
12455
+ }
12456
+ const reasons = [];
12457
+ if (instance.state === "stopped") reasons.push("stopped");
12458
+ const deadline2 = instance.labels["death-deadline"];
12459
+ if (deadline2 && deadline2 < iso(now)) reasons.push(`past its deadline ${deadline2}`);
12460
+ return reasons.length > 0 ? { action: "remove", reason: reasons.join(", ") } : { action: "stay" };
12058
12461
  }
12059
- function message(smtp, subject, text, date) {
12060
- const body2 = text.replaceAll("\r\n", "\n").replaceAll("\n", "\r\n");
12061
- return `From: plexus-ci <${smtp.from}>\r
12062
- To: ${smtp.to}\r
12063
- Subject: ${subject}\r
12064
- Date: ${date.toUTCString()}\r
12065
- \r
12066
- ${body2}\r
12462
+ async function sweepInstances(ctx, exo, options = {}) {
12463
+ const now = options.now ?? /* @__PURE__ */ new Date();
12464
+ const result = { removed: [], reminded: [], stayed: [] };
12465
+ for (const i of await exo.listInstances()) {
12466
+ if (i.labels["plexus-ci"] !== "true") continue;
12467
+ const verdict = verdictOn(i, now);
12468
+ if (verdict.action === "remove") {
12469
+ ctx.say(`${i.name} (${i.id}): ${verdict.reason}`);
12470
+ await exo.deleteInstance(i.id);
12471
+ result.removed.push({ name: i.name, reason: verdict.reason });
12472
+ continue;
12473
+ }
12474
+ const kept = i.labels[KEPT_LABEL];
12475
+ ctx.say(
12476
+ `${i.name} (${i.id}): ${i.state}, ${kept ? `kept since ${kept}` : `until ${i.labels["death-deadline"] ?? "-"}`}, stays`
12477
+ );
12478
+ result.stayed.push(i.name);
12479
+ if (verdict.action === "remind") result.reminded.push(`${i.name} (${i.id}), kept since ${kept}`);
12480
+ }
12481
+ if (options.smtp && result.removed.length > 0) {
12482
+ const text = `The daily sweep removed ${result.removed.length} plexus-ci instance(s) in ${exo.zone} that the other layers left behind:
12483
+
12484
+ ${result.removed.map((r) => `${r.name} (${r.reason})`).join("\n")}
12067
12485
  `;
12068
- }
12069
- async function sendMail(ctx, smtp, subject, text, date = /* @__PURE__ */ new Date()) {
12070
- const scheme = smtp.port === "465" || smtp.port === "2465" ? "smtps" : "smtp";
12071
- const file = join3(tmpdir2(), `plx-ci-mail-${process.pid}.eml`);
12072
- const config = [
12073
- `url = "${scheme}://${smtp.host}:${smtp.port}"`,
12074
- "ssl-reqd",
12075
- "silent",
12076
- "show-error",
12077
- `mail-from = "${smtp.from}"`,
12078
- `mail-rcpt = "${smtp.to}"`,
12079
- `user = ${JSON.stringify(`${smtp.user}:${smtp.password}`)}`,
12080
- `upload-file = ${JSON.stringify(file)}`
12081
- ].join("\n");
12082
- await ctx.apply([write(file, message(smtp, subject, text, date)), run("curl", ["-K", "-"], { input: config })]);
12486
+ await sendMail(ctx, options.smtp, "plexus-ci: the sweep removed instances", text, now);
12487
+ }
12488
+ if (options.smtp && result.reminded.length > 0) {
12489
+ const text = `These plexus-ci instance(s) in ${exo.zone} have been kept for ${REMIND_AFTER_DAYS} days after a missed boot, stopped, their disks billed. The sweep deletes each ${DELETE_KEPT_AFTER_DAYS} days after it was kept:
12490
+
12491
+ ${result.reminded.join("\n")}
12492
+ `;
12493
+ await sendMail(ctx, options.smtp, "plexus-ci: kept instances are still there", text, now);
12494
+ }
12495
+ ctx.say(`sweep-instances: ${result.removed.length} removed, ${result.reminded.length} reminded of`);
12496
+ return result;
12083
12497
  }
12084
12498
 
12085
- // src/commands/launch.ts
12499
+ // src/commands/hybrid.ts
12086
12500
  function launchOptionsFromEnv(env = process.env) {
12087
12501
  const minutes = (name) => Number(need(name, env));
12088
12502
  return {
@@ -12104,441 +12518,302 @@ function launchOptionsFromEnv(env = process.env) {
12104
12518
  securityGroup: DEFAULTS.securityGroup,
12105
12519
  sshKey: DEFAULTS.sshKey,
12106
12520
  onlineSeconds: Number(env.ONLINE_SECONDS || DEFAULTS.onlineSeconds),
12107
- tries: Number(env.LAUNCH_TRIES || DEFAULTS.tries),
12108
- keepMissed: env.KEEP_MISSED || void 0,
12109
- diagSshKey: env.DIAG_SSH_KEY || void 0
12521
+ tries: DEFAULTS.tries
12110
12522
  };
12111
12523
  }
12112
- function register3(program2) {
12113
- program2.command("launch").description("launch one throwaway runner for one job: JIT config, instance, wait until the runner is online").argument("<kind>", "build, test, dummy, or bare (the probe\u2019s user-data)").argument("<label>", "the one runner label the job asks for").argument("[app]", "the app, for the instance\u2019s labels").action(async (kind, label, app, _opts, command) => {
12524
+ function instanceLegs(matrix, tests = "[]") {
12525
+ const builds = JSON.parse(matrix);
12526
+ const testLegs = JSON.parse(tests);
12527
+ return [
12528
+ ...builds.map((l) => ({ kind: "build", label: l.runner, app: l.app })),
12529
+ ...testLegs.map((l) => ({ kind: "test", label: l.runner, app: l.app }))
12530
+ ];
12531
+ }
12532
+ function register2(program2) {
12533
+ const hybrid = program2.command("hybrid").description("the hybrid strategy\u2019s own: the throwaway instances its large jobs run on");
12534
+ hybrid.command("create-instances").description(
12535
+ "create one instance per build and test leg of the plan and wait for each runner; a miss is kept, stopped, mailed about and replaced once"
12536
+ ).argument("<matrix>", "the plan\u2019s build matrix, JSON").argument("[tests]", "the plan\u2019s test matrix, JSON").action(async (matrix, tests, _opts, command) => {
12114
12537
  const ctx = contextOf(command);
12115
12538
  const github = githubOf(ctx);
12116
12539
  if (!github) throw new Error("RUNNER_TOKEN is empty");
12117
- const result = await launch(ctx, exoOf(ctx), github, { ...launchOptionsFromEnv(), kind, label, app });
12118
- ctx.result(result, (r) => `${r.id} ${r.label} ${r.createdAt}`);
12119
- if (process.env.GITHUB_OUTPUT)
12120
- appendFileSync2(
12121
- process.env.GITHUB_OUTPUT,
12122
- `id=${result.id}
12123
- label=${result.label}
12124
- created=${result.createdAt}
12125
- `
12126
- );
12540
+ const legs = instanceLegs(matrix, tests);
12541
+ const result = await createInstances(ctx, exoOf(ctx), github, legs, launchOptionsFromEnv());
12542
+ ctx.result(result, (r) => r.map((l) => `${l.id} ${l.label} ${l.createdAt}`).join("\n"));
12543
+ });
12544
+ hybrid.command("destroy-instances").description(
12545
+ "delete every instance this run created, kept ones excepted, and the runner registrations it never used"
12546
+ ).action(async (_opts, command) => {
12547
+ const ctx = contextOf(command);
12548
+ const result = await destroyInstances(ctx, exoOf(ctx), githubOf(ctx), runIdentity());
12549
+ ctx.result(result, (r) => [...r.instances, ...r.registrations.map((n) => `registration ${n}`)].join("\n"));
12550
+ });
12551
+ hybrid.command("sweep-instances").description(
12552
+ "delete every instance that is stopped or past its deadline and mail about it; a kept one goes after fourteen days"
12553
+ ).action(async (_opts, command) => {
12554
+ const ctx = contextOf(command);
12555
+ const result = await sweepInstances(ctx, exoOf(ctx), { smtp: smtpFromEnv() });
12556
+ ctx.result(result, (r) => r.removed.map((x) => `${x.name} (${x.reason})`).join("\n"));
12127
12557
  });
12128
12558
  }
12129
12559
 
12130
12560
  // src/commands/plan.ts
12131
- import { appendFileSync as appendFileSync3 } from "node:fs";
12561
+ import { appendFileSync as appendFileSync2 } from "node:fs";
12132
12562
  import { resolve as resolve2 } from "node:path";
12133
-
12134
- // src/core/plan.ts
12135
- var NONEXISTENT = "<NONEXISTENT>";
12136
- function hashesOf(dryRun, scope) {
12137
- const data = JSON.parse(dryRun);
12138
- const tasks = (data.tasks ?? []).filter((t) => t.package.startsWith(`${scope}/`) && t.command !== NONEXISTENT);
12139
- const app = (t) => t.package.slice(scope.length + 1);
12140
- return tasks.filter((t) => t.taskId.endsWith("#build")).map((b) => {
12141
- const test2 = tasks.find((t) => t.package === b.package && t.taskId.endsWith("#test"));
12142
- return { app: app(b), hash: b.hash, ...test2 ? { test: test2.hash } : {} };
12143
- });
12144
- }
12145
- async function readHashes(ctx, dir, scope) {
12146
- const pkg = JSON.parse(await ctx.readFile(`${dir}/package.json`));
12147
- const turbo = pkg.devDependencies?.turbo ?? "latest";
12148
- const out = await ctx.read("npx", ["--yes", `turbo@${turbo}`, "run", "build", "test", "--dry-run=json"], {
12149
- cwd: dir
12150
- });
12151
- return hashesOf(out, scope);
12152
- }
12153
- function legsOf(apps, hashes, mode, runId, attempt) {
12154
- return apps.flatMap((app) => {
12155
- const hash = hashes.find((h) => h.app === app)?.hash;
12156
- if (!hash) throw new UsageError(`no build hash for ${app}; is it an app package of the workspace?`);
12157
- const copies = mode === "repro" ? 2 : 1;
12158
- return Array.from({ length: copies }, (_, i) => {
12159
- const n = i + 1;
12160
- const base = `exo-build-${runId}-${app}-${attempt}`;
12161
- return { app, hash, label: mode === "repro" ? `${base}-${n}` : base, n };
12563
+ function register3(program2) {
12564
+ withStrategy(program2.command("plan")).description(
12565
+ "compute the build and test hashes, look them up in the registry, and print the builds, tests and deploys the run needs"
12566
+ ).argument("[apps...]", "the apps (default: every directory under apps/)").option(
12567
+ "--mode <mode>",
12568
+ "build (skip what the registry holds), force (build all), repro (two no-cache builds per app)",
12569
+ "build"
12570
+ ).option("--test", "plan the tests too, for the apps with a test task").option("--deploy", "plan the deploys too: the environment and each app\u2019s target in it").option("--environment <name>", "the environment to release into (default: from the branch, main or develop)").option("--inventory <dir>", "the platform repo\u2019s inventory, for --deploy", "platform/inventory").option("-C, --dir <dir>", "the checkout to read", ".").action(async (apps, opts, command) => {
12571
+ const ctx = contextOf(command);
12572
+ const s = settings(opts);
12573
+ const dir = resolve2(opts.dir);
12574
+ const env = process.env;
12575
+ const all = apps.length > 0 ? apps : (await ctx.readDir(`${dir}/apps`)).filter((e) => e.directory).map((e) => e.name).sort();
12576
+ const environment = opts.deploy ? environmentOf(env.GITHUB_EVENT_NAME ?? "", env.GITHUB_REF_NAME ?? "", opts.environment) : "";
12577
+ const result = await plan(ctx, {
12578
+ apps: all,
12579
+ mode: opts.mode,
12580
+ tests: opts.test ?? false,
12581
+ hashes: await readHashes(ctx, dir, all),
12582
+ strategy: s.strategy,
12583
+ registry: registryFor(s),
12584
+ // Only hybrid names anything after the run: its instances.
12585
+ run: s.strategy === "hybrid" ? { runId: need("GITHUB_RUN_ID"), attempt: env.GITHUB_RUN_ATTEMPT || "1" } : { runId: env.GITHUB_RUN_ID || "0", attempt: env.GITHUB_RUN_ATTEMPT || "1" },
12586
+ deploy: environment ? {
12587
+ environment,
12588
+ targets: targetsIn(await readDeployTargets(ctx, resolve2(opts.inventory)), environment),
12589
+ prefix: imagePrefix(),
12590
+ sha: need("GITHUB_SHA")
12591
+ } : void 0
12162
12592
  });
12593
+ ctx.result(
12594
+ result,
12595
+ (r) => [
12596
+ ...r.legs.map((l) => `build ${l.app} ${l.hash} ${l.runner}`),
12597
+ ...r.tests.map((l) => `test ${l.app} ${l.hash} ${l.runner}`),
12598
+ ...r.deploys.map((l) => `deploy ${l.app} ${l.host} ${l.image}`)
12599
+ ].join("\n")
12600
+ );
12601
+ if (env.GITHUB_OUTPUT)
12602
+ appendFileSync2(
12603
+ env.GITHUB_OUTPUT,
12604
+ [
12605
+ `matrix=${JSON.stringify(result.legs)}`,
12606
+ `tests=${JSON.stringify(result.tests)}`,
12607
+ `apps=${JSON.stringify(result.apps)}`,
12608
+ `deploys=${JSON.stringify(result.deploys)}`,
12609
+ `environment=${result.environment}`,
12610
+ ""
12611
+ ].join("\n")
12612
+ );
12163
12613
  });
12164
12614
  }
12165
- function testLegsOf(apps, hashes, runId, attempt) {
12166
- return apps.flatMap((app) => {
12167
- const h = hashes.find((x) => x.app === app);
12168
- if (!h?.test) return [];
12169
- return [{ app, hash: h.test, build: h.hash, label: `exo-test-${runId}-${app}-${attempt}` }];
12170
- });
12171
- }
12172
- async function plan(ctx, exo, github, options) {
12173
- const { runId, attempt } = options.launch.run;
12174
- const result = {
12175
- legs: [],
12176
- tests: [],
12177
- apps: options.apps.map((app) => {
12178
- const h = options.hashes.find((x) => x.app === app);
12179
- if (!h) throw new UsageError(`no build hash for ${app}; is it an app package of the workspace?`);
12180
- return h;
12181
- }),
12182
- skipped: [],
12183
- skippedTests: []
12184
- };
12185
- const builds = [];
12186
- for (const leg of legsOf(options.apps, options.hashes, options.mode, runId, attempt)) {
12187
- if (options.mode === "build") {
12188
- const port = await tagExists(ctx, leg.app, `build-${leg.hash}`);
12189
- if (port !== void 0) {
12190
- ctx.say(`${leg.app}: build-${leg.hash} exists in the registry on :${port}, nothing to build`);
12191
- result.skipped.push({ app: leg.app, hash: leg.hash, port });
12192
- continue;
12193
- }
12194
- }
12195
- builds.push(leg);
12196
- }
12197
- const tests = [];
12198
- if (options.tests && options.mode !== "repro") {
12199
- for (const leg of testLegsOf(options.apps, options.hashes, runId, attempt)) {
12200
- if (options.mode === "build") {
12201
- const port = await tagExists(ctx, leg.app, `test-${leg.hash}`);
12202
- if (port !== void 0) {
12203
- ctx.say(`${leg.app}: test-${leg.hash} exists in the registry on :${port}, nothing to test`);
12204
- result.skippedTests.push({ app: leg.app, hash: leg.hash, port });
12205
- continue;
12206
- }
12207
- }
12208
- tests.push(leg);
12209
- }
12210
- }
12211
- const launches = await Promise.allSettled([
12212
- ...builds.map(
12213
- (leg) => launch(ctx, exo, github, { ...options.launch, kind: "build", label: leg.label, app: leg.app })
12214
- ),
12215
- ...tests.map(
12216
- (leg) => launch(ctx, exo, github, { ...options.launch, kind: "test", label: leg.label, app: leg.app })
12217
- )
12218
- ]);
12219
- const failed = launches.filter((l) => l.status === "rejected");
12220
- for (const l of launches) if (l.status === "fulfilled" && l.value.kept) result.kept = l.value.kept;
12221
- if (failed.length > 0) {
12222
- for (const f of failed) ctx.say(`\u2717 ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`);
12223
- throw new Error(`${failed.length} launch(es) failed`);
12224
- }
12225
- result.legs = builds;
12226
- result.tests = tests;
12227
- return result;
12228
- }
12229
- async function report(ctx, legs, mode, runId) {
12230
- const apps = [...new Set(legs.map((l) => l.app))].sort();
12231
- const out = [];
12232
- for (const app of apps) {
12233
- const hash = legs.find((l) => l.app === app)?.hash ?? "";
12234
- const c = { app, hash, digest: await digest(ctx, app, `build-${hash}`) };
12235
- ctx.say(`${app} build-${hash}: ${c.digest ?? "missing"}`);
12236
- if (mode === "repro") {
12237
- const [one, two] = await Promise.all([
12238
- digest(ctx, app, `repro-${runId}-1`),
12239
- digest(ctx, app, `repro-${runId}-2`)
12240
- ]);
12241
- const identical = one !== void 0 && one === two;
12242
- const [l1, l2] = identical ? [[], []] : await Promise.all([layers(ctx, app, `repro-${runId}-1`), layers(ctx, app, `repro-${runId}-2`)]);
12243
- const differing = [];
12244
- const a = l1 ?? [];
12245
- const b = l2 ?? [];
12246
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
12247
- if (a[i]?.digest !== b[i]?.digest)
12248
- differing.push(
12249
- `layer ${i + 1}: ${a[i]?.digest ?? "-"} (${a[i]?.size ?? "-"}) vs ${b[i]?.digest ?? "-"} (${b[i]?.size ?? "-"})`
12250
- );
12251
- }
12252
- c.repro = { one, two, identical, layers: a.length, same: a.length - differing.length, differing };
12253
- if (identical) ctx.say(` reproducible: both builds are ${one}`);
12254
- else {
12255
- ctx.say(
12256
- ` not identical: ${one ?? "-"} vs ${two ?? "-"}; layers: ${a.length} in each, ${a.length - differing.length} identical`
12257
- );
12258
- for (const d of differing) ctx.say(` ${d}`);
12259
- }
12260
- }
12261
- out.push(c);
12262
- }
12263
- return out;
12615
+
12616
+ // src/commands/publish.ts
12617
+ import { appendFileSync as appendFileSync3 } from "node:fs";
12618
+
12619
+ // src/core/publish.ts
12620
+ async function publish(ctx, options) {
12621
+ const { app, build: build2, test: test2, sha, registry } = options;
12622
+ const image = imageName(options.prefix, app);
12623
+ const target = `${image}:build-${build2}`;
12624
+ const source = test2 ? `test-${test2}` : `build-${build2}`;
12625
+ const missing = `${app}:${source} is in no registry here; ${test2 ? "the tests did not pass on this build" : "the build never ran"}`;
12626
+ if (registry.kind === "ghcr") {
12627
+ const ref = `${image}:${source}`;
12628
+ if (await craneDigest(ctx, ref) === void 0 && !ctx.dryRun) throw new Error(missing);
12629
+ ctx.say(`publish: ${ref} as ${sha}`);
12630
+ await ctx.apply([run("crane", ["tag", ref, sha])]);
12631
+ return { app, image, copied: false, tags: [`build-${build2}`, ...test2 ? [`test-${test2}`] : [], sha] };
12632
+ }
12633
+ const copied = await craneDigest(ctx, target) === void 0;
12634
+ if (copied) {
12635
+ const from = await findTag(ctx, registry, app, source);
12636
+ if (from === void 0 && !ctx.dryRun) throw new Error(missing);
12637
+ ctx.say(`publish: ${app}:${source} -> ${target}`);
12638
+ await ctx.apply([run("crane", ["copy", "--insecure", from ?? `localhost:5000/${app}:${source}`, target])]);
12639
+ } else ctx.say(`publish: ${target} is there already, tagging only`);
12640
+ const tags = [sha, ...test2 ? [`test-${test2}`] : []];
12641
+ await ctx.apply(tags.map((t) => run("crane", ["tag", target, t])));
12642
+ return { app, image, copied, tags: [`build-${build2}`, ...tags] };
12264
12643
  }
12265
12644
 
12266
- // src/commands/plan.ts
12645
+ // src/commands/publish.ts
12267
12646
  function register4(program2) {
12268
- program2.command("plan").description(
12269
- "compute the build and test hashes, look them up in the registry, and launch one runner per image to build or test"
12270
- ).argument("[apps...]", "the apps (default: every directory under apps/)").requiredOption(
12271
- "--mode <mode>",
12272
- "build (skip what the registry holds), force (build all), repro (two no-cache builds per app)"
12273
- ).option("--tests", "plan test runners too, for the apps with a test task").option("--scope <scope>", "the npm scope of the workspace packages", "@damage-ms").option("-C, --dir <dir>", "the checkout to read", ".").action(
12274
- async (apps, opts, command) => {
12647
+ withStrategy(program2.command("publish")).description("publish one app\u2019s tested image as <sha> in the forge\u2019s registry, beside build-<hash> and test-<hash>").argument("<app>", "the app").argument("<build>", "Turbo\u2019s hash of the app\u2019s build task").argument("<sha>", "the commit, the tag a deploy takes").option("--test <hash>", "Turbo\u2019s hash of the app\u2019s test task; the image is read from test-<hash>").action(
12648
+ async (app, build2, sha, opts, command) => {
12275
12649
  const ctx = contextOf(command);
12276
- const dir = resolve2(opts.dir);
12277
- const github = githubOf(ctx);
12278
- if (!github) throw new Error("RUNNER_TOKEN is empty");
12279
- const all = apps.length > 0 ? apps : (await ctx.readDir(`${dir}/apps`)).filter((e) => e.directory).map((e) => e.name).sort();
12280
- const result = await plan(ctx, exoOf(ctx), github, {
12281
- apps: all,
12282
- mode: opts.mode,
12283
- tests: opts.tests ?? false,
12284
- hashes: await readHashes(ctx, dir, opts.scope),
12285
- launch: launchOptionsFromEnv()
12650
+ const result = await publish(ctx, {
12651
+ app,
12652
+ build: build2,
12653
+ test: opts.test || void 0,
12654
+ sha,
12655
+ prefix: imagePrefix(),
12656
+ registry: registryFor(settings(opts))
12286
12657
  });
12287
- ctx.result(
12288
- result,
12289
- (r) => [
12290
- ...r.legs.map((l) => `build ${l.app} ${l.hash} ${l.label}`),
12291
- ...r.tests.map((l) => `test ${l.app} ${l.hash} ${l.label}`)
12292
- ].join("\n")
12293
- );
12294
- if (process.env.GITHUB_OUTPUT)
12295
- appendFileSync3(
12296
- process.env.GITHUB_OUTPUT,
12297
- `matrix=${JSON.stringify(result.legs)}
12298
- tests=${JSON.stringify(result.tests)}
12299
- apps=${JSON.stringify(result.apps)}
12300
- kept=${result.kept ?? ""}
12301
- `
12302
- );
12658
+ ctx.result(result, (r) => `${r.image}:${sha}${r.copied ? "" : " (was there)"}`);
12659
+ if (process.env.GITHUB_OUTPUT) appendFileSync3(process.env.GITHUB_OUTPUT, `image=${result.image}:${sha}
12660
+ `);
12303
12661
  }
12304
12662
  );
12305
12663
  }
12306
12664
 
12307
- // src/core/probe.ts
12308
- async function probe(ctx, exo, options) {
12309
- const now = options.now ?? (() => /* @__PURE__ */ new Date());
12310
- const [templateId, instanceTypeId, securityGroupId] = await Promise.all([
12311
- exo.templateId(options.template),
12312
- exo.instanceTypeId(options.instanceType),
12313
- exo.securityGroupId(DEFAULTS.securityGroup)
12314
- ]);
12315
- const text = render2(BARE_USER_DATA, { POWEROFF_MINUTES: "60" });
12316
- const userData = options.plain ? Buffer.from(text).toString("base64") : encodeUserData(text);
12317
- const boots = [];
12318
- const started = now();
12319
- for (let i = 1; i <= options.boots; i++) {
12320
- const name = `probe-${iso(now()).slice(11, 19).replaceAll(":", "")}-${i}`;
12321
- const request = {
12322
- name,
12323
- templateId,
12324
- instanceTypeId,
12325
- securityGroupId,
12326
- sshKey: DEFAULTS.sshKey,
12327
- diskSize: 50,
12328
- userDataBase64: userData,
12329
- labels: { "plexus-ci": "true", kind: "probe", "death-deadline": deadline(now(), 90) }
12330
- };
12331
- const op = await exo.createInstance(request);
12332
- boots.push({ name, id: op?.reference, result: op ? "pending" : "dry run", ok: false });
12333
- }
12334
- if (ctx.dryRun) return boots;
12335
- const pending = () => boots.filter((b) => b.result === "pending");
12336
- while (pending().length > 0 && (now().getTime() - started.getTime()) / 1e3 < options.waitSeconds) {
12337
- const instances = await exo.listInstances();
12338
- for (const b of pending()) {
12339
- const i = instances.find((x) => x.id === b.id);
12340
- if (!i?.publicIp) continue;
12341
- b.ip = i.publicIp;
12342
- const ok2 = await ctx.read("ssh", sshArgs(options.sshKey, i.publicIp, "true")).then(
12343
- () => true,
12344
- () => false
12345
- );
12346
- if (ok2) {
12347
- b.result = `ok ${Math.round((now().getTime() - started.getTime()) / 1e3)}s`;
12348
- b.ok = true;
12349
- }
12665
+ // src/commands/registry.ts
12666
+ import { resolve as resolve3 } from "node:path";
12667
+
12668
+ // src/core/ghcr.ts
12669
+ var PRUNE_AFTER_DAYS = 30;
12670
+ var CANDIDATE_TAG = /^(build|test)-[0-9a-f]+$/;
12671
+ function prunable(versions, now, days = PRUNE_AFTER_DAYS) {
12672
+ const before = now.getTime() - days * 864e5;
12673
+ return versions.filter(
12674
+ (v) => v.tags.length > 0 && v.tags.every((t) => CANDIDATE_TAG.test(t)) && Date.parse(v.createdAt) < before
12675
+ );
12676
+ }
12677
+ var Packages = class {
12678
+ ctx;
12679
+ org;
12680
+ token;
12681
+ constructor(ctx, org, token) {
12682
+ this.ctx = ctx;
12683
+ this.org = org;
12684
+ this.token = token;
12685
+ }
12686
+ config(method, path2) {
12687
+ return `${[
12688
+ `url = "https://api.github.com/orgs/${this.org}/packages${path2}"`,
12689
+ `request = "${method}"`,
12690
+ "fail-with-body",
12691
+ "silent",
12692
+ "show-error",
12693
+ `header = "Authorization: Bearer ${this.token}"`,
12694
+ 'header = "Accept: application/vnd.github+json"',
12695
+ 'header = "X-GitHub-Api-Version: 2022-11-28"'
12696
+ ].join("\n")}
12697
+ `;
12698
+ }
12699
+ /** Every page of a list, a hundred entries each. */
12700
+ async pages(path2) {
12701
+ const all = [];
12702
+ for (let page = 1; ; page++) {
12703
+ const sep = path2.includes("?") ? "&" : "?";
12704
+ const out = await this.ctx.read("curl", ["-K", "-"], {
12705
+ input: this.config("GET", `${path2}${sep}per_page=100&page=${page}`)
12706
+ });
12707
+ const items = JSON.parse(out);
12708
+ all.push(...items);
12709
+ if (items.length < 100) return all;
12350
12710
  }
12351
- if (pending().length > 0) await sleep(1e4);
12352
12711
  }
12353
- for (const b of boots) {
12354
- if (b.result === "pending") b.result = b.ip ? "unreachable" : "no ip";
12355
- ctx.say(
12356
- `${options.template} ${options.instanceType} boot ${b.name} (${b.id ?? "no id"}, ${b.ip ?? "no ip"}): ${b.result}`
12712
+ async versions(name) {
12713
+ const raw = await this.pages(`/container/${encodeURIComponent(name)}/versions`);
12714
+ return raw.map((v) => ({ id: v.id, createdAt: v.created_at, tags: v.metadata?.container?.tags ?? [] }));
12715
+ }
12716
+ async deleteVersion(name, id) {
12717
+ await this.ctx.effect(
12718
+ run("curl", ["-K", "-"], {
12719
+ input: this.config("DELETE", `/container/${encodeURIComponent(name)}/versions/${id}`)
12720
+ })
12357
12721
  );
12358
- if (b.id && (b.ok || !options.keep)) await exo.deleteInstance(b.id);
12359
- else if (b.id) ctx.say(` kept ${b.name} for inspection`);
12360
12722
  }
12361
- const ok = boots.filter((b) => b.ok).length;
12723
+ };
12724
+ function packageOf(prefix, app) {
12725
+ const path2 = prefix.toLowerCase().replace(/\/$/, "").split("/").slice(2);
12726
+ return [...path2, app].join("/");
12727
+ }
12728
+ async function prune(ctx, packages, names, now = /* @__PURE__ */ new Date()) {
12729
+ const pruned = [];
12730
+ for (const name of names) {
12731
+ let versions;
12732
+ try {
12733
+ versions = await packages.versions(name);
12734
+ } catch (error) {
12735
+ if (!(error instanceof ExecError)) throw error;
12736
+ ctx.say(`rotate: no package ${name} in ghcr.io/${packages.org}`);
12737
+ continue;
12738
+ }
12739
+ for (const v of prunable(versions, now)) {
12740
+ ctx.say(`rotate: ${name} ${v.tags.join(" ")} (${v.createdAt}) is older than ${PRUNE_AFTER_DAYS} days`);
12741
+ await packages.deleteVersion(name, v.id);
12742
+ pruned.push({ package: name, id: v.id, tags: v.tags });
12743
+ }
12744
+ }
12362
12745
  ctx.say(
12363
- `== ${options.template} on ${options.instanceType}: ${ok} ok, ${boots.length - ok} failed of ${boots.length}`
12746
+ `rotate: ${pruned.length} candidate version(s) pruned from ${names.length} package(s) of ghcr.io/${packages.org}`
12364
12747
  );
12365
- return boots;
12366
- }
12367
-
12368
- // src/commands/probe.ts
12369
- function register5(program2) {
12370
- program2.command("probe").description("boot bare instances and judge each by an SSH login; a failed one is kept with --keep").option("--boots <n>", "instances to boot at once", "10").option("--template <name>", "the template", DEFAULTS.template).option("--type <type>", "the instance type", DEFAULTS.instanceType).option("--wait <seconds>", "seconds to wait for the verdicts", "180").option("--ssh-key <path>", "the private half of the key the instances get", `${process.env.HOME}/.ssh/deploy`).option("--keep", "keep a failed instance for inspection").option("--plain", "send the user-data as plain base64 instead of gzip").action(async (opts, command) => {
12371
- const ctx = contextOf(command);
12372
- const boots = await probe(ctx, exoOf(ctx), {
12373
- template: opts.template,
12374
- instanceType: opts.type,
12375
- zone: zoneOf(),
12376
- boots: Number(opts.boots),
12377
- waitSeconds: Number(opts.wait),
12378
- sshKey: opts.sshKey,
12379
- keep: opts.keep,
12380
- plain: opts.plain
12381
- });
12382
- ctx.result(boots, (b) => b.map((x) => `${x.name}: ${x.result}`).join("\n"));
12383
- if (boots.some((b) => !b.ok)) process.exitCode = 1;
12384
- });
12748
+ return pruned;
12385
12749
  }
12386
12750
 
12387
12751
  // src/commands/registry.ts
12388
- function register6(program2) {
12389
- const registry = program2.command("registry").description(
12390
- "start the registry sidecars: :5000 on this month\u2019s bucket read-write, :5001 on the previous month\u2019s read-only; nothing where :5000 answers"
12391
- ).action(async (_opts, command) => {
12752
+ function register5(program2) {
12753
+ const registry = withStrategy(program2.command("registry")).description(
12754
+ "make the candidates\u2019 registry reachable here: the pair on localhost (started unless :5000 answers), or a login check for the forge\u2019s"
12755
+ ).action(async (opts, command) => {
12392
12756
  const ctx = contextOf(command);
12393
- const result = await startSidecars(ctx, storageFromEnv(), ownerOf(need("GITHUB_REPOSITORY")), zoneOf());
12757
+ const s = settings(opts);
12758
+ if (s.registry === "ghcr") {
12759
+ const host = imagePrefix().split("/")[0] ?? "ghcr.io";
12760
+ if (!await loggedIn(ctx, host) && !ctx.dryRun)
12761
+ throw new Error(`no login for ${host} here; the job logs in before it asks for the registry`);
12762
+ ctx.result(`registry: ${imagePrefix()}, logged in`);
12763
+ return;
12764
+ }
12765
+ const result = await startSidecars(ctx, storageFromEnv(), ownerOf(need("GITHUB_REPOSITORY")));
12394
12766
  ctx.result(
12395
12767
  result,
12396
12768
  (r) => `:5000 ${r.current}
12397
12769
  :5001 ${r.previous}${r.previousAnswers ? "" : " (not answering)"}`
12398
12770
  );
12399
12771
  });
12400
- registry.command("exists").description("exit 0 when either sidecar holds the tag").argument("<app>").argument("<tag>").action(async (app, tag, _opts, command) => {
12772
+ withStrategy(registry.command("exists")).description("exit 0 when the registry holds the tag").argument("<app>").argument("<tag>").action(async (app, tag, opts, command) => {
12401
12773
  const ctx = contextOf(command);
12402
- const port = await tagExists(ctx, app, tag);
12403
- if (port === void 0) {
12774
+ const ref = await findTag(ctx, registryFor(settings(opts)), app, tag);
12775
+ if (ref === void 0) {
12404
12776
  ctx.say(`${app}:${tag} is in no registry here`);
12405
12777
  process.exitCode = 1;
12406
- } else ctx.result(`${app}:${tag} in the registry on :${port}`);
12778
+ } else ctx.result(ref);
12407
12779
  });
12408
- }
12409
-
12410
- // src/commands/release.ts
12411
- import { appendFileSync as appendFileSync4 } from "node:fs";
12412
-
12413
- // src/core/release.ts
12414
- var imageName = (prefix, app) => `${prefix.toLowerCase().replace(/\/$/, "")}/${app}`;
12415
- async function exists(ctx, ref) {
12416
- try {
12417
- await ctx.read("crane", ["digest", ref]);
12418
- return true;
12419
- } catch {
12420
- return false;
12421
- }
12422
- }
12423
- async function release(ctx, options) {
12424
- const { app, build: build2, test: test2, sha } = options;
12425
- const image = imageName(options.prefix, app);
12426
- const target = `${image}:build-${build2}`;
12427
- const copied = !await exists(ctx, target);
12428
- if (copied) {
12429
- const source = test2 ? `test-${test2}` : `build-${build2}`;
12430
- const port = await tagExists(ctx, app, source);
12431
- if (port === void 0 && !ctx.dryRun)
12432
- throw new Error(
12433
- `${app}:${source} is in no registry here; ${test2 ? "the tests did not pass on this build" : "the build never ran"}`
12434
- );
12435
- ctx.say(`release: ${app}:${source} -> ${target}`);
12436
- await ctx.apply([run("crane", ["copy", "--insecure", `localhost:${port ?? 5e3}/${app}:${source}`, target])]);
12437
- } else ctx.say(`release: ${target} is there already, tagging only`);
12438
- const tags = [sha, ...test2 ? [`test-${test2}`] : []];
12439
- await ctx.apply(tags.map((t) => run("crane", ["tag", target, t])));
12440
- return { app, image, copied, tags: [`build-${build2}`, ...tags] };
12441
- }
12442
-
12443
- // src/commands/release.ts
12444
- function register7(program2) {
12445
- program2.command("release").description("copy one app\u2019s tested image from the sidecar to GHCR as build-<hash>, test-<hash> and <sha>").argument("<app>", "the app").argument("<build>", "Turbo\u2019s hash of the app\u2019s build task").argument("<sha>", "the commit, a tag on the released image").option("--test <hash>", "Turbo\u2019s hash of the app\u2019s test task; the image is read from test-<hash>").action(async (app, build2, sha, opts, command) => {
12780
+ withStrategy(registry.command("rotate")).description(
12781
+ "the retention: this month\u2019s bucket exists and the one from two months ago goes; in the forge\u2019s registry, candidates older than thirty days go"
12782
+ ).argument("[apps...]", "under ghcr: the apps whose packages are pruned (default: every directory under apps/)").option("-C, --dir <dir>", "the checkout to read", ".").action(async (apps, opts, command) => {
12446
12783
  const ctx = contextOf(command);
12447
- const prefix = process.env.IMAGE_PREFIX || `ghcr.io/${ownerOf(need("GITHUB_REPOSITORY"))}`;
12448
- const result = await release(ctx, { app, build: build2, test: opts.test || void 0, sha, prefix });
12449
- ctx.result(result, (r) => `${r.image}:${sha}${r.copied ? "" : " (was there)"}`);
12450
- if (process.env.GITHUB_OUTPUT) appendFileSync4(process.env.GITHUB_OUTPUT, `image=${result.image}:${sha}
12451
- `);
12452
- });
12453
- }
12454
-
12455
- // src/commands/report.ts
12456
- function register8(program2) {
12457
- program2.command("report").description("the digests the registry holds for a run\u2019s matrix, and in repro mode the layer-by-layer comparison").argument("<matrix>", "the plan\u2019s matrix, JSON").requiredOption("--mode <mode>", "build, force or repro").action(async (matrix, opts, command) => {
12458
- const ctx = contextOf(command);
12459
- const legs = JSON.parse(matrix);
12460
- const result = await report(ctx, legs, opts.mode, need("GITHUB_RUN_ID"));
12461
- ctx.result(result, () => "");
12462
- });
12463
- }
12464
-
12465
- // src/core/sweep.ts
12466
- function reasonToRemove(instance, now) {
12467
- const reasons = [];
12468
- if (instance.state === "stopped") reasons.push("stopped");
12469
- const deadline2 = instance.labels["death-deadline"];
12470
- if (deadline2 && deadline2 < now) reasons.push(`past its deadline ${deadline2}`);
12471
- return reasons.length > 0 ? reasons.join(", ") : void 0;
12472
- }
12473
- async function sweep(ctx, exo, options = {}) {
12474
- const now = options.now ?? /* @__PURE__ */ new Date();
12475
- const result = { removed: [], kept: [] };
12476
- for (const i of await exo.listInstances()) {
12477
- if (i.labels["plexus-ci"] !== "true") continue;
12478
- const reason = reasonToRemove(i, iso(now));
12479
- if (reason === void 0) {
12480
- ctx.say(`${i.name} (${i.id}): ${i.state}, until ${i.labels["death-deadline"] ?? "-"}, kept`);
12481
- result.kept.push(i.name);
12482
- continue;
12483
- }
12484
- ctx.say(`${i.name} (${i.id}): ${reason}`);
12485
- await exo.deleteInstance(i.id);
12486
- result.removed.push({ name: i.name, reason });
12487
- }
12488
- if (result.removed.length > 0 && options.smtp) {
12489
- const text = `The daily sweep removed ${result.removed.length} plexus-ci instance(s) in ${exo.zone} that the other layers left behind:
12490
-
12491
- ${result.removed.map((r) => `${r.name} (${r.reason})`).join("\n")}
12492
- `;
12493
- await sendMail(ctx, options.smtp, "plexus-ci: the sweep removed instances", text, now);
12494
- }
12495
- ctx.say(`sweep: ${result.removed.length} removed`);
12496
- if (options.storage && options.owner) {
12497
- const { expired } = registryBuckets(options.owner, now);
12498
- const sos = new Storage(ctx, options.storage.accessKey, options.storage.secretKey);
12499
- if (await sos.bucketExists(expired)) {
12500
- ctx.say(`sweep: the registry bucket ${expired} is two months old`);
12501
- await sos.removeBucket(expired);
12502
- result.bucket = expired;
12784
+ const s = settings(opts);
12785
+ const owner = ownerOf(need("GITHUB_REPOSITORY"));
12786
+ if (s.registry === "ghcr") {
12787
+ const all = apps.length > 0 ? apps : (await ctx.readDir(`${resolve3(opts.dir)}/apps`)).filter((e) => e.directory).map((e) => e.name).sort();
12788
+ const names = all.map((app) => packageOf(imagePrefix(), app));
12789
+ const result2 = await prune(ctx, new Packages(ctx, owner, need("GITHUB_TOKEN")), names);
12790
+ ctx.result(result2, (r) => r.map((v) => `${v.package} ${v.tags.join(" ")}`).join("\n"));
12791
+ return;
12503
12792
  }
12504
- }
12505
- return result;
12506
- }
12507
-
12508
- // src/commands/sweep.ts
12509
- function register9(program2) {
12510
- program2.command("sweep").description(
12511
- "delete every plexus-ci instance that is stopped or past its deadline, mail about it, rotate the registry buckets"
12512
- ).action(async (_opts, command) => {
12513
- const ctx = contextOf(command);
12514
- const repo = process.env.GITHUB_REPOSITORY;
12515
- const result = await sweep(ctx, exoOf(ctx), {
12516
- smtp: smtpFromEnv(),
12517
- storage: storageFromEnv(),
12518
- owner: repo ? ownerOf(repo) : void 0
12519
- });
12793
+ const storage = storageFromEnv();
12794
+ if (!storage)
12795
+ throw new Error("REGISTRY_ENDPOINT, REGISTRY_REGION, REGISTRY_ACCESS_KEY and REGISTRY_SECRET_KEY are needed");
12796
+ const result = await rotateBuckets(ctx, storage, owner);
12520
12797
  ctx.result(
12521
12798
  result,
12522
- (r) => [...r.removed.map((x) => `${x.name} (${x.reason})`), ...r.bucket ? [`bucket ${r.bucket}`] : []].join("\n")
12799
+ (r) => [...r.created ? [`created ${r.created}`] : [], ...r.removed ? [`removed ${r.removed}`] : []].join("\n")
12523
12800
  );
12524
12801
  });
12525
12802
  }
12526
12803
 
12527
- // src/commands/teardown.ts
12528
- function register10(program2) {
12529
- program2.command("teardown").description(
12530
- "delete every instance this run launched, kept ones excepted, and the runner registrations it never used"
12531
- ).action(async (_opts, command) => {
12804
+ // src/commands/report.ts
12805
+ function register6(program2) {
12806
+ withStrategy(program2.command("report")).description("the digests the registry holds for a run\u2019s matrix, and in repro mode the layer-by-layer comparison").argument("<matrix>", "the plan\u2019s matrix, JSON").requiredOption("--mode <mode>", "build, force or repro").action(async (matrix, opts, command) => {
12532
12807
  const ctx = contextOf(command);
12533
- const keep2 = (process.env.KEEP_IDS ?? "").split(/\s+/).filter(Boolean);
12534
- const result = await teardown(ctx, exoOf(ctx), githubOf(ctx), runIdentity(), keep2);
12535
- ctx.result(result, (r) => [...r.instances, ...r.registrations.map((n) => `registration ${n}`)].join("\n"));
12808
+ const legs = JSON.parse(matrix);
12809
+ const result = await report(ctx, registryFor(settings(opts)), legs, opts.mode, need("GITHUB_RUN_ID"));
12810
+ ctx.result(result, () => "");
12536
12811
  });
12537
12812
  }
12538
12813
 
12539
12814
  // src/commands/test.ts
12540
- import { appendFileSync as appendFileSync5 } from "node:fs";
12541
- import { resolve as resolve3 } from "node:path";
12815
+ import { appendFileSync as appendFileSync4 } from "node:fs";
12816
+ import { resolve as resolve4 } from "node:path";
12542
12817
 
12543
12818
  // src/core/test.ts
12544
12819
  import { join as join4 } from "node:path";
@@ -12564,16 +12839,15 @@ function specEnv(port, fromOverride) {
12564
12839
  };
12565
12840
  }
12566
12841
  async function test(ctx, options) {
12567
- const { app, hash, build: build2 } = options;
12842
+ const { app, hash, build: build2, registry } = options;
12568
12843
  const dir = options.dir ?? ".";
12569
12844
  const port = options.port ?? 3e3;
12570
12845
  const appDir = join4(dir, "apps", app);
12571
12846
  const composeDir = join4(appDir, "docker");
12572
12847
  const project = `ci-${app}`;
12573
- const registryPort = await tagExists(ctx, app, `build-${build2}`) ?? (ctx.dryRun ? CURRENT_PORT : void 0);
12574
- if (registryPort === void 0)
12848
+ const image = await findTag(ctx, registry, app, `build-${build2}`) ?? (ctx.dryRun ? candidateRef(registry, app, `build-${build2}`) : void 0);
12849
+ if (image === void 0)
12575
12850
  throw new Error(`${app}:build-${build2} is in no registry here; the build failed or never ran`);
12576
- const image = `localhost:${registryPort}/${app}:build-${build2}`;
12577
12851
  const tag = `test-${hash}`;
12578
12852
  const fromOverride = overrideEnv(await ctx.readFile(join4(composeDir, OVERRIDE)));
12579
12853
  const composeEnv = { PLEXUS_DEPLOYMENT_IMAGE: image, PLEXUS_LOOPBACK_PORT: String(port) };
@@ -12608,48 +12882,39 @@ ${logs}`);
12608
12882
  await ctx.apply([compose(["--profile", "migrate", "down", "--volumes", "--remove-orphans", "--timeout", "10"])]);
12609
12883
  }
12610
12884
  await ctx.apply([
12611
- run("docker", ["buildx", "imagetools", "create", "--tag", `localhost:${CURRENT_PORT}/${app}:${tag}`, image])
12885
+ run("docker", ["buildx", "imagetools", "create", "--tag", candidateRef(registry, app, tag), image])
12612
12886
  ]);
12613
12887
  const result = { app, hash, image, tag, stackSeconds, testSeconds };
12614
12888
  if (!ctx.dryRun) {
12615
- result.digest = await digest(ctx, app, tag);
12889
+ result.digest = await digestOf(ctx, registry, app, tag);
12616
12890
  ctx.say(`test: ${app} ${tag} -> ${result.digest ?? "<no digest>"}`);
12617
12891
  }
12618
12892
  return result;
12619
12893
  }
12620
12894
 
12621
12895
  // src/commands/test.ts
12622
- function register11(program2) {
12623
- program2.command("test").description("run one app\u2019s tests against its compose stack on the candidate image, and tag the image tested").argument("<app>", "the app, apps/<app> in the checkout").argument("<build>", "Turbo\u2019s hash of the app\u2019s build task, the candidate\u2019s tag").argument("<hash>", "Turbo\u2019s hash of the app\u2019s test task, the tag on success").option("-C, --dir <dir>", "the checkout to read", ".").action(async (app, build2, hash, opts, command) => {
12624
- const ctx = contextOf(command);
12625
- const result = await test(ctx, {
12626
- app,
12627
- build: build2,
12628
- hash,
12629
- dir: resolve3(opts.dir),
12630
- port: process.env.PLEXUS_LOOPBACK_PORT ? Number(process.env.PLEXUS_LOOPBACK_PORT) : void 0
12631
- });
12632
- ctx.result(result, (r) => r.digest ?? "");
12633
- if (process.env.GITHUB_OUTPUT && result.digest)
12634
- appendFileSync5(process.env.GITHUB_OUTPUT, `digest=${result.digest}
12896
+ function register7(program2) {
12897
+ withStrategy(program2.command("test")).description("run one app\u2019s tests against its compose stack on the candidate image, and tag the image tested").argument("<app>", "the app, apps/<app> in the checkout").argument("<build>", "Turbo\u2019s hash of the app\u2019s build task, the candidate\u2019s tag").argument("<hash>", "Turbo\u2019s hash of the app\u2019s test task, the tag on success").option("-C, --dir <dir>", "the checkout to read", ".").action(
12898
+ async (app, build2, hash, opts, command) => {
12899
+ const ctx = contextOf(command);
12900
+ const result = await test(ctx, {
12901
+ app,
12902
+ build: build2,
12903
+ hash,
12904
+ dir: resolve4(opts.dir),
12905
+ registry: registryFor(settings(opts)),
12906
+ port: process.env.PLEXUS_LOOPBACK_PORT ? Number(process.env.PLEXUS_LOOPBACK_PORT) : void 0
12907
+ });
12908
+ ctx.result(result, (r) => r.digest ?? "");
12909
+ if (process.env.GITHUB_OUTPUT && result.digest)
12910
+ appendFileSync4(process.env.GITHUB_OUTPUT, `digest=${result.digest}
12635
12911
  `);
12636
- });
12912
+ }
12913
+ );
12637
12914
  }
12638
12915
 
12639
12916
  // src/commands/index.ts
12640
- var commands = [
12641
- register4,
12642
- register3,
12643
- register,
12644
- register11,
12645
- register7,
12646
- register8,
12647
- register10,
12648
- register9,
12649
- register6,
12650
- register2,
12651
- register5
12652
- ];
12917
+ var commands = [register5, register3, register, register7, register4, register6, register2];
12653
12918
 
12654
12919
  // src/program.ts
12655
12920
  var { version, description } = createRequire(import.meta.url)("../package.json");
@@ -12658,7 +12923,7 @@ function buildProgram() {
12658
12923
  program2.addOption(new Option("-n, --dry-run", "print every effect this run would have, perform none").env("PLX_DRY_RUN")).addOption(
12659
12924
  new Option("-v, --verbose", "say more: once for reads and inputs, twice for command output").argParser((_value, previous) => previous + 1).default(0)
12660
12925
  ).addOption(new Option("-q, --quiet", "say nothing but results and errors").env("PLX_QUIET")).addOption(new Option("-y, --yes", "answer every confirmation with yes (needed off a terminal)").env("PLX_YES")).addOption(new Option("--json", "print results as JSON").env("PLX_JSON"));
12661
- for (const register12 of commands) register12(program2);
12926
+ for (const register8 of commands) register8(program2);
12662
12927
  return program2;
12663
12928
  }
12664
12929
  function contextOf(command) {