hammerkit 1.27.0-next.2 → 1.27.0-next.21

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 (103) hide show
  1. package/build.schema.json +27 -0
  2. package/dist/cache/backends/registry-cache-backend.js +2 -2
  3. package/dist/cache/explain.js +17 -0
  4. package/dist/cli.js +1 -0
  5. package/dist/docker/item-network.js +13 -5
  6. package/dist/executer/deadline.js +17 -3
  7. package/dist/executer/docker-service.js +30 -8
  8. package/dist/executer/event-cache.js +20 -2
  9. package/dist/executer/execute-work-task.js +5 -3
  10. package/dist/executer/execution-steps.js +25 -4
  11. package/dist/executer/reset-work-tree.js +4 -0
  12. package/dist/executer/scheduler/enqueue-next.js +7 -0
  13. package/dist/executer/scheduler/state-key.js +42 -5
  14. package/dist/harness/connect.js +20 -6
  15. package/dist/harness/mcp-command.js +22 -6
  16. package/dist/harness/mcp.js +63 -114
  17. package/dist/harness/opencode.js +158 -50
  18. package/dist/harness/pi.js +29 -0
  19. package/dist/harness/sandbox-client.js +207 -65
  20. package/dist/harness/service-tools.js +141 -0
  21. package/dist/harness/task-tools.js +221 -0
  22. package/dist/kubernetes/apply.js +27 -16
  23. package/dist/kubernetes/claim-layout.js +71 -0
  24. package/dist/kubernetes/ensure-http-route.js +1 -1
  25. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +5 -9
  26. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +1 -1
  27. package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
  28. package/dist/kubernetes/ensure-persistent-data.js +30 -20
  29. package/dist/kubernetes/get-service-ip.js +2 -2
  30. package/dist/kubernetes/job-secrets.js +33 -19
  31. package/dist/kubernetes/kept-claims.js +22 -10
  32. package/dist/kubernetes/network-policy.js +85 -27
  33. package/dist/kubernetes/remove-persistent-data.js +3 -1
  34. package/dist/kubernetes/resources.js +25 -3
  35. package/dist/kubernetes/restore-kubernetes-data.js +9 -3
  36. package/dist/kubernetes/sandbox-runner.js +626 -0
  37. package/dist/kubernetes/secrets.js +7 -11
  38. package/dist/kubernetes/volumes.js +6 -3
  39. package/dist/network/egress-proxy.js +45 -6
  40. package/dist/optimizer/calculate-checksum.js +3 -2
  41. package/dist/optimizer/check-key.js +94 -0
  42. package/dist/optimizer/check-keys.js +64 -0
  43. package/dist/optimizer/get-work-cache-stats.js +17 -3
  44. package/dist/optimizer/work-task-cache-description.js +51 -1
  45. package/dist/planner/utils/append-work-dependencies.js +10 -5
  46. package/dist/planner/utils/append-work-service.js +1 -0
  47. package/dist/planner/utils/append-work-task.js +15 -2
  48. package/dist/planner/utils/get-work-runtime.js +6 -1
  49. package/dist/planner/utils/normalize-path.js +19 -1
  50. package/dist/planner/utils/parse-work-mount.js +7 -1
  51. package/dist/planner/utils/parse-work-port.js +4 -1
  52. package/dist/planner/utils/task-arguments.js +3 -0
  53. package/dist/planner/validate.js +22 -0
  54. package/dist/planner/work-network.js +3 -1
  55. package/dist/planner/work-runtime-aggregate.js +32 -0
  56. package/dist/planner/work-runtime-docker.js +2 -1
  57. package/dist/planner/work-runtime-kubernetes.js +97 -48
  58. package/dist/planner/work-runtime-local.js +2 -1
  59. package/dist/planner/work-task.js +6 -1
  60. package/dist/program.js +35 -13
  61. package/dist/remote/annotations.js +13 -1
  62. package/dist/remote/device-login.js +7 -2
  63. package/dist/remote/host-config.js +64 -3
  64. package/dist/remote/remote-commands.js +41 -9
  65. package/dist/remote/remote-work-commands.js +246 -1
  66. package/dist/remote/server-client.js +15 -2
  67. package/dist/remote/token-source.js +64 -26
  68. package/dist/run-program.js +9 -0
  69. package/dist/schema/build-file-base-task-schema.js +5 -1
  70. package/dist/schema/build-file-container-service-schema.js +2 -0
  71. package/dist/schema/build-file-include-schema.js +10 -4
  72. package/dist/schema/reference-parser.js +9 -1
  73. package/dist/schema/schema-parser.js +36 -4
  74. package/dist/schema/work-scope-parser.js +33 -10
  75. package/dist/secrets/github-app-token-command.js +3 -1
  76. package/dist/server/accounts-api.js +3 -1
  77. package/dist/server/approvals-api.js +1 -1
  78. package/dist/server/checks-api.js +83 -0
  79. package/dist/server/checks.js +116 -0
  80. package/dist/server/definitions-api.js +4 -2
  81. package/dist/server/endpoints.js +612 -0
  82. package/dist/server/executor.js +56 -5
  83. package/dist/server/kubernetes-backend.js +303 -64
  84. package/dist/server/kubernetes-sandbox-storage.js +48 -3
  85. package/dist/server/oauth.js +12 -1
  86. package/dist/server/plan.js +126 -62
  87. package/dist/server/policy.js +32 -2
  88. package/dist/server/repo-access.js +5 -0
  89. package/dist/server/run-plan.js +50 -19
  90. package/dist/server/run-request.js +64 -3
  91. package/dist/server/run-worker.js +146 -10
  92. package/dist/server/runs.js +347 -27
  93. package/dist/server/sandbox-pod.js +28 -0
  94. package/dist/server/sandbox-storage.js +23 -0
  95. package/dist/server/sandboxes-api.js +10 -4
  96. package/dist/server/sandboxes.js +8 -0
  97. package/dist/server/server-command.js +39 -20
  98. package/dist/server/server-config.js +213 -14
  99. package/dist/server/server.js +268 -15
  100. package/dist/server/service-runs.js +82 -0
  101. package/dist/utils/abort-on-signals.js +46 -6
  102. package/dist/utils/tar.js +36 -0
  103. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
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;
3
+ exports.getJobSecretSpec = exports.removeJobCredentials = exports.assertJobAccounts = exports.ensureJobCredentials = exports.getCredentialsName = exports.getSecretHosts = exports.splitJobSecrets = exports.ACCOUNT_PATH = exports.SECRET_VALUES_PATH = void 0;
4
4
  const work_secret_1 = require("../planner/work-secret");
5
5
  const command_secret_provider_1 = require("../secrets/command-secret-provider");
6
+ const task_arguments_1 = require("../planner/utils/task-arguments");
6
7
  const version_1 = require("../version");
7
8
  const apply_1 = require("./apply");
8
9
  const resources_1 = require("./resources");
@@ -42,25 +43,30 @@ function splitJobSecrets(secrets, server) {
42
43
  exports.splitJobSecrets = splitJobSecrets;
43
44
  /**
44
45
  * 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.
46
+ * the task: under a limited network they reach the secret manager only through the
47
+ * task's egress proxy, which lets them reach the endpoints the server names for the
48
+ * provider (`backend.providers.<name>.allow`). A task that reads a secret of a provider
49
+ * without them keeps an open network. Answers the endpoints the proxy lets through.
48
50
  */
49
- function assertSecretsReachable(task, secrets, network = (0, work_network_1.getEffectiveNetwork)(task.data)) {
50
- if (secrets.length === 0 || network.type === 'open') {
51
- return;
51
+ function getSecretHosts(task, secrets, server, network = (0, work_network_1.getEffectiveNetwork)(task.data)) {
52
+ if (!server || secrets.length === 0 || network.type === 'open') {
53
+ return [];
54
+ }
55
+ const unreachable = secrets.find((secret) => !server.providers[secret.source.provider]?.allow?.length);
56
+ if (unreachable) {
57
+ throw new Error(`task ${task.name}: secret ${(0, work_secret_1.getSecretName)(unreachable.secret)} is read from ${(0, work_secret_1.describeSecretSource)(unreachable.secret)} in the Job of the task, which network ${(0, work_network_1.formatNetwork)(network)} would cut off from it; ` +
58
+ `on this cluster a task that reads secrets of ${unreachable.source.provider} keeps an open network, ` +
59
+ `unless the server names its endpoints in backend.providers.${unreachable.source.provider}.allow`);
52
60
  }
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');
61
+ return [...new Set(secrets.flatMap((secret) => server.providers[secret.source.provider].allow ?? []))].sort();
56
62
  }
57
- exports.assertSecretsReachable = assertSecretsReachable;
63
+ exports.getSecretHosts = getSecretHosts;
58
64
  // The accounts of the secrets, in the order they are first read
59
65
  function accountsOf(secrets) {
60
66
  return [...new Set(secrets.map((s) => s.source.account))];
61
67
  }
62
- function getCredentialsName(item) {
63
- return (0, resources_1.getResourceName)(item, '-credentials');
68
+ function getCredentialsName(item, kubernetes) {
69
+ return (0, resources_1.getResourceName)(item, kubernetes, '-credentials');
64
70
  }
65
71
  exports.getCredentialsName = getCredentialsName;
66
72
  // The keys of the credentials Secret: `<account>.token` and `<account>.file.<key>`
@@ -130,9 +136,9 @@ async function ensureJobCredentials(instance, kubernetes, item, secrets, admitte
130
136
  apiVersion: 'v1',
131
137
  metadata: {
132
138
  namespace: kubernetes.namespace,
133
- name: getCredentialsName(item),
139
+ name: getCredentialsName(item, kubernetes),
134
140
  annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
135
- labels: { 'hammerkit.dev/id': item.id(), ...(0, job_placement_1.getRunLabels)(kubernetes) },
141
+ labels: (0, resources_1.getItemLabels)(item, kubernetes),
136
142
  },
137
143
  type: 'Opaque',
138
144
  data,
@@ -163,7 +169,10 @@ async function removeJobCredentials(instance, kubernetes, item) {
163
169
  return;
164
170
  }
165
171
  try {
166
- await instance.coreApi.deleteNamespacedSecret({ name: getCredentialsName(item), namespace: kubernetes.namespace });
172
+ await instance.coreApi.deleteNamespacedSecret({
173
+ name: getCredentialsName(item, kubernetes),
174
+ namespace: kubernetes.namespace,
175
+ });
167
176
  }
168
177
  catch (e) {
169
178
  if ((0, apply_1.statusCodeOf)(e) !== 404) {
@@ -186,7 +195,9 @@ function quote(value) {
186
195
  * from it, and the task's output is masked before it leaves the pod, since the
187
196
  * server, which masks everything else, never sees these values.
188
197
  */
189
- function getJobSecretSpec(item, secrets, server, credentials, environment) {
198
+ function getJobSecretSpec(item, secrets, server, credentials, environment,
199
+ // under a limited network, what sends their requests through the egress proxy
200
+ proxyEnv = []) {
190
201
  if (!server || secrets.length === 0) {
191
202
  return {
192
203
  initContainers: [],
@@ -205,7 +216,7 @@ function getJobSecretSpec(item, secrets, server, credentials, environment) {
205
216
  }
206
217
  const name = `hammerkit-account-${index}`;
207
218
  accountVolume.set(account, name);
208
- volumes.push({ name, secret: { secretName: getCredentialsName(item), items, defaultMode: 0o444 } });
219
+ volumes.push({ name, secret: { secretName: getCredentialsName(item, { server }), items, defaultMode: 0o444 } });
209
220
  });
210
221
  const initContainers = secrets.map(({ index, secret, source }) => {
211
222
  const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
@@ -229,8 +240,11 @@ function getJobSecretSpec(item, secrets, server, credentials, environment) {
229
240
  '-c',
230
241
  'umask 022 && "$@" > "$HAMMERKIT_VALUE.part" && mv "$HAMMERKIT_VALUE.part" "$HAMMERKIT_VALUE"',
231
242
  ],
232
- args: ['sh', command, ...args],
243
+ // Kubernetes would expand a `$(NAME)` of the container's env in them, the proxy's token
244
+ // among them: the reference is text, as on any other backend
245
+ args: ['sh', command, ...args].map(task_arguments_1.escapeKubernetesText),
233
246
  env: [
247
+ ...proxyEnv,
234
248
  ...Object.entries(env).map(([name, value]) => ({ name, value })),
235
249
  { name: 'HAMMERKIT_VALUE', value: `${exports.SECRET_VALUES_PATH}/secret-${index}` },
236
250
  ],
@@ -5,6 +5,7 @@ const crypto_1 = require("crypto");
5
5
  const apply_1 = require("./apply");
6
6
  const sleep_1 = require("../utils/sleep");
7
7
  const job_placement_1 = require("./job-placement");
8
+ const resources_1 = require("./resources");
8
9
  // On a server with `backend.keepVolumes`, the claim of a task stays after the run, so the
9
10
  // next run of the same source finds the outputs in it and mounts them without restoring
10
11
  // them from the cache. A kept claim has no run label (the end of the run removes what has
@@ -18,11 +19,15 @@ exports.STATE_ANNOTATION = 'hammerkit.dev/state';
18
19
  // when it goes, set by the end of the run that mounted it last
19
20
  exports.EXPIRES_ANNOTATION = 'hammerkit.dev/expires';
20
21
  // 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
+ // run of another source never mounts it, and the runs of one scope go one at a time
23
+ // (KubernetesExecutor.exclusive). Any other claim on a server is the run's own, as the
24
+ // runs of a caller share their namespace. 63 characters at most, a pod's volume name.
22
25
  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
+ const scope = kubernetes.server?.volumeScope;
27
+ if (scope && isTask(work)) {
28
+ return `hammerkit-${work.id()}-${(0, crypto_1.createHash)('sha256').update(scope).digest('hex').slice(0, 12)}`;
29
+ }
30
+ return 'hammerkit-' + (0, resources_1.getItemKey)(work, kubernetes);
26
31
  }
27
32
  exports.getClaimName = getClaimName;
28
33
  // whether the claim of the work item stays after the run
@@ -156,14 +161,17 @@ async function expireKeptClaims(instance, namespace, runLabel, expires) {
156
161
  }
157
162
  }
158
163
  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.
164
+ // The kept claims that expired, and those a run left without an expiry because the server
165
+ // stopped before its end (all of them when the server keeps none any more), but those of
166
+ // the scopes `inUse`: a run of the scope executes and mounts them, or may in a moment.
167
+ // The names of those removed, and when the next of the others expires.
163
168
  async function sweepKeptClaims(instance, namespace, options) {
164
169
  const expired = [];
165
170
  let next = null;
166
171
  for (const claim of await listKeptClaims(instance, namespace)) {
172
+ if (options.inUse?.has(claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] ?? '')) {
173
+ continue;
174
+ }
167
175
  const expires = Date.parse(claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION] ?? '');
168
176
  if (!options.keep || !(expires > options.now.getTime())) {
169
177
  expired.push(claim);
@@ -178,14 +186,18 @@ async function sweepKeptClaims(instance, namespace, options) {
178
186
  exports.sweepKeptClaims = sweepKeptClaims;
179
187
  // When a run needs room under the quota of its namespace: the kept claims it does not use
180
188
  // 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.
189
+ // renamed or changed since). Of another scope, only one its last run released, which has an
190
+ // expiry: one without is mounted by a run of the caller that executes now (or was left by
191
+ // a server that stopped, which the next sweep removes). The names of those removed.
182
192
  async function evictKeptClaims(instance, kubernetes, timeout) {
183
193
  const server = kubernetes.server;
184
194
  if (!server?.volumeScope) {
185
195
  return [];
186
196
  }
187
197
  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);
198
+ const released = unused.filter((claim) => claim.metadata?.labels?.[exports.VOLUME_SCOPE_LABEL] === server.volumeScope ||
199
+ !!claim.metadata?.annotations?.[exports.EXPIRES_ANNOTATION]);
200
+ return removeClaims(instance, kubernetes.namespace, released, timeout);
189
201
  }
190
202
  exports.evictKeptClaims = evictKeptClaims;
191
203
  //# sourceMappingURL=kept-claims.js.map
@@ -17,12 +17,15 @@ const sleep_1 = require("../utils/sleep");
17
17
  const network_gate_1 = require("./network-gate");
18
18
  const job_placement_1 = require("./job-placement");
19
19
  Object.defineProperty(exports, "RUN_LABEL", { enumerable: true, get: function () { return job_placement_1.RUN_LABEL; } });
20
- const ID_LABEL = 'hammerkit.dev/id';
20
+ const resources_1 = require("./resources");
21
21
  const PROXY_LABEL = 'hammerkit.dev/proxy-for';
22
22
  const NETWORK_OF_LABEL = 'hammerkit.dev/network-of';
23
23
  // on the proxy of a task on a sandbox: the sandbox, whose pod of files it stays with
24
24
  const PROXY_OF_SANDBOX_LABEL = 'hammerkit.dev/proxy-of-sandbox';
25
25
  const PROXY_READY_TIMEOUT_MS = 60000;
26
+ // the token of the endpoints of secret providers, in a Secret named like the proxy
27
+ const SECRET_TOKEN_KEY = 'token';
28
+ const SECRET_TOKEN_VARIABLE = 'HAMMERKIT_PROXY_TOKEN';
26
29
  // how long the pods of an item may take to end before their policy goes
27
30
  const POD_END_TIMEOUT_MS = 120000;
28
31
  const POD_END_POLL_MS = 500;
@@ -83,7 +86,7 @@ function getNetworkPolicyName(item, scope) {
83
86
  exports.getNetworkPolicyName = getNetworkPolicyName;
84
87
  // the pods of the item, of the run when the scope has one
85
88
  function getItemPodLabels(item, scope) {
86
- return { [ID_LABEL]: item.id(), ...(scope.run ? { [job_placement_1.RUN_LABEL]: scope.run } : {}) };
89
+ return { [resources_1.ID_LABEL]: item.id(), ...(scope.run ? { [job_placement_1.RUN_LABEL]: scope.run } : {}) };
87
90
  }
88
91
  function toSelector(labels) {
89
92
  return Object.entries(labels)
@@ -104,15 +107,20 @@ function getIngress(env, item) {
104
107
  if (ids.length === 0) {
105
108
  return [];
106
109
  }
107
- const from = { matchExpressions: [{ key: ID_LABEL, operator: 'In', values: ids }] };
110
+ // on a server, of this run: the runs of a caller share their namespace
111
+ const run = (0, job_placement_1.getRunLabels)(env);
112
+ const from = {
113
+ matchExpressions: [{ key: resources_1.ID_LABEL, operator: 'In', values: ids }],
114
+ ...(Object.keys(run).length > 0 ? { matchLabels: run } : {}),
115
+ };
108
116
  // the client's name for `from`, a reserved word
109
117
  return [{ _from: [{ podSelector: from }] }];
110
118
  }
111
119
  // The NetworkPolicy of a limited item: egress to the pods of the services it
112
- // needs and, under `allow`, to its proxy; nothing else, DNS included (needs
113
- // and the proxy are host aliases). Ingress only from what needs it. An open
114
- // item has none.
115
- function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item, scope)) {
120
+ // needs and, under `allow` or with secrets read through it, to its proxy;
121
+ // nothing else, DNS included (needs and the proxy are host aliases). Ingress
122
+ // only from what needs it. An open item has none.
123
+ function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item, scope), proxied = network.type === 'allow') {
116
124
  const needs = item.needs.filter((need) => (0, work_item_1.isContainerWorkServiceItem)(need.service));
117
125
  return {
118
126
  apiVersion: 'networking.k8s.io/v1',
@@ -121,7 +129,8 @@ function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item,
121
129
  namespace: env.namespace,
122
130
  name: getNetworkPolicyName(item, scope),
123
131
  annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
124
- labels: { [NETWORK_OF_LABEL]: item.id(), ...(0, job_placement_1.getRunLabels)(env) },
132
+ labels: { [NETWORK_OF_LABEL]: item.id(), ...(scope.owners ? {} : (0, job_placement_1.getRunLabels)(env)) },
133
+ ...(scope.owners ? { ownerReferences: scope.owners } : {}),
125
134
  },
126
135
  spec: {
127
136
  podSelector: { matchLabels: getItemPodLabels(item, scope) },
@@ -129,9 +138,9 @@ function buildNetworkPolicy(env, item, network, scope, proxy = getRunProxy(item,
129
138
  ingress: getIngress(env, item),
130
139
  egress: [
131
140
  ...(needs.length > 0
132
- ? [{ to: needs.map((need) => ({ podSelector: { matchLabels: { [ID_LABEL]: need.service.id() } } })) }]
141
+ ? [{ to: needs.map((need) => ({ podSelector: { matchLabels: (0, resources_1.getItemLabels)(need.service, env) } })) }]
133
142
  : []),
134
- ...(network.type === 'allow'
143
+ ...(proxied
135
144
  ? [
136
145
  {
137
146
  to: [{ podSelector: { matchLabels: { [PROXY_LABEL]: proxy.key } } }],
@@ -179,6 +188,9 @@ exports.buildProxyPolicy = buildProxyPolicy;
179
188
  // left goes.
180
189
  // A task follows the proxy's log while its jobs run. A service outlives the
181
190
  // run on a cluster, so its refused hosts are in the proxy's own log.
191
+ // `secretHosts` are the endpoints of the providers whose secrets the init
192
+ // containers of a task on a server read: a limited task gets a proxy for them
193
+ // (under `none` too), which passes them to those containers only.
182
194
  async function ensureKubernetesItemNetwork(instance, env, item, options) {
183
195
  const network = (0, work_network_1.getEffectiveNetwork)(item.data);
184
196
  if (network.type !== 'open' && !env.networkPolicyEnforced) {
@@ -191,22 +203,30 @@ async function ensureKubernetesItemNetwork(instance, env, item, options) {
191
203
  // without the rights to manage NetworkPolicies, hammerkit never made one
192
204
  await removeKubernetesItemNetwork(instance, env, item, { run: null }, { ignoreForbidden: true });
193
205
  }
194
- return { env: [], hostAliases, initContainers: [], close() { } };
206
+ return { env: [], secretEnv: [], hostAliases, initContainers: [], close() { } };
207
+ }
208
+ const secretHosts = options.secretHosts ?? [];
209
+ if (secretHosts.length > 0 && options.sandbox) {
210
+ // a run on a sandbox uses no account, so it reads no secret of a provider
211
+ throw new Error(`${item.name}: a task on a sandbox reads no secret of a provider`);
195
212
  }
196
213
  const proxy = network.type === 'allow' && options.sandbox
197
214
  ? getSandboxProxy(env, item, network, hostAliases, options.sandbox)
198
215
  : getRunProxy(item, options);
199
- await (0, apply_1.apply)(instance, buildNetworkPolicy(env, item, network, options, proxy));
216
+ const proxied = network.type === 'allow' || secretHosts.length > 0;
217
+ await (0, apply_1.apply)(instance, buildNetworkPolicy(env, item, network, options, proxy, proxied));
200
218
  const initContainers = [(0, network_gate_1.getNetworkGateContainer)()];
201
- if (network.type === 'none') {
219
+ if (!proxied) {
202
220
  if (options.sandbox) {
203
221
  await removeSandboxProxies(instance, env.namespace, { sandbox: options.sandbox.label, item });
204
222
  }
205
223
  else {
206
224
  await removeProxy(instance, env.namespace, proxy.name, [404]);
207
225
  }
208
- return { env: [], hostAliases, initContainers, close() { } };
226
+ return { env: [], secretEnv: [], hostAliases, initContainers, close() { } };
209
227
  }
228
+ // under `none`, a proxy for the secrets only: it allows the item no host
229
+ const proxyNetwork = network.type === 'allow' ? network : { type: 'allow', hosts: [] };
210
230
  let started;
211
231
  try {
212
232
  if (proxy.sandbox) {
@@ -214,7 +234,7 @@ async function ensureKubernetesItemNetwork(instance, env, item, options) {
214
234
  await removeSandboxProxies(instance, env.namespace, { sandbox: proxy.sandbox.label, item }, (deployment) => deployment.metadata?.labels?.[PROXY_LABEL] === proxy.key);
215
235
  proxy.owner = await getFilesPodOwner(instance, env, proxy.sandbox);
216
236
  }
217
- started = await ensureProxy(instance, env, item, network, hostAliases, options, proxy);
237
+ started = await ensureProxy(instance, env, item, proxyNetwork, hostAliases, options, proxy, secretHosts);
218
238
  }
219
239
  catch (e) {
220
240
  await removeKubernetesItemNetwork(instance, env, item, options).catch(() => undefined);
@@ -223,11 +243,17 @@ async function ensureKubernetesItemNetwork(instance, env, item, options) {
223
243
  const follow = options.followLog
224
244
  ? await followProxyLog(instance, env, item, proxy, { tailLines: started.reused ? 0 : undefined })
225
245
  : null;
246
+ const needs = item.needs.map((need) => need.name);
247
+ const toEnv = (variables) => Object.entries(variables).map(([name, value]) => ({ name, value }));
226
248
  return {
227
- env: Object.entries((0, egress_proxy_1.getProxyClientEnvironment)(item.needs.map((need) => need.name))).map(([name, value]) => ({
228
- name,
229
- value,
230
- })),
249
+ env: network.type === 'allow' ? toEnv((0, egress_proxy_1.getProxyClientEnvironment)(needs)) : [],
250
+ // the token from the proxy's Secret, which the item's own container never mounts
251
+ secretEnv: secretHosts.length > 0
252
+ ? [
253
+ { name: SECRET_TOKEN_VARIABLE, valueFrom: { secretKeyRef: { name: proxy.name, key: SECRET_TOKEN_KEY } } },
254
+ ...toEnv((0, egress_proxy_1.getProxyClientEnvironment)(needs, `$(${SECRET_TOKEN_VARIABLE})`)),
255
+ ]
256
+ : [],
231
257
  // the item reaches each TCP forward under its host's name, at the proxy
232
258
  hostAliases: [
233
259
  ...hostAliases,
@@ -286,12 +312,14 @@ async function releaseKubernetesItemNetwork(instance, env, item, scope) {
286
312
  }
287
313
  exports.releaseKubernetesItemNetwork = releaseKubernetesItemNetwork;
288
314
  // Every policy and proxy of the item, of any run, once none of its pods runs:
289
- // what `down` and `clean` remove.
315
+ // what `down` and `clean` remove. On a server, those of the server's run only: the
316
+ // runs of a caller share their namespace, and the pods of another run of the same item
317
+ // would run unlimited without their policy.
290
318
  async function removeAllKubernetesItemNetworks(instance, env, item) {
291
- if (!(await awaitPodsEnded(instance, env, { [ID_LABEL]: item.id() }))) {
319
+ if (!(await awaitPodsEnded(instance, env, (0, resources_1.getItemLabels)(item, env)))) {
292
320
  throw new Error(`pods of ${item.name} are still running, so its network policy stays`);
293
321
  }
294
- const labelSelector = `${NETWORK_OF_LABEL}=${item.id()}`;
322
+ const labelSelector = toSelector({ [NETWORK_OF_LABEL]: item.id(), ...(0, job_placement_1.getRunLabels)(env) });
295
323
  let policies;
296
324
  try {
297
325
  const list = await instance.objectApi.list('networking.k8s.io/v1', 'NetworkPolicy', env.namespace, undefined, undefined, undefined, undefined, labelSelector);
@@ -318,6 +346,7 @@ async function removeAllKubernetesItemNetworks(instance, env, item) {
318
346
  if (name) {
319
347
  await ignoreStatus(instance.appsApi.deleteNamespacedDeployment({ name, namespace: env.namespace }), [404]);
320
348
  await ignoreStatus(instance.coreApi.deleteNamespacedService({ name, namespace: env.namespace }), [404]);
349
+ await ignoreStatus(instance.coreApi.deleteNamespacedSecret({ name, namespace: env.namespace }), [404]);
321
350
  }
322
351
  }
323
352
  }
@@ -349,7 +378,7 @@ function getProxyReadinessScript(ports) {
349
378
  `.on('error', () => process.exit(1))`);
350
379
  }
351
380
  exports.getProxyReadinessScript = getProxyReadinessScript;
352
- async function ensureProxy(instance, env, item, network, hostAliases, scope, proxy) {
381
+ async function ensureProxy(instance, env, item, network, hostAliases, scope, proxy, secretHosts = []) {
353
382
  const name = proxy.name;
354
383
  const labels = { [PROXY_LABEL]: proxy.key };
355
384
  const metadata = {
@@ -388,7 +417,17 @@ async function ensureProxy(instance, env, item, network, hostAliases, scope, pro
388
417
  name: 'proxy',
389
418
  image: egress_proxy_1.EGRESS_PROXY_IMAGE,
390
419
  command: (0, egress_proxy_1.getEgressProxyCommand)(),
391
- env: Object.entries((0, egress_proxy_1.getItemProxyEnvironment)(item)).map(([key, value]) => ({ name: key, value })),
420
+ env: [
421
+ ...Object.entries({ ...(0, egress_proxy_1.getItemProxyEnvironment)(item), ...(0, egress_proxy_1.getSecretProxyEnvironment)(secretHosts) }).map(([key, value]) => ({ name: key, value })),
422
+ ...(secretHosts.length > 0
423
+ ? [
424
+ {
425
+ name: 'HAMMERKIT_SECRET_TOKEN',
426
+ valueFrom: { secretKeyRef: { name, key: SECRET_TOKEN_KEY } },
427
+ },
428
+ ]
429
+ : []),
430
+ ],
392
431
  ports: (0, egress_proxy_1.getEgressProxyPorts)(network).map((port) => ({ containerPort: port })),
393
432
  // in the pod, since its ingress policy may keep the kubelet out;
394
433
  // ready once it listens on every port, its TCP forwards' too
@@ -422,6 +461,21 @@ async function ensureProxy(instance, env, item, network, hostAliases, scope, pro
422
461
  ? await instance.objectApi.read(deployment).then((current) => !current.metadata?.deletionTimestamp, (e) => ((0, apply_1.statusCodeOf)(e) === 404 ? false : Promise.reject(e)))
423
462
  : false;
424
463
  await (0, apply_1.apply)(instance, buildProxyPolicy(env, item, network, scope, proxy));
464
+ if (secretHosts.length > 0) {
465
+ // new with each run's proxy, and kept while it is there: its pod read it when it started.
466
+ // The init containers that read secrets get it, the task not.
467
+ const secret = {
468
+ apiVersion: 'v1',
469
+ kind: 'Secret',
470
+ metadata: { ...metadata, annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() } },
471
+ type: 'Opaque',
472
+ data: { [SECRET_TOKEN_KEY]: Buffer.from((0, crypto_1.randomBytes)(32).toString('hex')).toString('base64') },
473
+ };
474
+ const exists = await instance.objectApi.read(secret).then(() => true, (e) => ((0, apply_1.statusCodeOf)(e) === 404 ? false : Promise.reject(e)));
475
+ if (!exists) {
476
+ await instance.objectApi.create(secret);
477
+ }
478
+ }
425
479
  await (0, apply_1.apply)(instance, deployment);
426
480
  const created = await (0, apply_1.apply)(instance, service);
427
481
  await (0, await_running_state_1.awaitDeployRunningState)(instance, env, name, {
@@ -433,7 +487,7 @@ async function ensureProxy(instance, env, item, network, hostAliases, scope, pro
433
487
  if (!ip) {
434
488
  throw new Error(`the egress proxy of ${item.name} has no cluster ip`);
435
489
  }
436
- item.status.write('debug', `${reused ? 'egress proxy of the sandbox' : 'egress proxy'} allows ${network.hosts.join(', ') || 'no host'}`);
490
+ item.status.write('debug', `${reused ? 'egress proxy of the sandbox' : 'egress proxy'} allows ${network.hosts.join(', ') || 'no host'}${secretHosts.length > 0 ? `, and ${secretHosts.join(', ')} to the reads of secrets` : ''}`);
437
491
  return { ip, reused };
438
492
  }
439
493
  // Refused hosts show up in the item's log, as on Docker; of a proxy from an
@@ -468,9 +522,13 @@ async function followProxyLog(instance, env, item, proxy, options) {
468
522
  return null;
469
523
  }
470
524
  }
471
- async function removeProxy(instance, namespace, name, ignored) {
525
+ // The token of a proxy for secrets goes with it; a proxy of a sandbox has none.
526
+ async function removeProxy(instance, namespace, name, ignored, options = { token: true }) {
472
527
  await ignoreStatus(instance.appsApi.deleteNamespacedDeployment({ name, namespace }), ignored);
473
528
  await ignoreStatus(instance.coreApi.deleteNamespacedService({ name, namespace }), ignored);
529
+ if (options.token) {
530
+ await ignoreStatus(instance.coreApi.deleteNamespacedSecret({ name, namespace }), ignored);
531
+ }
474
532
  await ignoreStatus(instance.objectApi.delete({
475
533
  apiVersion: 'networking.k8s.io/v1',
476
534
  kind: 'NetworkPolicy',
@@ -492,7 +550,7 @@ async function removeSandboxProxies(instance, namespace, of, keep = () => false)
492
550
  for (const deployment of deployments.items) {
493
551
  const name = deployment.metadata?.name;
494
552
  if (name && !keep(deployment)) {
495
- await removeProxy(instance, namespace, name, [404]);
553
+ await removeProxy(instance, namespace, name, [404], { token: false });
496
554
  }
497
555
  }
498
556
  }
@@ -1,8 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.removePersistentData = void 0;
4
+ const resources_1 = require("./resources");
4
5
  async function removePersistentData(instance, kubernetes, task) {
5
- const labelSelector = `hammerkit.dev/id=${task.id()}`;
6
+ // on a server, the run's: a claim it kept has no run label and goes once it expired
7
+ const labelSelector = (0, resources_1.getItemSelector)(task, kubernetes);
6
8
  const configmaps = await instance.coreApi.listNamespacedConfigMap({ namespace: kubernetes.namespace, labelSelector });
7
9
  for (const configMap of configmaps.items) {
8
10
  if (configMap.metadata?.name) {
@@ -1,12 +1,34 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.removeInvalidCharacters = exports.getResourceName = void 0;
3
+ exports.removeInvalidCharacters = exports.getResourceName = exports.getItemSelector = exports.getItemLabels = exports.getItemKey = exports.ID_LABEL = void 0;
4
+ const job_placement_1 = require("./job-placement");
4
5
  // An object name is a DNS label: lowercase letters, digits and `-`, at most 63
5
6
  // characters, starting and ending with a letter or digit. The id makes a name
6
7
  // unique; the item's name is there for people and is cut to fit.
7
8
  const MAX_NAME = 63;
8
- function getResourceName(item, suffix) {
9
- const tail = `-${item.id()}${suffix ?? ''}`;
9
+ exports.ID_LABEL = 'hammerkit.dev/id';
10
+ // What tells the objects of an item apart in its namespace: its id, and on a server the
11
+ // run's label too. The runs of a caller execute side by side in their namespace, and two
12
+ // of them often run the same task (the same command on two sandboxes of a repository),
13
+ // so each has objects of its own, which nothing of the other run finds or removes. The
14
+ // first 8 digits of the run label tell runs apart, as they do for a run's proxies.
15
+ function getItemKey(item, env) {
16
+ return env.server ? `${item.id().substring(0, 32)}-${(0, job_placement_1.getRunLabelValue)(env.server.run).substring(0, 8)}` : item.id();
17
+ }
18
+ exports.getItemKey = getItemKey;
19
+ // The labels the objects of an item carry, by which they are found again
20
+ function getItemLabels(item, env) {
21
+ return { [exports.ID_LABEL]: item.id(), ...(0, job_placement_1.getRunLabels)(env) };
22
+ }
23
+ exports.getItemLabels = getItemLabels;
24
+ function getItemSelector(item, env) {
25
+ return Object.entries(getItemLabels(item, env))
26
+ .map(([key, value]) => `${key}=${value}`)
27
+ .join(',');
28
+ }
29
+ exports.getItemSelector = getItemSelector;
30
+ function getResourceName(item, env, suffix) {
31
+ const tail = `-${getItemKey(item, env)}${suffix ?? ''}`;
10
32
  const name = removeInvalidCharacters(item.name)
11
33
  .slice(0, Math.max(0, MAX_NAME - tail.length))
12
34
  .replace(/-+$/, '');
@@ -1,11 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.restoreKubernetesData = void 0;
3
+ exports.restoreKubernetesData = exports.getRestoreTarget = void 0;
4
4
  const event_cache_1 = require("../executer/event-cache");
5
5
  const log_1 = require("../log");
6
- const path_1 = require("path");
7
6
  const volumes_1 = require("./volumes");
8
7
  const ensure_persistent_data_1 = require("./ensure-persistent-data");
8
+ const claim_layout_1 = require("./claim-layout");
9
+ // Where the upload pod (which mounts the work's claim at /dev/hammerkit/<id>) unpacks the
10
+ // archive of an output: the output's place in the claim, which its mount reads
11
+ function getRestoreTarget(work, path) {
12
+ return `/dev/hammerkit/${work.id()}/${(0, claim_layout_1.getClaimSubPath)(work, path)}`;
13
+ }
14
+ exports.getRestoreTarget = getRestoreTarget;
9
15
  async function restoreKubernetesData(work, kubernetes, instance, environment, path,
10
16
  // the kept claim of the work holds what an earlier run left, which goes first
11
17
  options = {}) {
@@ -18,7 +24,7 @@ options = {}) {
18
24
  if (!(await environment.file.exists(generatedArchive.filename))) {
19
25
  continue;
20
26
  }
21
- const targetPath = `/dev/hammerkit/${work.id()}/${(0, path_1.basename)(generatedArchive.path)}`;
27
+ const targetPath = getRestoreTarget(work, generatedArchive.path);
22
28
  // a claim made for this run (a server removes the claims of a run when it
23
29
  // ends) does not have the directory of the output yet
24
30
  let failure = null;