@runuai/host 0.9.155 → 0.9.156
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 +264 -0
- package/lib/standard-image.ts +18 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +21 -2
- package/src/index.ts +36 -0
|
@@ -0,0 +1,264 @@
|
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* Any unreadable or malformed observation means "do nothing", never
|
|
18
|
+
* permission to delete.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { HostTask } from "../db/schema";
|
|
22
|
+
import { dockerCli, type DockerResult } from "./docker-exec";
|
|
23
|
+
import { getHostTask } from "./runtime-state";
|
|
24
|
+
import { isSafeHostTaskId } from "./task-identity";
|
|
25
|
+
import { isTerminal } from "./task-status";
|
|
26
|
+
import { parseTaskEnvironmentLocator } from "./task-environment/types";
|
|
27
|
+
|
|
28
|
+
const COMPOSE_PROJECT_LABEL = "com.docker.compose.project";
|
|
29
|
+
const NETWORK_LIST_FORMAT =
|
|
30
|
+
`{{.ID}}\t{{.Name}}\t{{.Label "${COMPOSE_PROJECT_LABEL}"}}`;
|
|
31
|
+
const MAX_NETWORK_LIST_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
const MAX_NETWORK_INSPECT_BYTES = 1024 * 1024;
|
|
33
|
+
const DOCKER_TIMEOUT_MS = 10_000;
|
|
34
|
+
|
|
35
|
+
interface ComposeNetworkCandidate {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
composeProject: string;
|
|
39
|
+
taskId: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ComposeNetworkLifecycle {
|
|
43
|
+
runTaskLifecycle<T>(
|
|
44
|
+
taskId: string,
|
|
45
|
+
operation: () => Promise<T>,
|
|
46
|
+
): Promise<T>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ComposeNetworkRecoveryDeps {
|
|
50
|
+
docker(args: string[], options?: {
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
maxOutputBytes?: number;
|
|
53
|
+
}): Promise<DockerResult>;
|
|
54
|
+
task(taskId: string): HostTask | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ComposeNetworkRecoveryResult {
|
|
58
|
+
inventory: "complete" | "unavailable" | "invalid";
|
|
59
|
+
candidates: number;
|
|
60
|
+
removed: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultDeps: ComposeNetworkRecoveryDeps = {
|
|
64
|
+
docker: dockerCli,
|
|
65
|
+
task: getHostTask,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function parseCandidate(
|
|
69
|
+
line: string,
|
|
70
|
+
): ComposeNetworkCandidate | "unrelated" | null {
|
|
71
|
+
const fields = line.split("\t");
|
|
72
|
+
if (fields.length !== 3) return null;
|
|
73
|
+
const [id, name, composeProject] = fields as [string, string, string];
|
|
74
|
+
if (!/^[a-f0-9]{12,64}$/.test(id)) return null;
|
|
75
|
+
if (!composeProject.startsWith("task-")) return "unrelated";
|
|
76
|
+
const taskId = composeProject.slice("task-".length);
|
|
77
|
+
if (!isSafeHostTaskId(taskId) || taskId !== taskId.toLowerCase()) return null;
|
|
78
|
+
if (name !== `${composeProject}_default`) return null;
|
|
79
|
+
return { id, name, composeProject, taskId };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseCandidates(stdout: string): ComposeNetworkCandidate[] | null {
|
|
83
|
+
const candidates: ComposeNetworkCandidate[] = [];
|
|
84
|
+
const ids = new Set<string>();
|
|
85
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
86
|
+
if (line.length === 0) continue;
|
|
87
|
+
const candidate = parseCandidate(line);
|
|
88
|
+
if (candidate === "unrelated") continue;
|
|
89
|
+
if (candidate === null || ids.has(candidate.id)) return null;
|
|
90
|
+
ids.add(candidate.id);
|
|
91
|
+
candidates.push(candidate);
|
|
92
|
+
}
|
|
93
|
+
return candidates;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function taskOwnsReclaimableNetwork(
|
|
97
|
+
task: HostTask | null,
|
|
98
|
+
candidate: ComposeNetworkCandidate,
|
|
99
|
+
): boolean {
|
|
100
|
+
if (
|
|
101
|
+
task === null ||
|
|
102
|
+
task.taskId !== candidate.taskId ||
|
|
103
|
+
(task.environmentProvider !== null &&
|
|
104
|
+
task.environmentProvider !== "docker-compose") ||
|
|
105
|
+
(task.statusMirror !== "error" &&
|
|
106
|
+
task.statusMirror !== "stopped" &&
|
|
107
|
+
!(typeof task.statusMirror === "string" && isTerminal(task.statusMirror)))
|
|
108
|
+
) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (task.composeProject === candidate.composeProject) return true;
|
|
112
|
+
if (!task.environmentLocator) return false;
|
|
113
|
+
try {
|
|
114
|
+
const locator = parseTaskEnvironmentLocator(task.environmentLocator);
|
|
115
|
+
if (
|
|
116
|
+
locator.schemaVersion !== 1 ||
|
|
117
|
+
locator.provider !== "docker-compose" ||
|
|
118
|
+
!locator.value ||
|
|
119
|
+
typeof locator.value !== "object" ||
|
|
120
|
+
Array.isArray(locator.value)
|
|
121
|
+
) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const value = locator.value as Record<string, unknown>;
|
|
125
|
+
return (
|
|
126
|
+
value.schemaVersion === 2 &&
|
|
127
|
+
value.taskId === candidate.taskId &&
|
|
128
|
+
value.composeProject === candidate.composeProject &&
|
|
129
|
+
value.containerName === `${candidate.composeProject}-app-1`
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function inspectedEmptyNetwork(
|
|
137
|
+
stdout: string,
|
|
138
|
+
candidate: ComposeNetworkCandidate,
|
|
139
|
+
): { fullId: string } | null {
|
|
140
|
+
let parsed: unknown;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(stdout);
|
|
143
|
+
} catch {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(parsed) || parsed.length !== 1) return null;
|
|
147
|
+
const network = parsed[0];
|
|
148
|
+
if (!network || typeof network !== "object" || Array.isArray(network)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const record = network as Record<string, unknown>;
|
|
152
|
+
const labels = record.Labels;
|
|
153
|
+
const containers = record.Containers;
|
|
154
|
+
if (
|
|
155
|
+
typeof record.Id !== "string" ||
|
|
156
|
+
!/^[a-f0-9]{64}$/.test(record.Id) ||
|
|
157
|
+
!record.Id.startsWith(candidate.id) ||
|
|
158
|
+
record.Name !== candidate.name ||
|
|
159
|
+
record.Driver !== "bridge" ||
|
|
160
|
+
record.Scope !== "local" ||
|
|
161
|
+
!labels ||
|
|
162
|
+
typeof labels !== "object" ||
|
|
163
|
+
Array.isArray(labels) ||
|
|
164
|
+
(labels as Record<string, unknown>)[COMPOSE_PROJECT_LABEL] !==
|
|
165
|
+
candidate.composeProject ||
|
|
166
|
+
!containers ||
|
|
167
|
+
typeof containers !== "object" ||
|
|
168
|
+
Array.isArray(containers) ||
|
|
169
|
+
Object.keys(containers).length !== 0
|
|
170
|
+
) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return { fullId: record.Id };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function reclaimCandidate(
|
|
177
|
+
candidate: ComposeNetworkCandidate,
|
|
178
|
+
deps: ComposeNetworkRecoveryDeps,
|
|
179
|
+
): Promise<boolean> {
|
|
180
|
+
if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
const inspected = await deps.docker(
|
|
184
|
+
["network", "inspect", candidate.id],
|
|
185
|
+
{
|
|
186
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
187
|
+
maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
|
|
188
|
+
},
|
|
189
|
+
);
|
|
190
|
+
if (inspected.status !== 0 || inspected.outputTruncated) return false;
|
|
191
|
+
const empty = inspectedEmptyNetwork(inspected.stdout, candidate);
|
|
192
|
+
if (empty === null) return false;
|
|
193
|
+
|
|
194
|
+
// `.Containers` is the network's own endpoint inventory. Independently ask
|
|
195
|
+
// Docker's all-container inventory as a second positive emptiness proof.
|
|
196
|
+
const attached = await deps.docker(
|
|
197
|
+
["ps", "-aq", "--filter", `network=${empty.fullId}`],
|
|
198
|
+
{
|
|
199
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
200
|
+
maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
|
|
201
|
+
},
|
|
202
|
+
);
|
|
203
|
+
if (
|
|
204
|
+
attached.status !== 0 ||
|
|
205
|
+
attached.outputTruncated ||
|
|
206
|
+
attached.stdout.trim().length !== 0
|
|
207
|
+
) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const removed = await deps.docker(["network", "rm", empty.fullId], {
|
|
212
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
213
|
+
maxOutputBytes: MAX_NETWORK_INSPECT_BYTES,
|
|
214
|
+
});
|
|
215
|
+
return removed.status === 0 && !removed.outputTruncated;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Remove only positively-owned, empty task networks. This is best-effort host
|
|
220
|
+
* hygiene: an unavailable inventory never blocks a task that may still fit in
|
|
221
|
+
* Docker's pool, and it never authorizes deletion.
|
|
222
|
+
*/
|
|
223
|
+
export async function reclaimUnusedUaiComposeNetworks(
|
|
224
|
+
lifecycle: ComposeNetworkLifecycle,
|
|
225
|
+
deps: ComposeNetworkRecoveryDeps = defaultDeps,
|
|
226
|
+
): Promise<ComposeNetworkRecoveryResult> {
|
|
227
|
+
const listed = await deps.docker(
|
|
228
|
+
[
|
|
229
|
+
"network",
|
|
230
|
+
"ls",
|
|
231
|
+
"--filter",
|
|
232
|
+
`label=${COMPOSE_PROJECT_LABEL}`,
|
|
233
|
+
"--format",
|
|
234
|
+
NETWORK_LIST_FORMAT,
|
|
235
|
+
],
|
|
236
|
+
{
|
|
237
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
238
|
+
maxOutputBytes: MAX_NETWORK_LIST_BYTES,
|
|
239
|
+
},
|
|
240
|
+
);
|
|
241
|
+
if (listed.status !== 0 || listed.outputTruncated) {
|
|
242
|
+
return { inventory: "unavailable", candidates: 0, removed: 0 };
|
|
243
|
+
}
|
|
244
|
+
const candidates = parseCandidates(listed.stdout);
|
|
245
|
+
if (candidates === null) {
|
|
246
|
+
return { inventory: "invalid", candidates: 0, removed: 0 };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let removed = 0;
|
|
250
|
+
for (const candidate of candidates) {
|
|
251
|
+
// Avoid queueing behind a live start merely to rediscover in-slot that it
|
|
252
|
+
// is not reclaimable. This observation grants no authority; the complete
|
|
253
|
+
// ownership/status check is repeated after the lifecycle slot is held.
|
|
254
|
+
if (!taskOwnsReclaimableNetwork(deps.task(candidate.taskId), candidate)) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
await lifecycle.runTaskLifecycle(candidate.taskId, async () => {
|
|
258
|
+
// The trusted row and Docker evidence are deliberately read only after
|
|
259
|
+
// entering the candidate task's lifecycle slot.
|
|
260
|
+
if (await reclaimCandidate(candidate, deps)) removed += 1;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
return { inventory: "complete", candidates: candidates.length, removed };
|
|
264
|
+
}
|
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" ] ` +
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -2607,8 +2607,27 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
|
2607
2607
|
fi
|
|
2608
2608
|
else
|
|
2609
2609
|
step "COMPOSE_UP_FAILED" "docker compose up -d"
|
|
2610
|
-
|
|
2611
|
-
|
|
2610
|
+
compose_up_error=""
|
|
2611
|
+
# Keep the diagnostic in memory only, but bound it: a broken plugin or
|
|
2612
|
+
# daemon must not make task-up retain an arbitrarily large stderr stream.
|
|
2613
|
+
# Docker's allocation error is emitted at the end of Compose's failure.
|
|
2614
|
+
if ! compose_up_error=$(printf '%s\n' "$compose_env_override" \
|
|
2615
|
+
| docker compose -p "$compose_project" \
|
|
2616
|
+
-f "$task_uai_dir/docker-compose.yml" -f - up -d --no-build \
|
|
2617
|
+
2>&1 >/dev/null | tail -c 65536); then
|
|
2618
|
+
[ -z "$compose_up_error" ] || printf '%s\n' "$compose_up_error" >&2
|
|
2619
|
+
require_docker_after_failed_step "docker compose up -d"
|
|
2620
|
+
if printf '%s\n' "$compose_up_error" \
|
|
2621
|
+
| grep -Fq 'all predefined address pools have been fully subnetted'; then
|
|
2622
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2623
|
+
"Docker has no subnet left for this task. Uai already reclaimed every empty task network it could prove safe. Close an unused stopped/error task, or enlarge Docker's non-overlapping default address pools and restart Docker, then press Resume." \
|
|
2624
|
+
"docker compose up -d"
|
|
2625
|
+
fi
|
|
2626
|
+
emit_err "COMPOSE_UP_FAILED" \
|
|
2627
|
+
"Uai could not start this task's Docker Compose stack. Fix the Docker error above, then press Resume." \
|
|
2628
|
+
"docker compose up -d"
|
|
2629
|
+
fi
|
|
2630
|
+
unset compose_up_error
|
|
2612
2631
|
fi
|
|
2613
2632
|
|
|
2614
2633
|
# Rendered YAML is not runtime evidence: Compose could reuse unexpected state,
|
package/src/index.ts
CHANGED
|
@@ -126,6 +126,7 @@ import {
|
|
|
126
126
|
waitForContainerRuntimeOperational,
|
|
127
127
|
} from "../lib/container-runtime";
|
|
128
128
|
import { vnodeAdmissionProblem } from "../lib/vnode-pressure";
|
|
129
|
+
import { reclaimUnusedUaiComposeNetworks } from "../lib/compose-network-recovery";
|
|
129
130
|
import {
|
|
130
131
|
machineTasksEnabled,
|
|
131
132
|
provisionTaskEnvironment,
|
|
@@ -616,6 +617,41 @@ async function taskUpOn(
|
|
|
616
617
|
);
|
|
617
618
|
}
|
|
618
619
|
}
|
|
620
|
+
// Every local Docker task consumes one daemon address-pool subnet. A task
|
|
621
|
+
// that failed after Compose created its network can retain an empty subnet
|
|
622
|
+
// while it waits for Resume. Reclaim only networks whose trusted host row,
|
|
623
|
+
// exact Docker identity and two empty-container inventories all agree. The
|
|
624
|
+
// sweep runs before this task's lifecycle slot so concurrent failed starts
|
|
625
|
+
// can never deadlock while trying to inspect one another.
|
|
626
|
+
if (
|
|
627
|
+
!sidecarMode() &&
|
|
628
|
+
!machineTasksEnabled() &&
|
|
629
|
+
pinnedContainerRuntimeProvider() === "docker"
|
|
630
|
+
) {
|
|
631
|
+
try {
|
|
632
|
+
const networkRecovery = await reclaimUnusedUaiComposeNetworks(
|
|
633
|
+
orchestrator,
|
|
634
|
+
);
|
|
635
|
+
if (networkRecovery.removed > 0) {
|
|
636
|
+
console.warn(
|
|
637
|
+
`[host-agent] reclaimed ${String(networkRecovery.removed)} empty UAI task ` +
|
|
638
|
+
`network(s) before starting ${input.task.id}`,
|
|
639
|
+
);
|
|
640
|
+
} else if (networkRecovery.inventory === "invalid") {
|
|
641
|
+
console.warn(
|
|
642
|
+
"[host-agent] skipped task-network recovery because Docker returned an invalid network inventory",
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
} catch (error) {
|
|
646
|
+
// Best-effort capacity recovery must not block a task when Docker still
|
|
647
|
+
// has room. `docker compose up` remains the authoritative allocation.
|
|
648
|
+
console.warn(
|
|
649
|
+
`[host-agent] task-network recovery was unavailable: ${
|
|
650
|
+
error instanceof Error ? error.message : String(error)
|
|
651
|
+
}`,
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
619
655
|
return orchestrator.runTaskLifecycle(input.task.id, releasingHold(cloud, async () => {
|
|
620
656
|
// A direct duplicate teardown may still be draining operations that
|
|
621
657
|
// target this task's stable compose/container names. Never recreate
|