@jr2/cli 0.1.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/LICENSE +21 -0
- package/README.md +18 -0
- package/bin/jr2.js +38 -0
- package/manifests/operator.yaml +6669 -0
- package/package.json +56 -0
- package/src/build.ts +1551 -0
- package/src/cli.ts +110 -0
- package/src/client.ts +219 -0
- package/src/commands/down.ts +104 -0
- package/src/commands/gc.ts +73 -0
- package/src/commands/init.ts +238 -0
- package/src/commands/kit.ts +141 -0
- package/src/commands/logs.ts +50 -0
- package/src/commands/run.ts +64 -0
- package/src/commands/runs.ts +19 -0
- package/src/commands/send.ts +83 -0
- package/src/commands/status.ts +90 -0
- package/src/commands/up.ts +1402 -0
- package/src/deploy.ts +592 -0
- package/src/env.ts +68 -0
- package/src/index.ts +11 -0
- package/src/instance.ts +105 -0
- package/src/kube.ts +809 -0
- package/src/nodes.ts +74 -0
- package/src/output.ts +211 -0
- package/src/repo-sweep.ts +134 -0
- package/src/run-id.ts +85 -0
- package/src/sse.ts +41 -0
- package/src/sweep.ts +232 -0
- package/src/typecheck.ts +75 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Top-level dispatch (ADR-0009). `argv[0]` is the verb; the rest is handed to that command, which does
|
|
2
|
+
// its own `parseArgs`. `main` returns an exit code (the bin sets `process.exitCode`) and never throws:
|
|
3
|
+
// a command error becomes an `error: …` line on stderr + code 1. Codes: 0 ok, 1 runtime error, 2 usage.
|
|
4
|
+
|
|
5
|
+
import { activity, defaultIo, type Io } from "./output.ts";
|
|
6
|
+
import { JR2HttpError } from "./client.ts";
|
|
7
|
+
import { loadDotenv } from "./env.ts";
|
|
8
|
+
import { init } from "./commands/init.ts";
|
|
9
|
+
import { run } from "./commands/run.ts";
|
|
10
|
+
import { runs } from "./commands/runs.ts";
|
|
11
|
+
import { status } from "./commands/status.ts";
|
|
12
|
+
import { logs } from "./commands/logs.ts";
|
|
13
|
+
import { send } from "./commands/send.ts";
|
|
14
|
+
import { up } from "./commands/up.ts";
|
|
15
|
+
import { down } from "./commands/down.ts";
|
|
16
|
+
import { gc } from "./commands/gc.ts";
|
|
17
|
+
import { kit } from "./commands/kit.ts";
|
|
18
|
+
|
|
19
|
+
const USAGE = `jr2 — orchestrate agentic workflows (ADR-0009)
|
|
20
|
+
|
|
21
|
+
usage: jr2 <command> [args]
|
|
22
|
+
|
|
23
|
+
init [dir] [--name <n>] scaffold a new instance folder
|
|
24
|
+
up [--yes] [--force] converge the current kube context to this instance (ADR-0019)
|
|
25
|
+
down [--all] remove the instance from the cluster (--all: operator too)
|
|
26
|
+
gc [--dry-run] [--repo-ttl 7d] remove jr2's images that no live instance names (ADR-0039), and
|
|
27
|
+
Repo resources no Machine binds and no run attached lately (ADR-0051)
|
|
28
|
+
kit push <registry> mirror the published kit images into a registry (ADR-0044)
|
|
29
|
+
run <workflow> [--input <json>] start a run; stream activity, print terminal result
|
|
30
|
+
[--detach] ...or just print the runId and return
|
|
31
|
+
runs list live runs
|
|
32
|
+
status [runId] print a run's current status (read-through),
|
|
33
|
+
or the instance's Repos, per node, when given none (ADR-0048/0051)
|
|
34
|
+
logs <runId> [-f] replay a run's status; -f to follow until it settles
|
|
35
|
+
send <runId> --event CANCEL abandon a live run
|
|
36
|
+
send <runId> --gate <gate> --event <name> [--input <json>]
|
|
37
|
+
deliver a workflow event to an open gate
|
|
38
|
+
|
|
39
|
+
run ids: any <runId> above may be abbreviated to a unique prefix (4+ chars, git-style);
|
|
40
|
+
an ambiguous prefix lists the candidates and fails rather than guessing
|
|
41
|
+
|
|
42
|
+
global (run verbs): -n/--namespace <ns>, --context <ctx> address the deployment (ADR-0019);
|
|
43
|
+
--url <u> / JR2_URL attaches to a specific orchestrator (skips kube entirely)`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The catch-all half of skew reporting: a route-shaped failure gets ONE extra line naming what the
|
|
47
|
+
* instance says it is. Deliberately not a diagnosis — `run-id.ts` diagnoses the one case we can
|
|
48
|
+
* prove, and this covers the routes nobody has thought to special-case yet.
|
|
49
|
+
*
|
|
50
|
+
* Scoped to 404/405 because those are the codes a MISSING route produces; a 400/401/403/409 is the
|
|
51
|
+
* instance understanding the request and refusing it, where naming the version would be noise. And
|
|
52
|
+
* it says nothing when the instance reports no version at all, since a host-booted fixture process
|
|
53
|
+
* (the e2e tier's) legitimately has neither version nor hash to report.
|
|
54
|
+
*/
|
|
55
|
+
function skewNote(err: unknown): string | undefined {
|
|
56
|
+
if (!(err instanceof JR2HttpError)) return undefined;
|
|
57
|
+
if (err.status !== 404 && err.status !== 405) return undefined;
|
|
58
|
+
const { version, hash } = err.instance ?? {};
|
|
59
|
+
if (!version) return undefined;
|
|
60
|
+
const id = hash ? `${version} (${hash})` : version;
|
|
61
|
+
return ` instance: ${id} — if it predates this CLI, \`jr2 up\` converges the cluster to this kit`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function main(argv: string[], io: Io = defaultIo): Promise<number> {
|
|
65
|
+
const [cmd, ...rest] = argv;
|
|
66
|
+
try {
|
|
67
|
+
// Before any verb, so the instance's `.env` is in `process.env` by the time a command imports
|
|
68
|
+
// `jr2.config.ts` (whose deployment-varying values are read from there — ADR-0019).
|
|
69
|
+
loadDotenv(io);
|
|
70
|
+
switch (cmd) {
|
|
71
|
+
case "init":
|
|
72
|
+
return await init(rest, io);
|
|
73
|
+
case "run":
|
|
74
|
+
return await run(rest, io);
|
|
75
|
+
case "runs":
|
|
76
|
+
return await runs(rest, io);
|
|
77
|
+
case "status":
|
|
78
|
+
return await status(rest, io);
|
|
79
|
+
case "logs":
|
|
80
|
+
return await logs(rest, io);
|
|
81
|
+
case "send":
|
|
82
|
+
return await send(rest, io);
|
|
83
|
+
case "up":
|
|
84
|
+
return await up(rest, io);
|
|
85
|
+
case "down":
|
|
86
|
+
return await down(rest, io);
|
|
87
|
+
case "gc":
|
|
88
|
+
return await gc(rest, io);
|
|
89
|
+
case "kit":
|
|
90
|
+
return await kit(rest, io);
|
|
91
|
+
case "help":
|
|
92
|
+
case "--help":
|
|
93
|
+
case "-h":
|
|
94
|
+
activity(io, USAGE);
|
|
95
|
+
return 0;
|
|
96
|
+
case undefined:
|
|
97
|
+
activity(io, USAGE);
|
|
98
|
+
return 2;
|
|
99
|
+
default:
|
|
100
|
+
activity(io, `unknown command: ${cmd}`);
|
|
101
|
+
activity(io, USAGE);
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
activity(io, `error: ${err instanceof Error ? err.message : String(err)}`);
|
|
106
|
+
const note = skewNote(err);
|
|
107
|
+
if (note) activity(io, note);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// The shared HTTP client every `jr2` verb sits on (ADR-0009). One thin class over the orchestrator's
|
|
2
|
+
// REST + SSE surface (`createApp`) — the SAME wire the deployed orchestrator serves, so `jr2` against
|
|
3
|
+
// the e2e tier's host-booted fixture and against a cluster are one code path. It carries no folder/CLI concerns (those
|
|
4
|
+
// live in `instance.ts`); it is just "talk to a base URL".
|
|
5
|
+
//
|
|
6
|
+
// `fetchImpl` is injectable so tests drive it with a hono `app.request` (no socket) the same way the
|
|
7
|
+
// orchestrator's own http tests do; in production it defaults to the global `fetch`.
|
|
8
|
+
|
|
9
|
+
import type { MachineDoc, RepoStatus } from "@jr2/orchestrator";
|
|
10
|
+
import { parseSSE } from "./sse.ts";
|
|
11
|
+
|
|
12
|
+
/** A run's current observable state — mirrors the orchestrator's `RunStatus` (run-host.ts). */
|
|
13
|
+
export type RunStatus = {
|
|
14
|
+
runId: string;
|
|
15
|
+
workflow: string;
|
|
16
|
+
instanceId: string;
|
|
17
|
+
status: string;
|
|
18
|
+
value: unknown;
|
|
19
|
+
context: unknown;
|
|
20
|
+
/** Why the host set this status, for statuses the Machine did not choose — `drifted` says the
|
|
21
|
+
* workflow changed shape since the run was saved, and names both fingerprints (ADR-0030). */
|
|
22
|
+
reason?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** One item on a run's observation feed — mirrors the orchestrator's `RunFeedEvent`. */
|
|
26
|
+
export type RunFeedEvent =
|
|
27
|
+
| { kind: "status"; status: RunStatus }
|
|
28
|
+
| { kind: "emit"; event: { type: string } & Record<string, unknown> }
|
|
29
|
+
| { kind: "retry"; child: string; attempt: number; reason: string };
|
|
30
|
+
|
|
31
|
+
/** A run-control event posted to a live run (ADR-0013): CANCEL is the vocabulary that is left. */
|
|
32
|
+
export type RunEvent = { type: string };
|
|
33
|
+
|
|
34
|
+
/** The subset of `fetch` the client uses. `globalThis.fetch` and hono's `app.request` both satisfy it. */
|
|
35
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
36
|
+
|
|
37
|
+
const JSON_HEADERS = { "content-type": "application/json" };
|
|
38
|
+
|
|
39
|
+
/** The prefix-resolution route. Named because `run-id.ts` matches on it to tell "no run by that
|
|
40
|
+
* prefix" apart from "this instance predates the route entirely" (ADR-0009). */
|
|
41
|
+
export const RESOLVE_PATH = "/runs/resolve";
|
|
42
|
+
|
|
43
|
+
/** What an orchestrator says it is (`GET /healthz`). Both fields are optional because an instance
|
|
44
|
+
* deployed before `/healthz` grew them answers a bare `{ ok: true }` — the absence is itself skew
|
|
45
|
+
* evidence, so it must not be an error. */
|
|
46
|
+
export type InstanceIdentity = { version?: string; hash?: string };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A non-2xx from the orchestrator, with the status code and path KEPT (ADR-0009). The bare `Error`
|
|
50
|
+
* this replaces flattened every failure to a message string, which made "unknown run" and "this
|
|
51
|
+
* instance has no such route" indistinguishable — the difference between a real 404 and version
|
|
52
|
+
* skew. Callers branch on `status`/`path`; `instance` is the best-effort identity probed at throw
|
|
53
|
+
* time, while the transport (a port-forward that the command's `finally` is about to close) is
|
|
54
|
+
* still open.
|
|
55
|
+
*/
|
|
56
|
+
export class JR2HttpError extends Error {
|
|
57
|
+
readonly status: number;
|
|
58
|
+
readonly path: string;
|
|
59
|
+
readonly instance?: InstanceIdentity;
|
|
60
|
+
|
|
61
|
+
constructor(message: string, status: number, path: string, instance?: InstanceIdentity) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "JR2HttpError";
|
|
64
|
+
this.status = status;
|
|
65
|
+
this.path = path;
|
|
66
|
+
this.instance = instance;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class JR2Client {
|
|
71
|
+
readonly baseUrl: string;
|
|
72
|
+
private readonly fetchImpl: FetchLike;
|
|
73
|
+
/** The Instance token (ADR-0013). The run and gate surfaces are authenticated; without it every
|
|
74
|
+
* call below is a 401. Absent only for an unauthenticated surface (a test's in-process app). */
|
|
75
|
+
private readonly token?: string;
|
|
76
|
+
|
|
77
|
+
constructor(baseUrl: string, fetchImpl: FetchLike = globalThis.fetch, token?: string) {
|
|
78
|
+
this.baseUrl = baseUrl.replace(/\/+$/, "");
|
|
79
|
+
this.fetchImpl = fetchImpl;
|
|
80
|
+
this.token = token;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Request headers: the bearer, plus whatever the call adds. */
|
|
84
|
+
private headers(extra: Record<string, string> = {}): Record<string, string> {
|
|
85
|
+
return this.token ? { ...extra, authorization: `Bearer ${this.token}` } : extra;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `GET /workflows` — names of the registered workflows. */
|
|
89
|
+
async workflows(): Promise<string[]> {
|
|
90
|
+
return (await this.json(await this.fetchImpl(`${this.baseUrl}/workflows`), "/workflows")) as string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `GET /workflows/:name/machine` — the workflow's Machine as the Console's DTO. */
|
|
94
|
+
async machine(workflow: string): Promise<MachineDoc> {
|
|
95
|
+
const res = await this.fetchImpl(`${this.baseUrl}/workflows/${encodeURIComponent(workflow)}/machine`);
|
|
96
|
+
return (await this.json(res, "/workflows/:name/machine")) as MachineDoc;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `POST /workflows/:name/runs` — start a run; unknown workflow → 404 → throws. */
|
|
100
|
+
async start(workflow: string, input: Record<string, unknown> = {}): Promise<{ runId: string; instanceId: string }> {
|
|
101
|
+
const res = await this.fetchImpl(`${this.baseUrl}/workflows/${encodeURIComponent(workflow)}/runs`, {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: this.headers(JSON_HEADERS),
|
|
104
|
+
body: JSON.stringify(input),
|
|
105
|
+
});
|
|
106
|
+
return (await this.json(res, "/workflows/:name/runs")) as { runId: string; instanceId: string };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** `GET /runs` — every live run's status. */
|
|
110
|
+
async list(): Promise<RunStatus[]> {
|
|
111
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs`, { headers: this.headers() });
|
|
112
|
+
return (await this.json(res, "/runs")) as RunStatus[];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `GET /repos` — whether the instance has a data plane, and every Repo resource as the cluster
|
|
116
|
+
* reports it (ADR-0048/0051): per node, present or not, synced or not, with git's own error. The
|
|
117
|
+
* cache agent keeps retrying on its own, so this is a snapshot of a moving thing. What
|
|
118
|
+
* `jr2 status` reports when it is given no run. */
|
|
119
|
+
async repos(): Promise<{ dataPlane: boolean; repos: RepoStatus[] }> {
|
|
120
|
+
const res = await this.fetchImpl(`${this.baseUrl}/repos`, { headers: this.headers() });
|
|
121
|
+
return (await this.json(res, "/repos")) as { dataPlane: boolean; repos: RepoStatus[] };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `GET /runs/resolve?prefix=` — run ids sharing a prefix, live and settled. The wire half of
|
|
125
|
+
* abbreviated run ids; the policy (floor, uuid fast path, ambiguity) lives in `run-id.ts`. */
|
|
126
|
+
async candidates(prefix: string): Promise<{ runIds: string[]; truncated: boolean }> {
|
|
127
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs/resolve?prefix=${encodeURIComponent(prefix)}`, {
|
|
128
|
+
headers: this.headers(),
|
|
129
|
+
});
|
|
130
|
+
const body = (await this.json(res, RESOLVE_PATH)) as { runIds: string[]; truncated: boolean };
|
|
131
|
+
return { runIds: body.runIds, truncated: body.truncated };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** `GET /runs/:runId` (read-through) — terminal runs included; a genuinely unknown run → undefined. */
|
|
135
|
+
async read(runId: string): Promise<RunStatus | undefined> {
|
|
136
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}`, {
|
|
137
|
+
headers: this.headers(),
|
|
138
|
+
});
|
|
139
|
+
if (res.status === 404) return undefined;
|
|
140
|
+
return (await this.json(res, "/runs/:runId")) as RunStatus;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `POST /runs/:runId/gates/:gate/events` — deliver a workflow-defined event to an open gate
|
|
144
|
+
* (ADR-0011). The body is `{ type, ...input }`, validated against the event's schema host-side;
|
|
145
|
+
* an unknown gate (404) or rejected payload (400) surfaces as a throw. */
|
|
146
|
+
async sendToGate(runId: string, gate: string, event: { type: string } & Record<string, unknown>): Promise<void> {
|
|
147
|
+
const res = await this.fetchImpl(
|
|
148
|
+
`${this.baseUrl}/runs/${encodeURIComponent(runId)}/gates/${encodeURIComponent(gate)}/events`,
|
|
149
|
+
{ method: "POST", headers: this.headers(JSON_HEADERS), body: JSON.stringify(event) },
|
|
150
|
+
);
|
|
151
|
+
await this.json(res, "/runs/:runId/gates/:gate/events"); // surface { error }; ignore { ok:true }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** `POST /runs/:runId/events` — feed one run-control event into a live run. */
|
|
155
|
+
async send(runId: string, event: RunEvent): Promise<void> {
|
|
156
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/events`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: this.headers(JSON_HEADERS),
|
|
159
|
+
body: JSON.stringify(event),
|
|
160
|
+
});
|
|
161
|
+
await this.json(res, "/runs/:runId/events"); // surface { error }; ignore { ok:true }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `GET /runs/:runId/events` — the SSE feed as an async-iterable of `RunFeedEvent`. The current status
|
|
166
|
+
* is replayed first (attach), then deltas + author `emit`s until the terminal status (or the consumer
|
|
167
|
+
* breaks, which cancels the stream). A settled run streams its final status once and ends.
|
|
168
|
+
*/
|
|
169
|
+
async *events(runId: string): AsyncGenerator<RunFeedEvent> {
|
|
170
|
+
const res = await this.fetchImpl(`${this.baseUrl}/runs/${encodeURIComponent(runId)}/events`, {
|
|
171
|
+
headers: this.headers({ accept: "text/event-stream" }),
|
|
172
|
+
});
|
|
173
|
+
if (res.status === 404) {
|
|
174
|
+
throw new JR2HttpError(`no run "${runId}"`, 404, "/runs/:runId/events", await this.identify());
|
|
175
|
+
}
|
|
176
|
+
if (!res.body) return;
|
|
177
|
+
for await (const frame of parseSSE(res.body)) {
|
|
178
|
+
if (frame.event === "emit") {
|
|
179
|
+
yield { kind: "emit", event: JSON.parse(frame.data) as { type: string } & Record<string, unknown> };
|
|
180
|
+
} else if (frame.event === "retry") {
|
|
181
|
+
yield { kind: "retry", ...(JSON.parse(frame.data) as { child: string; attempt: number; reason: string }) };
|
|
182
|
+
} else if (frame.event === "status") {
|
|
183
|
+
yield { kind: "status", status: JSON.parse(frame.data) as RunStatus };
|
|
184
|
+
}
|
|
185
|
+
// Any other frame (a Turn marker — ADR-0023 — or a kind this CLI predates) is skipped, not
|
|
186
|
+
// misread as a status: `jr2 logs -f` decides "settled" off `status.status`, and a marker
|
|
187
|
+
// parsed as a status would end the follow mid-run.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* `GET /healthz` — what this orchestrator says it is. Unauthenticated (it is the readiness probe),
|
|
193
|
+
* best-effort by contract: an unreachable or older instance answers `undefined`/`{}` rather than
|
|
194
|
+
* throwing, because this is only ever called to EXPLAIN another failure and must never replace it.
|
|
195
|
+
*/
|
|
196
|
+
async identify(): Promise<InstanceIdentity | undefined> {
|
|
197
|
+
try {
|
|
198
|
+
const res = await this.fetchImpl(`${this.baseUrl}/healthz`);
|
|
199
|
+
if (!res.ok) return undefined;
|
|
200
|
+
const body = (await res.json()) as InstanceIdentity;
|
|
201
|
+
return { version: body.version, hash: body.hash };
|
|
202
|
+
} catch {
|
|
203
|
+
return undefined; // the probe is a courtesy; its failure is not the user's error
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Parse a JSON response, turning a non-2xx `{ error }` body into a thrown `JR2HttpError`. */
|
|
208
|
+
private async json(res: Response, path: string): Promise<unknown> {
|
|
209
|
+
const text = await res.text();
|
|
210
|
+
const body = text ? JSON.parse(text) : undefined;
|
|
211
|
+
if (!res.ok) {
|
|
212
|
+
const message = (body as { error?: string } | undefined)?.error ?? `HTTP ${res.status}`;
|
|
213
|
+
// Probe HERE, not at the catch site: by the time the error reaches `cli.ts` the command's
|
|
214
|
+
// `finally` has closed the port-forward, and there is nothing left to ask.
|
|
215
|
+
throw new JR2HttpError(message, res.status, path, await this.identify());
|
|
216
|
+
}
|
|
217
|
+
return body;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `jr2 down [--all] [-n <ns>] [--context <ctx>]` (ADR-0019): remove the instance from the cluster —
|
|
2
|
+
// its namespace and everything `jr2 up` converged into it. ALWAYS confirms (deleting a namespace
|
|
3
|
+
// takes the runs, the store PVC, and every live Sandbox with it). `--all` also uninstalls the
|
|
4
|
+
// per-cluster operator — only sane when this was the cluster's last instance, which is the
|
|
5
|
+
// caller's judgment, not derivable here.
|
|
6
|
+
//
|
|
7
|
+
// It also sweeps IMAGES — on the host daemon that built them and, on kind, on every node (ADR-0039).
|
|
8
|
+
// Content addressing means ten Dockerfile iterations leave ten full images per store — invisible to
|
|
9
|
+
// `kubectl`, on the developer's own disk, and discovered at 100% full rather than at the moment
|
|
10
|
+
// anyone would think to pass a flag. Hence: by default, not behind a flag.
|
|
11
|
+
//
|
|
12
|
+
// The MECHANISM is the delete that just happened, not a name match: with this instance's namespace
|
|
13
|
+
// gone, its image map, its Sandboxes, and its pods are gone with it, so its images are unreachable
|
|
14
|
+
// by construction — while every other instance's roots still protect everything they share, kit
|
|
15
|
+
// refs included. Nothing here parses a tag.
|
|
16
|
+
//
|
|
17
|
+
// What the namespace delete does NOT reclaim: the node cache directories under
|
|
18
|
+
// `/var/lib/jr2/<namespace>/repos` (ADR-0051). The cache agent evicts a Repo's copy on its resource's
|
|
19
|
+
// deletion, but the DaemonSet dies with the namespace before it can act on the Repos going with it,
|
|
20
|
+
// so the bare clones stay on each node. They are inert — nothing mounts or refreshes them — and
|
|
21
|
+
// bounded by the node's disk; on kind they live inside the node container and go with the cluster.
|
|
22
|
+
|
|
23
|
+
import { basename } from "node:path";
|
|
24
|
+
import { parseArgs } from "node:util";
|
|
25
|
+
import { loadConfig } from "@jr2/orchestrator";
|
|
26
|
+
import { pnpmDockerBuild } from "../build.ts";
|
|
27
|
+
import { KIT_VERSION, LABEL_INSTANCE, operatorManifest } from "../deploy.ts";
|
|
28
|
+
import { resolveRoot } from "../instance.ts";
|
|
29
|
+
import { kubectlAdmin } from "../kube.ts";
|
|
30
|
+
import { activity, confirmOrBail, type Io } from "../output.ts";
|
|
31
|
+
import { sweepImages } from "../sweep.ts";
|
|
32
|
+
|
|
33
|
+
export async function down(args: string[], io: Io): Promise<number> {
|
|
34
|
+
const { values } = parseArgs({
|
|
35
|
+
args,
|
|
36
|
+
allowPositionals: true,
|
|
37
|
+
strict: false,
|
|
38
|
+
options: {
|
|
39
|
+
all: { type: "boolean" },
|
|
40
|
+
yes: { type: "boolean" },
|
|
41
|
+
namespace: { type: "string", short: "n" },
|
|
42
|
+
context: { type: "string" },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const root = resolveRoot(io.cwd);
|
|
47
|
+
const config = (await loadConfig(root)) ?? {};
|
|
48
|
+
const name = config.name ?? basename(root);
|
|
49
|
+
const namespace = (values.namespace as string | undefined) ?? name;
|
|
50
|
+
const kube = io.kubeAdmin ?? kubectlAdmin;
|
|
51
|
+
const context = (values.context as string | undefined) ?? (await kube.context());
|
|
52
|
+
if (!context) throw new Error("no kube context — nothing to remove from");
|
|
53
|
+
const ctx = values.context ? { context: values.context as string } : {};
|
|
54
|
+
|
|
55
|
+
const ns = await kube.getJson({ kind: "namespace", name: namespace, ...ctx });
|
|
56
|
+
const owner = ns?.metadata.labels?.[LABEL_INSTANCE];
|
|
57
|
+
if (!ns || !owner) {
|
|
58
|
+
activity(io, `not deployed here — namespace "${namespace}" on ${context} holds no jr2 instance`);
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
if (owner !== name) {
|
|
62
|
+
activity(io, `refusing: namespace "${namespace}" on ${context} belongs to another instance ("${owner}")`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const scope = values.all ? " AND the per-cluster operator" : "";
|
|
67
|
+
const ok =
|
|
68
|
+
values.yes === true ||
|
|
69
|
+
(await confirmOrBail(
|
|
70
|
+
io,
|
|
71
|
+
`remove instance "${name}" from context ${context} (delete namespace "${namespace}"${scope})?`,
|
|
72
|
+
));
|
|
73
|
+
if (!ok) {
|
|
74
|
+
activity(io, "aborted — nothing was changed");
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
activity(io, `deleting namespace "${namespace}" (runs, store, and Sandboxes go with it)`);
|
|
79
|
+
await kube.deleteObject({ kind: "Namespace", name: namespace, ...ctx });
|
|
80
|
+
|
|
81
|
+
// BEFORE the sweep, not after: while the operator Deployment stands, its pod is a live root and
|
|
82
|
+
// the operator image would survive its own uninstall.
|
|
83
|
+
if (values.all) {
|
|
84
|
+
activity(io, "uninstalling the operator (jr2-system)");
|
|
85
|
+
// The image ref doesn't matter for a delete-by-manifest; the object names do.
|
|
86
|
+
await kube.deleteManifest({ manifest: await operatorManifest(`jr2-operator:${KIT_VERSION}`), ...ctx });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// AFTER both deletes, which is the whole mechanism (see the module doc): `deleteObject` waits, so
|
|
90
|
+
// this instance's roots are gone before the roots read runs. No grace — a namespace that no
|
|
91
|
+
// longer exists has no propagation window to lose a provision in. `kubectl delete -f` waits on
|
|
92
|
+
// the operator Deployment but not on its pods, so a terminating operator pod can still hold its
|
|
93
|
+
// image one more round; the next `jr2 gc` collects it, and excluding terminating pods instead
|
|
94
|
+
// would let a sweep take an image out from under a mid-roll one.
|
|
95
|
+
try {
|
|
96
|
+
await sweepImages({ io, build: io.build ?? pnpmDockerBuild, kube, context, ctx });
|
|
97
|
+
} catch (err) {
|
|
98
|
+
// A warning, never a non-zero exit: the instance IS removed, which is what `down` promised.
|
|
99
|
+
activity(io, `image sweep failed (${err instanceof Error ? err.message : err}) — the instance is still removed`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
activity(io, "removed");
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `jr2 gc [--dry-run] [--repo-ttl <ttl>] [--context <ctx>]` (ADR-0039, ADR-0051): the two
|
|
2
|
+
// reachability sweeps, run off-cycle. An escape hatch for "disk is full now", not a step in any
|
|
3
|
+
// workflow — `jr2 up` and `jr2 down` already sweep images at the two moments the root set moves, and
|
|
4
|
+
// this is the same sweep with no converge attached, plus the one sweep only this verb runs: Repo
|
|
5
|
+
// resources no registered Machine binds and no run has attached within `--repo-ttl` (default 7d),
|
|
6
|
+
// whose deletion is what lets each node's cache agent evict its copy.
|
|
7
|
+
//
|
|
8
|
+
// It never confirms. By construction it removes only images jr2 built (the `jr2.dev/kind` stamp is
|
|
9
|
+
// the ownership gate) and only those no live root names, and only Repo resources the Orchestrator's
|
|
10
|
+
// own labels and clocks say nothing wants, so there is nothing for a human to weigh: the question
|
|
11
|
+
// "is this needed?" is answered by the cluster, not by the operator of the CLI.
|
|
12
|
+
//
|
|
13
|
+
// It takes no `-n`. The keep set is cluster-wide by definition — a ref another instance's image map
|
|
14
|
+
// or pod names is not garbage — so a namespace flag would narrow nothing and mean nothing. And it
|
|
15
|
+
// resolves no instance folder: "disk is full now" has to work from anywhere, and a kube context is
|
|
16
|
+
// the only address this command needs.
|
|
17
|
+
|
|
18
|
+
import { parseArgs } from "node:util";
|
|
19
|
+
import { pnpmDockerBuild } from "../build.ts";
|
|
20
|
+
import { kubectlAdmin } from "../kube.ts";
|
|
21
|
+
import { activity, type Io } from "../output.ts";
|
|
22
|
+
import { DEFAULT_REPO_TTL, parseRepoTtl, sweepRepos } from "../repo-sweep.ts";
|
|
23
|
+
import { sweepImages } from "../sweep.ts";
|
|
24
|
+
|
|
25
|
+
export async function gc(args: string[], io: Io): Promise<number> {
|
|
26
|
+
const { values } = parseArgs({
|
|
27
|
+
args,
|
|
28
|
+
allowPositionals: true,
|
|
29
|
+
strict: false,
|
|
30
|
+
options: {
|
|
31
|
+
"dry-run": { type: "boolean" },
|
|
32
|
+
"repo-ttl": { type: "string" },
|
|
33
|
+
context: { type: "string" },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const ttl = (values["repo-ttl"] as string | undefined) ?? DEFAULT_REPO_TTL;
|
|
37
|
+
// Refused before anything is read: a TTL that does not parse must not become "sweep now".
|
|
38
|
+
parseRepoTtl(ttl);
|
|
39
|
+
|
|
40
|
+
const kube = io.kubeAdmin ?? kubectlAdmin;
|
|
41
|
+
const context = (values.context as string | undefined) ?? (await kube.context());
|
|
42
|
+
if (!context) {
|
|
43
|
+
throw new Error("no kube context — `jr2 gc` decides what is garbage by asking a cluster what it still needs");
|
|
44
|
+
}
|
|
45
|
+
const ctx = values.context ? { context: values.context as string } : {};
|
|
46
|
+
const dryRun = values["dry-run"] === true;
|
|
47
|
+
|
|
48
|
+
activity(
|
|
49
|
+
io,
|
|
50
|
+
`jr2 gc — images on ${context} that no live root names${dryRun ? " (dry run: nothing is removed)" : ""}`,
|
|
51
|
+
);
|
|
52
|
+
try {
|
|
53
|
+
await sweepImages({ io, build: io.build ?? pnpmDockerBuild, kube, context, ctx, dryRun });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// Fail closed and say so: a keep set assembled from a partial roots read is not a smaller one,
|
|
56
|
+
// it is a wrong one, and acting on it would delete another instance's images. Unlike `up` and
|
|
57
|
+
// `down` — where the sweep is a courtesy on top of a job that already succeeded — the sweep IS
|
|
58
|
+
// this command, so a sweep that took nothing is a failed run.
|
|
59
|
+
activity(io, `error: the roots could not be read (${err instanceof Error ? err.message : err})`);
|
|
60
|
+
activity(io, " nothing was swept — a keep set missing one root would delete images the cluster still needs");
|
|
61
|
+
return 1;
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
await sweepRepos({ io, kube, ctx, ttl, dryRun });
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// A short read under-collects and nothing more — but "swept nothing" has to be the truth, not a
|
|
67
|
+
// partial list narrated as the whole, and a verb that promised a sweep and could not look is a
|
|
68
|
+
// failed run.
|
|
69
|
+
activity(io, `error: the Repo resources could not be read (${err instanceof Error ? err.message : err})`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
return 0;
|
|
73
|
+
}
|