hammerkit 1.22.0 → 1.24.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.
- package/build.schema.json +92 -2
- package/dist/cli.js +25 -0
- package/dist/executer/environment-mock.js +4 -1
- package/dist/executer/execute-command.js +7 -1
- package/dist/index.js +4 -1
- package/dist/planner/service-definition.js +3 -1
- package/dist/planner/utils/append-work-service.js +1 -1
- package/dist/planner/utils/append-work-task.js +2 -1
- package/dist/planner/work-item-status.js +3 -2
- package/dist/planner/work-secret.js +87 -10
- package/dist/program.js +4 -0
- package/dist/remote/bundle.js +59 -0
- package/dist/remote/collect-bundle.js +41 -0
- package/dist/remote/credentials.js +93 -0
- package/dist/remote/device-login.js +177 -0
- package/dist/remote/host-config.js +170 -0
- package/dist/remote/location.js +46 -0
- package/dist/remote/remote-commands.js +154 -0
- package/dist/remote/remote-work-commands.js +152 -0
- package/dist/remote/server-client.js +88 -0
- package/dist/remote/source-manifest.js +79 -0
- package/dist/remote/text-diff.js +89 -0
- package/dist/schema/build-file-base-task-schema.js +6 -0
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-schema.js +6 -0
- package/dist/schema/build-file-secret-provider-schema.js +36 -0
- package/dist/schema/build-file-secret-schema.js +6 -3
- package/dist/schema/reference-parser.js +4 -0
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/command-secret-provider.js +108 -0
- package/dist/secrets/prefetch-secrets.js +27 -0
- package/dist/secrets/provider-values.js +47 -0
- package/dist/secrets/secret-catalog.js +78 -0
- package/dist/secrets/secret-provider.js +19 -0
- package/dist/server/approvals-api.js +99 -0
- package/dist/server/definitions-api.js +119 -0
- package/dist/server/definitions.js +248 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +238 -0
- package/dist/server/git-checkout.js +63 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/policy.js +90 -0
- package/dist/server/proposals-api.js +123 -0
- package/dist/server/proposals.js +126 -0
- package/dist/server/run-request.js +67 -0
- package/dist/server/runs.js +279 -0
- package/dist/server/server-command.js +43 -0
- package/dist/server/server-config.js +233 -0
- package/dist/server/server.js +338 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/dist/utils/redact.js +75 -19
- 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,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getGrant = exports.isAdmin = exports.maySeeDefinitions = exports.mayPropose = exports.mayPublish = exports.matches = void 0;
|
|
4
|
+
function matches(entitlement, principal) {
|
|
5
|
+
if (entitlement.subject) {
|
|
6
|
+
return entitlement.subject.iss === principal.iss && entitlement.subject.sub === principal.sub;
|
|
7
|
+
}
|
|
8
|
+
if (entitlement.group) {
|
|
9
|
+
return entitlement.group.iss === principal.iss && principal.groups.includes(entitlement.group.name);
|
|
10
|
+
}
|
|
11
|
+
if (entitlement.claims) {
|
|
12
|
+
return (entitlement.claims.iss === principal.iss &&
|
|
13
|
+
Object.entries(entitlement.claims.match).every(([name, value]) => principal.claims[name] === value));
|
|
14
|
+
}
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
exports.matches = matches;
|
|
18
|
+
function mayPublish(grant, name) {
|
|
19
|
+
return grant.publish.includes('*') || grant.publish.includes(name);
|
|
20
|
+
}
|
|
21
|
+
exports.mayPublish = mayPublish;
|
|
22
|
+
function mayPropose(grant, name) {
|
|
23
|
+
return grant.propose.includes('*') || grant.propose.includes(name);
|
|
24
|
+
}
|
|
25
|
+
exports.mayPropose = mayPropose;
|
|
26
|
+
// A caller who can neither run nor publish anything sees nothing of the server's
|
|
27
|
+
// build files either. Admins see them, they have to know what they approve.
|
|
28
|
+
function maySeeDefinitions(grant) {
|
|
29
|
+
return grant.accounts.length > 0 || grant.request.length > 0 || grant.publish.length > 0 || grant.propose.length > 0;
|
|
30
|
+
}
|
|
31
|
+
exports.maySeeDefinitions = maySeeDefinitions;
|
|
32
|
+
// Admin of an account decides about the runs on it.
|
|
33
|
+
function isAdmin(grant, account) {
|
|
34
|
+
return account !== null && grant.accounts.includes(account);
|
|
35
|
+
}
|
|
36
|
+
exports.isAdmin = isAdmin;
|
|
37
|
+
// Default deny: a caller without a matching entitlement gets no account.
|
|
38
|
+
function getGrant(entitlements, principal) {
|
|
39
|
+
const matched = [];
|
|
40
|
+
const accounts = [];
|
|
41
|
+
const requested = [];
|
|
42
|
+
const publish = [];
|
|
43
|
+
const propose = [];
|
|
44
|
+
let defaultAccount = null;
|
|
45
|
+
let source = null;
|
|
46
|
+
entitlements.forEach((entitlement, index) => {
|
|
47
|
+
if (!matches(entitlement, principal)) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
matched.push(index);
|
|
51
|
+
for (const account of entitlement.accounts) {
|
|
52
|
+
if (!accounts.includes(account)) {
|
|
53
|
+
accounts.push(account);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const account of entitlement.request ?? []) {
|
|
57
|
+
if (!requested.includes(account)) {
|
|
58
|
+
requested.push(account);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const name of entitlement.publish ?? []) {
|
|
62
|
+
if (!publish.includes(name)) {
|
|
63
|
+
publish.push(name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const name of entitlement.propose ?? []) {
|
|
67
|
+
if (!propose.includes(name)) {
|
|
68
|
+
propose.push(name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
defaultAccount = defaultAccount ?? entitlement.default ?? null;
|
|
72
|
+
if (entitlement.source) {
|
|
73
|
+
source = source ? source.filter((s) => entitlement.source?.includes(s)) : [...entitlement.source];
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
// admin wins over request when two entitlements give both
|
|
77
|
+
const request = requested.filter((account) => !accounts.includes(account));
|
|
78
|
+
const usable = [...accounts, ...request];
|
|
79
|
+
return {
|
|
80
|
+
accounts,
|
|
81
|
+
request,
|
|
82
|
+
publish,
|
|
83
|
+
propose,
|
|
84
|
+
default: defaultAccount ?? (usable.length === 1 ? usable[0] : null),
|
|
85
|
+
source: source ?? ['upload', 'ref'],
|
|
86
|
+
matched,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
exports.getGrant = getGrant;
|
|
90
|
+
//# sourceMappingURL=policy.js.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleProposals = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const bundle_1 = require("../remote/bundle");
|
|
6
|
+
const definitions_1 = require("./definitions");
|
|
7
|
+
const http_1 = require("./http");
|
|
8
|
+
const oauth_1 = require("./oauth");
|
|
9
|
+
const policy_1 = require("./policy");
|
|
10
|
+
const server_config_1 = require("./server-config");
|
|
11
|
+
const decisionSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
|
|
12
|
+
const same = (a, b) => a.iss === b.iss && a.sub === b.sub;
|
|
13
|
+
// What the caller is shown of a proposal: what it is, who proposed it and the
|
|
14
|
+
// version it would replace, so the files can be compared.
|
|
15
|
+
function toView(store, proposal, principal, grant) {
|
|
16
|
+
return {
|
|
17
|
+
...proposal,
|
|
18
|
+
base: store.find(proposal.name, 'latest')?.hash ?? null,
|
|
19
|
+
mine: same(proposal.proposedBy, principal),
|
|
20
|
+
// approving needs the permission to publish the name, and somebody else's proposal
|
|
21
|
+
canDecide: proposal.state === 'waiting' && (0, policy_1.mayPublish)(grant, proposal.name) && !same(proposal.proposedBy, principal),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* New versions of build files that wait for an approval. Whoever may only
|
|
26
|
+
* propose a version (`propose` on an entitlement) puts one here; someone who may
|
|
27
|
+
* `publish` that name approves it, and it is published then.
|
|
28
|
+
*
|
|
29
|
+
* GET /v1/proposals the ones the caller proposed or may decide
|
|
30
|
+
* GET /v1/proposals/<name>/<hash> one of them
|
|
31
|
+
* GET /v1/proposals/<name>/<hash>/files its files, for who decides
|
|
32
|
+
* POST /v1/proposals/<name>/<hash>/approve publish it (body: optional {reason})
|
|
33
|
+
* POST /v1/proposals/<name>/<hash>/reject drop it
|
|
34
|
+
*/
|
|
35
|
+
async function handleProposals(config, store, proposals, log, principal, req, res, url) {
|
|
36
|
+
const match = /^\/v1\/proposals(?:\/([^/]+)\/([^/]+)(?:\/(files|approve|reject))?)?$/.exec(url.pathname);
|
|
37
|
+
if (!match) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
const [, name, hash, action] = match;
|
|
41
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
42
|
+
// what the caller neither proposed nor may decide does not exist for them
|
|
43
|
+
const visible = (proposal) => same(proposal.proposedBy, principal) || (0, policy_1.mayPublish)(grant, proposal.name);
|
|
44
|
+
if (name === undefined) {
|
|
45
|
+
if (req.method !== 'GET') {
|
|
46
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
(0, http_1.send)(res, 200, {
|
|
50
|
+
proposals: proposals
|
|
51
|
+
.all()
|
|
52
|
+
.filter(visible)
|
|
53
|
+
.map((proposal) => toView(store, proposal, principal, grant)),
|
|
54
|
+
});
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (!server_config_1.definitionName.safeParse(name).success || !bundle_1.HASH_PATTERN.test(hash)) {
|
|
58
|
+
(0, http_1.send)(res, 404, { error: 'not found' });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
const proposal = proposals.get(name, hash);
|
|
62
|
+
if (!action) {
|
|
63
|
+
if (req.method !== 'GET') {
|
|
64
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
proposal && visible(proposal)
|
|
68
|
+
? (0, http_1.send)(res, 200, toView(store, proposal, principal, grant))
|
|
69
|
+
: (0, http_1.send)(res, 404, { error: 'not found' });
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (action === 'files') {
|
|
73
|
+
if (req.method !== 'GET') {
|
|
74
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
// the files are for who decides, and for who wrote them
|
|
78
|
+
const bundle = proposal && visible(proposal) ? proposals.bundle(name, hash) : undefined;
|
|
79
|
+
bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (req.method !== 'POST') {
|
|
83
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
const body = decisionSchema.safeParse(req.headers['content-type'] ? await (0, http_1.readJson)(req) : {});
|
|
87
|
+
if (!body.success) {
|
|
88
|
+
const issue = body.error.issues[0];
|
|
89
|
+
(0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const outcome = action === 'approve' ? 'approved' : 'rejected';
|
|
93
|
+
try {
|
|
94
|
+
const result = await proposals.decide(principal, name, hash, outcome, (n) => (0, policy_1.mayPublish)(grant, n), async (bundle) => {
|
|
95
|
+
const proposed = proposals.get(name, hash);
|
|
96
|
+
await store.publish(name, bundle, proposed?.proposedBy ?? principal, principal);
|
|
97
|
+
}, body.data.reason);
|
|
98
|
+
log(`${action} proposal=${name}@${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
|
|
99
|
+
switch (result.status) {
|
|
100
|
+
case 'ok':
|
|
101
|
+
(0, http_1.send)(res, 200, toView(store, result.proposal, principal, grant));
|
|
102
|
+
break;
|
|
103
|
+
case 'own-proposal':
|
|
104
|
+
(0, http_1.send)(res, 403, { error: 'a version is approved by somebody else than who proposed it' });
|
|
105
|
+
break;
|
|
106
|
+
case 'not-waiting':
|
|
107
|
+
(0, http_1.send)(res, 409, { error: `the proposal is ${result.state}, it does not wait for an approval` });
|
|
108
|
+
break;
|
|
109
|
+
default:
|
|
110
|
+
(0, http_1.send)(res, 404, { error: 'not found' });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
if (e instanceof definitions_1.DefinitionError) {
|
|
115
|
+
(0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
exports.handleProposals = handleProposals;
|
|
123
|
+
//# sourceMappingURL=proposals-api.js.map
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProposalRegistry = exports.ProposalError = void 0;
|
|
4
|
+
class ProposalError extends Error {
|
|
5
|
+
}
|
|
6
|
+
exports.ProposalError = ProposalError;
|
|
7
|
+
// The proposals of the server, in memory: a restart forgets what waits, and
|
|
8
|
+
// whoever proposed it proposes it again. Nothing in here is runnable.
|
|
9
|
+
class ProposalRegistry {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.entries = new Map();
|
|
12
|
+
this.closed = false;
|
|
13
|
+
this.maxWaiting = options.maxWaiting ?? 100;
|
|
14
|
+
this.timeout = options.timeout ?? 60 * 60 * 1000;
|
|
15
|
+
this.keep = options.keep ?? 100;
|
|
16
|
+
this.now = options.now ?? Date.now;
|
|
17
|
+
}
|
|
18
|
+
// Proposing what already waits is not an error and changes nothing.
|
|
19
|
+
propose(version, bundle, proposer) {
|
|
20
|
+
if (this.closed) {
|
|
21
|
+
throw new ProposalError('the server is shutting down');
|
|
22
|
+
}
|
|
23
|
+
const key = this.key(version.name, version.hash);
|
|
24
|
+
const existing = this.entries.get(key);
|
|
25
|
+
if (existing && existing.proposal.state === 'waiting') {
|
|
26
|
+
return { proposal: { ...existing.proposal }, created: false };
|
|
27
|
+
}
|
|
28
|
+
if (this.waiting().length >= this.maxWaiting) {
|
|
29
|
+
throw new ProposalError('too many build file versions are waiting for an approval');
|
|
30
|
+
}
|
|
31
|
+
const proposal = {
|
|
32
|
+
name: version.name,
|
|
33
|
+
hash: version.hash,
|
|
34
|
+
entry: version.entry,
|
|
35
|
+
tasks: version.tasks,
|
|
36
|
+
services: version.services,
|
|
37
|
+
proposedBy: { iss: proposer.iss, sub: proposer.sub },
|
|
38
|
+
createdAt: new Date(this.now()).toISOString(),
|
|
39
|
+
expiresAt: new Date(this.now() + this.timeout).toISOString(),
|
|
40
|
+
state: 'waiting',
|
|
41
|
+
};
|
|
42
|
+
const entry = { proposal, bundle };
|
|
43
|
+
entry.timer = setTimeout(() => this.settle(entry, 'expired'), this.timeout);
|
|
44
|
+
entry.timer.unref();
|
|
45
|
+
this.entries.delete(key);
|
|
46
|
+
this.entries.set(key, entry);
|
|
47
|
+
this.evict();
|
|
48
|
+
return { proposal: { ...proposal }, created: true };
|
|
49
|
+
}
|
|
50
|
+
waiting() {
|
|
51
|
+
return [...this.entries.values()].filter((e) => e.proposal.state === 'waiting').map((e) => ({ ...e.proposal }));
|
|
52
|
+
}
|
|
53
|
+
// Everything kept, newest first.
|
|
54
|
+
all() {
|
|
55
|
+
return [...this.entries.values()].map((e) => ({ ...e.proposal })).reverse();
|
|
56
|
+
}
|
|
57
|
+
get(name, hash) {
|
|
58
|
+
const entry = this.entries.get(this.key(name, hash));
|
|
59
|
+
return entry ? { ...entry.proposal } : undefined;
|
|
60
|
+
}
|
|
61
|
+
// The files of one that still waits, for whoever decides on it.
|
|
62
|
+
bundle(name, hash) {
|
|
63
|
+
return this.entries.get(this.key(name, hash))?.bundle;
|
|
64
|
+
}
|
|
65
|
+
// An approval publishes the files first (`publish`), and only then is the
|
|
66
|
+
// proposal approved, so it never claims a version that is not there. Nobody
|
|
67
|
+
// decides on their own proposal.
|
|
68
|
+
async decide(decider, name, hash, outcome, mayDecide, publish, reason) {
|
|
69
|
+
const entry = this.entries.get(this.key(name, hash));
|
|
70
|
+
if (!entry || !mayDecide(name)) {
|
|
71
|
+
return { status: 'not-found' };
|
|
72
|
+
}
|
|
73
|
+
if (entry.proposal.proposedBy.iss === decider.iss && entry.proposal.proposedBy.sub === decider.sub) {
|
|
74
|
+
return { status: 'own-proposal' };
|
|
75
|
+
}
|
|
76
|
+
if (entry.proposal.state !== 'waiting' || !entry.bundle) {
|
|
77
|
+
return { status: 'not-waiting', state: entry.proposal.state };
|
|
78
|
+
}
|
|
79
|
+
if (outcome === 'approved') {
|
|
80
|
+
await publish(entry.bundle);
|
|
81
|
+
if (entry.proposal.state !== 'waiting') {
|
|
82
|
+
return { status: 'not-waiting', state: entry.proposal.state };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
this.settle(entry, outcome, { iss: decider.iss, sub: decider.sub }, reason);
|
|
86
|
+
return { status: 'ok', proposal: { ...entry.proposal } };
|
|
87
|
+
}
|
|
88
|
+
close() {
|
|
89
|
+
this.closed = true;
|
|
90
|
+
for (const entry of this.entries.values()) {
|
|
91
|
+
if (entry.timer) {
|
|
92
|
+
clearTimeout(entry.timer);
|
|
93
|
+
entry.timer = undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
settle(entry, state, by, reason) {
|
|
98
|
+
if (entry.proposal.state !== 'waiting') {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (entry.timer) {
|
|
102
|
+
clearTimeout(entry.timer);
|
|
103
|
+
entry.timer = undefined;
|
|
104
|
+
}
|
|
105
|
+
entry.proposal.state = state;
|
|
106
|
+
entry.proposal.decidedAt = new Date(this.now()).toISOString();
|
|
107
|
+
if (by) {
|
|
108
|
+
entry.proposal.decidedBy = by;
|
|
109
|
+
}
|
|
110
|
+
if (reason) {
|
|
111
|
+
entry.proposal.reason = reason;
|
|
112
|
+
}
|
|
113
|
+
entry.bundle = undefined;
|
|
114
|
+
}
|
|
115
|
+
evict() {
|
|
116
|
+
const decided = [...this.entries.entries()].filter(([, e]) => e.proposal.state !== 'waiting');
|
|
117
|
+
for (const [key] of decided.slice(0, Math.max(0, decided.length - this.keep))) {
|
|
118
|
+
this.entries.delete(key);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
key(name, hash) {
|
|
122
|
+
return `${name}@${hash}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
exports.ProposalRegistry = ProposalRegistry;
|
|
126
|
+
//# sourceMappingURL=proposals.js.map
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runRequestSchema = exports.runOptionsSchema = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const units_1 = require("../utils/units");
|
|
6
|
+
const bundle_1 = require("../remote/bundle");
|
|
7
|
+
const server_config_1 = require("./server-config");
|
|
8
|
+
const name = zod_1.z.string().min(1).max(200);
|
|
9
|
+
// What `POST /v1/runs` takes. Who is asking, which accounts they may use and
|
|
10
|
+
// where code may come from is decided from the token, never from this body.
|
|
11
|
+
const duration = zod_1.z.string().refine((value) => {
|
|
12
|
+
try {
|
|
13
|
+
(0, units_1.parseDuration)(value);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}, 'expected a duration like 30s, 5m or 1h30m');
|
|
20
|
+
// The settings of `hammerkit run` that make sense for a run on a server. What
|
|
21
|
+
// the server limits itself (workers, resources) and what only a terminal
|
|
22
|
+
// needs (watch, log mode) are left out.
|
|
23
|
+
exports.runOptionsSchema = zod_1.z
|
|
24
|
+
.object({
|
|
25
|
+
// as --env
|
|
26
|
+
env: name.optional(),
|
|
27
|
+
// as --filter and --exclude: labels
|
|
28
|
+
filter: zod_1.z.array(name).max(20).optional(),
|
|
29
|
+
exclude: zod_1.z.array(name).max(20).optional(),
|
|
30
|
+
// as --cache
|
|
31
|
+
cache: zod_1.z.enum(['checksum', 'modify-date', 'none']).optional(),
|
|
32
|
+
// as --cache-read-only
|
|
33
|
+
cacheReadOnly: zod_1.z.boolean().optional(),
|
|
34
|
+
// as --timeout
|
|
35
|
+
timeout: duration.optional(),
|
|
36
|
+
// as --no-skip-deps when false
|
|
37
|
+
skipDeps: zod_1.z.boolean().optional(),
|
|
38
|
+
})
|
|
39
|
+
.strict();
|
|
40
|
+
exports.runRequestSchema = zod_1.z
|
|
41
|
+
.object({
|
|
42
|
+
// the published build file to run, and the version of it: a hash, or the
|
|
43
|
+
// latest when left out. The server answers with the hash it chose.
|
|
44
|
+
definition: zod_1.z
|
|
45
|
+
.object({
|
|
46
|
+
name: server_config_1.definitionName,
|
|
47
|
+
hash: zod_1.z.union([zod_1.z.literal('latest'), zod_1.z.string().regex(bundle_1.HASH_PATTERN)]).default('latest'),
|
|
48
|
+
})
|
|
49
|
+
.strict(),
|
|
50
|
+
// tasks by their name in the build file, as on the command line; labels in
|
|
51
|
+
// the options can select tasks instead
|
|
52
|
+
tasks: zod_1.z.array(name).max(100).default([]),
|
|
53
|
+
// the service account to use; the caller's default when left out
|
|
54
|
+
account: name.optional(),
|
|
55
|
+
source: zod_1.z.discriminatedUnion('type', [
|
|
56
|
+
zod_1.z.object({ type: zod_1.z.literal('ref'), ref: name }).strict(),
|
|
57
|
+
// the name of a tree announced with POST /v1/sources
|
|
58
|
+
zod_1.z.object({ type: zod_1.z.literal('upload'), snapshot: zod_1.z.string().regex(bundle_1.HASH_PATTERN) }).strict(),
|
|
59
|
+
]),
|
|
60
|
+
options: exports.runOptionsSchema.default({}),
|
|
61
|
+
})
|
|
62
|
+
.strict()
|
|
63
|
+
.refine((request) => request.tasks.length > 0 || (request.options.filter ?? []).length > 0, {
|
|
64
|
+
message: 'name at least one task, or filter by labels',
|
|
65
|
+
path: ['tasks'],
|
|
66
|
+
});
|
|
67
|
+
//# sourceMappingURL=run-request.js.map
|