@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.
- package/dist/plx-ci.js +1033 -768
- 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((
|
|
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
|
-
|
|
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/
|
|
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
|
|
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
|
-
/**
|
|
11065
|
-
|
|
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
|
-
|
|
11080
|
-
|
|
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("<", "<").replaceAll(">", ">").replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&");
|
|
11171
|
+
var escapeXml = (text) => text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
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,
|
|
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
|
|
11201
|
-
if (!await
|
|
11202
|
-
ctx.say(`\u2192 creating the bucket ${buckets.current}
|
|
11203
|
-
await
|
|
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
|
|
11276
|
-
const ref = (tag) =>
|
|
11277
|
-
const tags =
|
|
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 =
|
|
11317
|
-
const context =
|
|
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:
|
|
11738
|
+
BUILDX_BUILDER: builder,
|
|
11321
11739
|
SOURCE_DATE_EPOCH: "0"
|
|
11322
11740
|
};
|
|
11323
|
-
const
|
|
11324
|
-
|
|
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",
|
|
11745
|
+
run("npx", ["--yes", await turboOf(ctx, dir), "prune", name, "--docker", "--out-dir", out], {
|
|
11328
11746
|
cwd: dir
|
|
11329
11747
|
}),
|
|
11330
|
-
run("cp", [
|
|
11331
|
-
run("cp", [
|
|
11332
|
-
run("mkdir", ["-p",
|
|
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 (
|
|
11338
|
-
|
|
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 =
|
|
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}
|
|
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
|
-
|
|
11551
|
-
|
|
11552
|
-
|
|
11553
|
-
|
|
11554
|
-
|
|
11555
|
-
|
|
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/
|
|
11560
|
-
|
|
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/
|
|
11893
|
+
// src/core/createInstances.ts
|
|
11563
11894
|
import { gzipSync } from "node:zlib";
|
|
11564
11895
|
|
|
11565
|
-
// src/core/
|
|
11896
|
+
// src/core/destroyInstances.ts
|
|
11566
11897
|
var repoLabel = (repo) => repo.replace("/", "-");
|
|
11567
|
-
|
|
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 &&
|
|
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
|
|
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
|
|
11579
|
-
const kept = all.filter(
|
|
11580
|
-
|
|
11581
|
-
|
|
11582
|
-
|
|
11583
|
-
|
|
11584
|
-
|
|
11585
|
-
|
|
11586
|
-
|
|
11587
|
-
|
|
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(`
|
|
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 (
|
|
11601
|
-
# fills the placeholders. Every teardown layer is
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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/
|
|
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
|
|
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
|
|
11934
|
-
const
|
|
11935
|
-
|
|
11936
|
-
|
|
11937
|
-
|
|
11938
|
-
|
|
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
|
-
|
|
11941
|
-
|
|
11942
|
-
const
|
|
11943
|
-
|
|
11944
|
-
|
|
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
|
-
|
|
11958
|
-
|
|
11959
|
-
|
|
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
|
|
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
|
-
|
|
11975
|
-
|
|
11976
|
-
|
|
11977
|
-
|
|
11978
|
-
|
|
11979
|
-
|
|
11980
|
-
|
|
11981
|
-
|
|
11982
|
-
|
|
11983
|
-
|
|
11984
|
-
|
|
11985
|
-
|
|
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
|
|
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
|
-
|
|
12009
|
-
|
|
12010
|
-
|
|
12011
|
-
|
|
12012
|
-
|
|
12013
|
-
|
|
12014
|
-
|
|
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
|
|
12018
|
-
const
|
|
12019
|
-
|
|
12020
|
-
|
|
12021
|
-
|
|
12022
|
-
|
|
12023
|
-
|
|
12024
|
-
|
|
12025
|
-
|
|
12026
|
-
|
|
12027
|
-
|
|
12028
|
-
|
|
12029
|
-
|
|
12030
|
-
|
|
12031
|
-
|
|
12032
|
-
|
|
12033
|
-
|
|
12034
|
-
|
|
12035
|
-
|
|
12036
|
-
|
|
12037
|
-
|
|
12038
|
-
|
|
12039
|
-
|
|
12040
|
-
ctx.say(
|
|
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/
|
|
12045
|
-
|
|
12046
|
-
|
|
12047
|
-
|
|
12048
|
-
|
|
12049
|
-
|
|
12050
|
-
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
|
|
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
|
|
12060
|
-
const
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
|
|
12066
|
-
${
|
|
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
|
-
|
|
12070
|
-
|
|
12071
|
-
|
|
12072
|
-
|
|
12073
|
-
|
|
12074
|
-
|
|
12075
|
-
"
|
|
12076
|
-
|
|
12077
|
-
|
|
12078
|
-
|
|
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/
|
|
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:
|
|
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
|
|
12113
|
-
|
|
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
|
|
12118
|
-
|
|
12119
|
-
|
|
12120
|
-
|
|
12121
|
-
|
|
12122
|
-
|
|
12123
|
-
|
|
12124
|
-
|
|
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
|
|
12561
|
+
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
12132
12562
|
import { resolve as resolve2 } from "node:path";
|
|
12133
|
-
|
|
12134
|
-
|
|
12135
|
-
|
|
12136
|
-
|
|
12137
|
-
|
|
12138
|
-
|
|
12139
|
-
|
|
12140
|
-
|
|
12141
|
-
const
|
|
12142
|
-
|
|
12143
|
-
|
|
12144
|
-
|
|
12145
|
-
|
|
12146
|
-
|
|
12147
|
-
|
|
12148
|
-
|
|
12149
|
-
|
|
12150
|
-
|
|
12151
|
-
|
|
12152
|
-
|
|
12153
|
-
|
|
12154
|
-
|
|
12155
|
-
|
|
12156
|
-
|
|
12157
|
-
|
|
12158
|
-
|
|
12159
|
-
|
|
12160
|
-
|
|
12161
|
-
|
|
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
|
-
|
|
12166
|
-
|
|
12167
|
-
|
|
12168
|
-
|
|
12169
|
-
|
|
12170
|
-
|
|
12171
|
-
}
|
|
12172
|
-
|
|
12173
|
-
const
|
|
12174
|
-
const
|
|
12175
|
-
|
|
12176
|
-
|
|
12177
|
-
|
|
12178
|
-
|
|
12179
|
-
|
|
12180
|
-
|
|
12181
|
-
}
|
|
12182
|
-
|
|
12183
|
-
|
|
12184
|
-
|
|
12185
|
-
|
|
12186
|
-
|
|
12187
|
-
|
|
12188
|
-
|
|
12189
|
-
|
|
12190
|
-
|
|
12191
|
-
|
|
12192
|
-
|
|
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/
|
|
12645
|
+
// src/commands/publish.ts
|
|
12267
12646
|
function register4(program2) {
|
|
12268
|
-
program2.command("
|
|
12269
|
-
|
|
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
|
|
12277
|
-
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
|
|
12281
|
-
|
|
12282
|
-
|
|
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
|
-
|
|
12289
|
-
|
|
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/
|
|
12308
|
-
|
|
12309
|
-
|
|
12310
|
-
|
|
12311
|
-
|
|
12312
|
-
|
|
12313
|
-
|
|
12314
|
-
|
|
12315
|
-
|
|
12316
|
-
|
|
12317
|
-
|
|
12318
|
-
|
|
12319
|
-
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
|
|
12323
|
-
|
|
12324
|
-
|
|
12325
|
-
|
|
12326
|
-
|
|
12327
|
-
|
|
12328
|
-
|
|
12329
|
-
|
|
12330
|
-
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
|
|
12334
|
-
|
|
12335
|
-
|
|
12336
|
-
|
|
12337
|
-
|
|
12338
|
-
|
|
12339
|
-
|
|
12340
|
-
|
|
12341
|
-
|
|
12342
|
-
|
|
12343
|
-
|
|
12344
|
-
|
|
12345
|
-
);
|
|
12346
|
-
|
|
12347
|
-
|
|
12348
|
-
|
|
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
|
-
|
|
12354
|
-
|
|
12355
|
-
|
|
12356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12746
|
+
`rotate: ${pruned.length} candidate version(s) pruned from ${names.length} package(s) of ghcr.io/${packages.org}`
|
|
12364
12747
|
);
|
|
12365
|
-
return
|
|
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
|
|
12389
|
-
const registry = program2.command("registry").description(
|
|
12390
|
-
"
|
|
12391
|
-
).action(async (
|
|
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
|
|
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
|
|
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
|
|
12403
|
-
if (
|
|
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(
|
|
12778
|
+
} else ctx.result(ref);
|
|
12407
12779
|
});
|
|
12408
|
-
|
|
12409
|
-
|
|
12410
|
-
|
|
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
|
|
12448
|
-
const
|
|
12449
|
-
|
|
12450
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
12528
|
-
function
|
|
12529
|
-
program2.command("
|
|
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
|
|
12534
|
-
const result = await
|
|
12535
|
-
ctx.result(result, (
|
|
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
|
|
12541
|
-
import { resolve as
|
|
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
|
|
12574
|
-
if (
|
|
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",
|
|
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
|
|
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
|
|
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(
|
|
12624
|
-
|
|
12625
|
-
|
|
12626
|
-
|
|
12627
|
-
|
|
12628
|
-
|
|
12629
|
-
|
|
12630
|
-
|
|
12631
|
-
|
|
12632
|
-
|
|
12633
|
-
|
|
12634
|
-
|
|
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
|
|
12926
|
+
for (const register8 of commands) register8(program2);
|
|
12662
12927
|
return program2;
|
|
12663
12928
|
}
|
|
12664
12929
|
function contextOf(command) {
|