hammerkit 1.26.0 → 1.27.0-next.10
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 +62 -1
- package/dist/cache/backends/local-cache-backend.js +10 -2
- package/dist/cache/backends/registry-cache-backend.js +2 -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 +24 -2
- 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 +411 -0
- package/dist/index.js +33 -36
- package/dist/kubernetes/apply.js +42 -1
- package/dist/kubernetes/await-running-state.js +33 -1
- package/dist/kubernetes/claim-layout.js +71 -0
- package/dist/kubernetes/ensure-http-route.js +1 -1
- package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +46 -28
- package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +132 -5
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +27 -11
- package/dist/kubernetes/ensure-persistent-data.js +75 -24
- package/dist/kubernetes/get-service-ip.js +16 -2
- package/dist/kubernetes/job-placement.js +99 -0
- package/dist/kubernetes/job-secrets.js +320 -0
- package/dist/kubernetes/kept-claims.js +203 -0
- package/dist/kubernetes/kubernetes-instance.js +10 -2
- package/dist/kubernetes/network-gate.js +71 -0
- package/dist/kubernetes/network-policy.js +583 -0
- package/dist/kubernetes/remove-persistent-data.js +3 -1
- package/dist/kubernetes/resources.js +37 -4
- package/dist/kubernetes/restore-kubernetes-data.js +28 -7
- package/dist/kubernetes/secrets.js +7 -9
- package/dist/kubernetes/store-kubernetes-data.js +1 -1
- package/dist/kubernetes/volumes.js +104 -12
- package/dist/log.js +1 -1
- package/dist/main.js +41 -0
- package/dist/network/egress-proxy.js +407 -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 +5 -2
- package/dist/planner/service-definition.js +5 -0
- package/dist/planner/utils/append-work-dependencies.js +5 -2
- package/dist/planner/utils/append-work-environment.js +2 -0
- package/dist/planner/utils/append-work-service.js +5 -0
- package/dist/planner/utils/append-work-task.js +20 -1
- package/dist/planner/utils/assert-item-paths.js +50 -0
- package/dist/planner/utils/hosted-volumes.js +35 -0
- package/dist/planner/utils/task-arguments.js +3 -0
- package/dist/planner/work-network.js +193 -0
- package/dist/planner/work-runtime-docker.js +43 -1
- package/dist/planner/work-runtime-kubernetes.js +370 -111
- package/dist/planner/work-runtime-local.js +2 -1
- package/dist/program.js +33 -5
- package/dist/remote/annotations.js +115 -0
- package/dist/remote/bundle-text.js +24 -0
- package/dist/remote/bundle.js +9 -1
- package/dist/remote/remote-commands.js +15 -3
- package/dist/remote/remote-work-commands.js +272 -71
- 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 +7 -0
- package/dist/schema/build-file-container-service-schema.js +8 -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 +2 -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 +26 -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 +603 -0
- package/dist/server/kubernetes-sandbox-storage.js +997 -0
- package/dist/server/oauth.js +12 -0
- package/dist/server/plan.js +88 -2
- 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 +48 -2
- package/dist/server/run-request.js +29 -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 +191 -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 +216 -0
- package/dist/server/sandboxes.js +519 -0
- package/dist/server/server-command.js +39 -20
- package/dist/server/server-config.js +443 -22
- package/dist/server/server.js +219 -40
- package/dist/utils/abort-on-signals.js +46 -6
- package/dist/utils/run-id.js +12 -0
- package/dist/utils/tar.js +25 -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
|
@@ -8,6 +8,8 @@ const labels_schema_1 = require("./labels-schema");
|
|
|
8
8
|
const build_file_service_container_healthcheck_1 = require("./build-file-service-container-healthcheck");
|
|
9
9
|
const build_file_secret_schema_1 = require("./build-file-secret-schema");
|
|
10
10
|
const resources_schema_1 = require("./resources-schema");
|
|
11
|
+
const size_schema_1 = require("./size-schema");
|
|
12
|
+
const network_schema_1 = require("./network-schema");
|
|
11
13
|
exports.buildFileContainerServiceSchema = (0, zod_1.object)({
|
|
12
14
|
image: (0, zod_1.string)(),
|
|
13
15
|
description: (0, zod_1.string)().optional(),
|
|
@@ -39,6 +41,12 @@ exports.buildFileContainerServiceSchema = (0, zod_1.object)({
|
|
|
39
41
|
// the service account its provider secrets are read as, unless a secret names one
|
|
40
42
|
account: (0, zod_1.string)().optional(),
|
|
41
43
|
resources: resources_schema_1.resourcesSchema.optional(),
|
|
44
|
+
// on Kubernetes, the size of the claim that holds its sources, mounts and volumes
|
|
45
|
+
volumeSize: size_schema_1.sizeSchema.describe('size of its claim on Kubernetes, e.g. 10Gi').optional(),
|
|
46
|
+
// what the service reaches besides its needs; tasks needing it always reach it
|
|
47
|
+
network: network_schema_1.networkSchema.optional(),
|
|
48
|
+
// whether its `cmd` takes the arguments after `--` of `up`: false refuses them
|
|
49
|
+
args: (0, zod_1.boolean)().optional(),
|
|
42
50
|
})
|
|
43
51
|
.strict()
|
|
44
52
|
.describe('container service');
|
|
@@ -3,10 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.buildFileContainerTaskSchema = void 0;
|
|
4
4
|
const build_file_base_task_schema_1 = require("./build-file-base-task-schema");
|
|
5
5
|
const zod_1 = require("zod");
|
|
6
|
+
const size_schema_1 = require("./size-schema");
|
|
6
7
|
exports.buildFileContainerTaskSchema = build_file_base_task_schema_1.buildFileBaseTaskSchema
|
|
7
8
|
.extend({
|
|
8
9
|
image: (0, zod_1.string)(),
|
|
9
10
|
mounts: (0, zod_1.array)((0, zod_1.string)()).optional(),
|
|
11
|
+
// on Kubernetes, the size of the claim that holds its sources, mounts and outputs
|
|
12
|
+
volumeSize: size_schema_1.sizeSchema.describe('size of its claim on Kubernetes, e.g. 10Gi').optional(),
|
|
10
13
|
})
|
|
11
14
|
.strict()
|
|
12
15
|
.describe('container task');
|
|
@@ -3,11 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.buildFileEnvironmentSchema = exports.buildFileDockerEnvironmentSchema = exports.buildFileKubernetesEnvironmentSchema = void 0;
|
|
4
4
|
const zod_1 = require("zod");
|
|
5
5
|
const build_file_environment_schema_http_route_1 = require("./build-file-environment-schema-http-route");
|
|
6
|
+
const size_schema_1 = require("./size-schema");
|
|
6
7
|
exports.buildFileKubernetesEnvironmentSchema = (0, zod_1.object)({
|
|
7
8
|
namespace: (0, zod_1.string)().optional(),
|
|
8
9
|
context: (0, zod_1.string)(),
|
|
9
10
|
kubeconfig: (0, zod_1.string)().optional(),
|
|
10
11
|
httpRoutes: (0, zod_1.array)(build_file_environment_schema_http_route_1.buildFileEnvironmentSchemaHttpRoute).optional(),
|
|
12
|
+
// Hammerkit can't tell from the API whether the cluster's network plugin
|
|
13
|
+
// enforces NetworkPolicy, so the build file states it before any task or
|
|
14
|
+
// service there may have a limited network.
|
|
15
|
+
networkPolicy: (0, zod_1.literal)('enforced')
|
|
16
|
+
.describe('the cluster enforces NetworkPolicy, which limited networks need')
|
|
17
|
+
.optional(),
|
|
18
|
+
// the size of the claim of a task or service that states none (1Gi when left out)
|
|
19
|
+
volumeSize: size_schema_1.sizeSchema.describe('size of the claim of each task and service, 1Gi by default').optional(),
|
|
11
20
|
// Ingress support was removed in 1.8.0. Fail instead of dropping the routes.
|
|
12
21
|
ingresses: (0, zod_1.never)({
|
|
13
22
|
errorMap: () => ({
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.networkSchema = exports.allowEntrySchema = exports.getAllowListError = exports.isAllowEntry = exports.parseAllowEntry = exports.isNeedName = exports.isAllowHost = exports.PROXY_PORT = void 0;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
// A host name, or `*.` and a domain for every name under it. No IP
|
|
6
|
+
// addresses: the proxy allows a name, whatever it resolves to.
|
|
7
|
+
const HOST_PATTERN = /^(\*\.)?([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)*[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i;
|
|
8
|
+
// The port the egress proxy serves HTTP on, so no TCP forward can take it.
|
|
9
|
+
exports.PROXY_PORT = 3128;
|
|
10
|
+
// Resolvers read a name like `0x7f000001`, `0x7f.1` or `127.1` as an address:
|
|
11
|
+
// a hex label, or a numeric last label (no top-level domain is numeric).
|
|
12
|
+
function looksLikeAddress(value) {
|
|
13
|
+
const labels = value.replace(/^\*\./, '').split('.');
|
|
14
|
+
return /^[0-9]+$/.test(labels[labels.length - 1]) || labels.some((label) => /^0x[0-9a-f]*$/i.test(label));
|
|
15
|
+
}
|
|
16
|
+
function isAllowHost(value) {
|
|
17
|
+
return HOST_PATTERN.test(value) && !looksLikeAddress(value);
|
|
18
|
+
}
|
|
19
|
+
exports.isAllowHost = isAllowHost;
|
|
20
|
+
// The name a limited item reaches a needed service under: a plain host name,
|
|
21
|
+
// which the egress proxy takes as exactly that name.
|
|
22
|
+
function isNeedName(value) {
|
|
23
|
+
return !value.startsWith('*.') && isAllowHost(value);
|
|
24
|
+
}
|
|
25
|
+
exports.isNeedName = isNeedName;
|
|
26
|
+
function parseAllowEntry(value) {
|
|
27
|
+
const match = /^([^:]+):([0-9]{1,5})$/.exec(value);
|
|
28
|
+
if (!match) {
|
|
29
|
+
return isAllowHost(value) ? { host: value, port: null } : null;
|
|
30
|
+
}
|
|
31
|
+
const port = parseInt(match[2], 10);
|
|
32
|
+
return isNeedName(match[1]) && port >= 1 && port <= 65535 && port !== exports.PROXY_PORT ? { host: match[1], port } : null;
|
|
33
|
+
}
|
|
34
|
+
exports.parseAllowEntry = parseAllowEntry;
|
|
35
|
+
function isAllowEntry(value) {
|
|
36
|
+
return parseAllowEntry(value) !== null;
|
|
37
|
+
}
|
|
38
|
+
exports.isAllowEntry = isAllowEntry;
|
|
39
|
+
// What is wrong with a whole allow list, null when nothing: the proxy listens
|
|
40
|
+
// on each forward's port under the item's name for it, so two hosts can't
|
|
41
|
+
// share a port.
|
|
42
|
+
function getAllowListError(entries) {
|
|
43
|
+
const hosts = new Map();
|
|
44
|
+
for (const entry of entries.map(parseAllowEntry)) {
|
|
45
|
+
if (!entry || entry.port === null) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const host = hosts.get(entry.port);
|
|
49
|
+
if (host && host.toLowerCase() !== entry.host.toLowerCase()) {
|
|
50
|
+
return `${host}:${entry.port} and ${entry.host}:${entry.port} share a port, each TCP forward needs its own`;
|
|
51
|
+
}
|
|
52
|
+
hosts.set(entry.port, entry.host);
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
exports.getAllowListError = getAllowListError;
|
|
57
|
+
exports.allowEntrySchema = (0, zod_1.string)().refine(isAllowEntry, (value) => ({
|
|
58
|
+
message: `invalid host "${value}", expected a name such as registry.npmjs.org or *.githubusercontent.com, ` +
|
|
59
|
+
`or a name and port such as db.example.com:5432 (any port but ${exports.PROXY_PORT})`,
|
|
60
|
+
}));
|
|
61
|
+
// What a container task or service reaches besides the services in its
|
|
62
|
+
// `needs`, which it reaches at every level: everything the host reaches
|
|
63
|
+
// (`open`, the default), nothing (`none`), or the listed hosts through an
|
|
64
|
+
// egress proxy: HTTP(S) to a host, raw TCP to a host and port. Not part of
|
|
65
|
+
// the cache key.
|
|
66
|
+
exports.networkSchema = (0, zod_1.union)([
|
|
67
|
+
(0, zod_1.literal)('open'),
|
|
68
|
+
(0, zod_1.literal)('none'),
|
|
69
|
+
(0, zod_1.object)({
|
|
70
|
+
allow: (0, zod_1.array)(exports.allowEntrySchema).superRefine((entries, ctx) => {
|
|
71
|
+
const error = getAllowListError(entries);
|
|
72
|
+
if (error) {
|
|
73
|
+
ctx.addIssue({ code: 'custom', message: error });
|
|
74
|
+
}
|
|
75
|
+
}),
|
|
76
|
+
}).strict(),
|
|
77
|
+
]).describe('network the task or service reaches besides its needs: open (default), none or { allow: [hosts or host:port] }');
|
|
78
|
+
//# sourceMappingURL=network-schema.js.map
|
|
@@ -128,6 +128,8 @@ function applySchemaExtension(reference, task) {
|
|
|
128
128
|
task.schema.cache = task.schema.cache ?? extend.schema.cache;
|
|
129
129
|
task.schema.timeout = task.schema.timeout ?? extend.schema.timeout;
|
|
130
130
|
task.schema.runner = task.schema.runner ?? extend.schema.runner;
|
|
131
|
+
task.schema.network = task.schema.network ?? extend.schema.network;
|
|
132
|
+
task.schema.args = task.schema.args ?? extend.schema.args;
|
|
131
133
|
if (!reset.has('cmds'))
|
|
132
134
|
task.schema.cmds = extendArray(task.schema.cmds, extend.schema.cmds);
|
|
133
135
|
if (!reset.has('src'))
|
|
@@ -12,8 +12,9 @@ async function createParseContext(fileName, environment, options) {
|
|
|
12
12
|
const ctx = {
|
|
13
13
|
files: {},
|
|
14
14
|
refreshed: options?.refresh ? new Map() : undefined,
|
|
15
|
+
directory: options?.directory,
|
|
15
16
|
};
|
|
16
|
-
const scope = await appendBuildFile((0, path_1.dirname)(fileName), ctx, environment, fileName, []);
|
|
17
|
+
const scope = await appendBuildFile(workDirectory(ctx, (0, path_1.dirname)(fileName)), ctx, environment, fileName, []);
|
|
17
18
|
return { ctx, scope };
|
|
18
19
|
}
|
|
19
20
|
exports.createParseContext = createParseContext;
|
|
@@ -41,8 +42,8 @@ async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix, remo
|
|
|
41
42
|
for (const referenceName of Object.keys(scope.schema.references)) {
|
|
42
43
|
const target = await locateInclude(ctx, scope, scope.schema.references[referenceName],
|
|
43
44
|
// a remote file's paths resolve within its repository
|
|
44
|
-
remote ? (0, path_1.dirname)(scope.fileName) : scope.cwd, environment);
|
|
45
|
-
const referenceScope = await appendBuildFile((0, path_1.dirname)(target.fileName), ctx, environment, target.fileName, [...namePrefix, referenceName], target.remote, target.inputs);
|
|
45
|
+
remote ? (0, path_1.dirname)(scope.fileName) : readDirectory(ctx, scope.cwd), environment);
|
|
46
|
+
const referenceScope = await appendBuildFile(workDirectory(ctx, (0, path_1.dirname)(target.fileName)), ctx, environment, target.fileName, [...namePrefix, referenceName], target.remote, target.inputs);
|
|
46
47
|
if (scope.references[referenceName]) {
|
|
47
48
|
throw new Error(referenceName + ' already exists');
|
|
48
49
|
}
|
|
@@ -66,6 +67,18 @@ async function appendBuildFile(cwd, ctx, environment, fileName, namePrefix, remo
|
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
exports.appendBuildFile = appendBuildFile;
|
|
70
|
+
// The directory a build file works in, for the directory it was read from: the same
|
|
71
|
+
// one, unless the files were read from elsewhere (see BuildFileDirectory).
|
|
72
|
+
function workDirectory(ctx, directory) {
|
|
73
|
+
return ctx.directory ? moveDirectory(directory, ctx.directory.read, ctx.directory.work) : directory;
|
|
74
|
+
}
|
|
75
|
+
// Where the build file of a directory it works in is read from.
|
|
76
|
+
function readDirectory(ctx, directory) {
|
|
77
|
+
return ctx.directory ? moveDirectory(directory, ctx.directory.work, ctx.directory.read) : directory;
|
|
78
|
+
}
|
|
79
|
+
function moveDirectory(directory, from, to) {
|
|
80
|
+
return (0, resolve_git_source_1.isWithin)(from, directory) ? (0, path_1.join)(to, (0, path_1.relative)(from, directory)) : directory;
|
|
81
|
+
}
|
|
69
82
|
// A string is a local path below `base`; an object is a git source, fetched once
|
|
70
83
|
// into the include cache. Inside a cached checkout a local path may not leave it:
|
|
71
84
|
// a remote file contributes definitions from its own repository only.
|
|
@@ -38,6 +38,11 @@ function getWorkContext(context, scope, environment) {
|
|
|
38
38
|
workTree.environment = filteredWorkTree.environment;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
// on a server the operator says where things run (a build file there has no environments)
|
|
42
|
+
if (environment.hosted?.kubernetes) {
|
|
43
|
+
workTree.environment = environment.hosted.kubernetes;
|
|
44
|
+
filteredWorkTree.environment = environment.hosted.kubernetes;
|
|
45
|
+
}
|
|
41
46
|
// everything is planned before the run is narrowed: planning a service adds
|
|
42
47
|
// its init task's need on it, which narrowing follows
|
|
43
48
|
const tasks = Object.values(context.tasks).map((task) => (0, append_work_task_1.appendWorkTask)(workTree, task.cwd, task, environment, context));
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.createCommandSecretProvider = void 0;
|
|
3
|
+
exports.ownInvocation = exports.getProviderInvocation = exports.createCommandSecretProvider = void 0;
|
|
4
4
|
const child_process_1 = require("child_process");
|
|
5
|
+
const fs_1 = require("fs");
|
|
6
|
+
const path_1 = require("path");
|
|
5
7
|
const abort_1 = require("../executer/abort");
|
|
6
8
|
const STDERR_LIMIT = 500;
|
|
7
9
|
// Runs the provider's command and takes its stdout, verbatim, as the value: a
|
|
@@ -11,20 +13,32 @@ const STDERR_LIMIT = 500;
|
|
|
11
13
|
// auth login`, the 1Password app) is never picked up, and a missing credential
|
|
12
14
|
// fails instead of falling back to one.
|
|
13
15
|
function createCommandSecretProvider(binding) {
|
|
14
|
-
const { definition, account } = binding;
|
|
15
16
|
return {
|
|
16
17
|
async resolve(ref, environment) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
...minimalEnvironment(environment),
|
|
20
|
-
...expandAll(definition.env, environment, `provider ${definition.name}`),
|
|
21
|
-
...expandAll(account.env, environment, `account ${account.name}, provider ${definition.name}`),
|
|
22
|
-
};
|
|
23
|
-
return run(command, args, env, definition.timeoutMs, environment);
|
|
18
|
+
const { command, args, env } = getProviderInvocation(binding, ref, environment);
|
|
19
|
+
return run(command, args, { ...minimalEnvironment(environment), ...env }, binding.definition.timeoutMs, environment);
|
|
24
20
|
},
|
|
25
21
|
};
|
|
26
22
|
}
|
|
27
23
|
exports.createCommandSecretProvider = createCommandSecretProvider;
|
|
24
|
+
/**
|
|
25
|
+
* The command that reads `ref` as the account of the binding, and the variables of
|
|
26
|
+
* the provider and the account it runs with: `${NAME}` filled from the environment,
|
|
27
|
+
* `{{ref}}` as part of one argument. Without PATH, which is the machine's that runs it.
|
|
28
|
+
*/
|
|
29
|
+
function getProviderInvocation(binding, ref, environment) {
|
|
30
|
+
const { definition, account } = binding;
|
|
31
|
+
const [command, ...args] = definition.command.map((part) => expand(part, environment, `provider ${definition.name}`).replace(/\{\{ref\}\}/g, () => ref));
|
|
32
|
+
return {
|
|
33
|
+
command,
|
|
34
|
+
args,
|
|
35
|
+
env: {
|
|
36
|
+
...expandAll(definition.env, environment, `provider ${definition.name}`),
|
|
37
|
+
...expandAll(account.env, environment, `account ${account.name}, provider ${definition.name}`),
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
exports.getProviderInvocation = getProviderInvocation;
|
|
28
42
|
function minimalEnvironment(environment) {
|
|
29
43
|
const names = process.platform === 'win32' ? ['PATH', 'Path', 'PATHEXT', 'SystemRoot'] : ['PATH'];
|
|
30
44
|
const result = {};
|
|
@@ -57,7 +71,13 @@ function run(command, args, env, timeoutMs, environment) {
|
|
|
57
71
|
reject(new abort_1.AbortError());
|
|
58
72
|
return;
|
|
59
73
|
}
|
|
60
|
-
const
|
|
74
|
+
const own = ownInvocation(command, args, env.PATH ?? env.Path);
|
|
75
|
+
const child = (0, child_process_1.spawn)(own.command, own.args, {
|
|
76
|
+
env,
|
|
77
|
+
shell: false,
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
80
|
+
});
|
|
61
81
|
const stdout = [];
|
|
62
82
|
const stderr = [];
|
|
63
83
|
let failure = null;
|
|
@@ -96,6 +116,49 @@ function run(command, args, env, timeoutMs, environment) {
|
|
|
96
116
|
});
|
|
97
117
|
});
|
|
98
118
|
}
|
|
119
|
+
const ownBinary = {
|
|
120
|
+
execPath: process.pkg ? process.execPath : null,
|
|
121
|
+
entry: (0, path_1.resolve)(__dirname, '..', 'index.js'),
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* A release binary (a build of pkg) gives every child PKG_EXECPATH, its own path,
|
|
125
|
+
* and a child that is the same binary then starts as plain node, loading its first
|
|
126
|
+
* argument as a script: `hammerkit github-app-token` as a provider fails with
|
|
127
|
+
* "Cannot find module". So when the command is this binary, by any name on PATH, it
|
|
128
|
+
* is started the way the server starts its workers: the entry script in front.
|
|
129
|
+
*/
|
|
130
|
+
function ownInvocation(command, args, path, self = ownBinary) {
|
|
131
|
+
if (self.execPath === null) {
|
|
132
|
+
return { command, args };
|
|
133
|
+
}
|
|
134
|
+
const own = statOf(self.execPath);
|
|
135
|
+
const found = candidates(command, path).find((candidate) => {
|
|
136
|
+
const stat = statOf(candidate);
|
|
137
|
+
return stat && own && stat.dev === own.dev && stat.ino === own.ino;
|
|
138
|
+
});
|
|
139
|
+
return found ? { command: self.execPath, args: [self.entry, ...args] } : { command, args };
|
|
140
|
+
}
|
|
141
|
+
exports.ownInvocation = ownInvocation;
|
|
142
|
+
// where spawn looks for the command: a path as it is, a name in each directory of PATH
|
|
143
|
+
function candidates(command, path) {
|
|
144
|
+
const extensions = process.platform === 'win32' ? ['', ...(process.env.PATHEXT ?? '.EXE;.CMD;.BAT;.COM').split(';')] : [''];
|
|
145
|
+
const bases = (0, path_1.isAbsolute)(command) || /[\\/]/.test(command)
|
|
146
|
+
? [(0, path_1.resolve)(command)]
|
|
147
|
+
: (path ?? '')
|
|
148
|
+
.split(path_1.delimiter)
|
|
149
|
+
.filter((dir) => dir.length > 0)
|
|
150
|
+
.map((dir) => (0, path_1.resolve)(dir, command));
|
|
151
|
+
return bases.flatMap((base) => extensions.map((extension) => base + extension));
|
|
152
|
+
}
|
|
153
|
+
function statOf(path) {
|
|
154
|
+
try {
|
|
155
|
+
const stat = (0, fs_1.statSync)(path);
|
|
156
|
+
return stat.isFile() ? stat : null;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
99
162
|
// What the CLI said, as the first lines of a failure; its own output may hold a
|
|
100
163
|
// value it was asked for, so it is masked like all output.
|
|
101
164
|
function describeStderr(chunks, environment) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerGithubAppTokenCommand = void 0;
|
|
4
|
+
const log_1 = require("../log");
|
|
5
|
+
const github_app_token_1 = require("./github-app-token");
|
|
6
|
+
// `hammerkit github-app-token <owner/repo>`: a secret provider command for the token a
|
|
7
|
+
// server reads and pushes a repository with, so the released binary mints it without a
|
|
8
|
+
// script or a runtime of its own. It prints the token and nothing else, without a newline.
|
|
9
|
+
// A failure goes to stderr, which the server puts in its error: the CLI's console writes to
|
|
10
|
+
// stdout, and the server drops what a failed provider printed there.
|
|
11
|
+
function registerGithubAppTokenCommand(program, environment) {
|
|
12
|
+
program
|
|
13
|
+
.command('github-app-token <repo>')
|
|
14
|
+
.description('print an installation token of a GitHub App for one repository (a secret provider command)')
|
|
15
|
+
.action(async (repo) => {
|
|
16
|
+
try {
|
|
17
|
+
environment.stdout.write(await (0, github_app_token_1.githubAppToken)(repo, environment.processEnvs));
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
environment.stderr.write(`github-app-token: ${(0, log_1.getErrorMessage)(e)}\n`);
|
|
21
|
+
throw e;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
exports.registerGithubAppTokenCommand = registerGithubAppTokenCommand;
|
|
26
|
+
//# sourceMappingURL=github-app-token-command.js.map
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.githubAppToken = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const defaultDeps = {
|
|
7
|
+
fetch: (input, init) => fetch(input, init),
|
|
8
|
+
readFile: (path) => (0, promises_1.readFile)(path),
|
|
9
|
+
now: () => Date.now(),
|
|
10
|
+
};
|
|
11
|
+
const CLOUD_PLATFORM = 'https://www.googleapis.com/auth/cloud-platform';
|
|
12
|
+
const METADATA_TOKEN = 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token';
|
|
13
|
+
const REQUEST_TIMEOUT_MS = 20 * 1000;
|
|
14
|
+
/**
|
|
15
|
+
* An installation token of a GitHub App for one repository (`owner/repo`), as a secret
|
|
16
|
+
* provider command prints it: the App's key signs a JWT, the JWT asks for a token of the
|
|
17
|
+
* installation on that repository, limited to it. For an owner alone (`owner`) the token
|
|
18
|
+
* covers every repository the installation of that owner may reach. The key is read from Google Secret
|
|
19
|
+
* Manager (`GITHUB_APP_KEY_SECRET`) as the workload's own Google identity, or from a file
|
|
20
|
+
* (`GITHUB_APP_KEY_FILE`). Errors say which step failed and never hold the key or a token.
|
|
21
|
+
*/
|
|
22
|
+
async function githubAppToken(repo, env, deps = defaultDeps) {
|
|
23
|
+
const match = /^([A-Za-z0-9-]+)(?:\/([A-Za-z0-9._-]+))?$/.exec(repo);
|
|
24
|
+
if (!match) {
|
|
25
|
+
throw new Error(`${repo} is not a repository: name it as owner/repo, or owner for all of an installation`);
|
|
26
|
+
}
|
|
27
|
+
const [, owner, name] = match;
|
|
28
|
+
const appId = env.GITHUB_APP_ID;
|
|
29
|
+
if (!appId) {
|
|
30
|
+
throw new Error('GITHUB_APP_ID is not set');
|
|
31
|
+
}
|
|
32
|
+
const api = (env.GITHUB_API_URL || 'https://api.github.com').replace(/\/+$/, '');
|
|
33
|
+
const key = await appKey(env, deps);
|
|
34
|
+
const now = Math.floor(deps.now() / 1000);
|
|
35
|
+
// a minute back for the clock of GitHub, and under its limit of ten minutes
|
|
36
|
+
const unsigned = `${base64url({ alg: 'RS256', typ: 'JWT' })}.${base64url({
|
|
37
|
+
iat: now - 60,
|
|
38
|
+
exp: now + 540,
|
|
39
|
+
iss: appId,
|
|
40
|
+
})}`;
|
|
41
|
+
let signature;
|
|
42
|
+
try {
|
|
43
|
+
signature = (0, crypto_1.createSign)('RSA-SHA256').update(unsigned).sign(key, 'base64url');
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error('the key of the GitHub App is not an RSA private key in PEM');
|
|
47
|
+
}
|
|
48
|
+
const headers = {
|
|
49
|
+
authorization: `Bearer ${unsigned}.${signature}`,
|
|
50
|
+
accept: 'application/vnd.github+json',
|
|
51
|
+
'x-github-api-version': '2022-11-28',
|
|
52
|
+
};
|
|
53
|
+
const notInstalled = `the GitHub App ${appId} is not installed on ${repo}`;
|
|
54
|
+
const installation = name
|
|
55
|
+
? await request(deps, `${api}/repos/${repo}/installation`, { headers }, (status) => status === 404 ? notInstalled : `GitHub did not find the App on ${repo}`)
|
|
56
|
+
: // an organisation, else a user
|
|
57
|
+
(await request(deps, `${api}/orgs/${owner}/installation`, { headers }, () => `GitHub did not find the App on ${owner}`, true)) ??
|
|
58
|
+
(await request(deps, `${api}/users/${owner}/installation`, { headers }, (status) => status === 404 ? notInstalled : `GitHub did not find the App on ${owner}`));
|
|
59
|
+
const created = await request(deps, `${api}/app/installations/${installation.id}/access_tokens`, { method: 'POST', headers, ...(name ? { body: JSON.stringify({ repositories: [name] }) } : {}) }, () => `GitHub did not give the App a token for ${repo}`);
|
|
60
|
+
if (!created.token) {
|
|
61
|
+
throw new Error(`GitHub did not give the App a token for ${repo}: the answer has no token`);
|
|
62
|
+
}
|
|
63
|
+
return created.token;
|
|
64
|
+
}
|
|
65
|
+
exports.githubAppToken = githubAppToken;
|
|
66
|
+
async function appKey(env, deps) {
|
|
67
|
+
const secret = env.GITHUB_APP_KEY_SECRET;
|
|
68
|
+
const file = env.GITHUB_APP_KEY_FILE;
|
|
69
|
+
if (!!secret === !!file) {
|
|
70
|
+
throw new Error('set one of GITHUB_APP_KEY_SECRET (a Secret Manager version) and GITHUB_APP_KEY_FILE');
|
|
71
|
+
}
|
|
72
|
+
if (file) {
|
|
73
|
+
try {
|
|
74
|
+
return await deps.readFile(file);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
throw new Error(`could not read GITHUB_APP_KEY_FILE ${file}: ${e.code ?? 'unreadable'}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!/^projects\/[^/]+\/secrets\/[^/]+\/versions\/[^/]+$/.test(secret)) {
|
|
81
|
+
throw new Error('GITHUB_APP_KEY_SECRET is not a version: projects/<project>/secrets/<secret>/versions/<version>');
|
|
82
|
+
}
|
|
83
|
+
const accessToken = await googleAccessToken(env, deps);
|
|
84
|
+
const version = await request(deps, `https://secretmanager.googleapis.com/v1/${secret}:access`, { headers: { authorization: `Bearer ${accessToken}` } }, () => `Secret Manager did not give ${secret}`);
|
|
85
|
+
if (!version.payload?.data) {
|
|
86
|
+
throw new Error(`Secret Manager did not give ${secret}: the version has no data`);
|
|
87
|
+
}
|
|
88
|
+
return Buffer.from(version.payload.data, 'base64');
|
|
89
|
+
}
|
|
90
|
+
// The workload's own Google identity, without a key of Google's: a federated identity from
|
|
91
|
+
// GOOGLE_APPLICATION_CREDENTIALS (a token file, such as a projected Kubernetes service
|
|
92
|
+
// account token, exchanged at Google's STS), else the metadata server (GCE, Cloud Run, GKE).
|
|
93
|
+
async function googleAccessToken(env, deps) {
|
|
94
|
+
const path = env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
95
|
+
if (!path) {
|
|
96
|
+
const token = await request(deps, METADATA_TOKEN, { headers: { 'metadata-flavor': 'Google' } }, () => 'the metadata server gave no Google token (and GOOGLE_APPLICATION_CREDENTIALS is not set)');
|
|
97
|
+
return required(token.access_token, 'the metadata server gave no Google token');
|
|
98
|
+
}
|
|
99
|
+
let account;
|
|
100
|
+
try {
|
|
101
|
+
account = JSON.parse((await deps.readFile(path)).toString('utf8'));
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error(`GOOGLE_APPLICATION_CREDENTIALS ${path} is not a readable JSON file`);
|
|
105
|
+
}
|
|
106
|
+
const source = account.credential_source?.file;
|
|
107
|
+
if (account.type !== 'external_account' || !source || !account.audience) {
|
|
108
|
+
throw new Error(`GOOGLE_APPLICATION_CREDENTIALS ${path} is not an external_account with a credential_source file; a key of a service account is not taken`);
|
|
109
|
+
}
|
|
110
|
+
let subject;
|
|
111
|
+
try {
|
|
112
|
+
const content = (await deps.readFile(source)).toString('utf8');
|
|
113
|
+
const format = account.credential_source?.format;
|
|
114
|
+
subject = format?.type === 'json' ? JSON.parse(content)[format.subject_token_field_name ?? ''] : content.trim();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error(`could not read the subject token file ${source} of GOOGLE_APPLICATION_CREDENTIALS`);
|
|
118
|
+
}
|
|
119
|
+
if (!subject) {
|
|
120
|
+
throw new Error(`the subject token file ${source} of GOOGLE_APPLICATION_CREDENTIALS is empty`);
|
|
121
|
+
}
|
|
122
|
+
const exchanged = await request(deps, account.token_url ?? 'https://sts.googleapis.com/v1/token', {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: { 'content-type': 'application/json' },
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
grantType: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
127
|
+
audience: account.audience,
|
|
128
|
+
scope: CLOUD_PLATFORM,
|
|
129
|
+
requestedTokenType: 'urn:ietf:params:oauth:token-type:access_token',
|
|
130
|
+
subjectToken: subject,
|
|
131
|
+
subjectTokenType: account.subject_token_type ?? 'urn:ietf:params:oauth:token-type:jwt',
|
|
132
|
+
}),
|
|
133
|
+
}, () => `Google STS did not exchange the token of ${source} for ${account.audience}`);
|
|
134
|
+
const federated = required(exchanged.access_token, 'Google STS gave no token');
|
|
135
|
+
if (!account.service_account_impersonation_url) {
|
|
136
|
+
return federated;
|
|
137
|
+
}
|
|
138
|
+
// the pool's identity acts as a service account of Google
|
|
139
|
+
const impersonated = await request(deps, account.service_account_impersonation_url, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { authorization: `Bearer ${federated}`, 'content-type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ scope: [CLOUD_PLATFORM] }),
|
|
143
|
+
}, () => 'Google did not let the federated identity act as the service account');
|
|
144
|
+
return required(impersonated.accessToken, 'Google gave no token of the service account');
|
|
145
|
+
}
|
|
146
|
+
async function request(deps, url, init, failed, missing) {
|
|
147
|
+
let response;
|
|
148
|
+
try {
|
|
149
|
+
response = await deps.fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
|
150
|
+
}
|
|
151
|
+
catch (e) {
|
|
152
|
+
const cause = e.cause?.code ?? (e instanceof Error ? e.message : String(e));
|
|
153
|
+
throw new Error(`${failed(0)}: ${new URL(url).host} could not be reached (${cause})`);
|
|
154
|
+
}
|
|
155
|
+
const text = await response.text();
|
|
156
|
+
if (missing && response.status === 404) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
if (!response.ok) {
|
|
160
|
+
throw new Error(`${failed(response.status)}: ${response.status} ${said(text)}`.trim());
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
return JSON.parse(text);
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new Error(`${failed(response.status)}: the answer is not JSON`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
function said(text) {
|
|
170
|
+
try {
|
|
171
|
+
const body = JSON.parse(text);
|
|
172
|
+
const message = body.message ?? body.error_description ?? body.error?.message ?? body.error;
|
|
173
|
+
if (typeof message === 'string') {
|
|
174
|
+
return message.slice(0, 300);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
// not JSON: only the status is said
|
|
179
|
+
}
|
|
180
|
+
return '';
|
|
181
|
+
}
|
|
182
|
+
function required(value, message) {
|
|
183
|
+
if (!value) {
|
|
184
|
+
throw new Error(message);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
188
|
+
function base64url(value) {
|
|
189
|
+
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=github-app-token.js.map
|
|
@@ -6,7 +6,7 @@ const policy_1 = require("./policy");
|
|
|
6
6
|
/**
|
|
7
7
|
* Who the caller is and what they may do:
|
|
8
8
|
*
|
|
9
|
-
* GET /v1/me identity, accounts with their level, repositories and the
|
|
9
|
+
* GET /v1/me identity, accounts with their level, repositories, the builds level and the sandbox grants
|
|
10
10
|
* GET /v1/accounts the accounts the caller has a level on, with what the operator says about them
|
|
11
11
|
*/
|
|
12
12
|
async function handleAccounts(config, log, principal, req, res, url) {
|
|
@@ -25,7 +25,14 @@ async function handleAccounts(config, log, principal, req, res, url) {
|
|
|
25
25
|
log(`grant iss=${principal.iss} sub=${principal.sub} accounts=[${accounts
|
|
26
26
|
.map((account) => `${account.name}:${account.level}`)
|
|
27
27
|
.join(',')}] entitlements=[${grant.matched.join(',')}]`);
|
|
28
|
-
(0, http_1.send)(res, 200, {
|
|
28
|
+
(0, http_1.send)(res, 200, {
|
|
29
|
+
iss: principal.iss,
|
|
30
|
+
sub: principal.sub,
|
|
31
|
+
accounts,
|
|
32
|
+
repos: grant.repos,
|
|
33
|
+
builds: grant.builds,
|
|
34
|
+
sandboxes: grant.sandboxes,
|
|
35
|
+
});
|
|
29
36
|
return true;
|
|
30
37
|
}
|
|
31
38
|
(0, http_1.send)(res, 200, {
|