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.
- package/build.schema.json +53 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/explain.js +13 -11
- package/dist/cli.js +3 -0
- package/dist/docker/item-network.js +471 -0
- package/dist/docker/pull.js +4 -2
- package/dist/docker/remove-orphaned-containers.js +13 -6
- package/dist/environment/replace-env-variables.js +8 -6
- package/dist/executer/await-completed-dependencies.js +2 -1
- package/dist/executer/check-readiness.js +2 -2
- package/dist/executer/container-task-state.js +8 -6
- package/dist/executer/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- package/dist/executer/execute-command.js +18 -5
- package/dist/executer/execute-docker.js +4 -1
- package/dist/executer/execution-steps.js +33 -7
- package/dist/executer/get-docker-executor.js +14 -7
- package/dist/executer/scheduler/enqueue-next.js +17 -3
- package/dist/executer/watch-loop.js +2 -2
- package/dist/executer/watch-state-key.js +1 -1
- package/dist/file/within-root.js +42 -0
- package/dist/harness/connect.js +99 -0
- package/dist/harness/format.js +73 -0
- package/dist/harness/mcp-command.js +59 -0
- package/dist/harness/mcp.js +432 -0
- package/dist/harness/opencode.js +492 -0
- package/dist/harness/pi.js +217 -0
- package/dist/harness/sandbox-client.js +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +44 -22
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -0
- package/dist/network/port-relay.js +35 -0
- package/dist/optimizer/get-work-cache-stats.js +16 -0
- package/dist/optimizer/work-task-cache-description.js +3 -2
- package/dist/planner/service-definition.js +8 -2
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/utils/task-arguments.js +155 -0
- package/dist/planner/work-cache-id.js +15 -1
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +43 -0
- package/dist/planner/work-runtime-kubernetes.js +336 -102
- package/dist/planner/work-runtime-local.js +16 -8
- package/dist/planner/work-service.js +7 -3
- package/dist/planner/work-state-record.js +30 -0
- package/dist/program.js +80 -7
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- package/dist/remote/server-client.js +18 -5
- package/dist/remote/source-manifest.js +15 -2
- package/dist/remote/token-source.js +67 -0
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -0
- package/dist/schema/build-file-container-task-schema.js +3 -0
- package/dist/schema/build-file-environment-schema.js +9 -0
- package/dist/schema/network-schema.js +78 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +13 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +23 -0
- package/dist/secrets/github-app-token.js +191 -0
- package/dist/server/accounts-api.js +9 -2
- package/dist/server/definitions-api.js +69 -47
- package/dist/server/definitions.js +76 -51
- package/dist/server/executor.js +156 -41
- package/dist/server/git-checkout.js +72 -8
- package/dist/server/kubernetes-backend.js +554 -0
- package/dist/server/kubernetes-sandbox-storage.js +996 -0
- package/dist/server/oauth.js +1 -0
- package/dist/server/plan.js +51 -1
- package/dist/server/policy.js +104 -15
- package/dist/server/proposals-api.js +24 -40
- package/dist/server/proposals.js +12 -25
- package/dist/server/repo-access.js +37 -6
- package/dist/server/run-logs.js +217 -0
- package/dist/server/run-plan.js +51 -2
- package/dist/server/run-request.js +23 -10
- package/dist/server/run-store.js +5 -0
- package/dist/server/run-worker-command.js +13 -1
- package/dist/server/run-worker.js +121 -24
- package/dist/server/runs.js +184 -10
- package/dist/server/sandbox-docker.js +62 -0
- package/dist/server/sandbox-files-api.js +269 -0
- package/dist/server/sandbox-files.js +1054 -0
- package/dist/server/sandbox-patch.js +481 -0
- package/dist/server/sandbox-pod-command.js +15 -0
- package/dist/server/sandbox-pod.js +146 -0
- package/dist/server/sandbox-storage.js +462 -0
- package/dist/server/sandboxes-api.js +210 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-config.js +412 -22
- package/dist/server/server.js +204 -39
- package/dist/utils/run-id.js +12 -0
- package/harness/opencode.ts +11 -0
- package/harness/pi.ts +7 -0
- package/package.json +8 -2
- package/dist/remote/text-diff.js +0 -89
|
@@ -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
|
-
|
|
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.
|
|
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 {
|