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.
- package/build.schema.json +10 -0
- package/dist/cli.js +6 -0
- package/dist/executer/execute-command.js +7 -1
- package/dist/optimizer/get-cache-directory.js +5 -0
- package/dist/planner/utils/append-work-task.js +1 -0
- package/dist/planner/work-secret.js +6 -2
- package/dist/program.js +6 -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 +158 -0
- package/dist/remote/remote-work-commands.js +224 -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 +4 -0
- package/dist/schema/build-file-schema.js +3 -0
- package/dist/schema/reference-parser.js +9 -2
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/provider-values.js +4 -0
- package/dist/server/accounts-api.js +42 -0
- package/dist/server/approvals-api.js +104 -0
- package/dist/server/definitions-api.js +163 -0
- package/dist/server/definitions.js +160 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +237 -0
- package/dist/server/git-checkout.js +115 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/plan.js +296 -0
- package/dist/server/policy.js +138 -0
- package/dist/server/proposals-api.js +145 -0
- package/dist/server/proposals.js +173 -0
- package/dist/server/repo-access.js +127 -0
- package/dist/server/repo.js +94 -0
- package/dist/server/run-environment.js +20 -0
- package/dist/server/run-plan.js +44 -0
- package/dist/server/run-request.js +85 -0
- package/dist/server/run-store.js +61 -0
- package/dist/server/run-worker-command.js +17 -0
- package/dist/server/run-worker-main.js +7 -0
- package/dist/server/run-worker.js +179 -0
- package/dist/server/runs.js +432 -0
- package/dist/server/server-command.js +61 -0
- package/dist/server/server-config.js +394 -0
- package/dist/server/server.js +434 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/package.json +1 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.handleProposals = exports.mayApprove = 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 proposals_1 = require("./proposals");
|
|
11
|
+
const server_config_1 = require("./server-config");
|
|
12
|
+
const decisionSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
|
|
13
|
+
const same = (a, b) => a.iss === b.iss && a.sub === b.sub;
|
|
14
|
+
// Whoever approves publishes it, and uses the accounts its proposer did not: a
|
|
15
|
+
// version that names an account is approved by somebody the account trusts.
|
|
16
|
+
const mayApprove = (grant, proposal) => (0, policy_1.mayPublish)(grant) && proposal.missing.every((account) => (0, policy_1.canUse)(grant, account));
|
|
17
|
+
exports.mayApprove = mayApprove;
|
|
18
|
+
// What the caller is shown of a proposal: what it is, who proposed it and the
|
|
19
|
+
// version it would replace, so the files can be compared.
|
|
20
|
+
function toView(store, proposal, principal, grant) {
|
|
21
|
+
const latest = store.find(proposal.name, 'latest')?.hash ?? null;
|
|
22
|
+
const stale = proposal.state === 'waiting' && latest !== proposal.base;
|
|
23
|
+
return {
|
|
24
|
+
...proposal,
|
|
25
|
+
// the version it follows is fixed when it is proposed; if another one was
|
|
26
|
+
// published since, it can only be rejected
|
|
27
|
+
latest,
|
|
28
|
+
stale,
|
|
29
|
+
mine: same(proposal.proposedBy, principal),
|
|
30
|
+
// approving needs the permission to publish, the accounts the proposer lacked, and
|
|
31
|
+
// somebody else's proposal
|
|
32
|
+
canDecide: proposal.state === 'waiting' && !stale && (0, exports.mayApprove)(grant, proposal) && !same(proposal.proposedBy, principal),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* New versions of build files that wait for an approval. Whoever may only
|
|
37
|
+
* propose a version (`builds: propose`), or publishes one that names accounts they
|
|
38
|
+
* do not use, puts one here; someone who may publish, and uses those accounts,
|
|
39
|
+
* approves it, and it is published then.
|
|
40
|
+
*
|
|
41
|
+
* GET /v1/proposals the ones the caller proposed or may decide
|
|
42
|
+
* GET /v1/proposals/<name>/<hash> one of them
|
|
43
|
+
* GET /v1/proposals/<name>/<hash>/files its files, for who decides
|
|
44
|
+
* POST /v1/proposals/<name>/<hash>/approve publish it (body: optional {reason})
|
|
45
|
+
* POST /v1/proposals/<name>/<hash>/reject drop it
|
|
46
|
+
*/
|
|
47
|
+
async function handleProposals(config, store, proposals, log, principal, req, res, url) {
|
|
48
|
+
const match = /^\/v1\/proposals(?:\/([^/]+)\/([^/]+)(?:\/(files|approve|reject))?)?$/.exec(url.pathname);
|
|
49
|
+
if (!match) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const [, name, hash, action] = match;
|
|
53
|
+
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
54
|
+
// what the caller neither proposed nor may decide does not exist for them
|
|
55
|
+
const visible = (proposal) => same(proposal.proposedBy, principal) || (0, policy_1.mayPublish)(grant);
|
|
56
|
+
if (name === undefined) {
|
|
57
|
+
if (req.method !== 'GET') {
|
|
58
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
(0, http_1.send)(res, 200, {
|
|
62
|
+
proposals: proposals
|
|
63
|
+
.all()
|
|
64
|
+
.filter(visible)
|
|
65
|
+
.map((proposal) => toView(store, proposal, principal, grant)),
|
|
66
|
+
});
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (!server_config_1.definitionName.safeParse(name).success || !bundle_1.HASH_PATTERN.test(hash)) {
|
|
70
|
+
(0, http_1.send)(res, 404, { error: 'not found' });
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
const proposal = proposals.get(name, hash);
|
|
74
|
+
if (!action) {
|
|
75
|
+
if (req.method !== 'GET') {
|
|
76
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
proposal && visible(proposal)
|
|
80
|
+
? (0, http_1.send)(res, 200, toView(store, proposal, principal, grant))
|
|
81
|
+
: (0, http_1.send)(res, 404, { error: 'not found' });
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (action === 'files') {
|
|
85
|
+
if (req.method !== 'GET') {
|
|
86
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
// the files are for who decides, and for who wrote them
|
|
90
|
+
const bundle = proposal && visible(proposal) ? proposals.bundle(name, hash) : undefined;
|
|
91
|
+
bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (req.method !== 'POST') {
|
|
95
|
+
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'POST' });
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
const body = decisionSchema.safeParse(req.headers['content-type'] ? await (0, http_1.readJson)(req) : {});
|
|
99
|
+
if (!body.success) {
|
|
100
|
+
const issue = body.error.issues[0];
|
|
101
|
+
(0, http_1.send)(res, 400, { error: `${issue.path.join('.') || 'request'}: ${issue.message}` });
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
const outcome = action === 'approve' ? 'approved' : 'rejected';
|
|
105
|
+
try {
|
|
106
|
+
const result = await proposals.decide(principal, name, hash, outcome, (proposed) => (0, exports.mayApprove)(grant, proposed), async (bundle) => {
|
|
107
|
+
const proposed = proposals.get(name, hash);
|
|
108
|
+
// looked at again here, just before it is published
|
|
109
|
+
const latest = store.find(name, 'latest')?.hash ?? null;
|
|
110
|
+
if (proposed && latest !== proposed.base) {
|
|
111
|
+
throw new proposals_1.StaleProposalError(proposed.base, latest);
|
|
112
|
+
}
|
|
113
|
+
await store.publish(name, bundle, proposed?.proposedBy ?? principal, principal);
|
|
114
|
+
}, body.data.reason);
|
|
115
|
+
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)}` : ''}`);
|
|
116
|
+
switch (result.status) {
|
|
117
|
+
case 'ok':
|
|
118
|
+
(0, http_1.send)(res, 200, toView(store, result.proposal, principal, grant));
|
|
119
|
+
break;
|
|
120
|
+
case 'own-proposal':
|
|
121
|
+
(0, http_1.send)(res, 403, { error: 'a version is approved by somebody else than who proposed it' });
|
|
122
|
+
break;
|
|
123
|
+
case 'not-waiting':
|
|
124
|
+
(0, http_1.send)(res, 409, { error: `the proposal is ${result.state}, it does not wait for an approval` });
|
|
125
|
+
break;
|
|
126
|
+
default:
|
|
127
|
+
(0, http_1.send)(res, 404, { error: 'not found' });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
if (e instanceof proposals_1.StaleProposalError) {
|
|
132
|
+
log(`${action} proposal=${name}@${hash} iss=${principal.iss} sub=${principal.sub} result=stale`);
|
|
133
|
+
(0, http_1.send)(res, 409, { error: e.message });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (e instanceof definitions_1.DefinitionError) {
|
|
137
|
+
(0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
throw e;
|
|
141
|
+
}
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
exports.handleProposals = handleProposals;
|
|
145
|
+
//# sourceMappingURL=proposals-api.js.map
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProposalRegistry = exports.StaleProposalError = exports.CooldownError = exports.ProposalError = void 0;
|
|
4
|
+
const units_1 = require("../utils/units");
|
|
5
|
+
class ProposalError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super(...arguments);
|
|
8
|
+
// what the caller is told
|
|
9
|
+
this.status = 503;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.ProposalError = ProposalError;
|
|
13
|
+
// The caller proposes too soon after a rejection
|
|
14
|
+
class CooldownError extends ProposalError {
|
|
15
|
+
constructor() {
|
|
16
|
+
super(...arguments);
|
|
17
|
+
this.status = 429;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
exports.CooldownError = CooldownError;
|
|
21
|
+
// The build file moved on since the proposal was made
|
|
22
|
+
class StaleProposalError extends Error {
|
|
23
|
+
constructor(base, latest) {
|
|
24
|
+
super(`the build file changed since this was proposed (it follows ${base ?? 'nothing'}, the latest is ${latest ?? 'nothing'}), propose it again on top of the latest`);
|
|
25
|
+
this.base = base;
|
|
26
|
+
this.latest = latest;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.StaleProposalError = StaleProposalError;
|
|
30
|
+
// The proposals of the server, in memory: a restart forgets what waits, and
|
|
31
|
+
// whoever proposed it proposes it again. Nothing in here is runnable.
|
|
32
|
+
class ProposalRegistry {
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.entries = new Map();
|
|
35
|
+
this.closed = false;
|
|
36
|
+
this.maxWaiting = options.maxWaiting ?? 100;
|
|
37
|
+
this.timeout = options.timeout ?? 60 * 60 * 1000;
|
|
38
|
+
this.keep = options.keep ?? 100;
|
|
39
|
+
this.cooldown = options.cooldown ?? 0;
|
|
40
|
+
this.now = options.now ?? Date.now;
|
|
41
|
+
}
|
|
42
|
+
// Proposing what already waits is not an error and changes nothing.
|
|
43
|
+
propose(version, bundle, proposer, missing = [], base = null) {
|
|
44
|
+
if (this.closed) {
|
|
45
|
+
throw new ProposalError('the server is shutting down');
|
|
46
|
+
}
|
|
47
|
+
const key = this.key(version.name, version.hash);
|
|
48
|
+
const existing = this.entries.get(key);
|
|
49
|
+
if (existing && existing.proposal.state === 'waiting') {
|
|
50
|
+
return { proposal: { ...existing.proposal }, created: false };
|
|
51
|
+
}
|
|
52
|
+
this.checkCooldown(version, proposer);
|
|
53
|
+
if (this.waiting().length >= this.maxWaiting) {
|
|
54
|
+
throw new ProposalError('too many build file versions are waiting for an approval');
|
|
55
|
+
}
|
|
56
|
+
const proposal = {
|
|
57
|
+
name: version.name,
|
|
58
|
+
hash: version.hash,
|
|
59
|
+
entry: version.entry,
|
|
60
|
+
tasks: version.tasks,
|
|
61
|
+
services: version.services,
|
|
62
|
+
accounts: version.accounts ?? [],
|
|
63
|
+
missing,
|
|
64
|
+
plan: version.plan ?? {},
|
|
65
|
+
base,
|
|
66
|
+
proposedBy: { iss: proposer.iss, sub: proposer.sub },
|
|
67
|
+
createdAt: new Date(this.now()).toISOString(),
|
|
68
|
+
expiresAt: new Date(this.now() + this.timeout).toISOString(),
|
|
69
|
+
state: 'waiting',
|
|
70
|
+
};
|
|
71
|
+
const entry = { proposal, bundle };
|
|
72
|
+
entry.timer = setTimeout(() => this.settle(entry, 'expired'), this.timeout);
|
|
73
|
+
entry.timer.unref();
|
|
74
|
+
this.entries.delete(key);
|
|
75
|
+
this.entries.set(key, entry);
|
|
76
|
+
this.evict();
|
|
77
|
+
return { proposal: { ...proposal }, created: true };
|
|
78
|
+
}
|
|
79
|
+
waiting() {
|
|
80
|
+
return [...this.entries.values()].filter((e) => e.proposal.state === 'waiting').map((e) => ({ ...e.proposal }));
|
|
81
|
+
}
|
|
82
|
+
// Everything kept, newest first.
|
|
83
|
+
all() {
|
|
84
|
+
return [...this.entries.values()].map((e) => ({ ...e.proposal })).reverse();
|
|
85
|
+
}
|
|
86
|
+
get(name, hash) {
|
|
87
|
+
const entry = this.entries.get(this.key(name, hash));
|
|
88
|
+
return entry ? { ...entry.proposal } : undefined;
|
|
89
|
+
}
|
|
90
|
+
// The files of one that still waits, for whoever decides on it.
|
|
91
|
+
bundle(name, hash) {
|
|
92
|
+
return this.entries.get(this.key(name, hash))?.bundle;
|
|
93
|
+
}
|
|
94
|
+
// An approval publishes the files first (`publish`), and only then is the
|
|
95
|
+
// proposal approved, so it never claims a version that is not there. Nobody
|
|
96
|
+
// decides on their own proposal.
|
|
97
|
+
async decide(decider, name, hash, outcome, mayDecide, publish, reason) {
|
|
98
|
+
const entry = this.entries.get(this.key(name, hash));
|
|
99
|
+
if (!entry || !mayDecide(entry.proposal)) {
|
|
100
|
+
return { status: 'not-found' };
|
|
101
|
+
}
|
|
102
|
+
if (entry.proposal.proposedBy.iss === decider.iss && entry.proposal.proposedBy.sub === decider.sub) {
|
|
103
|
+
return { status: 'own-proposal' };
|
|
104
|
+
}
|
|
105
|
+
if (entry.proposal.state !== 'waiting' || !entry.bundle) {
|
|
106
|
+
return { status: 'not-waiting', state: entry.proposal.state };
|
|
107
|
+
}
|
|
108
|
+
if (outcome === 'approved') {
|
|
109
|
+
await publish(entry.bundle);
|
|
110
|
+
if (entry.proposal.state !== 'waiting') {
|
|
111
|
+
return { status: 'not-waiting', state: entry.proposal.state };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
this.settle(entry, outcome, { iss: decider.iss, sub: decider.sub }, reason);
|
|
115
|
+
return { status: 'ok', proposal: { ...entry.proposal } };
|
|
116
|
+
}
|
|
117
|
+
close() {
|
|
118
|
+
this.closed = true;
|
|
119
|
+
for (const entry of this.entries.values()) {
|
|
120
|
+
if (entry.timer) {
|
|
121
|
+
clearTimeout(entry.timer);
|
|
122
|
+
entry.timer = undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
settle(entry, state, by, reason) {
|
|
127
|
+
if (entry.proposal.state !== 'waiting') {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (entry.timer) {
|
|
131
|
+
clearTimeout(entry.timer);
|
|
132
|
+
entry.timer = undefined;
|
|
133
|
+
}
|
|
134
|
+
entry.proposal.state = state;
|
|
135
|
+
entry.proposal.decidedAt = new Date(this.now()).toISOString();
|
|
136
|
+
if (by) {
|
|
137
|
+
entry.proposal.decidedBy = by;
|
|
138
|
+
}
|
|
139
|
+
if (reason) {
|
|
140
|
+
entry.proposal.reason = reason;
|
|
141
|
+
}
|
|
142
|
+
entry.bundle = undefined;
|
|
143
|
+
}
|
|
144
|
+
// A rejection is a no; a proposal that only ran out of time is not.
|
|
145
|
+
checkCooldown(version, proposer) {
|
|
146
|
+
if (this.cooldown <= 0) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
for (const { proposal } of this.entries.values()) {
|
|
150
|
+
if (proposal.state !== 'rejected' || proposal.name !== version.name) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const left = Date.parse(proposal.decidedAt ?? '') + this.cooldown - this.now();
|
|
154
|
+
const sameVersion = proposal.hash === version.hash;
|
|
155
|
+
const sameProposer = proposal.proposedBy.iss === proposer.iss && proposal.proposedBy.sub === proposer.sub;
|
|
156
|
+
if (left > 0 && (sameVersion || sameProposer)) {
|
|
157
|
+
throw new CooldownError(`${sameVersion ? 'this version was' : 'a version of this build file by you was'} rejected, ` +
|
|
158
|
+
`propose again in ${(0, units_1.formatDuration)(Math.ceil(left / 1000) * 1000)}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
evict() {
|
|
163
|
+
const decided = [...this.entries.entries()].filter(([, e]) => e.proposal.state !== 'waiting');
|
|
164
|
+
for (const [key] of decided.slice(0, Math.max(0, decided.length - this.keep))) {
|
|
165
|
+
this.entries.delete(key);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
key(name, hash) {
|
|
169
|
+
return `${name}@${hash}`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
exports.ProposalRegistry = ProposalRegistry;
|
|
173
|
+
//# sourceMappingURL=proposals.js.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RepoAccess = void 0;
|
|
4
|
+
const redact_1 = require("../utils/redact");
|
|
5
|
+
const provider_values_1 = require("../secrets/provider-values");
|
|
6
|
+
const git_checkout_1 = require("./git-checkout");
|
|
7
|
+
const repo_1 = require("./repo");
|
|
8
|
+
// A token is read again after this long, so a short-lived one (a GitHub App
|
|
9
|
+
// installation token minted by the provider command) is not used up and a revoked one
|
|
10
|
+
// stops working soon.
|
|
11
|
+
const TOKEN_TTL_MS = 60 * 1000;
|
|
12
|
+
// A run waits for the repository at submit, not forever
|
|
13
|
+
const RESOLVE_TIMEOUT_MS = 30 * 1000;
|
|
14
|
+
/**
|
|
15
|
+
* The repositories a server can run code from. What may be read is the operator's
|
|
16
|
+
* `sources` list; who may name which of them is the entitlement's `repos`. The read
|
|
17
|
+
* credential is a secret of a provider, read as a service account that nobody can use
|
|
18
|
+
* in a task. It is handed to git for one fetch through the environment of that git
|
|
19
|
+
* process (never an argument, never `.git/config`, never the environment of a task)
|
|
20
|
+
* and masked in whatever the run logs.
|
|
21
|
+
*/
|
|
22
|
+
class RepoAccess {
|
|
23
|
+
constructor(config,
|
|
24
|
+
// the base environment of the server's runs, with `hosted` set
|
|
25
|
+
environment, options = {}) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.environment = environment;
|
|
28
|
+
this.options = options;
|
|
29
|
+
this.tokens = new Map();
|
|
30
|
+
this.now = options.now ?? Date.now;
|
|
31
|
+
}
|
|
32
|
+
// The first source that covers the repository: patterns are listed from the most
|
|
33
|
+
// specific to the least.
|
|
34
|
+
source(repo) {
|
|
35
|
+
return this.config.sources.find((source) => (0, repo_1.matchesAnyRepo)([source.match], repo));
|
|
36
|
+
}
|
|
37
|
+
readable(repo) {
|
|
38
|
+
return this.source(repo) !== undefined;
|
|
39
|
+
}
|
|
40
|
+
url(repo) {
|
|
41
|
+
return this.options.url ? this.options.url(repo) : `https://${repo}.git`;
|
|
42
|
+
}
|
|
43
|
+
// The commit a ref points to now, or an error that says what is wrong with the ref.
|
|
44
|
+
async resolve(repo, ref, signal) {
|
|
45
|
+
const timeout = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
46
|
+
const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
47
|
+
if (this.options.resolve) {
|
|
48
|
+
return this.options.resolve(repo, ref, abort);
|
|
49
|
+
}
|
|
50
|
+
const credentials = await this.credentials(repo);
|
|
51
|
+
try {
|
|
52
|
+
return await (0, git_checkout_1.resolveRef)(this.url(repo), ref, credentials.env, abort);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
throw this.scrub(e, credentials.values);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The environment a git process needs to read the repository: the allowlisted
|
|
60
|
+
* variables of the server, and the token as a header of requests to that host. The
|
|
61
|
+
* values are what the run has to mask.
|
|
62
|
+
*/
|
|
63
|
+
async credentials(repo) {
|
|
64
|
+
const env = {};
|
|
65
|
+
for (const [key, value] of Object.entries(this.environment.processEnvs)) {
|
|
66
|
+
if (value !== undefined) {
|
|
67
|
+
env[key] = value;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const secret = this.source(repo)?.secret;
|
|
71
|
+
if (!secret) {
|
|
72
|
+
return { env, values: [] };
|
|
73
|
+
}
|
|
74
|
+
const token = await this.token(secret);
|
|
75
|
+
const host = repo.split('/')[0];
|
|
76
|
+
const header = `Authorization: Basic ${Buffer.from(`${secret.username}:${token}`).toString('base64')}`;
|
|
77
|
+
env.GIT_CONFIG_COUNT = '1';
|
|
78
|
+
env.GIT_CONFIG_KEY_0 = `http.https://${host}/.extraHeader`;
|
|
79
|
+
env.GIT_CONFIG_VALUE_0 = header;
|
|
80
|
+
return { env, values: [token, header] };
|
|
81
|
+
}
|
|
82
|
+
async token(secret) {
|
|
83
|
+
const key = `${secret.account}\0${secret.from}`;
|
|
84
|
+
const known = this.tokens.get(key);
|
|
85
|
+
if (known && this.now() - known.at < TOKEN_TTL_MS) {
|
|
86
|
+
return known.value;
|
|
87
|
+
}
|
|
88
|
+
const catalog = this.environment.hosted?.catalog;
|
|
89
|
+
const kind = secret.from.substring(0, secret.from.indexOf(':'));
|
|
90
|
+
const ref = secret.from.substring(secret.from.indexOf(':') + 1);
|
|
91
|
+
const provider = catalog.providers[kind];
|
|
92
|
+
const account = catalog.accounts[secret.account];
|
|
93
|
+
const credentials = account?.providers[kind];
|
|
94
|
+
if (!provider || !credentials) {
|
|
95
|
+
throw new Error(`the source credential ${secret.from} as ${secret.account} is not defined`);
|
|
96
|
+
}
|
|
97
|
+
// a fresh environment per read: values are kept here and for a short time only
|
|
98
|
+
const registry = (0, redact_1.createSecretRegistry)();
|
|
99
|
+
const environment = {
|
|
100
|
+
...this.environment,
|
|
101
|
+
secrets: registry,
|
|
102
|
+
hosted: { catalog, accounts: [secret.account] },
|
|
103
|
+
};
|
|
104
|
+
let value;
|
|
105
|
+
try {
|
|
106
|
+
value = await (0, provider_values_1.fetchProviderValue)({ definition: provider, account: { name: secret.account, env: credentials.env } }, ref, environment);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
throw new Error(`could not read the source credential ${secret.from}: ${e instanceof Error ? e.message : e}`);
|
|
110
|
+
}
|
|
111
|
+
if (value.length === 0) {
|
|
112
|
+
throw new Error(`the source credential ${secret.from} is empty`);
|
|
113
|
+
}
|
|
114
|
+
this.tokens.set(key, { value, at: this.now() });
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
// An error as the caller may read it: without the token.
|
|
118
|
+
scrub(error, values) {
|
|
119
|
+
const registry = (0, redact_1.createSecretRegistry)();
|
|
120
|
+
values.forEach((value) => registry.register(value));
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
const scrubbed = new (error instanceof Error ? error.constructor : Error)(registry.redact(message));
|
|
123
|
+
return scrubbed;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.RepoAccess = RepoAccess;
|
|
127
|
+
//# sourceMappingURL=repo-access.js.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Repositories are named `host/path`, such as `github.com/corp/payments` or
|
|
3
|
+
// `gitlab.corp.com/platform/team/tool`, so one pattern can cover a GitHub
|
|
4
|
+
// organisation or a GitLab group with its subgroups. Names are compared in
|
|
5
|
+
// lower case: both hosts treat them that way.
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.matchesRef = exports.matchesAnyRepo = exports.matchesRepo = exports.normalizeRepoPattern = exports.isRepoPattern = exports.normalizeRepo = void 0;
|
|
8
|
+
const SEGMENT = /^[a-z0-9._-]+$/;
|
|
9
|
+
const PATTERN_SEGMENT = /^[a-z0-9._*-]+$/;
|
|
10
|
+
// `host/path` of what a caller or an operator wrote, or null when it is not a
|
|
11
|
+
// plain repository address: no credentials, port, query, option-like names or
|
|
12
|
+
// `..`. A scheme (`https://`) and a trailing `.git` are accepted and dropped.
|
|
13
|
+
function normalizeRepo(input) {
|
|
14
|
+
let value = input.trim().toLowerCase();
|
|
15
|
+
value = value.replace(/^https:\/\//, '');
|
|
16
|
+
value = value.replace(/\.git$/, '').replace(/\/+$/, '');
|
|
17
|
+
const parts = value.split('/');
|
|
18
|
+
if (parts.length < 2 || parts.length > 12) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const [host, ...path] = parts;
|
|
22
|
+
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(host) || host.includes('..')) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (path.some((part) => !SEGMENT.test(part) || part === '.' || part === '..' || part.startsWith('-'))) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return [host, ...path].join('/');
|
|
29
|
+
}
|
|
30
|
+
exports.normalizeRepo = normalizeRepo;
|
|
31
|
+
// A pattern is a repository where a segment may hold `*` (any characters but `/`),
|
|
32
|
+
// and the last one may be `**`: everything below, subgroups included.
|
|
33
|
+
function isRepoPattern(input) {
|
|
34
|
+
const value = input
|
|
35
|
+
.trim()
|
|
36
|
+
.toLowerCase()
|
|
37
|
+
.replace(/^https:\/\//, '')
|
|
38
|
+
.replace(/\/+$/, '');
|
|
39
|
+
const parts = value.split('/');
|
|
40
|
+
if (parts.length < 2 || !/^[a-z0-9*]([a-z0-9.*-]*[a-z0-9*])?$/.test(parts[0])) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
return parts.slice(1).every((part, index, rest) => {
|
|
44
|
+
if (part === '**') {
|
|
45
|
+
return index === rest.length - 1;
|
|
46
|
+
}
|
|
47
|
+
return PATTERN_SEGMENT.test(part) && part !== '.' && part !== '..' && !part.startsWith('-') && !part.includes('**');
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
exports.isRepoPattern = isRepoPattern;
|
|
51
|
+
function normalizeRepoPattern(input) {
|
|
52
|
+
return input
|
|
53
|
+
.trim()
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/^https:\/\//, '')
|
|
56
|
+
.replace(/\/+$/, '');
|
|
57
|
+
}
|
|
58
|
+
exports.normalizeRepoPattern = normalizeRepoPattern;
|
|
59
|
+
function matchesRepo(pattern, repo) {
|
|
60
|
+
const parts = normalizeRepoPattern(pattern).split('/');
|
|
61
|
+
const target = repo.split('/');
|
|
62
|
+
const last = parts[parts.length - 1];
|
|
63
|
+
if (last === '**') {
|
|
64
|
+
const fixed = parts.slice(0, -1);
|
|
65
|
+
return target.length > fixed.length && fixed.every((part, index) => segmentMatches(part, target[index]));
|
|
66
|
+
}
|
|
67
|
+
return parts.length === target.length && parts.every((part, index) => segmentMatches(part, target[index]));
|
|
68
|
+
}
|
|
69
|
+
exports.matchesRepo = matchesRepo;
|
|
70
|
+
function segmentMatches(pattern, segment) {
|
|
71
|
+
const regex = pattern
|
|
72
|
+
.split('*')
|
|
73
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&'))
|
|
74
|
+
.join('[^/]*');
|
|
75
|
+
return new RegExp(`^${regex}$`).test(segment);
|
|
76
|
+
}
|
|
77
|
+
function matchesAnyRepo(patterns, repo) {
|
|
78
|
+
return patterns.some((pattern) => matchesRepo(pattern, repo));
|
|
79
|
+
}
|
|
80
|
+
exports.matchesAnyRepo = matchesAnyRepo;
|
|
81
|
+
// A ref is matched as written: `main`, `release/*` (one level) or `release/**`.
|
|
82
|
+
function matchesRef(patterns, ref) {
|
|
83
|
+
return patterns.some((pattern) => {
|
|
84
|
+
const parts = pattern.split('/');
|
|
85
|
+
const target = ref.split('/');
|
|
86
|
+
if (parts[parts.length - 1] === '**') {
|
|
87
|
+
const fixed = parts.slice(0, -1);
|
|
88
|
+
return target.length > fixed.length && fixed.every((part, index) => segmentMatches(part, target[index]));
|
|
89
|
+
}
|
|
90
|
+
return parts.length === target.length && parts.every((part, index) => segmentMatches(part, target[index]));
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
exports.matchesRef = matchesRef;
|
|
94
|
+
//# sourceMappingURL=repo.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runEnvironment = void 0;
|
|
4
|
+
// What a run sees of the server's environment: the variables the operator listed
|
|
5
|
+
// in `runs.env.allow`, and the fixed ones of `runs.env.set`. The credentials of a
|
|
6
|
+
// run are not among them, they exist only as the secrets of an account.
|
|
7
|
+
function runEnvironment(config, processEnvs) {
|
|
8
|
+
const passed = {};
|
|
9
|
+
for (const name of config.runs.env.allow) {
|
|
10
|
+
// Windows names are not case sensitive
|
|
11
|
+
const windows = process.platform === 'win32';
|
|
12
|
+
const key = Object.keys(processEnvs).find((k) => k === name || (windows && k.toLowerCase() === name.toLowerCase()));
|
|
13
|
+
if (key !== undefined && processEnvs[key] !== undefined) {
|
|
14
|
+
passed[name] = processEnvs[key];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return { ...passed, ...config.runs.env.set };
|
|
18
|
+
}
|
|
19
|
+
exports.runEnvironment = runEnvironment;
|
|
20
|
+
//# sourceMappingURL=run-environment.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.planRun = exports.getRunScopes = void 0;
|
|
4
|
+
const parse_label_arguments_1 = require("../parser/parse-label-arguments");
|
|
5
|
+
const plan_1 = require("./plan");
|
|
6
|
+
function labels(values) {
|
|
7
|
+
return values && values.length > 0 ? (0, parse_label_arguments_1.parseLabelArguments)(values) : undefined;
|
|
8
|
+
}
|
|
9
|
+
// The tasks in the order they were named, one scope each; by labels when none is.
|
|
10
|
+
function getRunScopes(tasks, options) {
|
|
11
|
+
return tasks.length > 0
|
|
12
|
+
? tasks.map((task) => ({
|
|
13
|
+
name: task,
|
|
14
|
+
scope: { taskName: task, environmentName: options.env ?? null },
|
|
15
|
+
}))
|
|
16
|
+
: [
|
|
17
|
+
{
|
|
18
|
+
name: 'labels',
|
|
19
|
+
scope: {
|
|
20
|
+
filterLabels: labels(options.filter),
|
|
21
|
+
excludeLabels: labels(options.exclude),
|
|
22
|
+
environmentName: options.env ?? null,
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
}
|
|
27
|
+
exports.getRunScopes = getRunScopes;
|
|
28
|
+
/**
|
|
29
|
+
* What a run will do, read with the engine from the files of the version it is
|
|
30
|
+
* pinned to and with the environment a run has: the same function answers at
|
|
31
|
+
* submit, and again in the executor, so what was approved is what runs.
|
|
32
|
+
*/
|
|
33
|
+
async function planRun(bundle, run, environment, policy) {
|
|
34
|
+
const plan = {};
|
|
35
|
+
for (const { scope } of getRunScopes(run.tasks, run.options)) {
|
|
36
|
+
Object.assign(plan, (await (0, plan_1.planBundle)(bundle, environment, { scope, policy })).plan);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
plan,
|
|
40
|
+
accounts: [...new Set(Object.values(plan).flatMap((entry) => entry.accounts))].sort(),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
exports.planRun = planRun;
|
|
44
|
+
//# sourceMappingURL=run-plan.js.map
|