@rallycry/conveyor-k3 0.1.3

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.
@@ -0,0 +1,777 @@
1
+ import { PersonalClusterEnrollmentBundle, PersonalClusterEnrollmentCompletion, PersonalClusterDesiredState, PersonalClusterHeartbeat, PersonalClusterKubeletState } from '@project/shared';
2
+
3
+ interface ClusterConnectionConfig {
4
+ apiUrl: string;
5
+ clusterToken: string;
6
+ /** Test-only override; production always reads the root-owned guest key. */
7
+ deviceKeyPath?: string;
8
+ }
9
+ declare class ClusterConnection {
10
+ private socket;
11
+ private readonly config;
12
+ /** Called when Conveyor says the desired state changed. */
13
+ onSyncRequested: (() => void) | null;
14
+ /** Called when Conveyor asks the machine to remove itself. */
15
+ onUninstall: (() => Promise<void>) | null;
16
+ constructor(config: ClusterConnectionConfig);
17
+ connect(): Promise<void>;
18
+ private require;
19
+ enroll(): Promise<PersonalClusterEnrollmentBundle>;
20
+ completeEnrollment(payload: PersonalClusterEnrollmentCompletion): Promise<void>;
21
+ requestDesiredState(): Promise<PersonalClusterDesiredState>;
22
+ /** Fire-and-forget: a lost heartbeat is covered by the next one. */
23
+ sendHeartbeat(payload: PersonalClusterHeartbeat): void;
24
+ /**
25
+ * Tell Conveyor this machine is stopping, so placement skips it at once
26
+ * instead of waiting out the stale window.
27
+ */
28
+ sayGoodbye(timeoutMs?: number): Promise<void>;
29
+ disconnect(): void;
30
+ }
31
+
32
+ interface RunResult {
33
+ stdout: string;
34
+ stderr: string;
35
+ exitCode: number;
36
+ }
37
+ interface RunOptions {
38
+ /** Hard cap on runtime. The process is SIGTERMed on timeout. */
39
+ timeoutMs?: number;
40
+ /** Bound output from untrusted guests before it can exhaust host memory. */
41
+ maxOutputBytes?: number;
42
+ /** Isolate and terminate the whole command tree for guest SSH commands. */
43
+ killProcessGroup?: boolean;
44
+ /** Piped to the child's stdin, for `--password-stdin` style input. */
45
+ stdin?: string;
46
+ env?: NodeJS.ProcessEnv;
47
+ }
48
+ /**
49
+ * Runs a command and returns its result — including a NON-ZERO exit.
50
+ *
51
+ * Deliberately not throwing. Almost every caller here has a meaningful
52
+ * response to a failure (defer a restart, record a warning, try the next
53
+ * image) and exceptions would turn each of those into a try/catch. The two
54
+ * places that genuinely require success use {@link runOrThrow}.
55
+ */
56
+ type Executor = (bin: string, args: readonly string[], options?: RunOptions) => Promise<RunResult>;
57
+ declare const spawnExecutor: Executor;
58
+ declare class CommandError extends Error {
59
+ readonly exitCode: number;
60
+ readonly stdout: string;
61
+ readonly stderr: string;
62
+ constructor(bin: string, result: RunResult);
63
+ }
64
+ /** Run a command and throw {@link CommandError} unless it exits 0. */
65
+ declare function runOrThrow(exec: Executor, bin: string, args: readonly string[], options?: RunOptions): Promise<RunResult>;
66
+
67
+ interface FileSystemPort$1 {
68
+ readFile(path: string): Promise<string | null>;
69
+ writeFile(path: string, content: string, mode?: number): Promise<void>;
70
+ removeFile(path: string): Promise<void>;
71
+ ensureDir(path: string): Promise<void>;
72
+ }
73
+ declare const nodeFileSystem: FileSystemPort$1;
74
+ /** In-memory filesystem for tests. Exported so suites share one shape. */
75
+ declare function createMemoryFileSystem(seed?: Record<string, string>): FileSystemPort$1 & {
76
+ files: Map<string, string>;
77
+ modes: Map<string, number>;
78
+ removed: string[];
79
+ };
80
+
81
+ interface DaemonPorts {
82
+ exec: Executor;
83
+ fs: FileSystemPort$1;
84
+ connection: ClusterConnection;
85
+ cliVersion: string;
86
+ log: (line: string) => void;
87
+ }
88
+ declare class ClusterDaemon {
89
+ private readonly ports;
90
+ private desired;
91
+ private state;
92
+ private warnings;
93
+ /**
94
+ * The image-GC high threshold the kubelet is really using — ours, or the
95
+ * owner's when their own config.yaml set it and the drop-in stood down.
96
+ * Null until the first reconcile has read both.
97
+ */
98
+ private effectiveImageGcHigh;
99
+ /**
100
+ * Warnings the last warm produced, as opposed to the last reconcile.
101
+ *
102
+ * Kept separate because the two clocks are separate: a warm runs hourly and
103
+ * a reconcile daily, and folding warm's findings into `warnings` would have
104
+ * the next reconcile silently clear them.
105
+ */
106
+ private warmWarnings;
107
+ private timers;
108
+ /** Serializes reconcile against itself — a sync nudge during a reconcile
109
+ * must queue, not interleave two kubectl applies of the same manifests. */
110
+ private reconciling;
111
+ constructor(ports: DaemonPorts);
112
+ start(): Promise<void>;
113
+ stop(): Promise<void>;
114
+ /** Fetch the desired state and converge to it. Queued, never concurrent. */
115
+ syncAndReconcile(): Promise<void>;
116
+ private runReconcile;
117
+ /**
118
+ * The hourly clock: warm, re-pin, and reclaim if the pile justifies it.
119
+ *
120
+ * The prune check rides the hourly warm rather than the daily reconcile
121
+ * because that is the cadence a bake-heavy day actually needs. With the
122
+ * kubelet back on its own thresholds, nothing else clears stranded
123
+ * generations in between.
124
+ */
125
+ private warmAndPruneIfDue;
126
+ /** Pre-pull the wanted refs. Silent on success; that is the normal case. */
127
+ warm(): Promise<void>;
128
+ /**
129
+ * Take the wanted refs out of the kubelet's reach, and release the ones that
130
+ * have left the wanted set.
131
+ *
132
+ * Runs on every warm, not once: importing an image under the same ref
133
+ * replaces the containerd record and drops the label with it, so a bake
134
+ * always leaves its `:current` unpinned until the next pass. It pins from
135
+ * the INVENTORY rather than from what warm pulled, because `warmImages`
136
+ * skips `localhost/` refs — which are the ones a local bake produces, and
137
+ * the ones that were actually being deleted.
138
+ */
139
+ private pinWanted;
140
+ /** Prune daily, or sooner when the disk is tight. */
141
+ private pruneIfDue;
142
+ /**
143
+ * Whether stranded generations alone justify a prune now.
144
+ *
145
+ * A bake-heavy day strands several gigabytes per bake, and with the kubelet
146
+ * back on its own thresholds nothing else clears them before the daily run.
147
+ * An unreadable inventory answers false: the prune itself would refuse the
148
+ * cycle anyway, and guessing "yes" would just run it to find that out.
149
+ */
150
+ private strandedBytesExceedLimit;
151
+ prune(): Promise<void>;
152
+ private buildHeartbeat;
153
+ beat(): Promise<void>;
154
+ }
155
+
156
+ /**
157
+ * Converge the machine onto Conveyor's desired state.
158
+ *
159
+ * This is what "manages itself" means in code. Conveyor publishes what the
160
+ * cluster SHOULD look like; this function makes it so, and it runs on connect,
161
+ * on request, and daily. Nothing here is a command from the server — a machine
162
+ * that was powered off for a week catches up on its next run, and a practice
163
+ * Conveyor changes reaches every enrolled box without anyone touching one.
164
+ *
165
+ * Three properties every step keeps:
166
+ *
167
+ * 1. **Idempotent.** Running twice changes nothing the first run already did.
168
+ * 2. **Independently fallible.** A step that fails records a warning and the
169
+ * rest still run. A cluster with one bad namespace is not a reason to skip
170
+ * image hygiene.
171
+ * 3. **Additive.** Nothing here deletes a namespace, a workload, or an image
172
+ * that is in use. The machine belongs to its owner.
173
+ * 4. **Bounded.** The server does not get to say what runs here. The one field
174
+ * it sends as raw YAML is checked against a local kind allowlist before it
175
+ * reaches `kubectl` — see `../lib/manifest-guard.js`.
176
+ */
177
+
178
+ /** The filesystem operations reconcile needs, injectable for tests. */
179
+ interface FileSystemPort {
180
+ readFile(path: string): Promise<string | null>;
181
+ writeFile(path: string, content: string, mode?: number): Promise<void>;
182
+ removeFile(path: string): Promise<void>;
183
+ ensureDir(path: string): Promise<void>;
184
+ }
185
+ interface ReconcileContext {
186
+ exec: Executor;
187
+ fs: FileSystemPort;
188
+ /**
189
+ * Conveyor pods currently running here, or null when that could not be
190
+ * determined. A disruptive step is unsafe above zero AND unsafe on null —
191
+ * "I could not ask" is not "nothing is running".
192
+ */
193
+ runningConveyorPods: number | null;
194
+ }
195
+ interface ReconcileResult {
196
+ appliedVersion: number;
197
+ warnings: string[];
198
+ /** True when the kubelet drop-in changed and k3s was restarted. */
199
+ restartedK3s: boolean;
200
+ /** Kubelet flags left to the owner because their own config sets them. */
201
+ skippedKubeletFlags: string[];
202
+ /**
203
+ * The image-GC high threshold the kubelet is actually enforcing — ours, or
204
+ * the owner's when their own config.yaml set it and the drop-in stood down.
205
+ * Null when neither side named one. Reported so the heartbeat can warn about
206
+ * GC pressure against the number in force rather than the one we sent.
207
+ */
208
+ effectiveImageGcHighThreshold: number | null;
209
+ /** True when a wrong-valued PriorityClass was deleted AND put back. */
210
+ recreatedPriorityClass: boolean;
211
+ }
212
+ declare function reconcile(ctx: ReconcileContext, desired: PersonalClusterDesiredState): Promise<ReconcileResult>;
213
+
214
+ /** One entry of `crictl images -o json`. */
215
+ interface CrictlImage {
216
+ id: string;
217
+ repoTags?: string[];
218
+ repoDigests?: string[];
219
+ size?: string;
220
+ /** CRI reports this for a pinned image; absent on a runtime too old to say. */
221
+ pinned?: boolean;
222
+ }
223
+ interface ImageInventory {
224
+ images: CrictlImage[];
225
+ /** Image ids referenced by a container, running or not. */
226
+ referencedIds: Set<string>;
227
+ }
228
+ interface PruneCandidate {
229
+ id: string;
230
+ sizeBytes: number;
231
+ }
232
+ /**
233
+ * Which images may be removed.
234
+ *
235
+ * Pure, so the selection table is testable against a real `crictl images`
236
+ * fixture rather than inferred from what the daemon happened to delete.
237
+ */
238
+ declare function selectStrandedImages(inventory: ImageInventory, wantedImages: string[]): PruneCandidate[];
239
+ /** Read the machine's images and which of them containers are holding. */
240
+ declare function readImageInventory(exec: Executor): Promise<ImageInventory>;
241
+ interface PruneResult {
242
+ removed: number;
243
+ bytesReclaimed: number;
244
+ failures: string[];
245
+ }
246
+ /** Remove the stranded images. Individual failures are reported, not thrown. */
247
+ declare function pruneStrandedImages(exec: Executor, inventory: ImageInventory, wantedImages: string[]): Promise<PruneResult>;
248
+ interface WarmResult {
249
+ pulled: string[];
250
+ skipped: string[];
251
+ failures: string[];
252
+ /** How many refs were skipped for being on Artifact Registry. */
253
+ skippedArtifactRegistry: number;
254
+ }
255
+ /**
256
+ * Pre-pull the refs a build will ask for.
257
+ *
258
+ * A no-op download when the ref has not moved, and the difference between a
259
+ * card starting immediately and a card waiting on a multi-gigabyte cold pull
260
+ * right after a bake.
261
+ *
262
+ * Three kinds of ref are skipped rather than attempted:
263
+ * - `localhost/...`, which a local bake already imported into containerd and
264
+ * which no registry can serve.
265
+ * - anything on a host we know has no credential, because that pull would
266
+ * fail slowly and noisily every hour.
267
+ * - Artifact Registry, whatever the credential. A personal cluster sits
268
+ * outside GCP, so that pull crosses the internet and is billed as AR egress
269
+ * — and such a ref is never placed here, so it buys nothing. The server
270
+ * already excludes these from `warmImages`, but a daemon can be running a
271
+ * desired state older than that field, so the refusal lives here too.
272
+ */
273
+ declare function warmImages(exec: Executor, wantedImages: string[], unauthenticatedHosts: string[]): Promise<WarmResult>;
274
+
275
+ interface TelemetryState {
276
+ cliVersion: string;
277
+ appliedDesiredStateVersion: number;
278
+ wantedImages: string[];
279
+ authRequiredHosts: string[];
280
+ lastPruneAt: string | null;
281
+ lastWarmAt: string | null;
282
+ lastUpdateAt: string | null;
283
+ localWarnings: string[];
284
+ /**
285
+ * The image-GC high threshold the kubelet is really enforcing, or null when
286
+ * no reconcile has read one yet. Drives the GC-pressure warning below.
287
+ */
288
+ imageGcHighThreshold?: number | null;
289
+ }
290
+ interface TelemetryPorts {
291
+ exec: Executor;
292
+ readFile(path: string): Promise<string | null>;
293
+ }
294
+ /**
295
+ * Conveyor pods running here, across every namespace it manages — or null when
296
+ * that cannot be determined.
297
+ *
298
+ * Three destructive operations gate on this number: the k3s restart after a
299
+ * kubelet change, the k3s channel upgrade, and the PriorityClass recreate. So
300
+ * "I could not ask" must NOT collapse to "nothing is running" — that reads as
301
+ * an idle machine and takes a card down mid-build. It returns null instead, and
302
+ * every guard treats null as busy.
303
+ *
304
+ * The selector comes from `@project/shared` because the API stamps the same
305
+ * label on the pods it creates. A hand-written selector here silently matched
306
+ * nothing and disabled all three guards at once.
307
+ */
308
+ declare function countRunningConveyorPods(exec: Executor): Promise<number | null>;
309
+ declare function collectHeartbeat(ports: TelemetryPorts, state: TelemetryState, inventory: ImageInventory | null): Promise<PersonalClusterHeartbeat>;
310
+
311
+ interface RegistryAuth {
312
+ username?: string;
313
+ password?: string;
314
+ auth?: string;
315
+ identitytoken?: string;
316
+ }
317
+ interface RegistryConfigEntry {
318
+ auth?: RegistryAuth;
319
+ tls?: Record<string, unknown>;
320
+ }
321
+ interface RegistriesFile {
322
+ mirrors?: Record<string, unknown>;
323
+ configs?: Record<string, RegistryConfigEntry>;
324
+ [key: string]: unknown;
325
+ }
326
+ /** Parse the file. An absent or empty file is an empty config, not an error. */
327
+ declare function parseRegistries(raw: string | null): RegistriesFile;
328
+ /** Hosts that already carry a credential. */
329
+ declare function configuredAuthHosts(file: RegistriesFile): string[];
330
+ /**
331
+ * Required hosts with no credential configured.
332
+ *
333
+ * This is what the daemon reports in its heartbeat and what the profile turns
334
+ * into a `conveyor-k3 registry add <host>` line. Without it, the first
335
+ * acceptance run stalls on an unauthenticated multi-gigabyte pull with nothing
336
+ * saying why.
337
+ */
338
+ declare function missingAuthHosts(file: RegistriesFile, requiredHosts: string[]): string[];
339
+ /**
340
+ * Insert or replace ONE host's auth entry, preserving everything else.
341
+ *
342
+ * Everything outside `configs.<host>.auth` is carried through untouched:
343
+ * other hosts, the `mirrors` block, and the target host's own `tls` settings.
344
+ */
345
+ declare function upsertRegistryAuth(file: RegistriesFile, host: string, auth: RegistryAuth): RegistriesFile;
346
+ /** Serialize back to YAML. */
347
+ declare function serializeRegistries(file: RegistriesFile): string;
348
+
349
+ /**
350
+ * The k3s configuration Conveyor sets, as a drop-in.
351
+ *
352
+ * k3s reads `/etc/rancher/k3s/config.yaml` and then every `*.yaml` under
353
+ * `config.yaml.d/`, merging them. Our settings go in the drop-in so the
354
+ * owner's `config.yaml` is never rewritten — this machine is theirs, and a
355
+ * managed cluster that silently replaced their config would be worse than no
356
+ * managed mode at all.
357
+ *
358
+ * Two details make the merge safe:
359
+ *
360
+ * - `kubelet-arg+:` APPENDS to any `kubelet-arg` list the owner already has,
361
+ * where a bare `kubelet-arg:` would replace it.
362
+ * - A GC threshold the owner already sets in their own `config.yaml` is
363
+ * SKIPPED, not overridden. They chose a number for a reason, and duplicate
364
+ * kubelet flags are a startup failure, not a last-wins merge.
365
+ */
366
+
367
+ /**
368
+ * Bundled components Conveyor turns off, and the kubeconfig mode it sets.
369
+ *
370
+ * These were passed to the k3s INSTALLER at setup, which is where they stopped
371
+ * surviving: the installer rebuilds the unit's `ExecStart` from its own
372
+ * arguments on every run, so an upgrade that does not repeat them rewrites the
373
+ * unit to a bare `k3s server` and traefik and servicelb come back — claiming
374
+ * host ports 80 and 443 on a machine its owner uses for other things.
375
+ *
376
+ * k3s merges the drop-in on every start regardless of how the unit was
377
+ * written, so carrying them here makes them survive an upgrade by construction
378
+ * rather than by remembering to pass them again. `update.ts` passes the same
379
+ * exec string as well, so the unit stays identical too — belt and braces.
380
+ */
381
+ declare const K3S_DISABLED_COMPONENTS: readonly ["traefik", "servicelb"];
382
+ /**
383
+ * What `setup` and `update` both pass as `INSTALL_K3S_EXEC`.
384
+ *
385
+ * One constant because the installer has no "keep what is there" mode: an
386
+ * unset value means a bare `k3s server`, so the two call sites drifting apart
387
+ * is exactly the bug this fixes.
388
+ */
389
+ declare const K3S_INSTALL_EXEC: string;
390
+ interface DropInRenderResult {
391
+ /** The file to write, or null when the owner already set everything. */
392
+ content: string | null;
393
+ /** Flags skipped because the owner's own config already sets them. */
394
+ skipped: string[];
395
+ }
396
+ /**
397
+ * Which kubelet flags the owner's `config.yaml` already sets.
398
+ *
399
+ * A deliberately textual scan rather than a YAML parse: we only need to know
400
+ * whether a flag NAME appears under a `kubelet-arg` key, and a config that
401
+ * fails to parse must not stop us reporting what we can see. Over-detecting
402
+ * costs a skipped flag and a note; under-detecting costs a kubelet that will
403
+ * not start.
404
+ */
405
+ declare function ownerConfiguredKubeletFlags(ownerConfig: string | null): Set<string>;
406
+ /**
407
+ * Whether the owner's `config.yaml` sets a top-level key of its own.
408
+ *
409
+ * Same textual-scan reasoning as the kubelet flags above: a top-level key is
410
+ * `key:` at column zero, and a config that fails to parse must not stop us
411
+ * reporting what we can see.
412
+ */
413
+ declare function ownerConfiguredKeys(ownerConfig: string | null): Set<string>;
414
+ /**
415
+ * Render the drop-in: the kubelet settings, plus the components Conveyor turns
416
+ * off and the kubeconfig mode it sets.
417
+ *
418
+ * Returns `content: null` when there is nothing left for us to set, so the
419
+ * caller can remove a stale drop-in rather than leaving an empty one behind.
420
+ *
421
+ * `disable+:` APPENDS, so an owner who disables something of their own keeps
422
+ * it and gains ours. That is why the disables are not skipped the way a
423
+ * kubelet flag is: duplicate kubelet flags are a startup failure, whereas
424
+ * `disable` is a set of component names where a repeat is harmless — and
425
+ * standing down would quietly re-enable servicelb for an owner who had only
426
+ * meant to disable traefik.
427
+ *
428
+ * `write-kubeconfig-mode` IS skipped when the owner sets it: it is a scalar
429
+ * they chose, and last-writer-wins on their own file is exactly what this
430
+ * module exists not to do.
431
+ */
432
+ declare function renderK3sDropIn(kubelet: PersonalClusterKubeletState, ownerConfig: string | null): DropInRenderResult;
433
+ /**
434
+ * @deprecated Use {@link renderK3sDropIn}. Kept as a named alias because the
435
+ * package exports it and a published CLI may still import it by this name.
436
+ */
437
+ declare const renderKubeletDropIn: typeof renderK3sDropIn;
438
+
439
+ interface UnitRenderOptions {
440
+ /** Absolute path to the installed `conveyor-k3` binary. */
441
+ cliBin: string;
442
+ /** Absolute path to `cloudflared`. */
443
+ cloudflaredBin: string;
444
+ /** Local hour (0-23) the update timer fires in. */
445
+ maintenanceHour: number;
446
+ }
447
+ /** Every unit file this CLI writes, keyed by unit name. */
448
+ declare function renderAllUnits(options: UnitRenderOptions): Record<string, string>;
449
+
450
+ /**
451
+ * The machine's own record of what it has done.
452
+ *
453
+ * Small on purpose: everything authoritative lives on Conveyor's side, and
454
+ * this file exists so a daemon restart does not re-warm every image or forget
455
+ * which settings version it applied. Losing it costs one extra reconcile.
456
+ */
457
+
458
+ interface MachineState {
459
+ /** The desired-state version last successfully applied. */
460
+ appliedDesiredStateVersion: number;
461
+ /** Tunnel hostname Conveyor routed here, for `status` to print. */
462
+ hostname: string | null;
463
+ /** True once the ServiceAccount token has been handed to Conveyor. */
464
+ enrolled: boolean;
465
+ lastPruneAt: string | null;
466
+ lastWarmAt: string | null;
467
+ lastUpdateAt: string | null;
468
+ /**
469
+ * Absolute paths setup resolved for the binaries the updater invokes.
470
+ *
471
+ * Recorded because systemd's default unit PATH does not include
472
+ * `/root/.bun/bin`, so a bare name is ENOENT from the update timer. Optional:
473
+ * a state file written before this field existed falls back to `which`.
474
+ */
475
+ bins?: {
476
+ bun: string;
477
+ cli: string;
478
+ cloudflared: string;
479
+ };
480
+ /**
481
+ * Refs this daemon has labelled `io.cri-containerd.pinned`.
482
+ *
483
+ * Recorded so a ref that LEAVES the wanted set can be released again. The
484
+ * node's own pinned images — the pause sandbox, a co-tenant's — look
485
+ * identical to a demoted one from the inventory alone, so "pinned but not
486
+ * wanted" is not a safe rule to unpin on. Only what we pinned, we unpin.
487
+ */
488
+ pinnedImages?: string[];
489
+ }
490
+ declare function readState(fs: FileSystemPort$1): Promise<MachineState>;
491
+ declare function writeState(fs: FileSystemPort$1, state: MachineState): Promise<void>;
492
+
493
+ /**
494
+ * The verb table.
495
+ *
496
+ * One binary and one table, rather than the deployment runner's no-argv-at-all
497
+ * or conveyor-mcp's one-bin-per-verb. A machine's owner types these, so the
498
+ * help output below is part of the product: every verb states what it does to
499
+ * their machine, and `uninstall` states what it does not.
500
+ */
501
+ declare const VERBS: readonly ["vm", "setup", "start", "stop", "status", "sync", "update", "registry", "runners", "uninstall", "daemon"];
502
+ type Verb = (typeof VERBS)[number];
503
+ declare function isVerb(value: string): value is Verb;
504
+ interface ParsedArgs {
505
+ verb: Verb | null;
506
+ /** Positional arguments after the verb (e.g. the registry host). */
507
+ positional: string[];
508
+ flags: Record<string, string | true>;
509
+ }
510
+ /**
511
+ * Parse `conveyor-k3 <verb> [positional…] [--flag[=value]]`.
512
+ *
513
+ * Deliberately small. A dependency-free parser is auditable at a glance, and
514
+ * this surface is nine verbs and six flags, not a general CLI framework.
515
+ */
516
+ declare function parseArgs(argv: readonly string[]): ParsedArgs;
517
+ declare const HELP = "conveyor-k3 \u2014 managed personal compute for Conveyor\n\nUsage: conveyor-k3 <command> [options]\n\nCommands:\n vm Manage isolated compute on macOS or Linux.\n Run conveyor-k3 vm for host lifecycle commands.\n setup Install and enrol this machine. Safe to re-run.\n start Start the tunnel and the daemon.\n stop Stop both, and tell Conveyor at once so your next\n build goes straight to project compute.\n status [--json] What is running, what Conveyor knows, and what is\n missing.\n sync Re-read your settings from Conveyor and apply them.\n update Update conveyor-k3, cloudflared, and (when you have\n allowed it and nothing is running) k3s.\n registry list Which registry credentials are configured, and which\n ones your projects still need.\n registry add <host> Add one registry credential.\n --username <user>\n --password-stdin read the password from stdin\n --no-restart skip the required k3s restart\n runners list Which GitHub Actions runners this machine hosts, and\n whether each may import a locally baked image.\n runners grant <user>\u2026 Let those runner users import a locally baked image\n into this node and pin it. Writes one file under\n /etc/sudoers.d, checked with visudo first. Needed only\n when a project has Bake Locally turned on.\n uninstall Remove Conveyor's units, environment, and state. Your\n k3s config, registries.yaml, and workloads are left\n alone.\n --purge-k3s --yes also remove k3s itself\n daemon Run in the foreground (what the systemd unit calls).\n\nOptions:\n --version Print the version.\n --help Print this message.\n\nDocs: https://conveyor.rallycryapp.com/docs/guides/managed-personal-compute\n";
518
+
519
+ /**
520
+ * `conveyor-k3 registry add|list` — the one place this CLI writes
521
+ * `registries.yaml`.
522
+ *
523
+ * The reconcile loop never touches that file. Only an explicit command does,
524
+ * it takes a timestamped backup first, and it merges rather than templates —
525
+ * so a machine pulling through its own LAN mirror keeps that mirror, its TLS
526
+ * settings, and every other host it had configured.
527
+ *
528
+ * A restart is required and is not optional: k3s reads `registries.yaml` at
529
+ * startup only, so a credential added without one silently does nothing.
530
+ */
531
+
532
+ interface RegistryPorts {
533
+ exec: Executor;
534
+ fs: FileSystemPort$1;
535
+ log: (line: string) => void;
536
+ /** Injected so the backup filename is deterministic in tests. */
537
+ now: () => Date;
538
+ }
539
+ interface RegistryAddOptions {
540
+ host: string;
541
+ username?: string;
542
+ password?: string;
543
+ /** Skip the k3s restart. The caller must then restart it themselves. */
544
+ noRestart?: boolean;
545
+ }
546
+ interface RegistryAddResult {
547
+ backupPath: string | null;
548
+ restarted: boolean;
549
+ }
550
+ declare function registryAdd(ports: RegistryPorts, options: RegistryAddOptions): Promise<RegistryAddResult>;
551
+ interface RegistryCoverage {
552
+ configured: string[];
553
+ missing: string[];
554
+ }
555
+ /** What is configured against what Conveyor says this machine needs. */
556
+ declare function registryList(ports: RegistryPorts, requiredHosts: string[]): Promise<RegistryCoverage>;
557
+ /**
558
+ * The lines `setup` prints at the very end.
559
+ *
560
+ * Without this the acceptance flow stalls on an unauthenticated multi-gigabyte
561
+ * pull with nothing saying why, so it is part of setup's contract rather than
562
+ * a nicety.
563
+ */
564
+ declare function renderRegistryAuthSummary(missing: string[]): string[];
565
+
566
+ /**
567
+ * `conveyor-k3 uninstall` — remove exactly what Conveyor put on this machine.
568
+ *
569
+ * Everything this CLI created carries a `conveyor-k3` name and lives under
570
+ * `/etc/conveyor` or `/var/lib/conveyor-k3`, which is what makes a precise
571
+ * removal possible. Nothing else is touched: not the owner's `config.yaml`,
572
+ * not `registries.yaml`, not their workloads.
573
+ *
574
+ * k3s itself is NOT removed by default. On most machines it predates us, or
575
+ * the owner wants to keep it — so removing it is `--purge-k3s`, and even then
576
+ * only after an explicit confirmation.
577
+ */
578
+
579
+ interface UninstallPorts {
580
+ exec: Executor;
581
+ fs: FileSystemPort$1;
582
+ log: (line: string) => void;
583
+ }
584
+ interface UninstallOptions {
585
+ /** Also run k3s-uninstall.sh. Requires `confirmed`. */
586
+ purgeK3s?: boolean;
587
+ /** The caller has an explicit yes from the person running this. */
588
+ confirmed?: boolean;
589
+ /** Conveyor asked for this, so the "click Disconnect" reminder is moot. */
590
+ serverInitiated?: boolean;
591
+ }
592
+ interface UninstallResult {
593
+ stoppedUnits: string[];
594
+ removedFiles: string[];
595
+ purgedK3s: boolean;
596
+ }
597
+ declare function uninstall(ports: UninstallPorts, options?: UninstallOptions): Promise<UninstallResult>;
598
+
599
+ /**
600
+ * `conveyor-k3 update` — the maintenance-window updater.
601
+ *
602
+ * Updates ride a systemd timer and NEVER runtime self-replacement. The agent
603
+ * removed runtime self-update after the 2026-07-19 eviction outage; a daemon
604
+ * that swaps its own binary mid-run has exactly the same failure shape. Here
605
+ * the package is replaced by a separate oneshot unit, and the daemon is
606
+ * restarted afterwards by systemd.
607
+ *
608
+ * Three independent pieces, each with its own decision:
609
+ *
610
+ * - **The package.** Installed at the EXACT version Conveyor's desired state
611
+ * names, never `@latest` — the npm dist-tag is a root-execution channel on
612
+ * every enrolled machine, so a compromised publish would reach them all
613
+ * within a day. The daemon restarts only if the version actually moved.
614
+ * - **cloudflared.** `cloudflared update` exits 11 to mean "updated"; that is
615
+ * the only case that needs a tunnel restart.
616
+ * - **k3s.** Only when the owner allowed it AND nothing of Conveyor's is
617
+ * running. A k3s upgrade at the wrong moment interrupts someone's build, and
618
+ * no upgrade is worth that.
619
+ */
620
+
621
+ interface UpdatePorts {
622
+ exec: Executor;
623
+ log: (line: string) => void;
624
+ /**
625
+ * Absolute paths to the binaries this updater invokes.
626
+ *
627
+ * Absolute, not bare names, because this runs from a systemd oneshot whose
628
+ * default PATH is `/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`
629
+ * — which does not include `/root/.bun/bin`, where the installer puts both
630
+ * Bun and this CLI. Bare names made every timed update exit 1 on ENOENT
631
+ * before it did anything, silently, forever.
632
+ */
633
+ bins: UpdateBinaries;
634
+ }
635
+ interface UpdateBinaries {
636
+ bun: string;
637
+ cli: string;
638
+ cloudflared: string;
639
+ }
640
+ interface UpdateDecisionInput {
641
+ autoUpgradeK3s: boolean;
642
+ /** Null when the count could not be read — treated exactly like "busy". */
643
+ runningConveyorPods: number | null;
644
+ installedK3s: [number, number] | null;
645
+ channelLatestK3s: [number, number] | null;
646
+ }
647
+ type K3sUpgradeDecision = {
648
+ upgrade: true;
649
+ } | {
650
+ upgrade: false;
651
+ reason: string;
652
+ };
653
+ /**
654
+ * Whether to take a k3s upgrade right now.
655
+ *
656
+ * Pure so the table is testable: the interesting cases are "owner said no",
657
+ * "machine is busy", and "already current", and each must produce a distinct
658
+ * reason the owner can read in their health panel.
659
+ */
660
+ declare function decideK3sUpgrade(input: UpdateDecisionInput): K3sUpgradeDecision;
661
+ interface UpdateResult {
662
+ packageUpdated: boolean;
663
+ cloudflaredUpdated: boolean;
664
+ k3sUpgraded: boolean;
665
+ notes: string[];
666
+ }
667
+ /**
668
+ * @param targetVersion The `cli.latestVersion` from desired state. Null or
669
+ * empty skips the package update instead of falling back to a dist-tag.
670
+ */
671
+ declare function runUpdate(ports: UpdatePorts, decision: K3sUpgradeDecision, channel: string, targetVersion: string | null): Promise<UpdateResult>;
672
+
673
+ /**
674
+ * `conveyor-k3 setup` — bring a machine from "nothing" to "running builds".
675
+ *
676
+ * Idempotent from end to end. Running it twice is the normal way to repair a
677
+ * half-finished install, so every step checks before it acts: k3s is installed
678
+ * only when absent, cloudflared only when absent, and the units are rewritten
679
+ * only when their content changed.
680
+ *
681
+ * The step order matters in one place. The ServiceAccount token is minted and
682
+ * handed to Conveyor LAST, because that write is what flips the cluster to
683
+ * enabled — so it must not happen until the tunnel, the RBAC, and the units
684
+ * are all actually in place. A cluster marked usable before it is usable sends
685
+ * the owner's next card to a machine that cannot run it.
686
+ */
687
+
688
+ /** Parse `k3s version v1.31.0+k3s1` into [major, minor]. */
689
+ declare function parseK3sVersion(output: string): [number, number] | null;
690
+ declare function meetsK3sMinimum(version: [number, number] | null): boolean;
691
+
692
+ interface NetworkEndpoint {
693
+ host: string;
694
+ ports: number[];
695
+ }
696
+ interface ResolvedNetworkHost {
697
+ /** Canonical hostname written into the guest's static DNS data. */
698
+ name: string;
699
+ /** An address on WORKER_SUBNET; it is never a real remote address. */
700
+ guestIp: string;
701
+ /** Host-resolved, pinned, globally-routable IPv4 address. */
702
+ address: string;
703
+ ports: number[];
704
+ }
705
+ interface GuestSrvRecord {
706
+ name: string;
707
+ target: string;
708
+ port: number;
709
+ priority: number;
710
+ weight: number;
711
+ }
712
+ interface ResolvedNetwork {
713
+ hosts: ResolvedNetworkHost[];
714
+ /** Static SRV answers needed by cloudflared's documented edge discovery. */
715
+ srv: GuestSrvRecord[];
716
+ }
717
+ /** Alias used by the VM host manager. */
718
+ type NetworkPolicy = ResolvedNetwork;
719
+ type IPv4Lookup = (host: string) => Promise<string[]>;
720
+ type SRVLookup = (name: string) => Promise<Array<{
721
+ name: string;
722
+ port: number;
723
+ priority: number;
724
+ weight: number;
725
+ }>>;
726
+ /** Reject addresses which cannot be a public Internet destination. */
727
+ declare function isGloballyRoutableIPv4(address: string): boolean;
728
+ /**
729
+ * Resolve approved egress destinations on the host before VM creation.
730
+ *
731
+ * The guest receives only synthetic addresses. Its root user may change its
732
+ * resolver or hosts file, but it cannot add a QEMU guestfwd rule, so this does
733
+ * not turn into arbitrary egress. Every answer is checked, rather than picking
734
+ * a public answer from a mixed public/private DNS response.
735
+ */
736
+ declare function resolveNetwork(endpoints: NetworkEndpoint[], lookup?: IPv4Lookup): Promise<ResolvedNetwork>;
737
+ /**
738
+ * Build static SRV results only when their region targets were explicitly
739
+ * approved. Callers opt into us-region targets by including them in endpoints.
740
+ */
741
+ declare function cloudflaredSrvRecords(hosts: ResolvedNetworkHost[]): GuestSrvRecord[];
742
+ /**
743
+ * Resolve Cloudflare's current SRV discovery records on the host and retain
744
+ * only targets explicitly present in the fixed endpoint allowlist. This is an
745
+ * opt-in refresh helper: callers must recreate the worker when an answer
746
+ * changes, and a guest never performs this lookup itself.
747
+ */
748
+ declare function resolveCloudflaredSrvRecords(hosts: ResolvedNetworkHost[], lookup?: SRVLookup): Promise<GuestSrvRecord[]>;
749
+
750
+ interface GuestDnsProvision {
751
+ /** Write verbatim to /etc/conveyor-vm/dns.json (mode 0600). */
752
+ data: string;
753
+ /** Write verbatim to /usr/local/lib/conveyor-vm-static-dns.py (mode 0755). */
754
+ daemon: string;
755
+ /** Write verbatim to /etc/systemd/system/conveyor-vm-static-dns.service. */
756
+ unit: string;
757
+ /** Write verbatim to /etc/resolv.conf after disabling any local stub resolver. */
758
+ resolvConf: string;
759
+ }
760
+ /**
761
+ * The daemon is deliberately a small authoritative server, not a forwarding
762
+ * resolver. It answers only the creation-time allowlist and returns NXDOMAIN
763
+ * for everything else. The network data is JSON, never interpolated as Python.
764
+ */
765
+ declare function renderGuestDnsProvision(network: ResolvedNetwork): GuestDnsProvision;
766
+
767
+ interface QemuNetworkPolicy extends ResolvedNetwork {
768
+ sshPort: number;
769
+ }
770
+ /** Host-only information used to tie QEMU file endpoints to one Lima instance. */
771
+ interface QemuPolicyContext {
772
+ instanceDir?: string;
773
+ }
774
+ /** Rewrites only a complete, known Lima QEMU profile; every unknown input fails closed. */
775
+ declare function rewriteQemuArgs(args: string[], network: QemuNetworkPolicy, ncPath?: string, context?: QemuPolicyContext): string[];
776
+
777
+ export { ClusterConnection, type ClusterConnectionConfig, ClusterDaemon, CommandError, type CrictlImage, type DaemonPorts, type Executor, type FileSystemPort$1 as FileSystemPort, type GuestDnsProvision, type GuestSrvRecord, HELP, type ImageInventory, K3S_DISABLED_COMPONENTS, K3S_INSTALL_EXEC, type K3sUpgradeDecision, type MachineState, type NetworkEndpoint, type NetworkPolicy, type QemuNetworkPolicy, type ReconcileContext, type ReconcileResult, type RegistriesFile, type ResolvedNetwork, type ResolvedNetworkHost, type SRVLookup, type UninstallResult, type UnitRenderOptions, VERBS, type Verb, cloudflaredSrvRecords, collectHeartbeat, configuredAuthHosts, countRunningConveyorPods, createMemoryFileSystem, decideK3sUpgrade, isGloballyRoutableIPv4, isVerb, meetsK3sMinimum, missingAuthHosts, nodeFileSystem, ownerConfiguredKeys, ownerConfiguredKubeletFlags, parseArgs, parseK3sVersion, parseRegistries, pruneStrandedImages, readImageInventory, readState, reconcile, registryAdd, registryList, renderAllUnits, renderGuestDnsProvision, renderK3sDropIn, renderKubeletDropIn, renderRegistryAuthSummary, resolveCloudflaredSrvRecords, resolveNetwork, rewriteQemuArgs, runOrThrow, runUpdate, selectStrandedImages, serializeRegistries, spawnExecutor, uninstall, upsertRegistryAuth, warmImages, writeState };