hammerkit 1.26.0 → 1.27.0-next.10

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 (128) hide show
  1. package/build.schema.json +62 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/backends/registry-cache-backend.js +2 -2
  4. package/dist/cache/explain.js +13 -11
  5. package/dist/cli.js +3 -0
  6. package/dist/docker/item-network.js +471 -0
  7. package/dist/docker/pull.js +4 -2
  8. package/dist/docker/remove-orphaned-containers.js +13 -6
  9. package/dist/environment/replace-env-variables.js +8 -6
  10. package/dist/executer/await-completed-dependencies.js +2 -1
  11. package/dist/executer/check-readiness.js +2 -2
  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 +24 -2
  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 +411 -0
  30. package/dist/index.js +33 -36
  31. package/dist/kubernetes/apply.js +42 -1
  32. package/dist/kubernetes/await-running-state.js +33 -1
  33. package/dist/kubernetes/claim-layout.js +71 -0
  34. package/dist/kubernetes/ensure-http-route.js +1 -1
  35. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +46 -28
  36. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +132 -5
  37. package/dist/kubernetes/ensure-kubernetes-service-exists.js +27 -11
  38. package/dist/kubernetes/ensure-persistent-data.js +75 -24
  39. package/dist/kubernetes/get-service-ip.js +16 -2
  40. package/dist/kubernetes/job-placement.js +99 -0
  41. package/dist/kubernetes/job-secrets.js +320 -0
  42. package/dist/kubernetes/kept-claims.js +203 -0
  43. package/dist/kubernetes/kubernetes-instance.js +10 -2
  44. package/dist/kubernetes/network-gate.js +71 -0
  45. package/dist/kubernetes/network-policy.js +583 -0
  46. package/dist/kubernetes/remove-persistent-data.js +3 -1
  47. package/dist/kubernetes/resources.js +37 -4
  48. package/dist/kubernetes/restore-kubernetes-data.js +28 -7
  49. package/dist/kubernetes/secrets.js +7 -9
  50. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  51. package/dist/kubernetes/volumes.js +104 -12
  52. package/dist/log.js +1 -1
  53. package/dist/main.js +41 -0
  54. package/dist/network/egress-proxy.js +407 -0
  55. package/dist/network/port-relay.js +35 -0
  56. package/dist/optimizer/get-work-cache-stats.js +16 -0
  57. package/dist/optimizer/work-task-cache-description.js +5 -2
  58. package/dist/planner/service-definition.js +5 -0
  59. package/dist/planner/utils/append-work-dependencies.js +5 -2
  60. package/dist/planner/utils/append-work-environment.js +2 -0
  61. package/dist/planner/utils/append-work-service.js +5 -0
  62. package/dist/planner/utils/append-work-task.js +20 -1
  63. package/dist/planner/utils/assert-item-paths.js +50 -0
  64. package/dist/planner/utils/hosted-volumes.js +35 -0
  65. package/dist/planner/utils/task-arguments.js +3 -0
  66. package/dist/planner/work-network.js +193 -0
  67. package/dist/planner/work-runtime-docker.js +43 -1
  68. package/dist/planner/work-runtime-kubernetes.js +370 -111
  69. package/dist/planner/work-runtime-local.js +2 -1
  70. package/dist/program.js +33 -5
  71. package/dist/remote/annotations.js +115 -0
  72. package/dist/remote/bundle-text.js +24 -0
  73. package/dist/remote/bundle.js +9 -1
  74. package/dist/remote/remote-commands.js +15 -3
  75. package/dist/remote/remote-work-commands.js +272 -71
  76. package/dist/remote/server-client.js +18 -5
  77. package/dist/remote/source-manifest.js +15 -2
  78. package/dist/remote/token-source.js +67 -0
  79. package/dist/schema/build-file-base-task-schema.js +7 -0
  80. package/dist/schema/build-file-container-service-schema.js +8 -0
  81. package/dist/schema/build-file-container-task-schema.js +3 -0
  82. package/dist/schema/build-file-environment-schema.js +9 -0
  83. package/dist/schema/network-schema.js +78 -0
  84. package/dist/schema/reference-parser.js +2 -0
  85. package/dist/schema/schema-parser.js +16 -3
  86. package/dist/schema/work-scope-parser.js +5 -0
  87. package/dist/secrets/command-secret-provider.js +73 -10
  88. package/dist/secrets/github-app-token-command.js +26 -0
  89. package/dist/secrets/github-app-token.js +191 -0
  90. package/dist/server/accounts-api.js +9 -2
  91. package/dist/server/definitions-api.js +69 -47
  92. package/dist/server/definitions.js +76 -51
  93. package/dist/server/executor.js +156 -41
  94. package/dist/server/git-checkout.js +72 -8
  95. package/dist/server/kubernetes-backend.js +603 -0
  96. package/dist/server/kubernetes-sandbox-storage.js +997 -0
  97. package/dist/server/oauth.js +12 -0
  98. package/dist/server/plan.js +88 -2
  99. package/dist/server/policy.js +104 -15
  100. package/dist/server/proposals-api.js +24 -40
  101. package/dist/server/proposals.js +12 -25
  102. package/dist/server/repo-access.js +37 -6
  103. package/dist/server/run-logs.js +217 -0
  104. package/dist/server/run-plan.js +48 -2
  105. package/dist/server/run-request.js +29 -10
  106. package/dist/server/run-store.js +5 -0
  107. package/dist/server/run-worker-command.js +13 -1
  108. package/dist/server/run-worker.js +121 -24
  109. package/dist/server/runs.js +191 -10
  110. package/dist/server/sandbox-docker.js +62 -0
  111. package/dist/server/sandbox-files-api.js +269 -0
  112. package/dist/server/sandbox-files.js +1054 -0
  113. package/dist/server/sandbox-patch.js +481 -0
  114. package/dist/server/sandbox-pod-command.js +15 -0
  115. package/dist/server/sandbox-pod.js +146 -0
  116. package/dist/server/sandbox-storage.js +462 -0
  117. package/dist/server/sandboxes-api.js +216 -0
  118. package/dist/server/sandboxes.js +519 -0
  119. package/dist/server/server-command.js +39 -20
  120. package/dist/server/server-config.js +443 -22
  121. package/dist/server/server.js +219 -40
  122. package/dist/utils/abort-on-signals.js +46 -6
  123. package/dist/utils/run-id.js +12 -0
  124. package/dist/utils/tar.js +25 -0
  125. package/harness/opencode.ts +11 -0
  126. package/harness/pi.ts +7 -0
  127. package/package.json +8 -2
  128. package/dist/remote/text-diff.js +0 -89
@@ -1,9 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ensurePersistentData = exports.getPodForPersistence = void 0;
3
+ exports.ensurePersistentData = exports.packSource = exports.clearClaim = exports.getPodForPersistence = void 0;
4
4
  const apply_1 = require("./apply");
5
5
  const path_1 = require("path");
6
- const tar_1 = require("tar");
6
+ const tar_1 = require("../utils/tar");
7
7
  const resources_1 = require("./resources");
8
8
  const await_running_state_1 = require("./await-running-state");
9
9
  const sleep_1 = require("../utils/sleep");
@@ -11,6 +11,8 @@ const version_1 = require("../version");
11
11
  const log_1 = require("../log");
12
12
  const ensure_kubernetes_persistent_volume_claim_exists_1 = require("./ensure-kubernetes-persistent-volume-claim-exists");
13
13
  const stream_1 = require("stream");
14
+ const job_placement_1 = require("./job-placement");
15
+ const assert_item_paths_1 = require("../planner/utils/assert-item-paths");
14
16
  // A previous run that crashed between creating an upload/download pod and its
15
17
  // `finally` cleanup leaves a pod behind under the same deterministic name. apply()
16
18
  // would then patch that stale pod in place — and if it is stuck in a terminal
@@ -43,9 +45,12 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
43
45
  if (persistence.volumes.length === 0) {
44
46
  return;
45
47
  }
48
+ // the claim of a sandbox is mounted by the pods of the work only: it holds no output
49
+ // and takes no upload
50
+ const own = persistence.mounts.filter((m) => !m.workspace);
46
51
  const mounts = type === 'read'
47
- ? persistence.mounts.map((m) => m.mount)
48
- : persistence.mounts.reduce((array, v) => {
52
+ ? own.map((m) => m.mount)
53
+ : own.reduce((array, v) => {
49
54
  if (!array.some((i) => i.name === v.mount.name)) {
50
55
  array.push({
51
56
  mountPath: v.uploadPath,
@@ -60,23 +65,29 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
60
65
  // function directly, and a claim shared with another work item can be removed
61
66
  // concurrently — without this the pod would hang Pending on "pvc not found".
62
67
  for (const volume of persistence.volumes) {
63
- await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service);
68
+ await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
64
69
  }
65
- const name = (0, resources_1.getResourceName)(service, type === 'write' ? '-upload' : '-download');
70
+ const name = (0, resources_1.getResourceName)(service, env, type === 'write' ? '-upload' : '-download');
66
71
  const podSpec = {
67
72
  kind: 'Pod',
68
73
  apiVersion: 'v1',
69
74
  metadata: {
70
75
  namespace: env.namespace,
71
76
  name,
72
- labels: {
73
- 'hammerkit.dev/id': service.id(),
74
- },
77
+ labels: (0, resources_1.getItemLabels)(service, env),
75
78
  annotations: {
76
79
  'hammerkit.dev/version': (0, version_1.getVersion)(),
77
80
  },
78
81
  },
79
82
  spec: {
83
+ // on a server the claims are bound where the pod of the task can go: the nodes
84
+ // of its pool
85
+ ...(env.server
86
+ ? {
87
+ automountServiceAccountToken: false,
88
+ ...(0, job_placement_1.getPodPlacement)(env.server, 'pool' in service.data ? service.data.pool : undefined),
89
+ }
90
+ : {}),
80
91
  containers: [
81
92
  {
82
93
  name: 'volume',
@@ -84,52 +95,92 @@ async function getPodForPersistence(instance, env, service, persistence, type, f
84
95
  command: ['sh'],
85
96
  tty: true,
86
97
  volumeMounts: mounts,
98
+ ...(0, job_placement_1.getContainerSecurity)(env),
87
99
  },
88
100
  ],
89
101
  volumes: persistence.volumes,
90
102
  },
91
103
  };
92
- service.status.console('stdout', `create pod ${name}`);
104
+ service.status.write('debug', `create pod ${name}`);
93
105
  await removeStalePod(instance, env.namespace, name);
94
106
  const pod = await (0, apply_1.apply)(instance, podSpec);
95
107
  if (pod.status?.phase !== 'Running') {
96
- service.status.console('stdout', `await running state ${name}`);
108
+ service.status.write('debug', `await running state ${name}`);
97
109
  await (0, await_running_state_1.awaitRunningState)(instance, env, name, 'Running');
98
110
  }
99
111
  try {
100
112
  await fn(name);
101
113
  }
102
114
  finally {
103
- service.status.console('stdout', `delete pod ${name}`);
115
+ service.status.write('debug', `delete pod ${name}`);
104
116
  await instance.coreApi.deleteNamespacedPod({ name, namespace: env.namespace });
105
117
  }
106
118
  }
107
119
  exports.getPodForPersistence = getPodForPersistence;
108
- async function ensurePersistentData(instance, env, environment, service, persistence) {
120
+ // Empties the claim of `work` in an upload pod: a kept claim an earlier run left, before
121
+ // the task runs on it or outputs are restored into it, so it holds what a new one would
122
+ async function clearClaim(instance, env, work, pod) {
123
+ const targetPath = `/dev/hammerkit/${work.id()}`;
124
+ work.status.write('info', `clear what an earlier run left in ${targetPath}`);
125
+ let failure = null;
126
+ const res = await instance.exec.exec(env.namespace, pod, 'volume', ['find', targetPath, '-mindepth', '1', '-maxdepth', '1', '-exec', 'rm', '-rf', '{}', '+'], process.stdout, process.stderr, null, false, (status) => {
127
+ if (status.status !== 'Success') {
128
+ failure = status.message ?? status.reason ?? 'unknown';
129
+ }
130
+ });
131
+ await new Promise((resolve, reject) => {
132
+ res.on('error', reject);
133
+ res.on('close', () => failure ? reject(new Error(`unable to clear ${targetPath} in ${pod}: ${failure}`)) : resolve());
134
+ });
135
+ }
136
+ exports.clearClaim = clearClaim;
137
+ // The archive that uploads a source into the claim: its file or directory, at its place in
138
+ // the claim (see getClaimSubPath) instead of under its own name
139
+ function packSource(source) {
140
+ const fileItem = (0, path_1.basename)(source.localPath);
141
+ return stream_1.Readable.from((0, tar_1.packArchive)({
142
+ cwd: (0, path_1.dirname)(source.localPath),
143
+ filter(path) {
144
+ return source.matcher(path, source.localPath);
145
+ },
146
+ onWriteEntry(entry) {
147
+ entry.path = source.subPath + entry.path.slice(fileItem.length);
148
+ // the second of two hard-linked files links to the first by its path in the archive
149
+ if (entry.type === 'Link' && entry.linkpath?.startsWith(fileItem)) {
150
+ entry.linkpath = source.subPath + entry.linkpath.slice(fileItem.length);
151
+ }
152
+ },
153
+ }, [fileItem]));
154
+ }
155
+ exports.packSource = packSource;
156
+ async function ensurePersistentData(instance, env, environment, service, persistence,
157
+ // the kept claim of the work holds what an earlier run left, which goes first
158
+ options = {}) {
159
+ // what a run of a server uploads comes from its checkout and nowhere else
160
+ if (environment.hosted) {
161
+ await (0, assert_item_paths_1.assertItemPathsWithinRoot)(service, environment.cwd);
162
+ }
109
163
  // Dedupe of already-uploaded state (skip the upload when the persisted state
110
164
  // key still matches) needs an in-cluster state marker per volume — deferred
111
165
  // post-1.6.0, tracked in FOLLOWUPS.md alongside the volumes.ts stateKey/matcher.
112
166
  for (const volume of persistence.volumes) {
113
- await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service);
167
+ await (0, ensure_kubernetes_persistent_volume_claim_exists_1.ensureKubernetesPersistentVolumeClaimExists)(instance, env, volume, service, persistence.sizes[volume.name], persistence.kept.includes(volume.name));
114
168
  }
115
- if (persistence.sources.length === 0) {
169
+ if (persistence.sources.length === 0 && !options.reset) {
116
170
  return;
117
171
  }
118
- service.status.console('stdout', 'start upload container');
172
+ service.status.write('debug', 'start upload container');
119
173
  await getPodForPersistence(instance, env, service, persistence, 'write', async (name) => {
174
+ if (options.reset) {
175
+ await clearClaim(instance, env, service, name);
176
+ }
120
177
  for (const source of persistence.sources) {
121
178
  // skip sources that don't exist on disk — nothing to upload
122
179
  if (!(await environment.file.exists(source.localPath))) {
123
180
  continue;
124
181
  }
125
- const fileItem = (0, path_1.basename)(source.localPath);
126
- const data = stream_1.Readable.from((0, tar_1.create)({
127
- cwd: (0, path_1.dirname)(source.localPath),
128
- filter(path) {
129
- return source.matcher(path, source.localPath);
130
- },
131
- }, [fileItem]));
132
- service.status.console('stdout', `upload ${source.localPath}`);
182
+ const data = packSource(source);
183
+ service.status.write('info', `upload ${source.localPath}`);
133
184
  const targetPath = `/dev/hammerkit/${service.id()}`;
134
185
  const res = await instance.exec.exec(env.namespace, name, 'volume', ['tar', 'xf', '-', '-C', targetPath], process.stdout, process.stderr, data, false, (status) => {
135
186
  service.status.write('debug', `upload ${source.localPath} to ${name}: ${status}`);
@@ -2,10 +2,24 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getServiceIp = void 0;
4
4
  const resources_1 = require("./resources");
5
+ // The address a task reaches the service on: its clusterIP, or for a headless
6
+ // Service (a service without ports) the address of its ready pod.
5
7
  async function getServiceIp(instance, env, workService) {
6
- const name = (0, resources_1.getResourceName)(workService);
8
+ const name = (0, resources_1.getResourceName)(workService, env);
7
9
  const svc = await instance.coreApi.readNamespacedService({ name, namespace: env.namespace });
8
- return svc.spec?.clusterIP ?? null;
10
+ const clusterIP = svc.spec?.clusterIP ?? null;
11
+ if (clusterIP !== 'None') {
12
+ return clusterIP;
13
+ }
14
+ const pods = await instance.coreApi.listNamespacedPod({
15
+ namespace: env.namespace,
16
+ labelSelector: (0, resources_1.getItemSelector)(workService, env),
17
+ });
18
+ const ready = pods.items.find((pod) => !pod.metadata?.deletionTimestamp &&
19
+ pod.status?.podIP &&
20
+ pod.status.conditions?.some((c) => c.type === 'Ready' && c.status === 'True'));
21
+ const running = ready ?? pods.items.find((pod) => !pod.metadata?.deletionTimestamp && pod.status?.podIP);
22
+ return running?.status?.podIP ?? null;
9
23
  }
10
24
  exports.getServiceIp = getServiceIp;
11
25
  //# sourceMappingURL=get-service-ip.js.map
@@ -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,320 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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
+ const work_secret_1 = require("../planner/work-secret");
5
+ const command_secret_provider_1 = require("../secrets/command-secret-provider");
6
+ const task_arguments_1 = require("../planner/utils/task-arguments");
7
+ const version_1 = require("../version");
8
+ const apply_1 = require("./apply");
9
+ const resources_1 = require("./resources");
10
+ const job_placement_1 = require("./job-placement");
11
+ const work_network_1 = require("../planner/work-network");
12
+ // Where the values are in the pod (an in-memory volume), and where the init container
13
+ // of a secret finds the credential of its account
14
+ exports.SECRET_VALUES_PATH = '/var/run/hammerkit/secrets';
15
+ exports.ACCOUNT_PATH = '/var/run/hammerkit/account';
16
+ const VALUES_VOLUME = 'hammerkit-secret-values';
17
+ // a token is made for each Job right before it is created; it has to outlast the
18
+ // time its pod waits for a node
19
+ const TOKEN_SECONDS = 3600;
20
+ /**
21
+ * Which secrets of a task the Job reads itself. On a server's cluster the secrets of
22
+ * a provider are read in the Job, by an init container per secret with only its
23
+ * account's credential, so the server holds no secret manager credential and sees no
24
+ * value. The others (`env:`, the server's own variables) are handed over in a Secret
25
+ * of the task, as without a server.
26
+ */
27
+ function splitJobSecrets(secrets, server) {
28
+ if (!server) {
29
+ return { delivered: secrets, inJob: [] };
30
+ }
31
+ const delivered = [];
32
+ const inJob = [];
33
+ for (const secret of secrets) {
34
+ if (secret.source.type === 'provider') {
35
+ inJob.push({ index: inJob.length, secret, source: secret.source });
36
+ }
37
+ else {
38
+ delivered.push(secret);
39
+ }
40
+ }
41
+ return { delivered, inJob };
42
+ }
43
+ exports.splitJobSecrets = splitJobSecrets;
44
+ /**
45
+ * The init containers that read secrets share the pod, and so the NetworkPolicy, of
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.
50
+ */
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`);
60
+ }
61
+ return [...new Set(secrets.flatMap((secret) => server.providers[secret.source.provider].allow ?? []))].sort();
62
+ }
63
+ exports.getSecretHosts = getSecretHosts;
64
+ // The accounts of the secrets, in the order they are first read
65
+ function accountsOf(secrets) {
66
+ return [...new Set(secrets.map((s) => s.source.account))];
67
+ }
68
+ function getCredentialsName(item, kubernetes) {
69
+ return (0, resources_1.getResourceName)(item, kubernetes, '-credentials');
70
+ }
71
+ exports.getCredentialsName = getCredentialsName;
72
+ // The keys of the credentials Secret: `<account>.token` and `<account>.file.<key>`
73
+ function tokenKey(account) {
74
+ return `${account}.token`;
75
+ }
76
+ function fileKey(account, key) {
77
+ return `${account}.file.${key}`;
78
+ }
79
+ /**
80
+ * The credentials the init containers need, in a Secret of the task that only they
81
+ * mount: per account, a short-lived token of its ServiceAccount and the files of its
82
+ * Secret, both from the server's namespace. Removed with the task's other Secret.
83
+ */
84
+ async function ensureJobCredentials(instance, kubernetes, item, secrets, admitted) {
85
+ const server = kubernetes.server;
86
+ const files = {};
87
+ if (!server || secrets.length === 0) {
88
+ return files;
89
+ }
90
+ assertJobAccounts(secrets, server, admitted);
91
+ const data = {};
92
+ for (const account of accountsOf(secrets)) {
93
+ const credentials = server.accounts[account];
94
+ files[account] = [];
95
+ if (credentials.serviceAccount) {
96
+ const response = await instance.coreApi.createNamespacedServiceAccountToken({
97
+ name: credentials.serviceAccount,
98
+ namespace: server.namespace,
99
+ body: {
100
+ spec: {
101
+ audiences: credentials.audience ? [credentials.audience] : [],
102
+ expirationSeconds: TOKEN_SECONDS,
103
+ },
104
+ },
105
+ });
106
+ const token = response.status?.token;
107
+ if (!token) {
108
+ throw new Error(`no token was issued for the ServiceAccount ${credentials.serviceAccount} of ${account}`);
109
+ }
110
+ data[tokenKey(account)] = Buffer.from(token, 'utf8').toString('base64');
111
+ files[account].push({ key: tokenKey(account), path: 'token' });
112
+ }
113
+ if (credentials.secret) {
114
+ let content;
115
+ try {
116
+ const secret = await instance.coreApi.readNamespacedSecret({
117
+ name: credentials.secret,
118
+ namespace: server.namespace,
119
+ });
120
+ content = secret.data ?? {};
121
+ }
122
+ catch (e) {
123
+ if ((0, apply_1.statusCodeOf)(e) === 404) {
124
+ throw new Error(`the Secret ${credentials.secret} of ${account} is not in ${server.namespace}`);
125
+ }
126
+ throw e;
127
+ }
128
+ for (const [key, value] of Object.entries(content)) {
129
+ data[fileKey(account, key)] = value;
130
+ files[account].push({ key: fileKey(account, key), path: key });
131
+ }
132
+ }
133
+ }
134
+ const spec = {
135
+ kind: 'Secret',
136
+ apiVersion: 'v1',
137
+ metadata: {
138
+ namespace: kubernetes.namespace,
139
+ name: getCredentialsName(item, kubernetes),
140
+ annotations: { 'hammerkit.dev/version': (0, version_1.getVersion)() },
141
+ labels: (0, resources_1.getItemLabels)(item, kubernetes),
142
+ },
143
+ type: 'Opaque',
144
+ data,
145
+ };
146
+ await (0, apply_1.apply)(instance, spec);
147
+ return files;
148
+ }
149
+ exports.ensureJobCredentials = ensureJobCredentials;
150
+ /**
151
+ * Refuses a secret of an account the run was not admitted for, or of one without a
152
+ * credential on the cluster: its command would run as whatever the node or the image
153
+ * of the provider is, which every account shares. Before anything is minted or copied.
154
+ */
155
+ function assertJobAccounts(secrets, server, admitted) {
156
+ for (const { secret, source } of secrets) {
157
+ const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
158
+ if (admitted && !admitted.includes(source.account)) {
159
+ throw new Error(`${label}: the run was not admitted for the account ${source.account}`);
160
+ }
161
+ if (!server.accounts[source.account]) {
162
+ throw new Error(`${label}: the account ${source.account} has no credential on this cluster (accounts.${source.account}.kubernetes)`);
163
+ }
164
+ }
165
+ }
166
+ exports.assertJobAccounts = assertJobAccounts;
167
+ async function removeJobCredentials(instance, kubernetes, item) {
168
+ if (!kubernetes.server) {
169
+ return;
170
+ }
171
+ try {
172
+ await instance.coreApi.deleteNamespacedSecret({
173
+ name: getCredentialsName(item, kubernetes),
174
+ namespace: kubernetes.namespace,
175
+ });
176
+ }
177
+ catch (e) {
178
+ if ((0, apply_1.statusCodeOf)(e) !== 404) {
179
+ throw e;
180
+ }
181
+ }
182
+ }
183
+ exports.removeJobCredentials = removeJobCredentials;
184
+ // A string as one word of a POSIX shell
185
+ function quote(value) {
186
+ return `'${value.replace(/'/g, `'\\''`)}'`;
187
+ }
188
+ /**
189
+ * What the pod of a task needs to read its secrets itself. Each secret gets an init
190
+ * container in the image of its provider, which runs the provider's command (no
191
+ * shell between the reference and the command) with the provider's and its
192
+ * account's variables and the account's credential under /var/run/hammerkit/account,
193
+ * and writes the value verbatim into a volume in memory. The task's container mounts
194
+ * that volume only: a `path` secret is the file itself, an `env` secret is exported
195
+ * from it, and the task's output is masked before it leaves the pod, since the
196
+ * server, which masks everything else, never sees these values.
197
+ */
198
+ function getJobSecretSpec(item, secrets, server, credentials, environment,
199
+ // under a limited network, what sends their requests through the egress proxy
200
+ proxyEnv = []) {
201
+ if (!server || secrets.length === 0) {
202
+ return {
203
+ initContainers: [],
204
+ volumes: [],
205
+ mounts: [],
206
+ command: (shell, cmd) => ({ command: [shell, '-c'], args: [cmd], env: [] }),
207
+ };
208
+ }
209
+ const volumes = [{ name: VALUES_VOLUME, emptyDir: { medium: 'Memory' } }];
210
+ // a volume per account with only its keys, mounted by the init containers of its secrets
211
+ const accountVolume = new Map();
212
+ accountsOf(secrets).forEach((account, index) => {
213
+ const items = credentials[account] ?? [];
214
+ if (items.length === 0) {
215
+ return;
216
+ }
217
+ const name = `hammerkit-account-${index}`;
218
+ accountVolume.set(account, name);
219
+ volumes.push({ name, secret: { secretName: getCredentialsName(item, { server }), items, defaultMode: 0o444 } });
220
+ });
221
+ const initContainers = secrets.map(({ index, secret, source }) => {
222
+ const label = `secret ${(0, work_secret_1.getSecretName)(secret)} (${(0, work_secret_1.describeSecretSource)(secret)})`;
223
+ // what the run was admitted for, as when the server reads a secret itself
224
+ const allowed = environment.hosted?.accounts;
225
+ if (allowed && !allowed.includes(source.account)) {
226
+ throw new Error(`${label}: the run was not admitted for the account ${source.account}`);
227
+ }
228
+ const image = server.providers[source.provider]?.image;
229
+ if (!image) {
230
+ throw new Error(`${label}: this server names no image for the provider ${source.provider}`);
231
+ }
232
+ const { command, args, env } = (0, command_secret_provider_1.getProviderInvocation)(source.binding, source.ref, environment);
233
+ const volume = accountVolume.get(source.account);
234
+ return {
235
+ name: `secret-${index}`,
236
+ image,
237
+ // the value as the command prints it, written once it is complete
238
+ command: [
239
+ 'sh',
240
+ '-c',
241
+ 'umask 022 && "$@" > "$HAMMERKIT_VALUE.part" && mv "$HAMMERKIT_VALUE.part" "$HAMMERKIT_VALUE"',
242
+ ],
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),
246
+ env: [
247
+ ...proxyEnv,
248
+ ...Object.entries(env).map(([name, value]) => ({ name, value })),
249
+ { name: 'HAMMERKIT_VALUE', value: `${exports.SECRET_VALUES_PATH}/secret-${index}` },
250
+ ],
251
+ volumeMounts: [
252
+ { name: VALUES_VOLUME, mountPath: exports.SECRET_VALUES_PATH },
253
+ ...(volume ? [{ name: volume, mountPath: exports.ACCOUNT_PATH, readOnly: true }] : []),
254
+ ],
255
+ ...(0, job_placement_1.getContainerSecurity)({ server }),
256
+ };
257
+ });
258
+ const mounts = [{ name: VALUES_VOLUME, mountPath: exports.SECRET_VALUES_PATH }];
259
+ const exportLines = [];
260
+ for (const { index, secret } of secrets) {
261
+ if (secret.target.type === 'file') {
262
+ mounts.push({ name: VALUES_VOLUME, mountPath: secret.target.path, subPath: `secret-${index}`, readOnly: true });
263
+ }
264
+ else {
265
+ // verbatim: the x keeps the trailing newlines command substitution would drop
266
+ const name = secret.target.name;
267
+ exportLines.push(`${name}="$(cat "$HAMMERKIT_SECRETS/secret-${index}"; printf x)" && ${name}="\${${name}%x}" && export ${name}`);
268
+ }
269
+ }
270
+ const script = [
271
+ ...exportLines,
272
+ `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; }`,
273
+ `{ "$HAMMERKIT_SHELL" -c "$HAMMERKIT_CMD"; echo $? > "$HAMMERKIT_SECRETS/.status"; } 2>&1 | awk -v dir="$HAMMERKIT_SECRETS" -v count=${secrets.length} ${quote(MASK_PROGRAM)}`,
274
+ `exit "$(cat "$HAMMERKIT_SECRETS/.status" 2>/dev/null || echo 1)"`,
275
+ ].join('\n');
276
+ return {
277
+ initContainers,
278
+ volumes,
279
+ mounts,
280
+ command: (shell, cmd) => ({
281
+ command: [shell, '-c'],
282
+ args: [script],
283
+ env: [
284
+ { name: 'HAMMERKIT_SECRETS', value: exports.SECRET_VALUES_PATH },
285
+ { name: 'HAMMERKIT_SHELL', value: shell },
286
+ { name: 'HAMMERKIT_CMD', value: cmd },
287
+ ],
288
+ }),
289
+ };
290
+ }
291
+ exports.getJobSecretSpec = getJobSecretSpec;
292
+ // Every line of every value (trimmed, 4 characters or more, like the server's own
293
+ // masking) is replaced by *** in each line of output. index() and not a regular
294
+ // expression, so a value is matched as written.
295
+ const MASK_PROGRAM = `
296
+ function mask(s, v, out, i) {
297
+ out = ""
298
+ while ((i = index(s, v)) > 0) { out = out substr(s, 1, i - 1) "***"; s = substr(s, i + length(v)) }
299
+ return out s
300
+ }
301
+ BEGIN {
302
+ n = 0
303
+ for (k = 0; k < count; k++) {
304
+ f = dir "/secret-" k
305
+ while ((getline line < f) > 0) {
306
+ gsub(/^[ \\t\\r]+|[ \\t\\r]+$/, "", line)
307
+ if (length(line) >= 4) values[n++] = line
308
+ }
309
+ close(f)
310
+ }
311
+ # longest first, so a value that holds another is masked as a whole
312
+ 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 }
313
+ }
314
+ {
315
+ out = $0
316
+ for (k = 0; k < n; k++) out = mask(out, values[k])
317
+ print out
318
+ fflush()
319
+ }`;
320
+ //# sourceMappingURL=job-secrets.js.map