@jr2/cli 0.2.1 → 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 +20 -21
- package/src/commands/kit.ts +24 -12
- package/src/commands/up.ts +163 -23
- package/src/commands/version.ts +1 -1
- package/src/deploy.ts +165 -73
- 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
|
*
|
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,14 +45,22 @@
|
|
|
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,
|
|
@@ -64,9 +72,11 @@ import {
|
|
|
64
72
|
type CarriedAgent,
|
|
65
73
|
type CarriedImage,
|
|
66
74
|
type CarriedRepo,
|
|
75
|
+
type HeldManifest,
|
|
67
76
|
type ImageRefs,
|
|
68
77
|
type JR2Config,
|
|
69
78
|
} from "@jr2/orchestrator";
|
|
79
|
+
import { caFits, fingerprint, issueCa, issueLeaf, leafFits, trustBundles, type Pem } from "../held-pki.ts";
|
|
70
80
|
import {
|
|
71
81
|
assertEmulation,
|
|
72
82
|
buildSandboxImage,
|
|
@@ -102,6 +112,8 @@ import {
|
|
|
102
112
|
OPERATOR_SELECTOR,
|
|
103
113
|
operatorManifest,
|
|
104
114
|
REPO_CACHE,
|
|
115
|
+
trustObject,
|
|
116
|
+
type HeldObjects,
|
|
105
117
|
} from "../deploy.ts";
|
|
106
118
|
import { resolveRoot } from "../instance.ts";
|
|
107
119
|
import {
|
|
@@ -323,16 +335,20 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
323
335
|
activity(
|
|
324
336
|
io,
|
|
325
337
|
kitRoot
|
|
326
|
-
? `images: kit checkout at ${kitRoot} — building the Harness
|
|
327
|
-
: `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 ` +
|
|
328
340
|
`${config.kitRegistry ? `${config.kitRegistry} (kitRegistry)` : KIT_IMAGE_HOME}`,
|
|
329
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)`);
|
|
330
346
|
|
|
331
347
|
// Read the Orchestrator Deployment ONCE: it carries both convergence records — the instance
|
|
332
348
|
// image's content hash (a label) and the previous name→ref map (an annotation, ADR-0038).
|
|
333
349
|
const orch = await kube.getJson({ kind: "deployment", name: ORCHESTRATOR_SERVICE, namespace, ...ctx });
|
|
334
350
|
const previous = previousImages(orch);
|
|
335
|
-
const converged: ConvergedImages = { harness: refs.harness,
|
|
351
|
+
const converged: ConvergedImages = { harness: refs.harness, custodian: custodianImage, sandbox: {}, sandboxUser: {} };
|
|
336
352
|
|
|
337
353
|
// ONE transport branch for every image (ADR-0038), so instance, kit, and Sandbox Images cannot
|
|
338
354
|
// drift into three delivery stories.
|
|
@@ -474,13 +490,11 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
474
490
|
}
|
|
475
491
|
|
|
476
492
|
// --- the kit's own runtime images (ADR-0038) ---------------------------------------------------
|
|
477
|
-
// Built here rather than lazily beside
|
|
493
|
+
// Built here rather than lazily beside its consumers. The Harness is the injection source
|
|
478
494
|
// (ADR-0037): the pod's `runtime` init step copies `/opt/jr2` out of this exact ref onto the
|
|
479
|
-
// volume the Sandbox Image mounts.
|
|
480
|
-
//
|
|
481
|
-
// 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.
|
|
482
497
|
await ensureKitImage("harness");
|
|
483
|
-
await ensureKitImage("adapter");
|
|
484
498
|
|
|
485
499
|
// --- instance image (content-addressed by the bundle) ------------------------------------------
|
|
486
500
|
// Stage first, THEN decide: the hash is over the materialized bundle — the actual image inputs,
|
|
@@ -613,10 +627,10 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
613
627
|
const secretData: Record<string, string> = {
|
|
614
628
|
JR2_INSTANCE_TOKEN: instanceToken,
|
|
615
629
|
JR2_SIGNING_KEY: signingKey,
|
|
616
|
-
// The Instance Harness
|
|
630
|
+
// The Instance Harness Custodian's credential (ADR-0013/0031): a sandbox-style token signed
|
|
617
631
|
// for the placement's name — it may deliver only to Turns hosted THERE (tokens.ts), so the
|
|
618
632
|
// Instance token never enters that pod. Derived from the kept key, so re-runs converge to
|
|
619
|
-
// the same value; live
|
|
633
|
+
// the same value; live Custodians keep verifying.
|
|
620
634
|
JR2_INSTANCE_HARNESS_TOKEN: sandboxToken(Buffer.from(signingKey, "base64"), INSTANCE_HARNESS_SERVICE),
|
|
621
635
|
};
|
|
622
636
|
// Git tokens (ADR-0019/0051): every env var a `git.credentials` entry names, materialized from
|
|
@@ -629,29 +643,49 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
629
643
|
|
|
630
644
|
// The HARNESS containers' env — a separate Secret (ADR-0013): Agent code executes where these
|
|
631
645
|
// land, so the Instance token/signing key above must be unreachable from it. Values declared in
|
|
632
|
-
// config (usually read off process.env/.env) materialize here (ADR-0019)
|
|
633
|
-
//
|
|
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.
|
|
634
648
|
const harnessEnvData: Record<string, string> = {};
|
|
635
649
|
for (const v of config.harness?.env ?? []) if (v.value !== undefined) harnessEnvData[v.name] = v.value;
|
|
636
|
-
if (config.harness?.provider?.apiKey) harnessEnvData.JR2_PROVIDER_API_KEY = config.harness.provider.apiKey;
|
|
637
650
|
|
|
638
651
|
// Preflight referenced-but-unmanaged Secrets: turn the CreateContainerConfigError hang into an
|
|
639
|
-
// 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[]>();
|
|
640
655
|
for (const ref of config.harness?.envFrom ?? []) {
|
|
641
|
-
const refName = ref.secretRef?.name;
|
|
656
|
+
const refName = ref.secretRef?.name ?? ref.configMapRef?.name;
|
|
642
657
|
if (!refName) continue;
|
|
643
|
-
|
|
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") {
|
|
644
666
|
throw new Error(
|
|
645
667
|
`harness.envFrom references Secret "${refName}", which does not exist in namespace "${namespace}" — ` +
|
|
646
668
|
`create it first: kubectl -n ${namespace} create secret generic ${refName} --from-literal=KEY=...`,
|
|
647
669
|
);
|
|
648
670
|
}
|
|
671
|
+
envFromKeys.set(`${kind}/${refName}`, Object.keys(found?.data ?? {}));
|
|
649
672
|
}
|
|
650
673
|
|
|
651
674
|
// --- private-CA bundle (ADR-0020): read HERE, host-side — the in-cluster config eval never
|
|
652
675
|
// touches the file (the path may not exist there); consumers get the ConfigMap.
|
|
653
676
|
const caPem = await readCaBundle(root, config);
|
|
654
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
|
+
|
|
655
689
|
// --- git over ssh: where the key comes from (ADR-0019, ADR-0047) -------------------------------
|
|
656
690
|
// Runs BEFORE the apply, because a missing key source is a converge that must not start, and the
|
|
657
691
|
// notice it hands back is owed to the END of the converge — a generated key is dead until a human
|
|
@@ -662,6 +696,10 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
662
696
|
await preflightProvider(io, kube, config, agents, namespace, ctx, caPem);
|
|
663
697
|
|
|
664
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 });
|
|
665
703
|
activity(io, `orchestrator: applying (image ${tag})`);
|
|
666
704
|
await kube.apply({
|
|
667
705
|
manifest: instanceObjects({
|
|
@@ -672,12 +710,21 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
672
710
|
secretData,
|
|
673
711
|
harnessEnvData,
|
|
674
712
|
harness: config.harness,
|
|
675
|
-
|
|
713
|
+
held: held.objects,
|
|
676
714
|
imageRefs: converged,
|
|
677
715
|
repoCache: carried.composesSandbox ? { image: operatorImage!, placement: config.sandbox } : undefined,
|
|
678
716
|
}),
|
|
679
717
|
...ctx,
|
|
680
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
|
+
}
|
|
681
728
|
activity(io, "orchestrator: waiting for rollout");
|
|
682
729
|
await awaitRollout(kube, {
|
|
683
730
|
name: ORCHESTRATOR_SERVICE,
|
|
@@ -748,7 +795,9 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
748
795
|
name,
|
|
749
796
|
namespace,
|
|
750
797
|
harnessImage,
|
|
751
|
-
|
|
798
|
+
custodianImage,
|
|
799
|
+
held: held.objects.manifest,
|
|
800
|
+
heldDigest: heldDigest(Buffer.from(signingKey, "base64"), held.objects),
|
|
752
801
|
harness: config.harness,
|
|
753
802
|
caBundle: caPem !== undefined,
|
|
754
803
|
// The wire's gate (ADR-0058): the digest of the bearer the Orchestrator derives for this
|
|
@@ -787,7 +836,7 @@ export async function up(args: string[], io: Io): Promise<number> {
|
|
|
787
836
|
}
|
|
788
837
|
|
|
789
838
|
/**
|
|
790
|
-
* Every image ref one converge resolved. The Orchestrator reads `harness`/`
|
|
839
|
+
* Every image ref one converge resolved. The Orchestrator reads `harness`/`custodian`/`sandbox` from
|
|
791
840
|
* the mounted map (`ImageRefs`); `operator` rides the same JSON because the record `jr2 up` diffs
|
|
792
841
|
* must cover every image it builds, and one map is what keeps the record it diffs and the map pods
|
|
793
842
|
* read from ever disagreeing (ADR-0038).
|
|
@@ -820,7 +869,7 @@ function previousImages(orch?: KubeObject): Partial<ConvergedImages> | undefined
|
|
|
820
869
|
|
|
821
870
|
/** Every ref one image map names, flattened — the kit's own plus each Sandbox Image. */
|
|
822
871
|
function mapRefs(map: Partial<ConvergedImages>): string[] {
|
|
823
|
-
return [map.harness, map.
|
|
872
|
+
return [map.harness, map.custodian, map.operator, ...Object.values(map.sandbox ?? {})].filter(
|
|
824
873
|
(ref): ref is string => typeof ref === "string",
|
|
825
874
|
);
|
|
826
875
|
}
|
|
@@ -872,6 +921,97 @@ async function sweepAfterConverge(
|
|
|
872
921
|
}
|
|
873
922
|
}
|
|
874
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
|
+
|
|
875
1015
|
/** The one delivery failure that is a configuration error rather than a docker one (ADR-0019). */
|
|
876
1016
|
function undeliverable(context: string): Error {
|
|
877
1017
|
return new Error(
|
|
@@ -955,7 +1095,7 @@ async function verifyRunningImage(
|
|
|
955
1095
|
// thing serving — the question is what the cluster runs now, not what it is done running.
|
|
956
1096
|
const live = pods.filter((p) => !p.metadata.deletionTimestamp);
|
|
957
1097
|
// `container` narrows a multi-container pod to the one this layer's image claim is about
|
|
958
|
-
// (the Instance Harness pod also carries the
|
|
1098
|
+
// (the Instance Harness pod also carries the Custodian, which is not this check's business).
|
|
959
1099
|
const stale = live.filter((p) =>
|
|
960
1100
|
p.spec.containers.some((c) => (container === undefined || c.name === container) && c.image !== image),
|
|
961
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);
|