@jr2/cli 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/manifests/operator.yaml +1 -1
- package/package.json +6 -4
- package/src/build.ts +22 -23
- package/src/commands/kit.ts +24 -12
- package/src/commands/up.ts +167 -26
- package/src/commands/version.ts +1 -1
- package/src/deploy.ts +217 -80
- package/src/held-pki.ts +306 -0
- package/src/index.ts +4 -0
- package/src/kube.ts +8 -4
- package/src/sweep.ts +5 -5
package/manifests/operator.yaml
CHANGED
|
@@ -6181,7 +6181,7 @@ spec:
|
|
|
6181
6181
|
Repos is the standing report on the caches this Sandbox mounts
|
|
6182
6182
|
(ADR-0053), one entry per key in spec.repos, in that order, computed on
|
|
6183
6183
|
every reconcile once the Pod has a node. It is what a fetch inside the
|
|
6184
|
-
pod waits on: the program asks the
|
|
6184
|
+
pod waits on: the program asks the Custodian, the Orchestrator marks the
|
|
6185
6185
|
ask on this CR, and the entry for that key says whether the node fetched
|
|
6186
6186
|
since. `Ready` is untouched by it — that stays the gate it was, sticky
|
|
6187
6187
|
and taken once per Pod life; this is the standing answer the gate's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jr2/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "The jr2 CLI: init, up, run — the interface to a jr2 Instance.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -39,13 +39,14 @@
|
|
|
39
39
|
"ts-blank-space": "^0.9.0"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@jr2/orchestrator": "0.
|
|
42
|
+
"@jr2/orchestrator": "0.3.0"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
+
"@anthropic-ai/sdk": "0.91.1",
|
|
45
46
|
"@types/node": "^26.0.1",
|
|
46
47
|
"xstate": "^5.18.0",
|
|
47
48
|
"zod": "^4.4.3",
|
|
48
|
-
"@jr2/orchestrator": "0.
|
|
49
|
+
"@jr2/orchestrator": "0.3.0"
|
|
49
50
|
},
|
|
50
51
|
"files": [
|
|
51
52
|
"bin",
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
],
|
|
55
56
|
"scripts": {
|
|
56
57
|
"typecheck": "tsc --noEmit",
|
|
57
|
-
"test": "node --test"
|
|
58
|
+
"test": "node --test 'test/**/*.test.ts'",
|
|
59
|
+
"test:custodian": "node --test --test-concurrency=1 test/custodian.docker.ts"
|
|
58
60
|
}
|
|
59
61
|
}
|
package/src/build.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// kinds — the instance's own (engine + this instance's workflows baked, ADR-0008), the instance's
|
|
3
3
|
// Sandbox Images (the `file:` docker contexts its Machines carry, ADR-0037/0049), and — only when the
|
|
4
4
|
// CLI is running out of
|
|
5
|
-
// a kit CHECKOUT — the Harness
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// a kit CHECKOUT — the Harness and operator images. Installed from npm those kit sources do not
|
|
6
|
+
// resolve, so a real instance takes the published-`<kitversion>` path and never needs docker for
|
|
7
|
+
// them. The checkout IS the signal: no flag, no config key, no env. One image is deployed and never
|
|
8
|
+
// built, in either world: the Custodian's, upstream Envoy pinned by digest (ADR-0038, ADR-0059).
|
|
8
9
|
//
|
|
9
10
|
// Every tag is a content address of its own inputs. That is what makes `imagePullPolicy:
|
|
10
11
|
// IfNotPresent` correct rather than lucky (a unique tag per content means "present" implies
|
|
@@ -34,7 +35,7 @@ import { createHash } from "node:crypto";
|
|
|
34
35
|
import { execFile, spawn } from "node:child_process";
|
|
35
36
|
import { cp, mkdir, mkdtemp, readdir, readFile, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
36
37
|
import { tmpdir } from "node:os";
|
|
37
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
38
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
38
39
|
import { fileURLToPath } from "node:url";
|
|
39
40
|
import { promisify } from "node:util";
|
|
40
41
|
import { KIT_VERSION } from "@jr2/orchestrator";
|
|
@@ -144,7 +145,7 @@ export type BuildPort = {
|
|
|
144
145
|
* context and carries no `.dockerignore`, so docker copies `dist/` and `node_modules/` straight in.
|
|
145
146
|
*/
|
|
146
147
|
|
|
147
|
-
/** For walks under `packages/*` (
|
|
148
|
+
/** For walks under `packages/*` (the Harness): its context is the KIT ROOT, so the root
|
|
148
149
|
* `.dockerignore` governs, and the only entries it drops at any depth are `**\/node_modules` and
|
|
149
150
|
* `**\/dist` (plus `*.log`/`.env` globs `contentHash`'s name-set cannot express — hashing a stray
|
|
150
151
|
* one of those over-hashes, which is allowed). A `packages/harness/bin/` would be context-VISIBLE,
|
|
@@ -226,7 +227,7 @@ export const LABEL_IMAGE_KIND = "jr2.dev/kind";
|
|
|
226
227
|
/** The three kinds ADR-0038 builds, and the whole value domain of {@link LABEL_IMAGE_KIND}. */
|
|
227
228
|
export type ImageKind = "instance" | "sandbox" | "kit";
|
|
228
229
|
|
|
229
|
-
/** The kit's own images (Harness,
|
|
230
|
+
/** The kit's own images (Harness, operator). No instance label: every instance on the
|
|
230
231
|
* cluster shares one copy, and "kit images are never swept" is not a rule any more — a kit ref is
|
|
231
232
|
* kept because some instance's map or pod names it, and collects with everything else when the
|
|
232
233
|
* last instance leaves (ADR-0039). */
|
|
@@ -258,8 +259,8 @@ export function sandboxImageLabels(instance: string): Record<string, string> {
|
|
|
258
259
|
* script's default and fails the gate when they drift.
|
|
259
260
|
*
|
|
260
261
|
* An arch outside this set is never built for. An instance image for `s390x` is dead weight: no Kit
|
|
261
|
-
* image could sit beside it in the pod, so the Sandbox would fail at the
|
|
262
|
-
*
|
|
262
|
+
* image could sit beside it in the pod, so the Sandbox would fail at the Harness instead of at
|
|
263
|
+
* the image nobody published.
|
|
263
264
|
*/
|
|
264
265
|
export const SUPPORTED_PLATFORMS: readonly string[] = ["linux/amd64", "linux/arm64"];
|
|
265
266
|
|
|
@@ -365,8 +366,8 @@ export async function assertEmulation(port: BuildPort, platforms: readonly strin
|
|
|
365
366
|
|
|
366
367
|
// --- the kit images (ADR-0038) --------------------------------------------------------------
|
|
367
368
|
|
|
368
|
-
/** The
|
|
369
|
-
export type KitImageName = "harness" | "
|
|
369
|
+
/** The two images the KIT owns and builds. An instance deploys them but never authors them. */
|
|
370
|
+
export type KitImageName = "harness" | "operator";
|
|
370
371
|
export type KitImageRefs = Record<KitImageName, string>;
|
|
371
372
|
|
|
372
373
|
type KitImage = {
|
|
@@ -415,13 +416,6 @@ export const KIT_IMAGES: Record<KitImageName, KitImage> = {
|
|
|
415
416
|
],
|
|
416
417
|
exclude: KIT_PACKAGE_EXCLUDE,
|
|
417
418
|
},
|
|
418
|
-
adapter: {
|
|
419
|
-
repo: "jr2-adapter",
|
|
420
|
-
dockerfile: "deploy/adapter/Dockerfile",
|
|
421
|
-
context: ".",
|
|
422
|
-
sources: ["packages/adapter", "deploy/adapter/Dockerfile"],
|
|
423
|
-
exclude: KIT_PACKAGE_EXCLUDE,
|
|
424
|
-
},
|
|
425
419
|
operator: {
|
|
426
420
|
repo: "jr2-operator",
|
|
427
421
|
dockerfile: "operator/Dockerfile",
|
|
@@ -451,14 +445,13 @@ export const KIT_IMAGE_HOME = "ghcr.io/snapwich";
|
|
|
451
445
|
* home rather than nesting under it: a mirror holds the same tags under its own name, seeded
|
|
452
446
|
* deliberately (`jr2 kit push`) and never by a converge. It is NOT `config.registry`: that key says
|
|
453
447
|
* where images this converge BUILDS go, and prefixing both with one key would make every
|
|
454
|
-
* private-registry user mirror
|
|
448
|
+
* private-registry user mirror the Kit images they could have pulled from the home. The version is
|
|
455
449
|
* still the CLI's own — re-homing says where the tags live, never which ones.
|
|
456
450
|
*/
|
|
457
451
|
export function publishedKitRefs(kitRegistry?: string): KitImageRefs {
|
|
458
452
|
const home = kitRegistry ?? KIT_IMAGE_HOME;
|
|
459
453
|
return {
|
|
460
454
|
harness: `${home}/jr2-harness:${KIT_VERSION}`,
|
|
461
|
-
adapter: `${home}/jr2-adapter:${KIT_VERSION}`,
|
|
462
455
|
operator: `${home}/jr2-operator:${KIT_VERSION}`,
|
|
463
456
|
};
|
|
464
457
|
}
|
|
@@ -468,12 +461,18 @@ export function publishedKitRefs(kitRegistry?: string): KitImageRefs {
|
|
|
468
461
|
* requiring BOTH `deploy/harness/Dockerfile` and a `packages/harness/package.json` that names
|
|
469
462
|
* `@jr2/harness`. Either marker alone matches an unrelated tree — someone else's `deploy/harness`,
|
|
470
463
|
* or a vendored copy of one package — and a false positive means `jr2 up` tries to docker-build a
|
|
471
|
-
* kit that is not there. Installed from npm
|
|
464
|
+
* kit that is not there. Installed from npm the answer is `undefined`.
|
|
465
|
+
*
|
|
466
|
+
* A module under `node_modules` came from a registry, so the walk stops there: an instance that
|
|
467
|
+
* lives INSIDE a checkout but installs `@jr2/*` from npm would otherwise reach the checkout root and
|
|
468
|
+
* build a Harness at HEAD beside an Orchestrator at the pinned version. The checkout's own instances
|
|
469
|
+
* link the workspace packages, and node resolves those links, so their CLI loads from `packages/`.
|
|
472
470
|
*/
|
|
473
471
|
export async function detectKitCheckout(
|
|
474
472
|
fromDir: string = fileURLToPath(new URL(".", import.meta.url)),
|
|
475
473
|
): Promise<string | undefined> {
|
|
476
474
|
let dir = resolve(fromDir);
|
|
475
|
+
if (dir.split(sep).includes("node_modules")) return undefined;
|
|
477
476
|
for (;;) {
|
|
478
477
|
if (await isKitRoot(dir)) return dir;
|
|
479
478
|
const parent = dirname(dir);
|
|
@@ -737,7 +736,7 @@ const emptySweep = (): SweepResult => ({ removed: [], kept: [], failed: [], byte
|
|
|
737
736
|
* One report out of several — the host's and every node's, or several converges' (`mergeSweeps` is
|
|
738
737
|
* associative, so a caller can fold as it goes). Refs are de-duplicated AFTER {@link normalizeRef},
|
|
739
738
|
* because that is the only way the promise holds: the two stores spell one image differently
|
|
740
|
-
* (`jr2-
|
|
739
|
+
* (`jr2-harness:33a4` on the host, `docker.io/library/jr2-harness:33a4` on a node), so a raw-string
|
|
741
740
|
* set reports one image twice. The merged refs are the normalized spelling — the one the roots, and
|
|
742
741
|
* the user, name an image by.
|
|
743
742
|
*
|
|
@@ -1004,8 +1003,8 @@ const execCommand: RunCommand = async (command, args, cwd) => {
|
|
|
1004
1003
|
* dependency graph, wherever the folder sits — so it is staged as a copy
|
|
1005
1004
|
* ({@link BUNDLE_STAGE_EXCLUDE}) and installed frozen and production-only by the lockfile's own
|
|
1006
1005
|
* package manager ({@link lockfileInstall}). This is how a standalone instance committed INSIDE
|
|
1007
|
-
* a pnpm monorepo bundles:
|
|
1008
|
-
*
|
|
1006
|
+
* a pnpm monorepo bundles: it sits under a `pnpm-workspace.yaml` that does not list it, so the
|
|
1007
|
+
* workspace above it says nothing about how it installs.
|
|
1009
1008
|
* 2. A `pnpm-workspace.yaml` above it. A workspace member carries no lockfile of its own — the
|
|
1010
1009
|
* workspace root holds it — so it is the ABSENCE of one that sends the instance to `pnpm deploy
|
|
1011
1010
|
* --legacy`, unchanged. pnpm is a contributor prerequisite, like go for the operator, never a
|
package/src/commands/kit.ts
CHANGED
|
@@ -2,16 +2,18 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Kit images live at a canonical public home (`ghcr.io/snapwich`, baked into `publishedKitRefs()`).
|
|
4
4
|
// A cluster that cannot reach it — air-gapped, mirror-only, or simply policy-bound to one registry —
|
|
5
|
-
// needs
|
|
6
|
-
// command is how the bytes get there.
|
|
5
|
+
// needs those refs at its own address, which is what `kitRegistry` names in the config. This
|
|
6
|
+
// command is how the bytes get there. It moves one more image the same way: the Custodian's, the
|
|
7
|
+
// pinned upstream Envoy every Harness pod runs (ADR-0059), which jr2 deploys and never builds — a
|
|
8
|
+
// mirror-only cluster needs it just as much, and `kitRegistry` re-homes it the same way.
|
|
7
9
|
//
|
|
8
10
|
// It is deliberately INSTANCE-LESS: no `jr2.config.ts`, no kube context, no namespace. Kit images are
|
|
9
11
|
// shared by every instance on a cluster (and possibly by every instance in an org), so moving them
|
|
10
12
|
// is its own deliberate act rather than a side effect of converging one instance — which is exactly
|
|
11
13
|
// why ADR-0044 keeps this out of `jr2 up`.
|
|
12
14
|
//
|
|
13
|
-
// It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness
|
|
14
|
-
//
|
|
15
|
+
// It is a MIRROR and nothing else. There is no build arm here: the npm packages carry no Harness
|
|
16
|
+
// source, and an installed CLI that could build Kit images would be the patched-Harness
|
|
15
17
|
// eject hatch ADR-0027/ADR-0038 welded shut. The kit developer's arm is `just kit-push`, in the
|
|
16
18
|
// checkout, where the sources actually are.
|
|
17
19
|
//
|
|
@@ -23,7 +25,7 @@
|
|
|
23
25
|
|
|
24
26
|
import { execFile } from "node:child_process";
|
|
25
27
|
import { parseArgs, promisify } from "node:util";
|
|
26
|
-
import { KIT_VERSION } from "@jr2/orchestrator";
|
|
28
|
+
import { custodianRef, KIT_VERSION } from "@jr2/orchestrator";
|
|
27
29
|
import { KIT_IMAGES, publishedKitRefs, type KitImageName } from "../build.ts";
|
|
28
30
|
import { activity, result, type Io } from "../output.ts";
|
|
29
31
|
|
|
@@ -31,8 +33,9 @@ const exec = promisify(execFile);
|
|
|
31
33
|
|
|
32
34
|
const USAGE = `usage: jr2 kit push <registry>
|
|
33
35
|
|
|
34
|
-
mirror the v${KIT_VERSION} kit images (harness,
|
|
35
|
-
|
|
36
|
+
mirror the v${KIT_VERSION} kit images (harness, operator) from their canonical home, and the
|
|
37
|
+
Custodian's pinned Envoy from its own, into <registry>, for a cluster whose \`kitRegistry\`
|
|
38
|
+
names it (ADR-0044, ADR-0059)`;
|
|
36
39
|
|
|
37
40
|
/** Run one `docker` invocation, rejecting on a non-zero exit. The seam this command is tested
|
|
38
41
|
* through: the claims are which argv each ref produces and what a failure means, and both are
|
|
@@ -45,7 +48,7 @@ const dockerCli: RunDocker = async (args) => {
|
|
|
45
48
|
};
|
|
46
49
|
|
|
47
50
|
/** What one image's mirror did — the per-image line, and the machine-readable result's rows. */
|
|
48
|
-
type Mirrored = { image: KitImageName; source: string; target: string; copied: boolean };
|
|
51
|
+
type Mirrored = { image: KitImageName | "custodian"; source: string; target: string; copied: boolean };
|
|
49
52
|
|
|
50
53
|
export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli): Promise<number> {
|
|
51
54
|
const { positionals } = parseArgs({ args, allowPositionals: true, strict: false, options: {} });
|
|
@@ -77,6 +80,9 @@ export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli)
|
|
|
77
80
|
for (const image of Object.keys(KIT_IMAGES) as KitImageName[]) {
|
|
78
81
|
rows.push(await mirror(io, docker, image, sources[image], targets[image]));
|
|
79
82
|
}
|
|
83
|
+
// The Custodian's: pinned by digest, and a registry-to-registry copy keeps the digest — so the
|
|
84
|
+
// ref a converge with this `kitRegistry` deploys (`custodianRef`) resolves in the mirror.
|
|
85
|
+
rows.push(await mirror(io, docker, "custodian", custodianRef(), custodianRef(target)));
|
|
80
86
|
|
|
81
87
|
const copied = rows.filter((r) => r.copied).length;
|
|
82
88
|
activity(io, `mirrored ${copied} image(s), ${rows.length - copied} already present`);
|
|
@@ -97,7 +103,7 @@ export async function kit(args: string[], io: Io, docker: RunDocker = dockerCli)
|
|
|
97
103
|
async function mirror(
|
|
98
104
|
io: Io,
|
|
99
105
|
docker: RunDocker,
|
|
100
|
-
image: KitImageName,
|
|
106
|
+
image: KitImageName | "custodian",
|
|
101
107
|
source: string,
|
|
102
108
|
target: string,
|
|
103
109
|
): Promise<Mirrored> {
|
|
@@ -106,7 +112,8 @@ async function mirror(
|
|
|
106
112
|
return { image, source, target, copied: false };
|
|
107
113
|
}
|
|
108
114
|
await requireSource(docker, image, source, target);
|
|
109
|
-
|
|
115
|
+
// A digest-pinned target is pushed by its tag; the copy lands at that same digest.
|
|
116
|
+
await docker(["buildx", "imagetools", "create", "-t", target.replace(/@sha256:[0-9a-f]+$/, ""), source]);
|
|
110
117
|
activity(io, `${image}: copied ${source} → ${target}`);
|
|
111
118
|
return { image, source, target, copied: true };
|
|
112
119
|
}
|
|
@@ -130,12 +137,17 @@ async function resolves(docker: RunDocker, ref: string): Promise<boolean> {
|
|
|
130
137
|
* it points at is the other arm of ADR-0044: `just kit-push`, in the checkout, is what puts images
|
|
131
138
|
* at a published name in the first place; this command only moves what is already there.
|
|
132
139
|
*/
|
|
133
|
-
async function requireSource(
|
|
140
|
+
async function requireSource(
|
|
141
|
+
docker: RunDocker,
|
|
142
|
+
image: KitImageName | "custodian",
|
|
143
|
+
source: string,
|
|
144
|
+
target: string,
|
|
145
|
+
): Promise<void> {
|
|
134
146
|
if (await resolves(docker, source)) return;
|
|
135
147
|
throw new Error(
|
|
136
148
|
`${image}: ${source} is not there, so nothing can be mirrored to ${target}\n` +
|
|
137
149
|
` a kit image reaches ${target} only by being copied from its published home;\n` +
|
|
138
150
|
` if v${KIT_VERSION} was never published (a dev version never is), seed a registry from a kit\n` +
|
|
139
|
-
` checkout with \`just kit-push <registry>\`, which builds the
|
|
151
|
+
` checkout with \`just kit-push <registry>\`, which builds the Kit images from source`,
|
|
140
152
|
);
|
|
141
153
|
}
|
package/src/commands/up.ts
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// `--platform` passed explicitly on every build. The cluster's schedulable nodes choose that set
|
|
25
25
|
// (`platforms` in the config overrides absolutely), so the daemon default and
|
|
26
26
|
// `DOCKER_DEFAULT_PLATFORM` steer nothing any more. In a kit CHECKOUT the built set includes the
|
|
27
|
-
// Harness
|
|
27
|
+
// Harness and the operator; installed from npm those sources do not resolve and the published
|
|
28
28
|
// `<kitversion>` refs are used with no docker at all (they are multi-arch manifest lists, so they
|
|
29
29
|
// take no suffix and never had this hole). There is no `images` config block and no env escape
|
|
30
30
|
// hatch — nobody gets to point a real cluster at a hand-picked Harness (ADR-0027's no-eject-hatch,
|
|
@@ -45,27 +45,38 @@
|
|
|
45
45
|
// labeled objects found → converge silently (it's home); nothing → confirm first-time setup
|
|
46
46
|
// (`--yes` for CI); objects labeled as a DIFFERENT instance → refuse.
|
|
47
47
|
|
|
48
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
48
|
+
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
49
49
|
import { open, readdir, readFile } from "node:fs/promises";
|
|
50
50
|
import { homedir } from "node:os";
|
|
51
51
|
import { basename, join } from "node:path";
|
|
52
52
|
import { parseArgs } from "node:util";
|
|
53
53
|
import {
|
|
54
|
+
custodianBootstrap,
|
|
55
|
+
custodianRef,
|
|
56
|
+
custodianScript,
|
|
54
57
|
customizeLine,
|
|
55
58
|
defaultImageContext,
|
|
59
|
+
heldSecretsOf,
|
|
60
|
+
HELD_CA_SECRET,
|
|
61
|
+
HELD_SECRETS_SECRET,
|
|
62
|
+
HELD_TLS_SECRET,
|
|
63
|
+
ORCHESTRATOR_PORT,
|
|
56
64
|
imageContextDigest,
|
|
57
65
|
isSshUrl,
|
|
58
66
|
loadConfig,
|
|
59
67
|
loadWorkflows,
|
|
60
68
|
matchCredential,
|
|
61
69
|
partsOf,
|
|
70
|
+
harnessTokenDigest,
|
|
62
71
|
sandboxToken,
|
|
63
72
|
type CarriedAgent,
|
|
64
73
|
type CarriedImage,
|
|
65
74
|
type CarriedRepo,
|
|
75
|
+
type HeldManifest,
|
|
66
76
|
type ImageRefs,
|
|
67
77
|
type JR2Config,
|
|
68
78
|
} from "@jr2/orchestrator";
|
|
79
|
+
import { caFits, fingerprint, issueCa, issueLeaf, leafFits, trustBundles, type Pem } from "../held-pki.ts";
|
|
69
80
|
import {
|
|
70
81
|
assertEmulation,
|
|
71
82
|
buildSandboxImage,
|
|
@@ -101,6 +112,8 @@ import {
|
|
|
101
112
|
OPERATOR_SELECTOR,
|
|
102
113
|
operatorManifest,
|
|
103
114
|
REPO_CACHE,
|
|
115
|
+
trustObject,
|
|
116
|
+
type HeldObjects,
|
|
104
117
|
} from "../deploy.ts";
|
|
105
118
|
import { resolveRoot } from "../instance.ts";
|
|
106
119
|
import {
|
|
@@ -322,16 +335,20 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
322
335
|
activity(
|
|
323
336
|
io,
|
|
324
337
|
kitRoot
|
|
325
|
-
? `images: kit checkout at ${kitRoot} — building the Harness
|
|
326
|
-
: `images: installed kit — the published v${KIT_VERSION} Harness
|
|
338
|
+
? `images: kit checkout at ${kitRoot} — building the Harness and operator from source`
|
|
339
|
+
: `images: installed kit — the published v${KIT_VERSION} Harness and operator, pulled from ` +
|
|
327
340
|
`${config.kitRegistry ? `${config.kitRegistry} (kitRegistry)` : KIT_IMAGE_HOME}`,
|
|
328
341
|
);
|
|
342
|
+
// The Custodian's image is deployed and never built, in either world (ADR-0038, ADR-0059): the
|
|
343
|
+
// pinned upstream Envoy, re-homed onto `kitRegistry` like a Kit image when one is set.
|
|
344
|
+
const custodianImage = custodianRef(config.kitRegistry);
|
|
345
|
+
activity(io, `custodian image: ${custodianImage} (pinned, deployed — never built)`);
|
|
329
346
|
|
|
330
347
|
// Read the Orchestrator Deployment ONCE: it carries both convergence records — the instance
|
|
331
348
|
// image's content hash (a label) and the previous name→ref map (an annotation, ADR-0038).
|
|
332
349
|
const orch = await kube.getJson({ kind: "deployment", name: ORCHESTRATOR_SERVICE, namespace, ...ctx });
|
|
333
350
|
const previous = previousImages(orch);
|
|
334
|
-
const converged: ConvergedImages = { harness: refs.harness,
|
|
351
|
+
const converged: ConvergedImages = { harness: refs.harness, custodian: custodianImage, sandbox: {}, sandboxUser: {} };
|
|
335
352
|
|
|
336
353
|
// ONE transport branch for every image (ADR-0038), so instance, kit, and Sandbox Images cannot
|
|
337
354
|
// drift into three delivery stories.
|
|
@@ -473,13 +490,11 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
473
490
|
}
|
|
474
491
|
|
|
475
492
|
// --- the kit's own runtime images (ADR-0038) ---------------------------------------------------
|
|
476
|
-
// Built here rather than lazily beside
|
|
493
|
+
// Built here rather than lazily beside its consumers. The Harness is the injection source
|
|
477
494
|
// (ADR-0037): the pod's `runtime` init step copies `/opt/jr2` out of this exact ref onto the
|
|
478
|
-
// volume the Sandbox Image mounts.
|
|
479
|
-
//
|
|
480
|
-
// so a kit edit re-images future pods and re-tags nothing of the user's.
|
|
495
|
+
// volume the Sandbox Image mounts. It is not a hash input for a Sandbox Image — the runtime rides
|
|
496
|
+
// the pod's volume, so a kit edit re-images future pods and re-tags nothing of the user's.
|
|
481
497
|
await ensureKitImage("harness");
|
|
482
|
-
await ensureKitImage("adapter");
|
|
483
498
|
|
|
484
499
|
// --- instance image (content-addressed by the bundle) ------------------------------------------
|
|
485
500
|
// Stage first, THEN decide: the hash is over the materialized bundle — the actual image inputs,
|
|
@@ -612,10 +627,10 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
612
627
|
const secretData: Record<string, string> = {
|
|
613
628
|
JR2_INSTANCE_TOKEN: instanceToken,
|
|
614
629
|
JR2_SIGNING_KEY: signingKey,
|
|
615
|
-
// The Instance Harness
|
|
630
|
+
// The Instance Harness Custodian's credential (ADR-0013/0031): a sandbox-style token signed
|
|
616
631
|
// for the placement's name — it may deliver only to Turns hosted THERE (tokens.ts), so the
|
|
617
632
|
// Instance token never enters that pod. Derived from the kept key, so re-runs converge to
|
|
618
|
-
// the same value; live
|
|
633
|
+
// the same value; live Custodians keep verifying.
|
|
619
634
|
JR2_INSTANCE_HARNESS_TOKEN: sandboxToken(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
|
|
620
635
|
};
|
|
621
636
|
// Git tokens (ADR-0019/0051): every env var a `git.credentials` entry names, materialized from
|
|
@@ -628,29 +643,49 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
628
643
|
|
|
629
644
|
// The HARNESS containers' env — a separate Secret (ADR-0013): Agent code executes where these
|
|
630
645
|
// land, so the Instance token/signing key above must be unreachable from it. Values declared in
|
|
631
|
-
// config (usually read off process.env/.env) materialize here (ADR-0019)
|
|
632
|
-
//
|
|
646
|
+
// config (usually read off process.env/.env) materialize here (ADR-0019). The provider key does
|
|
647
|
+
// NOT: it is a held secret (ADR-0059), and the Agent can read everything in this Secret.
|
|
633
648
|
const harnessEnvData: Record<string, string> = {};
|
|
634
649
|
for (const v of config.harness?.env ?? []) if (v.value !== undefined) harnessEnvData[v.name] = v.value;
|
|
635
|
-
if (config.harness?.provider?.apiKey) harnessEnvData.JR2_PROVIDER_API_KEY = config.harness.provider.apiKey;
|
|
636
650
|
|
|
637
651
|
// Preflight referenced-but-unmanaged Secrets: turn the CreateContainerConfigError hang into an
|
|
638
|
-
// immediate, named error (ADR-0019). Sealed/External Secrets ride this seam untouched.
|
|
652
|
+
// immediate, named error (ADR-0019). Sealed/External Secrets ride this seam untouched. The read
|
|
653
|
+
// keeps each one's key NAMES (never a value), which the held-secret rules check for a model key.
|
|
654
|
+
const envFromKeys = new Map<string, string[]>();
|
|
639
655
|
for (const ref of config.harness?.envFrom ?? []) {
|
|
640
|
-
const refName = ref.secretRef?.name;
|
|
656
|
+
const refName = ref.secretRef?.name ?? ref.configMapRef?.name;
|
|
641
657
|
if (!refName) continue;
|
|
642
|
-
|
|
658
|
+
const kind = ref.secretRef ? "secret" : "configmap";
|
|
659
|
+
const found = await kube.getJson<KubeObject & { data?: Record<string, string> }>({
|
|
660
|
+
kind,
|
|
661
|
+
name: refName,
|
|
662
|
+
namespace,
|
|
663
|
+
...ctx,
|
|
664
|
+
});
|
|
665
|
+
if (!found && kind === "secret") {
|
|
643
666
|
throw new Error(
|
|
644
667
|
`harness.envFrom references Secret "${refName}", which does not exist in namespace "${namespace}" — ` +
|
|
645
668
|
`create it first: kubectl -n ${namespace} create secret generic ${refName} --from-literal=KEY=...`,
|
|
646
669
|
);
|
|
647
670
|
}
|
|
671
|
+
envFromKeys.set(`${kind}/${refName}`, Object.keys(found?.data ?? {}));
|
|
648
672
|
}
|
|
649
673
|
|
|
650
674
|
// --- private-CA bundle (ADR-0020): read HERE, host-side — the in-cluster config eval never
|
|
651
675
|
// touches the file (the path may not exist there); consumers get the ConfigMap.
|
|
652
676
|
const caPem = await readCaBundle(root, config);
|
|
653
677
|
|
|
678
|
+
// --- held secrets (ADR-0059): resolved HERE, host-side, where `.env` exists ------------------
|
|
679
|
+
const held = await resolveHeld(io, kube, {
|
|
680
|
+
config,
|
|
681
|
+
name,
|
|
682
|
+
namespace,
|
|
683
|
+
ctx,
|
|
684
|
+
envFromKeys,
|
|
685
|
+
models: agents.map((a) => a.definition.model).filter((m): m is string => typeof m === "string"),
|
|
686
|
+
caBundle: caPem,
|
|
687
|
+
});
|
|
688
|
+
|
|
654
689
|
// --- git over ssh: where the key comes from (ADR-0019, ADR-0047) -------------------------------
|
|
655
690
|
// Runs BEFORE the apply, because a missing key source is a converge that must not start, and the
|
|
656
691
|
// notice it hands back is owed to the END of the converge — a generated key is dead until a human
|
|
@@ -661,6 +696,10 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
661
696
|
await preflightProvider(io, kube, config, agents, namespace, ctx, caPem);
|
|
662
697
|
|
|
663
698
|
// --- apply + rollout ---------------------------------------------------------------------------
|
|
699
|
+
// The trust bundles first, and server-side: every pod that mounts them must find them, and they
|
|
700
|
+
// carry every root certificate — past what client-side apply's annotation can hold.
|
|
701
|
+
const trust = trustObject({ name, namespace, caBundle: caPem, bundles: held.bundles });
|
|
702
|
+
if (trust) await kube.apply({ manifest: trust, serverSide: true, ...ctx });
|
|
664
703
|
activity(io, `orchestrator: applying (image ${tag})`);
|
|
665
704
|
await kube.apply({
|
|
666
705
|
manifest: instanceObjects({
|
|
@@ -671,12 +710,21 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
671
710
|
secretData,
|
|
672
711
|
harnessEnvData,
|
|
673
712
|
harness: config.harness,
|
|
674
|
-
|
|
713
|
+
held: held.objects,
|
|
675
714
|
imageRefs: converged,
|
|
676
715
|
repoCache: carried.composesSandbox ? { image: operatorImage!, placement: config.sandbox } : undefined,
|
|
677
716
|
}),
|
|
678
717
|
...ctx,
|
|
679
718
|
});
|
|
719
|
+
// No secret held any more: the Custodian's Secrets go with it. The CA stays (`jr2-held-ca`), so a
|
|
720
|
+
// later converge that holds one again issues leaves under the same CA.
|
|
721
|
+
if (held.objects.manifest.secrets.length === 0) {
|
|
722
|
+
for (const secret of [HELD_SECRETS_SECRET, HELD_TLS_SECRET]) {
|
|
723
|
+
await kube.deleteObject({ kind: "secret", name: secret, namespace, ...ctx });
|
|
724
|
+
}
|
|
725
|
+
} else if (Object.keys(held.objects.values).length === 0) {
|
|
726
|
+
await kube.deleteObject({ kind: "secret", name: HELD_SECRETS_SECRET, namespace, ...ctx });
|
|
727
|
+
}
|
|
680
728
|
activity(io, "orchestrator: waiting for rollout");
|
|
681
729
|
await awaitRollout(kube, {
|
|
682
730
|
name: ORCHESTRATOR_SERVICE,
|
|
@@ -747,12 +795,14 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
747
795
|
name,
|
|
748
796
|
namespace,
|
|
749
797
|
harnessImage,
|
|
750
|
-
|
|
798
|
+
custodianImage,
|
|
799
|
+
held: held.objects.manifest,
|
|
800
|
+
heldDigest: heldDigest(Buffer.from(signingKey, "base64"), held.objects),
|
|
751
801
|
harness: config.harness,
|
|
752
802
|
caBundle: caPem !== undefined,
|
|
753
|
-
// The
|
|
754
|
-
// the
|
|
755
|
-
|
|
803
|
+
// The wire's gate (ADR-0058): the digest of the bearer the Orchestrator derives for this
|
|
804
|
+
// placement from the kept key — the same shape it stamps onto every Sandbox at provision.
|
|
805
|
+
bearerSha256: harnessTokenDigest(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
|
|
756
806
|
}),
|
|
757
807
|
...ctx,
|
|
758
808
|
});
|
|
@@ -786,7 +836,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
786
836
|
}
|
|
787
837
|
|
|
788
838
|
/**
|
|
789
|
-
* Every image ref one converge resolved. The Orchestrator reads `harness`/`
|
|
839
|
+
* Every image ref one converge resolved. The Orchestrator reads `harness`/`custodian`/`sandbox` from
|
|
790
840
|
* the mounted map (`ImageRefs`); `operator` rides the same JSON because the record `jr2 up` diffs
|
|
791
841
|
* must cover every image it builds, and one map is what keeps the record it diffs and the map pods
|
|
792
842
|
* read from ever disagreeing (ADR-0038).
|
|
@@ -819,7 +869,7 @@ function previousImages(orch?: KubeObject): Partial<ConvergedImages> | undefined
|
|
|
819
869
|
|
|
820
870
|
/** Every ref one image map names, flattened — the kit's own plus each Sandbox Image. */
|
|
821
871
|
function mapRefs(map: Partial<ConvergedImages>): string[] {
|
|
822
|
-
return [map.harness, map.
|
|
872
|
+
return [map.harness, map.custodian, map.operator, ...Object.values(map.sandbox ?? {})].filter(
|
|
823
873
|
(ref): ref is string => typeof ref === "string",
|
|
824
874
|
);
|
|
825
875
|
}
|
|
@@ -871,6 +921,97 @@ async function sweepAfterConverge(
|
|
|
871
921
|
}
|
|
872
922
|
}
|
|
873
923
|
|
|
924
|
+
/**
|
|
925
|
+
* Held secrets, resolved (ADR-0059): the rules (`heldSecretsOf`, fed what only the cluster knows),
|
|
926
|
+
* the per-Instance CA (kept, or minted on first need), one leaf per bound target (reused while it
|
|
927
|
+
* still fits), the trust bundles, and the Custodian's rendered config. Narrated one line per secret
|
|
928
|
+
* — its name, its hosts, its source — and never a value.
|
|
929
|
+
*/
|
|
930
|
+
async function resolveHeld(
|
|
931
|
+
io: Io,
|
|
932
|
+
kube: KubeAdmin,
|
|
933
|
+
opts: {
|
|
934
|
+
config: JR2Config;
|
|
935
|
+
name: string;
|
|
936
|
+
namespace: string;
|
|
937
|
+
ctx: { context?: string };
|
|
938
|
+
envFromKeys: Map<string, string[]>;
|
|
939
|
+
models: string[];
|
|
940
|
+
/** The user's `caBundle` (ADR-0020): trusted upstream, and by the Harness beside the jr2 CA. */
|
|
941
|
+
caBundle?: string;
|
|
942
|
+
},
|
|
943
|
+
): Promise<{ objects: HeldObjects; bundles?: { extra: string; bundle: string; upstream: string } }> {
|
|
944
|
+
const { config, namespace, ctx } = opts;
|
|
945
|
+
type Data = KubeObject & { data?: Record<string, string> };
|
|
946
|
+
const decode = (data: Record<string, string> | undefined, key: string): string | undefined =>
|
|
947
|
+
data?.[key] === undefined ? undefined : Buffer.from(data[key], "base64").toString("utf8");
|
|
948
|
+
|
|
949
|
+
// R10: a referenced Secret and key must exist. Read once per Secret — key NAMES, never values.
|
|
950
|
+
const referenced = new Map<string, string[] | undefined>();
|
|
951
|
+
for (const s of config.harness?.heldSecrets ?? []) {
|
|
952
|
+
const ref = s.valueFrom?.secretKeyRef.name;
|
|
953
|
+
if (ref === undefined || referenced.has(ref)) continue;
|
|
954
|
+
const found = await kube.getJson<Data>({ kind: "secret", name: ref, namespace, ...ctx });
|
|
955
|
+
referenced.set(ref, found ? Object.keys(found.data ?? {}) : undefined);
|
|
956
|
+
}
|
|
957
|
+
const { manifest, values, warnings } = heldSecretsOf(config, {
|
|
958
|
+
envFromKeys: opts.envFromKeys,
|
|
959
|
+
secretKeys: (secret) => referenced.get(secret),
|
|
960
|
+
models: opts.models,
|
|
961
|
+
});
|
|
962
|
+
for (const warning of warnings) activity(io, `warning: ${warning}`);
|
|
963
|
+
const orchestrator = { host: `${ORCHESTRATOR_SERVICE}.${namespace}.svc`, port: ORCHESTRATOR_PORT };
|
|
964
|
+
const rendered = {
|
|
965
|
+
bootstrap: JSON.stringify(custodianBootstrap(manifest, orchestrator)),
|
|
966
|
+
script: custodianScript(manifest),
|
|
967
|
+
};
|
|
968
|
+
if (manifest.secrets.length === 0) {
|
|
969
|
+
return { objects: { manifest, values, leaves: {}, ...rendered } };
|
|
970
|
+
}
|
|
971
|
+
for (const s of manifest.secrets) {
|
|
972
|
+
const where = s.hosts.map((h) => (h.port === 443 ? h.host : `${h.host}:${h.port}`)).join(", ");
|
|
973
|
+
const source = s.source.kind === "literal" ? "literal" : `secret ${s.source.secret}/${s.source.key}`;
|
|
974
|
+
activity(io, `held secret ${s.name} → ${where} (${source})`);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// The CA: kept whenever the cluster holds a usable one — a live pod's Harness trusts it from its
|
|
978
|
+
// start, so a leaf a restarted Custodian reads must still chain to it. A human rotates it by
|
|
979
|
+
// deleting the Secret.
|
|
980
|
+
const stored = await kube.getJson<Data>({ kind: "secret", name: HELD_CA_SECRET, namespace, ...ctx });
|
|
981
|
+
const kept = { cert: decode(stored?.data, "ca.crt") ?? "", key: decode(stored?.data, "ca.key") ?? "" };
|
|
982
|
+
const ca: Pem = stored && caFits(kept) ? kept : issueCa(opts.name);
|
|
983
|
+
if (ca !== kept) activity(io, `held secrets: minted the Instance's CA into ${HELD_CA_SECRET}`);
|
|
984
|
+
|
|
985
|
+
const tls = await kube.getJson<Data>({ kind: "secret", name: HELD_TLS_SECRET, namespace, ...ctx });
|
|
986
|
+
const leaves: Record<string, Pem> = {};
|
|
987
|
+
for (const s of manifest.secrets) {
|
|
988
|
+
for (const h of s.hosts) {
|
|
989
|
+
if (leaves[h.leaf]) continue;
|
|
990
|
+
const existing = {
|
|
991
|
+
cert: decode(tls?.data, `${h.leaf}.crt`) ?? "",
|
|
992
|
+
key: decode(tls?.data, `${h.leaf}.key`) ?? "",
|
|
993
|
+
};
|
|
994
|
+
leaves[h.leaf] = leafFits(existing, ca, h.host) ? existing : issueLeaf(ca, h.host);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
return {
|
|
998
|
+
objects: { manifest, values, ca, leaves, ...rendered },
|
|
999
|
+
bundles: trustBundles(ca.cert, opts.caBundle),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** The Instance Harness's `jr2.dev/held-digest` (ADR-0059): an HMAC over every held-secret input,
|
|
1004
|
+
* keyed with the signing key so the annotation reveals nothing about a value. */
|
|
1005
|
+
function heldDigest(key: Buffer, held: HeldObjects): string {
|
|
1006
|
+
const mac = createHmac("sha256", key);
|
|
1007
|
+
mac.update(JSON.stringify(held.manifest));
|
|
1008
|
+
for (const stem of Object.keys(held.leaves).sort()) mac.update(`${stem}:${fingerprint(held.leaves[stem]!.cert)}`);
|
|
1009
|
+
for (const name of Object.keys(held.values).sort()) mac.update(`${name}=${held.values[name]}`);
|
|
1010
|
+
mac.update(held.bootstrap);
|
|
1011
|
+
mac.update(held.script);
|
|
1012
|
+
return mac.digest("base64url").slice(0, 32);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
874
1015
|
/** The one delivery failure that is a configuration error rather than a docker one (ADR-0019). */
|
|
875
1016
|
function undeliverable(context: string): Error {
|
|
876
1017
|
return new Error(
|
|
@@ -954,7 +1095,7 @@ async function verifyRunningImage(
|
|
|
954
1095
|
// thing serving — the question is what the cluster runs now, not what it is done running.
|
|
955
1096
|
const live = pods.filter((p) => !p.metadata.deletionTimestamp);
|
|
956
1097
|
// `container` narrows a multi-container pod to the one this layer's image claim is about
|
|
957
|
-
// (the Instance Harness pod also carries the
|
|
1098
|
+
// (the Instance Harness pod also carries the Custodian, which is not this check's business).
|
|
958
1099
|
const stale = live.filter((p) =>
|
|
959
1100
|
p.spec.containers.some((c) => (container === undefined || c.name === container) && c.image !== image),
|
|
960
1101
|
);
|
package/src/commands/version.ts
CHANGED
|
@@ -301,7 +301,7 @@ function deployedRows(d: NonNullable<VersionReport["deployed"]>, r: VersionRepor
|
|
|
301
301
|
}
|
|
302
302
|
if (d.images) {
|
|
303
303
|
if (d.images.harness) rows.push(["harness", d.images.harness]);
|
|
304
|
-
if (d.images.
|
|
304
|
+
if (d.images.custodian) rows.push(["custodian", d.images.custodian]);
|
|
305
305
|
for (const [key, ref] of Object.entries(d.images.sandbox ?? {})) rows.push([`sandbox ${key}`, ref]);
|
|
306
306
|
}
|
|
307
307
|
const verdict = skewSentence(r);
|