hammerkit 1.26.0 → 1.27.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.schema.json +53 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/explain.js +13 -11
- package/dist/cli.js +3 -0
- package/dist/docker/item-network.js +471 -0
- package/dist/docker/pull.js +4 -2
- package/dist/docker/remove-orphaned-containers.js +13 -6
- package/dist/environment/replace-env-variables.js +8 -6
- package/dist/executer/await-completed-dependencies.js +2 -1
- package/dist/executer/check-readiness.js +2 -2
- package/dist/executer/docker-service.js +24 -13
- package/dist/executer/docker-task.js +18 -29
- package/dist/executer/event-cache.js +4 -0
- package/dist/executer/execute-command.js +18 -5
- package/dist/executer/execute-docker.js +4 -1
- package/dist/executer/execution-steps.js +33 -7
- package/dist/executer/get-docker-executor.js +14 -7
- package/dist/executer/scheduler/enqueue-next.js +17 -3
- package/dist/executer/watch-loop.js +2 -2
- package/dist/executer/watch-state-key.js +1 -1
- package/dist/file/within-root.js +42 -0
- package/dist/harness/connect.js +99 -0
- package/dist/harness/format.js +73 -0
- package/dist/harness/mcp-command.js +59 -0
- package/dist/harness/mcp.js +432 -0
- package/dist/harness/opencode.js +492 -0
- package/dist/harness/pi.js +217 -0
- package/dist/harness/sandbox-client.js +373 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +31 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
- package/dist/kubernetes/ensure-persistent-data.js +48 -7
- package/dist/kubernetes/get-service-ip.js +15 -1
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +306 -0
- package/dist/kubernetes/kept-claims.js +191 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +526 -0
- package/dist/kubernetes/resources.js +13 -2
- package/dist/kubernetes/restore-kubernetes-data.js +19 -4
- package/dist/kubernetes/secrets.js +2 -0
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +100 -11
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +368 -0
- package/dist/network/port-relay.js +35 -0
- package/dist/optimizer/get-work-cache-stats.js +16 -0
- package/dist/optimizer/work-task-cache-description.js +3 -2
- package/dist/planner/service-definition.js +5 -0
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +4 -0
- package/dist/planner/utils/append-work-task.js +17 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +41 -0
- package/dist/planner/work-runtime-kubernetes.js +335 -102
- package/dist/program.js +21 -0
- package/dist/remote/annotations.js +103 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +4 -1
- package/dist/remote/remote-work-commands.js +182 -69
- package/dist/remote/server-client.js +18 -5
- package/dist/remote/source-manifest.js +15 -2
- package/dist/remote/token-source.js +67 -0
- package/dist/schema/build-file-base-task-schema.js +3 -0
- package/dist/schema/build-file-container-service-schema.js +6 -0
- package/dist/schema/build-file-container-task-schema.js +3 -0
- package/dist/schema/build-file-environment-schema.js +9 -0
- package/dist/schema/network-schema.js +78 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/schema-parser.js +16 -3
- package/dist/schema/work-scope-parser.js +5 -0
- package/dist/secrets/command-secret-provider.js +73 -10
- package/dist/secrets/github-app-token-command.js +23 -0
- package/dist/secrets/github-app-token.js +191 -0
- package/dist/server/accounts-api.js +9 -2
- package/dist/server/definitions-api.js +69 -47
- package/dist/server/definitions.js +76 -51
- package/dist/server/executor.js +156 -41
- package/dist/server/git-checkout.js +72 -8
- package/dist/server/kubernetes-backend.js +554 -0
- package/dist/server/kubernetes-sandbox-storage.js +996 -0
- package/dist/server/oauth.js +1 -0
- package/dist/server/plan.js +51 -1
- package/dist/server/policy.js +104 -15
- package/dist/server/proposals-api.js +24 -40
- package/dist/server/proposals.js +12 -25
- package/dist/server/repo-access.js +37 -6
- package/dist/server/run-logs.js +217 -0
- package/dist/server/run-plan.js +51 -2
- package/dist/server/run-request.js +23 -10
- package/dist/server/run-store.js +5 -0
- package/dist/server/run-worker-command.js +13 -1
- package/dist/server/run-worker.js +121 -24
- package/dist/server/runs.js +184 -10
- package/dist/server/sandbox-docker.js +62 -0
- package/dist/server/sandbox-files-api.js +269 -0
- package/dist/server/sandbox-files.js +1054 -0
- package/dist/server/sandbox-patch.js +481 -0
- package/dist/server/sandbox-pod-command.js +15 -0
- package/dist/server/sandbox-pod.js +146 -0
- package/dist/server/sandbox-storage.js +462 -0
- package/dist/server/sandboxes-api.js +210 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-config.js +412 -22
- package/dist/server/server.js +204 -39
- package/dist/utils/run-id.js +12 -0
- package/harness/opencode.ts +11 -0
- package/harness/pi.ts +7 -0
- package/package.json +8 -2
- package/dist/remote/text-diff.js +0 -89
package/dist/server/oauth.js
CHANGED
package/dist/server/plan.js
CHANGED
|
@@ -12,8 +12,11 @@ const reference_parser_1 = require("../schema/reference-parser");
|
|
|
12
12
|
const work_scope_parser_1 = require("../schema/work-scope-parser");
|
|
13
13
|
const work_task_1 = require("../planner/work-task");
|
|
14
14
|
const work_service_1 = require("../planner/work-service");
|
|
15
|
+
const hosted_volumes_1 = require("../planner/utils/hosted-volumes");
|
|
15
16
|
const log_1 = require("../log");
|
|
16
17
|
const parse_error_1 = require("../schema/parse-error");
|
|
18
|
+
const task_arguments_1 = require("../planner/utils/task-arguments");
|
|
19
|
+
const work_network_1 = require("../planner/work-network");
|
|
17
20
|
class DefinitionError extends Error {
|
|
18
21
|
}
|
|
19
22
|
exports.DefinitionError = DefinitionError;
|
|
@@ -155,6 +158,27 @@ function findPolicyProblems(items, policy, root) {
|
|
|
155
158
|
problems.push(`${item.name}: runs on the ${runtime} runtime, which this server does not allow (runtimes: ${policy.runtimes.join(', ')})`);
|
|
156
159
|
}
|
|
157
160
|
const data = item.data;
|
|
161
|
+
// what a task reads and writes is the bundle's: `src: ../..` would have the server
|
|
162
|
+
// copy its own files (its credentials, other callers' caches) into the task
|
|
163
|
+
for (const source of 'src' in data ? data.src : []) {
|
|
164
|
+
if (!source.inherited && insideBundle(root, data.cwd, source.absolutePath).length === 0) {
|
|
165
|
+
problems.push(`${item.name}: src ${source.source} is outside of the bundle`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const generate of (0, work_task_1.isWorkTask)(data) ? data.generates : []) {
|
|
169
|
+
if (!generate.inherited && insideBundle(root, data.cwd, generate.path).length === 0) {
|
|
170
|
+
problems.push(`${item.name}: generates ${generate.path}, which is outside of the bundle`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const ceiling = policy.network;
|
|
174
|
+
if (ceiling && (0, work_network_1.isLimitedNetwork)(ceiling)) {
|
|
175
|
+
if (data.type === 'local-task') {
|
|
176
|
+
problems.push(`${item.name}: is a local task, which can't be limited to the network this server allows (runs.network: ${(0, work_network_1.formatNetwork)(ceiling)})`);
|
|
177
|
+
}
|
|
178
|
+
else if ('network' in data && (0, work_network_1.exceedsNetwork)(data.network, ceiling)) {
|
|
179
|
+
problems.push(`${item.name}: asks for network ${(0, work_network_1.formatNetwork)(data.network)}, more than this server allows (runs.network: ${(0, work_network_1.formatNetwork)(ceiling)})`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
158
182
|
if ((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data)) {
|
|
159
183
|
for (const mount of data.mounts) {
|
|
160
184
|
// a path of the server is not the build file's to mount: the docker socket, /etc, a home directory
|
|
@@ -163,8 +187,31 @@ function findPolicyProblems(items, policy, root) {
|
|
|
163
187
|
}
|
|
164
188
|
}
|
|
165
189
|
}
|
|
190
|
+
for (const volume of (0, work_service_1.isContainerWorkService)(data) ? data.volumes : []) {
|
|
191
|
+
// docker takes anything else for a path of the host, such as the docker socket
|
|
192
|
+
if (!(0, hosted_volumes_1.isVolumeName)(volume.name)) {
|
|
193
|
+
problems.push(`${item.name}: volume ${volume.name} is not a volume name, a path of the server can't be mounted`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
166
196
|
for (const secret of secretsOf(item)) {
|
|
167
197
|
const source = secret.source;
|
|
198
|
+
if (policy.backend === 'kubernetes' && source.type === 'provider') {
|
|
199
|
+
// the server reads no secret of a provider on a cluster: the Job of a task does
|
|
200
|
+
if (!(0, work_task_1.isWorkTask)(item.data)) {
|
|
201
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider}, which ` +
|
|
202
|
+
'this server does for tasks only, a service on its cluster gets no provider secret yet');
|
|
203
|
+
}
|
|
204
|
+
else if (secret.cache) {
|
|
205
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} has cache: true, its digest would need ` +
|
|
206
|
+
'the value before the Job of the task reads it, which this server does not support yet');
|
|
207
|
+
}
|
|
208
|
+
else if (item.data.network.type !== 'open') {
|
|
209
|
+
// the init containers that read it share the pod's NetworkPolicy
|
|
210
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read from ${source.provider} in the ` +
|
|
211
|
+
`Job of the task, which network ${(0, work_network_1.formatNetwork)(item.data.network)} would cut off from it, ` +
|
|
212
|
+
'a task that reads secrets of a provider keeps an open network on this server');
|
|
213
|
+
}
|
|
214
|
+
}
|
|
168
215
|
if (source.type === 'provider' && policy.reserved?.includes(source.account)) {
|
|
169
216
|
problems.push(`${item.name}: the secret ${describeSecret(secret).target} is read as ${source.account}, which the server uses to read repositories`);
|
|
170
217
|
}
|
|
@@ -172,7 +219,7 @@ function findPolicyProblems(items, policy, root) {
|
|
|
172
219
|
problems.push(`${item.name}: a secret can not read a file of the server (file:), use a secret provider`);
|
|
173
220
|
}
|
|
174
221
|
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`);
|
|
222
|
+
problems.push(`${item.name}: the secret ${describeSecret(secret).target} reads env:${source.name}, which the server does not list in ${policy.envsFrom ?? 'runs.env.allow'}`);
|
|
176
223
|
}
|
|
177
224
|
}
|
|
178
225
|
}
|
|
@@ -205,10 +252,13 @@ function toPlan(root, item) {
|
|
|
205
252
|
runtime: data.type === 'local-task' ? 'local' : 'docker',
|
|
206
253
|
...((0, work_task_1.isContainerWorkTask)(data) || (0, work_service_1.isContainerWorkService)(data) ? { image: data.image } : {}),
|
|
207
254
|
cmds: 'cmds' in data ? data.cmds.map((cmd) => cmd.cmd) : 'cmd' in data && data.cmd ? [data.cmd.cmd] : [],
|
|
255
|
+
command: (0, task_arguments_1.getArgumentsTarget)(data)?.cmd ?? null,
|
|
256
|
+
network: (0, work_network_1.toNetworkSchema)('network' in data ? data.network : { type: 'open' }),
|
|
208
257
|
deps: item.deps.map((dep) => dep.name).sort(),
|
|
209
258
|
needs: item.needs.map((need) => need.name).sort(),
|
|
210
259
|
accounts,
|
|
211
260
|
secrets,
|
|
261
|
+
serverEnvs: 'envs' in data ? data.envs.processNames ?? [] : [],
|
|
212
262
|
mounts,
|
|
213
263
|
ports,
|
|
214
264
|
generates: 'generates' in data ? data.generates.map((generate) => generate.path) : [],
|
package/dist/server/policy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.decideRun = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
|
|
3
|
+
exports.decideRun = exports.sandboxRepos = exports.decideSandbox = exports.sandboxPushBranches = exports.sandboxBranchType = exports.getGrant = exports.usedAccounts = exports.mayYank = exports.canUse = exports.maySeeDefinitions = exports.mayPublish = exports.mayPropose = exports.mayRun = exports.matches = void 0;
|
|
4
4
|
const repo_1 = require("./repo");
|
|
5
5
|
const BUILDS_ORDER = ['run', 'propose', 'publish'];
|
|
6
6
|
function matches(entitlement, principal) {
|
|
@@ -54,6 +54,7 @@ function getGrant(entitlements, principal) {
|
|
|
54
54
|
const matched = [];
|
|
55
55
|
const accounts = {};
|
|
56
56
|
const repos = [];
|
|
57
|
+
const sandboxes = [];
|
|
57
58
|
let builds = null;
|
|
58
59
|
entitlements.forEach((entitlement, index) => {
|
|
59
60
|
if (!matches(entitlement, principal)) {
|
|
@@ -74,10 +75,99 @@ function getGrant(entitlements, principal) {
|
|
|
74
75
|
(builds === null || BUILDS_ORDER.indexOf(entitlement.builds) > BUILDS_ORDER.indexOf(builds))) {
|
|
75
76
|
builds = entitlement.builds;
|
|
76
77
|
}
|
|
78
|
+
for (const sandbox of entitlement.sandboxes ?? []) {
|
|
79
|
+
let known = sandboxes.find((entry) => entry.repo === sandbox.repo);
|
|
80
|
+
if (!known) {
|
|
81
|
+
known = { repo: sandbox.repo, branches: {} };
|
|
82
|
+
sandboxes.push(known);
|
|
83
|
+
}
|
|
84
|
+
for (const [pattern, type] of Object.entries(sandbox.branches)) {
|
|
85
|
+
known.branches[pattern] = higherType(known.branches[pattern], type);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
77
88
|
});
|
|
78
|
-
return { accounts, repos, builds, matched };
|
|
89
|
+
return { accounts, repos, builds, sandboxes, matched };
|
|
79
90
|
}
|
|
80
91
|
exports.getGrant = getGrant;
|
|
92
|
+
const BRANCH_TYPES = ['pull', 'push'];
|
|
93
|
+
function higherType(a, b) {
|
|
94
|
+
return a !== undefined && BRANCH_TYPES.indexOf(a) > BRANCH_TYPES.indexOf(b) ? a : b;
|
|
95
|
+
}
|
|
96
|
+
// What the caller may do with a branch of a repository in a sandbox: the higher type of the
|
|
97
|
+
// branch patterns that match it, in the entries whose `repo` covers the repository. The
|
|
98
|
+
// branches of an entry never count for another repository. Null for nothing.
|
|
99
|
+
function sandboxBranchType(grant, repo, branch) {
|
|
100
|
+
let found = null;
|
|
101
|
+
for (const sandbox of grant.sandboxes) {
|
|
102
|
+
if (!(0, repo_1.matchesRepo)(sandbox.repo, repo)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
for (const [pattern, type] of Object.entries(sandbox.branches)) {
|
|
106
|
+
if ((0, repo_1.matchesRef)([pattern], branch)) {
|
|
107
|
+
found = higherType(found ?? undefined, type);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return found;
|
|
112
|
+
}
|
|
113
|
+
exports.sandboxBranchType = sandboxBranchType;
|
|
114
|
+
// The branch patterns the caller may push to from a sandbox of a repository: those of type
|
|
115
|
+
// `push` in the entries of their `sandboxes` that cover the repository. A pattern of one
|
|
116
|
+
// repository never applies to another.
|
|
117
|
+
function sandboxPushBranches(grant, repo) {
|
|
118
|
+
const branches = grant.sandboxes
|
|
119
|
+
.filter((sandbox) => (0, repo_1.matchesRepo)(sandbox.repo, repo))
|
|
120
|
+
.flatMap((sandbox) => Object.entries(sandbox.branches))
|
|
121
|
+
.filter(([, type]) => type === 'push')
|
|
122
|
+
.map(([pattern]) => pattern);
|
|
123
|
+
return [...new Set(branches)];
|
|
124
|
+
}
|
|
125
|
+
exports.sandboxPushBranches = sandboxPushBranches;
|
|
126
|
+
// Whether the caller may start a sandbox of a repository from a branch, or fetch a branch
|
|
127
|
+
// into one: a `pull` or `push` type for the branch in an entry of their `sandboxes` that
|
|
128
|
+
// covers the repository, and the server able to read it. The answer is the reason when not.
|
|
129
|
+
function decideSandbox(grant, repo, branch, readable) {
|
|
130
|
+
if (grant.sandboxes.length === 0) {
|
|
131
|
+
return { status: 403, error: 'you are not entitled to keep sandboxes on this server' };
|
|
132
|
+
}
|
|
133
|
+
return checkSandbox(grant, repo, branch, readable, 'keep a sandbox of');
|
|
134
|
+
}
|
|
135
|
+
exports.decideSandbox = decideSandbox;
|
|
136
|
+
function checkSandbox(grant, repo, branch, readable, what) {
|
|
137
|
+
const repoProblem = checkRepo(sandboxRepos(grant), repo, readable, what);
|
|
138
|
+
if (repoProblem) {
|
|
139
|
+
return repoProblem;
|
|
140
|
+
}
|
|
141
|
+
if (sandboxBranchType(grant, repo, branch) === null) {
|
|
142
|
+
const branches = grant.sandboxes
|
|
143
|
+
.filter((sandbox) => (0, repo_1.matchesRepo)(sandbox.repo, repo))
|
|
144
|
+
.flatMap((sandbox) => Object.keys(sandbox.branches));
|
|
145
|
+
return {
|
|
146
|
+
status: 403,
|
|
147
|
+
error: `you may not ${what} ${repo} from ${branch}, your branches of it are ${[...new Set(branches)].join(', ')}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function sandboxRepos(grant) {
|
|
153
|
+
return grant.sandboxes.map((sandbox) => sandbox.repo);
|
|
154
|
+
}
|
|
155
|
+
exports.sandboxRepos = sandboxRepos;
|
|
156
|
+
// the caller's own list first: what they may not name is not told whether the server can read it
|
|
157
|
+
function checkRepo(repos, repo, readable, what) {
|
|
158
|
+
if (!(0, repo_1.matchesAnyRepo)(repos, repo)) {
|
|
159
|
+
return {
|
|
160
|
+
status: 403,
|
|
161
|
+
error: repos.length > 0
|
|
162
|
+
? `you may not ${what} ${repo}, your repositories are ${repos.join(', ')}`
|
|
163
|
+
: `you may not ${what} ${repo}, no entitlement of yours lists a repository`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (!readable(repo)) {
|
|
167
|
+
return { status: 400, error: `this server can not read ${repo}` };
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
81
171
|
const COMMIT = /^[0-9a-f]{40}([0-9a-f]{24})?$/i;
|
|
82
172
|
// The single place that decides what happens to a run that was submitted: it
|
|
83
173
|
// starts, waits for an approval, or is refused with the reason.
|
|
@@ -93,18 +183,15 @@ function decideRun(input) {
|
|
|
93
183
|
error: 'runs from an upload are not supported by this server yet, use a git ref',
|
|
94
184
|
};
|
|
95
185
|
}
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
};
|
|
186
|
+
if (source.type === 'sandbox' && grant.sandboxes.length === 0) {
|
|
187
|
+
return { type: 'refuse', status: 403, error: 'you are not entitled to keep sandboxes on this server' };
|
|
105
188
|
}
|
|
106
|
-
|
|
107
|
-
|
|
189
|
+
// runs on a sandbox: the repositories and branches in the `sandboxes` of the entitlements
|
|
190
|
+
const repoProblem = source.type === 'sandbox'
|
|
191
|
+
? checkSandbox(grant, source.repo, source.branch, readable, 'run on a sandbox of')
|
|
192
|
+
: checkRepo(grant.repos, source.repo, readable, 'run from');
|
|
193
|
+
if (repoProblem) {
|
|
194
|
+
return { type: 'refuse', ...repoProblem };
|
|
108
195
|
}
|
|
109
196
|
const request = [];
|
|
110
197
|
for (const account of accounts) {
|
|
@@ -113,10 +200,12 @@ function decideRun(input) {
|
|
|
113
200
|
return { type: 'refuse', status: 403, error: `you may not use the account ${account}, it needs use or request` };
|
|
114
201
|
}
|
|
115
202
|
const rules = catalog[account];
|
|
116
|
-
|
|
203
|
+
// a sandbox holds changes nobody reviewed: an account takes its runs only when it says so
|
|
204
|
+
const sources = rules?.source ?? ['ref', 'upload'];
|
|
205
|
+
if (!sources.includes(source.type)) {
|
|
117
206
|
return { type: 'refuse', status: 403, error: `the account ${account} is not used by runs from ${source.type}` };
|
|
118
207
|
}
|
|
119
|
-
if (rules?.refs) {
|
|
208
|
+
if (rules?.refs && source.type === 'ref') {
|
|
120
209
|
if (COMMIT.test(source.ref) || !(0, repo_1.matchesRef)(rules.refs, source.ref)) {
|
|
121
210
|
return {
|
|
122
211
|
type: 'refuse',
|
|
@@ -7,29 +7,20 @@ const definitions_1 = require("./definitions");
|
|
|
7
7
|
const http_1 = require("./http");
|
|
8
8
|
const oauth_1 = require("./oauth");
|
|
9
9
|
const policy_1 = require("./policy");
|
|
10
|
-
const proposals_1 = require("./proposals");
|
|
11
|
-
const server_config_1 = require("./server-config");
|
|
12
10
|
const decisionSchema = zod_1.z.object({ reason: zod_1.z.string().max(500).optional() }).strict();
|
|
13
11
|
const same = (a, b) => a.iss === b.iss && a.sub === b.sub;
|
|
14
12
|
// Whoever approves publishes it, and uses the accounts its proposer did not: a
|
|
15
13
|
// version that names an account is approved by somebody the account trusts.
|
|
16
14
|
const mayApprove = (grant, proposal) => (0, policy_1.mayPublish)(grant) && proposal.missing.every((account) => (0, policy_1.canUse)(grant, account));
|
|
17
15
|
exports.mayApprove = mayApprove;
|
|
18
|
-
// What the caller is shown of a proposal: what it is
|
|
19
|
-
|
|
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;
|
|
16
|
+
// What the caller is shown of a proposal: what it is and who proposed it.
|
|
17
|
+
function toView(proposal, principal, grant) {
|
|
23
18
|
return {
|
|
24
19
|
...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
20
|
mine: same(proposal.proposedBy, principal),
|
|
30
21
|
// approving needs the permission to publish, the accounts the proposer lacked, and
|
|
31
22
|
// somebody else's proposal
|
|
32
|
-
canDecide: proposal.state === 'waiting' &&
|
|
23
|
+
canDecide: proposal.state === 'waiting' && (0, exports.mayApprove)(grant, proposal) && !same(proposal.proposedBy, principal),
|
|
33
24
|
};
|
|
34
25
|
}
|
|
35
26
|
/**
|
|
@@ -38,22 +29,22 @@ function toView(store, proposal, principal, grant) {
|
|
|
38
29
|
* do not use, puts one here; someone who may publish, and uses those accounts,
|
|
39
30
|
* approves it, and it is published then.
|
|
40
31
|
*
|
|
41
|
-
* GET /v1/proposals
|
|
42
|
-
* GET /v1/proposals/<
|
|
43
|
-
* GET /v1/proposals/<
|
|
44
|
-
* POST /v1/proposals/<
|
|
45
|
-
* POST /v1/proposals/<
|
|
32
|
+
* GET /v1/proposals the ones the caller proposed or may decide
|
|
33
|
+
* GET /v1/proposals/<hash> one of them
|
|
34
|
+
* GET /v1/proposals/<hash>/files its files, for who decides
|
|
35
|
+
* POST /v1/proposals/<hash>/approve publish it (body: optional {reason})
|
|
36
|
+
* POST /v1/proposals/<hash>/reject drop it
|
|
46
37
|
*/
|
|
47
38
|
async function handleProposals(config, store, proposals, log, principal, req, res, url) {
|
|
48
|
-
const match = /^\/v1\/proposals(?:\/([^/]+)
|
|
39
|
+
const match = /^\/v1\/proposals(?:\/([^/]+)(?:\/(files|approve|reject))?)?$/.exec(url.pathname);
|
|
49
40
|
if (!match) {
|
|
50
41
|
return false;
|
|
51
42
|
}
|
|
52
|
-
const [,
|
|
43
|
+
const [, hash, action] = match;
|
|
53
44
|
const grant = (0, policy_1.getGrant)(config.entitlements, principal);
|
|
54
45
|
// what the caller neither proposed nor may decide does not exist for them
|
|
55
46
|
const visible = (proposal) => same(proposal.proposedBy, principal) || (0, policy_1.mayPublish)(grant);
|
|
56
|
-
if (
|
|
47
|
+
if (hash === undefined) {
|
|
57
48
|
if (req.method !== 'GET') {
|
|
58
49
|
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
59
50
|
return true;
|
|
@@ -62,22 +53,22 @@ async function handleProposals(config, store, proposals, log, principal, req, re
|
|
|
62
53
|
proposals: proposals
|
|
63
54
|
.all()
|
|
64
55
|
.filter(visible)
|
|
65
|
-
.map((proposal) => toView(
|
|
56
|
+
.map((proposal) => toView(proposal, principal, grant)),
|
|
66
57
|
});
|
|
67
58
|
return true;
|
|
68
59
|
}
|
|
69
|
-
if (!
|
|
60
|
+
if (!bundle_1.HASH_PATTERN.test(hash)) {
|
|
70
61
|
(0, http_1.send)(res, 404, { error: 'not found' });
|
|
71
62
|
return true;
|
|
72
63
|
}
|
|
73
|
-
const proposal = proposals.get(
|
|
64
|
+
const proposal = proposals.get(hash);
|
|
74
65
|
if (!action) {
|
|
75
66
|
if (req.method !== 'GET') {
|
|
76
67
|
(0, http_1.send)(res, 405, { error: 'method not allowed' }, { allow: 'GET' });
|
|
77
68
|
return true;
|
|
78
69
|
}
|
|
79
70
|
proposal && visible(proposal)
|
|
80
|
-
? (0, http_1.send)(res, 200, toView(
|
|
71
|
+
? (0, http_1.send)(res, 200, toView(proposal, principal, grant))
|
|
81
72
|
: (0, http_1.send)(res, 404, { error: 'not found' });
|
|
82
73
|
return true;
|
|
83
74
|
}
|
|
@@ -87,7 +78,7 @@ async function handleProposals(config, store, proposals, log, principal, req, re
|
|
|
87
78
|
return true;
|
|
88
79
|
}
|
|
89
80
|
// the files are for who decides, and for who wrote them
|
|
90
|
-
const bundle = proposal && visible(proposal) ? proposals.bundle(
|
|
81
|
+
const bundle = proposal && visible(proposal) ? proposals.bundle(hash) : undefined;
|
|
91
82
|
bundle ? (0, http_1.send)(res, 200, bundle) : (0, http_1.send)(res, 404, { error: 'not found' });
|
|
92
83
|
return true;
|
|
93
84
|
}
|
|
@@ -103,19 +94,17 @@ async function handleProposals(config, store, proposals, log, principal, req, re
|
|
|
103
94
|
}
|
|
104
95
|
const outcome = action === 'approve' ? 'approved' : 'rejected';
|
|
105
96
|
try {
|
|
106
|
-
const result = await proposals.decide(principal,
|
|
107
|
-
const proposed = proposals.get(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
await store.publish(name, bundle, proposed?.proposedBy ?? principal, principal);
|
|
97
|
+
const result = await proposals.decide(principal, hash, outcome, (proposed) => (0, exports.mayApprove)(grant, proposed), async (bundle) => {
|
|
98
|
+
const proposed = proposals.get(hash);
|
|
99
|
+
await store.publish(bundle, proposed?.proposedBy ?? principal, {
|
|
100
|
+
approvedBy: principal,
|
|
101
|
+
annotations: proposed?.annotations,
|
|
102
|
+
});
|
|
114
103
|
}, body.data.reason);
|
|
115
|
-
log(`${action} proposal=${
|
|
104
|
+
log(`${action} proposal=${hash} iss=${principal.iss} sub=${principal.sub} result=${result.status}${body.data.reason ? ` reason=${(0, oauth_1.quote)(body.data.reason)}` : ''}`);
|
|
116
105
|
switch (result.status) {
|
|
117
106
|
case 'ok':
|
|
118
|
-
(0, http_1.send)(res, 200, toView(
|
|
107
|
+
(0, http_1.send)(res, 200, toView(result.proposal, principal, grant));
|
|
119
108
|
break;
|
|
120
109
|
case 'own-proposal':
|
|
121
110
|
(0, http_1.send)(res, 403, { error: 'a version is approved by somebody else than who proposed it' });
|
|
@@ -128,11 +117,6 @@ async function handleProposals(config, store, proposals, log, principal, req, re
|
|
|
128
117
|
}
|
|
129
118
|
}
|
|
130
119
|
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
120
|
if (e instanceof definitions_1.DefinitionError) {
|
|
137
121
|
(0, http_1.send)(res, 400, { error: `the build file does not load: ${e.message}` });
|
|
138
122
|
return true;
|
package/dist/server/proposals.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ProposalRegistry = exports.
|
|
3
|
+
exports.ProposalRegistry = exports.CooldownError = exports.ProposalError = void 0;
|
|
4
4
|
const units_1 = require("../utils/units");
|
|
5
5
|
class ProposalError extends Error {
|
|
6
6
|
constructor() {
|
|
@@ -18,15 +18,6 @@ class CooldownError extends ProposalError {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
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
21
|
// The proposals of the server, in memory: a restart forgets what waits, and
|
|
31
22
|
// whoever proposed it proposes it again. Nothing in here is runnable.
|
|
32
23
|
class ProposalRegistry {
|
|
@@ -40,11 +31,11 @@ class ProposalRegistry {
|
|
|
40
31
|
this.now = options.now ?? Date.now;
|
|
41
32
|
}
|
|
42
33
|
// Proposing what already waits is not an error and changes nothing.
|
|
43
|
-
propose(version, bundle, proposer, missing = []
|
|
34
|
+
propose(version, bundle, proposer, missing = []) {
|
|
44
35
|
if (this.closed) {
|
|
45
36
|
throw new ProposalError('the server is shutting down');
|
|
46
37
|
}
|
|
47
|
-
const key =
|
|
38
|
+
const key = version.hash;
|
|
48
39
|
const existing = this.entries.get(key);
|
|
49
40
|
if (existing && existing.proposal.state === 'waiting') {
|
|
50
41
|
return { proposal: { ...existing.proposal }, created: false };
|
|
@@ -54,7 +45,6 @@ class ProposalRegistry {
|
|
|
54
45
|
throw new ProposalError('too many build file versions are waiting for an approval');
|
|
55
46
|
}
|
|
56
47
|
const proposal = {
|
|
57
|
-
name: version.name,
|
|
58
48
|
hash: version.hash,
|
|
59
49
|
entry: version.entry,
|
|
60
50
|
tasks: version.tasks,
|
|
@@ -62,7 +52,7 @@ class ProposalRegistry {
|
|
|
62
52
|
accounts: version.accounts ?? [],
|
|
63
53
|
missing,
|
|
64
54
|
plan: version.plan ?? {},
|
|
65
|
-
|
|
55
|
+
...(version.annotations ? { annotations: { ...version.annotations } } : {}),
|
|
66
56
|
proposedBy: { iss: proposer.iss, sub: proposer.sub },
|
|
67
57
|
createdAt: new Date(this.now()).toISOString(),
|
|
68
58
|
expiresAt: new Date(this.now() + this.timeout).toISOString(),
|
|
@@ -83,19 +73,19 @@ class ProposalRegistry {
|
|
|
83
73
|
all() {
|
|
84
74
|
return [...this.entries.values()].map((e) => ({ ...e.proposal })).reverse();
|
|
85
75
|
}
|
|
86
|
-
get(
|
|
87
|
-
const entry = this.entries.get(
|
|
76
|
+
get(hash) {
|
|
77
|
+
const entry = this.entries.get(hash);
|
|
88
78
|
return entry ? { ...entry.proposal } : undefined;
|
|
89
79
|
}
|
|
90
80
|
// The files of one that still waits, for whoever decides on it.
|
|
91
|
-
bundle(
|
|
92
|
-
return this.entries.get(
|
|
81
|
+
bundle(hash) {
|
|
82
|
+
return this.entries.get(hash)?.bundle;
|
|
93
83
|
}
|
|
94
84
|
// An approval publishes the files first (`publish`), and only then is the
|
|
95
85
|
// proposal approved, so it never claims a version that is not there. Nobody
|
|
96
86
|
// decides on their own proposal.
|
|
97
|
-
async decide(decider,
|
|
98
|
-
const entry = this.entries.get(
|
|
87
|
+
async decide(decider, hash, outcome, mayDecide, publish, reason) {
|
|
88
|
+
const entry = this.entries.get(hash);
|
|
99
89
|
if (!entry || !mayDecide(entry.proposal)) {
|
|
100
90
|
return { status: 'not-found' };
|
|
101
91
|
}
|
|
@@ -147,14 +137,14 @@ class ProposalRegistry {
|
|
|
147
137
|
return;
|
|
148
138
|
}
|
|
149
139
|
for (const { proposal } of this.entries.values()) {
|
|
150
|
-
if (proposal.state !== 'rejected'
|
|
140
|
+
if (proposal.state !== 'rejected') {
|
|
151
141
|
continue;
|
|
152
142
|
}
|
|
153
143
|
const left = Date.parse(proposal.decidedAt ?? '') + this.cooldown - this.now();
|
|
154
144
|
const sameVersion = proposal.hash === version.hash;
|
|
155
145
|
const sameProposer = proposal.proposedBy.iss === proposer.iss && proposal.proposedBy.sub === proposer.sub;
|
|
156
146
|
if (left > 0 && (sameVersion || sameProposer)) {
|
|
157
|
-
throw new CooldownError(`${sameVersion ? 'this version was' : 'a version
|
|
147
|
+
throw new CooldownError(`${sameVersion ? 'this version was' : 'a version you proposed was'} rejected, ` +
|
|
158
148
|
`propose again in ${(0, units_1.formatDuration)(Math.ceil(left / 1000) * 1000)}`);
|
|
159
149
|
}
|
|
160
150
|
}
|
|
@@ -165,9 +155,6 @@ class ProposalRegistry {
|
|
|
165
155
|
this.entries.delete(key);
|
|
166
156
|
}
|
|
167
157
|
}
|
|
168
|
-
key(name, hash) {
|
|
169
|
-
return `${name}@${hash}`;
|
|
170
|
-
}
|
|
171
158
|
}
|
|
172
159
|
exports.ProposalRegistry = ProposalRegistry;
|
|
173
160
|
//# sourceMappingURL=proposals.js.map
|
|
@@ -40,16 +40,17 @@ class RepoAccess {
|
|
|
40
40
|
url(repo) {
|
|
41
41
|
return this.options.url ? this.options.url(repo) : `https://${repo}.git`;
|
|
42
42
|
}
|
|
43
|
-
// The commit a ref points to now, or an error that says what is wrong with the ref.
|
|
44
|
-
|
|
43
|
+
// The commit a ref points to now, or an error that says what is wrong with the ref. With
|
|
44
|
+
// `branch`, only a branch.
|
|
45
|
+
async resolve(repo, ref, signal, only) {
|
|
45
46
|
const timeout = AbortSignal.timeout(RESOLVE_TIMEOUT_MS);
|
|
46
47
|
const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
47
48
|
if (this.options.resolve) {
|
|
48
|
-
return this.options.resolve(repo, ref, abort);
|
|
49
|
+
return this.options.resolve(repo, ref, abort, only);
|
|
49
50
|
}
|
|
50
51
|
const credentials = await this.credentials(repo);
|
|
51
52
|
try {
|
|
52
|
-
return await (0, git_checkout_1.resolveRef)(this.url(repo), ref, credentials.env, abort);
|
|
53
|
+
return await (0, git_checkout_1.resolveRef)(this.url(repo), ref, credentials.env, abort, only);
|
|
53
54
|
}
|
|
54
55
|
catch (e) {
|
|
55
56
|
throw this.scrub(e, credentials.values);
|
|
@@ -79,6 +80,31 @@ class RepoAccess {
|
|
|
79
80
|
env.GIT_CONFIG_VALUE_0 = header;
|
|
80
81
|
return { env, values: [token, header] };
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The same for pushing a sandbox to the repository, with the source's `secret`. A
|
|
85
|
+
* repository the server reads without one is not pushed to: a push never falls back to
|
|
86
|
+
* a credential git finds on the server.
|
|
87
|
+
*/
|
|
88
|
+
async pushCredentials(repo) {
|
|
89
|
+
if (!this.source(repo)?.secret) {
|
|
90
|
+
throw new Error(`this server does not push to ${repo}`);
|
|
91
|
+
}
|
|
92
|
+
const credentials = await this.credentials(repo);
|
|
93
|
+
// nothing but that token: no configuration, credential helper, .netrc or ssh agent of
|
|
94
|
+
// the server's user, which would push as whoever that user is
|
|
95
|
+
const env = { ...credentials.env };
|
|
96
|
+
for (const key of ['HOME', 'XDG_CONFIG_HOME', 'SSH_AUTH_SOCK', 'GIT_ASKPASS', 'SSH_ASKPASS', 'GIT_SSH_COMMAND']) {
|
|
97
|
+
delete env[key];
|
|
98
|
+
}
|
|
99
|
+
const count = Number(env.GIT_CONFIG_COUNT ?? '0');
|
|
100
|
+
env[`GIT_CONFIG_KEY_${count}`] = 'credential.helper';
|
|
101
|
+
env[`GIT_CONFIG_VALUE_${count}`] = '';
|
|
102
|
+
env.GIT_CONFIG_COUNT = String(count + 1);
|
|
103
|
+
env.GIT_CONFIG_NOSYSTEM = '1';
|
|
104
|
+
// an empty file on every platform: git for Windows opens `/dev/null` as `nul`
|
|
105
|
+
env.GIT_CONFIG_GLOBAL = '/dev/null';
|
|
106
|
+
return { env, values: credentials.values };
|
|
107
|
+
}
|
|
82
108
|
async token(secret) {
|
|
83
109
|
const key = `${secret.account}\0${secret.from}`;
|
|
84
110
|
const known = this.tokens.get(key);
|
|
@@ -118,8 +144,13 @@ class RepoAccess {
|
|
|
118
144
|
scrub(error, values) {
|
|
119
145
|
const registry = (0, redact_1.createSecretRegistry)();
|
|
120
146
|
values.forEach((value) => registry.register(value));
|
|
121
|
-
const
|
|
122
|
-
|
|
147
|
+
const scrubbed = new Error(registry.redact(error instanceof Error ? error.message : String(error)));
|
|
148
|
+
if (error instanceof Error) {
|
|
149
|
+
// its class, so a caller still tells a RefError apart, but none of its fields: a
|
|
150
|
+
// constructor may take more than the message (SandboxPushFailed), a field may hold the token
|
|
151
|
+
Object.setPrototypeOf(scrubbed, Object.getPrototypeOf(error));
|
|
152
|
+
scrubbed.name = error.name;
|
|
153
|
+
}
|
|
123
154
|
return scrubbed;
|
|
124
155
|
}
|
|
125
156
|
}
|