hammerkit 1.26.0 → 1.27.0-next.2
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/build.schema.json +53 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/explain.js +13 -11
- package/dist/cli.js +3 -0
- package/dist/docker/item-network.js +471 -0
- package/dist/docker/pull.js +4 -2
- package/dist/docker/remove-orphaned-containers.js +13 -6
- package/dist/environment/replace-env-variables.js +8 -6
- package/dist/executer/await-completed-dependencies.js +2 -1
- package/dist/executer/check-readiness.js +2 -2
- package/dist/executer/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- package/dist/executer/execute-command.js +18 -5
- package/dist/executer/execute-docker.js +4 -1
- package/dist/executer/execution-steps.js +33 -7
- package/dist/executer/get-docker-executor.js +14 -7
- package/dist/executer/scheduler/enqueue-next.js +17 -3
- package/dist/executer/watch-loop.js +2 -2
- package/dist/executer/watch-state-key.js +1 -1
- package/dist/file/within-root.js +42 -0
- package/dist/harness/connect.js +99 -0
- package/dist/harness/format.js +73 -0
- package/dist/harness/mcp-command.js +59 -0
- package/dist/harness/mcp.js +432 -0
- package/dist/harness/opencode.js +492 -0
- package/dist/harness/pi.js +217 -0
- package/dist/harness/sandbox-client.js +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/log.js +1 -1
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -0
- package/dist/network/port-relay.js +35 -0
- package/dist/optimizer/get-work-cache-stats.js +16 -0
- package/dist/optimizer/work-task-cache-description.js +3 -2
- package/dist/planner/service-definition.js +5 -0
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +41 -0
- package/dist/planner/work-runtime-kubernetes.js +335 -102
- package/dist/program.js +23 -1
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- package/dist/remote/server-client.js +18 -5
- package/dist/remote/source-manifest.js +15 -2
- package/dist/remote/token-source.js +67 -0
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -0
- package/dist/schema/build-file-container-task-schema.js +3 -0
- package/dist/schema/build-file-environment-schema.js +9 -0
- package/dist/schema/network-schema.js +78 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +5 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +26 -0
- package/dist/secrets/github-app-token.js +191 -0
- package/dist/server/accounts-api.js +9 -2
- package/dist/server/definitions-api.js +69 -47
- package/dist/server/definitions.js +76 -51
- package/dist/server/executor.js +156 -41
- package/dist/server/git-checkout.js +72 -8
- package/dist/server/kubernetes-backend.js +562 -0
- package/dist/server/kubernetes-sandbox-storage.js +996 -0
- package/dist/server/oauth.js +1 -0
- package/dist/server/plan.js +51 -1
- package/dist/server/policy.js +104 -15
- package/dist/server/proposals-api.js +24 -40
- package/dist/server/proposals.js +12 -25
- package/dist/server/repo-access.js +37 -6
- package/dist/server/run-logs.js +217 -0
- package/dist/server/run-plan.js +51 -2
- package/dist/server/run-request.js +23 -10
- package/dist/server/run-store.js +5 -0
- package/dist/server/run-worker-command.js +13 -1
- package/dist/server/run-worker.js +121 -24
- package/dist/server/runs.js +184 -10
- package/dist/server/sandbox-docker.js +62 -0
- package/dist/server/sandbox-files-api.js +269 -0
- package/dist/server/sandbox-files.js +1054 -0
- package/dist/server/sandbox-patch.js +481 -0
- package/dist/server/sandbox-pod-command.js +15 -0
- package/dist/server/sandbox-pod.js +146 -0
- package/dist/server/sandbox-storage.js +462 -0
- package/dist/server/sandboxes-api.js +210 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-config.js +412 -22
- package/dist/server/server.js +204 -39
- package/dist/utils/run-id.js +12 -0
- package/harness/opencode.ts +11 -0
- package/harness/pi.ts +7 -0
- package/package.json +8 -2
- package/dist/remote/text-diff.js +0 -89
package/build.schema.json
CHANGED
|
@@ -261,6 +261,34 @@
|
|
|
261
261
|
"additionalProperties": false,
|
|
262
262
|
"description": "cpu and memory requests and limits"
|
|
263
263
|
},
|
|
264
|
+
"network": {
|
|
265
|
+
"anyOf": [
|
|
266
|
+
{
|
|
267
|
+
"type": "string",
|
|
268
|
+
"const": "open"
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"type": "string",
|
|
272
|
+
"const": "none"
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"type": "object",
|
|
276
|
+
"properties": {
|
|
277
|
+
"allow": {
|
|
278
|
+
"type": "array",
|
|
279
|
+
"items": {
|
|
280
|
+
"type": "string"
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
"required": [
|
|
285
|
+
"allow"
|
|
286
|
+
],
|
|
287
|
+
"additionalProperties": false
|
|
288
|
+
}
|
|
289
|
+
],
|
|
290
|
+
"description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
|
|
291
|
+
},
|
|
264
292
|
"runner": {
|
|
265
293
|
"type": "string",
|
|
266
294
|
"minLength": 1
|
|
@@ -273,6 +301,10 @@
|
|
|
273
301
|
"items": {
|
|
274
302
|
"type": "string"
|
|
275
303
|
}
|
|
304
|
+
},
|
|
305
|
+
"volumeSize": {
|
|
306
|
+
"type": "string",
|
|
307
|
+
"description": "size of its claim on Kubernetes, e.g. 10Gi"
|
|
276
308
|
}
|
|
277
309
|
},
|
|
278
310
|
"required": [
|
|
@@ -335,6 +367,9 @@
|
|
|
335
367
|
"resources": {
|
|
336
368
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources"
|
|
337
369
|
},
|
|
370
|
+
"network": {
|
|
371
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network"
|
|
372
|
+
},
|
|
338
373
|
"runner": {
|
|
339
374
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
|
|
340
375
|
},
|
|
@@ -468,6 +503,14 @@
|
|
|
468
503
|
"resources": {
|
|
469
504
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources",
|
|
470
505
|
"description": "cpu and memory requests and limits"
|
|
506
|
+
},
|
|
507
|
+
"volumeSize": {
|
|
508
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/volumeSize",
|
|
509
|
+
"description": "size of its claim on Kubernetes, e.g. 10Gi"
|
|
510
|
+
},
|
|
511
|
+
"network": {
|
|
512
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/network",
|
|
513
|
+
"description": "network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }"
|
|
471
514
|
}
|
|
472
515
|
},
|
|
473
516
|
"required": [
|
|
@@ -630,6 +673,15 @@
|
|
|
630
673
|
"additionalProperties": false
|
|
631
674
|
}
|
|
632
675
|
},
|
|
676
|
+
"networkPolicy": {
|
|
677
|
+
"type": "string",
|
|
678
|
+
"const": "enforced",
|
|
679
|
+
"description": "the cluster enforces NetworkPolicy, which limited networks need"
|
|
680
|
+
},
|
|
681
|
+
"volumeSize": {
|
|
682
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/volumeSize",
|
|
683
|
+
"description": "size of the claim of each task and service, 1Gi by default"
|
|
684
|
+
},
|
|
633
685
|
"ingresses": {
|
|
634
686
|
"not": {}
|
|
635
687
|
}
|
|
@@ -750,7 +802,7 @@
|
|
|
750
802
|
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/timeout"
|
|
751
803
|
},
|
|
752
804
|
"maxSize": {
|
|
753
|
-
"
|
|
805
|
+
"$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/volumeSize"
|
|
754
806
|
},
|
|
755
807
|
"keepPerTask": {
|
|
756
808
|
"type": "integer",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createLocalCacheBackend = void 0;
|
|
3
|
+
exports.createLocalCacheBackend = exports.getDefaultLocalCachePath = void 0;
|
|
4
4
|
const path_1 = require("path");
|
|
5
5
|
const os_1 = require("os");
|
|
6
6
|
const promises_1 = require("fs/promises");
|
|
@@ -16,8 +16,16 @@ async function directories(path) {
|
|
|
16
16
|
return [];
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
+
// Below the home directory, or below `HAMMERKIT_DIR` when that is set: a server sets
|
|
20
|
+
// it per source trust, so the runs of one repository share their cache and nobody
|
|
21
|
+
// else's run finds it (a run's home directory is its own and goes with it).
|
|
22
|
+
function getDefaultLocalCachePath() {
|
|
23
|
+
const directory = process.env.HAMMERKIT_DIR;
|
|
24
|
+
return directory ? (0, path_1.join)(directory, 'remote-cache') : (0, path_1.join)((0, os_1.homedir)(), '.hammerkit', 'remote-cache');
|
|
25
|
+
}
|
|
26
|
+
exports.getDefaultLocalCachePath = getDefaultLocalCachePath;
|
|
19
27
|
function createLocalCacheBackend(spec) {
|
|
20
|
-
const root = spec.path ?? (
|
|
28
|
+
const root = spec.path ?? getDefaultLocalCachePath();
|
|
21
29
|
async function readEntry(taskId, stateKey) {
|
|
22
30
|
const dir = (0, path_1.join)(root, taskId, stateKey);
|
|
23
31
|
let created;
|
package/dist/cache/explain.js
CHANGED
|
@@ -121,6 +121,19 @@ async function explainTask(item, defaultCacheMethod, environment, memo = new Map
|
|
|
121
121
|
return { taskId: item.id(), taskName: item.name, status: 'miss', causes: [{ kind: 'never-cached' }] };
|
|
122
122
|
}
|
|
123
123
|
visiting.add(item.id());
|
|
124
|
+
// Like the scheduler, a task whose cache is off is never looked up, so its
|
|
125
|
+
// sources aren't hashed for it.
|
|
126
|
+
if ((0, state_key_1.resolveEffective)(item, defaultCacheMethod).method === 'none') {
|
|
127
|
+
const explanation = {
|
|
128
|
+
taskId: item.id(),
|
|
129
|
+
taskName: item.name,
|
|
130
|
+
status: 'uncacheable',
|
|
131
|
+
causes: [{ kind: 'caching-disabled' }],
|
|
132
|
+
};
|
|
133
|
+
memo.set(item.id(), explanation);
|
|
134
|
+
visiting.delete(item.id());
|
|
135
|
+
return explanation;
|
|
136
|
+
}
|
|
124
137
|
const { stateKey, stats, resolved, provable, unmatched } = await (0, state_key_1.computeStateKey)(item, defaultCacheMethod, environment);
|
|
125
138
|
// Mirrors the scheduler: a task that cannot be proven up to date always runs
|
|
126
139
|
// (see computeStateKey), whatever a cache holds for it.
|
|
@@ -144,17 +157,6 @@ async function explainTask(item, defaultCacheMethod, environment, memo = new Map
|
|
|
144
157
|
visiting.delete(item.id());
|
|
145
158
|
return explanation;
|
|
146
159
|
}
|
|
147
|
-
if (resolved.method === 'none') {
|
|
148
|
-
const explanation = {
|
|
149
|
-
taskId: item.id(),
|
|
150
|
-
taskName: item.name,
|
|
151
|
-
status: 'uncacheable',
|
|
152
|
-
causes: [{ kind: 'caching-disabled' }],
|
|
153
|
-
};
|
|
154
|
-
memo.set(item.id(), explanation);
|
|
155
|
-
visiting.delete(item.id());
|
|
156
|
-
return explanation;
|
|
157
|
-
}
|
|
158
160
|
const runtimeStateKey = await safeCurrentStateKey(item, environment);
|
|
159
161
|
const hasEntry = await safeHas(item, stateKey, environment);
|
|
160
162
|
const predictedHit = runtimeStateKey === stateKey || hasEntry;
|
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,7 @@ const dry_run_1 = require("./executer/dry-run");
|
|
|
23
23
|
const cache_sync_1 = require("./cache/cache-sync");
|
|
24
24
|
const cache_inventory_1 = require("./cache/cache-inventory");
|
|
25
25
|
const prefetch_secrets_1 = require("./secrets/prefetch-secrets");
|
|
26
|
+
const work_network_1 = require("./planner/work-network");
|
|
26
27
|
const isCliTask = (val) => val.type === 'task';
|
|
27
28
|
exports.isCliTask = isCliTask;
|
|
28
29
|
const isCliService = (val) => val.type === 'service';
|
|
@@ -50,6 +51,7 @@ class Cli {
|
|
|
50
51
|
return {
|
|
51
52
|
state: processWorkTree,
|
|
52
53
|
start: async () => {
|
|
54
|
+
(0, work_network_1.applyNetworkCeiling)(workTree, type === 'down' ? null : options?.network ?? null);
|
|
53
55
|
if (type !== 'down') {
|
|
54
56
|
await this.prepareSecrets(true);
|
|
55
57
|
}
|
|
@@ -80,6 +82,7 @@ class Cli {
|
|
|
80
82
|
skipDeps: options?.skipDeps ?? true,
|
|
81
83
|
wait: options?.wait ?? 'ready',
|
|
82
84
|
resourceCheck: options?.resourceCheck ?? true,
|
|
85
|
+
network: options?.network ?? null,
|
|
83
86
|
});
|
|
84
87
|
}
|
|
85
88
|
const result = (0, get_scheduler_execute_result_1.getSchedulerExecuteResult)(workTree);
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.removeOrphanedNetworks = exports.removeItemNetworks = exports.getNetworkHostConfig = exports.removeItemNetwork = exports.prepareItemNetwork = exports.checkNetworkLimitSupport = exports.getProxyNetworkName = exports.getRunNetworkName = exports.getItemNetworkName = void 0;
|
|
4
|
+
const readline_1 = require("readline");
|
|
5
|
+
const stream_1 = require("stream");
|
|
6
|
+
const work_service_1 = require("../planner/work-service");
|
|
7
|
+
const work_network_1 = require("../planner/work-network");
|
|
8
|
+
const work_instance_id_1 = require("../planner/work-instance-id");
|
|
9
|
+
const get_service_containers_1 = require("../executer/get-service-containers");
|
|
10
|
+
const service_dns_1 = require("../executer/service-dns");
|
|
11
|
+
const run_labels_1 = require("./run-labels");
|
|
12
|
+
const run_id_1 = require("../utils/run-id");
|
|
13
|
+
const pull_1 = require("./pull");
|
|
14
|
+
const remove_container_1 = require("./remove-container");
|
|
15
|
+
const egress_proxy_1 = require("../network/egress-proxy");
|
|
16
|
+
const port_relay_1 = require("../network/port-relay");
|
|
17
|
+
const log_1 = require("../log");
|
|
18
|
+
const sleep_1 = require("../utils/sleep");
|
|
19
|
+
const remove_orphaned_containers_1 = require("./remove-orphaned-containers");
|
|
20
|
+
const PROXY_READY_TIMEOUT_MS = 60000;
|
|
21
|
+
const NETWORK_REMOVE_ATTEMPTS = 20;
|
|
22
|
+
// the first release with com.docker.network.bridge.inhibit_ipv4, which keeps
|
|
23
|
+
// an internal network from reaching the host through its gateway; it also
|
|
24
|
+
// has the fix for CVE-2024-29018 (DNS forwarded from internal networks)
|
|
25
|
+
const MIN_DOCKER_MAJOR = 28;
|
|
26
|
+
// The internal network of a task or service with a limited network. Services
|
|
27
|
+
// always have one when limited, since what needs them joins it; a task only
|
|
28
|
+
// when it needs a service or allows hosts. The name carries the run, so
|
|
29
|
+
// concurrent runs never share or remove each other's networks.
|
|
30
|
+
function getItemNetworkName(item) {
|
|
31
|
+
return `hammerkit-${(0, work_instance_id_1.getWorkInstanceId)(item)}-${(0, run_id_1.getRunId)()}`;
|
|
32
|
+
}
|
|
33
|
+
exports.getItemNetworkName = getItemNetworkName;
|
|
34
|
+
// On a server, the network the open tasks and services of a run share, in place of the
|
|
35
|
+
// default bridge: Docker keeps user-defined bridges apart, so the containers of one run
|
|
36
|
+
// can't reach those of another, or of another caller. What lies beyond the host's own
|
|
37
|
+
// interfaces (the LAN, the cloud's metadata address) is the host firewall's to block.
|
|
38
|
+
function getRunNetworkName() {
|
|
39
|
+
return `hammerkit-run-${(0, run_id_1.getRunId)()}`;
|
|
40
|
+
}
|
|
41
|
+
exports.getRunNetworkName = getRunNetworkName;
|
|
42
|
+
const runNetworks = new WeakMap();
|
|
43
|
+
function withRunNetwork(docker, fn) {
|
|
44
|
+
let state = runNetworks.get(docker);
|
|
45
|
+
if (!state) {
|
|
46
|
+
state = { users: 0, lock: Promise.resolve() };
|
|
47
|
+
runNetworks.set(docker, state);
|
|
48
|
+
}
|
|
49
|
+
const current = state;
|
|
50
|
+
const result = current.lock.then(() => fn(current));
|
|
51
|
+
current.lock = result.catch(() => undefined);
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
// The run's network for an open item, made by its first user and removed after the last
|
|
55
|
+
// one: null off a server, for a daemon (it outlives the run) and on a daemon that has no
|
|
56
|
+
// Linux bridges, where the item stays on the default bridge as before.
|
|
57
|
+
async function acquireRunNetwork(docker, environment, options) {
|
|
58
|
+
if (!environment.hosted || options.daemon || (await getDaemonVersion(docker)).Os !== 'linux') {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return withRunNetwork(docker, async (state) => {
|
|
62
|
+
const name = getRunNetworkName();
|
|
63
|
+
if (state.users === 0) {
|
|
64
|
+
try {
|
|
65
|
+
await docker.createNetwork({
|
|
66
|
+
Name: name,
|
|
67
|
+
Labels: { app: 'hammerkit', ...(0, run_labels_1.getRunLabels)(), 'hammerkit-daemon': 'false' },
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
// left by an item whose release failed
|
|
72
|
+
if (e?.statusCode !== 409) {
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
state.users++;
|
|
78
|
+
return name;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function releaseRunNetwork(docker) {
|
|
82
|
+
await withRunNetwork(docker, async (state) => {
|
|
83
|
+
state.users--;
|
|
84
|
+
if (state.users === 0) {
|
|
85
|
+
await removeItemNetwork(docker, getRunNetworkName());
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function getAddressOn(docker, containerId, network) {
|
|
90
|
+
const info = await docker.getContainer(containerId).inspect();
|
|
91
|
+
const address = info.NetworkSettings.Networks[network]?.IPAddress;
|
|
92
|
+
if (!address) {
|
|
93
|
+
throw new Error(`${info.Name.replace(/^\//, '')} has no address on the network ${network} of the run`);
|
|
94
|
+
}
|
|
95
|
+
return address;
|
|
96
|
+
}
|
|
97
|
+
// The network between an `allow` item and its proxy, which nothing else joins:
|
|
98
|
+
// the services the item needs don't get to use its proxy.
|
|
99
|
+
function getProxyNetworkName(networkName) {
|
|
100
|
+
return `${networkName}-proxy`;
|
|
101
|
+
}
|
|
102
|
+
exports.getProxyNetworkName = getProxyNetworkName;
|
|
103
|
+
const daemonVersions = new WeakMap();
|
|
104
|
+
function getDaemonVersion(docker) {
|
|
105
|
+
let version = daemonVersions.get(docker);
|
|
106
|
+
if (!version) {
|
|
107
|
+
version = docker.version();
|
|
108
|
+
daemonVersions.set(docker, version);
|
|
109
|
+
}
|
|
110
|
+
return version;
|
|
111
|
+
}
|
|
112
|
+
// Limits rely on internal networks without a gateway on the host, which only
|
|
113
|
+
// Linux containers on Docker 28 or later have. Anything else would run the
|
|
114
|
+
// item with a hole, so it is refused.
|
|
115
|
+
async function checkNetworkLimitSupport(docker, item) {
|
|
116
|
+
const { Version, Os } = await getDaemonVersion(docker);
|
|
117
|
+
const level = (0, work_network_1.getEffectiveNetwork)(item.data).type;
|
|
118
|
+
if (Os !== 'linux') {
|
|
119
|
+
throw new Error(`${item.name}: network ${level} needs Linux containers, the docker daemon runs ${Os} containers`);
|
|
120
|
+
}
|
|
121
|
+
const major = parseInt(Version, 10);
|
|
122
|
+
if (!(major >= MIN_DOCKER_MAJOR)) {
|
|
123
|
+
throw new Error(`${item.name}: network ${level} needs Docker ${MIN_DOCKER_MAJOR} or later, the daemon is ${Version}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.checkNetworkLimitSupport = checkNetworkLimitSupport;
|
|
127
|
+
// Internal (no route out) and without an address on the host, so the host's
|
|
128
|
+
// services can't be reached through it either.
|
|
129
|
+
async function createInternalNetwork(docker, name, labels) {
|
|
130
|
+
await docker.createNetwork({
|
|
131
|
+
Name: name,
|
|
132
|
+
Internal: true,
|
|
133
|
+
Options: { 'com.docker.network.bridge.inhibit_ipv4': 'true' },
|
|
134
|
+
Labels: labels,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
// What the proxy and relay may do: serve, nothing else.
|
|
138
|
+
const HELPER_HOST_CONFIG = {
|
|
139
|
+
CapDrop: ['ALL'],
|
|
140
|
+
SecurityOpt: ['no-new-privileges'],
|
|
141
|
+
ReadonlyRootfs: true,
|
|
142
|
+
Memory: 256 * 1024 * 1024,
|
|
143
|
+
PidsLimit: 64,
|
|
144
|
+
};
|
|
145
|
+
// Where the container of a service is reached: on the default bridge (open)
|
|
146
|
+
// or on its own internal network (limited), which may be of an earlier run
|
|
147
|
+
// (`up --daemon`).
|
|
148
|
+
async function getNeedNetwork(docker, need, containerId) {
|
|
149
|
+
const service = need.service;
|
|
150
|
+
if (!(0, work_service_1.isContainerWorkService)(service.data) || !(0, work_network_1.isLimitedNetwork)((0, work_network_1.getEffectiveNetwork)(service.data))) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const info = await docker.getContainer(containerId).inspect();
|
|
154
|
+
return info.HostConfig.NetworkMode ?? null;
|
|
155
|
+
}
|
|
156
|
+
// Docker options for the network of a task or service. `open` is the default
|
|
157
|
+
// bridge with links to the needed services, as before network limits, and on a
|
|
158
|
+
// server the run's own network (see getRunNetworkName). `none`
|
|
159
|
+
// and `allow` put the container on an internal network of its own (or none at
|
|
160
|
+
// all for a task that needs nothing) that the needed services join under the
|
|
161
|
+
// names it needs them by; `allow` adds the egress proxy to it.
|
|
162
|
+
async function prepareItemNetwork(docker, item, environment,
|
|
163
|
+
// `publish`: the ports a service publishes on the host
|
|
164
|
+
options) {
|
|
165
|
+
const network = (0, work_network_1.getEffectiveNetwork)(item.data);
|
|
166
|
+
const dns = (0, get_service_containers_1.getServiceContainers)(item.needs);
|
|
167
|
+
const hosts = [];
|
|
168
|
+
const containers = [];
|
|
169
|
+
for (const need of item.needs) {
|
|
170
|
+
const target = dns[need.name];
|
|
171
|
+
if (!target || (!(0, service_dns_1.isHostServiceDns)(target) && !target.containerId)) {
|
|
172
|
+
throw new Error(`service ${need.name} is not running`);
|
|
173
|
+
}
|
|
174
|
+
if ((0, service_dns_1.isHostServiceDns)(target)) {
|
|
175
|
+
hosts.push(`${need.name}:${target.host}`);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
containers.push({
|
|
179
|
+
need,
|
|
180
|
+
containerId: target.containerId,
|
|
181
|
+
network: await getNeedNetwork(docker, need, target.containerId),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (!(0, work_network_1.isLimitedNetwork)(network)) {
|
|
186
|
+
const linked = containers.filter((c) => !c.network);
|
|
187
|
+
const shared = await acquireRunNetwork(docker, environment, options);
|
|
188
|
+
if (shared) {
|
|
189
|
+
try {
|
|
190
|
+
// the services it needs are on the same network, found under the names it needs them by
|
|
191
|
+
for (const target of linked) {
|
|
192
|
+
hosts.push(`${target.need.name}:${await getAddressOn(docker, target.containerId, shared)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
await releaseRunNetwork(docker);
|
|
197
|
+
throw e;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
options: shared
|
|
202
|
+
? { mode: shared, hosts }
|
|
203
|
+
: { links: linked.map((c) => `${c.containerId}:${c.need.name}`), hosts },
|
|
204
|
+
// a limited service is on its internal network only, so an open item
|
|
205
|
+
// joins that network to reach it
|
|
206
|
+
async connect(container) {
|
|
207
|
+
for (const target of containers) {
|
|
208
|
+
if (target.network) {
|
|
209
|
+
// a link on a user-defined network resolves by container name only
|
|
210
|
+
const info = await docker.getContainer(target.containerId).inspect();
|
|
211
|
+
await docker.getNetwork(target.network).connect({
|
|
212
|
+
Container: container.id,
|
|
213
|
+
EndpointConfig: { Links: [`${info.Name.replace(/^\//, '')}:${target.need.name}`] },
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
async release() {
|
|
219
|
+
if (shared) {
|
|
220
|
+
await releaseRunNetwork(docker);
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
if (network.type === 'none' && containers.length === 0 && !(0, work_service_1.isContainerWorkService)(item.data)) {
|
|
226
|
+
return {
|
|
227
|
+
options: { mode: 'none' },
|
|
228
|
+
async connect() { },
|
|
229
|
+
async release() { },
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
await checkNetworkLimitSupport(docker, item);
|
|
233
|
+
const name = getItemNetworkName(item);
|
|
234
|
+
const labels = {
|
|
235
|
+
app: 'hammerkit',
|
|
236
|
+
'hammerkit-network-of': (0, work_instance_id_1.getWorkInstanceId)(item),
|
|
237
|
+
...(0, run_labels_1.getRunLabels)(),
|
|
238
|
+
'hammerkit-daemon': options.daemon ? 'true' : 'false',
|
|
239
|
+
};
|
|
240
|
+
await createInternalNetwork(docker, name, labels);
|
|
241
|
+
try {
|
|
242
|
+
for (const target of containers) {
|
|
243
|
+
await docker.getNetwork(name).connect({
|
|
244
|
+
Container: target.containerId,
|
|
245
|
+
EndpointConfig: { Aliases: [target.need.name] },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (network.type === 'allow') {
|
|
249
|
+
await createInternalNetwork(docker, getProxyNetworkName(name), labels);
|
|
250
|
+
const proxyIp = await startEgressProxy(docker, item, environment, name, network, options, containers.length > 0);
|
|
251
|
+
// the item reaches each TCP forward under its host's name, at the proxy
|
|
252
|
+
for (const forward of (0, work_network_1.getTcpForwards)(network)) {
|
|
253
|
+
hosts.push(`${forward.host}:${proxyIp}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
await removeItemNetwork(docker, name);
|
|
259
|
+
throw e;
|
|
260
|
+
}
|
|
261
|
+
const publish = (options.publish ?? []).filter((port) => !!port.hostPort);
|
|
262
|
+
return {
|
|
263
|
+
options: {
|
|
264
|
+
mode: name,
|
|
265
|
+
hosts,
|
|
266
|
+
env: network.type === 'allow' ? (0, egress_proxy_1.getProxyClientEnvironment)(item.needs.map((need) => need.name)) : {},
|
|
267
|
+
relayPorts: (0, work_service_1.isContainerWorkService)(item.data),
|
|
268
|
+
},
|
|
269
|
+
async connect(container) {
|
|
270
|
+
try {
|
|
271
|
+
if (network.type === 'allow') {
|
|
272
|
+
await docker.getNetwork(getProxyNetworkName(name)).connect({ Container: container.id });
|
|
273
|
+
}
|
|
274
|
+
if (publish.length > 0) {
|
|
275
|
+
await startPortRelay(docker, item, environment, name, container, publish, options);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
await removeItemNetwork(docker, name);
|
|
280
|
+
throw e;
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
async release() {
|
|
284
|
+
await removeItemNetwork(docker, name);
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
exports.prepareItemNetwork = prepareItemNetwork;
|
|
289
|
+
function getHelperLabels(networkName, options) {
|
|
290
|
+
return {
|
|
291
|
+
app: 'hammerkit',
|
|
292
|
+
// goes with the task or service it serves
|
|
293
|
+
'hammerkit-type': 'proxy',
|
|
294
|
+
'hammerkit-network': networkName,
|
|
295
|
+
...(0, run_labels_1.getRunLabels)(),
|
|
296
|
+
'hammerkit-daemon': options.daemon ? 'true' : 'false',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// On the default bridge for the way out, and on the proxy network under
|
|
300
|
+
// EGRESS_PROXY_HOST, the only address it listens on: neither the bridge nor
|
|
301
|
+
// the services on the item's network reach it. It joins the item's network
|
|
302
|
+
// to reach the needed services (for tools that ignore NO_PROXY), but doesn't
|
|
303
|
+
// listen there. Refused hosts show up in the item's log. Returns its address
|
|
304
|
+
// on the proxy network, where it also listens for the TCP forwards.
|
|
305
|
+
async function startEgressProxy(docker, item, environment, networkName, network, options, reachNeeds) {
|
|
306
|
+
await (0, pull_1.pull)(item.status, docker, egress_proxy_1.EGRESS_PROXY_IMAGE, environment);
|
|
307
|
+
const createOptions = {
|
|
308
|
+
Image: egress_proxy_1.EGRESS_PROXY_IMAGE,
|
|
309
|
+
Cmd: (0, egress_proxy_1.getEgressProxyCommand)(),
|
|
310
|
+
User: 'node',
|
|
311
|
+
Env: Object.entries((0, egress_proxy_1.getItemProxyEnvironment)(item, egress_proxy_1.EGRESS_PROXY_HOST)).map(([key, value]) => `${key}=${value}`),
|
|
312
|
+
Labels: getHelperLabels(networkName, options),
|
|
313
|
+
// unprivileged, it still listens on the port of a TCP forward below 1024
|
|
314
|
+
HostConfig: { ...HELPER_HOST_CONFIG, Sysctls: { 'net.ipv4.ip_unprivileged_port_start': '0' } },
|
|
315
|
+
};
|
|
316
|
+
const container = await docker.createContainer(createOptions);
|
|
317
|
+
await docker.getNetwork(getProxyNetworkName(networkName)).connect({
|
|
318
|
+
Container: container.id,
|
|
319
|
+
EndpointConfig: { Aliases: [egress_proxy_1.EGRESS_PROXY_HOST] },
|
|
320
|
+
});
|
|
321
|
+
if (reachNeeds) {
|
|
322
|
+
await docker.getNetwork(networkName).connect({ Container: container.id });
|
|
323
|
+
}
|
|
324
|
+
const stream = await container.attach({ stream: true, stdout: true, stderr: true });
|
|
325
|
+
const stdout = new stream_1.PassThrough();
|
|
326
|
+
const stderr = new stream_1.PassThrough();
|
|
327
|
+
docker.modem.demuxStream(stream, stdout, stderr);
|
|
328
|
+
stream.on('end', () => {
|
|
329
|
+
stdout.end();
|
|
330
|
+
stderr.end();
|
|
331
|
+
});
|
|
332
|
+
stderr.on('data', (data) => item.status.write('debug', `proxy: ${data.toString().trim()}`));
|
|
333
|
+
const ready = new Promise((resolve, reject) => {
|
|
334
|
+
const timeout = setTimeout(() => reject(new Error('the egress proxy did not start')), PROXY_READY_TIMEOUT_MS);
|
|
335
|
+
const lines = (0, readline_1.createInterface)({ input: stdout });
|
|
336
|
+
lines.on('line', (line) => {
|
|
337
|
+
const event = (0, egress_proxy_1.parseEgressProxyEvent)(line);
|
|
338
|
+
if (event?.type === 'ready') {
|
|
339
|
+
clearTimeout(timeout);
|
|
340
|
+
resolve();
|
|
341
|
+
}
|
|
342
|
+
else if (event) {
|
|
343
|
+
item.status.write('warn', (0, egress_proxy_1.formatEgressProxyEvent)(event));
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
lines.on('close', () => {
|
|
347
|
+
clearTimeout(timeout);
|
|
348
|
+
reject(new Error('the egress proxy stopped'));
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
// once ready, a later close only means the proxy was removed with the item
|
|
352
|
+
ready.catch(() => undefined);
|
|
353
|
+
await container.start();
|
|
354
|
+
await ready;
|
|
355
|
+
const ip = (await container.inspect()).NetworkSettings.Networks[getProxyNetworkName(networkName)]?.IPAddress;
|
|
356
|
+
if (!ip) {
|
|
357
|
+
throw new Error(`the egress proxy of ${item.name} has no address on its network`);
|
|
358
|
+
}
|
|
359
|
+
item.status.write('debug', `egress proxy allows ${network.hosts.join(', ') || 'no host'}`);
|
|
360
|
+
return ip;
|
|
361
|
+
}
|
|
362
|
+
// On the default bridge, where Docker publishes ports, and on the service's
|
|
363
|
+
// network, from where it passes connections on to the service.
|
|
364
|
+
async function startPortRelay(docker, item, environment, networkName, service, ports, options) {
|
|
365
|
+
await (0, pull_1.pull)(item.status, docker, egress_proxy_1.EGRESS_PROXY_IMAGE, environment);
|
|
366
|
+
// a container is found by its name on a user-defined network
|
|
367
|
+
const host = (await service.inspect()).Name.replace(/^\//, '');
|
|
368
|
+
const container = await docker.createContainer({
|
|
369
|
+
Image: egress_proxy_1.EGRESS_PROXY_IMAGE,
|
|
370
|
+
Cmd: (0, port_relay_1.getPortRelayCommand)(),
|
|
371
|
+
User: 'node',
|
|
372
|
+
Env: Object.entries((0, port_relay_1.getPortRelayEnvironment)(ports.map((port) => ({ port: port.containerPort, host, targetPort: port.containerPort })))).map(([key, value]) => `${key}=${value}`),
|
|
373
|
+
Labels: getHelperLabels(networkName, options),
|
|
374
|
+
ExposedPorts: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, {}])),
|
|
375
|
+
HostConfig: {
|
|
376
|
+
...HELPER_HOST_CONFIG,
|
|
377
|
+
PortBindings: Object.fromEntries(ports.map((port) => [`${port.containerPort}/tcp`, [{ HostPort: `${port.hostPort}` }]])),
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
await docker.getNetwork(networkName).connect({ Container: container.id });
|
|
381
|
+
await container.start();
|
|
382
|
+
item.status.write('debug', `ports ${ports.map((port) => port.hostPort).join(', ')} published by a relay`);
|
|
383
|
+
}
|
|
384
|
+
// The network, its proxy network, its proxy and relay, and every endpoint on
|
|
385
|
+
// them: what an earlier run left behind, or what the item used.
|
|
386
|
+
async function removeItemNetwork(docker, name) {
|
|
387
|
+
const helpers = await docker.listContainers({ all: true, filters: { label: [`hammerkit-network=${name}`] } });
|
|
388
|
+
for (const helper of helpers) {
|
|
389
|
+
await (0, remove_container_1.removeContainer)(docker.getContainer(helper.Id));
|
|
390
|
+
}
|
|
391
|
+
await removeNetwork(docker, getProxyNetworkName(name));
|
|
392
|
+
await removeNetwork(docker, name);
|
|
393
|
+
}
|
|
394
|
+
exports.removeItemNetwork = removeItemNetwork;
|
|
395
|
+
async function removeNetwork(docker, name) {
|
|
396
|
+
const network = docker.getNetwork(name);
|
|
397
|
+
let info;
|
|
398
|
+
try {
|
|
399
|
+
info = await network.inspect();
|
|
400
|
+
}
|
|
401
|
+
catch (e) {
|
|
402
|
+
if (e?.statusCode === 404) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
throw e;
|
|
406
|
+
}
|
|
407
|
+
for (const containerId of Object.keys(info.Containers ?? {})) {
|
|
408
|
+
try {
|
|
409
|
+
await network.disconnect({ Container: containerId, Force: true });
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// gone already, or on its way out with its container
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// a container being removed leaves its endpoints a moment later
|
|
416
|
+
for (let attempt = 1;; attempt++) {
|
|
417
|
+
try {
|
|
418
|
+
await network.remove();
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
catch (e) {
|
|
422
|
+
if (e?.statusCode === 404) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (attempt >= NETWORK_REMOVE_ATTEMPTS) {
|
|
426
|
+
throw new Error(`unable to remove network ${name}: ${(0, log_1.getErrorMessage)(e)}`);
|
|
427
|
+
}
|
|
428
|
+
await (0, sleep_1.sleep)(500);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function getNetworkHostConfig(network) {
|
|
433
|
+
return {
|
|
434
|
+
...(network.mode ? { NetworkMode: network.mode } : {}),
|
|
435
|
+
Links: network.links ?? [],
|
|
436
|
+
ExtraHosts: network.hosts ?? [],
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
exports.getNetworkHostConfig = getNetworkHostConfig;
|
|
440
|
+
// Every network of the item, of this run or another one: what `down` and
|
|
441
|
+
// `clean` remove.
|
|
442
|
+
async function removeItemNetworks(docker, item) {
|
|
443
|
+
const networks = await docker.listNetworks({
|
|
444
|
+
filters: { label: [`hammerkit-network-of=${(0, work_instance_id_1.getWorkInstanceId)(item)}`] },
|
|
445
|
+
});
|
|
446
|
+
for (const network of networks) {
|
|
447
|
+
await removeItemNetwork(docker, network.Name);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
exports.removeItemNetworks = removeItemNetworks;
|
|
451
|
+
// The internal networks of tasks and services a run left behind when it was
|
|
452
|
+
// killed. Networks of `up --daemon` services stay until `down`.
|
|
453
|
+
async function removeOrphanedNetworks(docker) {
|
|
454
|
+
let networks;
|
|
455
|
+
try {
|
|
456
|
+
networks = await docker.listNetworks({ filters: { label: ['app=hammerkit'] } });
|
|
457
|
+
}
|
|
458
|
+
catch (e) {
|
|
459
|
+
if ((0, remove_orphaned_containers_1.isDockerUnreachable)(e)) {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
throw e;
|
|
463
|
+
}
|
|
464
|
+
const orphaned = networks.filter((network) => (0, remove_orphaned_containers_1.isOfStoppedRun)(network.Labels ?? {}, remove_orphaned_containers_1.isProcessAlive));
|
|
465
|
+
for (const network of orphaned) {
|
|
466
|
+
await removeItemNetwork(docker, network.Name);
|
|
467
|
+
}
|
|
468
|
+
return orphaned.map((network) => network.Name);
|
|
469
|
+
}
|
|
470
|
+
exports.removeOrphanedNetworks = removeOrphanedNetworks;
|
|
471
|
+
//# sourceMappingURL=item-network.js.map
|
package/dist/docker/pull.js
CHANGED
|
@@ -19,11 +19,13 @@ async function pull(status, docker, imageName, environment) {
|
|
|
19
19
|
exports.pull = pull;
|
|
20
20
|
// A tag is present when an image carries it; an image pinned by digest
|
|
21
21
|
// (`repo:tag@sha256:…`) when an image was pulled with that digest, whatever
|
|
22
|
-
// tag it has now.
|
|
22
|
+
// tag it has now. The containerd image store lists an image pulled by digest
|
|
23
|
+
// under that name in RepoTags rather than in RepoDigests.
|
|
23
24
|
function isImagePresent(images, imageName) {
|
|
24
25
|
const { repository, tag, digest } = splitImageName(imageName);
|
|
25
26
|
if (digest) {
|
|
26
|
-
|
|
27
|
+
const name = `${repository}@${digest}`;
|
|
28
|
+
return images.some((i) => i.RepoDigests?.includes(name) || i.RepoTags?.includes(name));
|
|
27
29
|
}
|
|
28
30
|
return images.some((i) => i.RepoTags?.some((repoTag) => repoTag === `${repository}:${tag ?? 'latest'}`));
|
|
29
31
|
}
|