hammerkit 1.27.0-next.8 → 2.0.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 +176 -6
- package/dist/cache/backends/local-cache-backend.js +6 -0
- package/dist/cache/backends/registry/registry-client.js +1 -0
- package/dist/cache/cache-sync.js +1 -1
- package/dist/cache/explain.js +17 -0
- package/dist/cli.js +2 -0
- package/dist/dashboard/assets/index-CjnKm5-3.css +2 -0
- package/dist/dashboard/assets/index-zPXa4klZ.js +13 -0
- package/dist/dashboard/favicon.svg +1 -0
- package/dist/dashboard/index.html +15 -0
- package/dist/docker/item-network.js +13 -5
- package/dist/executer/archive-task-entry.js +4 -2
- package/dist/executer/deadline.js +17 -3
- package/dist/executer/deferred-restore.js +36 -0
- package/dist/executer/docker-service.js +30 -8
- package/dist/executer/docker-task.js +21 -1
- package/dist/executer/execute-work-service.js +2 -0
- package/dist/executer/execute-work-task.js +11 -4
- package/dist/executer/execution-steps.js +25 -4
- package/dist/executer/reset-work-tree.js +4 -0
- package/dist/executer/scheduler/enqueue-next.js +57 -6
- package/dist/executer/scheduler/state-key.js +42 -5
- package/dist/executer/watch-loop.js +6 -3
- package/dist/harness/connect.js +20 -6
- package/dist/harness/mcp-command.js +22 -6
- package/dist/harness/mcp.js +63 -114
- package/dist/harness/opencode.js +158 -50
- package/dist/harness/pi.js +29 -0
- package/dist/harness/sandbox-client.js +194 -23
- package/dist/harness/service-tools.js +141 -0
- package/dist/harness/task-tools.js +221 -0
- package/dist/kubernetes/apply.js +76 -17
- package/dist/kubernetes/await-running-state.js +172 -13
- package/dist/kubernetes/cluster-state.js +245 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +138 -18
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +6 -9
- package/dist/kubernetes/ensure-persistent-data.js +212 -10
- package/dist/kubernetes/follow-pod-logs.js +182 -0
- package/dist/kubernetes/get-service-ip.js +2 -2
- package/dist/kubernetes/job-placement.js +36 -12
- package/dist/kubernetes/job-secrets.js +9 -6
- package/dist/kubernetes/kept-claims.js +22 -10
- package/dist/kubernetes/kubernetes-instance.js +1 -0
- package/dist/kubernetes/network-policy.js +16 -8
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/remove-stale-items.js +46 -0
- package/dist/kubernetes/resources.js +25 -3
- package/dist/kubernetes/restore-kubernetes-data.js +9 -5
- package/dist/kubernetes/sandbox-runner.js +628 -0
- package/dist/kubernetes/secrets.js +7 -11
- package/dist/kubernetes/service-rollouts.js +74 -0
- package/dist/kubernetes/store-kubernetes-data.js +5 -2
- package/dist/kubernetes/volumes.js +96 -11
- package/dist/main.js +2 -0
- package/dist/optimizer/calculate-checksum.js +3 -2
- package/dist/optimizer/check-key.js +94 -0
- package/dist/optimizer/check-keys.js +64 -0
- package/dist/optimizer/get-work-cache-stats.js +17 -3
- package/dist/optimizer/work-task-cache-description.js +49 -1
- package/dist/planner/service-definition.js +9 -4
- package/dist/planner/utils/append-work-dependencies.js +5 -3
- package/dist/planner/utils/append-work-service.js +18 -1
- package/dist/planner/utils/append-work-task.js +13 -3
- package/dist/planner/utils/get-work-runtime.js +6 -1
- package/dist/planner/utils/normalize-path.js +19 -1
- package/dist/planner/utils/parse-work-mount.js +7 -1
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/planner/validate.js +22 -0
- package/dist/planner/work-network.js +3 -1
- package/dist/planner/work-runtime-aggregate.js +32 -0
- package/dist/planner/work-runtime-kubernetes.js +153 -26
- package/dist/planner/work-task.js +6 -1
- package/dist/program.js +27 -9
- package/dist/remote/device-login.js +7 -2
- package/dist/remote/host-config.js +69 -3
- package/dist/remote/remote-commands.js +33 -7
- package/dist/remote/remote-status.js +112 -0
- package/dist/remote/remote-work-commands.js +562 -45
- package/dist/remote/server-client.js +15 -2
- package/dist/remote/token-source.js +64 -26
- package/dist/run-program.js +9 -0
- package/dist/schema/build-file-base-task-schema.js +3 -3
- package/dist/schema/build-file-container-service-schema.js +10 -0
- package/dist/schema/build-file-container-task-schema.js +16 -1
- package/dist/schema/build-file-include-schema.js +18 -4
- package/dist/schema/build-file-kubernetes-service-schema.js +2 -0
- package/dist/schema/build-file-task-schema.js +5 -1
- package/dist/schema/capabilities-schema.js +56 -0
- package/dist/schema/reference-parser.js +119 -11
- package/dist/schema/removed-properties.js +20 -0
- package/dist/schema/schema-parser.js +94 -41
- package/dist/schema/work-scope-parser.js +33 -10
- package/dist/secrets/command-secret-provider.js +3 -1
- package/dist/secrets/github-app-token-command.js +3 -1
- package/dist/secrets/secret-catalog.js +43 -19
- package/dist/server/accounts-api.js +4 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/capacity-format.js +36 -0
- package/dist/server/capacity.js +300 -0
- package/dist/server/checks-api.js +83 -0
- package/dist/server/checks.js +116 -0
- package/dist/server/dashboard.js +196 -0
- package/dist/server/definitions-api.js +66 -3
- package/dist/server/endpoints.js +794 -0
- package/dist/server/executor.js +164 -9
- package/dist/server/kubernetes-backend.js +478 -70
- package/dist/server/kubernetes-sandbox-storage.js +46 -2
- package/dist/server/overview.js +271 -0
- package/dist/server/plan.js +170 -121
- package/dist/server/policy.js +34 -2
- package/dist/server/proposals-api.js +17 -8
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-logs.js +4 -0
- package/dist/server/run-plan.js +38 -4
- package/dist/server/run-request.js +58 -3
- package/dist/server/run-worker.js +262 -13
- package/dist/server/runs.js +969 -36
- package/dist/server/sandbox-pod.js +28 -0
- package/dist/server/sandbox-storage.js +23 -0
- package/dist/server/sandboxes.js +8 -0
- package/dist/server/server-config.js +246 -10
- package/dist/server/server.js +429 -32
- package/dist/server/service-runs.js +82 -0
- package/dist/server/timings.js +132 -0
- package/dist/server/wait-reason.js +21 -0
- package/dist/utils/tar.js +28 -1
- package/dist/utils/terminal-prompt.js +53 -0
- package/package.json +8 -2
package/dist/server/plan.js
CHANGED
|
@@ -13,6 +13,7 @@ const work_scope_parser_1 = require("../schema/work-scope-parser");
|
|
|
13
13
|
const work_task_1 = require("../planner/work-task");
|
|
14
14
|
const work_service_1 = require("../planner/work-service");
|
|
15
15
|
const hosted_volumes_1 = require("../planner/utils/hosted-volumes");
|
|
16
|
+
const normalize_path_1 = require("../planner/utils/normalize-path");
|
|
16
17
|
const log_1 = require("../log");
|
|
17
18
|
const parse_error_1 = require("../schema/parse-error");
|
|
18
19
|
const task_arguments_1 = require("../planner/utils/task-arguments");
|
|
@@ -39,50 +40,33 @@ const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
|
39
40
|
// A hash alone can only name what is inside the bundle, so everything a build
|
|
40
41
|
// file pulls in from elsewhere has to be pinned: a git include needs a commit
|
|
41
42
|
// and a remote that is not a path on the server.
|
|
42
|
-
function
|
|
43
|
+
function checkGitSource(path, where, source, noRemote) {
|
|
44
|
+
if (noRemote === 'proposal') {
|
|
45
|
+
throw new DefinitionError(`${path}: ${where}: a proposal may not include from git, the server would fetch it before anybody approved it`);
|
|
46
|
+
}
|
|
47
|
+
if (noRemote === 'check') {
|
|
48
|
+
throw new DefinitionError(`${path}: ${where}: checking a build file that includes from git needs builds: publish, the server would fetch it`);
|
|
49
|
+
}
|
|
43
50
|
const problems = [];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
catch {
|
|
53
|
-
continue; // a file that isn't valid yaml is reported when the bundle is parsed
|
|
54
|
-
}
|
|
55
|
-
for (const block of ['includes', 'references']) {
|
|
56
|
-
for (const [name, include] of Object.entries(document?.[block] ?? {})) {
|
|
57
|
-
const source = include;
|
|
58
|
-
if (!source || typeof source !== 'object' || source.git === undefined) {
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
if (noRemote) {
|
|
62
|
-
problems.push(`${path}: ${block}.${name}: a proposal may not include from git, the server would fetch it before anybody approved it`);
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
if (typeof source.git !== 'string' || !/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
|
|
66
|
-
problems.push(`${path}: ${block}.${name}: git has to be an https:// or ssh:// address`);
|
|
67
|
-
}
|
|
68
|
-
if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
|
|
69
|
-
problems.push(`${path}: ${block}.${name}: pin ref to a commit, a branch or tag can move`);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
51
|
+
if (!/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
|
|
52
|
+
problems.push(`${path}: ${where}: git has to be an https:// or ssh:// address`);
|
|
53
|
+
}
|
|
54
|
+
if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
|
|
55
|
+
problems.push(`${path}: ${where}: pin ref to a commit, a branch or tag can move`);
|
|
56
|
+
}
|
|
57
|
+
if (problems.length > 0) {
|
|
58
|
+
throw new DefinitionError(problems.join('\n'));
|
|
73
59
|
}
|
|
74
|
-
return problems;
|
|
75
60
|
}
|
|
76
61
|
// What a build file may not name on a server: the places it would run or store things
|
|
77
62
|
// are the operator's. The docker host, a Kubernetes cluster and a remote cache are
|
|
78
63
|
// credentials of the machine, not of the build file, and a project file that could carry
|
|
79
64
|
// them could send runs, or the cache, anywhere.
|
|
80
|
-
|
|
65
|
+
// A file a server leaves out (`publish: false`) is not checked, and neither is a service
|
|
66
|
+
// with `publish: false`: what is left out is never run there.
|
|
67
|
+
function findHostProblems(files) {
|
|
81
68
|
const problems = [];
|
|
82
|
-
for (const [path, content] of Object.entries(
|
|
83
|
-
if (!/\.ya?ml$/i.test(path)) {
|
|
84
|
-
continue;
|
|
85
|
-
}
|
|
69
|
+
for (const [path, content] of Object.entries(files)) {
|
|
86
70
|
let document;
|
|
87
71
|
try {
|
|
88
72
|
document = (0, yaml_1.parse)(content);
|
|
@@ -104,6 +88,7 @@ function findHostProblems(bundle) {
|
|
|
104
88
|
for (const [name, service] of Object.entries(document.services ?? {})) {
|
|
105
89
|
if (service &&
|
|
106
90
|
typeof service === 'object' &&
|
|
91
|
+
service.publish !== false &&
|
|
107
92
|
('selector' in service || 'kubeconfig' in service || 'context' in service)) {
|
|
108
93
|
problems.push(`${path}: services.${name}: Kubernetes services are not supported on a server`);
|
|
109
94
|
}
|
|
@@ -111,6 +96,17 @@ function findHostProblems(bundle) {
|
|
|
111
96
|
}
|
|
112
97
|
return problems;
|
|
113
98
|
}
|
|
99
|
+
// The files of the bundle a server publishes, see getPublishedFiles
|
|
100
|
+
function publishedBundleFiles(bundle, directory, entry) {
|
|
101
|
+
const files = {};
|
|
102
|
+
for (const scope of (0, reference_parser_1.getPublishedFiles)(entry)) {
|
|
103
|
+
const path = (0, path_1.relative)(directory, scope.fileName).split(path_1.sep).join('/');
|
|
104
|
+
if (!scope.remote && path in bundle.files) {
|
|
105
|
+
files[path] = bundle.files[path];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
114
110
|
// What a build file may read while it is inspected: its own files. The rest of
|
|
115
111
|
// the server is not its business (a missing file is "not there", not an error).
|
|
116
112
|
function confined(files, root) {
|
|
@@ -165,101 +161,139 @@ function describeSecret(secret) {
|
|
|
165
161
|
// server has no account, so nothing would stand between the build file and the
|
|
166
162
|
// operator's own credentials.
|
|
167
163
|
function findPolicyProblems(items, policy, root) {
|
|
168
|
-
|
|
164
|
+
// the problems of each item, said once for the items of a directory that share one, such
|
|
165
|
+
// as the tasks of an included file that all mount the docker socket
|
|
166
|
+
const grouped = new Map();
|
|
169
167
|
for (const item of items) {
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
for (const source of 'src' in data ? data.src : []) {
|
|
178
|
-
if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
|
|
179
|
-
problems.push(`${item.name}: src ${source.source} is outside of the bundle`);
|
|
168
|
+
const prefix = `${item.name}: `;
|
|
169
|
+
for (const problem of findItemPolicyProblems(item, policy, root)) {
|
|
170
|
+
const said = problem.startsWith(prefix) ? problem.slice(prefix.length) : problem;
|
|
171
|
+
const key = `${item.data.cwd}\0${said}`;
|
|
172
|
+
const group = grouped.get(key);
|
|
173
|
+
if (group) {
|
|
174
|
+
group.names.push(item.name);
|
|
180
175
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
|
|
184
|
-
problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
|
|
176
|
+
else {
|
|
177
|
+
grouped.set(key, { names: [item.name], problem: said });
|
|
185
178
|
}
|
|
186
179
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
180
|
+
}
|
|
181
|
+
return [...grouped.values()].map(({ names: [name, ...others], problem }) => others.length > 0 ? `${name} (also ${others.join(', ')}): ${problem}` : `${name}: ${problem}`);
|
|
182
|
+
}
|
|
183
|
+
function findItemPolicyProblems(item, policy, root) {
|
|
184
|
+
const problems = [];
|
|
185
|
+
const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
|
|
186
|
+
// an aggregate runs nothing, on no runtime
|
|
187
|
+
if (policy.runtimes && !(0, work_task_1.isAggregateTask)(item.data) && !policy.runtimes.includes(runtime)) {
|
|
188
|
+
problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
|
|
189
|
+
}
|
|
190
|
+
const data = item.data;
|
|
191
|
+
// a capability holds in the sandboxed runtime the operator named for it, so only those
|
|
192
|
+
// the operator allows are added
|
|
193
|
+
const refused = policy.capabilities
|
|
194
|
+
? ('capAdd' in data ? data.capAdd : []).filter((capability) => !policy.capabilities?.includes(capability))
|
|
195
|
+
: [];
|
|
196
|
+
if (refused.length > 0) {
|
|
197
|
+
const from = policy.capabilitiesFrom ?? 'backend.capabilities.allow';
|
|
198
|
+
problems.push(`${item.name}: adds the ${refused.length > 1 ? 'capabilities' : 'capability'} ${refused.join(', ')}, ` +
|
|
199
|
+
(policy.capabilities && policy.capabilities.length > 0
|
|
200
|
+
? `which this server does not allow (${from}: ${policy.capabilities.join(', ')})`
|
|
201
|
+
: `and this server allows none (${from})`));
|
|
202
|
+
}
|
|
203
|
+
// what a task reads and writes is the bundle's: `src: ../..` would have the server
|
|
204
|
+
// copy its own files (its credentials, other callers' caches) into the task
|
|
205
|
+
for (const source of 'src' in data ? data.src : []) {
|
|
206
|
+
if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
|
|
207
|
+
problems.push(`${item.name}: src ${source.source} is outside of the bundle`);
|
|
196
208
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
reason: ` to what reads ${reads.join(', ')}`,
|
|
202
|
-
});
|
|
209
|
+
}
|
|
210
|
+
for (const generate of (0, work_task_1.isWorkTask)(data) ? data.generates : []) {
|
|
211
|
+
if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
|
|
212
|
+
problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
|
|
203
213
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
+
}
|
|
215
|
+
// what holds a credential gets at most `runs.secretNetwork`, so a tool it runs can't
|
|
216
|
+
// send the credential anywhere else
|
|
217
|
+
const reads = getCredentialReads({
|
|
218
|
+
secrets: secretsOf(item).map(describeSecret),
|
|
219
|
+
serverEnvs: 'envs' in data ? data.envs.processNames ?? [] : [],
|
|
220
|
+
});
|
|
221
|
+
const ceilings = [];
|
|
222
|
+
if (policy.network && (0, work_network_1.isLimitedNetwork)(policy.network)) {
|
|
223
|
+
ceilings.push({ ceiling: policy.network, setting: 'runs.network', reason: '' });
|
|
224
|
+
}
|
|
225
|
+
if (reads.length > 0 && policy.secretNetwork && (0, work_network_1.isLimitedNetwork)(policy.secretNetwork)) {
|
|
226
|
+
ceilings.push({
|
|
227
|
+
ceiling: policy.secretNetwork,
|
|
228
|
+
setting: 'runs.secretNetwork',
|
|
229
|
+
reason: ` to what reads ${reads.join(', ')}`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
// an aggregate reaches nothing: it runs no command
|
|
233
|
+
for (const { ceiling, setting, reason } of (0, work_task_1.isAggregateTask)(data) ? [] : ceilings) {
|
|
234
|
+
// on a cluster a local task is a pod of a pool's tool image, limited like any other
|
|
235
|
+
if (data.type === 'local-task' && policy.backend !== 'kubernetes') {
|
|
236
|
+
problems.push(`${item.name}: is a local task, which can't be limited to the network this server allows${reason} ` +
|
|
237
|
+
`(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
|
|
214
238
|
}
|
|
215
|
-
if (
|
|
216
|
-
for (
|
|
217
|
-
|
|
218
|
-
if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
|
|
219
|
-
problems.push(`${item.name}: mounts ${mount.localPath}, which is outside of the bundle`);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
239
|
+
else if ('network' in data && (0, work_network_1.exceedsNetwork)(data.network, ceiling)) {
|
|
240
|
+
problems.push(`${item.name}: asks for network ${(0, work_network_1.formatNetwork)(data.network)}, more than this server allows${reason} ` +
|
|
241
|
+
`(${setting}: ${(0, work_network_1.formatNetwork)(ceiling)})`);
|
|
222
242
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
243
|
+
}
|
|
244
|
+
if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
|
|
245
|
+
for (const mount of data.mounts) {
|
|
246
|
+
// a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
|
|
247
|
+
if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
|
|
248
|
+
problems.push(`${item.name}: mounts ${describeMountSource(mount)}, which is outside of the bundle`);
|
|
227
249
|
}
|
|
228
250
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
// provider only through the egress proxy
|
|
244
|
-
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
|
|
245
|
-
`Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it: ` +
|
|
246
|
-
`this server names no endpoints of ${source.provider} in backend.providers.${source.provider}.allow ` +
|
|
247
|
-
'for the egress proxy to let through, so the task keeps an open network');
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
|
|
251
|
-
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
|
|
251
|
+
}
|
|
252
|
+
for (const volume of (0, work_service_1.isContainerWorkService)(data) ? data.volumes : []) {
|
|
253
|
+
// docker takes anything else for a path of the host, such as the docker socket
|
|
254
|
+
if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
|
|
255
|
+
problems.push(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
for (const secret of secretsOf(item)) {
|
|
259
|
+
const source = secret.source;
|
|
260
|
+
if (policy.backend === 'kubernetes' && source.type === 'provider') {
|
|
261
|
+
// the server reads no secret of a provider on a cluster: the Job of a task does
|
|
262
|
+
if (!(0, work_task_1.isWorkTask)(item.data)) {
|
|
263
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider}, which ` +
|
|
264
|
+
'this server does for tasks only, a service on its cluster gets no provider secret yet');
|
|
252
265
|
}
|
|
253
|
-
else if (
|
|
254
|
-
problems.push(`${item.name}:
|
|
266
|
+
else if (secret.cache) {
|
|
267
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
|
|
268
|
+
'the value before the Job of the task reads it, which this server does not support yet');
|
|
255
269
|
}
|
|
256
|
-
else if (
|
|
257
|
-
|
|
270
|
+
else if ((0, work_network_1.isLimitedNetwork)(item.data.network) && !policy.proxiedProviders?.includes(source.provider)) {
|
|
271
|
+
// the init containers that read it share the pod's NetworkPolicy, and reach the
|
|
272
|
+
// provider only through the egress proxy
|
|
273
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
|
|
274
|
+
`Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it: ` +
|
|
275
|
+
`this server names no endpoints of ${source.provider} in backend.providers.${source.provider}.allow ` +
|
|
276
|
+
'for the egress proxy to let through, so the task keeps an open network');
|
|
258
277
|
}
|
|
259
278
|
}
|
|
279
|
+
if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
|
|
280
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
|
|
281
|
+
}
|
|
282
|
+
else if (source.type === 'file') {
|
|
283
|
+
problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
|
|
284
|
+
}
|
|
285
|
+
else if (source.type === 'env' && !policy.envs.includes(source.name)) {
|
|
286
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in ${policy.envsFrom ?? 'runs.env.allow'}`);
|
|
287
|
+
}
|
|
260
288
|
}
|
|
261
289
|
return problems;
|
|
262
290
|
}
|
|
291
|
+
// A mount of the home directory says so: `$PWD` in a mount is the home directory, not the
|
|
292
|
+
// project, which reads like a path of the checkout
|
|
293
|
+
function describeMountSource(mount) {
|
|
294
|
+
const home = (0, normalize_path_1.getHomeReference)(mount.mount.split(':')[0]);
|
|
295
|
+
return home ? `${mount.localPath} (${home} is the home directory)` : mount.localPath;
|
|
296
|
+
}
|
|
263
297
|
function toPlan(root, item) {
|
|
264
298
|
const data = item.data;
|
|
265
299
|
const secrets = secretsOf(item).map(describeSecret);
|
|
@@ -272,7 +306,7 @@ function toPlan(root, item) {
|
|
|
272
306
|
: [];
|
|
273
307
|
const ports = 'ports' in data ? data.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`) : [];
|
|
274
308
|
const flags = [];
|
|
275
|
-
if (data.type === 'local-task') {
|
|
309
|
+
if (data.type === 'local-task' && !(0, work_task_1.isAggregateTask)(data)) {
|
|
276
310
|
flags.push('local-task');
|
|
277
311
|
}
|
|
278
312
|
if ('ports' in data && data.ports.some((port) => port.hostPort !== null)) {
|
|
@@ -281,10 +315,15 @@ function toPlan(root, item) {
|
|
|
281
315
|
if (data.scope.remote) {
|
|
282
316
|
flags.push('remote-include');
|
|
283
317
|
}
|
|
318
|
+
const capAdd = (0, work_task_1.isContainerWorkTask)(data) ? data.capAdd : [];
|
|
319
|
+
if (capAdd.length > 0) {
|
|
320
|
+
flags.push('capabilities');
|
|
321
|
+
}
|
|
284
322
|
const isTask = data.type === 'local-task' || data.type === 'container-task';
|
|
285
323
|
return {
|
|
286
324
|
kind: isTask ? 'task' : 'service',
|
|
287
|
-
|
|
325
|
+
...(data.description ? { description: data.description } : {}),
|
|
326
|
+
runtime: (0, work_task_1.isAggregateTask)(data) ? 'none' : data.type === 'local-task' ? 'local' : 'docker',
|
|
288
327
|
...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
|
|
289
328
|
cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
|
|
290
329
|
command: (0, task_arguments_1.getArgumentsTarget)(data)?.cmd ?? null,
|
|
@@ -298,6 +337,7 @@ function toPlan(root, item) {
|
|
|
298
337
|
mounts,
|
|
299
338
|
ports,
|
|
300
339
|
generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
|
|
340
|
+
...(capAdd.length > 0 ? { capAdd: [...capAdd] } : {}),
|
|
301
341
|
flags,
|
|
302
342
|
};
|
|
303
343
|
}
|
|
@@ -314,10 +354,6 @@ function describeFailure(e) {
|
|
|
314
354
|
// when somebody wants to run it. The same function answers what a run will do,
|
|
315
355
|
// with the scope of the run: which tasks, services and accounts it involves.
|
|
316
356
|
async function planBundle(bundle, environment, options = {}) {
|
|
317
|
-
const problems = [...findLooseIncludes(bundle, options.noRemote), ...(options.policy ? findHostProblems(bundle) : [])];
|
|
318
|
-
if (problems.length > 0) {
|
|
319
|
-
throw new DefinitionError(problems.join('\n'));
|
|
320
|
-
}
|
|
321
357
|
const directory = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'hammerkit-definition-'));
|
|
322
358
|
try {
|
|
323
359
|
for (const [path, content] of Object.entries(bundle.files)) {
|
|
@@ -330,7 +366,20 @@ async function planBundle(bundle, environment, options = {}) {
|
|
|
330
366
|
file: confined((0, get_file_context_1.getFileContext)(directory), directory),
|
|
331
367
|
};
|
|
332
368
|
try {
|
|
333
|
-
const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected
|
|
369
|
+
const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected, {
|
|
370
|
+
// a hash alone can only name what is inside the bundle, see checkGitSource
|
|
371
|
+
checkGitSource: (source, from, block, name) => checkGitSource(
|
|
372
|
+
// a file of a git include is named by its repository
|
|
373
|
+
from.remote
|
|
374
|
+
? `${from.remote.git} ${(0, path_1.relative)(from.remote.root, from.fileName).split(path_1.sep).join('/')}`
|
|
375
|
+
: (0, path_1.relative)(directory, from.fileName), `${block}.${name}`, source, options.noRemote),
|
|
376
|
+
});
|
|
377
|
+
if (options.policy) {
|
|
378
|
+
const problems = findHostProblems(publishedBundleFiles(bundle, directory, scope));
|
|
379
|
+
if (problems.length > 0) {
|
|
380
|
+
throw new DefinitionError(problems.join('\n'));
|
|
381
|
+
}
|
|
382
|
+
}
|
|
334
383
|
const context = await (0, reference_parser_1.parseReferences)(ctx, scope, inspected);
|
|
335
384
|
const workTree = (0, work_scope_parser_1.getWorkContext)(context, options.scope ?? { environmentName: null }, inspected);
|
|
336
385
|
const items = itemsOf(workTree);
|
package/dist/server/policy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
|
|
3
|
+
exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.endpointLevel = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayReadChecks = exports.mayRun = exports.matches = void 0;
|
|
4
4
|
const repo_1 = require("./repo");
|
|
5
5
|
const BUILDS_ORDER = ['run', 'propose', 'publish'];
|
|
6
6
|
function matches(entitlement, principal) {
|
|
@@ -22,6 +22,12 @@ function atLeast(grant, level) {
|
|
|
22
22
|
}
|
|
23
23
|
const mayRun = (grant) => atLeast(grant, 'run');
|
|
24
24
|
exports.mayRun = mayRun;
|
|
25
|
+
// Who may run from a repository's refs may see what passed for its files, and so may who
|
|
26
|
+
// is entitled to just that (a CI job that only verifies).
|
|
27
|
+
function mayReadChecks(grant, repo) {
|
|
28
|
+
return (grant.checks || grant.builds !== null) && (0, repo_1.matchesAnyRepo)(grant.repos, repo);
|
|
29
|
+
}
|
|
30
|
+
exports.mayReadChecks = mayReadChecks;
|
|
25
31
|
const mayPropose = (grant) => atLeast(grant, 'propose');
|
|
26
32
|
exports.mayPropose = mayPropose;
|
|
27
33
|
const mayPublish = (grant) => atLeast(grant, 'publish');
|
|
@@ -55,12 +61,17 @@ function getGrant(entitlements, principal) {
|
|
|
55
61
|
const accounts = {};
|
|
56
62
|
const repos = [];
|
|
57
63
|
const sandboxes = [];
|
|
64
|
+
const endpoints = [];
|
|
58
65
|
let builds = null;
|
|
66
|
+
let checks = false;
|
|
67
|
+
let observe = false;
|
|
59
68
|
entitlements.forEach((entitlement, index) => {
|
|
60
69
|
if (!matches(entitlement, principal)) {
|
|
61
70
|
return;
|
|
62
71
|
}
|
|
63
72
|
matched.push(index);
|
|
73
|
+
checks = checks || entitlement.checks === 'read';
|
|
74
|
+
observe = observe || entitlement.observe === true;
|
|
64
75
|
for (const [account, level] of Object.entries(entitlement.accounts)) {
|
|
65
76
|
if (accounts[account] !== 'use') {
|
|
66
77
|
accounts[account] = level;
|
|
@@ -85,10 +96,31 @@ function getGrant(entitlements, principal) {
|
|
|
85
96
|
known.branches[pattern] = higherType(known.branches[pattern], type);
|
|
86
97
|
}
|
|
87
98
|
}
|
|
99
|
+
for (const endpoint of entitlement.endpoints ?? []) {
|
|
100
|
+
const known = endpoints.find((entry) => entry.repo === endpoint.repo);
|
|
101
|
+
if (!known) {
|
|
102
|
+
endpoints.push({ ...endpoint });
|
|
103
|
+
}
|
|
104
|
+
else if (endpoint.level === 'publish') {
|
|
105
|
+
known.level = 'publish';
|
|
106
|
+
}
|
|
107
|
+
}
|
|
88
108
|
});
|
|
89
|
-
return { accounts, repos, builds, sandboxes, matched };
|
|
109
|
+
return { accounts, repos, builds, sandboxes, endpoints, checks, observe, matched };
|
|
90
110
|
}
|
|
91
111
|
exports.getGrant = getGrant;
|
|
112
|
+
// What the caller may do with the endpoints of service runs of a repository: the higher
|
|
113
|
+
// level of the entries whose `repo` covers it, null for nothing.
|
|
114
|
+
function endpointLevel(grant, repo) {
|
|
115
|
+
let found = null;
|
|
116
|
+
for (const endpoint of grant.endpoints) {
|
|
117
|
+
if ((0, repo_1.matchesRepo)(endpoint.repo, repo) && found !== 'publish') {
|
|
118
|
+
found = endpoint.level;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
exports.endpointLevel = endpointLevel;
|
|
92
124
|
const BRANCH_TYPES = ['pull', 'push'];
|
|
93
125
|
function higherType(a, b) {
|
|
94
126
|
return a !== undefined && BRANCH_TYPES.indexOf(a) > BRANCH_TYPES.indexOf(b) ? a : b;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.handleProposals = exports.mayApprove = void 0;
|
|
3
|
+
exports.handleProposals = exports.decideProposal = exports.mayApprove = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const bundle_1 = require("../remote/bundle");
|
|
6
6
|
const definitions_1 = require("./definitions");
|
|
@@ -23,6 +23,21 @@ function toView(proposal, principal, grant) {
|
|
|
23
23
|
canDecide: proposal.state === 'waiting' && (0, exports.mayApprove)(grant, proposal) && !same(proposal.proposedBy, principal),
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Approves (publishes) or rejects a proposal, for the API and the dashboard: who decides
|
|
28
|
+
* needs the permission to publish and the accounts its proposer lacked. Throws a
|
|
29
|
+
* DefinitionError when the files do not load.
|
|
30
|
+
*/
|
|
31
|
+
async function decideProposal(store, proposals, grant, principal, hash, outcome, reason) {
|
|
32
|
+
return proposals.decide(principal, hash, outcome, (proposed) => (0, exports.mayApprove)(grant, proposed), async (bundle) => {
|
|
33
|
+
const proposed = proposals.get(hash);
|
|
34
|
+
await store.publish(bundle, proposed?.proposedBy ?? principal, {
|
|
35
|
+
approvedBy: principal,
|
|
36
|
+
annotations: proposed?.annotations,
|
|
37
|
+
});
|
|
38
|
+
}, reason);
|
|
39
|
+
}
|
|
40
|
+
exports.decideProposal = decideProposal;
|
|
26
41
|
/**
|
|
27
42
|
* New versions of build files that wait for an approval. Whoever may only
|
|
28
43
|
* propose a version (`builds: propose`), or publishes one that names accounts they
|
|
@@ -94,13 +109,7 @@ async function handleProposals(config, store, proposals, log, principal, req, re
|
|
|
94
109
|
}
|
|
95
110
|
const outcome = action === 'approve' ? 'approved' : 'rejected';
|
|
96
111
|
try {
|
|
97
|
-
const result = await
|
|
98
|
-
const proposed = proposals.get(hash);
|
|
99
|
-
await store.publish(bundle, proposed?.proposedBy ?? principal, {
|
|
100
|
-
approvedBy: principal,
|
|
101
|
-
annotations: proposed?.annotations,
|
|
102
|
-
});
|
|
103
|
-
}, body.data.reason);
|
|
112
|
+
const result = await decideProposal(store, proposals, grant, principal, hash, outcome, body.data.reason);
|
|
104
113
|
log(`${action} proposal=${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
|
|
105
114
|
switch (result.status) {
|
|
106
115
|
case 'ok':
|
|
@@ -105,6 +105,11 @@ class RepoAccess {
|
|
|
105
105
|
env.GIT_CONFIG_GLOBAL = '/dev/null';
|
|
106
106
|
return { env, values: credentials.values };
|
|
107
107
|
}
|
|
108
|
+
// A secret only the server reads, as an account no caller may use: the client secret of
|
|
109
|
+
// the sign-in at endpoints, read like the credential of a source.
|
|
110
|
+
serverSecret(secret) {
|
|
111
|
+
return this.token(secret);
|
|
112
|
+
}
|
|
108
113
|
async token(secret) {
|
|
109
114
|
const key = `${secret.account}\0${secret.from}`;
|
|
110
115
|
const known = this.tokens.get(key);
|
package/dist/server/run-logs.js
CHANGED
|
@@ -106,6 +106,10 @@ class LogIndex {
|
|
|
106
106
|
get size() {
|
|
107
107
|
return this.lineStart.length;
|
|
108
108
|
}
|
|
109
|
+
// the tasks, services and `hammerkit` that wrote to the log, in the order they first did
|
|
110
|
+
names() {
|
|
111
|
+
return [...this.taskLines.keys()];
|
|
112
|
+
}
|
|
109
113
|
lines(task) {
|
|
110
114
|
return this.taskLines.get(task) ?? [];
|
|
111
115
|
}
|
package/dist/server/run-plan.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.refuseArguments = exports.planRun = exports.getRunScopes = void 0;
|
|
3
|
+
exports.describeArguments = exports.refuseArguments = exports.planRun = exports.getRunScopes = void 0;
|
|
4
4
|
const parse_label_arguments_1 = require("../parser/parse-label-arguments");
|
|
5
5
|
const plan_1 = require("./plan");
|
|
6
6
|
function labels(values) {
|
|
7
7
|
return values && values.length > 0 ? (0, parse_label_arguments_1.parseLabelArguments)(values) : undefined;
|
|
8
8
|
}
|
|
9
|
-
// The tasks in the order they were named, one scope each; by labels when none is.
|
|
9
|
+
// The tasks in the order they were named, one scope each; by labels when none is. A
|
|
10
|
+
// service run is one scope of its services.
|
|
10
11
|
// The arguments, if any, go to the one task named (the request allows no more). That
|
|
11
12
|
// task runs uncached unless the request names a `cache` method, as `run --cache` does.
|
|
12
|
-
function getRunScopes(tasks, options) {
|
|
13
|
+
function getRunScopes(tasks, options, services = []) {
|
|
14
|
+
if (services.length > 0) {
|
|
15
|
+
return [{ name: services.join(', '), scope: { serviceNames: services, environmentName: options.env ?? null } }];
|
|
16
|
+
}
|
|
13
17
|
const args = options.args && options.args.length > 0
|
|
14
18
|
? { values: options.args, services: false, cache: options.cache !== undefined }
|
|
15
19
|
: undefined;
|
|
@@ -37,7 +41,7 @@ exports.getRunScopes = getRunScopes;
|
|
|
37
41
|
*/
|
|
38
42
|
async function planRun(bundle, run, environment, policy) {
|
|
39
43
|
const plan = {};
|
|
40
|
-
for (const { scope } of getRunScopes(run.tasks, run.options)) {
|
|
44
|
+
for (const { scope } of getRunScopes(run.tasks, run.options, run.up?.services ?? run.services)) {
|
|
41
45
|
Object.assign(plan, (await (0, plan_1.planBundle)(bundle, environment, { scope, policy })).plan);
|
|
42
46
|
}
|
|
43
47
|
return {
|
|
@@ -87,4 +91,34 @@ function refuseArguments(run, plan, policy = {}) {
|
|
|
87
91
|
return null;
|
|
88
92
|
}
|
|
89
93
|
exports.refuseArguments = refuseArguments;
|
|
94
|
+
/**
|
|
95
|
+
* For each task of a version, whether a run of it alone may pass arguments: null when it may,
|
|
96
|
+
* else why not, as {@link refuseArguments} answers a run. The plan is the one of the whole
|
|
97
|
+
* version: a task's run is the task with its dependencies and services.
|
|
98
|
+
*/
|
|
99
|
+
function describeArguments(plan, policy = {}) {
|
|
100
|
+
const described = {};
|
|
101
|
+
for (const [name, entry] of Object.entries(plan)) {
|
|
102
|
+
if (entry.kind !== 'task') {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const run = {};
|
|
106
|
+
const visit = (item) => {
|
|
107
|
+
if (run[item] || !plan[item]) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
run[item] = plan[item];
|
|
111
|
+
for (const next of [...plan[item].deps, ...plan[item].needs]) {
|
|
112
|
+
visit(next);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
visit(name);
|
|
116
|
+
described[name] =
|
|
117
|
+
entry.command === null
|
|
118
|
+
? `${name} has no command to take arguments`
|
|
119
|
+
: refuseArguments({ tasks: [name], options: { args: ['-'] } }, run, policy);
|
|
120
|
+
}
|
|
121
|
+
return described;
|
|
122
|
+
}
|
|
123
|
+
exports.describeArguments = describeArguments;
|
|
90
124
|
//# sourceMappingURL=run-plan.js.map
|