@plexus-ms/cli-ci 0.109.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 +1045 -773
- 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";
|
|
@@ -11173,7 +11335,10 @@ function sidecarArgs(name, port, bucket, storage, readOnly) {
|
|
|
11173
11335
|
"-e",
|
|
11174
11336
|
"REGISTRY_STORAGE_S3_FORCEPATHSTYLE=true",
|
|
11175
11337
|
"-e",
|
|
11176
|
-
|
|
11338
|
+
// registry:2 panics on the read-only flag as one nested variable
|
|
11339
|
+
// (…_READONLY_ENABLED=true): the map it builds from it has the wrong
|
|
11340
|
+
// type. The whole key as one YAML value is read as from a config file.
|
|
11341
|
+
readOnly ? 'REGISTRY_STORAGE_MAINTENANCE_READONLY={"enabled":true}' : "REGISTRY_STORAGE_DELETE_ENABLED=true",
|
|
11177
11342
|
REGISTRY_IMAGE
|
|
11178
11343
|
];
|
|
11179
11344
|
}
|
|
@@ -11185,12 +11350,19 @@ async function answers(ctx, port) {
|
|
|
11185
11350
|
return false;
|
|
11186
11351
|
}
|
|
11187
11352
|
}
|
|
11188
|
-
async function startSidecars(ctx, storage, owner,
|
|
11353
|
+
async function startSidecars(ctx, storage, owner, now = /* @__PURE__ */ new Date()) {
|
|
11189
11354
|
const buckets = registryBuckets(owner, now);
|
|
11190
|
-
|
|
11191
|
-
|
|
11192
|
-
ctx.say(
|
|
11193
|
-
|
|
11355
|
+
if (!ctx.dryRun && await answers(ctx, CURRENT_PORT)) {
|
|
11356
|
+
const previousAnswers2 = await answers(ctx, PREVIOUS_PORT);
|
|
11357
|
+
ctx.say(`registry: :${CURRENT_PORT} answers already, the host's pair; nothing started`);
|
|
11358
|
+
return { current: buckets.current, previous: buckets.previous, previousAnswers: previousAnswers2 };
|
|
11359
|
+
}
|
|
11360
|
+
if (!storage)
|
|
11361
|
+
throw new Error("REGISTRY_ENDPOINT, REGISTRY_REGION, REGISTRY_ACCESS_KEY and REGISTRY_SECRET_KEY are needed");
|
|
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);
|
|
11194
11366
|
}
|
|
11195
11367
|
await ctx.apply([
|
|
11196
11368
|
run("docker", sidecarArgs("registry", CURRENT_PORT, buckets.current, storage, false)),
|
|
@@ -11257,14 +11429,247 @@ async function layers(ctx, app, tag, port = CURRENT_PORT) {
|
|
|
11257
11429
|
return void 0;
|
|
11258
11430
|
}
|
|
11259
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
|
+
}
|
|
11260
11663
|
|
|
11261
11664
|
// src/core/build.ts
|
|
11262
|
-
var REGISTRY = `localhost:${CURRENT_PORT}`;
|
|
11263
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
|
+
}
|
|
11264
11669
|
function buildArgs(options, context, metadataFile, previousAnswers) {
|
|
11265
|
-
const { app
|
|
11266
|
-
const ref = (tag) =>
|
|
11267
|
-
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)]);
|
|
11268
11673
|
const cache = options.noCache ? ["--no-cache"] : [
|
|
11269
11674
|
"--cache-from",
|
|
11270
11675
|
`type=registry,ref=${ref("cache")}`,
|
|
@@ -11283,7 +11688,7 @@ function buildArgs(options, context, metadataFile, previousAnswers) {
|
|
|
11283
11688
|
"--build-arg",
|
|
11284
11689
|
`APP=${app}`,
|
|
11285
11690
|
"--build-arg",
|
|
11286
|
-
`BUILD_HASH=${hash}`,
|
|
11691
|
+
`BUILD_HASH=${options.hash}`,
|
|
11287
11692
|
"--secret",
|
|
11288
11693
|
"id=next-server-actions-encryption-key,env=NEXT_SERVER_ACTIONS_ENCRYPTION_KEY",
|
|
11289
11694
|
...cache,
|
|
@@ -11300,159 +11705,71 @@ function buildArgs(options, context, metadataFile, previousAnswers) {
|
|
|
11300
11705
|
"plain"
|
|
11301
11706
|
];
|
|
11302
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
|
+
}
|
|
11303
11730
|
async function build(ctx, options) {
|
|
11304
11731
|
if (!options.encryptionKey) throw new UsageError("NEXT_SERVER_ACTIONS_ENCRYPTION_KEY is empty");
|
|
11305
11732
|
const dir = options.dir ?? ".";
|
|
11306
|
-
const out =
|
|
11307
|
-
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";
|
|
11308
11736
|
const env = {
|
|
11309
11737
|
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY: options.encryptionKey,
|
|
11310
|
-
BUILDX_BUILDER:
|
|
11738
|
+
BUILDX_BUILDER: builder,
|
|
11311
11739
|
SOURCE_DATE_EPOCH: "0"
|
|
11312
11740
|
};
|
|
11313
|
-
const
|
|
11314
|
-
|
|
11741
|
+
const name = await packageName(ctx, dir, options.app);
|
|
11742
|
+
await ensureBuilder(ctx, builder);
|
|
11315
11743
|
let t = Date.now();
|
|
11316
11744
|
await ctx.apply([
|
|
11317
|
-
run("npx", ["--yes",
|
|
11745
|
+
run("npx", ["--yes", await turboOf(ctx, dir), "prune", name, "--docker", "--out-dir", out], {
|
|
11318
11746
|
cwd: dir
|
|
11319
11747
|
}),
|
|
11320
|
-
run("cp", [
|
|
11321
|
-
run("cp", [
|
|
11322
|
-
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")])
|
|
11323
11751
|
]);
|
|
11324
11752
|
const pruneSeconds = Math.round((Date.now() - t) / 1e3);
|
|
11325
11753
|
ctx.say(`build: prune ${pruneSeconds}s`);
|
|
11326
|
-
const previousAnswers = ctx.dryRun ? true : await answers(ctx, PREVIOUS_PORT);
|
|
11327
|
-
if (
|
|
11328
|
-
|
|
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");
|
|
11329
11758
|
t = Date.now();
|
|
11330
11759
|
await ctx.apply([run("docker", buildArgs(options, context, metadataFile, previousAnswers), { env })]);
|
|
11331
11760
|
const buildSeconds = Math.round((Date.now() - t) / 1e3);
|
|
11332
11761
|
ctx.say(`build: build${options.noCache ? " no-cache" : ""} ${buildSeconds}s`);
|
|
11333
|
-
const tags =
|
|
11762
|
+
const tags = tagsOf(options);
|
|
11334
11763
|
const result = { app: options.app, tags, pruneSeconds, buildSeconds };
|
|
11335
11764
|
if (!ctx.dryRun) {
|
|
11336
11765
|
const meta = JSON.parse(await ctx.readFile(metadataFile));
|
|
11337
11766
|
result.digest = meta["containerimage.digest"];
|
|
11338
|
-
ctx.say(`build: ${options.app}
|
|
11767
|
+
ctx.say(`build: ${options.app} ${tags.join(" ")} -> ${result.digest ?? "<no digest>"}`);
|
|
11339
11768
|
}
|
|
11340
11769
|
await ctx.apply([run("rm", ["-rf", out])]);
|
|
11341
11770
|
return result;
|
|
11342
11771
|
}
|
|
11343
11772
|
|
|
11344
|
-
// src/commands/build.ts
|
|
11345
|
-
function register(program2) {
|
|
11346
|
-
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(
|
|
11347
|
-
async (app, hash, sha, tags, opts, command) => {
|
|
11348
|
-
const ctx = contextOf(command);
|
|
11349
|
-
const result = await build(ctx, {
|
|
11350
|
-
app,
|
|
11351
|
-
hash,
|
|
11352
|
-
sha,
|
|
11353
|
-
tags,
|
|
11354
|
-
encryptionKey: process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ?? "",
|
|
11355
|
-
noCache: process.env.NO_CACHE === "1",
|
|
11356
|
-
dir: resolve(opts.dir),
|
|
11357
|
-
scope: opts.scope,
|
|
11358
|
-
builder: process.env.BUILDX_BUILDER
|
|
11359
|
-
});
|
|
11360
|
-
ctx.result(result, (r) => r.digest ?? "");
|
|
11361
|
-
if (process.env.GITHUB_OUTPUT && result.digest)
|
|
11362
|
-
appendFileSync(process.env.GITHUB_OUTPUT, `digest=${result.digest}
|
|
11363
|
-
`);
|
|
11364
|
-
}
|
|
11365
|
-
);
|
|
11366
|
-
}
|
|
11367
|
-
|
|
11368
|
-
// src/core/inspect.ts
|
|
11369
|
-
import { join as join2 } from "node:path";
|
|
11370
|
-
var LOGS = [
|
|
11371
|
-
["journal-previous-boot.log", "sudo journalctl -b -1 --no-pager -o short-iso"],
|
|
11372
|
-
[
|
|
11373
|
-
"journal-previous-boot-network.log",
|
|
11374
|
-
'sudo journalctl -b -1 --no-pager -o short-iso -u systemd-networkd -u cloud-init-local -u cloud-init -u "ifup@*" -u networking -k'
|
|
11375
|
-
],
|
|
11376
|
-
["cloud-init.log", "sudo cat /var/log/cloud-init.log"],
|
|
11377
|
-
["cloud-init-output.log", "sudo cat /var/log/cloud-init-output.log"],
|
|
11378
|
-
[
|
|
11379
|
-
"system.txt",
|
|
11380
|
-
"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"
|
|
11381
|
-
]
|
|
11382
|
-
];
|
|
11383
|
-
function sshArgs(key, ip, command) {
|
|
11384
|
-
return [
|
|
11385
|
-
"-i",
|
|
11386
|
-
key,
|
|
11387
|
-
"-o",
|
|
11388
|
-
"BatchMode=yes",
|
|
11389
|
-
"-o",
|
|
11390
|
-
"ConnectTimeout=5",
|
|
11391
|
-
"-o",
|
|
11392
|
-
"StrictHostKeyChecking=no",
|
|
11393
|
-
"-o",
|
|
11394
|
-
"UserKnownHostsFile=/dev/null",
|
|
11395
|
-
"-o",
|
|
11396
|
-
"LogLevel=ERROR",
|
|
11397
|
-
`debian@${ip}`,
|
|
11398
|
-
command
|
|
11399
|
-
];
|
|
11400
|
-
}
|
|
11401
|
-
async function inspect(ctx, exo, options) {
|
|
11402
|
-
const instance = await exo.instance(options.id);
|
|
11403
|
-
if (!instance) throw new UsageError(`no instance ${options.id} in ${exo.zone}`);
|
|
11404
|
-
if (!instance.publicIp) throw new UsageError(`instance ${options.id} has no public address`);
|
|
11405
|
-
const ip = instance.publicIp;
|
|
11406
|
-
const out = options.out ?? `inspect-${options.id}`;
|
|
11407
|
-
const ssh = (command) => ctx.read("ssh", sshArgs(options.sshKey, ip, command));
|
|
11408
|
-
ctx.say(`inspect: ${options.id} at ${ip}, state ${instance.state}`);
|
|
11409
|
-
if (instance.state === "stopped") {
|
|
11410
|
-
ctx.say("inspect: starting (the launch stopped it when it kept it)");
|
|
11411
|
-
await exo.startInstance(options.id);
|
|
11412
|
-
} else {
|
|
11413
|
-
const before = await ssh("true").then(
|
|
11414
|
-
() => true,
|
|
11415
|
-
() => false
|
|
11416
|
-
);
|
|
11417
|
-
ctx.say(
|
|
11418
|
-
before ? " SSH answers before the reboot (not a stall?)" : " no SSH before the reboot, as expected of a stalled boot"
|
|
11419
|
-
);
|
|
11420
|
-
ctx.say("inspect: rebooting");
|
|
11421
|
-
await exo.rebootInstance(options.id);
|
|
11422
|
-
}
|
|
11423
|
-
if (ctx.dryRun) {
|
|
11424
|
-
ctx.say(` (then: wait for SSH, mask plexus-ci-runner and the timers, copy the previous boot's logs to ${out}/)`);
|
|
11425
|
-
return out;
|
|
11426
|
-
}
|
|
11427
|
-
let reached = false;
|
|
11428
|
-
for (let i = 0; i < 60 && !reached; i++) {
|
|
11429
|
-
await sleep(2e3);
|
|
11430
|
-
reached = await ssh("true").then(
|
|
11431
|
-
() => true,
|
|
11432
|
-
() => false
|
|
11433
|
-
);
|
|
11434
|
-
}
|
|
11435
|
-
if (!reached) throw new Error("no SSH after the reboot");
|
|
11436
|
-
ctx.say("inspect: in, masking the units");
|
|
11437
|
-
await ssh(
|
|
11438
|
-
"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"
|
|
11439
|
-
);
|
|
11440
|
-
ctx.say("inspect: copying the logs");
|
|
11441
|
-
await ctx.apply([run("mkdir", ["-p", out])]);
|
|
11442
|
-
for (const [file, command] of LOGS) {
|
|
11443
|
-
let text;
|
|
11444
|
-
try {
|
|
11445
|
-
text = await ssh(command);
|
|
11446
|
-
} catch (error) {
|
|
11447
|
-
text = error instanceof ExecError ? `${error.message}
|
|
11448
|
-
` : String(error);
|
|
11449
|
-
}
|
|
11450
|
-
await ctx.apply([write(join2(out, file), text)]);
|
|
11451
|
-
}
|
|
11452
|
-
ctx.say(`inspect: done; the instance ${options.id} is still running, delete it when finished`);
|
|
11453
|
-
return out;
|
|
11454
|
-
}
|
|
11455
|
-
|
|
11456
11773
|
// src/core/github.ts
|
|
11457
11774
|
var GitHubRunners = class {
|
|
11458
11775
|
ctx;
|
|
@@ -11536,60 +11853,145 @@ function githubOf(ctx, env = process.env) {
|
|
|
11536
11853
|
return env.RUNNER_TOKEN ? new GitHubRunners(ctx, need("GITHUB_REPOSITORY", env), env.RUNNER_TOKEN) : void 0;
|
|
11537
11854
|
}
|
|
11538
11855
|
var ownerOf = (repo) => repo.split("/")[0] ?? repo;
|
|
11539
|
-
|
|
11540
|
-
|
|
11541
|
-
|
|
11542
|
-
|
|
11543
|
-
|
|
11544
|
-
|
|
11545
|
-
|
|
11546
|
-
}
|
|
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) : "");
|
|
11547
11867
|
}
|
|
11548
11868
|
|
|
11549
|
-
// src/commands/
|
|
11550
|
-
|
|
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
|
+
}
|
|
11551
11892
|
|
|
11552
|
-
// src/core/
|
|
11893
|
+
// src/core/createInstances.ts
|
|
11553
11894
|
import { gzipSync } from "node:zlib";
|
|
11554
11895
|
|
|
11555
|
-
// src/core/
|
|
11896
|
+
// src/core/destroyInstances.ts
|
|
11556
11897
|
var repoLabel = (repo) => repo.replace("/", "-");
|
|
11557
|
-
|
|
11898
|
+
var KEPT_LABEL = "kept";
|
|
11899
|
+
function selectRunInstances(instances, run2) {
|
|
11558
11900
|
return instances.filter(
|
|
11559
|
-
(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
|
|
11560
11902
|
);
|
|
11561
11903
|
}
|
|
11562
11904
|
function selectRunRegistrations(runners, run2) {
|
|
11563
11905
|
const pattern = new RegExp(`^exo-[a-z]+-${run2.runId}(-[^-]+)*-${run2.attempt}(-[a-z0-9]+)?$`);
|
|
11564
11906
|
return runners.filter((r) => !r.busy && pattern.test(r.name));
|
|
11565
11907
|
}
|
|
11566
|
-
async function
|
|
11908
|
+
async function destroyInstances(ctx, exo, github, run2, { retries = 12, wait = sleep } = {}) {
|
|
11567
11909
|
const all = await exo.listInstances();
|
|
11568
|
-
const mine = selectRunInstances(all, run2
|
|
11569
|
-
const kept = all.filter(
|
|
11570
|
-
|
|
11571
|
-
|
|
11572
|
-
|
|
11573
|
-
|
|
11574
|
-
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
|
|
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) {
|
|
11578
11935
|
for (const r of selectRunRegistrations(await github.list(), run2)) {
|
|
11579
11936
|
ctx.say(`runner ${r.name} (${r.id}): ${r.status}`);
|
|
11580
11937
|
await github.deleteRunner(r.id);
|
|
11581
11938
|
registrations.push(r.name);
|
|
11582
11939
|
}
|
|
11583
|
-
ctx.say(`
|
|
11940
|
+
ctx.say(`destroy-instances: ${registrations.length} registration(s) removed`);
|
|
11584
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
|
+
);
|
|
11585
11946
|
return { instances: mine.map((i) => i.name), kept, registrations };
|
|
11586
11947
|
}
|
|
11587
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
|
+
|
|
11588
11990
|
// src/core/userData.ts
|
|
11589
11991
|
var RUNNER_USER_DATA = `#cloud-config
|
|
11590
|
-
# One throwaway CI runner (
|
|
11591
|
-
# fills the placeholders. Every teardown layer is
|
|
11592
|
-
# 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:
|
|
11593
11995
|
#
|
|
11594
11996
|
# layer 1 the runner exits after its one job, the instance deletes itself
|
|
11595
11997
|
# layer 1b noJobTimeout, @@NO_JOB_MINUTES@@ minutes after boot with no job ever started,
|
|
@@ -11605,7 +12007,7 @@ var RUNNER_USER_DATA = `#cloud-config
|
|
|
11605
12007
|
#
|
|
11606
12008
|
# The self-delete key and the registry bucket's write key sit in
|
|
11607
12009
|
# /etc/plexus-ci/env, readable by root; the job that runs here could read
|
|
11608
|
-
# them anyway (
|
|
12010
|
+
# them anyway (\xA7 8 PLX-Delivery).
|
|
11609
12011
|
hostname: @@LABEL@@
|
|
11610
12012
|
|
|
11611
12013
|
write_files:
|
|
@@ -11644,7 +12046,7 @@ write_files:
|
|
|
11644
12046
|
# The env file is sourced exported (set -a): exo reads the key from the
|
|
11645
12047
|
# environment, and a plain source would leave it shell-local. A timer's
|
|
11646
12048
|
# service has no HOME, and exo refuses to run without one (layers 1b and
|
|
11647
|
-
# 2b failed on it on 2026-11-04
|
|
12049
|
+
# 2b failed on it on 2026-11-04); the runner's unit has a user.
|
|
11648
12050
|
- path: /usr/local/sbin/plexus-ci-exo
|
|
11649
12051
|
permissions: "0755"
|
|
11650
12052
|
content: |
|
|
@@ -11688,7 +12090,7 @@ write_files:
|
|
|
11688
12090
|
set -u
|
|
11689
12091
|
set -a; . /etc/plexus-ci/env; set +a
|
|
11690
12092
|
# SELF_LAYERS=off (the launch's SELF_LAYERS, while the boot stall is
|
|
11691
|
-
# investigated
|
|
12093
|
+
# investigated): layers 1, 1b and 2b log and do nothing; the
|
|
11692
12094
|
# power-off, the teardown job and the sweep remain.
|
|
11693
12095
|
[ "\${SELF_LAYERS:-on}" != off ] || { logger -t plexus-ci "self-delete ($1): layers off, not deleting"; exit 0; }
|
|
11694
12096
|
id=$(curl -fsS --max-time 10 http://metadata.exoscale.com/latest/meta-data/instance-id) || id=
|
|
@@ -11702,7 +12104,7 @@ write_files:
|
|
|
11702
12104
|
#!/bin/sh
|
|
11703
12105
|
# Layer 1b: the runner spawns a Worker process for a job and nothing
|
|
11704
12106
|
# else, and logs it as _diag/Worker_*.log; _work itself exists empty
|
|
11705
|
-
# from the runner's start (seen 2026-11-04
|
|
12107
|
+
# from the runner's start (seen 2026-11-04). No Worker log by
|
|
11706
12108
|
# now means no job ever came, and nothing will.
|
|
11707
12109
|
set -u
|
|
11708
12110
|
if ! ls /opt/runner/_diag/Worker_*.log >/dev/null 2>&1; then
|
|
@@ -11771,6 +12173,32 @@ write_files:
|
|
|
11771
12173
|
[Install]
|
|
11772
12174
|
WantedBy=timers.target
|
|
11773
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
|
+
|
|
11774
12202
|
# The runner, one job, then layer 1: ExecStopPost runs as root (the +) when
|
|
11775
12203
|
# the runner exits for any reason.
|
|
11776
12204
|
- path: /etc/systemd/system/plexus-ci-runner.service
|
|
@@ -11793,45 +12221,21 @@ runcmd:
|
|
|
11793
12221
|
# Layers 1b, 2b and 2a first, before anything that can hang.
|
|
11794
12222
|
- systemctl daemon-reload
|
|
11795
12223
|
- systemctl start plexus-ci-no-job.timer plexus-ci-death.timer plexus-ci-poweroff.timer
|
|
12224
|
+
- plexus-ci-apt-free
|
|
11796
12225
|
- export DEBIAN_FRONTEND=noninteractive; apt-get update -q && apt-get install -yq --no-install-recommends ca-certificates curl jq git tar unzip
|
|
11797
12226
|
- curl -fsSL https://get.docker.com | sh
|
|
11798
12227
|
- useradd -m -s /bin/bash -G docker runner && echo 'runner ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/runner
|
|
11799
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
|
|
11800
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
|
|
11801
12230
|
- plexus-ci-exo version >/dev/null
|
|
11802
|
-
# 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.
|
|
11803
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
|
|
11804
|
-
- . /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
|
|
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
|
|
11805
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.
|
|
11806
12235
|
- . /etc/plexus-ci/env && if [ "$KIND" = build ]; then su -s /bin/bash runner -c 'docker buildx create --name ci --driver docker-container --driver-opt network=host --bootstrap >/dev/null' & fi
|
|
11807
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
|
|
11808
12237
|
- systemctl start plexus-ci-runner.service
|
|
11809
12238
|
`;
|
|
11810
|
-
var BARE_USER_DATA = `#cloud-config
|
|
11811
|
-
# The bisect probe (ci-dummy with break = bare): nothing but the power-off
|
|
11812
|
-
# timer, so that a boot with this and a boot with user-data.yaml tell apart
|
|
11813
|
-
# the image from the scripts. The sweep removes a powered-off instance.
|
|
11814
|
-
write_files:
|
|
11815
|
-
- path: /etc/systemd/system/plexus-ci-poweroff.service
|
|
11816
|
-
content: |
|
|
11817
|
-
[Unit]
|
|
11818
|
-
Description=plexus-ci: power off at the hard deadline
|
|
11819
|
-
[Service]
|
|
11820
|
-
Type=oneshot
|
|
11821
|
-
ExecStart=/usr/bin/systemctl poweroff
|
|
11822
|
-
- path: /etc/systemd/system/plexus-ci-poweroff.timer
|
|
11823
|
-
content: |
|
|
11824
|
-
[Unit]
|
|
11825
|
-
Description=plexus-ci: layer 2a
|
|
11826
|
-
[Timer]
|
|
11827
|
-
OnBootSec=@@POWEROFF_MINUTES@@min
|
|
11828
|
-
AccuracySec=10s
|
|
11829
|
-
[Install]
|
|
11830
|
-
WantedBy=timers.target
|
|
11831
|
-
runcmd:
|
|
11832
|
-
- systemctl daemon-reload
|
|
11833
|
-
- systemctl start plexus-ci-poweroff.timer
|
|
11834
|
-
`;
|
|
11835
12239
|
function placeholders(template) {
|
|
11836
12240
|
return [...new Set([...template.matchAll(/@@([A-Z0-9_]+)@@/g)].map((m) => m[1] ?? ""))].filter((n) => n !== "");
|
|
11837
12241
|
}
|
|
@@ -11842,7 +12246,7 @@ function render2(template, values) {
|
|
|
11842
12246
|
return text;
|
|
11843
12247
|
}
|
|
11844
12248
|
|
|
11845
|
-
// src/core/
|
|
12249
|
+
// src/core/createInstances.ts
|
|
11846
12250
|
var DEFAULTS = {
|
|
11847
12251
|
zone: "de-fra-1",
|
|
11848
12252
|
instanceType: "cpu.extra-large",
|
|
@@ -11859,8 +12263,6 @@ var DEFAULTS = {
|
|
|
11859
12263
|
userDataLimit: 32768
|
|
11860
12264
|
};
|
|
11861
12265
|
function renderUserData(options, jitconfig, now) {
|
|
11862
|
-
if (options.kind === "bare")
|
|
11863
|
-
return render2(BARE_USER_DATA, { POWEROFF_MINUTES: String(options.deadlines.poweroffMinutes) });
|
|
11864
12266
|
const owner = options.run.repo.split("/")[0] ?? options.run.repo;
|
|
11865
12267
|
const buckets = registryBuckets(owner, now);
|
|
11866
12268
|
return render2(RUNNER_USER_DATA, {
|
|
@@ -11914,44 +12316,49 @@ function instanceLabels(options, noJobDeadline, deathDeadline) {
|
|
|
11914
12316
|
}
|
|
11915
12317
|
async function launch(ctx, exo, github, options) {
|
|
11916
12318
|
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
11917
|
-
const { label
|
|
12319
|
+
const { label } = options;
|
|
11918
12320
|
if (options.deadlines.poweroffMinutes <= options.deadlines.deathMinutes)
|
|
11919
12321
|
throw new UsageError("the power-off must come after the death deadline");
|
|
11920
12322
|
const t0 = now();
|
|
11921
12323
|
const noJobDeadline = deadline(t0, options.deadlines.noJobMinutes);
|
|
11922
12324
|
const deathDeadline = deadline(t0, options.deadlines.deathMinutes);
|
|
11923
|
-
const
|
|
11924
|
-
const
|
|
11925
|
-
|
|
11926
|
-
|
|
11927
|
-
|
|
11928
|
-
|
|
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: []
|
|
11929
12339
|
};
|
|
11930
|
-
|
|
11931
|
-
|
|
11932
|
-
const
|
|
11933
|
-
|
|
11934
|
-
|
|
11935
|
-
exo.securityGroupId(options.securityGroup)
|
|
11936
|
-
]);
|
|
11937
|
-
const request = {
|
|
11938
|
-
name: label,
|
|
11939
|
-
templateId,
|
|
11940
|
-
instanceTypeId,
|
|
11941
|
-
securityGroupId,
|
|
11942
|
-
sshKey: options.sshKey,
|
|
11943
|
-
diskSize: options.diskSize,
|
|
11944
|
-
userDataBase64: userData,
|
|
11945
|
-
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);
|
|
11946
12345
|
};
|
|
11947
|
-
|
|
11948
|
-
|
|
11949
|
-
|
|
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
|
+
};
|
|
11950
12357
|
const operation = await exo.createInstance(request);
|
|
11951
12358
|
result.tries = attempt;
|
|
11952
12359
|
if (operation === void 0) {
|
|
11953
12360
|
ctx.say(
|
|
11954
|
-
` (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)`
|
|
11955
12362
|
);
|
|
11956
12363
|
return result;
|
|
11957
12364
|
}
|
|
@@ -11961,118 +12368,135 @@ async function launch(ctx, exo, github, options) {
|
|
|
11961
12368
|
const state = await exo.waitOperation(operation.id);
|
|
11962
12369
|
if (state !== "success") throw new Error(`create-instance ended in state ${state}`);
|
|
11963
12370
|
ctx.say(`created ${label} (${result.id}) in ${options.zone}, try ${attempt}`);
|
|
11964
|
-
|
|
11965
|
-
|
|
11966
|
-
|
|
11967
|
-
|
|
11968
|
-
|
|
11969
|
-
|
|
11970
|
-
|
|
11971
|
-
|
|
11972
|
-
|
|
11973
|
-
|
|
11974
|
-
|
|
11975
|
-
|
|
11976
|
-
await exo.stopInstance(result.id).catch(() => ctx.say(" stop-instance refused; the instance may delete itself"));
|
|
11977
|
-
ctx.say(`kept ${label} (${result.id}) for inspection, stopped; run plx-ci inspect ${result.id}`);
|
|
11978
|
-
result.kept = result.id;
|
|
11979
|
-
} else {
|
|
11980
|
-
ctx.say(`deleting ${label} (${result.id})`);
|
|
11981
|
-
await exo.deleteInstance(result.id).catch(() => ctx.say(` delete of ${result.id} failed`));
|
|
11982
|
-
}
|
|
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;
|
|
11983
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);
|
|
11984
12387
|
await unregister();
|
|
11985
|
-
throw new Error(`${options.tries} instance(s) of ${label} failed to boot, giving up`);
|
|
11986
|
-
} catch (error) {
|
|
11987
|
-
if (!(error instanceof Error && error.message.endsWith("giving up"))) await unregister();
|
|
11988
|
-
throw error;
|
|
11989
12388
|
}
|
|
12389
|
+
throw new Error(`${options.tries} instance(s) of ${label} failed to come online and are kept, giving up`);
|
|
11990
12390
|
}
|
|
11991
|
-
async function waitOnline(github, label, seconds) {
|
|
12391
|
+
async function waitOnline(github, label, seconds, wait) {
|
|
11992
12392
|
for (let waited = 0; waited < seconds; waited += 5) {
|
|
11993
|
-
await
|
|
12393
|
+
await wait(5e3);
|
|
11994
12394
|
if (await github.status(label) === "online") return waited + 5;
|
|
11995
12395
|
}
|
|
11996
12396
|
return void 0;
|
|
11997
12397
|
}
|
|
11998
|
-
|
|
11999
|
-
|
|
12000
|
-
|
|
12001
|
-
|
|
12002
|
-
|
|
12003
|
-
|
|
12004
|
-
|
|
12005
|
-
|
|
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
|
+
};
|
|
12006
12413
|
}
|
|
12007
|
-
async function
|
|
12008
|
-
const
|
|
12009
|
-
|
|
12010
|
-
|
|
12011
|
-
|
|
12012
|
-
|
|
12013
|
-
|
|
12014
|
-
|
|
12015
|
-
|
|
12016
|
-
|
|
12017
|
-
|
|
12018
|
-
|
|
12019
|
-
|
|
12020
|
-
|
|
12021
|
-
|
|
12022
|
-
|
|
12023
|
-
|
|
12024
|
-
|
|
12025
|
-
|
|
12026
|
-
|
|
12027
|
-
|
|
12028
|
-
|
|
12029
|
-
|
|
12030
|
-
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`);
|
|
12031
12439
|
}
|
|
12440
|
+
return launches.flatMap((l) => l.status === "fulfilled" ? [l.value] : []);
|
|
12032
12441
|
}
|
|
12033
12442
|
|
|
12034
|
-
// src/core/
|
|
12035
|
-
|
|
12036
|
-
|
|
12037
|
-
|
|
12038
|
-
|
|
12039
|
-
|
|
12040
|
-
|
|
12041
|
-
|
|
12042
|
-
|
|
12043
|
-
|
|
12044
|
-
|
|
12045
|
-
|
|
12046
|
-
|
|
12047
|
-
|
|
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" };
|
|
12048
12461
|
}
|
|
12049
|
-
function
|
|
12050
|
-
const
|
|
12051
|
-
|
|
12052
|
-
|
|
12053
|
-
|
|
12054
|
-
|
|
12055
|
-
|
|
12056
|
-
${
|
|
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")}
|
|
12057
12485
|
`;
|
|
12058
|
-
|
|
12059
|
-
|
|
12060
|
-
|
|
12061
|
-
|
|
12062
|
-
|
|
12063
|
-
|
|
12064
|
-
|
|
12065
|
-
"
|
|
12066
|
-
|
|
12067
|
-
|
|
12068
|
-
|
|
12069
|
-
`user = ${JSON.stringify(`${smtp.user}:${smtp.password}`)}`,
|
|
12070
|
-
`upload-file = ${JSON.stringify(file)}`
|
|
12071
|
-
].join("\n");
|
|
12072
|
-
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;
|
|
12073
12497
|
}
|
|
12074
12498
|
|
|
12075
|
-
// src/commands/
|
|
12499
|
+
// src/commands/hybrid.ts
|
|
12076
12500
|
function launchOptionsFromEnv(env = process.env) {
|
|
12077
12501
|
const minutes = (name) => Number(need(name, env));
|
|
12078
12502
|
return {
|
|
@@ -12094,444 +12518,302 @@ function launchOptionsFromEnv(env = process.env) {
|
|
|
12094
12518
|
securityGroup: DEFAULTS.securityGroup,
|
|
12095
12519
|
sshKey: DEFAULTS.sshKey,
|
|
12096
12520
|
onlineSeconds: Number(env.ONLINE_SECONDS || DEFAULTS.onlineSeconds),
|
|
12097
|
-
tries:
|
|
12098
|
-
keepMissed: env.KEEP_MISSED || void 0,
|
|
12099
|
-
diagSshKey: env.DIAG_SSH_KEY || void 0
|
|
12521
|
+
tries: DEFAULTS.tries
|
|
12100
12522
|
};
|
|
12101
12523
|
}
|
|
12102
|
-
function
|
|
12103
|
-
|
|
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) => {
|
|
12104
12537
|
const ctx = contextOf(command);
|
|
12105
12538
|
const github = githubOf(ctx);
|
|
12106
12539
|
if (!github) throw new Error("RUNNER_TOKEN is empty");
|
|
12107
|
-
const
|
|
12108
|
-
|
|
12109
|
-
|
|
12110
|
-
|
|
12111
|
-
|
|
12112
|
-
|
|
12113
|
-
|
|
12114
|
-
|
|
12115
|
-
|
|
12116
|
-
|
|
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"));
|
|
12117
12557
|
});
|
|
12118
12558
|
}
|
|
12119
12559
|
|
|
12120
12560
|
// src/commands/plan.ts
|
|
12121
|
-
import { appendFileSync as
|
|
12561
|
+
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
12122
12562
|
import { resolve as resolve2 } from "node:path";
|
|
12123
|
-
|
|
12124
|
-
|
|
12125
|
-
|
|
12126
|
-
|
|
12127
|
-
|
|
12128
|
-
|
|
12129
|
-
|
|
12130
|
-
|
|
12131
|
-
const
|
|
12132
|
-
|
|
12133
|
-
|
|
12134
|
-
|
|
12135
|
-
|
|
12136
|
-
|
|
12137
|
-
|
|
12138
|
-
|
|
12139
|
-
|
|
12140
|
-
|
|
12141
|
-
|
|
12142
|
-
|
|
12143
|
-
|
|
12144
|
-
|
|
12145
|
-
|
|
12146
|
-
|
|
12147
|
-
|
|
12148
|
-
|
|
12149
|
-
|
|
12150
|
-
|
|
12151
|
-
|
|
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
|
|
12152
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
|
+
);
|
|
12153
12613
|
});
|
|
12154
12614
|
}
|
|
12155
|
-
|
|
12156
|
-
|
|
12157
|
-
|
|
12158
|
-
|
|
12159
|
-
|
|
12160
|
-
|
|
12161
|
-
}
|
|
12162
|
-
|
|
12163
|
-
const
|
|
12164
|
-
const
|
|
12165
|
-
|
|
12166
|
-
|
|
12167
|
-
|
|
12168
|
-
|
|
12169
|
-
|
|
12170
|
-
|
|
12171
|
-
}
|
|
12172
|
-
|
|
12173
|
-
|
|
12174
|
-
|
|
12175
|
-
|
|
12176
|
-
|
|
12177
|
-
|
|
12178
|
-
|
|
12179
|
-
|
|
12180
|
-
|
|
12181
|
-
|
|
12182
|
-
|
|
12183
|
-
}
|
|
12184
|
-
}
|
|
12185
|
-
builds.push(leg);
|
|
12186
|
-
}
|
|
12187
|
-
const tests = [];
|
|
12188
|
-
if (options.tests && options.mode !== "repro") {
|
|
12189
|
-
for (const leg of testLegsOf(options.apps, options.hashes, runId, attempt)) {
|
|
12190
|
-
if (options.mode === "build") {
|
|
12191
|
-
const port = await tagExists(ctx, leg.app, `test-${leg.hash}`);
|
|
12192
|
-
if (port !== void 0) {
|
|
12193
|
-
ctx.say(`${leg.app}: test-${leg.hash} exists in the registry on :${port}, nothing to test`);
|
|
12194
|
-
result.skippedTests.push({ app: leg.app, hash: leg.hash, port });
|
|
12195
|
-
continue;
|
|
12196
|
-
}
|
|
12197
|
-
}
|
|
12198
|
-
tests.push(leg);
|
|
12199
|
-
}
|
|
12200
|
-
}
|
|
12201
|
-
const launches = await Promise.allSettled([
|
|
12202
|
-
...builds.map(
|
|
12203
|
-
(leg) => launch(ctx, exo, github, { ...options.launch, kind: "build", label: leg.label, app: leg.app })
|
|
12204
|
-
),
|
|
12205
|
-
...tests.map(
|
|
12206
|
-
(leg) => launch(ctx, exo, github, { ...options.launch, kind: "test", label: leg.label, app: leg.app })
|
|
12207
|
-
)
|
|
12208
|
-
]);
|
|
12209
|
-
const failed = launches.filter((l) => l.status === "rejected");
|
|
12210
|
-
for (const l of launches) if (l.status === "fulfilled" && l.value.kept) result.kept = l.value.kept;
|
|
12211
|
-
if (failed.length > 0) {
|
|
12212
|
-
for (const f of failed) ctx.say(`\u2717 ${f.reason instanceof Error ? f.reason.message : String(f.reason)}`);
|
|
12213
|
-
throw new Error(`${failed.length} launch(es) failed`);
|
|
12214
|
-
}
|
|
12215
|
-
result.legs = builds;
|
|
12216
|
-
result.tests = tests;
|
|
12217
|
-
return result;
|
|
12218
|
-
}
|
|
12219
|
-
async function report(ctx, legs, mode, runId) {
|
|
12220
|
-
const apps = [...new Set(legs.map((l) => l.app))].sort();
|
|
12221
|
-
const out = [];
|
|
12222
|
-
for (const app of apps) {
|
|
12223
|
-
const hash = legs.find((l) => l.app === app)?.hash ?? "";
|
|
12224
|
-
const c = { app, hash, digest: await digest(ctx, app, `build-${hash}`) };
|
|
12225
|
-
ctx.say(`${app} build-${hash}: ${c.digest ?? "missing"}`);
|
|
12226
|
-
if (mode === "repro") {
|
|
12227
|
-
const [one, two] = await Promise.all([
|
|
12228
|
-
digest(ctx, app, `repro-${runId}-1`),
|
|
12229
|
-
digest(ctx, app, `repro-${runId}-2`)
|
|
12230
|
-
]);
|
|
12231
|
-
const identical = one !== void 0 && one === two;
|
|
12232
|
-
const [l1, l2] = identical ? [[], []] : await Promise.all([layers(ctx, app, `repro-${runId}-1`), layers(ctx, app, `repro-${runId}-2`)]);
|
|
12233
|
-
const differing = [];
|
|
12234
|
-
const a = l1 ?? [];
|
|
12235
|
-
const b = l2 ?? [];
|
|
12236
|
-
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
|
12237
|
-
if (a[i]?.digest !== b[i]?.digest)
|
|
12238
|
-
differing.push(
|
|
12239
|
-
`layer ${i + 1}: ${a[i]?.digest ?? "-"} (${a[i]?.size ?? "-"}) vs ${b[i]?.digest ?? "-"} (${b[i]?.size ?? "-"})`
|
|
12240
|
-
);
|
|
12241
|
-
}
|
|
12242
|
-
c.repro = { one, two, identical, layers: a.length, same: a.length - differing.length, differing };
|
|
12243
|
-
if (identical) ctx.say(` reproducible: both builds are ${one}`);
|
|
12244
|
-
else {
|
|
12245
|
-
ctx.say(
|
|
12246
|
-
` not identical: ${one ?? "-"} vs ${two ?? "-"}; layers: ${a.length} in each, ${a.length - differing.length} identical`
|
|
12247
|
-
);
|
|
12248
|
-
for (const d of differing) ctx.say(` ${d}`);
|
|
12249
|
-
}
|
|
12250
|
-
}
|
|
12251
|
-
out.push(c);
|
|
12252
|
-
}
|
|
12253
|
-
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] };
|
|
12254
12643
|
}
|
|
12255
12644
|
|
|
12256
|
-
// src/commands/
|
|
12645
|
+
// src/commands/publish.ts
|
|
12257
12646
|
function register4(program2) {
|
|
12258
|
-
program2.command("
|
|
12259
|
-
|
|
12260
|
-
).argument("[apps...]", "the apps (default: every directory under apps/)").requiredOption(
|
|
12261
|
-
"--mode <mode>",
|
|
12262
|
-
"build (skip what the registry holds), force (build all), repro (two no-cache builds per app)"
|
|
12263
|
-
).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(
|
|
12264
|
-
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) => {
|
|
12265
12649
|
const ctx = contextOf(command);
|
|
12266
|
-
const
|
|
12267
|
-
|
|
12268
|
-
|
|
12269
|
-
|
|
12270
|
-
|
|
12271
|
-
|
|
12272
|
-
|
|
12273
|
-
tests: opts.tests ?? false,
|
|
12274
|
-
hashes: await readHashes(ctx, dir, opts.scope),
|
|
12275
|
-
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))
|
|
12276
12657
|
});
|
|
12277
|
-
ctx.result(
|
|
12278
|
-
|
|
12279
|
-
|
|
12280
|
-
...r.legs.map((l) => `build ${l.app} ${l.hash} ${l.label}`),
|
|
12281
|
-
...r.tests.map((l) => `test ${l.app} ${l.hash} ${l.label}`)
|
|
12282
|
-
].join("\n")
|
|
12283
|
-
);
|
|
12284
|
-
if (process.env.GITHUB_OUTPUT)
|
|
12285
|
-
appendFileSync3(
|
|
12286
|
-
process.env.GITHUB_OUTPUT,
|
|
12287
|
-
`matrix=${JSON.stringify(result.legs)}
|
|
12288
|
-
tests=${JSON.stringify(result.tests)}
|
|
12289
|
-
apps=${JSON.stringify(result.apps)}
|
|
12290
|
-
kept=${result.kept ?? ""}
|
|
12291
|
-
`
|
|
12292
|
-
);
|
|
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
|
+
`);
|
|
12293
12661
|
}
|
|
12294
12662
|
);
|
|
12295
12663
|
}
|
|
12296
12664
|
|
|
12297
|
-
// src/
|
|
12298
|
-
|
|
12299
|
-
|
|
12300
|
-
|
|
12301
|
-
|
|
12302
|
-
|
|
12303
|
-
|
|
12304
|
-
|
|
12305
|
-
|
|
12306
|
-
|
|
12307
|
-
|
|
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
|
-
|
|
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;
|
|
12340
12710
|
}
|
|
12341
|
-
if (pending().length > 0) await sleep(1e4);
|
|
12342
12711
|
}
|
|
12343
|
-
|
|
12344
|
-
|
|
12345
|
-
|
|
12346
|
-
|
|
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
|
+
})
|
|
12347
12721
|
);
|
|
12348
|
-
if (b.id && (b.ok || !options.keep)) await exo.deleteInstance(b.id);
|
|
12349
|
-
else if (b.id) ctx.say(` kept ${b.name} for inspection`);
|
|
12350
12722
|
}
|
|
12351
|
-
|
|
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
|
+
}
|
|
12352
12745
|
ctx.say(
|
|
12353
|
-
|
|
12746
|
+
`rotate: ${pruned.length} candidate version(s) pruned from ${names.length} package(s) of ghcr.io/${packages.org}`
|
|
12354
12747
|
);
|
|
12355
|
-
return
|
|
12356
|
-
}
|
|
12357
|
-
|
|
12358
|
-
// src/commands/probe.ts
|
|
12359
|
-
function register5(program2) {
|
|
12360
|
-
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) => {
|
|
12361
|
-
const ctx = contextOf(command);
|
|
12362
|
-
const boots = await probe(ctx, exoOf(ctx), {
|
|
12363
|
-
template: opts.template,
|
|
12364
|
-
instanceType: opts.type,
|
|
12365
|
-
zone: zoneOf(),
|
|
12366
|
-
boots: Number(opts.boots),
|
|
12367
|
-
waitSeconds: Number(opts.wait),
|
|
12368
|
-
sshKey: opts.sshKey,
|
|
12369
|
-
keep: opts.keep,
|
|
12370
|
-
plain: opts.plain
|
|
12371
|
-
});
|
|
12372
|
-
ctx.result(boots, (b) => b.map((x) => `${x.name}: ${x.result}`).join("\n"));
|
|
12373
|
-
if (boots.some((b) => !b.ok)) process.exitCode = 1;
|
|
12374
|
-
});
|
|
12748
|
+
return pruned;
|
|
12375
12749
|
}
|
|
12376
12750
|
|
|
12377
12751
|
// src/commands/registry.ts
|
|
12378
|
-
function
|
|
12379
|
-
const registry = program2.command("registry").description(
|
|
12380
|
-
"
|
|
12381
|
-
).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) => {
|
|
12382
12756
|
const ctx = contextOf(command);
|
|
12383
|
-
const
|
|
12384
|
-
if (
|
|
12385
|
-
|
|
12386
|
-
|
|
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")));
|
|
12387
12766
|
ctx.result(
|
|
12388
12767
|
result,
|
|
12389
12768
|
(r) => `:5000 ${r.current}
|
|
12390
12769
|
:5001 ${r.previous}${r.previousAnswers ? "" : " (not answering)"}`
|
|
12391
12770
|
);
|
|
12392
12771
|
});
|
|
12393
|
-
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) => {
|
|
12394
12773
|
const ctx = contextOf(command);
|
|
12395
|
-
const
|
|
12396
|
-
if (
|
|
12774
|
+
const ref = await findTag(ctx, registryFor(settings(opts)), app, tag);
|
|
12775
|
+
if (ref === void 0) {
|
|
12397
12776
|
ctx.say(`${app}:${tag} is in no registry here`);
|
|
12398
12777
|
process.exitCode = 1;
|
|
12399
|
-
} else ctx.result(
|
|
12778
|
+
} else ctx.result(ref);
|
|
12400
12779
|
});
|
|
12401
|
-
|
|
12402
|
-
|
|
12403
|
-
|
|
12404
|
-
import { appendFileSync as appendFileSync4 } from "node:fs";
|
|
12405
|
-
|
|
12406
|
-
// src/core/release.ts
|
|
12407
|
-
var imageName = (prefix, app) => `${prefix.toLowerCase().replace(/\/$/, "")}/${app}`;
|
|
12408
|
-
async function exists(ctx, ref) {
|
|
12409
|
-
try {
|
|
12410
|
-
await ctx.read("crane", ["digest", ref]);
|
|
12411
|
-
return true;
|
|
12412
|
-
} catch {
|
|
12413
|
-
return false;
|
|
12414
|
-
}
|
|
12415
|
-
}
|
|
12416
|
-
async function release(ctx, options) {
|
|
12417
|
-
const { app, build: build2, test: test2, sha } = options;
|
|
12418
|
-
const image = imageName(options.prefix, app);
|
|
12419
|
-
const target = `${image}:build-${build2}`;
|
|
12420
|
-
const copied = !await exists(ctx, target);
|
|
12421
|
-
if (copied) {
|
|
12422
|
-
const source = test2 ? `test-${test2}` : `build-${build2}`;
|
|
12423
|
-
const port = await tagExists(ctx, app, source);
|
|
12424
|
-
if (port === void 0 && !ctx.dryRun)
|
|
12425
|
-
throw new Error(
|
|
12426
|
-
`${app}:${source} is in no registry here; ${test2 ? "the tests did not pass on this build" : "the build never ran"}`
|
|
12427
|
-
);
|
|
12428
|
-
ctx.say(`release: ${app}:${source} -> ${target}`);
|
|
12429
|
-
await ctx.apply([run("crane", ["copy", "--insecure", `localhost:${port ?? 5e3}/${app}:${source}`, target])]);
|
|
12430
|
-
} else ctx.say(`release: ${target} is there already, tagging only`);
|
|
12431
|
-
const tags = [sha, ...test2 ? [`test-${test2}`] : []];
|
|
12432
|
-
await ctx.apply(tags.map((t) => run("crane", ["tag", target, t])));
|
|
12433
|
-
return { app, image, copied, tags: [`build-${build2}`, ...tags] };
|
|
12434
|
-
}
|
|
12435
|
-
|
|
12436
|
-
// src/commands/release.ts
|
|
12437
|
-
function register7(program2) {
|
|
12438
|
-
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) => {
|
|
12439
|
-
const ctx = contextOf(command);
|
|
12440
|
-
const prefix = process.env.IMAGE_PREFIX || `ghcr.io/${ownerOf(need("GITHUB_REPOSITORY"))}`;
|
|
12441
|
-
const result = await release(ctx, { app, build: build2, test: opts.test || void 0, sha, prefix });
|
|
12442
|
-
ctx.result(result, (r) => `${r.image}:${sha}${r.copied ? "" : " (was there)"}`);
|
|
12443
|
-
if (process.env.GITHUB_OUTPUT) appendFileSync4(process.env.GITHUB_OUTPUT, `image=${result.image}:${sha}
|
|
12444
|
-
`);
|
|
12445
|
-
});
|
|
12446
|
-
}
|
|
12447
|
-
|
|
12448
|
-
// src/commands/report.ts
|
|
12449
|
-
function register8(program2) {
|
|
12450
|
-
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) => {
|
|
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) => {
|
|
12451
12783
|
const ctx = contextOf(command);
|
|
12452
|
-
const
|
|
12453
|
-
const
|
|
12454
|
-
|
|
12455
|
-
|
|
12456
|
-
|
|
12457
|
-
|
|
12458
|
-
|
|
12459
|
-
|
|
12460
|
-
const reasons = [];
|
|
12461
|
-
if (instance.state === "stopped") reasons.push("stopped");
|
|
12462
|
-
const deadline2 = instance.labels["death-deadline"];
|
|
12463
|
-
if (deadline2 && deadline2 < now) reasons.push(`past its deadline ${deadline2}`);
|
|
12464
|
-
return reasons.length > 0 ? reasons.join(", ") : void 0;
|
|
12465
|
-
}
|
|
12466
|
-
async function sweep(ctx, exo, options = {}) {
|
|
12467
|
-
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
12468
|
-
const result = { removed: [], kept: [] };
|
|
12469
|
-
for (const i of await exo.listInstances()) {
|
|
12470
|
-
if (i.labels["plexus-ci"] !== "true") continue;
|
|
12471
|
-
const reason = reasonToRemove(i, iso(now));
|
|
12472
|
-
if (reason === void 0) {
|
|
12473
|
-
ctx.say(`${i.name} (${i.id}): ${i.state}, until ${i.labels["death-deadline"] ?? "-"}, kept`);
|
|
12474
|
-
result.kept.push(i.name);
|
|
12475
|
-
continue;
|
|
12476
|
-
}
|
|
12477
|
-
ctx.say(`${i.name} (${i.id}): ${reason}`);
|
|
12478
|
-
await exo.deleteInstance(i.id);
|
|
12479
|
-
result.removed.push({ name: i.name, reason });
|
|
12480
|
-
}
|
|
12481
|
-
if (result.removed.length > 0 && options.smtp) {
|
|
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")}
|
|
12485
|
-
`;
|
|
12486
|
-
await sendMail(ctx, options.smtp, "plexus-ci: the sweep removed instances", text, now);
|
|
12487
|
-
}
|
|
12488
|
-
ctx.say(`sweep: ${result.removed.length} removed`);
|
|
12489
|
-
if (options.storage && options.owner) {
|
|
12490
|
-
const { expired } = registryBuckets(options.owner, now);
|
|
12491
|
-
const sos = new Storage(ctx, options.storage.accessKey, options.storage.secretKey);
|
|
12492
|
-
if (await sos.bucketExists(expired)) {
|
|
12493
|
-
ctx.say(`sweep: the registry bucket ${expired} is two months old`);
|
|
12494
|
-
await sos.removeBucket(expired);
|
|
12495
|
-
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;
|
|
12496
12792
|
}
|
|
12497
|
-
|
|
12498
|
-
|
|
12499
|
-
|
|
12500
|
-
|
|
12501
|
-
// src/commands/sweep.ts
|
|
12502
|
-
function register9(program2) {
|
|
12503
|
-
program2.command("sweep").description(
|
|
12504
|
-
"delete every plexus-ci instance that is stopped or past its deadline, mail about it, rotate the registry buckets"
|
|
12505
|
-
).action(async (_opts, command) => {
|
|
12506
|
-
const ctx = contextOf(command);
|
|
12507
|
-
const repo = process.env.GITHUB_REPOSITORY;
|
|
12508
|
-
const result = await sweep(ctx, exoOf(ctx), {
|
|
12509
|
-
smtp: smtpFromEnv(),
|
|
12510
|
-
storage: storageFromEnv(),
|
|
12511
|
-
owner: repo ? ownerOf(repo) : void 0
|
|
12512
|
-
});
|
|
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);
|
|
12513
12797
|
ctx.result(
|
|
12514
12798
|
result,
|
|
12515
|
-
(r) => [...r.
|
|
12799
|
+
(r) => [...r.created ? [`created ${r.created}`] : [], ...r.removed ? [`removed ${r.removed}`] : []].join("\n")
|
|
12516
12800
|
);
|
|
12517
12801
|
});
|
|
12518
12802
|
}
|
|
12519
12803
|
|
|
12520
|
-
// src/commands/
|
|
12521
|
-
function
|
|
12522
|
-
program2.command("
|
|
12523
|
-
"delete every instance this run launched, kept ones excepted, and the runner registrations it never used"
|
|
12524
|
-
).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) => {
|
|
12525
12807
|
const ctx = contextOf(command);
|
|
12526
|
-
const
|
|
12527
|
-
const result = await
|
|
12528
|
-
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, () => "");
|
|
12529
12811
|
});
|
|
12530
12812
|
}
|
|
12531
12813
|
|
|
12532
12814
|
// src/commands/test.ts
|
|
12533
|
-
import { appendFileSync as
|
|
12534
|
-
import { resolve as
|
|
12815
|
+
import { appendFileSync as appendFileSync4 } from "node:fs";
|
|
12816
|
+
import { resolve as resolve4 } from "node:path";
|
|
12535
12817
|
|
|
12536
12818
|
// src/core/test.ts
|
|
12537
12819
|
import { join as join4 } from "node:path";
|
|
@@ -12557,16 +12839,15 @@ function specEnv(port, fromOverride) {
|
|
|
12557
12839
|
};
|
|
12558
12840
|
}
|
|
12559
12841
|
async function test(ctx, options) {
|
|
12560
|
-
const { app, hash, build: build2 } = options;
|
|
12842
|
+
const { app, hash, build: build2, registry } = options;
|
|
12561
12843
|
const dir = options.dir ?? ".";
|
|
12562
12844
|
const port = options.port ?? 3e3;
|
|
12563
12845
|
const appDir = join4(dir, "apps", app);
|
|
12564
12846
|
const composeDir = join4(appDir, "docker");
|
|
12565
12847
|
const project = `ci-${app}`;
|
|
12566
|
-
const
|
|
12567
|
-
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)
|
|
12568
12850
|
throw new Error(`${app}:build-${build2} is in no registry here; the build failed or never ran`);
|
|
12569
|
-
const image = `localhost:${registryPort}/${app}:build-${build2}`;
|
|
12570
12851
|
const tag = `test-${hash}`;
|
|
12571
12852
|
const fromOverride = overrideEnv(await ctx.readFile(join4(composeDir, OVERRIDE)));
|
|
12572
12853
|
const composeEnv = { PLEXUS_DEPLOYMENT_IMAGE: image, PLEXUS_LOOPBACK_PORT: String(port) };
|
|
@@ -12601,48 +12882,39 @@ ${logs}`);
|
|
|
12601
12882
|
await ctx.apply([compose(["--profile", "migrate", "down", "--volumes", "--remove-orphans", "--timeout", "10"])]);
|
|
12602
12883
|
}
|
|
12603
12884
|
await ctx.apply([
|
|
12604
|
-
run("docker", ["buildx", "imagetools", "create", "--tag",
|
|
12885
|
+
run("docker", ["buildx", "imagetools", "create", "--tag", candidateRef(registry, app, tag), image])
|
|
12605
12886
|
]);
|
|
12606
12887
|
const result = { app, hash, image, tag, stackSeconds, testSeconds };
|
|
12607
12888
|
if (!ctx.dryRun) {
|
|
12608
|
-
result.digest = await
|
|
12889
|
+
result.digest = await digestOf(ctx, registry, app, tag);
|
|
12609
12890
|
ctx.say(`test: ${app} ${tag} -> ${result.digest ?? "<no digest>"}`);
|
|
12610
12891
|
}
|
|
12611
12892
|
return result;
|
|
12612
12893
|
}
|
|
12613
12894
|
|
|
12614
12895
|
// src/commands/test.ts
|
|
12615
|
-
function
|
|
12616
|
-
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(
|
|
12617
|
-
|
|
12618
|
-
|
|
12619
|
-
|
|
12620
|
-
|
|
12621
|
-
|
|
12622
|
-
|
|
12623
|
-
|
|
12624
|
-
|
|
12625
|
-
|
|
12626
|
-
|
|
12627
|
-
|
|
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}
|
|
12628
12911
|
`);
|
|
12629
|
-
|
|
12912
|
+
}
|
|
12913
|
+
);
|
|
12630
12914
|
}
|
|
12631
12915
|
|
|
12632
12916
|
// src/commands/index.ts
|
|
12633
|
-
var commands = [
|
|
12634
|
-
register4,
|
|
12635
|
-
register3,
|
|
12636
|
-
register,
|
|
12637
|
-
register11,
|
|
12638
|
-
register7,
|
|
12639
|
-
register8,
|
|
12640
|
-
register10,
|
|
12641
|
-
register9,
|
|
12642
|
-
register6,
|
|
12643
|
-
register2,
|
|
12644
|
-
register5
|
|
12645
|
-
];
|
|
12917
|
+
var commands = [register5, register3, register, register7, register4, register6, register2];
|
|
12646
12918
|
|
|
12647
12919
|
// src/program.ts
|
|
12648
12920
|
var { version, description } = createRequire(import.meta.url)("../package.json");
|
|
@@ -12651,7 +12923,7 @@ function buildProgram() {
|
|
|
12651
12923
|
program2.addOption(new Option("-n, --dry-run", "print every effect this run would have, perform none").env("PLX_DRY_RUN")).addOption(
|
|
12652
12924
|
new Option("-v, --verbose", "say more: once for reads and inputs, twice for command output").argParser((_value, previous) => previous + 1).default(0)
|
|
12653
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"));
|
|
12654
|
-
for (const
|
|
12926
|
+
for (const register8 of commands) register8(program2);
|
|
12655
12927
|
return program2;
|
|
12656
12928
|
}
|
|
12657
12929
|
function contextOf(command) {
|