@runuai/host 0.9.155 → 0.9.157
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/lib/compose-network-recovery.ts +283 -0
- package/lib/credentials-activation-authority.ts +25 -10
- package/lib/orchestrator.ts +35 -0
- package/lib/standard-image.ts +18 -0
- package/lib/task-environment/self.ts +19 -5
- package/lib/task-files-machine-retirement-outbox.ts +521 -0
- package/lib/task-files-machine-retirement.ts +33 -5
- package/lib/task-machine-credentials-activation.ts +93 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +21 -2
- package/src/cloud-credentials.ts +9 -0
- package/src/index.ts +53 -1
- package/src/main.ts +137 -12
- package/src/protocol.ts +106 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded recovery for Docker's local-network address pool.
|
|
3
|
+
*
|
|
4
|
+
* Compose allocates one bridge network per local task. A task that fails after
|
|
5
|
+
* creating its network can remain resumable in `error`, and Docker retains the
|
|
6
|
+
* empty network. Enough such networks eventually make every later task fail
|
|
7
|
+
* with "all predefined address pools have been fully subnetted".
|
|
8
|
+
*
|
|
9
|
+
* This sweep is deliberately narrower than `docker network prune`:
|
|
10
|
+
*
|
|
11
|
+
* - the exact task/project identity must be present in trusted host state;
|
|
12
|
+
* - the task must not be starting or running;
|
|
13
|
+
* - Docker must independently report no attached container; and
|
|
14
|
+
* - deletion happens inside that task's lifecycle slot, so Resume/Stop cannot
|
|
15
|
+
* interleave with the proof. The slot is taken only if it is free at once:
|
|
16
|
+
* the sweep never waits on a task's lifecycle.
|
|
17
|
+
*
|
|
18
|
+
* Any unreadable or malformed observation means "do nothing", never
|
|
19
|
+
* permission to delete.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { HostTask } from "../db/schema";
|
|
23
|
+
import { dockerCli, type DockerResult } from "./docker-exec";
|
|
24
|
+
import { getHostTask } from "./runtime-state";
|
|
25
|
+
import { isSafeHostTaskId } from "./task-identity";
|
|
26
|
+
import { isTerminal } from "./task-status";
|
|
27
|
+
import { parseTaskEnvironmentLocator } from "./task-environment/types";
|
|
28
|
+
|
|
29
|
+
const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
|
|
30
|
+
const NETWORK_LIST_FORMAT =
|
|
31
|
+
`{{.ID}}\t{{.Name}}\t{{.Label "${COMPOSE_PROJECT_LABEL}"}}`;
|
|
32
|
+
const MAX_NETWORK_LIST_BYTES = 2 * 1024 * 1024;
|
|
33
|
+
const MAX_NETWORK_INSPECT_BYTES = 1024 * 1024;
|
|
34
|
+
const DOCKER_TIMEOUT_MS = 10_000;
|
|
35
|
+
|
|
36
|
+
interface ComposeNetworkCandidate {
|
|
37
|
+
id: string;
|
|
38
|
+
name: string;
|
|
39
|
+
composeProject: string;
|
|
40
|
+
taskId: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ComposeNetworkLifecycle {
|
|
44
|
+
/** Runs `operation` in the task's lifecycle slot only if that slot is free
|
|
45
|
+
* at once (the orchestrator's `tryRunTaskLifecycle`); never waits. */
|
|
46
|
+
tryRunTaskLifecycle<T>(
|
|
47
|
+
taskId: string,
|
|
48
|
+
operation: () => Promise<T>,
|
|
49
|
+
): Promise<{ ran: true; value: T } | { ran: false }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ComposeNetworkRecoveryDeps {
|
|
53
|
+
docker(args: string[], options?: {
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
maxOutputBytes?: number;
|
|
56
|
+
}): Promise<DockerResult>;
|
|
57
|
+
task(taskId: string): HostTask | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ComposeNetworkRecoveryResult {
|
|
61
|
+
inventory: "complete" | "unavailable" | "invalid";
|
|
62
|
+
candidates: number;
|
|
63
|
+
removed: number;
|
|
64
|
+
/** Candidates left alone because their task's lifecycle slot was busy. */
|
|
65
|
+
busy: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const defaultDeps: ComposeNetworkRecoveryDeps = {
|
|
69
|
+
docker: dockerCli,
|
|
70
|
+
task: getHostTask,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function parseCandidate(
|
|
74
|
+
line: string,
|
|
75
|
+
): ComposeNetworkCandidate | "unrelated" | null {
|
|
76
|
+
const fields = line.split("\t");
|
|
77
|
+
if (fields.length !== 3) return null;
|
|
78
|
+
const [id, name, composeProject] = fields as [string, string, string];
|
|
79
|
+
if (!/^[a-f0-9]{12,64}$/.test(id)) return null;
|
|
80
|
+
if (!composeProject.startsWith("task-")) return "unrelated";
|
|
81
|
+
const taskId = composeProject.slice("task-".length);
|
|
82
|
+
if (!isSafeHostTaskId(taskId) || taskId !== taskId.toLowerCase()) return null;
|
|
83
|
+
if (name !== `${composeProject}_default`) return null;
|
|
84
|
+
return { id, name, composeProject, taskId };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseCandidates(stdout: string): ComposeNetworkCandidate[] | null {
|
|
88
|
+
const candidates: ComposeNetworkCandidate[] = [];
|
|
89
|
+
const ids = new Set<string>();
|
|
90
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
91
|
+
if (line.length === 0) continue;
|
|
92
|
+
const candidate = parseCandidate(line);
|
|
93
|
+
if (candidate === "unrelated") continue;
|
|
94
|
+
if (candidate === null || ids.has(candidate.id)) return null;
|
|
95
|
+
ids.add(candidate.id);
|
|
96
|
+
candidates.push(candidate);
|
|
97
|
+
}
|
|
98
|
+
return candidates;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function taskOwnsReclaimableNetwork(
|
|
102
|
+
task: HostTask | null,
|
|
103
|
+
candidate: ComposeNetworkCandidate,
|
|
104
|
+
): boolean {
|
|
105
|
+
if (
|
|
106
|
+
task === null ||
|
|
107
|
+
task.taskId !== candidate.taskId ||
|
|
108
|
+
(task.environmentProvider !== null &&
|
|
109
|
+
task.environmentProvider !== "docker-compose") ||
|
|
110
|
+
(task.statusMirror !== "error" &&
|
|
111
|
+
task.statusMirror !== "stopped" &&
|
|
112
|
+
!(typeof task.statusMirror === "string" && isTerminal(task.statusMirror)))
|
|
113
|
+
) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (task.composeProject === candidate.composeProject) return true;
|
|
117
|
+
if (!task.environmentLocator) return false;
|
|
118
|
+
try {
|
|
119
|
+
const locator = parseTaskEnvironmentLocator(task.environmentLocator);
|
|
120
|
+
if (
|
|
121
|
+
locator.schemaVersion !== 1 ||
|
|
122
|
+
locator.provider !== "docker-compose" ||
|
|
123
|
+
!locator.value ||
|
|
124
|
+
typeof locator.value !== "object" ||
|
|
125
|
+
Array.isArray(locator.value)
|
|
126
|
+
) {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
const value = locator.value as Record<string, unknown>;
|
|
130
|
+
return (
|
|
131
|
+
value.schemaVersion === 2 &&
|
|
132
|
+
value.taskId === candidate.taskId &&
|
|
133
|
+
value.composeProject === candidate.composeProject &&
|
|
134
|
+
value.containerName === `${candidate.composeProject}-app-1`
|
|
135
|
+
);
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function inspectedEmptyNetwork(
|
|
142
|
+
stdout: string,
|
|
143
|
+
candidate: ComposeNetworkCandidate,
|
|
144
|
+
): { fullId: string } | null {
|
|
145
|
+
let parsed: unknown;
|
|
146
|
+
try {
|
|
147
|
+
parsed = JSON.parse(stdout);
|
|
148
|
+
} catch {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
if (!Array.isArray(parsed) || parsed.length !== 1) return null;
|
|
152
|
+
const network = parsed[0];
|
|
153
|
+
if (!network || typeof network !== "object" || Array.isArray(network)) {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
const record = network as Record<string, unknown>;
|
|
157
|
+
const labels = record.Labels;
|
|
158
|
+
const containers = record.Containers;
|
|
159
|
+
if (
|
|
160
|
+
typeof record.Id !== "string" ||
|
|
161
|
+
!/^[a-f0-9]{64}$/.test(record.Id) ||
|
|
162
|
+
!record.Id.startsWith(candidate.id) ||
|
|
163
|
+
record.Name !== candidate.name ||
|
|
164
|
+
record.Driver !== "bridge" ||
|
|
165
|
+
record.Scope !== "local" ||
|
|
166
|
+
!labels ||
|
|
167
|
+
typeof labels !== "object" ||
|
|
168
|
+
Array.isArray(labels) ||
|
|
169
|
+
(labels as Record<string, unknown>)[COMPOSE_PROJECT_LABEL] !==
|
|
170
|
+
candidate.composeProject ||
|
|
171
|
+
!containers ||
|
|
172
|
+
typeof containers !== "object" ||
|
|
173
|
+
Array.isArray(containers) ||
|
|
174
|
+
Object.keys(containers).length !== 0
|
|
175
|
+
) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
return { fullId: record.Id };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function reclaimCandidate(
|
|
182
|
+
candidate: ComposeNetworkCandidate,
|
|
183
|
+
deps: ComposeNetworkRecoveryDeps,
|
|
184
|
+
): Promise<boolean> {
|
|
185
|
+
if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
const inspected = await deps.docker(
|
|
189
|
+
["network", "inspect", candidate.id],
|
|
190
|
+
{
|
|
191
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
192
|
+
maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
if (inspected.status !== 0 || inspected.outputTruncated) return false;
|
|
196
|
+
const empty = inspectedEmptyNetwork(inspected.stdout, candidate);
|
|
197
|
+
if (empty === null) return false;
|
|
198
|
+
|
|
199
|
+
// `.Containers` is the network's own endpoint inventory. Independently ask
|
|
200
|
+
// Docker's all-container inventory as a second positive emptiness proof.
|
|
201
|
+
const attached = await deps.docker(
|
|
202
|
+
["ps", "-aq", "--filter", `network=${empty.fullId}`],
|
|
203
|
+
{
|
|
204
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
205
|
+
maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
if (
|
|
209
|
+
attached.status !== 0 ||
|
|
210
|
+
attached.outputTruncated ||
|
|
211
|
+
attached.stdout.trim().length !== 0
|
|
212
|
+
) {
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const removed = await deps.docker(["network", "rm", empty.fullId], {
|
|
217
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
218
|
+
maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
|
|
219
|
+
});
|
|
220
|
+
return removed.status === 0 && !removed.outputTruncated;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Remove only positively-owned, empty task networks. This is best-effort host
|
|
225
|
+
* hygiene: an unavailable inventory never blocks a task that may still fit in
|
|
226
|
+
* Docker's pool, and it never authorizes deletion.
|
|
227
|
+
*/
|
|
228
|
+
export async function reclaimUnusedUaiComposeNetworks(
|
|
229
|
+
lifecycle: ComposeNetworkLifecycle,
|
|
230
|
+
deps: ComposeNetworkRecoveryDeps = defaultDeps,
|
|
231
|
+
): Promise<ComposeNetworkRecoveryResult> {
|
|
232
|
+
const listed = await deps.docker(
|
|
233
|
+
[
|
|
234
|
+
"network",
|
|
235
|
+
"ls",
|
|
236
|
+
"--filter",
|
|
237
|
+
`label=${COMPOSE_PROJECT_LABEL}`,
|
|
238
|
+
"--format",
|
|
239
|
+
NETWORK_LIST_FORMAT,
|
|
240
|
+
],
|
|
241
|
+
{
|
|
242
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
243
|
+
maxOutputBytes: MAX_NETWORK_LIST_BYTES,
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
if (listed.status !== 0 || listed.outputTruncated) {
|
|
247
|
+
return { inventory: "unavailable", candidates: 0, removed: 0, busy: 0 };
|
|
248
|
+
}
|
|
249
|
+
const candidates = parseCandidates(listed.stdout);
|
|
250
|
+
if (candidates === null) {
|
|
251
|
+
return { inventory: "invalid", candidates: 0, removed: 0, busy: 0 };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let removed = 0;
|
|
255
|
+
let busy = 0;
|
|
256
|
+
for (const candidate of candidates) {
|
|
257
|
+
// Avoid taking a live start's slot merely to rediscover in-slot that it
|
|
258
|
+
// is not reclaimable. This observation grants no authority; the complete
|
|
259
|
+
// ownership/status check is repeated after the lifecycle slot is held.
|
|
260
|
+
if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
// Never wait for the candidate's slot. A Resume marks its task `starting`
|
|
264
|
+
// only partway into its own slot, so the row can still read `error` while
|
|
265
|
+
// that slot is held, and waiting for it meant waiting out the whole
|
|
266
|
+
// start; a stuck operation on any stopped task would hold every later
|
|
267
|
+
// start here. A busy task's network is left for a later start's sweep.
|
|
268
|
+
const attempt = await lifecycle.tryRunTaskLifecycle(
|
|
269
|
+
candidate.taskId,
|
|
270
|
+
// The trusted row and Docker evidence are deliberately read only after
|
|
271
|
+
// entering the candidate task's lifecycle slot.
|
|
272
|
+
() => reclaimCandidate(candidate, deps),
|
|
273
|
+
);
|
|
274
|
+
if (!attempt.ran) busy += 1;
|
|
275
|
+
else if (attempt.value) removed += 1;
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
inventory: "complete",
|
|
279
|
+
candidates: candidates.length,
|
|
280
|
+
removed,
|
|
281
|
+
busy,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
@@ -114,7 +114,7 @@ export type ActivationAnswer = { ok: true } | { ok: false; error: string };
|
|
|
114
114
|
const RECORD_SCHEMA_VERSION = 1;
|
|
115
115
|
const STEPS: readonly ActivationStep[] = ["local", "cloud", "active"];
|
|
116
116
|
|
|
117
|
-
export interface ActivationAuthorityDeps {
|
|
117
|
+
export interface ActivationAuthorityDeps<Retirement = RetirementPlan> {
|
|
118
118
|
/** The durable record (non-secret), beside the runner's database. */
|
|
119
119
|
recordPath: string;
|
|
120
120
|
/** The credentials floor's file exists: the switch began, even with no
|
|
@@ -122,13 +122,17 @@ export interface ActivationAuthorityDeps {
|
|
|
122
122
|
floorClaimed: () => boolean;
|
|
123
123
|
/** Whether this runner moves to cloud credentials in this release. */
|
|
124
124
|
eligible: () => boolean;
|
|
125
|
+
/** A marked task machine is born behind the v1 fence: it may ask the
|
|
126
|
+
* cloud to activate, but must never expose a transient `legacy` mode or
|
|
127
|
+
* request a v0 lease while that answer is pending. */
|
|
128
|
+
initiallyPending?: () => boolean;
|
|
125
129
|
/** One phase asked of the cloud. Rejects when it could not be asked or
|
|
126
130
|
* answered (no bridge, a timeout): the next attempt asks again. */
|
|
127
131
|
ask: (phase: CredentialsActivationPhase) => Promise<ActivationAnswer>;
|
|
128
132
|
/** Admit no new start on the runner's own credentials (cloud-credential-host). */
|
|
129
133
|
pauseLegacyStarts: () => { drained: Promise<void>; resume(): void };
|
|
130
134
|
/** The retirement's plan: changes nothing, rejects with every blocker. */
|
|
131
|
-
planRetirement: () => Promise<
|
|
135
|
+
planRetirement: () => Promise<Retirement>;
|
|
132
136
|
/** Under the managed-operation lock, prove the next boot reads the record
|
|
133
137
|
* and fence standalone recovery, then call `record` to write it
|
|
134
138
|
* (managed-runtime.ts commitCredentialsSwitchLocally). */
|
|
@@ -140,7 +144,7 @@ export interface ActivationAuthorityDeps {
|
|
|
140
144
|
/** Carry the plan out (idempotent). `stopped` names each task whose
|
|
141
145
|
* runtime is gone, at its exact run epoch; `problem` says what could not
|
|
142
146
|
* be proven removed. */
|
|
143
|
-
retire: (plan:
|
|
147
|
+
retire: (plan: Retirement) => Promise<{ stopped: StoppedReport[]; problem: string | null }>;
|
|
144
148
|
/** Send each owed report again; returns those still owed (the moot ones
|
|
145
149
|
* dropped: a newer run of the task, or its row gone). */
|
|
146
150
|
reportStopped: (owed: readonly StoppedReport[]) => StoppedReport[];
|
|
@@ -165,8 +169,8 @@ export interface ActivationAuthorityDeps {
|
|
|
165
169
|
log: (line: string) => void;
|
|
166
170
|
}
|
|
167
171
|
|
|
168
|
-
export class CredentialsActivationAuthority {
|
|
169
|
-
readonly #deps: ActivationAuthorityDeps
|
|
172
|
+
export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
|
|
173
|
+
readonly #deps: ActivationAuthorityDeps<Retirement>;
|
|
170
174
|
#record: ActivationRecord | null;
|
|
171
175
|
/** `#record` is known to be durable (see the header). */
|
|
172
176
|
#durable = false;
|
|
@@ -179,13 +183,18 @@ export class CredentialsActivationAuthority {
|
|
|
179
183
|
#sweepsItself = false;
|
|
180
184
|
/** The record was `active` when this process started. */
|
|
181
185
|
readonly #activeAtBoot: boolean;
|
|
186
|
+
/** A fresh task-machine incarnation starts behind the v1 fence before it
|
|
187
|
+
* has a local activation record. Unlike an owned runner's reversible
|
|
188
|
+
* prepare, a cloud-marked incarnation must never fall back to v0. */
|
|
189
|
+
readonly #initiallyPending: boolean;
|
|
182
190
|
#advancing: Promise<void> | null = null;
|
|
183
191
|
#lastNote: string | null = null;
|
|
184
192
|
/** Leaving the runner's own credentials failed in this process. */
|
|
185
193
|
#stuck = false;
|
|
186
194
|
|
|
187
|
-
constructor(deps: ActivationAuthorityDeps) {
|
|
195
|
+
constructor(deps: ActivationAuthorityDeps<Retirement>) {
|
|
188
196
|
this.#deps = deps;
|
|
197
|
+
this.#initiallyPending = deps.initiallyPending?.() === true;
|
|
189
198
|
this.#record = readActivationRecord(deps.recordPath);
|
|
190
199
|
if (this.#record === null && deps.floorClaimed()) {
|
|
191
200
|
// Only the switch claims the floor, after its record: a floor with no
|
|
@@ -210,7 +219,9 @@ export class CredentialsActivationAuthority {
|
|
|
210
219
|
}
|
|
211
220
|
|
|
212
221
|
mode(): CredentialsMode {
|
|
213
|
-
if (this.#record === null)
|
|
222
|
+
if (this.#record === null) {
|
|
223
|
+
return this.#initiallyPending ? "pending" : "legacy";
|
|
224
|
+
}
|
|
214
225
|
return this.#record.step === "active" && (this.#announced || this.#announcing) ? "active" : "pending";
|
|
215
226
|
}
|
|
216
227
|
|
|
@@ -253,7 +264,7 @@ export class CredentialsActivationAuthority {
|
|
|
253
264
|
await this.#activate();
|
|
254
265
|
return;
|
|
255
266
|
}
|
|
256
|
-
let plan:
|
|
267
|
+
let plan: Retirement | null = null;
|
|
257
268
|
if (this.#record === null) {
|
|
258
269
|
if (!this.#deps.eligible()) return;
|
|
259
270
|
const prepared = await this.#deps.ask("prepare");
|
|
@@ -276,7 +287,11 @@ export class CredentialsActivationAuthority {
|
|
|
276
287
|
this.#record ??= readActivationRecord(this.#deps.recordPath);
|
|
277
288
|
if (this.#record === null) {
|
|
278
289
|
paused.resume();
|
|
279
|
-
this.#note(
|
|
290
|
+
this.#note(
|
|
291
|
+
this.#initiallyPending
|
|
292
|
+
? `the v1-required task machine stays pending, with no v0 fallback: ${message(error)}`
|
|
293
|
+
: `the runner stays on its own credentials, nothing changed: ${message(error)}`,
|
|
294
|
+
);
|
|
280
295
|
return;
|
|
281
296
|
}
|
|
282
297
|
}
|
|
@@ -395,7 +410,7 @@ export class CredentialsActivationAuthority {
|
|
|
395
410
|
|
|
396
411
|
/** The retirement, its reports owed durably before they are sent, and
|
|
397
412
|
* sent; rejects when anything could not be proven removed. */
|
|
398
|
-
async #retire(plan:
|
|
413
|
+
async #retire(plan: Retirement): Promise<void> {
|
|
399
414
|
const { stopped, problem } = await this.#deps.retire(plan);
|
|
400
415
|
const owed = new Map((this.#record?.stopped ?? []).map((report) => [report.taskId, report]));
|
|
401
416
|
for (const report of stopped) owed.set(report.taskId, report);
|
package/lib/orchestrator.ts
CHANGED
|
@@ -847,6 +847,35 @@ export class Orchestrator {
|
|
|
847
847
|
}
|
|
848
848
|
}
|
|
849
849
|
|
|
850
|
+
/** Run one task lifecycle mutation only if the task's slot is free right
|
|
851
|
+
* now: nothing running or queued for it, and its boot recovery finished.
|
|
852
|
+
* Never waits, so best-effort work on another task's resources (the
|
|
853
|
+
* task-network reclaim) cannot be held up by a long start or a stuck
|
|
854
|
+
* operation. The check and the claim happen in one synchronous step, so
|
|
855
|
+
* nothing can enter between them, and a `runTaskLifecycle` that arrives
|
|
856
|
+
* meanwhile queues behind this one as it would behind any other. */
|
|
857
|
+
async tryRunTaskLifecycle<T>(
|
|
858
|
+
taskId: string,
|
|
859
|
+
operation: () => Promise<T>,
|
|
860
|
+
): Promise<{ ran: true; value: T } | { ran: false }> {
|
|
861
|
+
if (this.taskLifecycleTails.has(taskId) || taskRecoveryPending(taskId)) {
|
|
862
|
+
return { ran: false };
|
|
863
|
+
}
|
|
864
|
+
let release!: () => void;
|
|
865
|
+
const tail = new Promise<void>((resolve) => {
|
|
866
|
+
release = resolve;
|
|
867
|
+
});
|
|
868
|
+
this.taskLifecycleTails.set(taskId, tail);
|
|
869
|
+
try {
|
|
870
|
+
return { ran: true, value: await operation() };
|
|
871
|
+
} finally {
|
|
872
|
+
release();
|
|
873
|
+
if (this.taskLifecycleTails.get(taskId) === tail) {
|
|
874
|
+
this.taskLifecycleTails.delete(taskId);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
850
879
|
/** Emit a host-originated system note into a task's channel (ADR-027). */
|
|
851
880
|
emitSystemNote(taskId: string, text: string): void {
|
|
852
881
|
this.emitHost({ kind: "system.note", taskId, text });
|
|
@@ -7481,6 +7510,12 @@ export function taskRecoveryComplete(taskId: string): Promise<void> {
|
|
|
7481
7510
|
return taskRecoveryBarriers.get(taskId)?.promise ?? Promise.resolve();
|
|
7482
7511
|
}
|
|
7483
7512
|
|
|
7513
|
+
/** Boot recovery has not yet finished with this task. Work that must not
|
|
7514
|
+
* wait (`tryRunTaskLifecycle`) skips the task rather than join its barrier. */
|
|
7515
|
+
export function taskRecoveryPending(taskId: string): boolean {
|
|
7516
|
+
return taskRecoveryBarriers.has(taskId);
|
|
7517
|
+
}
|
|
7518
|
+
|
|
7484
7519
|
/**
|
|
7485
7520
|
* Resolves once boot-time recovery has finished (starting the orchestrator —
|
|
7486
7521
|
* and with it recovery — if that hasn't happened yet). Never rejects; false
|
package/lib/standard-image.ts
CHANGED
|
@@ -714,6 +714,24 @@ export function standardImageVolumeMaintenanceCommandForTest(
|
|
|
714
714
|
`ensure_real_dir "$node_root" node-root; ` +
|
|
715
715
|
`ensure_real_dir "$shims_root" shims-root; ` +
|
|
716
716
|
`ensure_real_dir "$corepack_root" corepack-root; ` +
|
|
717
|
+
// Legacy images could seed individual shims as root even though the
|
|
718
|
+
// directory itself was later repaired. asdf 0.14 then prints permission
|
|
719
|
+
// errors while reshim quietly leaves the old executable behind; task
|
|
720
|
+
// materialization can appear successful but the next CLI launch is stale
|
|
721
|
+
// or non-executable. The trusted root maintenance transaction repairs only
|
|
722
|
+
// the flat, regular-file shim set before dropping to node for every asdf
|
|
723
|
+
// mutation. Anything else is ambiguous volume content and fails closed.
|
|
724
|
+
`repair_shim_metadata() { ` +
|
|
725
|
+
`unsafe_shim_entry="$(/usr/bin/find "$shims_root" -xdev -mindepth 1 ` +
|
|
726
|
+
`-maxdepth 1 ! -type f -print -quit)"; ` +
|
|
727
|
+
`[ -z "$unsafe_shim_entry" ] || fail unsafe-shim-entry; ` +
|
|
728
|
+
`/usr/bin/find "$shims_root" -xdev -mindepth 1 -maxdepth 1 -type f ` +
|
|
729
|
+
`-exec /usr/bin/chown node:node -- {} + ` +
|
|
730
|
+
`|| fail repair-shim-owner; ` +
|
|
731
|
+
`/usr/bin/find "$shims_root" -xdev -mindepth 1 -maxdepth 1 -type f ` +
|
|
732
|
+
`-exec /usr/bin/chmod 0755 -- {} + ` +
|
|
733
|
+
`|| fail repair-shim-mode; }; ` +
|
|
734
|
+
`repair_shim_metadata; ` +
|
|
717
735
|
`candidate_is_safe() { ` +
|
|
718
736
|
`lexical_candidate="$(/usr/bin/realpath -m -s -- "$candidate")" ` +
|
|
719
737
|
`|| return 1; [ "$lexical_candidate" = "$node_root/$default_node" ] ` +
|
|
@@ -61,9 +61,23 @@ import {
|
|
|
61
61
|
} from "./machine-environment";
|
|
62
62
|
import { SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH } from "./self-identity";
|
|
63
63
|
import { readBoundedFile, workspacePathParts } from "./workspace-files";
|
|
64
|
+
import { assertTaskFilesMachineProcessAllowed } from "../task-files-machine-retirement";
|
|
64
65
|
|
|
65
66
|
export { ETC_ENVIRONMENT_PATH, parseEtcEnvironment, SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH };
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A terminal Files failure latches before its synchronous handler runs. Check
|
|
70
|
+
* on both sides of a caller's own admission callback so that callback cannot
|
|
71
|
+
* trigger retirement and then let this launch repopulate the cgroup the
|
|
72
|
+
* handler just emptied. JavaScript runs the final check and node spawn in one
|
|
73
|
+
* turn, so no other callback can interleave between them.
|
|
74
|
+
*/
|
|
75
|
+
function admitTaskMachineCall(admit?: () => void): void {
|
|
76
|
+
assertTaskFilesMachineProcessAllowed();
|
|
77
|
+
admit?.();
|
|
78
|
+
assertTaskFilesMachineProcessAllowed();
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
/**
|
|
68
82
|
* Binds the view, then runs the rest of its argv. With no view yet (nothing
|
|
69
83
|
* pinned so far: a start whose CA failed, the proxy off), it first makes one,
|
|
@@ -281,7 +295,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
281
295
|
|
|
282
296
|
async readWorkspaceFile(path: string, options?: TaskEnvironmentReadOptions): Promise<Uint8Array | null> {
|
|
283
297
|
assertWorkspaceScopedPath(this.descriptor, path);
|
|
284
|
-
options?.admit
|
|
298
|
+
admitTaskMachineCall(options?.admit);
|
|
285
299
|
return readBoundedFile(this.descriptor.workspacePath, workspacePathParts(this.descriptor, path), options);
|
|
286
300
|
}
|
|
287
301
|
|
|
@@ -355,7 +369,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
355
369
|
refusePrivilegedExec(request);
|
|
356
370
|
const scoped = this.#withMachineEnv(request);
|
|
357
371
|
const { command, args, env } = this.#command(scoped, "interactive");
|
|
358
|
-
request.admit
|
|
372
|
+
admitTaskMachineCall(request.admit);
|
|
359
373
|
return capturedCliExec(this.#spawn, command, args, scoped, env);
|
|
360
374
|
}
|
|
361
375
|
|
|
@@ -363,7 +377,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
363
377
|
assertTaskEnvironmentProcessRequest(request);
|
|
364
378
|
const scoped = this.#withMachineEnv(request);
|
|
365
379
|
const { command, args, env } = this.#command(scoped, "interactive");
|
|
366
|
-
request.admit
|
|
380
|
+
admitTaskMachineCall(request.admit);
|
|
367
381
|
return streamingCliExec(this.#spawn, command, args, scoped, env);
|
|
368
382
|
}
|
|
369
383
|
|
|
@@ -374,7 +388,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
374
388
|
cwd: request.cwd ?? this.#value.workspacePath,
|
|
375
389
|
});
|
|
376
390
|
const { command, args, env } = this.#command(session, "interactive");
|
|
377
|
-
request.admit
|
|
391
|
+
admitTaskMachineCall(request.admit);
|
|
378
392
|
return streamingCliExec(this.#spawn, command, args, session, env);
|
|
379
393
|
}
|
|
380
394
|
|
|
@@ -387,7 +401,7 @@ class SelfTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
387
401
|
cwd: request.cwd ?? this.#value.workspacePath,
|
|
388
402
|
});
|
|
389
403
|
const { command, args, env } = this.#command(session, "detached");
|
|
390
|
-
request.admit
|
|
404
|
+
admitTaskMachineCall(request.admit);
|
|
391
405
|
return capturedCliExec(
|
|
392
406
|
this.#spawn,
|
|
393
407
|
command,
|