hammerkit 1.23.0 → 1.25.0

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 (52) hide show
  1. package/build.schema.json +10 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/executer/execute-command.js +7 -1
  4. package/dist/optimizer/get-cache-directory.js +5 -0
  5. package/dist/planner/utils/append-work-task.js +1 -0
  6. package/dist/planner/work-secret.js +6 -2
  7. package/dist/program.js +6 -0
  8. package/dist/remote/bundle.js +59 -0
  9. package/dist/remote/collect-bundle.js +41 -0
  10. package/dist/remote/credentials.js +93 -0
  11. package/dist/remote/device-login.js +177 -0
  12. package/dist/remote/host-config.js +170 -0
  13. package/dist/remote/location.js +46 -0
  14. package/dist/remote/remote-commands.js +158 -0
  15. package/dist/remote/remote-work-commands.js +224 -0
  16. package/dist/remote/server-client.js +88 -0
  17. package/dist/remote/source-manifest.js +79 -0
  18. package/dist/remote/text-diff.js +89 -0
  19. package/dist/schema/build-file-base-task-schema.js +4 -0
  20. package/dist/schema/build-file-schema.js +3 -0
  21. package/dist/schema/reference-parser.js +9 -2
  22. package/dist/schema/runner-schema.js +13 -0
  23. package/dist/secrets/provider-values.js +4 -0
  24. package/dist/server/accounts-api.js +42 -0
  25. package/dist/server/approvals-api.js +104 -0
  26. package/dist/server/definitions-api.js +163 -0
  27. package/dist/server/definitions.js +160 -0
  28. package/dist/server/discovery.js +9 -0
  29. package/dist/server/executor.js +237 -0
  30. package/dist/server/git-checkout.js +115 -0
  31. package/dist/server/http.js +39 -0
  32. package/dist/server/oauth.js +226 -0
  33. package/dist/server/plan.js +296 -0
  34. package/dist/server/policy.js +138 -0
  35. package/dist/server/proposals-api.js +145 -0
  36. package/dist/server/proposals.js +173 -0
  37. package/dist/server/repo-access.js +127 -0
  38. package/dist/server/repo.js +94 -0
  39. package/dist/server/run-environment.js +20 -0
  40. package/dist/server/run-plan.js +44 -0
  41. package/dist/server/run-request.js +85 -0
  42. package/dist/server/run-store.js +61 -0
  43. package/dist/server/run-worker-command.js +17 -0
  44. package/dist/server/run-worker-main.js +7 -0
  45. package/dist/server/run-worker.js +179 -0
  46. package/dist/server/runs.js +432 -0
  47. package/dist/server/server-command.js +61 -0
  48. package/dist/server/server-config.js +394 -0
  49. package/dist/server/server.js +434 -0
  50. package/dist/server/sources-api.js +106 -0
  51. package/dist/server/sources.js +258 -0
  52. package/package.json +1 -1
@@ -0,0 +1,226 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TokenVerifier = exports.quote = exports.TokenError = void 0;
4
+ const crypto_1 = require("crypto");
5
+ // The reason is for the operator's log, never for the caller.
6
+ class TokenError extends Error {
7
+ }
8
+ exports.TokenError = TokenError;
9
+ // Values from a token are written to the operator's log before the signature
10
+ // is checked, so they are quoted and cut: they can't start a log line of their own.
11
+ function quote(value) {
12
+ return JSON.stringify(String(value).slice(0, 80));
13
+ }
14
+ exports.quote = quote;
15
+ // Asymmetric algorithms only: there is no shared secret, and `none` and HS*
16
+ // are never accepted.
17
+ const ALGORITHMS = {
18
+ RS256: { hash: 'sha256', kty: 'RSA' },
19
+ RS384: { hash: 'sha384', kty: 'RSA' },
20
+ RS512: { hash: 'sha512', kty: 'RSA' },
21
+ PS256: { hash: 'sha256', kty: 'RSA', pss: true },
22
+ ES256: { hash: 'sha256', kty: 'EC', crv: 'P-256' },
23
+ ES384: { hash: 'sha384', kty: 'EC', crv: 'P-384' },
24
+ ES512: { hash: 'sha512', kty: 'EC', crv: 'P-521' },
25
+ };
26
+ const CLOCK_SKEW_SECONDS = 60;
27
+ const KEYS_TTL_MS = 10 * 60 * 1000;
28
+ // an unknown kid refreshes the keys, but not more often than this
29
+ const REFRESH_COOLDOWN_MS = 60 * 1000;
30
+ const FETCH_TIMEOUT_MS = 5000;
31
+ // after a failed load the issuer is left alone for this long, instead of being hit by every request
32
+ const FAILURE_BACKOFF_MS = 10 * 1000;
33
+ // keys that could not be refreshed are still used for this long past their TTL, so a short IdP outage does not lock everyone out
34
+ const STALE_KEYS_MS = 60 * 60 * 1000;
35
+ class TokenVerifier {
36
+ constructor(issuers, options = {}) {
37
+ this.issuers = issuers;
38
+ this.keys = new Map();
39
+ this.loading = new Map();
40
+ this.failures = new Map();
41
+ const fetcher = options.fetch ?? fetch;
42
+ this.now = options.now ?? Date.now;
43
+ this.fetchJson = async (url) => {
44
+ const response = await fetcher(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
45
+ if (!response.ok) {
46
+ throw new TokenError(`${url} answered ${response.status}`);
47
+ }
48
+ return response.json();
49
+ };
50
+ }
51
+ async verify(token) {
52
+ const parts = token.split('.');
53
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
54
+ throw new TokenError('not a signed JWT');
55
+ }
56
+ const header = decodeObject(parts[0], 'header');
57
+ const payload = decodeObject(parts[1], 'payload');
58
+ const algorithm = typeof header.alg === 'string' ? ALGORITHMS[header.alg] : undefined;
59
+ if (!algorithm) {
60
+ throw new TokenError(`algorithm ${quote(header.alg)} is not accepted`);
61
+ }
62
+ const issuer = this.issuers.find((i) => i.issuer === payload.iss);
63
+ if (!issuer) {
64
+ throw new TokenError(`issuer ${quote(payload.iss)} is not accepted`);
65
+ }
66
+ const jwk = await this.findKey(issuer, algorithm, typeof header.kid === 'string' ? header.kid : undefined);
67
+ const signature = Buffer.from(parts[2], 'base64url');
68
+ const data = Buffer.from(`${parts[0]}.${parts[1]}`);
69
+ const key = (0, crypto_1.createPublicKey)({ key: jwk, format: 'jwk' });
70
+ if (!verifySignature(algorithm, data, key, signature)) {
71
+ throw new TokenError('signature does not match');
72
+ }
73
+ return this.readClaims(issuer, payload);
74
+ }
75
+ readClaims(issuer, payload) {
76
+ const now = this.now() / 1000;
77
+ if (typeof payload.exp !== 'number') {
78
+ throw new TokenError('token has no expiry');
79
+ }
80
+ if (payload.exp + CLOCK_SKEW_SECONDS < now) {
81
+ throw new TokenError('token expired');
82
+ }
83
+ if (typeof payload.nbf === 'number' && payload.nbf - CLOCK_SKEW_SECONDS > now) {
84
+ throw new TokenError('token not valid yet');
85
+ }
86
+ const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
87
+ if (!audiences.includes(issuer.audience)) {
88
+ throw new TokenError(`token is not meant for ${issuer.audience}`);
89
+ }
90
+ if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
91
+ throw new TokenError('token has no subject');
92
+ }
93
+ const groups = payload[issuer.groupsClaim];
94
+ const claims = {};
95
+ for (const [name, value] of Object.entries(payload)) {
96
+ if (typeof value === 'string') {
97
+ claims[name] = value;
98
+ }
99
+ }
100
+ return {
101
+ iss: issuer.issuer,
102
+ sub: payload.sub,
103
+ groups: Array.isArray(groups)
104
+ ? groups.filter((g) => typeof g === 'string')
105
+ : typeof groups === 'string'
106
+ ? [groups]
107
+ : [],
108
+ claims,
109
+ };
110
+ }
111
+ async findKey(issuer, algorithm, kid) {
112
+ const known = this.keys.get(issuer.issuer);
113
+ const stale = !known || this.now() - known.loadedAt > KEYS_TTL_MS;
114
+ let loaded;
115
+ if (stale) {
116
+ try {
117
+ loaded = await this.loadKeys(issuer);
118
+ }
119
+ catch (e) {
120
+ if (!known || this.now() - known.loadedAt > KEYS_TTL_MS + STALE_KEYS_MS) {
121
+ throw e;
122
+ }
123
+ loaded = known;
124
+ }
125
+ }
126
+ else {
127
+ loaded = known;
128
+ }
129
+ let jwk = pickKey(loaded.keys, algorithm, kid);
130
+ if (!jwk && !stale && this.now() - loaded.loadedAt > REFRESH_COOLDOWN_MS) {
131
+ // the issuer may have rotated its keys since they were cached
132
+ try {
133
+ loaded = await this.loadKeys(issuer);
134
+ jwk = pickKey(loaded.keys, algorithm, kid);
135
+ }
136
+ catch {
137
+ // keep the cached keys: the token simply has no key we know
138
+ }
139
+ }
140
+ if (!jwk) {
141
+ throw new TokenError(`issuer has no ${algorithm.kty} key${kid ? ` ${quote(kid)}` : ''}`);
142
+ }
143
+ return jwk;
144
+ }
145
+ // One load per issuer at a time, and none right after a failed one.
146
+ loadKeys(issuer) {
147
+ const running = this.loading.get(issuer.issuer);
148
+ if (running) {
149
+ return running;
150
+ }
151
+ const failure = this.failures.get(issuer.issuer);
152
+ if (failure && this.now() - failure.at < FAILURE_BACKOFF_MS) {
153
+ return Promise.reject(failure.error);
154
+ }
155
+ const load = this.fetchKeys(issuer)
156
+ .then((loaded) => {
157
+ this.failures.delete(issuer.issuer);
158
+ return loaded;
159
+ })
160
+ .catch((error) => {
161
+ this.failures.set(issuer.issuer, { at: this.now(), error });
162
+ throw error;
163
+ })
164
+ .finally(() => this.loading.delete(issuer.issuer));
165
+ this.loading.set(issuer.issuer, load);
166
+ return load;
167
+ }
168
+ async fetchKeys(issuer) {
169
+ const base = issuer.issuer.replace(/\/+$/, '');
170
+ const metadata = (await this.fetchJson(`${base}/.well-known/openid-configuration`));
171
+ if (metadata.issuer !== issuer.issuer) {
172
+ throw new TokenError(`issuer metadata names ${quote(metadata.issuer)}`);
173
+ }
174
+ const jwksUri = typeof metadata.jwks_uri === 'string' ? metadata.jwks_uri : '';
175
+ if (!isSecureUrl(jwksUri)) {
176
+ throw new TokenError(`jwks_uri ${quote(jwksUri)} is not https`);
177
+ }
178
+ const jwks = (await this.fetchJson(jwksUri));
179
+ const loaded = { keys: Array.isArray(jwks.keys) ? jwks.keys : [], loadedAt: this.now() };
180
+ this.keys.set(issuer.issuer, loaded);
181
+ return loaded;
182
+ }
183
+ }
184
+ exports.TokenVerifier = TokenVerifier;
185
+ function pickKey(keys, algorithm, kid) {
186
+ const candidates = keys.filter((jwk) => jwk.kty === algorithm.kty &&
187
+ jwk.use !== 'enc' &&
188
+ (algorithm.crv === undefined || jwk.crv === algorithm.crv));
189
+ if (kid !== undefined) {
190
+ return candidates.find((jwk) => jwk.kid === kid);
191
+ }
192
+ // a token without kid is only unambiguous when the issuer has one such key
193
+ return candidates.length === 1 ? candidates[0] : undefined;
194
+ }
195
+ function verifySignature(algorithm, data, key, signature) {
196
+ if (algorithm.kty === 'EC') {
197
+ return (0, crypto_1.verify)(algorithm.hash, data, { key, dsaEncoding: 'ieee-p1363' }, signature);
198
+ }
199
+ if (algorithm.pss) {
200
+ return (0, crypto_1.verify)(algorithm.hash, data, { key, padding: crypto_1.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto_1.constants.RSA_PSS_SALTLEN_DIGEST }, signature);
201
+ }
202
+ return (0, crypto_1.verify)(algorithm.hash, data, key, signature);
203
+ }
204
+ function decodeObject(part, what) {
205
+ try {
206
+ const value = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
207
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
208
+ return value;
209
+ }
210
+ }
211
+ catch {
212
+ // reported below
213
+ }
214
+ throw new TokenError(`token ${what} is not a JSON object`);
215
+ }
216
+ function isSecureUrl(value) {
217
+ try {
218
+ const url = new URL(value);
219
+ return (url.protocol === 'https:' ||
220
+ (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ //# sourceMappingURL=oauth.js.map
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.inspectBundle = exports.planBundle = exports.DefinitionError = void 0;
4
+ const promises_1 = require("fs/promises");
5
+ const os_1 = require("os");
6
+ const path_1 = require("path");
7
+ const yaml_1 = require("yaml");
8
+ const get_file_context_1 = require("../file/get-file-context");
9
+ const resolve_git_source_1 = require("../includes/resolve-git-source");
10
+ const schema_parser_1 = require("../schema/schema-parser");
11
+ const reference_parser_1 = require("../schema/reference-parser");
12
+ const work_scope_parser_1 = require("../schema/work-scope-parser");
13
+ const work_task_1 = require("../planner/work-task");
14
+ const work_service_1 = require("../planner/work-service");
15
+ const log_1 = require("../log");
16
+ const parse_error_1 = require("../schema/parse-error");
17
+ class DefinitionError extends Error {
18
+ }
19
+ exports.DefinitionError = DefinitionError;
20
+ // 40 (sha-1) or 64 (sha-256) hex digits
21
+ const COMMIT_PATTERN = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
22
+ // A hash alone can only name what is inside the bundle, so everything a build
23
+ // file pulls in from elsewhere has to be pinned: a git include needs a commit
24
+ // and a remote that is not a path on the server.
25
+ function findLooseIncludes(bundle, noRemote = false) {
26
+ const problems = [];
27
+ for (const [path, content] of Object.entries(bundle.files)) {
28
+ if (!/\.ya?ml$/i.test(path)) {
29
+ continue;
30
+ }
31
+ let document;
32
+ try {
33
+ document = (0, yaml_1.parse)(content);
34
+ }
35
+ catch {
36
+ continue; // a file that isn't valid yaml is reported when the bundle is parsed
37
+ }
38
+ for (const block of ['includes', 'references']) {
39
+ for (const [name, include] of Object.entries(document?.[block] ?? {})) {
40
+ const source = include;
41
+ if (!source || typeof source !== 'object' || source.git === undefined) {
42
+ continue;
43
+ }
44
+ if (noRemote) {
45
+ problems.push(`${path}: ${block}.${name}: a proposal may not include from git, the server would fetch it before anybody approved it`);
46
+ continue;
47
+ }
48
+ if (typeof source.git !== 'string' || !/^(https:\/\/|ssh:\/\/|git@[\w.-]+:)/.test(source.git)) {
49
+ problems.push(`${path}: ${block}.${name}: git has to be an https:// or ssh:// address`);
50
+ }
51
+ if (typeof source.ref !== 'string' || !COMMIT_PATTERN.test(source.ref)) {
52
+ problems.push(`${path}: ${block}.${name}: pin ref to a commit, a branch or tag can move`);
53
+ }
54
+ }
55
+ }
56
+ }
57
+ return problems;
58
+ }
59
+ // What a build file may not name on a server: the places it would run or store things
60
+ // are the operator's. The docker host, a Kubernetes cluster and a remote cache are
61
+ // credentials of the machine, not of the build file, and a project file that could carry
62
+ // them could send runs, or the cache, anywhere.
63
+ function findHostProblems(bundle) {
64
+ const problems = [];
65
+ for (const [path, content] of Object.entries(bundle.files)) {
66
+ if (!/\.ya?ml$/i.test(path)) {
67
+ continue;
68
+ }
69
+ let document;
70
+ try {
71
+ document = (0, yaml_1.parse)(content);
72
+ }
73
+ catch {
74
+ continue; // reported when the bundle is parsed
75
+ }
76
+ if (!document || typeof document !== 'object') {
77
+ continue;
78
+ }
79
+ if (document.environments && Object.keys(document.environments).length > 0) {
80
+ problems.push(`${path}: environments pick a docker host or a Kubernetes cluster, which are the server's to choose, remove them`);
81
+ }
82
+ for (const [name, cache] of Object.entries(document.caches ?? {})) {
83
+ if (cache?.backend?.type !== 'local' || cache.backend.path !== undefined) {
84
+ problems.push(`${path}: caches.${name}: a server keeps its own cache, a build file names no backend or path`);
85
+ }
86
+ }
87
+ for (const [name, service] of Object.entries(document.services ?? {})) {
88
+ if (service &&
89
+ typeof service === 'object' &&
90
+ ('selector' in service || 'kubeconfig' in service || 'context' in service)) {
91
+ problems.push(`${path}: services.${name}: Kubernetes services are not supported on a server`);
92
+ }
93
+ }
94
+ }
95
+ return problems;
96
+ }
97
+ // What a build file may read while it is inspected: its own files. The rest of
98
+ // the server is not its business (a missing file is "not there", not an error).
99
+ function confined(files, root) {
100
+ const check = (path) => {
101
+ if (!(0, resolve_git_source_1.isWithin)(root, (0, path_1.resolve)(root, path))) {
102
+ throw new DefinitionError(`${path} is outside of the bundle`);
103
+ }
104
+ };
105
+ const guarded = (fn) => (path) => {
106
+ check(path);
107
+ return fn.call(files, path);
108
+ };
109
+ return {
110
+ ...files,
111
+ read: guarded(files.read),
112
+ readStream: guarded(files.readStream),
113
+ listFiles: guarded(files.listFiles),
114
+ stats: guarded(files.stats),
115
+ exists: async (path) => (0, resolve_git_source_1.isWithin)(root, (0, path_1.resolve)(root, path)) && files.exists(path),
116
+ watch: files.watch,
117
+ };
118
+ }
119
+ // A path of a task, written relative to the root of the bundle with forward
120
+ // slashes. A leading `!` (an exclusion) is kept. Nothing is returned for a path
121
+ // outside of the bundle.
122
+ function insideBundle(root, cwd, path) {
123
+ const negated = path.startsWith('!');
124
+ const absolute = (0, path_1.resolve)(cwd, negated ? path.slice(1) : path);
125
+ const inside = (0, path_1.relative)(root, absolute);
126
+ if (inside === '..' || inside.startsWith(`..${path_1.sep}`) || (0, path_1.isAbsolute)(inside)) {
127
+ return [];
128
+ }
129
+ const portable = inside.split(path_1.sep).join(path_1.posix.sep);
130
+ // a pattern keeps its glob characters, `resolve` only normalizes separators
131
+ return [`${negated ? '!' : ''}${portable === '' ? '.' : portable}`];
132
+ }
133
+ function itemsOf(workTree) {
134
+ return [...Object.values(workTree.tasks), ...Object.values(workTree.services)];
135
+ }
136
+ function secretsOf(item) {
137
+ return 'secrets' in item.data ? item.data.secrets : [];
138
+ }
139
+ function describeSecret(secret) {
140
+ const target = secret.target.type === 'env' ? secret.target.name : secret.target.path;
141
+ const source = secret.source;
142
+ if (source.type === 'provider') {
143
+ return { from: `${source.provider}:${source.ref}`, account: source.account, target };
144
+ }
145
+ return { from: source.type === 'env' ? `env:${source.name}` : `file:${source.path}`, account: null, target };
146
+ }
147
+ // What a server refuses to publish. A secret that reads a file or a variable of the
148
+ // server has no account, so nothing would stand between the build file and the
149
+ // operator's own credentials.
150
+ function findPolicyProblems(items, policy, root) {
151
+ const problems = [];
152
+ for (const item of items) {
153
+ const runtime = item.data.type === 'local-task' ? 'local' : 'docker';
154
+ if (policy.runtimes && !policy.runtimes.includes(runtime)) {
155
+ problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
156
+ }
157
+ const data = item.data;
158
+ if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
159
+ for (const mount of data.mounts) {
160
+ // a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
161
+ if (insideBundle(root, data.cwd, mount.localPath).length === 0) {
162
+ problems.push(`${item.name}: mounts ${mount.localPath}, which is outside of the bundle`);
163
+ }
164
+ }
165
+ }
166
+ for (const secret of secretsOf(item)) {
167
+ const source = secret.source;
168
+ if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
169
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
170
+ }
171
+ else if (source.type === 'file') {
172
+ problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
173
+ }
174
+ else if (source.type === 'env' && !policy.envs.includes(source.name)) {
175
+ problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in runs.env.allow`);
176
+ }
177
+ }
178
+ }
179
+ return problems;
180
+ }
181
+ function toPlan(root, item) {
182
+ const data = item.data;
183
+ const secrets = secretsOf(item).map(describeSecret);
184
+ const accounts = [...new Set(secrets.flatMap((secret) => (secret.account ? [secret.account] : [])))].sort();
185
+ const mounts = (0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)
186
+ ? data.mounts.map((mount) => {
187
+ const inside = insideBundle(root, data.cwd, mount.localPath);
188
+ return `${inside.length > 0 ? inside[0] : mount.localPath}:${mount.containerPath}${mount.readOnly ? ':ro' : ''}`;
189
+ })
190
+ : [];
191
+ const ports = 'ports' in data ? data.ports.map((port) => `${port.hostPort ?? ''}:${port.containerPort}`) : [];
192
+ const flags = [];
193
+ if (data.type === 'local-task') {
194
+ flags.push('local-task');
195
+ }
196
+ if ('ports' in data && data.ports.some((port) => port.hostPort !== null)) {
197
+ flags.push('host-port');
198
+ }
199
+ if (data.scope.remote) {
200
+ flags.push('remote-include');
201
+ }
202
+ const isTask = data.type === 'local-task' || data.type === 'container-task';
203
+ return {
204
+ kind: isTask ? 'task' : 'service',
205
+ runtime: data.type === 'local-task' ? 'local' : 'docker',
206
+ ...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
207
+ cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
208
+ deps: item.deps.map((dep) => dep.name).sort(),
209
+ needs: item.needs.map((need) => need.name).sort(),
210
+ accounts,
211
+ secrets,
212
+ mounts,
213
+ ports,
214
+ generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
215
+ flags,
216
+ };
217
+ }
218
+ // A file that does not match the schema says where, not only that it does not.
219
+ function describeFailure(e) {
220
+ if (e instanceof parse_error_1.ParseError) {
221
+ const issue = e.zod.issues[0];
222
+ return `${e.buildFilePath}: ${issue.path.length > 0 ? `${issue.path.join('.')}: ` : ''}${issue.message}`;
223
+ }
224
+ return (0, log_1.getErrorMessage)(e);
225
+ }
226
+ // Writes the bundle into a directory of its own and reads it the way a run
227
+ // would, so a build file that doesn't parse is refused when it is published, not
228
+ // when somebody wants to run it. The same function answers what a run will do,
229
+ // with the scope of the run: which tasks, services and accounts it involves.
230
+ async function planBundle(bundle, environment, options = {}) {
231
+ const problems = [...findLooseIncludes(bundle, options.noRemote), ...(options.policy ? findHostProblems(bundle) : [])];
232
+ if (problems.length > 0) {
233
+ throw new DefinitionError(problems.join('\n'));
234
+ }
235
+ const directory = await (0, promises_1.mkdtemp)((0, path_1.join)((0, os_1.tmpdir)(), 'hammerkit-definition-'));
236
+ try {
237
+ for (const [path, content] of Object.entries(bundle.files)) {
238
+ await (0, promises_1.mkdir)((0, path_1.dirname)((0, path_1.join)(directory, path)), { recursive: true });
239
+ await (0, promises_1.writeFile)((0, path_1.join)(directory, path), content);
240
+ }
241
+ const inspected = {
242
+ ...environment,
243
+ cwd: directory,
244
+ file: confined((0, get_file_context_1.getFileContext)(directory), directory),
245
+ };
246
+ try {
247
+ const { ctx, scope } = await (0, schema_parser_1.createParseContext)((0, path_1.join)(directory, bundle.entry), inspected);
248
+ const context = await (0, reference_parser_1.parseReferences)(ctx, scope, inspected);
249
+ const workTree = (0, work_scope_parser_1.getWorkContext)(context, options.scope ?? { environmentName: null }, inspected);
250
+ const items = itemsOf(workTree);
251
+ if (options.policy) {
252
+ const policyProblems = findPolicyProblems(items, options.policy, directory);
253
+ if (policyProblems.length > 0) {
254
+ throw new DefinitionError(policyProblems.join('\n'));
255
+ }
256
+ }
257
+ const inputs = {};
258
+ for (const item of Object.values(workTree.tasks)) {
259
+ inputs[item.name] = {
260
+ // the sources a task inherits from its dependencies belong to those
261
+ src: item.data.src
262
+ .filter((source) => source.inherited === null)
263
+ .flatMap((source) => insideBundle(directory, item.data.cwd, source.source)),
264
+ mounts: (0, work_task_1.isContainerWorkTask)(item.data)
265
+ ? item.data.mounts.flatMap((mount) => insideBundle(directory, item.data.cwd, mount.localPath))
266
+ : [],
267
+ deps: item.deps.map((dep) => dep.name).sort(),
268
+ needs: item.needs.map((need) => need.name).sort(),
269
+ };
270
+ }
271
+ const plan = {};
272
+ for (const item of items) {
273
+ plan[item.name] = toPlan(directory, item);
274
+ }
275
+ const sorted = (record) => Object.fromEntries(Object.entries(record).sort(([a], [b]) => (a < b ? -1 : 1)));
276
+ return {
277
+ tasks: Object.keys(context.tasks).sort(),
278
+ services: Object.keys(context.services).sort(),
279
+ inputs: sorted(inputs),
280
+ plan: sorted(plan),
281
+ accounts: [...new Set(Object.values(plan).flatMap((entry) => entry.accounts))].sort(),
282
+ };
283
+ }
284
+ catch (e) {
285
+ throw new DefinitionError(describeFailure(e).split(directory).join('.'));
286
+ }
287
+ }
288
+ finally {
289
+ await (0, promises_1.rm)(directory, { recursive: true, force: true });
290
+ }
291
+ }
292
+ exports.planBundle = planBundle;
293
+ // Reads a bundle the way a run would; see planBundle.
294
+ const inspectBundle = (bundle, environment) => planBundle(bundle, environment);
295
+ exports.inspectBundle = inspectBundle;
296
+ //# sourceMappingURL=plan.js.map
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decideRun = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
4
+ const repo_1 = require("./repo");
5
+ const BUILDS_ORDER = ['run', 'propose', 'publish'];
6
+ function matches(entitlement, principal) {
7
+ if (entitlement.subject) {
8
+ return entitlement.subject.iss === principal.iss && entitlement.subject.sub === principal.sub;
9
+ }
10
+ if (entitlement.group) {
11
+ return entitlement.group.iss === principal.iss && principal.groups.includes(entitlement.group.name);
12
+ }
13
+ if (entitlement.claims) {
14
+ return (entitlement.claims.iss === principal.iss &&
15
+ Object.entries(entitlement.claims.match).every(([name, value]) => principal.claims[name] === value));
16
+ }
17
+ return false;
18
+ }
19
+ exports.matches = matches;
20
+ function atLeast(grant, level) {
21
+ return grant.builds !== null && BUILDS_ORDER.indexOf(grant.builds) >= BUILDS_ORDER.indexOf(level);
22
+ }
23
+ const mayRun = (grant) => atLeast(grant, 'run');
24
+ exports.mayRun = mayRun;
25
+ const mayPropose = (grant) => atLeast(grant, 'propose');
26
+ exports.mayPropose = mayPropose;
27
+ const mayPublish = (grant) => atLeast(grant, 'publish');
28
+ exports.mayPublish = mayPublish;
29
+ // A caller who can neither run nor publish anything sees nothing of the server's
30
+ // build files either. Someone who uses an account sees them: they have to know what
31
+ // they approve.
32
+ function maySeeDefinitions(grant) {
33
+ return grant.builds !== null || Object.keys(grant.accounts).length > 0;
34
+ }
35
+ exports.maySeeDefinitions = maySeeDefinitions;
36
+ // `use` includes deciding about the runs others request on the account.
37
+ function canUse(grant, account) {
38
+ return grant.accounts[account] === 'use';
39
+ }
40
+ exports.canUse = canUse;
41
+ // Pulling a version back is a safe thing to do, so it is open to who publishes the build
42
+ // file, and to who uses an account the version works with: they are the ones who
43
+ // answer for what runs as it.
44
+ function mayYank(grant, version) {
45
+ return (0, exports.mayPublish)(grant) || (version.accounts ?? []).some((account) => canUse(grant, account));
46
+ }
47
+ exports.mayYank = mayYank;
48
+ function usedAccounts(grant) {
49
+ return Object.keys(grant.accounts).filter((account) => grant.accounts[account] === 'use');
50
+ }
51
+ exports.usedAccounts = usedAccounts;
52
+ // Default deny: a caller without a matching entitlement gets nothing.
53
+ function getGrant(entitlements, principal) {
54
+ const matched = [];
55
+ const accounts = {};
56
+ const repos = [];
57
+ let builds = null;
58
+ entitlements.forEach((entitlement, index) => {
59
+ if (!matches(entitlement, principal)) {
60
+ return;
61
+ }
62
+ matched.push(index);
63
+ for (const [account, level] of Object.entries(entitlement.accounts)) {
64
+ if (accounts[account] !== 'use') {
65
+ accounts[account] = level;
66
+ }
67
+ }
68
+ for (const repo of entitlement.repos) {
69
+ if (!repos.includes(repo)) {
70
+ repos.push(repo);
71
+ }
72
+ }
73
+ if (entitlement.builds &&
74
+ (builds === null || BUILDS_ORDER.indexOf(entitlement.builds) > BUILDS_ORDER.indexOf(builds))) {
75
+ builds = entitlement.builds;
76
+ }
77
+ });
78
+ return { accounts, repos, builds, matched };
79
+ }
80
+ exports.getGrant = getGrant;
81
+ const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
82
+ // The single place that decides what happens to a run that was submitted: it
83
+ // starts, waits for an approval, or is refused with the reason.
84
+ function decideRun(input) {
85
+ const { grant, accounts, catalog, source, approvable, readable } = input;
86
+ if (!(0, exports.mayRun)(grant)) {
87
+ return { type: 'refuse', status: 403, error: 'you are not entitled to run anything on this server' };
88
+ }
89
+ if (source.type === 'upload') {
90
+ return {
91
+ type: 'refuse',
92
+ status: 501,
93
+ error: 'runs from an upload are not supported by this server yet, use a git ref',
94
+ };
95
+ }
96
+ // the caller's own list first: what they may not name is not told whether the server can read it
97
+ if (!(0, repo_1.matchesAnyRepo)(grant.repos, source.repo)) {
98
+ return {
99
+ type: 'refuse',
100
+ status: 403,
101
+ error: grant.repos.length > 0
102
+ ? `you may not run from ${source.repo}, your repositories are ${grant.repos.join(', ')}`
103
+ : `you may not run from ${source.repo}, no entitlement of yours lists a repository`,
104
+ };
105
+ }
106
+ if (!readable(source.repo)) {
107
+ return { type: 'refuse', status: 400, error: `this server can not read ${source.repo}` };
108
+ }
109
+ const request = [];
110
+ for (const account of accounts) {
111
+ const level = grant.accounts[account];
112
+ if (level === undefined) {
113
+ return { type: 'refuse', status: 403, error: `you may not use the account ${account}, it needs use or request` };
114
+ }
115
+ const rules = catalog[account];
116
+ if (rules?.source && !rules.source.includes(source.type)) {
117
+ return { type: 'refuse', status: 403, error: `the account ${account} is not used by runs from ${source.type}` };
118
+ }
119
+ if (rules?.refs) {
120
+ if (COMMIT.test(source.ref) || !(0, repo_1.matchesRef)(rules.refs, source.ref)) {
121
+ return {
122
+ type: 'refuse',
123
+ status: 403,
124
+ error: `the account ${account} is only used by runs from ${rules.refs.join(', ')}`,
125
+ };
126
+ }
127
+ }
128
+ if (level === 'request') {
129
+ if (!approvable(account)) {
130
+ return { type: 'refuse', status: 409, error: `nobody can approve ${account}, no entitlement uses it` };
131
+ }
132
+ request.push(account);
133
+ }
134
+ }
135
+ return request.length > 0 ? { type: 'approval', accounts: request } : { type: 'run' };
136
+ }
137
+ exports.decideRun = decideRun;
138
+ //# sourceMappingURL=policy.js.map