@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/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 = readFileSync(path, "utf8");
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-PVMC53P5.js
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 socket = io(this.config.apiUrl, buildConveyorSocketOptions(auth));
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 lines = [
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
- lines.push("disable+:");
7081
- for (const component of K3S_DISABLED_COMPONENTS) lines.push(` - ${component}`);
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
- lines.push(`${WRITE_KUBECONFIG_MODE}: "${KUBECONFIG_MODE}"`);
7217
+ lines2.push(`${WRITE_KUBECONFIG_MODE}: "${KUBECONFIG_MODE}"`);
7086
7218
  }
7087
7219
  if (args.length > 0) {
7088
- lines.push("kubelet-arg+:", ...args);
7220
+ lines2.push("kubelet-arg+:", ...args);
7089
7221
  }
7090
- return { content: [...lines, ""].join("\n"), skipped };
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/reconcile.ts
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
- async function kubectlApply(exec, manifest) {
7147
- await runOrThrow(exec, "k3s", ["kubectl", "apply", "-f", "-"], {
7148
- stdin: manifest,
7149
- timeoutMs: 6e4
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
- async function reconcileKubelet(ctx, desired, result) {
7153
- const ownerConfig = await ctx.fs.readFile(K3S_OWNER_CONFIG);
7154
- const rendered = renderK3sDropIn(desired.kubelet, ownerConfig);
7155
- result.skippedKubeletFlags = rendered.skipped;
7156
- result.effectiveImageGcHighThreshold = rendered.skipped.includes("image-gc-high-threshold") ? ownerImageGcHighThreshold(ownerConfig) : desired.kubelet.imageGcHighThreshold;
7157
- for (const flag of rendered.skipped) {
7158
- result.warnings.push(
7159
- `Left \`${flag}\` to your own /etc/rancher/k3s/config.yaml \u2014 Conveyor does not override a setting you chose.`
7160
- );
7161
- }
7162
- const current = await ctx.fs.readFile(K3S_CONFIG_DROPIN);
7163
- if (rendered.content === null) {
7164
- if (current !== null) await ctx.fs.removeFile(K3S_CONFIG_DROPIN);
7165
- return;
7166
- }
7167
- if (current === rendered.content) return;
7168
- await ctx.fs.ensureDir(K3S_CONFIG_DROPIN.slice(0, K3S_CONFIG_DROPIN.lastIndexOf("/")));
7169
- await ctx.fs.writeFile(K3S_CONFIG_DROPIN, rendered.content, 420);
7170
- if (!mayDisrupt(ctx.runningConveyorPods)) {
7171
- result.warnings.push(
7172
- `Kubelet settings changed but ${busyReason(ctx.runningConveyorPods)} \u2014 the restart is deferred until the machine is idle.`
7173
- );
7174
- return;
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
- const restart = await ctx.exec("systemctl", ["restart", "k3s"], { timeoutMs: 12e4 });
7177
- if (restart.exitCode === 0) {
7178
- result.restartedK3s = true;
7179
- } else {
7180
- result.warnings.push(
7181
- `Could not restart k3s to apply new kubelet settings: ${restart.stderr.trim().slice(0, 200)}`
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 reconcilePriorityClass(ctx, desired, result) {
7186
- const name = assertK8sName(desired.priorityClass.name, "priorityClass.name");
7187
- const read = await ctx.exec("k3s", [
7188
- "kubectl",
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
- "priorityclass",
7191
- name,
7495
+ "deployment",
7496
+ PERSONAL_LOCAL_REGISTRY_NAME,
7497
+ "--ignore-not-found",
7192
7498
  "-o",
7193
- "jsonpath={.value}"
7499
+ "json"
7194
7500
  ]);
7195
- if (read.exitCode !== 0) return;
7196
- const existingValue = Number.parseInt(read.stdout.trim(), 10);
7197
- if (existingValue === desired.priorityClass.value) return;
7198
- if (!mayDisrupt(ctx.runningConveyorPods)) {
7199
- result.warnings.push(
7200
- `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)}.`
7201
- );
7202
- return;
7203
- }
7204
- const removed = await ctx.exec("k3s", ["kubectl", "delete", "priorityclass", name]);
7205
- if (removed.exitCode !== 0) {
7206
- result.warnings.push(
7207
- `Could not replace the \`${name}\` PriorityClass (value ${existingValue}, needs ${desired.priorityClass.value}): ${removed.stderr.trim().slice(0, 160)}`
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
- await kubectlApply(ctx.exec, desired.rbacManifest);
7213
- result.recreatedPriorityClass = true;
7518
+ process.kill(pid, 0);
7519
+ return true;
7214
7520
  } catch (error) {
7215
- result.warnings.push(
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 renderLimitRange(namespace, limits) {
7221
- const ns = assertK8sName(namespace, "namespace");
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
- async function reconcileNamespaces(ctx, desired, result) {
7247
- for (const namespace of desired.namespaces) {
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
- await kubectlApply(ctx.exec, renderLimitRange(namespace.name, namespace.limitRange));
7250
- } catch (error) {
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 apply resource defaults to ${namespace.name} (${namespace.projectName}): ${error instanceof Error ? error.message : String(error)}`
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
- async function step(result, label, run) {
7258
- try {
7259
- await run();
7260
- } catch (error) {
7261
- result.warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
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 reconcile(ctx, desired) {
7265
- const result = {
7266
- appliedVersion: desired.version,
7267
- warnings: [],
7268
- restartedK3s: false,
7269
- skippedKubeletFlags: [],
7270
- effectiveImageGcHighThreshold: null,
7271
- recreatedPriorityClass: false
7272
- };
7273
- const disallowedKinds = findDisallowedKinds(desired.rbacManifest);
7274
- if (disallowedKinds.length > 0) {
7275
- result.warnings.push(
7276
- `Conveyor sent a cluster manifest containing disallowed kind(s) ${disallowedKinds.join(", ")} \u2014 refused. RBAC and PriorityClass steps skipped; this should never happen, report it.`
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
- } else {
7279
- await step(result, "Could not apply RBAC", () => kubectlApply(ctx.exec, desired.rbacManifest));
7280
- await step(
7281
- result,
7282
- "Could not reconcile the PriorityClass",
7283
- () => reconcilePriorityClass(ctx, desired, result)
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
- await step(
7287
- result,
7288
- "Could not apply namespace defaults",
7289
- () => reconcileNamespaces(ctx, desired, result)
7290
- );
7291
- await step(
7292
- result,
7293
- "Could not apply kubelet settings",
7294
- () => reconcileKubelet(ctx, desired, result)
7295
- );
7296
- return result;
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 readDisk() {
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: 0
8218
+ k3sStoreBytes
7376
8219
  };
7377
8220
  } catch {
7378
- return { totalBytes: 0, freeBytes: 0, k3sStoreBytes: 0 };
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 collectHeartbeat(ports, state, inventory) {
7402
- const registries = parseRegistries(await ports.readFile(REGISTRIES_FILE));
7403
- const disk = await readDisk();
7404
- const gcWarning = imageGcPressureWarning(disk, state.imageGcHighThreshold);
7405
- const runnerUsers = await detectRunnerUsers(ports.exec);
7406
- const ungranted = ungrantedRunnerUsers(runnerUsers, await ports.readFile(RUNNERS_SUDOERS_FILE));
7407
- return {
7408
- publicWebProxy: (await ports.readFile("/etc/conveyor-vm/public-web"))?.trim() === "enabled",
7409
- cliVersion: state.cliVersion,
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
- async function readPodIdentities(exec) {
7432
- const result = await exec(
7433
- "k3s",
7434
- [
7435
- "kubectl",
7436
- "get",
7437
- "pods",
7438
- "-A",
7439
- "-l",
7440
- CONVEYOR_POD_SELECTOR,
7441
- "--request-timeout=5s",
7442
- "--no-headers",
7443
- "-o",
7444
- "custom-columns=NS:.metadata.namespace,NAME:.metadata.name,UID:.metadata.uid"
7445
- ],
7446
- { timeoutMs: 7e3, maxOutputBytes: 65536 }
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 (result.exitCode !== 0) return [];
7449
- const rows = result.stdout.trim().split("\n").filter(Boolean);
7450
- if (rows.length > 100) return [];
7451
- return rows.flatMap((row) => {
7452
- const [namespace, name, uid, extra] = row.trim().split(/\s+/);
7453
- return !extra && [namespace, name, uid].every((s) => s && /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}$/.test(s)) ? [{ namespace, name, uid }] : [];
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 HOUR_MS = 60 * 6e4;
7459
- var DAY_MS = 24 * HOUR_MS;
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
- }, HOUR_MS),
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
- { exec: this.ports.exec, readFile: (path) => this.ports.fs.readFile(path) },
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: () => Promise.resolve()
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 state. Your
7927
- k3s config, registries.yaml, and workloads are left
7928
- alone.
7929
- --purge-k3s --yes also remove k3s itself
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 ports.fs.writeFile(
7956
- REGISTRIES_FILE,
7957
- serializeRegistries(upsertRegistryAuth(parsed, host, auth)),
7958
- 384
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("rm", ["-rf", STATE_DIR]);
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 state.");
8026
- ports.log("Your k3s config, registries.yaml, and workloads were not touched.");
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 lines = unit.split("\n");
8040
- const start = lines.findIndex((line) => line.trimStart().startsWith("ExecStart="));
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 < lines.length; index += 1) {
8044
- const line = lines[index] ?? "";
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 answers = await lookup(name);
8282
- if (!Array.isArray(answers) || answers.length === 0) {
8283
- throw new Error(`No IPv4 address resolved for ${name}`);
8284
- }
8285
- const uniqueAnswers = [...new Set(answers)];
8286
- const unsafe = uniqueAnswers.find(
8287
- (address) => !isGloballyRoutableIPv4(address) || localAddresses.has(address)
8288
- );
8289
- if (unsafe) {
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
- return { hosts, srv: cloudflaredSrvRecords(hosts) };
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) => /^(?:us-)?region[12]\.v2\.argotunnel\.com$/.test(host.name)).map((host) => [host.name, 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
- var BOOT_DISK_BPS = "104857600";
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
- return {
8695
- kind: "boot",
8696
- value: `${value},bps=${BOOT_DISK_BPS},iops=${BOOT_DISK_IOPS}`
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", "lima-conveyor"),
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", context = {}) {
10229
+ function rewriteQemuArgs(args, network, ncPath = "/usr/bin/nc", hostContext = {}) {
8878
10230
  if (isProbe(args)) return [...args];
8879
- assertPort(network.sshPort, "SSH port");
10231
+ assertPort2(network.sshPort, "SSH port");
8880
10232
  if (!TRUSTED_NC_PATH.test(ncPath)) throw new Error("Invalid netcat path");
8881
- const forwards = network.hosts.flatMap(
8882
- (host) => host.ports.map((port) => ({ guestIp: host.guestIp, address: host.address, port }))
8883
- );
8884
- if (forwards.length > MAX_NETWORK_FORWARDS) throw new Error("Too many QEMU forwarding rules");
8885
- validateForwards(forwards);
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
- function maximumGuestMemoryGiB(hostBytes) {
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
- const budget = hostBytes / 1024 ** 3 * 0.75;
8928
- return Math.max(0, Math.floor(Math.min(budget - 1, budget / 1.25)));
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,