hammerkit 1.25.0 → 1.27.0-next.1

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.
Files changed (123) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/container-task-state.js +8 -6
  12. package/dist/executer/docker-service.js +24 -13
  13. package/dist/executer/docker-task.js +18 -29
  14. package/dist/executer/event-cache.js +4 -0
  15. package/dist/executer/execute-command.js +18 -5
  16. package/dist/executer/execute-docker.js +4 -1
  17. package/dist/executer/execution-steps.js +33 -7
  18. package/dist/executer/get-docker-executor.js +14 -7
  19. package/dist/executer/scheduler/enqueue-next.js +17 -3
  20. package/dist/executer/watch-loop.js +2 -2
  21. package/dist/executer/watch-state-key.js +1 -1
  22. package/dist/file/within-root.js +42 -0
  23. package/dist/harness/connect.js +99 -0
  24. package/dist/harness/format.js +73 -0
  25. package/dist/harness/mcp-command.js +59 -0
  26. package/dist/harness/mcp.js +432 -0
  27. package/dist/harness/opencode.js +492 -0
  28. package/dist/harness/pi.js +217 -0
  29. package/dist/harness/sandbox-client.js +373 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +31 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
  34. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  35. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  36. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  37. package/dist/kubernetes/get-service-ip.js +15 -1
  38. package/dist/kubernetes/job-placement.js +99 -0
  39. package/dist/kubernetes/job-secrets.js +306 -0
  40. package/dist/kubernetes/kept-claims.js +191 -0
  41. package/dist/kubernetes/kubernetes-instance.js +10 -2
  42. package/dist/kubernetes/network-gate.js +71 -0
  43. package/dist/kubernetes/network-policy.js +526 -0
  44. package/dist/kubernetes/resources.js +13 -2
  45. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  46. package/dist/kubernetes/secrets.js +2 -0
  47. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  48. package/dist/kubernetes/volumes.js +100 -11
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +8 -2
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/utils/task-arguments.js +155 -0
  61. package/dist/planner/work-cache-id.js +15 -1
  62. package/dist/planner/work-network.js +193 -0
  63. package/dist/planner/work-runtime-docker.js +43 -0
  64. package/dist/planner/work-runtime-kubernetes.js +336 -102
  65. package/dist/planner/work-runtime-local.js +16 -8
  66. package/dist/planner/work-service.js +7 -3
  67. package/dist/planner/work-state-record.js +30 -0
  68. package/dist/program.js +80 -7
  69. package/dist/remote/annotations.js +103 -0
  70. package/dist/remote/bundle-text.js +24 -0
  71. package/dist/remote/bundle.js +9 -1
  72. package/dist/remote/remote-commands.js +4 -1
  73. package/dist/remote/remote-work-commands.js +182 -69
  74. package/dist/remote/server-client.js +18 -5
  75. package/dist/remote/source-manifest.js +15 -2
  76. package/dist/remote/token-source.js +67 -0
  77. package/dist/schema/build-file-base-task-schema.js +3 -0
  78. package/dist/schema/build-file-container-service-schema.js +6 -0
  79. package/dist/schema/build-file-container-task-schema.js +3 -0
  80. package/dist/schema/build-file-environment-schema.js +9 -0
  81. package/dist/schema/network-schema.js +78 -0
  82. package/dist/schema/reference-parser.js +1 -0
  83. package/dist/schema/schema-parser.js +16 -3
  84. package/dist/schema/work-scope-parser.js +13 -0
  85. package/dist/secrets/command-secret-provider.js +73 -10
  86. package/dist/secrets/github-app-token-command.js +23 -0
  87. package/dist/secrets/github-app-token.js +191 -0
  88. package/dist/server/accounts-api.js +9 -2
  89. package/dist/server/definitions-api.js +69 -47
  90. package/dist/server/definitions.js +76 -51
  91. package/dist/server/executor.js +156 -41
  92. package/dist/server/git-checkout.js +72 -8
  93. package/dist/server/kubernetes-backend.js +554 -0
  94. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  95. package/dist/server/oauth.js +1 -0
  96. package/dist/server/plan.js +51 -1
  97. package/dist/server/policy.js +104 -15
  98. package/dist/server/proposals-api.js +24 -40
  99. package/dist/server/proposals.js +12 -25
  100. package/dist/server/repo-access.js +37 -6
  101. package/dist/server/run-logs.js +217 -0
  102. package/dist/server/run-plan.js +51 -2
  103. package/dist/server/run-request.js +23 -10
  104. package/dist/server/run-store.js +5 -0
  105. package/dist/server/run-worker-command.js +13 -1
  106. package/dist/server/run-worker.js +121 -24
  107. package/dist/server/runs.js +184 -10
  108. package/dist/server/sandbox-docker.js +62 -0
  109. package/dist/server/sandbox-files-api.js +269 -0
  110. package/dist/server/sandbox-files.js +1054 -0
  111. package/dist/server/sandbox-patch.js +481 -0
  112. package/dist/server/sandbox-pod-command.js +15 -0
  113. package/dist/server/sandbox-pod.js +146 -0
  114. package/dist/server/sandbox-storage.js +462 -0
  115. package/dist/server/sandboxes-api.js +210 -0
  116. package/dist/server/sandboxes.js +519 -0
  117. package/dist/server/server-config.js +412 -22
  118. package/dist/server/server.js +204 -39
  119. package/dist/utils/run-id.js +12 -0
  120. package/harness/opencode.ts +11 -0
  121. package/harness/pi.ts +7 -0
  122. package/package.json +8 -2
  123. package/dist/remote/text-diff.js +0 -89
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getContainerSecurity = exports.getRunLabels = exports.getRunLabelValue = exports.RUN_LABEL = exports.getPodPlacement = exports.placeOnPool = exports.getNodeArch = exports.runInToolImage = exports.getLocalTaskPool = void 0;
4
+ const crypto_1 = require("crypto");
5
+ /**
6
+ * The pool a local task runs on: the first that offers its platform. A pool says
7
+ * `linux` with `amd64` or `arm64`, so a task that names another platform has none.
8
+ */
9
+ function getLocalTaskPool(server, name, platform) {
10
+ if (platform?.os === 'macos' || platform?.os === 'win') {
11
+ throw new Error(`task ${name}: runs on ${platform.os === 'win' ? 'Windows' : 'macOS'}, this server runs tasks on Linux nodes`);
12
+ }
13
+ if (platform?.arch === 'arm') {
14
+ throw new Error(`task ${name}: runs on arm, the pools of this server offer amd64 and arm64`);
15
+ }
16
+ const pools = server.pools.filter((pool) => !platform?.arch || pool.platform.arch === platform.arch);
17
+ const pool = pools.find((candidate) => candidate.toolImage);
18
+ if (!pool) {
19
+ throw new Error(`task ${name}: a local task runs in the toolImage of a pool, and no pool${platform?.arch ? ` for ${platform.arch}` : ''} of this server has one`);
20
+ }
21
+ return pool;
22
+ }
23
+ exports.getLocalTaskPool = getLocalTaskPool;
24
+ /**
25
+ * A local task on a cluster: a container of the pool's tool image, which runs its
26
+ * commands in the same directories as a container task would.
27
+ */
28
+ function runInToolImage(task, server, platform) {
29
+ const pool = getLocalTaskPool(server, task.name, platform);
30
+ return {
31
+ ...task,
32
+ type: 'container-task',
33
+ image: pool.toolImage,
34
+ user: null,
35
+ mounts: [],
36
+ volumeSize: null,
37
+ pool: pool.name,
38
+ arch: getNodeArch(pool),
39
+ };
40
+ }
41
+ exports.runInToolImage = runInToolImage;
42
+ // The architecture of a pool's nodes as process.arch names it, so a cache entry made on
43
+ // amd64 nodes is never restored on arm64 ones
44
+ function getNodeArch(pool) {
45
+ return pool.platform.arch === 'amd64' ? 'x64' : pool.platform.arch;
46
+ }
47
+ exports.getNodeArch = getNodeArch;
48
+ // A task on a server's cluster: a container task runs on the first pool
49
+ function placeOnPool(task, server) {
50
+ return task.arch ? task : { ...task, arch: getNodeArch(server.pools[0]) };
51
+ }
52
+ exports.placeOnPool = placeOnPool;
53
+ // Where the pod of a task goes: its pool's nodes, the first pool unless a local task
54
+ // chose another.
55
+ function getPodPlacement(server, pool) {
56
+ const chosen = server.pools.find((candidate) => candidate.name === pool) ?? server.pools[0];
57
+ return {
58
+ nodeSelector: {
59
+ 'kubernetes.io/os': chosen.platform.os,
60
+ 'kubernetes.io/arch': chosen.platform.arch,
61
+ ...chosen.nodeSelector,
62
+ },
63
+ tolerations: chosen.tolerations,
64
+ };
65
+ }
66
+ exports.getPodPlacement = getPodPlacement;
67
+ // On a server everything made for a run carries its id, so the server removes all of
68
+ // it when the run ends, and a NetworkPolicy can select the pods of a run.
69
+ exports.RUN_LABEL = 'hammerkit.dev/run';
70
+ /**
71
+ * The value of the run label. A run id is base64url and may end in `-` or `_`, which a
72
+ * label value can't, so the label carries a hex digest of it.
73
+ */
74
+ function getRunLabelValue(run) {
75
+ return (0, crypto_1.createHash)('sha256').update(run).digest('hex').slice(0, 40);
76
+ }
77
+ exports.getRunLabelValue = getRunLabelValue;
78
+ function getRunLabels(kubernetes) {
79
+ return kubernetes.server ? { [exports.RUN_LABEL]: getRunLabelValue(kubernetes.server.run) } : {};
80
+ }
81
+ exports.getRunLabels = getRunLabels;
82
+ /**
83
+ * What a container of a run may do on a server, where it shares nodes with the pods of
84
+ * other callers: no privilege escalation (setuid binaries such as sudo), no raw sockets
85
+ * (spoofing on a bridge network) and the runtime's seccomp profile.
86
+ */
87
+ function getContainerSecurity(kubernetes) {
88
+ return kubernetes.server
89
+ ? {
90
+ securityContext: {
91
+ allowPrivilegeEscalation: false,
92
+ capabilities: { drop: ['NET_RAW'] },
93
+ seccompProfile: { type: 'RuntimeDefault' },
94
+ },
95
+ }
96
+ : {};
97
+ }
98
+ exports.getContainerSecurity = getContainerSecurity;
99
+ //# sourceMappingURL=job-placement.js.map
@@ -0,0 +1,306 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getJobSecretSpec = exports.removeJobCredentials = exports.assertJobAccounts = exports.ensureJobCredentials = exports.getCredentialsName = exports.assertSecretsReachable = exports.splitJobSecrets = exports.ACCOUNT_PATH = exports.SECRET_VALUES_PATH = void 0;
4
+ const work_secret_1 = require("../planner/work-secret");
5
+ const command_secret_provider_1 = require("../secrets/command-secret-provider");
6
+ const version_1 = require("../version");
7
+ const apply_1 = require("./apply");
8
+ const resources_1 = require("./resources");
9
+ const job_placement_1 = require("./job-placement");
10
+ const work_network_1 = require("../planner/work-network");
11
+ // Where the values are in the pod (an in-memory volume), and where the init container
12
+ // of a secret finds the credential of its account
13
+ exports.SECRET_VALUES_PATH = '/var/run/hammerkit/secrets';
14
+ exports.ACCOUNT_PATH = '/var/run/hammerkit/account';
15
+ const VALUES_VOLUME = 'hammerkit-secret-values';
16
+ // a token is made for each Job right before it is created; it has to outlast the
17
+ // time its pod waits for a node
18
+ const TOKEN_SECONDS = 3600;
19
+ /**
20
+ * Which secrets of a task the Job reads itself. On a server's cluster the secrets of
21
+ * a provider are read in the Job, by an init container per secret with only its
22
+ * account's credential, so the server holds no secret manager credential and sees no
23
+ * value. The others (`env:`, the server's own variables) are handed over in a Secret
24
+ * of the task, as without a server.
25
+ */
26
+ function splitJobSecrets(secrets, server) {
27
+ if (!server) {
28
+ return { delivered: secrets, inJob: [] };
29
+ }
30
+ const delivered = [];
31
+ const inJob = [];
32
+ for (const secret of secrets) {
33
+ if (secret.source.type === 'provider') {
34
+ inJob.push({ index: inJob.length, secret, source: secret.source });
35
+ }
36
+ else {
37
+ delivered.push(secret);
38
+ }
39
+ }
40
+ return { delivered, inJob };
41
+ }
42
+ exports.splitJobSecrets = splitJobSecrets;
43
+ /**
44
+ * The init containers that read secrets share the pod, and so the NetworkPolicy, of
45
+ * the task: under a limited network they could not reach the secret manager. Until
46
+ * they run in a pod of their own, a task that reads a provider's secret in its Job
47
+ * keeps an open network.
48
+ */
49
+ function assertSecretsReachable(task, secrets, network = (0, work_network_1.getEffectiveNetwork)(task.data)) {
50
+ if (secrets.length === 0 || network.type === 'open') {
51
+ return;
52
+ }
53
+ const secret = secrets[0];
54
+ throw new Error(`task ${task.name}: secret ${(0, work_secret_1.getSecretName)(secret.secret)} is read from ${(0, work_secret_1.describeSecretSource)(secret.secret)} in the Job of the task, which network ${(0, work_network_1.formatNetwork)(network)} would cut off from it; ` +
55
+ 'on this cluster a task that reads secrets of a provider keeps an open network');
56
+ }
57
+ exports.assertSecretsReachable = assertSecretsReachable;
58
+ // The accounts of the secrets, in the order they are first read
59
+ function accountsOf(secrets) {
60
+ return [...new Set(secrets.map((s) => s.source.account))];
61
+ }
62
+ function getCredentialsName(item) {
63
+ return (0, resources_1.getResourceName)(item, '-credentials');
64
+ }
65
+ exports.getCredentialsName = getCredentialsName;
66
+ // The keys of the credentials Secret: `<account>.token` and `<account>.file.<key>`
67
+ function tokenKey(account) {
68
+ return `${account}.token`;
69
+ }
70
+ function fileKey(account, key) {
71
+ return `${account}.file.${key}`;
72
+ }
73
+ /**
74
+ * The credentials the init containers need, in a Secret of the task that only they
75
+ * mount: per account, a short-lived token of its ServiceAccount and the files of its
76
+ * Secret, both from the server's namespace. Removed with the task's other Secret.
77
+ */
78
+ async function ensureJobCredentials(instance, kubernetes, item, secrets, admitted) {
79
+ const server = kubernetes.server;
80
+ const files = {};
81
+ if (!server || secrets.length === 0) {
82
+ return files;
83
+ }
84
+ assertJobAccounts(secrets, server, admitted);
85
+ const data = {};
86
+ for (const account of accountsOf(secrets)) {
87
+ const credentials = server.accounts[account];
88
+ files[account] = [];
89
+ if (credentials.serviceAccount) {
90
+ const response = await instance.coreApi.createNamespacedServiceAccountToken({
91
+ name: credentials.serviceAccount,
92
+ namespace: server.namespace,
93
+ body: {
94
+ spec: {
95
+ audiences: credentials.audience ? [credentials.audience] : [],
96
+ expirationSeconds: TOKEN_SECONDS,
97
+ },
98
+ },
99
+ });
100
+ const token = response.status?.token;
101
+ if (!token) {
102
+ throw new Error(`no token was issued for the ServiceAccount ${credentials.serviceAccount} of ${account}`);
103
+ }
104
+ data[tokenKey(account)] = Buffer.from(token, 'utf8').toString('base64');
105
+ files[account].push({ key: tokenKey(account), path: 'token' });
106
+ }
107
+ if (credentials.secret) {
108
+ let content;
109
+ try {
110
+ const secret = await instance.coreApi.readNamespacedSecret({
111
+ name: credentials.secret,
112
+ namespace: server.namespace,
113
+ });
114
+ content = secret.data ?? {};
115
+ }
116
+ catch (e) {
117
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
118
+ throw new Error(`the Secret ${credentials.secret} of ${account} is not in ${server.namespace}`);
119
+ }
120
+ throw e;
121
+ }
122
+ for (const [key, value] of Object.entries(content)) {
123
+ data[fileKey(account, key)] = value;
124
+ files[account].push({ key: fileKey(account, key), path: key });
125
+ }
126
+ }
127
+ }
128
+ const spec = {
129
+ kind: 'Secret',
130
+ apiVersion: 'v1',
131
+ metadata: {
132
+ namespace: kubernetes.namespace,
133
+ name: getCredentialsName(item),
134
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
135
+ labels: { 'hammerkit.dev/id': item.id(), ...(0, job_placement_1.getRunLabels)(kubernetes) },
136
+ },
137
+ type: 'Opaque',
138
+ data,
139
+ };
140
+ await (0, apply_1.apply)(instance, spec);
141
+ return files;
142
+ }
143
+ exports.ensureJobCredentials = ensureJobCredentials;
144
+ /**
145
+ * Refuses a secret of an account the run was not admitted for, or of one without a
146
+ * credential on the cluster: its command would run as whatever the node or the image
147
+ * of the provider is, which every account shares. Before anything is minted or copied.
148
+ */
149
+ function assertJobAccounts(secrets, server, admitted) {
150
+ for (const { secret, source } of secrets) {
151
+ const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
152
+ if (admitted && !admitted.includes(source.account)) {
153
+ throw new Error(`${label}: the run was not admitted for the account ${source.account}`);
154
+ }
155
+ if (!server.accounts[source.account]) {
156
+ throw new Error(`${label}: the account ${source.account} has no credential on this cluster (accounts.${source.account}.kubernetes)`);
157
+ }
158
+ }
159
+ }
160
+ exports.assertJobAccounts = assertJobAccounts;
161
+ async function removeJobCredentials(instance, kubernetes, item) {
162
+ if (!kubernetes.server) {
163
+ return;
164
+ }
165
+ try {
166
+ await instance.coreApi.deleteNamespacedSecret({ name: getCredentialsName(item), namespace: kubernetes.namespace });
167
+ }
168
+ catch (e) {
169
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
170
+ throw e;
171
+ }
172
+ }
173
+ }
174
+ exports.removeJobCredentials = removeJobCredentials;
175
+ // A string as one word of a POSIX shell
176
+ function quote(value) {
177
+ return `'${value.replace(/'/g, `'\\''`)}'`;
178
+ }
179
+ /**
180
+ * What the pod of a task needs to read its secrets itself. Each secret gets an init
181
+ * container in the image of its provider, which runs the provider's command (no
182
+ * shell between the reference and the command) with the provider's and its
183
+ * account's variables and the account's credential under /var/run/hammerkit/account,
184
+ * and writes the value verbatim into a volume in memory. The task's container mounts
185
+ * that volume only: a `path` secret is the file itself, an `env` secret is exported
186
+ * from it, and the task's output is masked before it leaves the pod, since the
187
+ * server, which masks everything else, never sees these values.
188
+ */
189
+ function getJobSecretSpec(item, secrets, server, credentials, environment) {
190
+ if (!server || secrets.length === 0) {
191
+ return {
192
+ initContainers: [],
193
+ volumes: [],
194
+ mounts: [],
195
+ command: (shell, cmd) => ({ command: [shell, '-c'], args: [cmd], env: [] }),
196
+ };
197
+ }
198
+ const volumes = [{ name: VALUES_VOLUME, emptyDir: { medium: 'Memory' } }];
199
+ // a volume per account with only its keys, mounted by the init containers of its secrets
200
+ const accountVolume = new Map();
201
+ accountsOf(secrets).forEach((account, index) => {
202
+ const items = credentials[account] ?? [];
203
+ if (items.length === 0) {
204
+ return;
205
+ }
206
+ const name = `hammerkit-account-${index}`;
207
+ accountVolume.set(account, name);
208
+ volumes.push({ name, secret: { secretName: getCredentialsName(item), items, defaultMode: 0o444 } });
209
+ });
210
+ const initContainers = secrets.map(({ index, secret, source }) => {
211
+ const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
212
+ // what the run was admitted for, as when the server reads a secret itself
213
+ const allowed = environment.hosted?.accounts;
214
+ if (allowed && !allowed.includes(source.account)) {
215
+ throw new Error(`${label}: the run was not admitted for the account ${source.account}`);
216
+ }
217
+ const image = server.providers[source.provider]?.image;
218
+ if (!image) {
219
+ throw new Error(`${label}: this server names no image for the provider ${source.provider}`);
220
+ }
221
+ const { command, args, env } = (0, command_secret_provider_1.getProviderInvocation)(source.binding, source.ref, environment);
222
+ const volume = accountVolume.get(source.account);
223
+ return {
224
+ name: `secret-${index}`,
225
+ image,
226
+ // the value as the command prints it, written once it is complete
227
+ command: [
228
+ 'sh',
229
+ '-c',
230
+ 'umask 022 && "$@" > "$HAMMERKIT_VALUE.part" && mv "$HAMMERKIT_VALUE.part" "$HAMMERKIT_VALUE"',
231
+ ],
232
+ args: ['sh', command, ...args],
233
+ env: [
234
+ ...Object.entries(env).map(([name, value]) => ({ name, value })),
235
+ { name: 'HAMMERKIT_VALUE', value: `${exports.SECRET_VALUES_PATH}/secret-${index}` },
236
+ ],
237
+ volumeMounts: [
238
+ { name: VALUES_VOLUME, mountPath: exports.SECRET_VALUES_PATH },
239
+ ...(volume ? [{ name: volume, mountPath: exports.ACCOUNT_PATH, readOnly: true }] : []),
240
+ ],
241
+ ...(0, job_placement_1.getContainerSecurity)({ server }),
242
+ };
243
+ });
244
+ const mounts = [{ name: VALUES_VOLUME, mountPath: exports.SECRET_VALUES_PATH }];
245
+ const exportLines = [];
246
+ for (const { index, secret } of secrets) {
247
+ if (secret.target.type === 'file') {
248
+ mounts.push({ name: VALUES_VOLUME, mountPath: secret.target.path, subPath: `secret-${index}`, readOnly: true });
249
+ }
250
+ else {
251
+ // verbatim: the x keeps the trailing newlines command substitution would drop
252
+ const name = secret.target.name;
253
+ exportLines.push(`${name}="$(cat "$HAMMERKIT_SECRETS/secret-${index}"; printf x)" && ${name}="\${${name}%x}" && export ${name}`);
254
+ }
255
+ }
256
+ const script = [
257
+ ...exportLines,
258
+ `command -v awk >/dev/null 2>&1 || { echo 'hammerkit: the image has no awk, which masks the secrets of this task in its output' >&2; exit 127; }`,
259
+ `{ "$HAMMERKIT_SHELL" -c "$HAMMERKIT_CMD"; echo $? > "$HAMMERKIT_SECRETS/.status"; } 2>&1 | awk -v dir="$HAMMERKIT_SECRETS" -v count=${secrets.length} ${quote(MASK_PROGRAM)}`,
260
+ `exit "$(cat "$HAMMERKIT_SECRETS/.status" 2>/dev/null || echo 1)"`,
261
+ ].join('\n');
262
+ return {
263
+ initContainers,
264
+ volumes,
265
+ mounts,
266
+ command: (shell, cmd) => ({
267
+ command: [shell, '-c'],
268
+ args: [script],
269
+ env: [
270
+ { name: 'HAMMERKIT_SECRETS', value: exports.SECRET_VALUES_PATH },
271
+ { name: 'HAMMERKIT_SHELL', value: shell },
272
+ { name: 'HAMMERKIT_CMD', value: cmd },
273
+ ],
274
+ }),
275
+ };
276
+ }
277
+ exports.getJobSecretSpec = getJobSecretSpec;
278
+ // Every line of every value (trimmed, 4 characters or more, like the server's own
279
+ // masking) is replaced by *** in each line of output. index() and not a regular
280
+ // expression, so a value is matched as written.
281
+ const MASK_PROGRAM = `
282
+ function mask(s, v, out, i) {
283
+ out = ""
284
+ while ((i = index(s, v)) > 0) { out = out substr(s, 1, i - 1) "***"; s = substr(s, i + length(v)) }
285
+ return out s
286
+ }
287
+ BEGIN {
288
+ n = 0
289
+ for (k = 0; k < count; k++) {
290
+ f = dir "/secret-" k
291
+ while ((getline line < f) > 0) {
292
+ gsub(/^[ \\t\\r]+|[ \\t\\r]+$/, "", line)
293
+ if (length(line) >= 4) values[n++] = line
294
+ }
295
+ close(f)
296
+ }
297
+ # longest first, so a value that holds another is masked as a whole
298
+ for (a = 0; a < n; a++) for (b = a + 1; b < n; b++) if (length(values[b]) > length(values[a])) { t = values[a]; values[a] = values[b]; values[b] = t }
299
+ }
300
+ {
301
+ out = $0
302
+ for (k = 0; k < n; k++) out = mask(out, values[k])
303
+ print out
304
+ fflush()
305
+ }`;
306
+ //# sourceMappingURL=job-secrets.js.map
@@ -0,0 +1,191 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.evictKeptClaims = exports.sweepKeptClaims = exports.expireKeptClaims = exports.recordClaimState = exports.forgetClaimState = exports.useClaimState = exports.isKeptClaim = exports.getClaimName = exports.EXPIRES_ANNOTATION = exports.STATE_ANNOTATION = exports.KEPT_BY_LABEL = exports.VOLUME_SCOPE_LABEL = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const apply_1 = require("./apply");
6
+ const sleep_1 = require("../utils/sleep");
7
+ const job_placement_1 = require("./job-placement");
8
+ // On a server with `backend.keepVolumes`, the claim of a task stays after the run, so the
9
+ // next run of the same source finds the outputs in it and mounts them without restoring
10
+ // them from the cache. A kept claim has no run label (the end of the run removes what has
11
+ // one), but these:
12
+ // the trust of the source of the runs that may mount it (see getVolumeScope)
13
+ exports.VOLUME_SCOPE_LABEL = 'hammerkit.dev/volume-scope';
14
+ // the run label of the run that mounted it last; while it runs, the claim does not expire
15
+ exports.KEPT_BY_LABEL = 'hammerkit.dev/kept-by';
16
+ // the state key of the outputs in it, set once the task completed or they were restored
17
+ exports.STATE_ANNOTATION = 'hammerkit.dev/state';
18
+ // when it goes, set by the end of the run that mounted it last
19
+ exports.EXPIRES_ANNOTATION = 'hammerkit.dev/expires';
20
+ // The claim of a work item: a task's on a server that keeps them carries the scope, so a
21
+ // run of another source never mounts it. 63 characters at most, a pod's volume name.
22
+ function getClaimName(work, kubernetes) {
23
+ const scope = kubernetes?.server?.volumeScope;
24
+ const name = 'hammerkit-' + work.id();
25
+ return scope && isTask(work) ? `${name}-${(0, crypto_1.createHash)('sha256').update(scope).digest('hex').slice(0, 12)}` : name;
26
+ }
27
+ exports.getClaimName = getClaimName;
28
+ // whether the claim of the work item stays after the run
29
+ function isKeptClaim(work, kubernetes) {
30
+ return !!kubernetes.server?.volumeScope && isTask(work);
31
+ }
32
+ exports.isKeptClaim = isKeptClaim;
33
+ function isTask(work) {
34
+ return work.data.type === 'container-task';
35
+ }
36
+ async function readClaim(instance, namespace, name) {
37
+ try {
38
+ return await instance.coreApi.readNamespacedPersistentVolumeClaim({ name, namespace });
39
+ }
40
+ catch (e) {
41
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
42
+ return null;
43
+ }
44
+ throw e;
45
+ }
46
+ }
47
+ // The state key of the outputs a kept claim holds, null when it holds none that are whole.
48
+ // The claim is the run's from now: it does not expire or make way for another claim
49
+ // before the run ended, so outputs the run found in it are still there when it mounts them.
50
+ async function useClaimState(instance, kubernetes, name) {
51
+ let claim;
52
+ try {
53
+ claim = await instance.objectApi.patch({
54
+ apiVersion: 'v1',
55
+ kind: 'PersistentVolumeClaim',
56
+ metadata: {
57
+ name,
58
+ namespace: kubernetes.namespace,
59
+ labels: { [exports.KEPT_BY_LABEL]: (0, job_placement_1.getRunLabelValue)(kubernetes.server?.run ?? '') },
60
+ // null removes it
61
+ annotations: { [exports.EXPIRES_ANNOTATION]: null },
62
+ },
63
+ });
64
+ }
65
+ catch (e) {
66
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
67
+ return null;
68
+ }
69
+ throw e;
70
+ }
71
+ // one being removed holds nothing to mount, nor one of another source (ensured anyway
72
+ // by the hash of the source in its name)
73
+ if (claim.metadata?.deletionTimestamp ||
74
+ claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] !== kubernetes.server?.volumeScope) {
75
+ return null;
76
+ }
77
+ return claim.metadata?.annotations?.[exports.STATE_ANNOTATION] ?? null;
78
+ }
79
+ exports.useClaimState = useClaimState;
80
+ async function annotate(instance, namespace, name, annotations) {
81
+ try {
82
+ await instance.objectApi.patch({
83
+ apiVersion: 'v1',
84
+ kind: 'PersistentVolumeClaim',
85
+ metadata: { name, namespace, annotations: annotations },
86
+ });
87
+ return true;
88
+ }
89
+ catch (e) {
90
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
91
+ return false;
92
+ }
93
+ throw e;
94
+ }
95
+ }
96
+ // Before a task writes to its kept claim: the outputs in it stop being those of a state.
97
+ // Whether the claim was there, with what an earlier run left in it.
98
+ function forgetClaimState(instance, kubernetes, name) {
99
+ return annotate(instance, kubernetes.namespace, name, { [exports.STATE_ANNOTATION]: null });
100
+ }
101
+ exports.forgetClaimState = forgetClaimState;
102
+ // Once the outputs of `stateKey` are whole in the kept claim
103
+ async function recordClaimState(instance, kubernetes, name, stateKey) {
104
+ await annotate(instance, kubernetes.namespace, name, { [exports.STATE_ANNOTATION]: stateKey });
105
+ }
106
+ exports.recordClaimState = recordClaimState;
107
+ // The kept claims in a namespace
108
+ async function listKeptClaims(instance, namespace, labelSelector = exports.VOLUME_SCOPE_LABEL) {
109
+ const claims = await instance.coreApi.listNamespacedPersistentVolumeClaim({ namespace, labelSelector });
110
+ return claims.items.filter((claim) => !claim.metadata?.deletionTimestamp);
111
+ }
112
+ async function removeClaims(instance, namespace, claims, timeout) {
113
+ const names = [];
114
+ for (const claim of claims) {
115
+ const name = claim.metadata?.name;
116
+ if (!name) {
117
+ continue;
118
+ }
119
+ try {
120
+ // only as it was listed: one a run took for itself since (useClaimState) stays
121
+ await instance.coreApi.deleteNamespacedPersistentVolumeClaim({
122
+ name,
123
+ namespace,
124
+ body: { preconditions: { resourceVersion: claim.metadata?.resourceVersion } },
125
+ });
126
+ names.push(name);
127
+ }
128
+ catch (e) {
129
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
130
+ names.push(name);
131
+ }
132
+ else if ((0, apply_1.statusCodeOf)(e) !== 409) {
133
+ throw e;
134
+ }
135
+ }
136
+ }
137
+ // a run mounts a claim of the same name again
138
+ const deadline = Date.now() + timeout;
139
+ for (const name of names) {
140
+ while (await readClaim(instance, namespace, name)) {
141
+ if (Date.now() > deadline) {
142
+ throw new Error(`the claim ${name} is still there after ${Math.round(timeout / 1000)}s`);
143
+ }
144
+ await (0, sleep_1.sleep)(1000);
145
+ }
146
+ }
147
+ return names;
148
+ }
149
+ // At the end of a run: the claims it kept go `keep` milliseconds from now, unless a run
150
+ // mounts them before
151
+ async function expireKeptClaims(instance, namespace, runLabel, expires) {
152
+ for (const claim of await listKeptClaims(instance, namespace, `${exports.KEPT_BY_LABEL}=${runLabel}`)) {
153
+ if (claim.metadata?.name) {
154
+ await annotate(instance, namespace, claim.metadata.name, { [exports.EXPIRES_ANNOTATION]: expires.toISOString() });
155
+ }
156
+ }
157
+ }
158
+ exports.expireKeptClaims = expireKeptClaims;
159
+ // While no run of the caller is in the namespace: the kept claims that expired, and those
160
+ // a run left without an expiry because the server stopped before its end (all of them
161
+ // when the server keeps none any more). The names of those removed, and when the next of
162
+ // the others expires.
163
+ async function sweepKeptClaims(instance, namespace, options) {
164
+ const expired = [];
165
+ let next = null;
166
+ for (const claim of await listKeptClaims(instance, namespace)) {
167
+ const expires = Date.parse(claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION] ?? '');
168
+ if (!options.keep || !(expires > options.now.getTime())) {
169
+ expired.push(claim);
170
+ }
171
+ else if (next === null || expires < next) {
172
+ next = expires;
173
+ }
174
+ }
175
+ const removed = await removeClaims(instance, namespace, expired, options.timeout);
176
+ return { removed, next: next === null ? null : new Date(next) };
177
+ }
178
+ exports.sweepKeptClaims = sweepKeptClaims;
179
+ // When a run needs room under the quota of its namespace: the kept claims it does not use
180
+ // go, those of the other sources and those of its own a task of this run did not take (one
181
+ // renamed or changed since). The names of those removed.
182
+ async function evictKeptClaims(instance, kubernetes, timeout) {
183
+ const server = kubernetes.server;
184
+ if (!server?.volumeScope) {
185
+ return [];
186
+ }
187
+ const unused = await listKeptClaims(instance, kubernetes.namespace, `${exports.VOLUME_SCOPE_LABEL},${exports.KEPT_BY_LABEL}!=${(0, job_placement_1.getRunLabelValue)(server.run)}`);
188
+ return removeClaims(instance, kubernetes.namespace, unused, timeout);
189
+ }
190
+ exports.evictKeptClaims = evictKeptClaims;
191
+ //# sourceMappingURL=kept-claims.js.map
@@ -12,23 +12,31 @@ function createKubernetesInstances(kubernetes) {
12
12
  else {
13
13
  kc.loadFromDefault();
14
14
  }
15
- kc.setCurrentContext(kubernetes.context);
15
+ if (kubernetes.context) {
16
+ kc.setCurrentContext(kubernetes.context);
17
+ }
16
18
  const coreApi = kc.makeApiClient(client_node_1.CoreV1Api);
17
19
  const batchApi = kc.makeApiClient(client_node_1.BatchV1Api);
18
20
  const appsApi = kc.makeApiClient(client_node_1.AppsV1Api);
19
21
  const objectApi = kc.makeApiClient(client_node_1.KubernetesObjectApi);
22
+ const authApi = kc.makeApiClient(client_node_1.AuthenticationV1Api);
20
23
  const exec = new client_node_1.Exec(kc);
21
24
  return {
22
25
  objectApi,
26
+ log: new client_node_1.Log(kc),
23
27
  exec,
24
28
  appsApi,
25
29
  coreApi,
26
30
  batchApi,
31
+ authApi,
27
32
  };
28
33
  }
29
34
  catch (e) {
30
35
  if ((0, log_1.getErrorMessage)(e) === 'No active cluster!') {
31
- if (kubernetes.kubeConfig) {
36
+ if (!kubernetes.context) {
37
+ throw new Error('No cluster found: no kubeconfig names a current context, and this is not a pod of a cluster');
38
+ }
39
+ else if (kubernetes.kubeConfig) {
32
40
  throw new Error(`No cluster found for context ${kubernetes.context} in ${kubernetes.kubeConfig}`);
33
41
  }
34
42
  else {