@rallycry/conveyor-k3 0.1.11 → 0.1.12
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/cli.js +4941 -1129
- package/dist/index.d.ts +258 -82
- package/dist/index.js +1782 -362
- package/dist/vm-qemu.js +256 -91
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -21,8 +21,61 @@ function buildConveyorSocketOptions(auth) {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// src/daemon/control-endpoint.ts
|
|
25
|
+
import { lookup as dnsLookup } from "dns";
|
|
26
|
+
import { existsSync, readFileSync } from "fs";
|
|
27
|
+
import { Agent } from "https";
|
|
28
|
+
var CONTROL_FILE = "/etc/conveyor-vm/control.json";
|
|
29
|
+
var CONTROL_ALIAS_IP = /^192\.168\.5\.24[0-7]$/;
|
|
30
|
+
function readControlFile(path) {
|
|
31
|
+
try {
|
|
32
|
+
return readFileSync(path, "utf8");
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function controlAlias(apiUrl, text) {
|
|
38
|
+
if (!text) return void 0;
|
|
39
|
+
let hostname;
|
|
40
|
+
let aliases;
|
|
41
|
+
try {
|
|
42
|
+
hostname = new URL(apiUrl).hostname.toLowerCase();
|
|
43
|
+
aliases = JSON.parse(text);
|
|
44
|
+
} catch {
|
|
45
|
+
return void 0;
|
|
46
|
+
}
|
|
47
|
+
if (typeof aliases !== "object" || aliases === null || Array.isArray(aliases)) return void 0;
|
|
48
|
+
const alias = aliases[hostname];
|
|
49
|
+
return typeof alias === "string" && CONTROL_ALIAS_IP.test(alias) ? { hostname, alias } : void 0;
|
|
50
|
+
}
|
|
51
|
+
function controlPlaneRoute(apiUrl, readFile3 = readControlFile, fileExists = existsSync) {
|
|
52
|
+
if (!fileExists(CONTROL_FILE)) return void 0;
|
|
53
|
+
let skipAlias = false;
|
|
54
|
+
let usedAlias = false;
|
|
55
|
+
const lookup = (hostname, options, callback) => {
|
|
56
|
+
const target = skipAlias ? void 0 : controlAlias(apiUrl, readFile3(CONTROL_FILE));
|
|
57
|
+
usedAlias = target !== void 0 && hostname.toLowerCase() === target.hostname;
|
|
58
|
+
if (!target || !usedAlias) {
|
|
59
|
+
dnsLookup(hostname, options, callback);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (options.all) {
|
|
63
|
+
const addresses = [{ address: target.alias, family: 4 }];
|
|
64
|
+
callback(null, addresses);
|
|
65
|
+
} else callback(null, target.alias, 4);
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
agent: new Agent({ lookup }),
|
|
69
|
+
// After a failure through the alias, try DNS; after a failure through DNS,
|
|
70
|
+
// try the alias again. A lasting fault in either route costs one attempt.
|
|
71
|
+
failed: () => {
|
|
72
|
+
skipAlias = usedAlias;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
24
77
|
// src/daemon/device-proof.ts
|
|
25
|
-
import { lstatSync, readFileSync } from "fs";
|
|
78
|
+
import { lstatSync, readFileSync as readFileSync2 } from "fs";
|
|
26
79
|
import { createPrivateKey, randomBytes, sign } from "crypto";
|
|
27
80
|
var DEVICE_KEY_PATH = "/etc/conveyor/device-key.pem";
|
|
28
81
|
function privateDeviceKey(path, ownerUid) {
|
|
@@ -37,7 +90,7 @@ function privateDeviceKey(path, ownerUid) {
|
|
|
37
90
|
throw new Error("Conveyor device key must be a regular file.");
|
|
38
91
|
if ((stat.mode & 511) !== 384 || stat.uid !== ownerUid)
|
|
39
92
|
throw new Error("Conveyor device key must be owned by root with mode 0600.");
|
|
40
|
-
const pem =
|
|
93
|
+
const pem = readFileSync2(path, "utf8");
|
|
41
94
|
if (Buffer.byteLength(pem) > 16 * 1024) throw new Error("Conveyor device key is too large.");
|
|
42
95
|
const key = createPrivateKey(pem);
|
|
43
96
|
if (key.asymmetricKeyType !== "ed25519")
|
|
@@ -4109,7 +4162,7 @@ var coerce = {
|
|
|
4109
4162
|
};
|
|
4110
4163
|
var NEVER = INVALID;
|
|
4111
4164
|
|
|
4112
|
-
// ../shared/dist/chunk-
|
|
4165
|
+
// ../shared/dist/chunk-BH4SLNGU.js
|
|
4113
4166
|
var DEFAULT_SONNET_MODEL = "claude-sonnet-5-5";
|
|
4114
4167
|
var DEFAULT_OPUS_MODEL = "claude-opus-5-5";
|
|
4115
4168
|
var DEFAULT_HAIKU_MODEL = "claude-haiku-4-5-20251001";
|
|
@@ -4600,6 +4653,11 @@ var DeleteTaskFileRequestSchema = external_exports.object({
|
|
|
4600
4653
|
sessionId: external_exports.string(),
|
|
4601
4654
|
fileId: external_exports.string()
|
|
4602
4655
|
});
|
|
4656
|
+
var SetTaskFileShowcaseRequestSchema = external_exports.object({
|
|
4657
|
+
sessionId: external_exports.string(),
|
|
4658
|
+
fileId: external_exports.string(),
|
|
4659
|
+
showcase: external_exports.boolean()
|
|
4660
|
+
});
|
|
4603
4661
|
var GetTaskRequestSchema = external_exports.object({
|
|
4604
4662
|
sessionId: external_exports.string(),
|
|
4605
4663
|
taskSlugOrId: external_exports.string()
|
|
@@ -4648,7 +4706,11 @@ var ConfirmFileUploadRequestSchema = external_exports.object({
|
|
|
4648
4706
|
fileId: external_exports.string(),
|
|
4649
4707
|
title: external_exports.string().max(500).optional(),
|
|
4650
4708
|
/** Glossary tag names (or ids) this file is an example of. */
|
|
4651
|
-
tags: external_exports.array(external_exports.string().min(1).max(MAX_FILE_TAG_LENGTH)).max(MAX_FILE_TAGS).optional()
|
|
4709
|
+
tags: external_exports.array(external_exports.string().min(1).max(MAX_FILE_TAG_LENGTH)).max(MAX_FILE_TAGS).optional(),
|
|
4710
|
+
/** Mark the file as the card's showcase (the deliverable). */
|
|
4711
|
+
showcase: external_exports.boolean().optional(),
|
|
4712
|
+
/** Id of an older file on the same card this upload replaces. */
|
|
4713
|
+
supersedes: external_exports.string().min(1).max(64).optional()
|
|
4652
4714
|
});
|
|
4653
4715
|
var UpdateTaskStatusRequestSchema = external_exports.object({
|
|
4654
4716
|
sessionId: external_exports.string(),
|
|
@@ -5183,6 +5245,9 @@ var CreateProjectTaskRequestSchema = external_exports.object({
|
|
|
5183
5245
|
status: external_exports.string().optional(),
|
|
5184
5246
|
// Assign to a sub-project board. Validated to belong to `projectId` in the handler.
|
|
5185
5247
|
subProjectId: external_exports.string().nullable().optional(),
|
|
5248
|
+
// Priority value. Resolved to the project's configured Priority row in the
|
|
5249
|
+
// handler, which rejects an unconfigured value before the card is written.
|
|
5250
|
+
priorityValue: external_exports.number().int().positive().optional(),
|
|
5186
5251
|
requestingUserId: external_exports.string().optional()
|
|
5187
5252
|
});
|
|
5188
5253
|
var SetProjectTaskParentRequestSchema = external_exports.object({
|
|
@@ -5212,6 +5277,9 @@ var UpdateProjectTaskRequestSchema = external_exports.object({
|
|
|
5212
5277
|
// Story-point value, or null to clear. Resolved to the project's configured
|
|
5213
5278
|
// StoryPoint row in the handler, which rejects an unconfigured value.
|
|
5214
5279
|
storyPointValue: external_exports.number().int().positive().nullable().optional(),
|
|
5280
|
+
// Priority value, or null to clear. Resolved to the project's configured
|
|
5281
|
+
// Priority row in the handler, which rejects an unconfigured value.
|
|
5282
|
+
priorityValue: external_exports.number().int().positive().nullable().optional(),
|
|
5215
5283
|
assignedUserId: external_exports.string().nullish(),
|
|
5216
5284
|
// Move to a different sub-project board, or null to move to the parent board.
|
|
5217
5285
|
// Validated to belong to `projectId` in the handler.
|
|
@@ -5228,9 +5296,9 @@ var UpdateProjectTaskRequestSchema = external_exports.object({
|
|
|
5228
5296
|
onHold: external_exports.boolean().optional(),
|
|
5229
5297
|
requestingUserId: external_exports.string().optional()
|
|
5230
5298
|
}).strict().refine(
|
|
5231
|
-
(v) => v.title !== void 0 || v.description !== void 0 || v.plan !== void 0 || v.status !== void 0 || v.risk !== void 0 || v.storyPointValue !== void 0 || v.assignedUserId !== void 0 || v.subProjectId !== void 0 || v.githubBranch !== void 0 || v.onHold !== void 0,
|
|
5299
|
+
(v) => v.title !== void 0 || v.description !== void 0 || v.plan !== void 0 || v.status !== void 0 || v.risk !== void 0 || v.storyPointValue !== void 0 || v.priorityValue !== void 0 || v.assignedUserId !== void 0 || v.subProjectId !== void 0 || v.githubBranch !== void 0 || v.onHold !== void 0,
|
|
5232
5300
|
{
|
|
5233
|
-
message: "update_task requires at least one field to change (title, description, plan, status, risk, storyPointValue, assignedUserId, subProjectId, githubBranch, or onHold)"
|
|
5301
|
+
message: "update_task requires at least one field to change (title, description, plan, status, risk, storyPointValue, priorityValue, assignedUserId, subProjectId, githubBranch, or onHold)"
|
|
5234
5302
|
}
|
|
5235
5303
|
);
|
|
5236
5304
|
var TransitionProjectTaskStatusRequestSchema = external_exports.object({
|
|
@@ -5478,43 +5546,6 @@ var GetProjectTaskDependenciesRequestSchema = external_exports.object({
|
|
|
5478
5546
|
projectId: external_exports.string(),
|
|
5479
5547
|
taskId: external_exports.string()
|
|
5480
5548
|
});
|
|
5481
|
-
var ListProjectTaskFilesRequestSchema = external_exports.object({
|
|
5482
|
-
projectId: external_exports.string(),
|
|
5483
|
-
taskId: external_exports.string()
|
|
5484
|
-
});
|
|
5485
|
-
var GetProjectAttachmentRequestSchema = external_exports.object({
|
|
5486
|
-
projectId: external_exports.string(),
|
|
5487
|
-
taskId: external_exports.string(),
|
|
5488
|
-
fileId: external_exports.string(),
|
|
5489
|
-
/** Byte offset into text content (paging large logs/JSON). Default 0. */
|
|
5490
|
-
offset: external_exports.number().int().nonnegative().optional(),
|
|
5491
|
-
/** Max bytes of text content to return from `offset`. Server default applies. */
|
|
5492
|
-
maxBytes: external_exports.number().int().positive().optional()
|
|
5493
|
-
});
|
|
5494
|
-
var DeleteProjectTaskFileRequestSchema = external_exports.object({
|
|
5495
|
-
projectId: external_exports.string(),
|
|
5496
|
-
taskId: external_exports.string(),
|
|
5497
|
-
fileId: external_exports.string(),
|
|
5498
|
-
requestingUserId: external_exports.string().optional()
|
|
5499
|
-
});
|
|
5500
|
-
var RequestProjectFileUploadRequestSchema = external_exports.object({
|
|
5501
|
-
projectId: external_exports.string(),
|
|
5502
|
-
taskId: external_exports.string(),
|
|
5503
|
-
fileName: external_exports.string().min(1).max(255),
|
|
5504
|
-
mimeType: external_exports.string().min(1).max(128),
|
|
5505
|
-
fileSize: external_exports.number().int().positive().max(MAX_FILE_SIZE_BYTES),
|
|
5506
|
-
requestingUserId: external_exports.string().optional()
|
|
5507
|
-
});
|
|
5508
|
-
var ConfirmProjectFileUploadRequestSchema = external_exports.object({
|
|
5509
|
-
projectId: external_exports.string(),
|
|
5510
|
-
taskId: external_exports.string(),
|
|
5511
|
-
fileId: external_exports.string(),
|
|
5512
|
-
/** When set, the attachment is also posted to the task chat with this text. */
|
|
5513
|
-
comment: external_exports.string().max(2e3).optional(),
|
|
5514
|
-
/** Glossary tag names (or ids) this file is an example of. */
|
|
5515
|
-
tags: external_exports.array(external_exports.string().min(1).max(MAX_FILE_TAG_LENGTH)).max(MAX_FILE_TAGS).optional(),
|
|
5516
|
-
requestingUserId: external_exports.string().optional()
|
|
5517
|
-
});
|
|
5518
5549
|
var CreateProjectPullRequestRequestSchema = external_exports.object({
|
|
5519
5550
|
projectId: external_exports.string(),
|
|
5520
5551
|
taskId: external_exports.string(),
|
|
@@ -5621,6 +5652,54 @@ var GetProjectAnalyticsSummaryRequestSchema = external_exports.object({
|
|
|
5621
5652
|
rangeDays: external_exports.number().int().min(1).max(GET_ANALYTICS_SUMMARY_MAX_RANGE_DAYS).optional(),
|
|
5622
5653
|
campaign: external_exports.string().max(200).optional()
|
|
5623
5654
|
});
|
|
5655
|
+
var ListProjectTaskFilesRequestSchema = external_exports.object({
|
|
5656
|
+
projectId: external_exports.string(),
|
|
5657
|
+
taskId: external_exports.string()
|
|
5658
|
+
});
|
|
5659
|
+
var GetProjectAttachmentRequestSchema = external_exports.object({
|
|
5660
|
+
projectId: external_exports.string(),
|
|
5661
|
+
taskId: external_exports.string(),
|
|
5662
|
+
fileId: external_exports.string(),
|
|
5663
|
+
/** Byte offset into text content (paging large logs/JSON). Default 0. */
|
|
5664
|
+
offset: external_exports.number().int().nonnegative().optional(),
|
|
5665
|
+
/** Max bytes of text content to return from `offset`. Server default applies. */
|
|
5666
|
+
maxBytes: external_exports.number().int().positive().optional()
|
|
5667
|
+
});
|
|
5668
|
+
var DeleteProjectTaskFileRequestSchema = external_exports.object({
|
|
5669
|
+
projectId: external_exports.string(),
|
|
5670
|
+
taskId: external_exports.string(),
|
|
5671
|
+
fileId: external_exports.string(),
|
|
5672
|
+
requestingUserId: external_exports.string().optional()
|
|
5673
|
+
});
|
|
5674
|
+
var SetProjectTaskFileShowcaseRequestSchema = external_exports.object({
|
|
5675
|
+
projectId: external_exports.string(),
|
|
5676
|
+
taskId: external_exports.string(),
|
|
5677
|
+
fileId: external_exports.string(),
|
|
5678
|
+
showcase: external_exports.boolean(),
|
|
5679
|
+
requestingUserId: external_exports.string().optional()
|
|
5680
|
+
});
|
|
5681
|
+
var RequestProjectFileUploadRequestSchema = external_exports.object({
|
|
5682
|
+
projectId: external_exports.string(),
|
|
5683
|
+
taskId: external_exports.string(),
|
|
5684
|
+
fileName: external_exports.string().min(1).max(255),
|
|
5685
|
+
mimeType: external_exports.string().min(1).max(128),
|
|
5686
|
+
fileSize: external_exports.number().int().positive().max(MAX_FILE_SIZE_BYTES),
|
|
5687
|
+
requestingUserId: external_exports.string().optional()
|
|
5688
|
+
});
|
|
5689
|
+
var ConfirmProjectFileUploadRequestSchema = external_exports.object({
|
|
5690
|
+
projectId: external_exports.string(),
|
|
5691
|
+
taskId: external_exports.string(),
|
|
5692
|
+
fileId: external_exports.string(),
|
|
5693
|
+
/** When set, the attachment is also posted to the task chat with this text. */
|
|
5694
|
+
comment: external_exports.string().max(2e3).optional(),
|
|
5695
|
+
/** Glossary tag names (or ids) this file is an example of. */
|
|
5696
|
+
tags: external_exports.array(external_exports.string().min(1).max(MAX_FILE_TAG_LENGTH)).max(MAX_FILE_TAGS).optional(),
|
|
5697
|
+
/** The card's showcase (its deliverable); always posts to chat, even with no `comment`. */
|
|
5698
|
+
showcase: external_exports.boolean().optional(),
|
|
5699
|
+
/** Id of an older file on the same card this upload replaces. */
|
|
5700
|
+
supersedes: external_exports.string().min(1).max(64).optional(),
|
|
5701
|
+
requestingUserId: external_exports.string().optional()
|
|
5702
|
+
});
|
|
5624
5703
|
var RequestWorkspaceRecycleRequestSchema = external_exports.object({
|
|
5625
5704
|
sessionId: external_exports.string(),
|
|
5626
5705
|
reason: external_exports.string().max(2e3)
|
|
@@ -6189,6 +6268,7 @@ function isArtifactRegistryHost(host) {
|
|
|
6189
6268
|
return bare.endsWith(".pkg.dev") || bare === "gcr.io" || bare.endsWith(".gcr.io");
|
|
6190
6269
|
}
|
|
6191
6270
|
var CONTAINERD_K8S_NAMESPACE = "k8s.io";
|
|
6271
|
+
var CONVEYOR_CONTENT_HASH_LABEL = "com.rallycry.conveyor.content-hash";
|
|
6192
6272
|
var LOCAL_BAKE_ROOT_COMMANDS = [
|
|
6193
6273
|
{
|
|
6194
6274
|
name: "import",
|
|
@@ -6201,6 +6281,15 @@ var LOCAL_BAKE_ROOT_COMMANDS = [
|
|
|
6201
6281
|
takesImageArgs: true
|
|
6202
6282
|
}
|
|
6203
6283
|
];
|
|
6284
|
+
var PERSONAL_LOCAL_REGISTRY_NAMESPACE = "conveyor-system";
|
|
6285
|
+
var PERSONAL_LOCAL_REGISTRY_NAME = "conveyor-registry";
|
|
6286
|
+
var PERSONAL_LOCAL_REGISTRY_PORT = 5e3;
|
|
6287
|
+
var PERSONAL_LOCAL_REGISTRY_HOST = `${PERSONAL_LOCAL_REGISTRY_NAME}.${PERSONAL_LOCAL_REGISTRY_NAMESPACE}.svc.cluster.local:${PERSONAL_LOCAL_REGISTRY_PORT}`;
|
|
6288
|
+
var PERSONAL_LOCAL_REGISTRY_NODE_ENDPOINT = `http://127.0.0.1:${PERSONAL_LOCAL_REGISTRY_PORT}`;
|
|
6289
|
+
var BAKE_COMPONENT_LABEL = "claudespace.rallycry.com/component";
|
|
6290
|
+
var BAKE_COMPONENT_PREBAKE = "prebake";
|
|
6291
|
+
var BAKE_COMPONENT_STATE_BAKE = "state-bake";
|
|
6292
|
+
var BAKE_COMPONENT_VALUES = [BAKE_COMPONENT_PREBAKE, BAKE_COMPONENT_STATE_BAKE];
|
|
6204
6293
|
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
6205
6294
|
var POSTGRES_ENV = {
|
|
6206
6295
|
POSTGRES_HOST_AUTH_METHOD: "trust",
|
|
@@ -6552,7 +6641,15 @@ var ClusterConnection = class {
|
|
|
6552
6641
|
reject(error instanceof Error ? error : new Error(String(error)));
|
|
6553
6642
|
return;
|
|
6554
6643
|
}
|
|
6555
|
-
const
|
|
6644
|
+
const route = controlPlaneRoute(
|
|
6645
|
+
this.config.apiUrl,
|
|
6646
|
+
this.config.readControlFile,
|
|
6647
|
+
this.config.controlFileExists
|
|
6648
|
+
);
|
|
6649
|
+
const socket = io(this.config.apiUrl, {
|
|
6650
|
+
...buildConveyorSocketOptions(auth),
|
|
6651
|
+
...route ? { transportOptions: { websocket: { agent: route.agent } } } : {}
|
|
6652
|
+
});
|
|
6556
6653
|
this.socket = socket;
|
|
6557
6654
|
socket.on("connect", () => {
|
|
6558
6655
|
if (!settled) {
|
|
@@ -6568,6 +6665,7 @@ var ClusterConnection = class {
|
|
|
6568
6665
|
settled = true;
|
|
6569
6666
|
reject(err);
|
|
6570
6667
|
}
|
|
6668
|
+
if (!isAuthFailure) route?.failed();
|
|
6571
6669
|
});
|
|
6572
6670
|
socket.on("disconnect", (reason) => {
|
|
6573
6671
|
process.stderr.write(`[conveyor-k3] disconnected: ${reason}
|
|
@@ -6939,31 +7037,6 @@ function countPinnedWanted(inventory, wantedImages) {
|
|
|
6939
7037
|
|
|
6940
7038
|
// src/lib/registries.ts
|
|
6941
7039
|
import { parse, stringify } from "yaml";
|
|
6942
|
-
function parseRegistries(raw) {
|
|
6943
|
-
if (!raw || raw.trim().length === 0) return {};
|
|
6944
|
-
const parsed = parse(raw);
|
|
6945
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
6946
|
-
return parsed;
|
|
6947
|
-
}
|
|
6948
|
-
function configuredAuthHosts(file) {
|
|
6949
|
-
return Object.entries(file.configs ?? {}).filter(([, entry]) => Boolean(entry?.auth)).map(([host]) => host).sort();
|
|
6950
|
-
}
|
|
6951
|
-
function missingAuthHosts(file, requiredHosts) {
|
|
6952
|
-
const configured = new Set(configuredAuthHosts(file));
|
|
6953
|
-
return requiredHosts.filter((host) => !configured.has(host)).sort();
|
|
6954
|
-
}
|
|
6955
|
-
function upsertRegistryAuth(file, host, auth) {
|
|
6956
|
-
assertRegistryHost(host);
|
|
6957
|
-
const next = { ...file };
|
|
6958
|
-
const configs = { ...next.configs ?? {} };
|
|
6959
|
-
const existing = configs[host] ?? {};
|
|
6960
|
-
configs[host] = { ...existing, auth };
|
|
6961
|
-
next.configs = configs;
|
|
6962
|
-
return next;
|
|
6963
|
-
}
|
|
6964
|
-
function serializeRegistries(file) {
|
|
6965
|
-
return stringify(file);
|
|
6966
|
-
}
|
|
6967
7040
|
|
|
6968
7041
|
// src/lib/paths.ts
|
|
6969
7042
|
var ETC_CONVEYOR = "/etc/conveyor";
|
|
@@ -6971,11 +7044,16 @@ var ENV_FILE = `${ETC_CONVEYOR}/k3.env`;
|
|
|
6971
7044
|
var TUNNEL_ENV_FILE = `${ETC_CONVEYOR}/k3-tunnel.env`;
|
|
6972
7045
|
var STATE_DIR = "/var/lib/conveyor-k3";
|
|
6973
7046
|
var STATE_FILE = `${STATE_DIR}/state.json`;
|
|
7047
|
+
var LOCAL_REGISTRY_DATA_DIR = `${STATE_DIR}/registry`;
|
|
7048
|
+
var LOCAL_REGISTRY_MIRROR_RESTART_FILE = `${STATE_DIR}/registry-mirror-restart`;
|
|
6974
7049
|
var RANCHER_DIR = "/etc/rancher/k3s";
|
|
6975
7050
|
var K3S_OWNER_CONFIG = `${RANCHER_DIR}/config.yaml`;
|
|
6976
7051
|
var K3S_CONFIG_DROPIN_DIR = `${RANCHER_DIR}/config.yaml.d`;
|
|
6977
7052
|
var K3S_CONFIG_DROPIN = `${K3S_CONFIG_DROPIN_DIR}/50-conveyor-k3.yaml`;
|
|
6978
7053
|
var REGISTRIES_FILE = `${RANCHER_DIR}/registries.yaml`;
|
|
7054
|
+
var K3S_CERTS_D = "/var/lib/rancher/k3s/agent/etc/containerd/certs.d";
|
|
7055
|
+
var K3S_CONTAINERD_DIR = "/var/lib/rancher/k3s/agent/containerd";
|
|
7056
|
+
var JOURNALD_DROPIN = "/etc/systemd/journald.conf.d/50-conveyor-k3.conf";
|
|
6979
7057
|
var CLOUDFLARED_DIR = "/etc/cloudflared";
|
|
6980
7058
|
var CA_POOL_PATH = `${CLOUDFLARED_DIR}/conveyor-k3-server-ca.crt`;
|
|
6981
7059
|
var SYSTEMD_DIR = "/etc/systemd/system";
|
|
@@ -6989,10 +7067,64 @@ var OWNED_FILES = [
|
|
|
6989
7067
|
TUNNEL_ENV_FILE,
|
|
6990
7068
|
K3S_CONFIG_DROPIN,
|
|
6991
7069
|
CA_POOL_PATH,
|
|
7070
|
+
JOURNALD_DROPIN,
|
|
6992
7071
|
...ALL_UNITS.map((unit) => `${SYSTEMD_DIR}/${unit}`)
|
|
6993
7072
|
];
|
|
6994
7073
|
var RUNNERS_SUDOERS_FILE = "/etc/sudoers.d/conveyor-k3-runners";
|
|
6995
7074
|
|
|
7075
|
+
// src/lib/registries.ts
|
|
7076
|
+
function parseRegistries(raw) {
|
|
7077
|
+
if (!raw || raw.trim().length === 0) return {};
|
|
7078
|
+
const parsed = parse(raw);
|
|
7079
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
7080
|
+
return parsed;
|
|
7081
|
+
}
|
|
7082
|
+
function configuredAuthHosts(file) {
|
|
7083
|
+
return Object.entries(file.configs ?? {}).filter(([, entry]) => Boolean(entry?.auth)).map(([host]) => host).sort();
|
|
7084
|
+
}
|
|
7085
|
+
function missingAuthHosts(file, requiredHosts) {
|
|
7086
|
+
const configured = new Set(configuredAuthHosts(file));
|
|
7087
|
+
return requiredHosts.filter((host) => !configured.has(host)).sort();
|
|
7088
|
+
}
|
|
7089
|
+
function upsertRegistryAuth(file, host, auth) {
|
|
7090
|
+
assertRegistryHost(host);
|
|
7091
|
+
const next = { ...file };
|
|
7092
|
+
const configs = { ...next.configs ?? {} };
|
|
7093
|
+
const existing = configs[host] ?? {};
|
|
7094
|
+
configs[host] = { ...existing, auth };
|
|
7095
|
+
next.configs = configs;
|
|
7096
|
+
return next;
|
|
7097
|
+
}
|
|
7098
|
+
function mirrorEndpoints(file, host) {
|
|
7099
|
+
const entry = file.mirrors?.[host];
|
|
7100
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
|
7101
|
+
const endpoint = entry.endpoint;
|
|
7102
|
+
return Array.isArray(endpoint) ? endpoint.filter((value) => typeof value === "string") : [];
|
|
7103
|
+
}
|
|
7104
|
+
function upsertRegistryMirror(file, host, endpoints) {
|
|
7105
|
+
assertRegistryHost(host);
|
|
7106
|
+
return { ...file, mirrors: { ...file.mirrors ?? {}, [host]: { endpoint: [...endpoints] } } };
|
|
7107
|
+
}
|
|
7108
|
+
function removeRegistryMirror(file, host) {
|
|
7109
|
+
if (!file.mirrors || !Object.hasOwn(file.mirrors, host)) return file;
|
|
7110
|
+
const { [host]: _removed, ...mirrors } = file.mirrors;
|
|
7111
|
+
const next = { ...file, mirrors };
|
|
7112
|
+
if (Object.keys(mirrors).length === 0) delete next.mirrors;
|
|
7113
|
+
return next;
|
|
7114
|
+
}
|
|
7115
|
+
function serializeRegistries(file) {
|
|
7116
|
+
return stringify(file);
|
|
7117
|
+
}
|
|
7118
|
+
async function writeRegistriesWithBackup(fs, raw, next, now) {
|
|
7119
|
+
let backupPath = null;
|
|
7120
|
+
if (raw !== null) {
|
|
7121
|
+
backupPath = `${REGISTRIES_FILE}.bak-${now.toISOString().replace(/[:.]/g, "-")}`;
|
|
7122
|
+
await fs.writeFile(backupPath, raw, 384);
|
|
7123
|
+
}
|
|
7124
|
+
await fs.writeFile(REGISTRIES_FILE, serializeRegistries(next), 384);
|
|
7125
|
+
return backupPath;
|
|
7126
|
+
}
|
|
7127
|
+
|
|
6996
7128
|
// src/lib/state.ts
|
|
6997
7129
|
var EMPTY_STATE = {
|
|
6998
7130
|
appliedDesiredStateVersion: 0,
|
|
@@ -7072,22 +7204,22 @@ function renderK3sDropIn(kubelet, ownerConfig) {
|
|
|
7072
7204
|
const memory = assertQuantity(kubelet.systemReserved.memory, "reserveMemory");
|
|
7073
7205
|
add(SYSTEM_RESERVED, `cpu=${cpu},memory=${memory}`);
|
|
7074
7206
|
}
|
|
7075
|
-
const
|
|
7207
|
+
const lines2 = [
|
|
7076
7208
|
"# Managed by Conveyor (@rallycry/conveyor-k3). Do not edit.",
|
|
7077
7209
|
"# Your own /etc/rancher/k3s/config.yaml is never modified; k3s merges both.",
|
|
7078
7210
|
"# `kubelet-arg+` and `disable+` append rather than replacing a list you set."
|
|
7079
7211
|
];
|
|
7080
|
-
|
|
7081
|
-
for (const component of K3S_DISABLED_COMPONENTS)
|
|
7212
|
+
lines2.push("disable+:");
|
|
7213
|
+
for (const component of K3S_DISABLED_COMPONENTS) lines2.push(` - ${component}`);
|
|
7082
7214
|
if (ownerKeys.has(WRITE_KUBECONFIG_MODE)) {
|
|
7083
7215
|
skipped.push(WRITE_KUBECONFIG_MODE);
|
|
7084
7216
|
} else {
|
|
7085
|
-
|
|
7217
|
+
lines2.push(`${WRITE_KUBECONFIG_MODE}: "${KUBECONFIG_MODE}"`);
|
|
7086
7218
|
}
|
|
7087
7219
|
if (args.length > 0) {
|
|
7088
|
-
|
|
7220
|
+
lines2.push("kubelet-arg+:", ...args);
|
|
7089
7221
|
}
|
|
7090
|
-
return { content: [...
|
|
7222
|
+
return { content: [...lines2, ""].join("\n"), skipped };
|
|
7091
7223
|
}
|
|
7092
7224
|
var renderKubeletDropIn = renderK3sDropIn;
|
|
7093
7225
|
|
|
@@ -7136,169 +7268,855 @@ function findDisallowedKinds(manifest) {
|
|
|
7136
7268
|
return disallowed;
|
|
7137
7269
|
}
|
|
7138
7270
|
|
|
7139
|
-
// src/daemon/
|
|
7271
|
+
// src/daemon/cluster-ops.ts
|
|
7272
|
+
async function kubectlApply(exec, manifest) {
|
|
7273
|
+
await runOrThrow(exec, "k3s", ["kubectl", "apply", "-f", "-"], {
|
|
7274
|
+
stdin: manifest,
|
|
7275
|
+
timeoutMs: 6e4
|
|
7276
|
+
});
|
|
7277
|
+
}
|
|
7140
7278
|
function mayDisrupt(runningConveyorPods) {
|
|
7141
7279
|
return runningConveyorPods === 0;
|
|
7142
7280
|
}
|
|
7143
7281
|
function busyReason(runningConveyorPods) {
|
|
7144
7282
|
return runningConveyorPods === null ? "the number of running Conveyor pods could not be read" : `${runningConveyorPods} Conveyor pod(s) are running`;
|
|
7145
7283
|
}
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7284
|
+
|
|
7285
|
+
// src/daemon/local-registry-upkeep.ts
|
|
7286
|
+
import { createHash } from "crypto";
|
|
7287
|
+
|
|
7288
|
+
// src/lib/local-registry.ts
|
|
7289
|
+
var LOCAL_REGISTRY_IMAGE = "docker.io/library/registry:3.1.2@sha256:c87f33837722a100572e95d7dc4bf539fc42cf68202b13c3bc03c0ff54c3a649";
|
|
7290
|
+
var LOCAL_REGISTRY_CONFIG = "/etc/distribution/config.yml";
|
|
7291
|
+
var LOCAL_REGISTRY_GC_JOB = "conveyor-registry-gc";
|
|
7292
|
+
var LOCAL_REGISTRY_SELECTOR = { "app.kubernetes.io/name": PERSONAL_LOCAL_REGISTRY_NAME };
|
|
7293
|
+
var MANAGED_BY = { "app.kubernetes.io/managed-by": "conveyor-k3" };
|
|
7294
|
+
var DATA_MOUNT = "/var/lib/registry";
|
|
7295
|
+
var REGISTRY_ENV = [
|
|
7296
|
+
{ name: "REGISTRY_STORAGE_DELETE_ENABLED", value: "true" },
|
|
7297
|
+
{ name: "OTEL_TRACES_EXPORTER", value: "none" }
|
|
7298
|
+
];
|
|
7299
|
+
var CONTAINER_SECURITY = {
|
|
7300
|
+
allowPrivilegeEscalation: false,
|
|
7301
|
+
readOnlyRootFilesystem: true,
|
|
7302
|
+
capabilities: { drop: ["ALL"] }
|
|
7303
|
+
};
|
|
7304
|
+
var VOLUME_MOUNTS = [
|
|
7305
|
+
{ name: "data", mountPath: DATA_MOUNT },
|
|
7306
|
+
{ name: "tmp", mountPath: "/tmp" }
|
|
7307
|
+
];
|
|
7308
|
+
var VOLUMES = [
|
|
7309
|
+
{ name: "data", hostPath: { path: LOCAL_REGISTRY_DATA_DIR, type: "Directory" } },
|
|
7310
|
+
{ name: "tmp", emptyDir: { sizeLimit: "64Mi" } }
|
|
7311
|
+
];
|
|
7312
|
+
var POD_BASE = {
|
|
7313
|
+
automountServiceAccountToken: false,
|
|
7314
|
+
enableServiceLinks: false,
|
|
7315
|
+
securityContext: { seccompProfile: { type: "RuntimeDefault" } },
|
|
7316
|
+
volumes: VOLUMES
|
|
7317
|
+
};
|
|
7318
|
+
function registryNetworkPolicy(metadata) {
|
|
7319
|
+
return {
|
|
7320
|
+
apiVersion: "networking.k8s.io/v1",
|
|
7321
|
+
kind: "NetworkPolicy",
|
|
7322
|
+
metadata,
|
|
7323
|
+
spec: {
|
|
7324
|
+
podSelector: { matchLabels: LOCAL_REGISTRY_SELECTOR },
|
|
7325
|
+
policyTypes: ["Ingress"],
|
|
7326
|
+
ingress: [
|
|
7327
|
+
{
|
|
7328
|
+
from: [
|
|
7329
|
+
{
|
|
7330
|
+
namespaceSelector: {},
|
|
7331
|
+
podSelector: {
|
|
7332
|
+
matchExpressions: [
|
|
7333
|
+
{ key: BAKE_COMPONENT_LABEL, operator: "In", values: [...BAKE_COMPONENT_VALUES] }
|
|
7334
|
+
]
|
|
7335
|
+
}
|
|
7336
|
+
}
|
|
7337
|
+
],
|
|
7338
|
+
ports: [{ protocol: "TCP", port: PERSONAL_LOCAL_REGISTRY_PORT }]
|
|
7339
|
+
}
|
|
7340
|
+
]
|
|
7341
|
+
}
|
|
7342
|
+
};
|
|
7151
7343
|
}
|
|
7152
|
-
|
|
7153
|
-
const
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7344
|
+
function renderLocalRegistryManifests() {
|
|
7345
|
+
const metadata = {
|
|
7346
|
+
name: PERSONAL_LOCAL_REGISTRY_NAME,
|
|
7347
|
+
namespace: PERSONAL_LOCAL_REGISTRY_NAMESPACE,
|
|
7348
|
+
labels: { ...LOCAL_REGISTRY_SELECTOR, ...MANAGED_BY }
|
|
7349
|
+
};
|
|
7350
|
+
const deployment = {
|
|
7351
|
+
apiVersion: "apps/v1",
|
|
7352
|
+
kind: "Deployment",
|
|
7353
|
+
metadata,
|
|
7354
|
+
spec: {
|
|
7355
|
+
replicas: 1,
|
|
7356
|
+
// One pod owns the data directory at a time.
|
|
7357
|
+
strategy: { type: "Recreate" },
|
|
7358
|
+
selector: { matchLabels: LOCAL_REGISTRY_SELECTOR },
|
|
7359
|
+
template: {
|
|
7360
|
+
metadata: { labels: metadata.labels },
|
|
7361
|
+
spec: {
|
|
7362
|
+
...POD_BASE,
|
|
7363
|
+
containers: [
|
|
7364
|
+
{
|
|
7365
|
+
name: "registry",
|
|
7366
|
+
image: LOCAL_REGISTRY_IMAGE,
|
|
7367
|
+
imagePullPolicy: "IfNotPresent",
|
|
7368
|
+
env: REGISTRY_ENV,
|
|
7369
|
+
ports: [
|
|
7370
|
+
{
|
|
7371
|
+
name: "registry",
|
|
7372
|
+
containerPort: PERSONAL_LOCAL_REGISTRY_PORT,
|
|
7373
|
+
// Loopback only: the node's containerd is the one client
|
|
7374
|
+
// that comes in through this port.
|
|
7375
|
+
hostPort: PERSONAL_LOCAL_REGISTRY_PORT,
|
|
7376
|
+
hostIP: "127.0.0.1",
|
|
7377
|
+
protocol: "TCP"
|
|
7378
|
+
}
|
|
7379
|
+
],
|
|
7380
|
+
readinessProbe: {
|
|
7381
|
+
httpGet: { path: "/v2/", port: PERSONAL_LOCAL_REGISTRY_PORT },
|
|
7382
|
+
periodSeconds: 10
|
|
7383
|
+
},
|
|
7384
|
+
resources: {
|
|
7385
|
+
requests: { cpu: "50m", memory: "64Mi" },
|
|
7386
|
+
limits: { memory: "512Mi" }
|
|
7387
|
+
},
|
|
7388
|
+
securityContext: CONTAINER_SECURITY,
|
|
7389
|
+
volumeMounts: VOLUME_MOUNTS
|
|
7390
|
+
}
|
|
7391
|
+
]
|
|
7392
|
+
}
|
|
7393
|
+
}
|
|
7394
|
+
}
|
|
7395
|
+
};
|
|
7396
|
+
const service = {
|
|
7397
|
+
apiVersion: "v1",
|
|
7398
|
+
kind: "Service",
|
|
7399
|
+
metadata,
|
|
7400
|
+
spec: {
|
|
7401
|
+
type: "ClusterIP",
|
|
7402
|
+
selector: LOCAL_REGISTRY_SELECTOR,
|
|
7403
|
+
ports: [
|
|
7404
|
+
{
|
|
7405
|
+
name: "registry",
|
|
7406
|
+
port: PERSONAL_LOCAL_REGISTRY_PORT,
|
|
7407
|
+
targetPort: PERSONAL_LOCAL_REGISTRY_PORT,
|
|
7408
|
+
protocol: "TCP"
|
|
7409
|
+
}
|
|
7410
|
+
]
|
|
7411
|
+
}
|
|
7412
|
+
};
|
|
7413
|
+
const items = [deployment, service, registryNetworkPolicy(metadata)];
|
|
7414
|
+
return `${JSON.stringify({ apiVersion: "v1", kind: "List", items }, null, 2)}
|
|
7415
|
+
`;
|
|
7416
|
+
}
|
|
7417
|
+
function renderRegistryGcJob() {
|
|
7418
|
+
const labels = { "app.kubernetes.io/name": LOCAL_REGISTRY_GC_JOB, ...MANAGED_BY };
|
|
7419
|
+
const job = {
|
|
7420
|
+
apiVersion: "batch/v1",
|
|
7421
|
+
kind: "Job",
|
|
7422
|
+
metadata: { name: LOCAL_REGISTRY_GC_JOB, namespace: PERSONAL_LOCAL_REGISTRY_NAMESPACE, labels },
|
|
7423
|
+
spec: {
|
|
7424
|
+
backoffLimit: 0,
|
|
7425
|
+
activeDeadlineSeconds: 600,
|
|
7426
|
+
// A Job left by a daemon that died mid-sweep goes on its own.
|
|
7427
|
+
ttlSecondsAfterFinished: 600,
|
|
7428
|
+
template: {
|
|
7429
|
+
metadata: { labels },
|
|
7430
|
+
spec: {
|
|
7431
|
+
...POD_BASE,
|
|
7432
|
+
restartPolicy: "Never",
|
|
7433
|
+
containers: [
|
|
7434
|
+
{
|
|
7435
|
+
name: "garbage-collect",
|
|
7436
|
+
image: LOCAL_REGISTRY_IMAGE,
|
|
7437
|
+
imagePullPolicy: "IfNotPresent",
|
|
7438
|
+
args: ["garbage-collect", LOCAL_REGISTRY_CONFIG],
|
|
7439
|
+
env: REGISTRY_ENV,
|
|
7440
|
+
resources: {
|
|
7441
|
+
requests: { cpu: "100m", memory: "128Mi" },
|
|
7442
|
+
limits: { memory: "1Gi" }
|
|
7443
|
+
},
|
|
7444
|
+
securityContext: CONTAINER_SECURITY,
|
|
7445
|
+
volumeMounts: VOLUME_MOUNTS
|
|
7446
|
+
}
|
|
7447
|
+
]
|
|
7448
|
+
}
|
|
7449
|
+
}
|
|
7450
|
+
}
|
|
7451
|
+
};
|
|
7452
|
+
return `${JSON.stringify(job, null, 2)}
|
|
7453
|
+
`;
|
|
7454
|
+
}
|
|
7455
|
+
function mirrorHostsTomlPaths(host) {
|
|
7456
|
+
const colon = host.lastIndexOf(":");
|
|
7457
|
+
const escaped = colon > 0 ? `${host.slice(0, colon)}_${host.slice(colon + 1)}_` : host;
|
|
7458
|
+
return [.../* @__PURE__ */ new Set([escaped, host])].map((dir) => `${K3S_CERTS_D}/${dir}/hosts.toml`);
|
|
7459
|
+
}
|
|
7460
|
+
async function mirrorLoaded(readFile3) {
|
|
7461
|
+
for (const path of mirrorHostsTomlPaths(PERSONAL_LOCAL_REGISTRY_HOST)) {
|
|
7462
|
+
const content = await readFile3(path);
|
|
7463
|
+
if (content?.includes(PERSONAL_LOCAL_REGISTRY_NODE_ENDPOINT)) return true;
|
|
7175
7464
|
}
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
);
|
|
7465
|
+
return false;
|
|
7466
|
+
}
|
|
7467
|
+
async function registryAnswers(fetchImpl = fetch) {
|
|
7468
|
+
try {
|
|
7469
|
+
const response = await fetchImpl(`${PERSONAL_LOCAL_REGISTRY_NODE_ENDPOINT}/v2/`, {
|
|
7470
|
+
signal: AbortSignal.timeout(2e3)
|
|
7471
|
+
});
|
|
7472
|
+
await response.body?.cancel();
|
|
7473
|
+
return response.status === 200;
|
|
7474
|
+
} catch {
|
|
7475
|
+
return false;
|
|
7183
7476
|
}
|
|
7184
7477
|
}
|
|
7185
|
-
async function
|
|
7186
|
-
const
|
|
7187
|
-
|
|
7188
|
-
|
|
7478
|
+
async function localRegistryReady(ports) {
|
|
7479
|
+
const [answers, mirrored] = await Promise.all([
|
|
7480
|
+
registryAnswers(ports.fetch),
|
|
7481
|
+
mirrorLoaded(ports.readFile)
|
|
7482
|
+
]);
|
|
7483
|
+
return answers && mirrored;
|
|
7484
|
+
}
|
|
7485
|
+
|
|
7486
|
+
// src/daemon/registry-gc-lock.ts
|
|
7487
|
+
var REGISTRY_GC_MARK = "conveyor.rallycry.com/registry-gc";
|
|
7488
|
+
var REGISTRY_GC_MARK_TTL_MS = 20 * 6e4;
|
|
7489
|
+
function inRegistryNamespace(exec, args, timeoutMs = 3e4) {
|
|
7490
|
+
return exec("k3s", ["kubectl", "-n", PERSONAL_LOCAL_REGISTRY_NAMESPACE, ...args], { timeoutMs });
|
|
7491
|
+
}
|
|
7492
|
+
async function readRegistryDeployment(exec) {
|
|
7493
|
+
const result = await inRegistryNamespace(exec, [
|
|
7189
7494
|
"get",
|
|
7190
|
-
"
|
|
7191
|
-
|
|
7495
|
+
"deployment",
|
|
7496
|
+
PERSONAL_LOCAL_REGISTRY_NAME,
|
|
7497
|
+
"--ignore-not-found",
|
|
7192
7498
|
"-o",
|
|
7193
|
-
"
|
|
7499
|
+
"json"
|
|
7194
7500
|
]);
|
|
7195
|
-
if (
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7199
|
-
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
);
|
|
7209
|
-
return;
|
|
7501
|
+
if (result.exitCode !== 0) return null;
|
|
7502
|
+
if (result.stdout.trim().length === 0) return { exists: false, replicas: null, mark: null };
|
|
7503
|
+
try {
|
|
7504
|
+
const deployment = JSON.parse(result.stdout);
|
|
7505
|
+
const replicas = deployment.spec?.replicas;
|
|
7506
|
+
const mark = deployment.metadata?.annotations?.[REGISTRY_GC_MARK];
|
|
7507
|
+
return {
|
|
7508
|
+
exists: true,
|
|
7509
|
+
replicas: typeof replicas === "number" ? replicas : null,
|
|
7510
|
+
mark: typeof mark === "string" ? mark : null
|
|
7511
|
+
};
|
|
7512
|
+
} catch {
|
|
7513
|
+
return null;
|
|
7210
7514
|
}
|
|
7515
|
+
}
|
|
7516
|
+
function processAlive(pid) {
|
|
7211
7517
|
try {
|
|
7212
|
-
|
|
7213
|
-
|
|
7518
|
+
process.kill(pid, 0);
|
|
7519
|
+
return true;
|
|
7214
7520
|
} catch (error) {
|
|
7215
|
-
|
|
7216
|
-
`The \`${name}\` PriorityClass was removed to change its value but could not be recreated: ${error instanceof Error ? error.message : String(error)}. No build can start on this machine until it exists \u2014 run \`conveyor-k3 sync\`.`
|
|
7217
|
-
);
|
|
7521
|
+
return error.code === "EPERM";
|
|
7218
7522
|
}
|
|
7219
7523
|
}
|
|
7220
|
-
function
|
|
7221
|
-
|
|
7222
|
-
const values = [
|
|
7223
|
-
assertQuantity(limits.defaultRequest.cpu, "defaultRequest.cpu"),
|
|
7224
|
-
assertQuantity(limits.defaultRequest.memory, "defaultRequest.memory"),
|
|
7225
|
-
assertQuantity(limits.default.cpu, "default.cpu"),
|
|
7226
|
-
assertQuantity(limits.default.memory, "default.memory")
|
|
7227
|
-
];
|
|
7228
|
-
return [
|
|
7229
|
-
"apiVersion: v1",
|
|
7230
|
-
"kind: LimitRange",
|
|
7231
|
-
"metadata:",
|
|
7232
|
-
" name: conveyor-k3-defaults",
|
|
7233
|
-
` namespace: ${ns}`,
|
|
7234
|
-
"spec:",
|
|
7235
|
-
" limits:",
|
|
7236
|
-
" - type: Container",
|
|
7237
|
-
" defaultRequest:",
|
|
7238
|
-
` cpu: "${values[0]}"`,
|
|
7239
|
-
` memory: "${values[1]}"`,
|
|
7240
|
-
" default:",
|
|
7241
|
-
` cpu: "${values[2]}"`,
|
|
7242
|
-
` memory: "${values[3]}"`,
|
|
7243
|
-
""
|
|
7244
|
-
].join("\n");
|
|
7524
|
+
function newGcMark(now) {
|
|
7525
|
+
return `${process.pid}@${new Date(now).toISOString()}`;
|
|
7245
7526
|
}
|
|
7246
|
-
|
|
7247
|
-
|
|
7527
|
+
function gcMarkIsLive(mark, now, alive = processAlive) {
|
|
7528
|
+
const match = mark ? /^(\d+)@(.+)$/.exec(mark) : null;
|
|
7529
|
+
if (!match?.[1] || !match[2]) return false;
|
|
7530
|
+
const started = Date.parse(match[2]);
|
|
7531
|
+
return !Number.isNaN(started) && now - started < REGISTRY_GC_MARK_TTL_MS && alive(Number(match[1]));
|
|
7532
|
+
}
|
|
7533
|
+
async function setGcMark(exec, mark) {
|
|
7534
|
+
await runOrThrow(
|
|
7535
|
+
exec,
|
|
7536
|
+
"k3s",
|
|
7537
|
+
[
|
|
7538
|
+
"kubectl",
|
|
7539
|
+
"-n",
|
|
7540
|
+
PERSONAL_LOCAL_REGISTRY_NAMESPACE,
|
|
7541
|
+
"annotate",
|
|
7542
|
+
"deployment",
|
|
7543
|
+
PERSONAL_LOCAL_REGISTRY_NAME,
|
|
7544
|
+
`${REGISTRY_GC_MARK}=${mark}`,
|
|
7545
|
+
"--overwrite"
|
|
7546
|
+
],
|
|
7547
|
+
{ timeoutMs: 3e4 }
|
|
7548
|
+
);
|
|
7549
|
+
}
|
|
7550
|
+
async function clearGcMark(exec) {
|
|
7551
|
+
const result = await inRegistryNamespace(exec, [
|
|
7552
|
+
"annotate",
|
|
7553
|
+
"deployment",
|
|
7554
|
+
PERSONAL_LOCAL_REGISTRY_NAME,
|
|
7555
|
+
`${REGISTRY_GC_MARK}-`
|
|
7556
|
+
]);
|
|
7557
|
+
return result.exitCode === 0;
|
|
7558
|
+
}
|
|
7559
|
+
async function deleteSweepJob(exec) {
|
|
7560
|
+
const result = await inRegistryNamespace(
|
|
7561
|
+
exec,
|
|
7562
|
+
[
|
|
7563
|
+
"delete",
|
|
7564
|
+
"job",
|
|
7565
|
+
LOCAL_REGISTRY_GC_JOB,
|
|
7566
|
+
"--ignore-not-found",
|
|
7567
|
+
"--cascade=foreground",
|
|
7568
|
+
"--wait=true"
|
|
7569
|
+
],
|
|
7570
|
+
12e4
|
|
7571
|
+
);
|
|
7572
|
+
return result.exitCode === 0;
|
|
7573
|
+
}
|
|
7574
|
+
|
|
7575
|
+
// src/daemon/local-registry-upkeep.ts
|
|
7576
|
+
function nowDate() {
|
|
7577
|
+
return /* @__PURE__ */ new Date();
|
|
7578
|
+
}
|
|
7579
|
+
function mirrorConfigured(raw) {
|
|
7580
|
+
const endpoints = mirrorEndpoints(parseRegistries(raw), PERSONAL_LOCAL_REGISTRY_HOST);
|
|
7581
|
+
return endpoints.length === 1 && endpoints[0] === PERSONAL_LOCAL_REGISTRY_NODE_ENDPOINT;
|
|
7582
|
+
}
|
|
7583
|
+
async function applyUnlessCollecting(ctx) {
|
|
7584
|
+
const deployment = await readRegistryDeployment(ctx.exec);
|
|
7585
|
+
if (!deployment) throw new Error("the registry's Deployment could not be read");
|
|
7586
|
+
if (gcMarkIsLive(deployment.mark, (ctx.now ?? nowDate)().getTime(), ctx.isAlive)) return false;
|
|
7587
|
+
if (deployment.mark !== null && !await deleteSweepJob(ctx.exec)) {
|
|
7588
|
+
throw new Error("a stopped cleanup's Job could not be deleted, so the registry stays stopped");
|
|
7589
|
+
}
|
|
7590
|
+
await kubectlApply(ctx.exec, renderLocalRegistryManifests());
|
|
7591
|
+
if (deployment.mark !== null) await clearGcMark(ctx.exec);
|
|
7592
|
+
return true;
|
|
7593
|
+
}
|
|
7594
|
+
async function deployLocalRegistry(ctx) {
|
|
7595
|
+
await ctx.fs.ensureDir(LOCAL_REGISTRY_DATA_DIR, 448);
|
|
7596
|
+
const applied = await applyUnlessCollecting(ctx);
|
|
7597
|
+
const raw = await ctx.fs.readFile(REGISTRIES_FILE);
|
|
7598
|
+
if (mirrorConfigured(raw)) return { applied, mirrorWritten: false, backupPath: null };
|
|
7599
|
+
const next = upsertRegistryMirror(parseRegistries(raw), PERSONAL_LOCAL_REGISTRY_HOST, [
|
|
7600
|
+
PERSONAL_LOCAL_REGISTRY_NODE_ENDPOINT
|
|
7601
|
+
]);
|
|
7602
|
+
const backupPath = await writeRegistriesWithBackup(ctx.fs, raw, next, (ctx.now ?? nowDate)());
|
|
7603
|
+
return { applied, mirrorWritten: true, backupPath };
|
|
7604
|
+
}
|
|
7605
|
+
async function readMirrorState(fs) {
|
|
7606
|
+
const raw = await fs.readFile(REGISTRIES_FILE);
|
|
7607
|
+
const fingerprint = createHash("sha256").update(raw ?? "").digest("hex");
|
|
7608
|
+
if (!mirrorConfigured(raw)) return { state: "absent", fingerprint };
|
|
7609
|
+
if (await mirrorLoaded((path) => fs.readFile(path))) return { state: "loaded", fingerprint };
|
|
7610
|
+
const tried = (await fs.readFile(LOCAL_REGISTRY_MIRROR_RESTART_FILE))?.trim() === fingerprint;
|
|
7611
|
+
return { state: tried ? "tried" : "owed", fingerprint };
|
|
7612
|
+
}
|
|
7613
|
+
async function registryMirrorPending(fs) {
|
|
7614
|
+
return (await readMirrorState(fs)).state === "owed";
|
|
7615
|
+
}
|
|
7616
|
+
async function loadRegistryMirror(ctx) {
|
|
7617
|
+
const { state, fingerprint } = await readMirrorState(ctx.fs);
|
|
7618
|
+
if (state === "tried") {
|
|
7619
|
+
return {
|
|
7620
|
+
restarted: false,
|
|
7621
|
+
warning: `k3s restarted but did not load the local image registry's mirror, so pods cannot pull from that registry. Conveyor restarts k3s for it again only after /etc/rancher/k3s/registries.yaml changes. Check that k3s reads that file and keeps its data in the default directory: the mirror should appear under ${K3S_CERTS_D}.`
|
|
7622
|
+
};
|
|
7623
|
+
}
|
|
7624
|
+
if (state !== "owed") return { restarted: false, warning: null };
|
|
7625
|
+
if (!mayDisrupt(ctx.runningConveyorPods)) {
|
|
7626
|
+
return {
|
|
7627
|
+
restarted: false,
|
|
7628
|
+
warning: `The local image registry needs a k3s restart to load its mirror, but ${busyReason(ctx.runningConveyorPods)} \u2014 the restart is deferred until the machine is idle.`
|
|
7629
|
+
};
|
|
7630
|
+
}
|
|
7631
|
+
const restart = await ctx.exec("systemctl", ["restart", "k3s"], { timeoutMs: 12e4 });
|
|
7632
|
+
if (restart.exitCode !== 0) {
|
|
7633
|
+
return {
|
|
7634
|
+
restarted: false,
|
|
7635
|
+
warning: `Could not restart k3s to load the local image registry mirror: ${restart.stderr.trim().slice(0, 200)}`
|
|
7636
|
+
};
|
|
7637
|
+
}
|
|
7638
|
+
await ctx.fs.writeFile(LOCAL_REGISTRY_MIRROR_RESTART_FILE, `${fingerprint}
|
|
7639
|
+
`, 384);
|
|
7640
|
+
return { restarted: true, warning: null };
|
|
7641
|
+
}
|
|
7642
|
+
|
|
7643
|
+
// src/daemon/prune-namespaces.ts
|
|
7644
|
+
var POOL_NAMESPACE_PREFIX = "claudespace-";
|
|
7645
|
+
var WORKSPACE_MANAGER_ROLE = "conveyor-workspace-manager";
|
|
7646
|
+
var MAX_NAMESPACE_DELETES_PER_RUN = 5;
|
|
7647
|
+
var KUBECTL_TIMEOUT = "--request-timeout=10s";
|
|
7648
|
+
var EXEC_OPTIONS = { timeoutMs: 15e3 };
|
|
7649
|
+
function kubectl(exec, args) {
|
|
7650
|
+
return exec("k3s", ["kubectl", ...args, KUBECTL_TIMEOUT], EXEC_OPTIONS).catch(() => null);
|
|
7651
|
+
}
|
|
7652
|
+
function candidateNamespaces(listing, wanted) {
|
|
7653
|
+
const names = [];
|
|
7654
|
+
for (const line of listing.split("\n")) {
|
|
7655
|
+
const [name, phase] = line.trim().split(/\s+/);
|
|
7656
|
+
if (!name?.startsWith(POOL_NAMESPACE_PREFIX) || wanted.has(name)) continue;
|
|
7657
|
+
if (phase === "Terminating") continue;
|
|
7248
7658
|
try {
|
|
7249
|
-
|
|
7250
|
-
} catch
|
|
7659
|
+
names.push(assertK8sName(name, "namespace"));
|
|
7660
|
+
} catch {
|
|
7661
|
+
}
|
|
7662
|
+
}
|
|
7663
|
+
return names;
|
|
7664
|
+
}
|
|
7665
|
+
async function isEmptyConveyorNamespace(exec, namespace) {
|
|
7666
|
+
const role = await kubectl(exec, [
|
|
7667
|
+
"get",
|
|
7668
|
+
"role",
|
|
7669
|
+
WORKSPACE_MANAGER_ROLE,
|
|
7670
|
+
"-n",
|
|
7671
|
+
namespace,
|
|
7672
|
+
"-o",
|
|
7673
|
+
"name"
|
|
7674
|
+
]);
|
|
7675
|
+
if (role?.exitCode !== 0) return false;
|
|
7676
|
+
const pods = await kubectl(exec, ["get", "pods", "-n", namespace, "-o", "name"]);
|
|
7677
|
+
return pods?.exitCode === 0 && pods.stdout.trim() === "";
|
|
7678
|
+
}
|
|
7679
|
+
async function pruneStaleNamespaces(ctx, desired, result) {
|
|
7680
|
+
if (desired.namespaces.length === 0) return;
|
|
7681
|
+
const listed = await kubectl(ctx.exec, [
|
|
7682
|
+
"get",
|
|
7683
|
+
"namespaces",
|
|
7684
|
+
"-o",
|
|
7685
|
+
'jsonpath={range .items[*]}{.metadata.name}{" "}{.status.phase}{"\\n"}{end}'
|
|
7686
|
+
]);
|
|
7687
|
+
if (listed?.exitCode !== 0) return;
|
|
7688
|
+
const wanted = new Set(desired.namespaces.map((namespace) => namespace.name));
|
|
7689
|
+
let deletes = 0;
|
|
7690
|
+
for (const namespace of candidateNamespaces(listed.stdout, wanted)) {
|
|
7691
|
+
if (deletes >= MAX_NAMESPACE_DELETES_PER_RUN) return;
|
|
7692
|
+
if (!await isEmptyConveyorNamespace(ctx.exec, namespace)) continue;
|
|
7693
|
+
deletes += 1;
|
|
7694
|
+
const removed = await kubectl(ctx.exec, ["delete", "namespace", namespace, "--wait=false"]);
|
|
7695
|
+
if (removed?.exitCode === 0) {
|
|
7696
|
+
result.prunedNamespaces.push(namespace);
|
|
7697
|
+
} else {
|
|
7251
7698
|
result.warnings.push(
|
|
7252
|
-
`Could not
|
|
7699
|
+
`Could not remove the unused namespace ${namespace}: ${(removed?.stderr ?? "no answer from k3s").trim().slice(0, 160)}`
|
|
7253
7700
|
);
|
|
7254
7701
|
}
|
|
7255
7702
|
}
|
|
7256
7703
|
}
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7704
|
+
|
|
7705
|
+
// src/daemon/registry-tags.ts
|
|
7706
|
+
var REPO_NAME = /^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$/;
|
|
7707
|
+
var TAG_NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
|
|
7708
|
+
var LEG_TAG = /^current-([0-9a-f]{16})-(?:amd64|arm64)$/;
|
|
7709
|
+
var DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
7710
|
+
var PAGE_SIZE = 100;
|
|
7711
|
+
var MAX_REPOSITORIES = 1e3;
|
|
7712
|
+
var MAX_TAGS = 5e3;
|
|
7713
|
+
var REQUEST_TIMEOUT_MS = 15e3;
|
|
7714
|
+
var MANIFEST_ACCEPT = [
|
|
7715
|
+
"application/vnd.oci.image.index.v1+json",
|
|
7716
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
7717
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
7718
|
+
"application/vnd.docker.distribution.manifest.v2+json"
|
|
7719
|
+
].join(", ");
|
|
7720
|
+
function stringList(value) {
|
|
7721
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === "string") : [];
|
|
7722
|
+
}
|
|
7723
|
+
async function getJson(fetchImpl, path) {
|
|
7724
|
+
const response = await fetchImpl(`${PERSONAL_LOCAL_REGISTRY_NODE_ENDPOINT}${path}`, {
|
|
7725
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7726
|
+
});
|
|
7727
|
+
if (!response.ok) {
|
|
7728
|
+
await response.body?.cancel();
|
|
7729
|
+
throw new Error(`The local registry answered ${response.status} to GET ${path}`);
|
|
7730
|
+
}
|
|
7731
|
+
return { body: await response.json(), hasNext: response.headers.has("link") };
|
|
7732
|
+
}
|
|
7733
|
+
async function listPaged(fetchImpl, path, key, limit) {
|
|
7734
|
+
const names = [];
|
|
7735
|
+
let last;
|
|
7736
|
+
while (names.length < limit) {
|
|
7737
|
+
const query = `n=${PAGE_SIZE}${last ? `&last=${encodeURIComponent(last)}` : ""}`;
|
|
7738
|
+
const { body, hasNext } = await getJson(fetchImpl, `${path}?${query}`);
|
|
7739
|
+
const page = stringList(body?.[key]);
|
|
7740
|
+
names.push(...page);
|
|
7741
|
+
if (!hasNext || page.length === 0) return { names, complete: true };
|
|
7742
|
+
last = page.at(-1);
|
|
7743
|
+
}
|
|
7744
|
+
return { names: names.slice(0, limit), complete: false };
|
|
7745
|
+
}
|
|
7746
|
+
async function readManifest(fetchImpl, repo, reference, method) {
|
|
7747
|
+
const response = await fetchImpl(
|
|
7748
|
+
`${PERSONAL_LOCAL_REGISTRY_NODE_ENDPOINT}/v2/${repo}/manifests/${reference}`,
|
|
7749
|
+
{
|
|
7750
|
+
method,
|
|
7751
|
+
headers: { Accept: MANIFEST_ACCEPT },
|
|
7752
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7753
|
+
}
|
|
7754
|
+
);
|
|
7755
|
+
const digest = response.headers.get("docker-content-digest");
|
|
7756
|
+
if (!response.ok || !digest || !DIGEST.test(digest)) {
|
|
7757
|
+
await response.body?.cancel();
|
|
7758
|
+
return null;
|
|
7759
|
+
}
|
|
7760
|
+
if (method === "HEAD") return { digest, children: [] };
|
|
7761
|
+
const body = await response.json();
|
|
7762
|
+
const children = (body?.manifests ?? []).map((entry) => entry.digest).filter((child) => typeof child === "string" && DIGEST.test(child));
|
|
7763
|
+
return { digest, children };
|
|
7764
|
+
}
|
|
7765
|
+
function isSupersededLeg(tag, retain) {
|
|
7766
|
+
const match = LEG_TAG.exec(tag);
|
|
7767
|
+
return match?.[1] !== void 0 && !retain.has(match[1]);
|
|
7768
|
+
}
|
|
7769
|
+
async function planRepository(fetchImpl, repo, retain) {
|
|
7770
|
+
const listed = await listPaged(fetchImpl, `/v2/${repo}/tags/list`, "tags", MAX_TAGS);
|
|
7771
|
+
if (!listed.complete) return { skip: `it has more than ${MAX_TAGS} tags` };
|
|
7772
|
+
const tags = listed.names.filter((tag) => TAG_NAME.test(tag));
|
|
7773
|
+
const legs = tags.filter((tag) => isSupersededLeg(tag, retain));
|
|
7774
|
+
const kept = tags.filter((tag) => !isSupersededLeg(tag, retain));
|
|
7775
|
+
if (legs.length === 0) return { deletions: [], kept: kept.length };
|
|
7776
|
+
const keep = /* @__PURE__ */ new Set();
|
|
7777
|
+
for (const tag of kept) {
|
|
7778
|
+
const manifest = await readManifest(fetchImpl, repo, tag, "GET");
|
|
7779
|
+
if (!manifest) return { skip: `the tag ${tag} it keeps could not be read` };
|
|
7780
|
+
keep.add(manifest.digest);
|
|
7781
|
+
for (const child of manifest.children) keep.add(child);
|
|
7782
|
+
}
|
|
7783
|
+
const byDigest = /* @__PURE__ */ new Map();
|
|
7784
|
+
for (const tag of legs) {
|
|
7785
|
+
const manifest = await readManifest(fetchImpl, repo, tag, "HEAD");
|
|
7786
|
+
if (!manifest || keep.has(manifest.digest)) continue;
|
|
7787
|
+
byDigest.set(manifest.digest, [...byDigest.get(manifest.digest) ?? [], tag]);
|
|
7788
|
+
}
|
|
7789
|
+
const deletions = [...byDigest].map(([digest, names]) => ({ repo, digest, tags: names }));
|
|
7790
|
+
return { deletions, kept: kept.length };
|
|
7791
|
+
}
|
|
7792
|
+
async function planTagCleanup(fetchImpl, retain) {
|
|
7793
|
+
const catalog = await listPaged(fetchImpl, "/v2/_catalog", "repositories", MAX_REPOSITORIES);
|
|
7794
|
+
const repos = catalog.names.filter(
|
|
7795
|
+
(repo) => repo.startsWith("personal-") && REPO_NAME.test(repo)
|
|
7796
|
+
);
|
|
7797
|
+
const plan = { deletions: [], keptTags: 0, skippedRepos: [] };
|
|
7798
|
+
for (const repo of repos) {
|
|
7799
|
+
const planned = await planRepository(fetchImpl, repo, retain).catch((error) => ({
|
|
7800
|
+
skip: `its tags could not be read (${error instanceof Error ? error.message : String(error)})`
|
|
7801
|
+
}));
|
|
7802
|
+
if ("skip" in planned) {
|
|
7803
|
+
plan.skippedRepos.push({ repo, reason: planned.skip });
|
|
7804
|
+
continue;
|
|
7805
|
+
}
|
|
7806
|
+
plan.deletions.push(...planned.deletions);
|
|
7807
|
+
plan.keptTags += planned.kept;
|
|
7262
7808
|
}
|
|
7809
|
+
return plan;
|
|
7263
7810
|
}
|
|
7264
|
-
async function
|
|
7265
|
-
const
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7275
|
-
|
|
7276
|
-
|
|
7811
|
+
async function deleteManifest(fetchImpl, deletion) {
|
|
7812
|
+
const response = await fetchImpl(
|
|
7813
|
+
`${PERSONAL_LOCAL_REGISTRY_NODE_ENDPOINT}/v2/${deletion.repo}/manifests/${deletion.digest}`,
|
|
7814
|
+
{ method: "DELETE", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }
|
|
7815
|
+
);
|
|
7816
|
+
await response.body?.cancel();
|
|
7817
|
+
return response.status === 202 || response.status === 404;
|
|
7818
|
+
}
|
|
7819
|
+
|
|
7820
|
+
// src/daemon/registry-gc.ts
|
|
7821
|
+
var HOUR_MS = 60 * 6e4;
|
|
7822
|
+
var REGISTRY_GC_INTERVAL_MS = 20 * HOUR_MS;
|
|
7823
|
+
var REGISTRY_GC_SIZE_BYTES = 20 * 1024 ** 3;
|
|
7824
|
+
var REGISTRY_GC_SIZE_INTERVAL_MS = 6 * HOUR_MS;
|
|
7825
|
+
var BAKE_OBJECT_STALE_MS = 6 * HOUR_MS;
|
|
7826
|
+
function lines(text) {
|
|
7827
|
+
return text.split("\n").filter((line) => line.trim().length > 0);
|
|
7828
|
+
}
|
|
7829
|
+
var inNamespace = inRegistryNamespace;
|
|
7830
|
+
function defaultSleep(ms) {
|
|
7831
|
+
return new Promise((resolve) => {
|
|
7832
|
+
setTimeout(resolve, ms);
|
|
7833
|
+
});
|
|
7834
|
+
}
|
|
7835
|
+
async function bakeActivity(exec, now) {
|
|
7836
|
+
const pods = await exec(
|
|
7837
|
+
"k3s",
|
|
7838
|
+
[
|
|
7839
|
+
"kubectl",
|
|
7840
|
+
"get",
|
|
7841
|
+
"pods",
|
|
7842
|
+
"--all-namespaces",
|
|
7843
|
+
"-l",
|
|
7844
|
+
CONVEYOR_POD_SELECTOR,
|
|
7845
|
+
// A pod still pulling its image needs the registry as much as a running one.
|
|
7846
|
+
"--field-selector=status.phase!=Succeeded,status.phase!=Failed",
|
|
7847
|
+
"-o",
|
|
7848
|
+
"name"
|
|
7849
|
+
],
|
|
7850
|
+
{ timeoutMs: 3e4 }
|
|
7851
|
+
);
|
|
7852
|
+
if (pods.exitCode !== 0) return "the Conveyor pods could not be listed";
|
|
7853
|
+
const podCount = lines(pods.stdout).length;
|
|
7854
|
+
if (podCount > 0) return `${podCount} Conveyor pod(s) are on the machine`;
|
|
7855
|
+
const bakes = await exec(
|
|
7856
|
+
"k3s",
|
|
7857
|
+
[
|
|
7858
|
+
"kubectl",
|
|
7859
|
+
"get",
|
|
7860
|
+
"jobs,secrets,configmaps",
|
|
7861
|
+
"--all-namespaces",
|
|
7862
|
+
"-l",
|
|
7863
|
+
`${BAKE_COMPONENT_LABEL} in (${BAKE_COMPONENT_VALUES.join(",")})`,
|
|
7864
|
+
"-o",
|
|
7865
|
+
'jsonpath={range .items[*]}{.metadata.creationTimestamp}{"\\n"}{end}'
|
|
7866
|
+
],
|
|
7867
|
+
{ timeoutMs: 3e4 }
|
|
7868
|
+
);
|
|
7869
|
+
if (bakes.exitCode !== 0) return "the bake objects could not be listed";
|
|
7870
|
+
const inFlight = lines(bakes.stdout).some((stamp) => {
|
|
7871
|
+
const created = Date.parse(stamp.trim());
|
|
7872
|
+
return Number.isNaN(created) || now - created < BAKE_OBJECT_STALE_MS;
|
|
7873
|
+
});
|
|
7874
|
+
return inFlight ? "a bake is in flight" : null;
|
|
7875
|
+
}
|
|
7876
|
+
async function pollUntil(check, timeoutMs, intervalMs, sleep2) {
|
|
7877
|
+
for (let waited = 0; waited < timeoutMs; waited += intervalMs) {
|
|
7878
|
+
if (await check()) return true;
|
|
7879
|
+
await sleep2(intervalMs);
|
|
7880
|
+
}
|
|
7881
|
+
return await check();
|
|
7882
|
+
}
|
|
7883
|
+
async function registryPodsGone(exec) {
|
|
7884
|
+
const result = await inNamespace(exec, [
|
|
7885
|
+
"get",
|
|
7886
|
+
"pods",
|
|
7887
|
+
"-l",
|
|
7888
|
+
`app.kubernetes.io/name=${PERSONAL_LOCAL_REGISTRY_NAME}`,
|
|
7889
|
+
"-o",
|
|
7890
|
+
"name"
|
|
7891
|
+
]);
|
|
7892
|
+
return result.exitCode === 0 && lines(result.stdout).length === 0;
|
|
7893
|
+
}
|
|
7894
|
+
async function gcJobState(exec) {
|
|
7895
|
+
const result = await inNamespace(exec, [
|
|
7896
|
+
"get",
|
|
7897
|
+
"job",
|
|
7898
|
+
LOCAL_REGISTRY_GC_JOB,
|
|
7899
|
+
"-o",
|
|
7900
|
+
"jsonpath={.status.succeeded},{.status.failed}"
|
|
7901
|
+
]);
|
|
7902
|
+
if (result.exitCode !== 0) return null;
|
|
7903
|
+
const [succeeded, failed] = result.stdout.trim().split(",");
|
|
7904
|
+
if (Number(succeeded) > 0) return "succeeded";
|
|
7905
|
+
return Number(failed) > 0 ? "failed" : null;
|
|
7906
|
+
}
|
|
7907
|
+
async function waitForGcJob(exec, sleep2) {
|
|
7908
|
+
for (let waited = 0; waited <= 11 * 6e4; waited += 5e3) {
|
|
7909
|
+
const state = await gcJobState(exec);
|
|
7910
|
+
if (state) return state;
|
|
7911
|
+
await sleep2(5e3);
|
|
7912
|
+
}
|
|
7913
|
+
return null;
|
|
7914
|
+
}
|
|
7915
|
+
async function releaseRegistry(ports, mark) {
|
|
7916
|
+
const { exec } = ports;
|
|
7917
|
+
const now = (ports.now ?? Date.now)();
|
|
7918
|
+
const current = await readRegistryDeployment(exec);
|
|
7919
|
+
if (current?.mark !== mark && gcMarkIsLive(current?.mark ?? null, now, ports.isAlive)) return;
|
|
7920
|
+
if (!await deleteSweepJob(exec)) {
|
|
7921
|
+
ports.log(
|
|
7922
|
+
"Left the local image registry stopped: its cleanup Job could not be deleted. The next hourly pass tries again."
|
|
7277
7923
|
);
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
7281
|
-
|
|
7282
|
-
|
|
7283
|
-
|
|
7924
|
+
return;
|
|
7925
|
+
}
|
|
7926
|
+
await clearGcMark(exec);
|
|
7927
|
+
const restored = await inNamespace(exec, [
|
|
7928
|
+
"scale",
|
|
7929
|
+
"deployment",
|
|
7930
|
+
PERSONAL_LOCAL_REGISTRY_NAME,
|
|
7931
|
+
"--replicas=1"
|
|
7932
|
+
]);
|
|
7933
|
+
if (restored.exitCode !== 0) {
|
|
7934
|
+
ports.log(
|
|
7935
|
+
`Could not scale the local image registry back up; the next hourly pass restores it: ${restored.stderr.trim().slice(0, 200)}`
|
|
7284
7936
|
);
|
|
7285
7937
|
}
|
|
7286
|
-
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
);
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7938
|
+
}
|
|
7939
|
+
async function collectBlobs(ports) {
|
|
7940
|
+
const { exec } = ports;
|
|
7941
|
+
const sleep2 = ports.sleep ?? defaultSleep;
|
|
7942
|
+
if (!await deleteSweepJob(exec)) throw new Error("a previous cleanup Job could not be deleted");
|
|
7943
|
+
const mark = newGcMark((ports.now ?? Date.now)());
|
|
7944
|
+
await setGcMark(exec, mark);
|
|
7945
|
+
try {
|
|
7946
|
+
const scaled = await inNamespace(exec, [
|
|
7947
|
+
"scale",
|
|
7948
|
+
"deployment",
|
|
7949
|
+
PERSONAL_LOCAL_REGISTRY_NAME,
|
|
7950
|
+
"--replicas=0"
|
|
7951
|
+
]);
|
|
7952
|
+
if (scaled.exitCode !== 0) throw new Error("the registry could not be scaled to zero");
|
|
7953
|
+
if (!await pollUntil(() => registryPodsGone(exec), 12e4, 2e3, sleep2)) {
|
|
7954
|
+
throw new Error("the registry pod did not stop within 2 minutes");
|
|
7955
|
+
}
|
|
7956
|
+
const held = await readRegistryDeployment(exec);
|
|
7957
|
+
if (held?.replicas !== 0 || held.mark !== mark) {
|
|
7958
|
+
throw new Error(
|
|
7959
|
+
"the registry was started again by another process, so the sweep did not run"
|
|
7960
|
+
);
|
|
7961
|
+
}
|
|
7962
|
+
await kubectlApply(exec, renderRegistryGcJob());
|
|
7963
|
+
const state = await waitForGcJob(exec, sleep2);
|
|
7964
|
+
if (state !== "succeeded") throw new Error(`garbage-collect ${state ?? "did not finish"}`);
|
|
7965
|
+
} finally {
|
|
7966
|
+
await releaseRegistry(ports, mark);
|
|
7967
|
+
}
|
|
7968
|
+
}
|
|
7969
|
+
async function readRetainList(ports) {
|
|
7970
|
+
const desired = await ports.requestDesiredState().catch(() => null);
|
|
7971
|
+
if (!desired) return "the settings could not be fetched from Conveyor";
|
|
7972
|
+
if (desired.localRegistry?.enabled !== true) return "the local image registry is off";
|
|
7973
|
+
return desired.localRegistry.retainHashes ?? "Conveyor did not send the list of bakes to keep";
|
|
7974
|
+
}
|
|
7975
|
+
async function registryBlocker(ports, now) {
|
|
7976
|
+
const deployment = await readRegistryDeployment(ports.exec);
|
|
7977
|
+
if (!deployment) return "the local image registry could not be read";
|
|
7978
|
+
if (!deployment.exists) return "the local image registry is not deployed yet";
|
|
7979
|
+
if (gcMarkIsLive(deployment.mark, now, ports.isAlive))
|
|
7980
|
+
return "another registry cleanup is running";
|
|
7981
|
+
return await bakeActivity(ports.exec, now);
|
|
7982
|
+
}
|
|
7983
|
+
async function collectRegistryGarbage(ports, options) {
|
|
7984
|
+
const skip = (reason) => ({
|
|
7985
|
+
skipped: reason,
|
|
7986
|
+
deletions: [],
|
|
7987
|
+
blobsCollected: false
|
|
7988
|
+
});
|
|
7989
|
+
const now = ports.now ?? Date.now;
|
|
7990
|
+
const fetchImpl = ports.fetch ?? fetch;
|
|
7991
|
+
const retain = await readRetainList(ports);
|
|
7992
|
+
if (typeof retain === "string") return skip(retain);
|
|
7993
|
+
const blocked = await registryBlocker(ports, now());
|
|
7994
|
+
if (blocked) return skip(blocked);
|
|
7995
|
+
const plan = await planTagCleanup(fetchImpl, new Set(retain));
|
|
7996
|
+
for (const skipped of plan.skippedRepos) {
|
|
7997
|
+
ports.log(`Left ${skipped.repo} alone: ${skipped.reason}.`);
|
|
7998
|
+
}
|
|
7999
|
+
if (options.dryRun) return { deletions: plan.deletions, blobsCollected: false };
|
|
8000
|
+
const late2 = await bakeActivity(ports.exec, now());
|
|
8001
|
+
if (late2) return skip(late2);
|
|
8002
|
+
const deletions = [];
|
|
8003
|
+
for (const deletion of plan.deletions) {
|
|
8004
|
+
if (await deleteManifest(fetchImpl, deletion)) deletions.push(deletion);
|
|
8005
|
+
else ports.log(`Could not delete ${deletion.repo}@${deletion.digest}.`);
|
|
8006
|
+
}
|
|
8007
|
+
await collectBlobs(ports);
|
|
8008
|
+
return { deletions, blobsCollected: true };
|
|
8009
|
+
}
|
|
8010
|
+
async function registryDataBytes(exec) {
|
|
8011
|
+
const result = await exec("du", ["-s", "--block-size=1", LOCAL_REGISTRY_DATA_DIR], {
|
|
8012
|
+
timeoutMs: 12e4
|
|
8013
|
+
});
|
|
8014
|
+
if (result.exitCode !== 0) return null;
|
|
8015
|
+
const bytes = Number.parseInt(result.stdout.trim().split(/\s+/)[0] ?? "", 10);
|
|
8016
|
+
return Number.isFinite(bytes) ? bytes : null;
|
|
8017
|
+
}
|
|
8018
|
+
async function registryGcDue(exec, lastGcAt, maintenanceHour, now = /* @__PURE__ */ new Date()) {
|
|
8019
|
+
const last = lastGcAt ? Date.parse(lastGcAt) : Number.NaN;
|
|
8020
|
+
const since = Number.isNaN(last) ? Number.POSITIVE_INFINITY : now.getTime() - last;
|
|
8021
|
+
if (now.getHours() === maintenanceHour && since >= REGISTRY_GC_INTERVAL_MS) return true;
|
|
8022
|
+
if (since < REGISTRY_GC_SIZE_INTERVAL_MS) return false;
|
|
8023
|
+
const bytes = await registryDataBytes(exec);
|
|
8024
|
+
return bytes !== null && bytes >= REGISTRY_GC_SIZE_BYTES;
|
|
7297
8025
|
}
|
|
7298
8026
|
|
|
7299
8027
|
// src/daemon/telemetry.ts
|
|
7300
8028
|
import { statfs } from "fs/promises";
|
|
7301
8029
|
|
|
8030
|
+
// src/vm/capacity.ts
|
|
8031
|
+
var GIB = 1024 ** 3;
|
|
8032
|
+
var MIB = 1024 ** 2;
|
|
8033
|
+
var CONTROL_RELAY_SLOTS = 6;
|
|
8034
|
+
var CAPACITY_BOUNDS = {
|
|
8035
|
+
relaySlots: { min: 16, max: 192, flag: "relay-slots" },
|
|
8036
|
+
relayMaxGiB: { min: 1, max: 16, flag: "relay-max-gib" },
|
|
8037
|
+
diskMiBps: { min: 25, max: 2e3, flag: "disk-mbps" },
|
|
8038
|
+
diskIops: { min: 500, max: 5e4, flag: "disk-iops" }
|
|
8039
|
+
};
|
|
8040
|
+
var LEGACY_CAPACITY_LIMITS = Object.freeze({
|
|
8041
|
+
schema: 1,
|
|
8042
|
+
profile: "default",
|
|
8043
|
+
relaySlots: 16,
|
|
8044
|
+
reservedRelaySlots: 0,
|
|
8045
|
+
relayMaxBytes: GIB,
|
|
8046
|
+
diskBytesPerSecond: 100 * MIB,
|
|
8047
|
+
diskIops: 2e3
|
|
8048
|
+
});
|
|
8049
|
+
var KEYS = Object.keys(CAPACITY_BOUNDS);
|
|
8050
|
+
var PROFILES = /* @__PURE__ */ new Set(["default", "server", "custom"]);
|
|
8051
|
+
function parseCapacityLimits(value) {
|
|
8052
|
+
if (value === void 0) return { ...LEGACY_CAPACITY_LIMITS };
|
|
8053
|
+
const limits = typeof value === "object" && value !== null ? value : {};
|
|
8054
|
+
const within = (key, min, max) => Number.isSafeInteger(limits[key]) && limits[key] >= min && limits[key] <= max;
|
|
8055
|
+
const bounds = CAPACITY_BOUNDS;
|
|
8056
|
+
if (limits.schema !== 1 || !PROFILES.has(limits.profile) || !within("relaySlots", bounds.relaySlots.min, bounds.relaySlots.max) || limits.reservedRelaySlots !== 0 && limits.reservedRelaySlots !== CONTROL_RELAY_SLOTS || !within("relayMaxBytes", bounds.relayMaxGiB.min * GIB, bounds.relayMaxGiB.max * GIB) || !within("diskBytesPerSecond", bounds.diskMiBps.min * MIB, bounds.diskMiBps.max * MIB) || !within("diskIops", bounds.diskIops.min, bounds.diskIops.max))
|
|
8057
|
+
throw new Error("The VM capacity limits in the host network policy are invalid.");
|
|
8058
|
+
return {
|
|
8059
|
+
schema: 1,
|
|
8060
|
+
profile: limits.profile,
|
|
8061
|
+
relaySlots: limits.relaySlots,
|
|
8062
|
+
reservedRelaySlots: limits.reservedRelaySlots,
|
|
8063
|
+
relayMaxBytes: limits.relayMaxBytes,
|
|
8064
|
+
diskBytesPerSecond: limits.diskBytesPerSecond,
|
|
8065
|
+
diskIops: limits.diskIops
|
|
8066
|
+
};
|
|
8067
|
+
}
|
|
8068
|
+
|
|
8069
|
+
// src/daemon/local-images.ts
|
|
8070
|
+
var MAX_LOCAL_IMAGES = 64;
|
|
8071
|
+
var MAX_INSPECTIONS_PER_BEAT = 4;
|
|
8072
|
+
var CONTENT_HASH = /^[0-9a-f]{16}$/;
|
|
8073
|
+
function isImageRef(ref) {
|
|
8074
|
+
try {
|
|
8075
|
+
assertImageRef(ref);
|
|
8076
|
+
return true;
|
|
8077
|
+
} catch {
|
|
8078
|
+
return false;
|
|
8079
|
+
}
|
|
8080
|
+
}
|
|
8081
|
+
async function inspectContentHash(exec, imageId) {
|
|
8082
|
+
const result = await exec("crictl", ["inspecti", "-o", "json", imageId], {
|
|
8083
|
+
timeoutMs: 5e3,
|
|
8084
|
+
maxOutputBytes: 256 * 1024
|
|
8085
|
+
}).catch(() => null);
|
|
8086
|
+
if (!result || result.exitCode !== 0) return void 0;
|
|
8087
|
+
try {
|
|
8088
|
+
const label = JSON.parse(result.stdout).info?.imageSpec?.config?.Labels?.[CONVEYOR_CONTENT_HASH_LABEL];
|
|
8089
|
+
return typeof label === "string" && CONTENT_HASH.test(label) ? label : null;
|
|
8090
|
+
} catch {
|
|
8091
|
+
return void 0;
|
|
8092
|
+
}
|
|
8093
|
+
}
|
|
8094
|
+
async function reportLocalImages(exec, inventory, wantedImages, cache) {
|
|
8095
|
+
if (!inventory) return void 0;
|
|
8096
|
+
const wanted = new Set(wantedImages);
|
|
8097
|
+
const held = inventory.images.flatMap(
|
|
8098
|
+
(image) => (image.repoTags ?? []).filter((ref) => ref.startsWith("localhost/") && isImageRef(ref)).map((ref) => ({ ref, imageId: image.id }))
|
|
8099
|
+
);
|
|
8100
|
+
held.sort(
|
|
8101
|
+
(a, b) => Number(wanted.has(b.ref)) - Number(wanted.has(a.ref)) || (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0)
|
|
8102
|
+
);
|
|
8103
|
+
const reported = held.slice(0, MAX_LOCAL_IMAGES);
|
|
8104
|
+
const live = new Set(reported.map((entry) => entry.imageId));
|
|
8105
|
+
for (const id of [...cache.keys()]) if (!live.has(id)) cache.delete(id);
|
|
8106
|
+
const images = [];
|
|
8107
|
+
let inspections = 0;
|
|
8108
|
+
for (const entry of reported) {
|
|
8109
|
+
let contentHash = cache.get(entry.imageId);
|
|
8110
|
+
if (contentHash === void 0 && inspections < MAX_INSPECTIONS_PER_BEAT) {
|
|
8111
|
+
inspections += 1;
|
|
8112
|
+
contentHash = await inspectContentHash(exec, entry.imageId);
|
|
8113
|
+
if (contentHash !== void 0) cache.set(entry.imageId, contentHash);
|
|
8114
|
+
}
|
|
8115
|
+
images.push({ ...entry, contentHash: contentHash ?? null });
|
|
8116
|
+
}
|
|
8117
|
+
return images;
|
|
8118
|
+
}
|
|
8119
|
+
|
|
7302
8120
|
// src/commands/runners.ts
|
|
7303
8121
|
var STAGING_FILE = `${RUNNERS_SUDOERS_FILE}.staged`;
|
|
7304
8122
|
async function detectRunnerUsers(exec) {
|
|
@@ -7334,6 +8152,21 @@ function ungrantedRunnersWarning(users) {
|
|
|
7334
8152
|
var GRANTED_COMMAND_NAMES = LOCAL_BAKE_ROOT_COMMANDS.map((command) => command.name);
|
|
7335
8153
|
|
|
7336
8154
|
// src/daemon/telemetry.ts
|
|
8155
|
+
var LOCAL_REGISTRY_WARN_AFTER_MS = 10 * 6e4;
|
|
8156
|
+
async function reportLocalRegistry(ports, watch, now = Date.now()) {
|
|
8157
|
+
if (!watch) return { fields: {}, warnings: [] };
|
|
8158
|
+
const ready = await localRegistryReady({ readFile: ports.readFile, fetch: ports.fetch });
|
|
8159
|
+
const paused = (watch.pausedUntil ?? 0) > now;
|
|
8160
|
+
watch.notReadySince = ready || paused ? null : watch.notReadySince ?? now;
|
|
8161
|
+
const stuckMs = watch.notReadySince === null ? 0 : now - watch.notReadySince;
|
|
8162
|
+
return {
|
|
8163
|
+
fields: { localRegistry: { host: PERSONAL_LOCAL_REGISTRY_HOST, ready } },
|
|
8164
|
+
warnings: stuckMs >= LOCAL_REGISTRY_WARN_AFTER_MS ? [
|
|
8165
|
+
`The local image registry has not been ready for ${Math.floor(stuckMs / 6e4)} minutes, so shadow bakes cannot use it yet. Check its pod with \`k3s kubectl -n conveyor-system get pods -l app.kubernetes.io/name=conveyor-registry\`. If the pod is running, k3s has not loaded the registry's mirror yet: it restarts k3s for that only while no Conveyor pod runs.`
|
|
8166
|
+
] : []
|
|
8167
|
+
};
|
|
8168
|
+
}
|
|
8169
|
+
var VM_CAPACITY_FILE = "/etc/conveyor-vm/capacity.json";
|
|
7337
8170
|
async function versionOf(exec, bin, args) {
|
|
7338
8171
|
const result = await exec(bin, args, { timeoutMs: 1e4 }).catch(() => null);
|
|
7339
8172
|
if (!result || result.exitCode !== 0) return null;
|
|
@@ -7366,16 +8199,26 @@ async function readNodeAllocatable(exec) {
|
|
|
7366
8199
|
const [cpu, memory] = result.stdout.trim().split(/\s+/);
|
|
7367
8200
|
return { allocatableCpu: cpu ?? null, allocatableMemory: memory ?? null };
|
|
7368
8201
|
}
|
|
7369
|
-
async function
|
|
8202
|
+
async function measureK3sStore(exec) {
|
|
8203
|
+
const result = await exec("du", ["-sx", "-B1", K3S_CONTAINERD_DIR], {
|
|
8204
|
+
timeoutMs: 6e4,
|
|
8205
|
+
maxOutputBytes: 1024 * 1024
|
|
8206
|
+
}).catch(() => null);
|
|
8207
|
+
const match = result?.stdout.match(/^\s*(\d+)\s/);
|
|
8208
|
+
if (!match?.[1]) return null;
|
|
8209
|
+
const bytes = Number(match[1]);
|
|
8210
|
+
return Number.isSafeInteger(bytes) ? bytes : null;
|
|
8211
|
+
}
|
|
8212
|
+
async function readDisk(k3sStoreBytes) {
|
|
7370
8213
|
try {
|
|
7371
8214
|
const stats = await statfs(STATE_DIR);
|
|
7372
8215
|
return {
|
|
7373
8216
|
totalBytes: Number(stats.blocks) * Number(stats.bsize),
|
|
7374
8217
|
freeBytes: Number(stats.bavail) * Number(stats.bsize),
|
|
7375
|
-
k3sStoreBytes
|
|
8218
|
+
k3sStoreBytes
|
|
7376
8219
|
};
|
|
7377
8220
|
} catch {
|
|
7378
|
-
return { totalBytes: 0, freeBytes: 0, k3sStoreBytes
|
|
8221
|
+
return { totalBytes: 0, freeBytes: 0, k3sStoreBytes };
|
|
7379
8222
|
}
|
|
7380
8223
|
}
|
|
7381
8224
|
function summarizeImages(inventory, wantedImages) {
|
|
@@ -7391,72 +8234,319 @@ function summarizeImages(inventory, wantedImages) {
|
|
|
7391
8234
|
pinnedCount: countPinnedWanted(inventory, wantedImages)
|
|
7392
8235
|
};
|
|
7393
8236
|
}
|
|
7394
|
-
function imageGcPressureWarning(disk, highThreshold) {
|
|
7395
|
-
if (highThreshold === null || highThreshold === void 0) return null;
|
|
7396
|
-
if (disk.totalBytes <= 0) return null;
|
|
7397
|
-
const usedPercent = Math.round((disk.totalBytes - disk.freeBytes) / disk.totalBytes * 100);
|
|
7398
|
-
if (usedPercent < highThreshold) return null;
|
|
7399
|
-
return `Kubelet image garbage collection is active: the disk is ${usedPercent}% full and the image-gc-high-threshold is ${highThreshold}%. Images that no container is running are removed within 5 minutes, a freshly baked one included. Free disk space, or raise image-gc-high-threshold.`;
|
|
8237
|
+
function imageGcPressureWarning(disk, highThreshold) {
|
|
8238
|
+
if (highThreshold === null || highThreshold === void 0) return null;
|
|
8239
|
+
if (disk.totalBytes <= 0) return null;
|
|
8240
|
+
const usedPercent = Math.round((disk.totalBytes - disk.freeBytes) / disk.totalBytes * 100);
|
|
8241
|
+
if (usedPercent < highThreshold) return null;
|
|
8242
|
+
return `Kubelet image garbage collection is active: the disk is ${usedPercent}% full and the image-gc-high-threshold is ${highThreshold}%. Images that no container is running are removed within 5 minutes, a freshly baked one included. Free disk space, or raise image-gc-high-threshold.`;
|
|
8243
|
+
}
|
|
8244
|
+
async function readVmCapacity(ports) {
|
|
8245
|
+
const text = await ports.readFile(VM_CAPACITY_FILE);
|
|
8246
|
+
if (text === null) return void 0;
|
|
8247
|
+
try {
|
|
8248
|
+
const { schema: _schema, ...capacity } = parseCapacityLimits(JSON.parse(text));
|
|
8249
|
+
return capacity;
|
|
8250
|
+
} catch {
|
|
8251
|
+
return void 0;
|
|
8252
|
+
}
|
|
8253
|
+
}
|
|
8254
|
+
async function collectHeartbeat(ports, state, inventory) {
|
|
8255
|
+
const registries = parseRegistries(await ports.readFile(REGISTRIES_FILE));
|
|
8256
|
+
const disk = await readDisk(state.k3sStoreBytes ?? null);
|
|
8257
|
+
const gcWarning = imageGcPressureWarning(disk, state.imageGcHighThreshold);
|
|
8258
|
+
const vmCapacity = await readVmCapacity(ports);
|
|
8259
|
+
const localImages = await reportLocalImages(
|
|
8260
|
+
ports.exec,
|
|
8261
|
+
inventory,
|
|
8262
|
+
state.wantedImages,
|
|
8263
|
+
state.localImageHashes ?? /* @__PURE__ */ new Map()
|
|
8264
|
+
);
|
|
8265
|
+
const registry = await reportLocalRegistry(ports, state.localRegistry);
|
|
8266
|
+
const runnerUsers = await detectRunnerUsers(ports.exec);
|
|
8267
|
+
const ungranted = ungrantedRunnerUsers(runnerUsers, await ports.readFile(RUNNERS_SUDOERS_FILE));
|
|
8268
|
+
return {
|
|
8269
|
+
publicWebProxy: (await ports.readFile("/etc/conveyor-vm/public-web"))?.trim() === "enabled",
|
|
8270
|
+
...vmCapacity ? { vmCapacity } : {},
|
|
8271
|
+
cliVersion: state.cliVersion,
|
|
8272
|
+
k3sVersion: await versionOf(ports.exec, "k3s", ["--version"]),
|
|
8273
|
+
cloudflaredVersion: await versionOf(ports.exec, "cloudflared", ["--version"]),
|
|
8274
|
+
appliedDesiredStateVersion: state.appliedDesiredStateVersion,
|
|
8275
|
+
node: await readNodeAllocatable(ports.exec),
|
|
8276
|
+
disk,
|
|
8277
|
+
images: summarizeImages(inventory, state.wantedImages),
|
|
8278
|
+
registryAuthMissing: missingAuthHosts(registries, state.authRequiredHosts),
|
|
8279
|
+
// Display only. A null count is reported as 0 here; the guards that must
|
|
8280
|
+
// not act on an unknown count read the nullable value directly.
|
|
8281
|
+
runningConveyorPods: await countRunningConveyorPods(ports.exec) ?? 0,
|
|
8282
|
+
lastPruneAt: state.lastPruneAt,
|
|
8283
|
+
lastWarmAt: state.lastWarmAt,
|
|
8284
|
+
lastUpdateAt: state.lastUpdateAt,
|
|
8285
|
+
...runnerUsers.length > 0 ? { runnerUsers } : {},
|
|
8286
|
+
...localImages ? { localImages } : {},
|
|
8287
|
+
...registry.fields,
|
|
8288
|
+
localWarnings: [
|
|
8289
|
+
...state.localWarnings,
|
|
8290
|
+
...gcWarning ? [gcWarning] : [],
|
|
8291
|
+
...ungranted.length > 0 ? [ungrantedRunnersWarning(ungranted)] : [],
|
|
8292
|
+
...registry.warnings
|
|
8293
|
+
]
|
|
8294
|
+
};
|
|
8295
|
+
}
|
|
8296
|
+
async function readPodIdentities(exec) {
|
|
8297
|
+
const result = await exec(
|
|
8298
|
+
"k3s",
|
|
8299
|
+
[
|
|
8300
|
+
"kubectl",
|
|
8301
|
+
"get",
|
|
8302
|
+
"pods",
|
|
8303
|
+
"-A",
|
|
8304
|
+
"-l",
|
|
8305
|
+
CONVEYOR_POD_SELECTOR,
|
|
8306
|
+
"--request-timeout=5s",
|
|
8307
|
+
"--no-headers",
|
|
8308
|
+
"-o",
|
|
8309
|
+
"custom-columns=NS:.metadata.namespace,NAME:.metadata.name,UID:.metadata.uid"
|
|
8310
|
+
],
|
|
8311
|
+
{ timeoutMs: 7e3, maxOutputBytes: 65536 }
|
|
8312
|
+
);
|
|
8313
|
+
if (result.exitCode !== 0) return [];
|
|
8314
|
+
const rows = result.stdout.trim().split("\n").filter(Boolean);
|
|
8315
|
+
if (rows.length > 100) return [];
|
|
8316
|
+
return rows.flatMap((row) => {
|
|
8317
|
+
const [namespace, name, uid, extra] = row.trim().split(/\s+/);
|
|
8318
|
+
return !extra && [namespace, name, uid].every((s) => s && /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(s)) ? [{ namespace, name, uid }] : [];
|
|
8319
|
+
});
|
|
8320
|
+
}
|
|
8321
|
+
|
|
8322
|
+
// src/daemon/registry-upkeep-clock.ts
|
|
8323
|
+
function errorText(error) {
|
|
8324
|
+
return error instanceof Error ? error.message : String(error);
|
|
8325
|
+
}
|
|
8326
|
+
async function collect(ports, now) {
|
|
8327
|
+
const { exec, log, requestDesiredState, fetch: fetch2, sleep: sleep2, isAlive, watch } = ports;
|
|
8328
|
+
if (watch) watch.pausedUntil = now.getTime() + REGISTRY_GC_MARK_TTL_MS;
|
|
8329
|
+
try {
|
|
8330
|
+
const outcome = await collectRegistryGarbage(
|
|
8331
|
+
{ exec, log, requestDesiredState, fetch: fetch2, sleep: sleep2, isAlive },
|
|
8332
|
+
{ dryRun: false }
|
|
8333
|
+
);
|
|
8334
|
+
if (outcome.skipped) return outcome.skipped;
|
|
8335
|
+
log(
|
|
8336
|
+
`Registry garbage collection removed ${outcome.deletions.length} superseded image manifest(s) and their unreferenced layers.`
|
|
8337
|
+
);
|
|
8338
|
+
return null;
|
|
8339
|
+
} finally {
|
|
8340
|
+
if (watch) {
|
|
8341
|
+
watch.pausedUntil = null;
|
|
8342
|
+
watch.notReadySince = null;
|
|
8343
|
+
}
|
|
8344
|
+
}
|
|
8345
|
+
}
|
|
8346
|
+
async function runRegistryUpkeep(ports, lastGcAt, maintenanceHour) {
|
|
8347
|
+
const now = (ports.now ?? (() => /* @__PURE__ */ new Date()))();
|
|
8348
|
+
try {
|
|
8349
|
+
await deployLocalRegistry({
|
|
8350
|
+
exec: ports.exec,
|
|
8351
|
+
fs: ports.fs,
|
|
8352
|
+
runningConveyorPods: null,
|
|
8353
|
+
now: ports.now,
|
|
8354
|
+
isAlive: ports.isAlive
|
|
8355
|
+
});
|
|
8356
|
+
if (await registryMirrorPending(ports.fs) && mayDisrupt(await countRunningConveyorPods(ports.exec))) {
|
|
8357
|
+
await ports.reconcile();
|
|
8358
|
+
}
|
|
8359
|
+
if (!await registryGcDue(ports.exec, lastGcAt, maintenanceHour, now)) return lastGcAt;
|
|
8360
|
+
} catch (error) {
|
|
8361
|
+
ports.log(`Local image registry upkeep failed: ${errorText(error)}`);
|
|
8362
|
+
return lastGcAt;
|
|
8363
|
+
}
|
|
8364
|
+
try {
|
|
8365
|
+
const skipped = await collect(ports, now);
|
|
8366
|
+
if (skipped) {
|
|
8367
|
+
ports.log(`Registry garbage collection skipped: ${skipped}.`);
|
|
8368
|
+
return lastGcAt;
|
|
8369
|
+
}
|
|
8370
|
+
} catch (error) {
|
|
8371
|
+
ports.log(`Registry garbage collection failed: ${errorText(error)}`);
|
|
8372
|
+
}
|
|
8373
|
+
return now.toISOString();
|
|
8374
|
+
}
|
|
8375
|
+
|
|
8376
|
+
// src/daemon/reconcile.ts
|
|
8377
|
+
async function reconcileKubelet(ctx, desired, result) {
|
|
8378
|
+
const ownerConfig = await ctx.fs.readFile(K3S_OWNER_CONFIG);
|
|
8379
|
+
const rendered = renderK3sDropIn(desired.kubelet, ownerConfig);
|
|
8380
|
+
result.skippedKubeletFlags = rendered.skipped;
|
|
8381
|
+
result.effectiveImageGcHighThreshold = rendered.skipped.includes("image-gc-high-threshold") ? ownerImageGcHighThreshold(ownerConfig) : desired.kubelet.imageGcHighThreshold;
|
|
8382
|
+
for (const flag of rendered.skipped) {
|
|
8383
|
+
result.warnings.push(
|
|
8384
|
+
`Left \`${flag}\` to your own /etc/rancher/k3s/config.yaml \u2014 Conveyor does not override a setting you chose.`
|
|
8385
|
+
);
|
|
8386
|
+
}
|
|
8387
|
+
const current = await ctx.fs.readFile(K3S_CONFIG_DROPIN);
|
|
8388
|
+
if (rendered.content === null) {
|
|
8389
|
+
if (current !== null) await ctx.fs.removeFile(K3S_CONFIG_DROPIN);
|
|
8390
|
+
return;
|
|
8391
|
+
}
|
|
8392
|
+
if (current === rendered.content) return;
|
|
8393
|
+
await ctx.fs.ensureDir(K3S_CONFIG_DROPIN.slice(0, K3S_CONFIG_DROPIN.lastIndexOf("/")));
|
|
8394
|
+
await ctx.fs.writeFile(K3S_CONFIG_DROPIN, rendered.content, 420);
|
|
8395
|
+
if (!mayDisrupt(ctx.runningConveyorPods)) {
|
|
8396
|
+
result.warnings.push(
|
|
8397
|
+
`Kubelet settings changed but ${busyReason(ctx.runningConveyorPods)} \u2014 the restart is deferred until the machine is idle.`
|
|
8398
|
+
);
|
|
8399
|
+
return;
|
|
8400
|
+
}
|
|
8401
|
+
const restart = await ctx.exec("systemctl", ["restart", "k3s"], { timeoutMs: 12e4 });
|
|
8402
|
+
if (restart.exitCode === 0) {
|
|
8403
|
+
result.restartedK3s = true;
|
|
8404
|
+
} else {
|
|
8405
|
+
result.warnings.push(
|
|
8406
|
+
`Could not restart k3s to apply new kubelet settings: ${restart.stderr.trim().slice(0, 200)}`
|
|
8407
|
+
);
|
|
8408
|
+
}
|
|
8409
|
+
}
|
|
8410
|
+
async function reconcilePriorityClass(ctx, desired, result) {
|
|
8411
|
+
const name = assertK8sName(desired.priorityClass.name, "priorityClass.name");
|
|
8412
|
+
const read = await ctx.exec("k3s", [
|
|
8413
|
+
"kubectl",
|
|
8414
|
+
"get",
|
|
8415
|
+
"priorityclass",
|
|
8416
|
+
name,
|
|
8417
|
+
"-o",
|
|
8418
|
+
"jsonpath={.value}"
|
|
8419
|
+
]);
|
|
8420
|
+
if (read.exitCode !== 0) return;
|
|
8421
|
+
const existingValue = Number.parseInt(read.stdout.trim(), 10);
|
|
8422
|
+
if (existingValue === desired.priorityClass.value) return;
|
|
8423
|
+
if (!mayDisrupt(ctx.runningConveyorPods)) {
|
|
8424
|
+
result.warnings.push(
|
|
8425
|
+
`The \`${name}\` PriorityClass has value ${existingValue}, but Conveyor needs ${desired.priorityClass.value}. The value is immutable, so it must be recreated \u2014 deferred because ${busyReason(ctx.runningConveyorPods)}.`
|
|
8426
|
+
);
|
|
8427
|
+
return;
|
|
8428
|
+
}
|
|
8429
|
+
const removed = await ctx.exec("k3s", ["kubectl", "delete", "priorityclass", name]);
|
|
8430
|
+
if (removed.exitCode !== 0) {
|
|
8431
|
+
result.warnings.push(
|
|
8432
|
+
`Could not replace the \`${name}\` PriorityClass (value ${existingValue}, needs ${desired.priorityClass.value}): ${removed.stderr.trim().slice(0, 160)}`
|
|
8433
|
+
);
|
|
8434
|
+
return;
|
|
8435
|
+
}
|
|
8436
|
+
try {
|
|
8437
|
+
await kubectlApply(ctx.exec, desired.rbacManifest);
|
|
8438
|
+
result.recreatedPriorityClass = true;
|
|
8439
|
+
} catch (error) {
|
|
8440
|
+
result.warnings.push(
|
|
8441
|
+
`The \`${name}\` PriorityClass was removed to change its value but could not be recreated: ${error instanceof Error ? error.message : String(error)}. No build can start on this machine until it exists \u2014 run \`conveyor-k3 sync\`.`
|
|
8442
|
+
);
|
|
8443
|
+
}
|
|
8444
|
+
}
|
|
8445
|
+
function renderLimitRange(namespace, limits) {
|
|
8446
|
+
const ns = assertK8sName(namespace, "namespace");
|
|
8447
|
+
const values = [
|
|
8448
|
+
assertQuantity(limits.defaultRequest.cpu, "defaultRequest.cpu"),
|
|
8449
|
+
assertQuantity(limits.defaultRequest.memory, "defaultRequest.memory"),
|
|
8450
|
+
assertQuantity(limits.default.cpu, "default.cpu"),
|
|
8451
|
+
assertQuantity(limits.default.memory, "default.memory")
|
|
8452
|
+
];
|
|
8453
|
+
return [
|
|
8454
|
+
"apiVersion: v1",
|
|
8455
|
+
"kind: LimitRange",
|
|
8456
|
+
"metadata:",
|
|
8457
|
+
" name: conveyor-k3-defaults",
|
|
8458
|
+
` namespace: ${ns}`,
|
|
8459
|
+
"spec:",
|
|
8460
|
+
" limits:",
|
|
8461
|
+
" - type: Container",
|
|
8462
|
+
" defaultRequest:",
|
|
8463
|
+
` cpu: "${values[0]}"`,
|
|
8464
|
+
` memory: "${values[1]}"`,
|
|
8465
|
+
" default:",
|
|
8466
|
+
` cpu: "${values[2]}"`,
|
|
8467
|
+
` memory: "${values[3]}"`,
|
|
8468
|
+
""
|
|
8469
|
+
].join("\n");
|
|
8470
|
+
}
|
|
8471
|
+
async function reconcileNamespaces(ctx, desired, result) {
|
|
8472
|
+
for (const namespace of desired.namespaces) {
|
|
8473
|
+
try {
|
|
8474
|
+
await kubectlApply(ctx.exec, renderLimitRange(namespace.name, namespace.limitRange));
|
|
8475
|
+
} catch (error) {
|
|
8476
|
+
result.warnings.push(
|
|
8477
|
+
`Could not apply resource defaults to ${namespace.name} (${namespace.projectName}): ${error instanceof Error ? error.message : String(error)}`
|
|
8478
|
+
);
|
|
8479
|
+
}
|
|
8480
|
+
}
|
|
8481
|
+
}
|
|
8482
|
+
async function step(result, label, run) {
|
|
8483
|
+
try {
|
|
8484
|
+
await run();
|
|
8485
|
+
} catch (error) {
|
|
8486
|
+
result.warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
8487
|
+
}
|
|
7400
8488
|
}
|
|
7401
|
-
async function
|
|
7402
|
-
const
|
|
7403
|
-
|
|
7404
|
-
|
|
7405
|
-
|
|
7406
|
-
|
|
7407
|
-
|
|
7408
|
-
|
|
7409
|
-
|
|
7410
|
-
k3sVersion: await versionOf(ports.exec, "k3s", ["--version"]),
|
|
7411
|
-
cloudflaredVersion: await versionOf(ports.exec, "cloudflared", ["--version"]),
|
|
7412
|
-
appliedDesiredStateVersion: state.appliedDesiredStateVersion,
|
|
7413
|
-
node: await readNodeAllocatable(ports.exec),
|
|
7414
|
-
disk,
|
|
7415
|
-
images: summarizeImages(inventory, state.wantedImages),
|
|
7416
|
-
registryAuthMissing: missingAuthHosts(registries, state.authRequiredHosts),
|
|
7417
|
-
// Display only. A null count is reported as 0 here; the guards that must
|
|
7418
|
-
// not act on an unknown count read the nullable value directly.
|
|
7419
|
-
runningConveyorPods: await countRunningConveyorPods(ports.exec) ?? 0,
|
|
7420
|
-
lastPruneAt: state.lastPruneAt,
|
|
7421
|
-
lastWarmAt: state.lastWarmAt,
|
|
7422
|
-
lastUpdateAt: state.lastUpdateAt,
|
|
7423
|
-
...runnerUsers.length > 0 ? { runnerUsers } : {},
|
|
7424
|
-
localWarnings: [
|
|
7425
|
-
...state.localWarnings,
|
|
7426
|
-
...gcWarning ? [gcWarning] : [],
|
|
7427
|
-
...ungranted.length > 0 ? [ungrantedRunnersWarning(ungranted)] : []
|
|
7428
|
-
]
|
|
8489
|
+
async function reconcile(ctx, desired) {
|
|
8490
|
+
const result = {
|
|
8491
|
+
appliedVersion: desired.version,
|
|
8492
|
+
warnings: [],
|
|
8493
|
+
restartedK3s: false,
|
|
8494
|
+
skippedKubeletFlags: [],
|
|
8495
|
+
effectiveImageGcHighThreshold: null,
|
|
8496
|
+
recreatedPriorityClass: false,
|
|
8497
|
+
prunedNamespaces: []
|
|
7429
8498
|
};
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
8499
|
+
const disallowedKinds = findDisallowedKinds(desired.rbacManifest);
|
|
8500
|
+
const manifestAccepted = disallowedKinds.length === 0;
|
|
8501
|
+
if (disallowedKinds.length > 0) {
|
|
8502
|
+
result.warnings.push(
|
|
8503
|
+
`Conveyor sent a cluster manifest containing disallowed kind(s) ${disallowedKinds.join(", ")} \u2014 refused. RBAC and PriorityClass steps skipped; this should never happen, report it.`
|
|
8504
|
+
);
|
|
8505
|
+
} else {
|
|
8506
|
+
await step(result, "Could not apply RBAC", () => kubectlApply(ctx.exec, desired.rbacManifest));
|
|
8507
|
+
await step(
|
|
8508
|
+
result,
|
|
8509
|
+
"Could not reconcile the PriorityClass",
|
|
8510
|
+
() => reconcilePriorityClass(ctx, desired, result)
|
|
8511
|
+
);
|
|
8512
|
+
}
|
|
8513
|
+
await step(
|
|
8514
|
+
result,
|
|
8515
|
+
"Could not apply namespace defaults",
|
|
8516
|
+
() => reconcileNamespaces(ctx, desired, result)
|
|
7447
8517
|
);
|
|
7448
|
-
if (
|
|
7449
|
-
|
|
7450
|
-
|
|
7451
|
-
|
|
7452
|
-
|
|
7453
|
-
|
|
7454
|
-
}
|
|
8518
|
+
if (manifestAccepted) {
|
|
8519
|
+
await step(
|
|
8520
|
+
result,
|
|
8521
|
+
"Could not remove unused namespaces",
|
|
8522
|
+
() => pruneStaleNamespaces(ctx, desired, result)
|
|
8523
|
+
);
|
|
8524
|
+
}
|
|
8525
|
+
const registry = desired.localRegistry?.enabled === true;
|
|
8526
|
+
if (registry) {
|
|
8527
|
+
await step(result, "Could not reconcile the local image registry", async () => {
|
|
8528
|
+
await deployLocalRegistry(ctx);
|
|
8529
|
+
});
|
|
8530
|
+
}
|
|
8531
|
+
await step(
|
|
8532
|
+
result,
|
|
8533
|
+
"Could not apply kubelet settings",
|
|
8534
|
+
() => reconcileKubelet(ctx, desired, result)
|
|
8535
|
+
);
|
|
8536
|
+
if (registry && !result.restartedK3s) {
|
|
8537
|
+
await step(result, "Could not load the local image registry mirror", async () => {
|
|
8538
|
+
const loaded = await loadRegistryMirror(ctx);
|
|
8539
|
+
result.restartedK3s = loaded.restarted;
|
|
8540
|
+
if (loaded.warning) result.warnings.push(loaded.warning);
|
|
8541
|
+
});
|
|
8542
|
+
}
|
|
8543
|
+
return result;
|
|
7455
8544
|
}
|
|
7456
8545
|
|
|
7457
8546
|
// src/daemon/runner.ts
|
|
7458
|
-
var
|
|
7459
|
-
var DAY_MS = 24 *
|
|
8547
|
+
var HOUR_MS2 = 60 * 6e4;
|
|
8548
|
+
var DAY_MS = 24 * HOUR_MS2;
|
|
8549
|
+
var STORE_MEASURE_INTERVAL_MS = 55 * 6e4;
|
|
7460
8550
|
var DISK_PRESSURE_FREE_FRACTION = 0.2;
|
|
7461
8551
|
var STRANDED_PRUNE_BYTES = 20 * 1024 ** 3;
|
|
7462
8552
|
function flattenWanted(desired) {
|
|
@@ -7493,7 +8583,13 @@ var ClusterDaemon = class {
|
|
|
7493
8583
|
* the next reconcile silently clear them.
|
|
7494
8584
|
*/
|
|
7495
8585
|
warmWarnings = [];
|
|
8586
|
+
/** Content hash labels by image id, so each image is inspected once. */
|
|
8587
|
+
localImageHashes = /* @__PURE__ */ new Map();
|
|
8588
|
+
/** How long the in-cluster registry has been not ready, across beats. */
|
|
8589
|
+
registryWatch = { notReadySince: null };
|
|
7496
8590
|
timers = [];
|
|
8591
|
+
/** When the last store walk started, so one that failed is not retried on every reconcile. */
|
|
8592
|
+
storeWalkStartedAt = 0;
|
|
7497
8593
|
/** Serializes reconcile against itself — a sync nudge during a reconcile
|
|
7498
8594
|
* must queue, not interleave two kubectl applies of the same manifests. */
|
|
7499
8595
|
reconciling = null;
|
|
@@ -7513,7 +8609,7 @@ var ClusterDaemon = class {
|
|
|
7513
8609
|
}, PERSONAL_CLUSTER_HEARTBEAT_INTERVAL_MS),
|
|
7514
8610
|
setInterval(() => {
|
|
7515
8611
|
void this.warmAndPruneIfDue();
|
|
7516
|
-
},
|
|
8612
|
+
}, HOUR_MS2),
|
|
7517
8613
|
setInterval(() => {
|
|
7518
8614
|
void this.syncAndReconcile();
|
|
7519
8615
|
}, DAY_MS)
|
|
@@ -7552,12 +8648,18 @@ var ClusterDaemon = class {
|
|
|
7552
8648
|
);
|
|
7553
8649
|
this.warnings = result.warnings;
|
|
7554
8650
|
for (const warning of result.warnings) this.ports.log(warning);
|
|
8651
|
+
if (result.prunedNamespaces.length > 0) {
|
|
8652
|
+
this.ports.log(
|
|
8653
|
+
`Removed ${result.prunedNamespaces.length} empty namespace(s) of projects this machine no longer serves: ${result.prunedNamespaces.join(", ")}.`
|
|
8654
|
+
);
|
|
8655
|
+
}
|
|
7555
8656
|
this.effectiveImageGcHigh = result.effectiveImageGcHighThreshold;
|
|
7556
8657
|
const state = this.state ?? await readState(this.ports.fs);
|
|
7557
8658
|
this.state = { ...state, appliedDesiredStateVersion: result.appliedVersion };
|
|
7558
8659
|
await writeState(this.ports.fs, this.state);
|
|
7559
8660
|
await this.warm();
|
|
7560
8661
|
await this.pruneIfDue();
|
|
8662
|
+
await this.measureStoreIfDue();
|
|
7561
8663
|
}
|
|
7562
8664
|
/**
|
|
7563
8665
|
* The hourly clock: warm, re-pin, and reclaim if the pile justifies it.
|
|
@@ -7570,6 +8672,45 @@ var ClusterDaemon = class {
|
|
|
7570
8672
|
async warmAndPruneIfDue() {
|
|
7571
8673
|
await this.warm();
|
|
7572
8674
|
await this.pruneIfDue();
|
|
8675
|
+
await this.measureStoreIfDue();
|
|
8676
|
+
if (this.desired?.localRegistry?.enabled !== true) return;
|
|
8677
|
+
const lastRegistryGcAt = await runRegistryUpkeep(
|
|
8678
|
+
{
|
|
8679
|
+
exec: this.ports.exec,
|
|
8680
|
+
fs: this.ports.fs,
|
|
8681
|
+
log: this.ports.log,
|
|
8682
|
+
requestDesiredState: () => this.ports.connection.requestDesiredState(),
|
|
8683
|
+
reconcile: () => this.syncAndReconcile(),
|
|
8684
|
+
watch: this.registryWatch,
|
|
8685
|
+
fetch: this.ports.fetch
|
|
8686
|
+
},
|
|
8687
|
+
this.state?.lastRegistryGcAt ?? null,
|
|
8688
|
+
this.desired.maintenance.hour
|
|
8689
|
+
);
|
|
8690
|
+
if (lastRegistryGcAt === (this.state?.lastRegistryGcAt ?? null)) return;
|
|
8691
|
+
const state = this.state ?? await readState(this.ports.fs);
|
|
8692
|
+
this.state = { ...state, lastRegistryGcAt };
|
|
8693
|
+
await writeState(this.ports.fs, this.state);
|
|
8694
|
+
}
|
|
8695
|
+
/**
|
|
8696
|
+
* Measure k3s's containerd store when the last measurement is old enough,
|
|
8697
|
+
* and keep the result for the heartbeat. After a prune, so the number it
|
|
8698
|
+
* reports is what the prune left. A failed measurement keeps the last one.
|
|
8699
|
+
*/
|
|
8700
|
+
async measureStoreIfDue() {
|
|
8701
|
+
const measured = this.state?.k3sStoreMeasuredAt ? Date.parse(this.state.k3sStoreMeasuredAt) : 0;
|
|
8702
|
+
const last = Math.max(measured, this.storeWalkStartedAt);
|
|
8703
|
+
if (Date.now() - last < STORE_MEASURE_INTERVAL_MS) return;
|
|
8704
|
+
this.storeWalkStartedAt = Date.now();
|
|
8705
|
+
const bytes = await measureK3sStore(this.ports.exec);
|
|
8706
|
+
if (bytes === null) return;
|
|
8707
|
+
const state = this.state ?? await readState(this.ports.fs);
|
|
8708
|
+
this.state = { ...state, k3sStoreBytes: bytes, k3sStoreMeasuredAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
8709
|
+
await writeState(this.ports.fs, this.state);
|
|
8710
|
+
}
|
|
8711
|
+
/** The store size the heartbeat reports: the last measurement, null before one. */
|
|
8712
|
+
get measuredStoreBytes() {
|
|
8713
|
+
return this.state?.k3sStoreBytes ?? null;
|
|
7573
8714
|
}
|
|
7574
8715
|
/** Pre-pull the wanted refs. Silent on success; that is the normal case. */
|
|
7575
8716
|
async warm() {
|
|
@@ -7674,8 +8815,14 @@ var ClusterDaemon = class {
|
|
|
7674
8815
|
async buildHeartbeat() {
|
|
7675
8816
|
const { wantedImages, authRequiredHosts } = this.desired ? flattenWanted(this.desired) : { wantedImages: [], authRequiredHosts: [] };
|
|
7676
8817
|
const inventory = await readImageInventory(this.ports.exec).catch(() => null);
|
|
8818
|
+
const registryOn = this.desired?.localRegistry?.enabled === true;
|
|
8819
|
+
if (!registryOn) this.registryWatch.notReadySince = null;
|
|
7677
8820
|
return await collectHeartbeat(
|
|
7678
|
-
{
|
|
8821
|
+
{
|
|
8822
|
+
exec: this.ports.exec,
|
|
8823
|
+
readFile: (path) => this.ports.fs.readFile(path),
|
|
8824
|
+
fetch: this.ports.fetch
|
|
8825
|
+
},
|
|
7679
8826
|
{
|
|
7680
8827
|
cliVersion: this.ports.cliVersion,
|
|
7681
8828
|
appliedDesiredStateVersion: this.state?.appliedDesiredStateVersion ?? 0,
|
|
@@ -7685,7 +8832,10 @@ var ClusterDaemon = class {
|
|
|
7685
8832
|
lastWarmAt: this.state?.lastWarmAt ?? null,
|
|
7686
8833
|
lastUpdateAt: this.state?.lastUpdateAt ?? null,
|
|
7687
8834
|
localWarnings: [...this.warnings, ...this.warmWarnings],
|
|
7688
|
-
imageGcHighThreshold: this.effectiveImageGcHigh
|
|
8835
|
+
imageGcHighThreshold: this.effectiveImageGcHigh,
|
|
8836
|
+
localImageHashes: this.localImageHashes,
|
|
8837
|
+
...registryOn ? { localRegistry: this.registryWatch } : {},
|
|
8838
|
+
k3sStoreBytes: this.measuredStoreBytes
|
|
7689
8839
|
},
|
|
7690
8840
|
inventory
|
|
7691
8841
|
);
|
|
@@ -7833,8 +8983,9 @@ var nodeFileSystem = {
|
|
|
7833
8983
|
async removeFile(path) {
|
|
7834
8984
|
await rm(path, { force: true });
|
|
7835
8985
|
},
|
|
7836
|
-
async ensureDir(path) {
|
|
8986
|
+
async ensureDir(path, mode) {
|
|
7837
8987
|
await mkdir(path, { recursive: true });
|
|
8988
|
+
if (mode !== void 0) await chmod(path, mode);
|
|
7838
8989
|
}
|
|
7839
8990
|
};
|
|
7840
8991
|
function createMemoryFileSystem(seed = {}) {
|
|
@@ -7856,7 +9007,10 @@ function createMemoryFileSystem(seed = {}) {
|
|
|
7856
9007
|
removed.push(path);
|
|
7857
9008
|
return Promise.resolve();
|
|
7858
9009
|
},
|
|
7859
|
-
ensureDir: () =>
|
|
9010
|
+
ensureDir: (path, mode) => {
|
|
9011
|
+
if (mode !== void 0) modes.set(path, mode);
|
|
9012
|
+
return Promise.resolve();
|
|
9013
|
+
}
|
|
7860
9014
|
};
|
|
7861
9015
|
}
|
|
7862
9016
|
|
|
@@ -7917,16 +9071,22 @@ Commands:
|
|
|
7917
9071
|
--username <user>
|
|
7918
9072
|
--password-stdin read the password from stdin
|
|
7919
9073
|
--no-restart skip the required k3s restart
|
|
9074
|
+
registry gc Delete superseded shadow-bake images from the image
|
|
9075
|
+
registry inside this cluster. Runs daily on its own,
|
|
9076
|
+
and only while no Conveyor pod or bake is running.
|
|
9077
|
+
--dry-run list what it would delete
|
|
7920
9078
|
runners list Which GitHub Actions runners this machine hosts, and
|
|
7921
9079
|
whether each may import a locally baked image.
|
|
7922
9080
|
runners grant <user>\u2026 Let those runner users import a locally baked image
|
|
7923
9081
|
into this node and pin it. Writes one file under
|
|
7924
9082
|
/etc/sudoers.d, checked with visudo first. Needed only
|
|
7925
9083
|
when a project has Bake Locally turned on.
|
|
7926
|
-
uninstall Remove Conveyor's units, environment, and
|
|
7927
|
-
k3s config, registries.yaml
|
|
7928
|
-
|
|
7929
|
-
|
|
9084
|
+
uninstall Remove Conveyor's units, environment, state, and image
|
|
9085
|
+
registry. Your k3s config, your own registries.yaml
|
|
9086
|
+
entries, your workloads, and the registry's data are
|
|
9087
|
+
left alone.
|
|
9088
|
+
--purge-k3s --yes also remove k3s itself and the
|
|
9089
|
+
registry's data
|
|
7930
9090
|
daemon Run in the foreground (what the systemd unit calls).
|
|
7931
9091
|
|
|
7932
9092
|
Options:
|
|
@@ -7945,17 +9105,12 @@ async function registryAdd(ports, options) {
|
|
|
7945
9105
|
);
|
|
7946
9106
|
}
|
|
7947
9107
|
const raw = await ports.fs.readFile(REGISTRIES_FILE);
|
|
7948
|
-
const parsed = parseRegistries(raw);
|
|
7949
|
-
let backupPath = null;
|
|
7950
|
-
if (raw !== null) {
|
|
7951
|
-
backupPath = `${REGISTRIES_FILE}.bak-${ports.now().toISOString().replace(/[:.]/g, "-")}`;
|
|
7952
|
-
await ports.fs.writeFile(backupPath, raw, 384);
|
|
7953
|
-
}
|
|
7954
9108
|
const auth = { username: options.username, password: options.password };
|
|
7955
|
-
await
|
|
7956
|
-
|
|
7957
|
-
|
|
7958
|
-
|
|
9109
|
+
const backupPath = await writeRegistriesWithBackup(
|
|
9110
|
+
ports.fs,
|
|
9111
|
+
raw,
|
|
9112
|
+
upsertRegistryAuth(parseRegistries(raw), host, auth),
|
|
9113
|
+
ports.now()
|
|
7959
9114
|
);
|
|
7960
9115
|
if (options.noRestart) {
|
|
7961
9116
|
ports.log(`Added credentials for ${host}. Restart k3s to pick them up: systemctl restart k3s`);
|
|
@@ -7994,24 +9149,73 @@ function renderRegistryAuthSummary(missing) {
|
|
|
7994
9149
|
}
|
|
7995
9150
|
|
|
7996
9151
|
// src/commands/uninstall.ts
|
|
9152
|
+
async function removeLocalRegistry(ports) {
|
|
9153
|
+
const removed = await ports.exec(
|
|
9154
|
+
"k3s",
|
|
9155
|
+
[
|
|
9156
|
+
"kubectl",
|
|
9157
|
+
"-n",
|
|
9158
|
+
PERSONAL_LOCAL_REGISTRY_NAMESPACE,
|
|
9159
|
+
"delete",
|
|
9160
|
+
"deployment,service,networkpolicy",
|
|
9161
|
+
PERSONAL_LOCAL_REGISTRY_NAME,
|
|
9162
|
+
"--ignore-not-found"
|
|
9163
|
+
],
|
|
9164
|
+
{ timeoutMs: 12e4 }
|
|
9165
|
+
);
|
|
9166
|
+
if (removed.exitCode !== 0) {
|
|
9167
|
+
ports.log(
|
|
9168
|
+
`Could not remove the local image registry from the cluster: ${removed.stderr.trim().slice(0, 200)}`
|
|
9169
|
+
);
|
|
9170
|
+
}
|
|
9171
|
+
const raw = await ports.fs.readFile(REGISTRIES_FILE);
|
|
9172
|
+
const parsed = parseRegistries(raw);
|
|
9173
|
+
if (mirrorEndpoints(parsed, PERSONAL_LOCAL_REGISTRY_HOST).length === 0) return;
|
|
9174
|
+
const backup = await writeRegistriesWithBackup(
|
|
9175
|
+
ports.fs,
|
|
9176
|
+
raw,
|
|
9177
|
+
removeRegistryMirror(parsed, PERSONAL_LOCAL_REGISTRY_HOST),
|
|
9178
|
+
(ports.now ?? (() => /* @__PURE__ */ new Date()))()
|
|
9179
|
+
);
|
|
9180
|
+
ports.log(
|
|
9181
|
+
`Removed the local image registry's mirror from ${REGISTRIES_FILE} (backup: ${backup}).`
|
|
9182
|
+
);
|
|
9183
|
+
}
|
|
7997
9184
|
async function uninstall(ports, options = {}) {
|
|
7998
9185
|
const result = { stoppedUnits: [], removedFiles: [], purgedK3s: false };
|
|
9186
|
+
if (options.purgeK3s && !options.confirmed) {
|
|
9187
|
+
throw new Error(
|
|
9188
|
+
"--purge-k3s removes k3s, everything running on it, and the local image registry's data. Re-run with --yes if that is what you want."
|
|
9189
|
+
);
|
|
9190
|
+
}
|
|
7999
9191
|
for (const unit of ALL_UNITS) {
|
|
8000
9192
|
await ports.exec("systemctl", ["disable", "--now", unit]);
|
|
8001
9193
|
result.stoppedUnits.push(unit);
|
|
8002
9194
|
}
|
|
9195
|
+
await removeLocalRegistry(ports);
|
|
8003
9196
|
for (const path of OWNED_FILES) {
|
|
8004
9197
|
await ports.fs.removeFile(path);
|
|
8005
9198
|
result.removedFiles.push(path);
|
|
8006
9199
|
}
|
|
8007
|
-
await ports.exec("
|
|
9200
|
+
await ports.exec("find", [
|
|
9201
|
+
STATE_DIR,
|
|
9202
|
+
"-mindepth",
|
|
9203
|
+
"1",
|
|
9204
|
+
"-maxdepth",
|
|
9205
|
+
"1",
|
|
9206
|
+
"!",
|
|
9207
|
+
"-path",
|
|
9208
|
+
LOCAL_REGISTRY_DATA_DIR,
|
|
9209
|
+
"-exec",
|
|
9210
|
+
"rm",
|
|
9211
|
+
"-rf",
|
|
9212
|
+
"--",
|
|
9213
|
+
"{}",
|
|
9214
|
+
"+"
|
|
9215
|
+
]);
|
|
8008
9216
|
await ports.exec("systemctl", ["daemon-reload"]);
|
|
9217
|
+
await ports.exec("systemctl", ["restart", "systemd-journald"]).catch(() => void 0);
|
|
8009
9218
|
if (options.purgeK3s) {
|
|
8010
|
-
if (!options.confirmed) {
|
|
8011
|
-
throw new Error(
|
|
8012
|
-
"--purge-k3s removes k3s and everything running on it. Re-run with --yes if that is what you want."
|
|
8013
|
-
);
|
|
8014
|
-
}
|
|
8015
9219
|
const purge = await ports.exec("/usr/local/bin/k3s-uninstall.sh", [], {
|
|
8016
9220
|
timeoutMs: 10 * 6e4
|
|
8017
9221
|
});
|
|
@@ -8021,10 +9225,18 @@ async function uninstall(ports, options = {}) {
|
|
|
8021
9225
|
"k3s-uninstall.sh did not complete. k3s is still installed; remove it by hand if you want it gone."
|
|
8022
9226
|
);
|
|
8023
9227
|
}
|
|
9228
|
+
await ports.exec("rm", ["-rf", STATE_DIR]);
|
|
8024
9229
|
}
|
|
8025
|
-
ports.log("Removed Conveyor's units, environment files, and
|
|
8026
|
-
ports.log(
|
|
9230
|
+
ports.log("Removed Conveyor's units, environment files, state, and local image registry.");
|
|
9231
|
+
ports.log(
|
|
9232
|
+
"Your k3s config, your own registries.yaml entries, and your workloads were not touched."
|
|
9233
|
+
);
|
|
8027
9234
|
if (options.purgeK3s && result.purgedK3s) ports.log("k3s was removed.");
|
|
9235
|
+
if (!options.purgeK3s) {
|
|
9236
|
+
ports.log(
|
|
9237
|
+
`The local image registry's data is still at ${LOCAL_REGISTRY_DATA_DIR}. Delete that directory to reclaim its disk space.`
|
|
9238
|
+
);
|
|
9239
|
+
}
|
|
8028
9240
|
if (!options.serverInitiated) {
|
|
8029
9241
|
ports.log(
|
|
8030
9242
|
"Now click Disconnect in your Conveyor profile so the tunnel and DNS record are removed too."
|
|
@@ -8036,12 +9248,12 @@ async function uninstall(ports, options = {}) {
|
|
|
8036
9248
|
// src/commands/update.ts
|
|
8037
9249
|
var CLOUDFLARED_UPDATED_EXIT = 11;
|
|
8038
9250
|
function readExecStart(unit) {
|
|
8039
|
-
const
|
|
8040
|
-
const start =
|
|
9251
|
+
const lines2 = unit.split("\n");
|
|
9252
|
+
const start = lines2.findIndex((line) => line.trimStart().startsWith("ExecStart="));
|
|
8041
9253
|
if (start < 0) return "";
|
|
8042
9254
|
const collected = [];
|
|
8043
|
-
for (let index = start; index <
|
|
8044
|
-
const line =
|
|
9255
|
+
for (let index = start; index < lines2.length; index += 1) {
|
|
9256
|
+
const line = lines2[index] ?? "";
|
|
8045
9257
|
collected.push(line);
|
|
8046
9258
|
if (!line.trimEnd().endsWith("\\")) break;
|
|
8047
9259
|
}
|
|
@@ -8183,12 +9395,75 @@ function meetsK3sMinimum(version) {
|
|
|
8183
9395
|
// src/vm/network.ts
|
|
8184
9396
|
import { networkInterfaces } from "os";
|
|
8185
9397
|
import { resolve4 as systemResolve4, resolveSrv as systemResolveSrv } from "dns/promises";
|
|
9398
|
+
|
|
9399
|
+
// src/vm/endpoints.ts
|
|
9400
|
+
var CONTROL_PLANE_HOSTS = ["api.conveyor.rallycryapp.com", "conveyor-api.rallycryapp.com"];
|
|
9401
|
+
var DEFAULT_ENDPOINTS = [
|
|
9402
|
+
"archive.ubuntu.com",
|
|
9403
|
+
"security.ubuntu.com",
|
|
9404
|
+
"ports.ubuntu.com",
|
|
9405
|
+
"registry.npmjs.org",
|
|
9406
|
+
// Font Awesome Pro tarballs through Conveyor's Artifact Registry cache.
|
|
9407
|
+
"us-central1-npm.pkg.dev",
|
|
9408
|
+
// Tiptap Pro packages, which every claudespace bake installs.
|
|
9409
|
+
"registry.tiptap.dev",
|
|
9410
|
+
// The Python venv a pod builds at runtime.
|
|
9411
|
+
"pypi.org",
|
|
9412
|
+
"files.pythonhosted.org",
|
|
9413
|
+
"bun.sh",
|
|
9414
|
+
"get.k3s.io",
|
|
9415
|
+
"update.k3s.io",
|
|
9416
|
+
"github.com",
|
|
9417
|
+
"api.github.com",
|
|
9418
|
+
"raw.githubusercontent.com",
|
|
9419
|
+
"release-assets.githubusercontent.com",
|
|
9420
|
+
"objects.githubusercontent.com",
|
|
9421
|
+
"ghcr.io",
|
|
9422
|
+
"pkg-containers.githubusercontent.com",
|
|
9423
|
+
// The crane image that shadow bakes' state-bake and publish Jobs run. gcr.io
|
|
9424
|
+
// serves its blobs from the same host (/artifacts-downloads), on Google's
|
|
9425
|
+
// shared frontend addresses like us-central1-npm.pkg.dev.
|
|
9426
|
+
"gcr.io",
|
|
9427
|
+
// The first shadow bake on a VM builds the toolchain base inside it; base
|
|
9428
|
+
// reuse never crosses machines. These are the hosts that build downloads
|
|
9429
|
+
// from. Several sit on shared CDN addresses.
|
|
9430
|
+
// Plain HTTP too: the first apt-get runs before ca-certificates exists.
|
|
9431
|
+
"deb.debian.org",
|
|
9432
|
+
"www.postgresql.org",
|
|
9433
|
+
"apt.postgresql.org",
|
|
9434
|
+
"cli.github.com",
|
|
9435
|
+
"johnvansickle.com",
|
|
9436
|
+
"astral.sh",
|
|
9437
|
+
// uv downloads CPython from here.
|
|
9438
|
+
"releases.astral.sh",
|
|
9439
|
+
"opencode.ai",
|
|
9440
|
+
"cdn.playwright.dev",
|
|
9441
|
+
"playwright.download.prss.microsoft.com",
|
|
9442
|
+
"registry-1.docker.io",
|
|
9443
|
+
"auth.docker.io",
|
|
9444
|
+
"production.cloudflare.docker.com",
|
|
9445
|
+
"production.cloudfront.docker.com",
|
|
9446
|
+
"api.cloudflare.com",
|
|
9447
|
+
"conveyor.rallycryapp.com",
|
|
9448
|
+
"conveyor-api.rallycryapp.com",
|
|
9449
|
+
"api.conveyor.rallycryapp.com",
|
|
9450
|
+
"api.anthropic.com",
|
|
9451
|
+
"api.openai.com",
|
|
9452
|
+
"chatgpt.com"
|
|
9453
|
+
].map((host) => ({
|
|
9454
|
+
host,
|
|
9455
|
+
ports: host.endsWith(".ubuntu.com") || host === "deb.debian.org" ? [80, 443] : [443]
|
|
9456
|
+
}));
|
|
9457
|
+
|
|
9458
|
+
// src/vm/network.ts
|
|
8186
9459
|
var WORKER_SUBNET = "192.168.5.0/24";
|
|
8187
9460
|
var WORKER_IP = "192.168.5.15";
|
|
8188
9461
|
var MAX_NETWORK_ENDPOINTS = 100;
|
|
8189
9462
|
var MAX_PORTS_PER_ENDPOINT = 16;
|
|
8190
9463
|
var MAX_NETWORK_FORWARDS = 100;
|
|
8191
9464
|
var MAX_CLOUDFLARE_EDGE_ADDRESSES = 4;
|
|
9465
|
+
var MAX_CONTROL_ALIASES = 8;
|
|
9466
|
+
var ARGOTUNNEL = /^(?:us-)?region[12]\.v2\.argotunnel\.com$/;
|
|
8192
9467
|
var DOMAIN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
8193
9468
|
function parseIPv4(address) {
|
|
8194
9469
|
const parts = address.split(".");
|
|
@@ -8255,6 +9530,36 @@ function validatePorts(ports, host) {
|
|
|
8255
9530
|
function syntheticIp(index) {
|
|
8256
9531
|
return `192.168.5.${100 + index}`;
|
|
8257
9532
|
}
|
|
9533
|
+
function pinAddresses(name, answers, localAddresses) {
|
|
9534
|
+
if (!Array.isArray(answers) || answers.length === 0) {
|
|
9535
|
+
throw new Error(`No IPv4 address resolved for ${name}`);
|
|
9536
|
+
}
|
|
9537
|
+
const uniqueAnswers = [...new Set(answers)];
|
|
9538
|
+
const unsafe = uniqueAnswers.find(
|
|
9539
|
+
(address) => !isGloballyRoutableIPv4(address) || localAddresses.has(address)
|
|
9540
|
+
);
|
|
9541
|
+
if (unsafe) {
|
|
9542
|
+
throw new Error(`Refusing unsafe IPv4 answer for ${name}: ${unsafe}`);
|
|
9543
|
+
}
|
|
9544
|
+
return ARGOTUNNEL.test(name) ? uniqueAnswers.slice(0, MAX_CLOUDFLARE_EDGE_ADDRESSES) : uniqueAnswers.slice(0, 1);
|
|
9545
|
+
}
|
|
9546
|
+
function forwardCount(entries) {
|
|
9547
|
+
return entries.reduce((sum, entry) => sum + entry.ports.length, 0);
|
|
9548
|
+
}
|
|
9549
|
+
function controlAliases(hosts) {
|
|
9550
|
+
return CONTROL_PLANE_HOSTS.slice(0, MAX_CONTROL_ALIASES).flatMap((name, index) => {
|
|
9551
|
+
const host = hosts.find((entry) => entry.name === name && entry.ports.includes(443));
|
|
9552
|
+
return host ? [
|
|
9553
|
+
{
|
|
9554
|
+
name,
|
|
9555
|
+
guestIp: `192.168.5.${240 + index}`,
|
|
9556
|
+
address: host.address,
|
|
9557
|
+
ports: [443],
|
|
9558
|
+
reserve: "control"
|
|
9559
|
+
}
|
|
9560
|
+
] : [];
|
|
9561
|
+
});
|
|
9562
|
+
}
|
|
8258
9563
|
async function resolveNetwork(endpoints, lookup = systemResolve4) {
|
|
8259
9564
|
if (!Array.isArray(endpoints) || endpoints.length > MAX_NETWORK_ENDPOINTS) {
|
|
8260
9565
|
throw new Error(`Too many network endpoints (maximum ${MAX_NETWORK_ENDPOINTS})`);
|
|
@@ -8278,28 +9583,27 @@ async function resolveNetwork(endpoints, lookup = systemResolve4) {
|
|
|
8278
9583
|
const localAddresses = localIPv4Addresses();
|
|
8279
9584
|
const hosts = [];
|
|
8280
9585
|
for (const [name, ports] of merged) {
|
|
8281
|
-
const
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
throw new Error(`Refusing unsafe IPv4 answer for ${name}: ${unsafe}`);
|
|
8291
|
-
}
|
|
8292
|
-
const addresses = /^(?:us-)?region[12]\.v2\.argotunnel\.com$/.test(name) ? uniqueAnswers.slice(0, MAX_CLOUDFLARE_EDGE_ADDRESSES) : uniqueAnswers.slice(0, 1);
|
|
8293
|
-
for (const address of addresses) {
|
|
8294
|
-
hosts.push({ name, guestIp: syntheticIp(hosts.length), address, ports });
|
|
9586
|
+
const tunnel = ARGOTUNNEL.test(name);
|
|
9587
|
+
for (const address of pinAddresses(name, await lookup(name), localAddresses)) {
|
|
9588
|
+
hosts.push({
|
|
9589
|
+
name,
|
|
9590
|
+
guestIp: syntheticIp(hosts.length),
|
|
9591
|
+
address,
|
|
9592
|
+
ports,
|
|
9593
|
+
...tunnel ? { reserve: "control" } : {}
|
|
9594
|
+
});
|
|
8295
9595
|
}
|
|
8296
9596
|
}
|
|
8297
|
-
|
|
9597
|
+
const control = controlAliases(hosts);
|
|
9598
|
+
if (forwardCount(hosts) + forwardCount(control) > MAX_NETWORK_FORWARDS) {
|
|
9599
|
+
throw new Error(`Too many network forwarding rules (maximum ${MAX_NETWORK_FORWARDS})`);
|
|
9600
|
+
}
|
|
9601
|
+
return { hosts, srv: cloudflaredSrvRecords(hosts), ...control.length > 0 ? { control } : {} };
|
|
8298
9602
|
}
|
|
8299
9603
|
function cloudflaredSrvRecords(hosts) {
|
|
8300
9604
|
const targets = [
|
|
8301
9605
|
...new Map(
|
|
8302
|
-
hosts.filter((host) =>
|
|
9606
|
+
hosts.filter((host) => ARGOTUNNEL.test(host.name)).map((host) => [host.name, host])
|
|
8303
9607
|
).values()
|
|
8304
9608
|
];
|
|
8305
9609
|
if (targets.length === 0) return [];
|
|
@@ -8508,6 +9812,28 @@ while True:
|
|
|
8508
9812
|
udp.sendto(response, peer)
|
|
8509
9813
|
`;
|
|
8510
9814
|
|
|
9815
|
+
// src/vm/instance-name.ts
|
|
9816
|
+
var VM_NAME = "conveyor";
|
|
9817
|
+
var DEFAULT_INSTANCE = "default";
|
|
9818
|
+
var NAMED_INSTANCE = /^(?=.{1,12}$)[a-z](?:-?[a-z0-9])*$/;
|
|
9819
|
+
function isNamedInstance(value) {
|
|
9820
|
+
return typeof value === "string" && NAMED_INSTANCE.test(value) && value !== DEFAULT_INSTANCE && value !== VM_NAME;
|
|
9821
|
+
}
|
|
9822
|
+
function parseInstanceName(value) {
|
|
9823
|
+
if (value === void 0 || value === DEFAULT_INSTANCE) return DEFAULT_INSTANCE;
|
|
9824
|
+
if (value === true) throw new Error("Use --instance=<name>.");
|
|
9825
|
+
if (value === VM_NAME)
|
|
9826
|
+
throw new Error(`"${VM_NAME}" is reserved for the default VM. Omit --instance to use it.`);
|
|
9827
|
+
if (!isNamedInstance(value))
|
|
9828
|
+
throw new Error(
|
|
9829
|
+
"Use an instance name of 1-12 lowercase letters, digits, or single hyphens, starting with a letter."
|
|
9830
|
+
);
|
|
9831
|
+
return value;
|
|
9832
|
+
}
|
|
9833
|
+
function vmNameFor(instance) {
|
|
9834
|
+
return instance === DEFAULT_INSTANCE ? VM_NAME : instance;
|
|
9835
|
+
}
|
|
9836
|
+
|
|
8511
9837
|
// src/vm/public-web.ts
|
|
8512
9838
|
import { resolve4 } from "dns/promises";
|
|
8513
9839
|
import { networkInterfaces as networkInterfaces2 } from "os";
|
|
@@ -8515,6 +9841,65 @@ var PUBLIC_WEB_IP = "192.168.5.250";
|
|
|
8515
9841
|
var PUBLIC_WEB_PORT = 3128;
|
|
8516
9842
|
var PUBLIC_WEB_URL = `http://${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}`;
|
|
8517
9843
|
|
|
9844
|
+
// src/vm/qemu-forwards.ts
|
|
9845
|
+
var ENDPOINT_IP = /^192\.168\.5\.(?:1\d\d)$/;
|
|
9846
|
+
var CONTROL_ALIAS_IP2 = /^192\.168\.5\.24[0-7]$/;
|
|
9847
|
+
var PINNED_IPV4 = /^(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])){3}$/;
|
|
9848
|
+
function assertPort(port) {
|
|
9849
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
9850
|
+
throw new Error("Invalid guest forwarding port");
|
|
9851
|
+
}
|
|
9852
|
+
function validateForwards(forwards) {
|
|
9853
|
+
const seen = /* @__PURE__ */ new Set();
|
|
9854
|
+
for (const forward of forwards) {
|
|
9855
|
+
assertPort(forward.port);
|
|
9856
|
+
const key = `${forward.guestIp}:${forward.port}`;
|
|
9857
|
+
if (seen.has(key)) throw new Error("Duplicate QEMU guest forwarding rule");
|
|
9858
|
+
seen.add(key);
|
|
9859
|
+
if (!isGloballyRoutableIPv4(forward.address) || !PINNED_IPV4.test(forward.address))
|
|
9860
|
+
throw new Error("Invalid pinned forwarding address");
|
|
9861
|
+
}
|
|
9862
|
+
}
|
|
9863
|
+
function forwardsOf(entries, guestIps, control) {
|
|
9864
|
+
return entries.flatMap((host) => {
|
|
9865
|
+
if (!guestIps.test(host.guestIp) || control && host.reserve !== "control")
|
|
9866
|
+
throw new Error("Invalid pinned forwarding address");
|
|
9867
|
+
return host.ports.map((port) => ({
|
|
9868
|
+
guestIp: host.guestIp,
|
|
9869
|
+
address: host.address,
|
|
9870
|
+
port,
|
|
9871
|
+
...host.reserve === "control" ? { reserve: "control" } : {}
|
|
9872
|
+
}));
|
|
9873
|
+
});
|
|
9874
|
+
}
|
|
9875
|
+
function qemuForwards(network) {
|
|
9876
|
+
const forwards = [
|
|
9877
|
+
...forwardsOf(network.hosts, ENDPOINT_IP, false),
|
|
9878
|
+
...forwardsOf(network.control ?? [], CONTROL_ALIAS_IP2, true)
|
|
9879
|
+
];
|
|
9880
|
+
if (forwards.length > MAX_NETWORK_FORWARDS) throw new Error("Too many QEMU forwarding rules");
|
|
9881
|
+
validateForwards(forwards);
|
|
9882
|
+
return forwards;
|
|
9883
|
+
}
|
|
9884
|
+
function restrictedUsernet(network, forwards, ncPath) {
|
|
9885
|
+
const guestForwards = forwards.map(
|
|
9886
|
+
(forward) => `guestfwd=tcp:${forward.guestIp}:${forward.port}-cmd:${ncPath} -4 -n -w 60 ${forward.address} ${forward.port}${forward.reserve === "control" ? " control" : ""}`
|
|
9887
|
+
);
|
|
9888
|
+
return [
|
|
9889
|
+
"user",
|
|
9890
|
+
"id=net0",
|
|
9891
|
+
`net=${WORKER_SUBNET}`,
|
|
9892
|
+
`dhcpstart=${WORKER_IP}`,
|
|
9893
|
+
"restrict=on",
|
|
9894
|
+
"ipv6=off",
|
|
9895
|
+
`hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
|
|
9896
|
+
...guestForwards,
|
|
9897
|
+
...network.publicWeb === true ? [
|
|
9898
|
+
`guestfwd=tcp:${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}-cmd:${ncPath} -4 -n -w 60 public-web ${PUBLIC_WEB_PORT}`
|
|
9899
|
+
] : []
|
|
9900
|
+
].join(",");
|
|
9901
|
+
}
|
|
9902
|
+
|
|
8518
9903
|
// src/vm/qemu-policy.ts
|
|
8519
9904
|
var ALLOWED_PROBES = new Set(
|
|
8520
9905
|
[
|
|
@@ -8568,9 +9953,7 @@ var REQUIRED_CONTROLS = [
|
|
|
8568
9953
|
"drive:cidata"
|
|
8569
9954
|
];
|
|
8570
9955
|
var DISALLOWED_CONTROLS = /* @__PURE__ */ new Set(["-sandbox", "-no-user-config", "-nodefaults"]);
|
|
8571
|
-
|
|
8572
|
-
var BOOT_DISK_IOPS = "2000";
|
|
8573
|
-
function assertPort(port, field) {
|
|
9956
|
+
function assertPort2(port, field) {
|
|
8574
9957
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Invalid ${field}`);
|
|
8575
9958
|
}
|
|
8576
9959
|
function isProbe(args) {
|
|
@@ -8691,10 +10074,11 @@ function validateDrive(value, context) {
|
|
|
8691
10074
|
}
|
|
8692
10075
|
assertFields(fields, { file: /.+/, if: "none", discard: "on", id: "boot-disk" }, "boot drive");
|
|
8693
10076
|
assertInstancePath(path, "disk", context);
|
|
8694
|
-
|
|
8695
|
-
|
|
8696
|
-
|
|
10077
|
+
const disk = context.disk ?? {
|
|
10078
|
+
bps: LEGACY_CAPACITY_LIMITS.diskBytesPerSecond,
|
|
10079
|
+
iops: LEGACY_CAPACITY_LIMITS.diskIops
|
|
8697
10080
|
};
|
|
10081
|
+
return { kind: "boot", value: `${value},bps=${disk.bps},iops=${disk.iops}` };
|
|
8698
10082
|
}
|
|
8699
10083
|
if (fields.get("id") === "cdrom0") {
|
|
8700
10084
|
assertFields(
|
|
@@ -8739,38 +10123,6 @@ function parseOriginalUsernet(value, sshPort) {
|
|
|
8739
10123
|
"Lima QEMU user network"
|
|
8740
10124
|
);
|
|
8741
10125
|
}
|
|
8742
|
-
function restrictedUsernet(network, forwards, ncPath) {
|
|
8743
|
-
const guestForwards = forwards.map(
|
|
8744
|
-
(forward) => `guestfwd=tcp:${forward.guestIp}:${forward.port}-cmd:${ncPath} -4 -n -w 60 ${forward.address} ${forward.port}`
|
|
8745
|
-
);
|
|
8746
|
-
return [
|
|
8747
|
-
"user",
|
|
8748
|
-
"id=net0",
|
|
8749
|
-
`net=${WORKER_SUBNET}`,
|
|
8750
|
-
`dhcpstart=${WORKER_IP}`,
|
|
8751
|
-
"restrict=on",
|
|
8752
|
-
"ipv6=off",
|
|
8753
|
-
`hostfwd=tcp:127.0.0.1:${network.sshPort}-:22`,
|
|
8754
|
-
...guestForwards,
|
|
8755
|
-
...network.publicWeb === true ? [
|
|
8756
|
-
`guestfwd=tcp:${PUBLIC_WEB_IP}:${PUBLIC_WEB_PORT}-cmd:${ncPath} -4 -n -w 60 public-web ${PUBLIC_WEB_PORT}`
|
|
8757
|
-
] : []
|
|
8758
|
-
].join(",");
|
|
8759
|
-
}
|
|
8760
|
-
function validateForwards(forwards) {
|
|
8761
|
-
const seen = /* @__PURE__ */ new Set();
|
|
8762
|
-
for (const forward of forwards) {
|
|
8763
|
-
assertPort(forward.port, "guest forwarding port");
|
|
8764
|
-
const key = `${forward.guestIp}:${forward.port}`;
|
|
8765
|
-
if (seen.has(key)) throw new Error("Duplicate QEMU guest forwarding rule");
|
|
8766
|
-
seen.add(key);
|
|
8767
|
-
if (!/^192\.168\.5\.(?:1\d\d)$/.test(forward.guestIp) || !isGloballyRoutableIPv4(forward.address) || !/^(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:[1-9]?\d|1\d\d|2[0-4]\d|25[0-5])){3}$/.test(
|
|
8768
|
-
forward.address
|
|
8769
|
-
)) {
|
|
8770
|
-
throw new Error("Invalid pinned forwarding address");
|
|
8771
|
-
}
|
|
8772
|
-
}
|
|
8773
|
-
}
|
|
8774
10126
|
function fixedControl(control, expected) {
|
|
8775
10127
|
return (value) => {
|
|
8776
10128
|
if (value !== expected) throw new Error(`Unexpected QEMU ${control}`);
|
|
@@ -8803,7 +10155,7 @@ var SIMPLE_VALIDATORS = {
|
|
|
8803
10155
|
"-parallel": fixedControl("-parallel", "none"),
|
|
8804
10156
|
"-serial": fixedControl("serial", "chardev:char-serial"),
|
|
8805
10157
|
"-qmp": fixedControl("qmp", "chardev:char-qmp"),
|
|
8806
|
-
"-name": fixedControl("name",
|
|
10158
|
+
"-name": (value, { vmName = VM_NAME }) => fixedControl("name", `lima-${vmName}`)(value, {}),
|
|
8807
10159
|
"-pidfile": (value, context) => {
|
|
8808
10160
|
assertInstancePath(value, "qemu.pid", context);
|
|
8809
10161
|
return { value, control: "pidfile" };
|
|
@@ -8874,15 +10226,16 @@ function assertCompleteProfile(state) {
|
|
|
8874
10226
|
throw new Error("QEMU PCI serial device and chardev must appear together");
|
|
8875
10227
|
}
|
|
8876
10228
|
}
|
|
8877
|
-
function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc",
|
|
10229
|
+
function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc", hostContext = {}) {
|
|
8878
10230
|
if (isProbe(args)) return [...args];
|
|
8879
|
-
|
|
10231
|
+
assertPort2(network.sshPort, "SSH port");
|
|
8880
10232
|
if (!TRUSTED_NC_PATH.test(ncPath)) throw new Error("Invalid netcat path");
|
|
8881
|
-
const forwards = network
|
|
8882
|
-
|
|
8883
|
-
|
|
8884
|
-
|
|
8885
|
-
|
|
10233
|
+
const forwards = qemuForwards(network);
|
|
10234
|
+
const limits = parseCapacityLimits(network.limits);
|
|
10235
|
+
const context = {
|
|
10236
|
+
...hostContext,
|
|
10237
|
+
disk: { bps: limits.diskBytesPerSecond, iops: limits.diskIops }
|
|
10238
|
+
};
|
|
8886
10239
|
const output = [];
|
|
8887
10240
|
const state = {
|
|
8888
10241
|
controls: /* @__PURE__ */ new Set(),
|
|
@@ -8922,15 +10275,79 @@ function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc", context = {}) {
|
|
|
8922
10275
|
}
|
|
8923
10276
|
|
|
8924
10277
|
// src/vm/memory-budget.ts
|
|
8925
|
-
|
|
10278
|
+
var RELAY_RSS_ALLOWANCE_MIB = 128;
|
|
10279
|
+
var BASE_RELAY_SLOTS = 16;
|
|
10280
|
+
function relayAllowanceGiB(relaySlots = BASE_RELAY_SLOTS) {
|
|
10281
|
+
return Math.max(0, relaySlots - BASE_RELAY_SLOTS) * RELAY_RSS_ALLOWANCE_MIB / 1024;
|
|
10282
|
+
}
|
|
10283
|
+
function maximumGuestMemoryGiBWithin(budgetGiB, relaySlots = BASE_RELAY_SLOTS) {
|
|
10284
|
+
const guestBudget = budgetGiB - relayAllowanceGiB(relaySlots);
|
|
10285
|
+
if (!Number.isFinite(guestBudget) || guestBudget <= 0) return 0;
|
|
10286
|
+
return Math.max(0, Math.floor(Math.min(guestBudget - 1, guestBudget / 1.25)));
|
|
10287
|
+
}
|
|
10288
|
+
function maximumGuestMemoryGiB(hostBytes, relaySlots = BASE_RELAY_SLOTS) {
|
|
8926
10289
|
if (!Number.isFinite(hostBytes) || hostBytes <= 0) return 0;
|
|
8927
|
-
|
|
8928
|
-
|
|
10290
|
+
return maximumGuestMemoryGiBWithin(hostBytes / 1024 ** 3 * 0.75, relaySlots);
|
|
10291
|
+
}
|
|
10292
|
+
|
|
10293
|
+
// src/vm/host-state.ts
|
|
10294
|
+
import { access, chmod as chmod2, lstat, mkdir as mkdir2, readFile as readFile2, rename, rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
10295
|
+
import { homedir, uptime } from "os";
|
|
10296
|
+
import { join } from "path";
|
|
10297
|
+
import { setTimeout as sleep } from "timers/promises";
|
|
10298
|
+
function vmProfile() {
|
|
10299
|
+
const profile = process.env.CONVEYOR_VM_TEST_PROFILE;
|
|
10300
|
+
if (profile !== void 0 && !/^acceptance-[a-z0-9-]{1,32}$/.test(profile)) {
|
|
10301
|
+
throw new Error("Test VM profiles must match acceptance-[a-z0-9-]{1,32}.");
|
|
10302
|
+
}
|
|
10303
|
+
return profile;
|
|
10304
|
+
}
|
|
10305
|
+
function instancesBase() {
|
|
10306
|
+
const profile = vmProfile();
|
|
10307
|
+
return join(
|
|
10308
|
+
homedir(),
|
|
10309
|
+
".local",
|
|
10310
|
+
"share",
|
|
10311
|
+
profile ? `conveyor-k3-${profile}-vms` : "conveyor-k3-vms"
|
|
10312
|
+
);
|
|
10313
|
+
}
|
|
10314
|
+
function hostPaths(instance = DEFAULT_INSTANCE) {
|
|
10315
|
+
const name = parseInstanceName(instance);
|
|
10316
|
+
if (name !== DEFAULT_INSTANCE) return pathsForRoot(join(instancesBase(), name), name);
|
|
10317
|
+
const profile = vmProfile();
|
|
10318
|
+
return pathsForRoot(
|
|
10319
|
+
join(homedir(), ".local", "share", profile ? `conveyor-k3-${profile}` : "conveyor-k3-vm")
|
|
10320
|
+
);
|
|
10321
|
+
}
|
|
10322
|
+
function pathsForRoot(root, instance = DEFAULT_INSTANCE) {
|
|
10323
|
+
const vmName = vmNameFor(parseInstanceName(instance));
|
|
10324
|
+
const instanceDir = join(root, "lima", vmName);
|
|
10325
|
+
return {
|
|
10326
|
+
root,
|
|
10327
|
+
limaHome: join(root, "lima"),
|
|
10328
|
+
state: join(root, "state.json"),
|
|
10329
|
+
network: join(root, "network.json"),
|
|
10330
|
+
template: join(root, "conveyor.yaml"),
|
|
10331
|
+
wrapper: join(root, "qemu-wrapper"),
|
|
10332
|
+
wrapperJs: join(root, "vm-qemu.js"),
|
|
10333
|
+
applied: join(root, "applied-network.json"),
|
|
10334
|
+
guard: join(root, "guard-state.json"),
|
|
10335
|
+
startRequest: join(root, "start-request.json"),
|
|
10336
|
+
policyTransaction: join(root, "policy-transaction.json"),
|
|
10337
|
+
lock: join(root, "operation.lock"),
|
|
10338
|
+
/** Written by an owner stop, so a supervising service does not restart the VM. */
|
|
10339
|
+
stopIntent: join(root, "stop-intent.json"),
|
|
10340
|
+
instance,
|
|
10341
|
+
vmName,
|
|
10342
|
+
instanceDir,
|
|
10343
|
+
limaConfig: join(instanceDir, "lima.yaml")
|
|
10344
|
+
};
|
|
8929
10345
|
}
|
|
8930
10346
|
export {
|
|
8931
10347
|
ClusterConnection,
|
|
8932
10348
|
ClusterDaemon,
|
|
8933
10349
|
CommandError,
|
|
10350
|
+
DEFAULT_INSTANCE,
|
|
8934
10351
|
HELP,
|
|
8935
10352
|
K3S_DISABLED_COMPONENTS,
|
|
8936
10353
|
K3S_INSTALL_EXEC,
|
|
@@ -8941,15 +10358,18 @@ export {
|
|
|
8941
10358
|
countRunningConveyorPods,
|
|
8942
10359
|
createMemoryFileSystem,
|
|
8943
10360
|
decideK3sUpgrade,
|
|
10361
|
+
hostPaths,
|
|
8944
10362
|
isGloballyRoutableIPv4,
|
|
8945
10363
|
isVerb,
|
|
8946
10364
|
maximumGuestMemoryGiB,
|
|
10365
|
+
maximumGuestMemoryGiBWithin,
|
|
8947
10366
|
meetsK3sMinimum,
|
|
8948
10367
|
missingAuthHosts,
|
|
8949
10368
|
nodeFileSystem,
|
|
8950
10369
|
ownerConfiguredKeys,
|
|
8951
10370
|
ownerConfiguredKubeletFlags,
|
|
8952
10371
|
parseArgs,
|
|
10372
|
+
parseInstanceName,
|
|
8953
10373
|
parseK3sVersion,
|
|
8954
10374
|
parseRegistries,
|
|
8955
10375
|
pruneStrandedImages,
|