hammerkit 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/build.schema.json +10 -0
- package/dist/cli.js +6 -0
- package/dist/executer/execute-command.js +7 -1
- package/dist/planner/utils/append-work-task.js +1 -0
- package/dist/program.js +4 -0
- package/dist/remote/bundle.js +59 -0
- package/dist/remote/collect-bundle.js +41 -0
- package/dist/remote/credentials.js +93 -0
- package/dist/remote/device-login.js +177 -0
- package/dist/remote/host-config.js +170 -0
- package/dist/remote/location.js +46 -0
- package/dist/remote/remote-commands.js +154 -0
- package/dist/remote/remote-work-commands.js +152 -0
- package/dist/remote/server-client.js +88 -0
- package/dist/remote/source-manifest.js +79 -0
- package/dist/remote/text-diff.js +89 -0
- package/dist/schema/build-file-base-task-schema.js +4 -0
- package/dist/schema/build-file-schema.js +3 -0
- package/dist/schema/reference-parser.js +1 -0
- package/dist/schema/runner-schema.js +13 -0
- package/dist/server/approvals-api.js +99 -0
- package/dist/server/definitions-api.js +119 -0
- package/dist/server/definitions.js +248 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +238 -0
- package/dist/server/git-checkout.js +63 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/policy.js +90 -0
- package/dist/server/proposals-api.js +123 -0
- package/dist/server/proposals.js +126 -0
- package/dist/server/run-request.js +67 -0
- package/dist/server/runs.js +279 -0
- package/dist/server/server-command.js +43 -0
- package/dist/server/server-config.js +233 -0
- package/dist/server/server.js +338 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveRunLocation = void 0;
|
|
4
|
+
const host_config_1 = require("./host-config");
|
|
5
|
+
// Where a run happens, highest precedence first: --on / --local, HAMMERKIT_ON,
|
|
6
|
+
// the runner of the tasks, `run.on` of the host config, local. `auto` (as
|
|
7
|
+
// --on, HAMMERKIT_ON or run.on) means the runner of the tasks, else local.
|
|
8
|
+
// A name that is not registered is an error: a run never falls back to the
|
|
9
|
+
// local machine silently.
|
|
10
|
+
async function resolveRunLocation(workTree, environment, options = {}) {
|
|
11
|
+
if (options.on && options.local) {
|
|
12
|
+
throw new Error('--on and --local contradict each other');
|
|
13
|
+
}
|
|
14
|
+
const config = await (0, host_config_1.readHostConfig)(environment);
|
|
15
|
+
const explicit = options.local ? 'local' : options.on ?? (environment.processEnvs.HAMMERKIT_ON || undefined);
|
|
16
|
+
if (explicit && explicit !== 'auto') {
|
|
17
|
+
return explicit === 'local' ? { type: 'local' } : lookup(config.servers, explicit);
|
|
18
|
+
}
|
|
19
|
+
const runners = getTaskRunners(workTree);
|
|
20
|
+
if (runners.length > 1) {
|
|
21
|
+
throw new Error(`a run goes to one server, but its tasks name ${runners.join(', ')}`);
|
|
22
|
+
}
|
|
23
|
+
const requested = runners[0] ?? (explicit ? undefined : config.run.on);
|
|
24
|
+
if (!requested || requested === 'local' || requested === 'auto') {
|
|
25
|
+
return { type: 'local' };
|
|
26
|
+
}
|
|
27
|
+
return lookup(config.servers, requested);
|
|
28
|
+
}
|
|
29
|
+
exports.resolveRunLocation = resolveRunLocation;
|
|
30
|
+
function lookup(servers, name) {
|
|
31
|
+
const server = (0, host_config_1.hasServer)(servers, name) ? servers[name] : undefined;
|
|
32
|
+
if (!server) {
|
|
33
|
+
throw new Error(`server ${name} is not registered, add it with: hammerkit remote add ${name} <url> --issuer <url>`);
|
|
34
|
+
}
|
|
35
|
+
return { type: 'server', name, server };
|
|
36
|
+
}
|
|
37
|
+
function getTaskRunners(workTree) {
|
|
38
|
+
const runners = new Set();
|
|
39
|
+
for (const task of Object.values(workTree.tasks)) {
|
|
40
|
+
if (task.data.runner) {
|
|
41
|
+
runners.add(task.data.runner);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...runners].sort();
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=location.js.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerRemoteCommands = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const colors_1 = __importDefault(require("colors"));
|
|
9
|
+
const log_1 = require("../log");
|
|
10
|
+
const host_config_1 = require("./host-config");
|
|
11
|
+
const device_login_1 = require("./device-login");
|
|
12
|
+
const credentials_1 = require("./credentials");
|
|
13
|
+
const server_client_1 = require("./server-client");
|
|
14
|
+
const remote_work_commands_1 = require("./remote-work-commands");
|
|
15
|
+
// Host-level commands: they only touch the machine's config file, so they work
|
|
16
|
+
// with or without a build file in the working directory.
|
|
17
|
+
function registerRemoteCommands(program, environment, buildFile) {
|
|
18
|
+
const remote = program.command('remote').description('register the servers runs can be sent to');
|
|
19
|
+
const guarded = (failure, fn) => {
|
|
20
|
+
return async (...args) => {
|
|
21
|
+
try {
|
|
22
|
+
await fn(...args);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof commander_1.CommanderError) {
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
program.error(`${failure}: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
remote
|
|
33
|
+
.command('add <name> <url>')
|
|
34
|
+
.description('register a server under a name that build files and --on can refer to')
|
|
35
|
+
.addOption(new commander_1.Option('--issuer <url>', 'OAuth issuer the server accepts tokens from').makeOptionMandatory())
|
|
36
|
+
.addOption(new commander_1.Option('--client-id <id>', 'OAuth client to sign in with (pinned; default: what the server offers)'))
|
|
37
|
+
.addOption(new commander_1.Option('--audience <audience>', 'audience of the token (pinned; default: what the server offers)'))
|
|
38
|
+
.addOption(new commander_1.Option('--scope <scope>', 'scopes to request (pinned; default: what the server offers)'))
|
|
39
|
+
.addOption(new commander_1.Option('--force', 'point an already registered name at a different server').default(false))
|
|
40
|
+
.action(guarded('Remote add was not successful', async (name, url, options) => {
|
|
41
|
+
const server = await (0, host_config_1.addServer)(environment, name, url, {
|
|
42
|
+
issuer: options.issuer,
|
|
43
|
+
clientId: options.clientId,
|
|
44
|
+
audience: options.audience,
|
|
45
|
+
scope: options.scope,
|
|
46
|
+
force: options.force,
|
|
47
|
+
});
|
|
48
|
+
environment.stdout.write(`added ${name}: ${server.url} (issuer ${server.issuer})\n`);
|
|
49
|
+
}));
|
|
50
|
+
remote
|
|
51
|
+
.command('list')
|
|
52
|
+
.description('list the registered servers')
|
|
53
|
+
.action(guarded('Remote list was not successful', async () => {
|
|
54
|
+
const config = await (0, host_config_1.readHostConfig)(environment);
|
|
55
|
+
const names = Object.keys(config.servers).sort();
|
|
56
|
+
if (names.length === 0) {
|
|
57
|
+
environment.stdout.write(`no servers registered (config: ${(0, host_config_1.getHostConfigPath)(environment)})\n`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const server = config.servers[name];
|
|
62
|
+
const marker = config.run.on === name ? colors_1.default.green(' (default)') : '';
|
|
63
|
+
environment.stdout.write(`${colors_1.default.bold(name)}${marker} ${server.url} issuer ${server.issuer}\n`);
|
|
64
|
+
}
|
|
65
|
+
if (config.run.on === 'auto' || config.run.on === 'local') {
|
|
66
|
+
environment.stdout.write(`default location: ${config.run.on}\n`);
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
remote
|
|
70
|
+
.command('remove <name>')
|
|
71
|
+
.description('forget a registered server')
|
|
72
|
+
.action(guarded('Remote remove was not successful', async (name) => {
|
|
73
|
+
const { resetDefault } = await (0, host_config_1.removeServer)(environment, name);
|
|
74
|
+
environment.stdout.write(`removed ${name}\n`);
|
|
75
|
+
if (resetDefault) {
|
|
76
|
+
environment.stdout.write('it was the default location, runs are local again\n');
|
|
77
|
+
}
|
|
78
|
+
}));
|
|
79
|
+
remote
|
|
80
|
+
.command('use <location>')
|
|
81
|
+
.description('set where runs happen by default: local, auto or a registered server')
|
|
82
|
+
.action(guarded('Remote use was not successful', async (location) => {
|
|
83
|
+
await (0, host_config_1.setDefaultLocation)(environment, location);
|
|
84
|
+
environment.stdout.write(`default location: ${location}\n`);
|
|
85
|
+
}));
|
|
86
|
+
const getServer = async (name) => {
|
|
87
|
+
const config = await (0, host_config_1.readHostConfig)(environment);
|
|
88
|
+
const chosen = name ?? config.run.on;
|
|
89
|
+
if (!chosen || !(0, host_config_1.hasServer)(config.servers, chosen)) {
|
|
90
|
+
throw new Error(name
|
|
91
|
+
? `server ${name} is not registered, add it with: hammerkit remote add ${name} <url> --issuer <url>`
|
|
92
|
+
: 'name a server, or set one as the default with: hammerkit remote use <name>');
|
|
93
|
+
}
|
|
94
|
+
return { name: chosen, server: config.servers[chosen] };
|
|
95
|
+
};
|
|
96
|
+
remote
|
|
97
|
+
.command('login <name>')
|
|
98
|
+
.description('sign in to a registered server with your identity provider (device code, works headless)')
|
|
99
|
+
.action(guarded('Remote login was not successful', async (name) => {
|
|
100
|
+
const { server } = await getServer(name);
|
|
101
|
+
const { pinned, ...result } = await (0, device_login_1.deviceLogin)(server, {
|
|
102
|
+
prompt: (message) => environment.stdout.write(`${message}\n`),
|
|
103
|
+
signal: environment.abortCtrl.signal,
|
|
104
|
+
});
|
|
105
|
+
await (0, host_config_1.pinLogin)(environment, name, pinned);
|
|
106
|
+
await (0, credentials_1.saveToken)(environment, name, { url: server.url, issuer: server.issuer, ...result });
|
|
107
|
+
try {
|
|
108
|
+
const me = await (0, server_client_1.getMe)(server, result.accessToken);
|
|
109
|
+
environment.stdout.write(`signed in to ${name} as ${me.sub}\n`);
|
|
110
|
+
environment.stdout.write(me.accounts.length > 0
|
|
111
|
+
? `service accounts: ${me.accounts.join(', ')}\n`
|
|
112
|
+
: 'you have no service accounts on this server\n');
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
// a token the server does not accept is of no use
|
|
116
|
+
await (0, credentials_1.removeToken)(environment, name);
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}));
|
|
120
|
+
remote
|
|
121
|
+
.command('logout <name>')
|
|
122
|
+
.description('forget the sign-in to a server')
|
|
123
|
+
.action(guarded('Remote logout was not successful', async (name) => {
|
|
124
|
+
const removed = await (0, credentials_1.removeToken)(environment, name);
|
|
125
|
+
environment.stdout.write(removed ? `signed out of ${name}\n` : `not signed in to ${name}\n`);
|
|
126
|
+
}));
|
|
127
|
+
remote
|
|
128
|
+
.command('accounts [name]')
|
|
129
|
+
.description('show who you are on a server and which service accounts you may use')
|
|
130
|
+
.action(guarded('Remote accounts was not successful', async (name) => {
|
|
131
|
+
const chosen = await getServer(name);
|
|
132
|
+
const token = await (0, credentials_1.getAccessToken)(environment, chosen.name, chosen.server);
|
|
133
|
+
const me = await (0, server_client_1.getMe)(chosen.server, token);
|
|
134
|
+
environment.stdout.write(`${chosen.name}: ${me.sub} (${me.iss})\n`);
|
|
135
|
+
if (me.accounts.length === 0 && !(me.request && me.request.length > 0)) {
|
|
136
|
+
environment.stdout.write('no service accounts\n');
|
|
137
|
+
}
|
|
138
|
+
for (const account of me.accounts) {
|
|
139
|
+
environment.stdout.write(`${account}${account === me.default ? colors_1.default.green(' (default)') : ''}\n`);
|
|
140
|
+
}
|
|
141
|
+
if (me.publish && me.publish.length > 0) {
|
|
142
|
+
environment.stdout.write(`may publish build files: ${me.publish.join(', ')}\n`);
|
|
143
|
+
}
|
|
144
|
+
if (me.propose && me.propose.length > 0) {
|
|
145
|
+
environment.stdout.write(`may propose build files, which need an approval: ${me.propose.join(', ')}\n`);
|
|
146
|
+
}
|
|
147
|
+
if (me.request && me.request.length > 0) {
|
|
148
|
+
environment.stdout.write(`may request runs, which an admin approves: ${me.request.join(', ')}\n`);
|
|
149
|
+
}
|
|
150
|
+
}));
|
|
151
|
+
(0, remote_work_commands_1.registerWorkCommands)(remote, { environment, buildFile, getServer, guarded });
|
|
152
|
+
}
|
|
153
|
+
exports.registerRemoteCommands = registerRemoteCommands;
|
|
154
|
+
//# sourceMappingURL=remote-commands.js.map
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerWorkCommands = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const colors_1 = __importDefault(require("colors"));
|
|
9
|
+
const credentials_1 = require("./credentials");
|
|
10
|
+
const collect_bundle_1 = require("./collect-bundle");
|
|
11
|
+
const bundle_1 = require("./bundle");
|
|
12
|
+
const server_client_1 = require("./server-client");
|
|
13
|
+
const text_diff_1 = require("./text-diff");
|
|
14
|
+
const PROPOSAL_NAME = /^([a-z0-9][a-z0-9._-]{0,63})@(sha256:[0-9a-f]{64})$/i;
|
|
15
|
+
// A proposed version is named like `payments@sha256:...`
|
|
16
|
+
function parseProposal(value) {
|
|
17
|
+
const match = PROPOSAL_NAME.exec(value);
|
|
18
|
+
return match && bundle_1.HASH_PATTERN.test(match[2]) ? { name: match[1], hash: match[2] } : undefined;
|
|
19
|
+
}
|
|
20
|
+
const RUN_ID = /^run_[A-Za-z0-9_-]{16}$/;
|
|
21
|
+
// What a person who approves is shown of a run: everything the decision rests on.
|
|
22
|
+
function describe(run) {
|
|
23
|
+
const source = run.source.type === 'ref' ? `ref ${run.source.ref}` : `snapshot ${run.source.snapshot}`;
|
|
24
|
+
const options = Object.entries(run.options)
|
|
25
|
+
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(',') : value}`)
|
|
26
|
+
.join(' ');
|
|
27
|
+
return [
|
|
28
|
+
`${colors_1.default.bold((0, server_client_1.printable)(run.id))} requested by ${(0, server_client_1.printable)(run.requestedBy.sub)} (${(0, server_client_1.printable)(run.requestedBy.iss)})`,
|
|
29
|
+
` ${(0, server_client_1.printable)(run.definition.name)} ${(0, server_client_1.printable)(run.definition.hash)}`,
|
|
30
|
+
` tasks: ${run.tasks.length > 0 ? run.tasks.map(server_client_1.printable).join(', ') : 'selected by labels'}`,
|
|
31
|
+
` account ${run.account === null ? 'none' : (0, server_client_1.printable)(run.account)}, source ${(0, server_client_1.printable)(source)}${options ? `, ${(0, server_client_1.printable)(options)}` : ''}`,
|
|
32
|
+
` needs the approval of an admin of ${run.account === null ? 'none' : (0, server_client_1.printable)(run.account)}`,
|
|
33
|
+
` decide before ${(0, server_client_1.printable)(run.approval?.expiresAt ?? '-')}`,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
function registerWorkCommands(remote, context) {
|
|
37
|
+
const { environment, buildFile, getServer, guarded } = context;
|
|
38
|
+
const connect = async (name) => {
|
|
39
|
+
const chosen = await getServer(name);
|
|
40
|
+
const token = await (0, credentials_1.getAccessToken)(environment, chosen.name, chosen.server);
|
|
41
|
+
return { ...chosen, token };
|
|
42
|
+
};
|
|
43
|
+
remote
|
|
44
|
+
.command('publish <definition> [server]')
|
|
45
|
+
.description('publish the build file and the files it includes to a server, under a name runs can refer to')
|
|
46
|
+
.action(guarded('Remote publish was not successful', async (definition, name) => {
|
|
47
|
+
const bundle = await (0, collect_bundle_1.collectBundle)(buildFile, environment);
|
|
48
|
+
const { name: serverName, server, token } = await connect(name);
|
|
49
|
+
const answer = await (0, server_client_1.callServer)(server, token, 'PUT', `/v1/definitions/${encodeURIComponent(definition)}`, bundle);
|
|
50
|
+
const version = answer.body;
|
|
51
|
+
const files = Object.keys(bundle.files).length;
|
|
52
|
+
const proposed = answer.status === 202;
|
|
53
|
+
const verb = proposed ? 'proposed' : answer.status === 201 ? 'published' : 'already published';
|
|
54
|
+
environment.stdout.write(`${verb} ${definition} to ${serverName}: ${(0, server_client_1.printable)(version.hash)} (${files} ${files === 1 ? 'file' : 'files'})\n`);
|
|
55
|
+
const names = [...(version.tasks ?? []), ...(version.services ?? [])];
|
|
56
|
+
environment.stdout.write(`tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
|
|
57
|
+
if (proposed) {
|
|
58
|
+
environment.stdout.write(`it can not run before somebody who may publish ${(0, server_client_1.printable)(definition)} approves it, until ${(0, server_client_1.printable)(version.expiresAt)}\n`);
|
|
59
|
+
}
|
|
60
|
+
}));
|
|
61
|
+
remote
|
|
62
|
+
.command('approvals [server]')
|
|
63
|
+
.description('list the runs on a server that wait for your approval')
|
|
64
|
+
.action(guarded('Remote approvals was not successful', async (name) => {
|
|
65
|
+
const { name: serverName, server, token } = await connect(name);
|
|
66
|
+
const { body } = await (0, server_client_1.callServer)(server, token, 'GET', '/v1/approvals');
|
|
67
|
+
if (body.approvals.length === 0) {
|
|
68
|
+
environment.stdout.write(`${serverName}: no runs wait for your approval\n`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
environment.stdout.write(`${serverName}: ${body.approvals.length} waiting for your approval\n`);
|
|
72
|
+
for (const run of body.approvals) {
|
|
73
|
+
for (const line of describe(run)) {
|
|
74
|
+
environment.stdout.write(`${line}\n`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}));
|
|
78
|
+
remote
|
|
79
|
+
.command('proposals [server]')
|
|
80
|
+
.description('list the proposed versions of build files you made or may approve')
|
|
81
|
+
.action(guarded('Remote proposals was not successful', async (name) => {
|
|
82
|
+
const { name: serverName, server, token } = await connect(name);
|
|
83
|
+
const { body } = await (0, server_client_1.callServer)(server, token, 'GET', '/v1/proposals');
|
|
84
|
+
if (body.proposals.length === 0) {
|
|
85
|
+
environment.stdout.write(`${serverName}: no proposed versions\n`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
environment.stdout.write(`${serverName}: ${body.proposals.length} proposed\n`);
|
|
89
|
+
for (const proposal of body.proposals) {
|
|
90
|
+
const who = proposal.mine ? 'you' : (0, server_client_1.printable)(proposal.proposedBy.sub);
|
|
91
|
+
const state = proposal.canDecide ? colors_1.default.bold('waits for your approval') : (0, server_client_1.printable)(proposal.state);
|
|
92
|
+
environment.stdout.write(`${colors_1.default.bold(`${(0, server_client_1.printable)(proposal.name)}@${(0, server_client_1.printable)(proposal.hash)}`)} proposed by ${who}, ${state}\n`);
|
|
93
|
+
const names = [...proposal.tasks, ...proposal.services];
|
|
94
|
+
environment.stdout.write(` tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
|
|
95
|
+
if (proposal.state === 'waiting') {
|
|
96
|
+
environment.stdout.write(` decide before ${(0, server_client_1.printable)(proposal.expiresAt)}\n`);
|
|
97
|
+
}
|
|
98
|
+
else if (proposal.reason) {
|
|
99
|
+
environment.stdout.write(` ${(0, server_client_1.printable)(proposal.state)}: ${(0, server_client_1.printable)(proposal.reason)}\n`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
remote
|
|
104
|
+
.command('diff <proposal> [server]')
|
|
105
|
+
.description('show what a proposed version (name@hash) changes against the version it follows')
|
|
106
|
+
.action(guarded('Remote diff was not successful', async (target, name) => {
|
|
107
|
+
const proposal = parseProposal(target);
|
|
108
|
+
if (!proposal) {
|
|
109
|
+
throw new Error(`${(0, server_client_1.printable)(target)} is not a proposal, list them with: hammerkit remote proposals`);
|
|
110
|
+
}
|
|
111
|
+
const { name: serverName, server, token } = await connect(name);
|
|
112
|
+
const path = `/v1/proposals/${encodeURIComponent(proposal.name)}/${proposal.hash}`;
|
|
113
|
+
const view = (await (0, server_client_1.callServer)(server, token, 'GET', path)).body;
|
|
114
|
+
const proposed = (await (0, server_client_1.callServer)(server, token, 'GET', `${path}/files`)).body;
|
|
115
|
+
const base = view.base
|
|
116
|
+
? (await (0, server_client_1.callServer)(server, token, 'GET', `/v1/definitions/${encodeURIComponent(proposal.name)}/${view.base}/files`)).body
|
|
117
|
+
: undefined;
|
|
118
|
+
environment.stdout.write(`${(0, server_client_1.printable)(proposal.name)}@${(0, server_client_1.printable)(proposal.hash)} on ${serverName}, proposed by ${(0, server_client_1.printable)(view.proposedBy.sub)} (${(0, server_client_1.printable)(view.proposedBy.iss)})\n`);
|
|
119
|
+
environment.stdout.write(base ? `compared with ${(0, server_client_1.printable)(view.base)}\n` : 'no version is published yet, everything is new\n');
|
|
120
|
+
const lines = (0, text_diff_1.diffBundles)(base, proposed);
|
|
121
|
+
environment.stdout.write(lines.length > 0 ? `${lines.join('\n')}\n` : 'the files are the same\n');
|
|
122
|
+
}));
|
|
123
|
+
for (const action of ['approve', 'reject']) {
|
|
124
|
+
remote
|
|
125
|
+
.command(`${action} <target> [server]`)
|
|
126
|
+
.description(action === 'approve'
|
|
127
|
+
? 'let a run (run_...) or a proposed version (name@hash) that waits for your approval go ahead'
|
|
128
|
+
: 'end a run (run_...) or drop a proposed version (name@hash) that waits for your approval')
|
|
129
|
+
.addOption(new commander_1.Option('--reason <text>', 'why, shown to who asked').argParser((value) => value))
|
|
130
|
+
.action(guarded(`Remote ${action} was not successful`, async (target, name, options) => {
|
|
131
|
+
const proposal = parseProposal(target);
|
|
132
|
+
if (!proposal && !RUN_ID.test(target)) {
|
|
133
|
+
throw new Error(`${(0, server_client_1.printable)(target)} is not a run id or a proposed version, list them with: hammerkit remote approvals or remote proposals`);
|
|
134
|
+
}
|
|
135
|
+
const { name: serverName, server, token } = await connect(name);
|
|
136
|
+
const path = proposal
|
|
137
|
+
? `/v1/proposals/${encodeURIComponent(proposal.name)}/${proposal.hash}/${action}`
|
|
138
|
+
: `/v1/approvals/${target}/${action}`;
|
|
139
|
+
const { body } = await (0, server_client_1.callServer)(server, token, 'POST', path, options.reason ? { reason: options.reason } : undefined);
|
|
140
|
+
const rejected = body.state === 'rejected';
|
|
141
|
+
environment.stdout.write(proposal
|
|
142
|
+
? rejected
|
|
143
|
+
? `rejected ${target} on ${serverName}\n`
|
|
144
|
+
: `approved ${target} on ${serverName}, it is published\n`
|
|
145
|
+
: rejected
|
|
146
|
+
? `rejected ${target} on ${serverName}\n`
|
|
147
|
+
: `approved ${target} on ${serverName}, it is queued\n`);
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
exports.registerWorkCommands = registerWorkCommands;
|
|
152
|
+
//# sourceMappingURL=remote-work-commands.js.map
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.callServer = exports.getMe = exports.getDiscovery = exports.printable = exports.ServerError = void 0;
|
|
4
|
+
const discovery_1 = require("../server/discovery");
|
|
5
|
+
const TIMEOUT_MS = 10000;
|
|
6
|
+
const WRITE_TIMEOUT_MS = 30000;
|
|
7
|
+
class ServerError extends Error {
|
|
8
|
+
}
|
|
9
|
+
exports.ServerError = ServerError;
|
|
10
|
+
// Text from the server or the identity provider is shown to the user: no
|
|
11
|
+
// control characters, so it can't rewrite the terminal.
|
|
12
|
+
function printable(value) {
|
|
13
|
+
return String(value)
|
|
14
|
+
.replace(/\p{Cc}/gu, '?')
|
|
15
|
+
.slice(0, 300);
|
|
16
|
+
}
|
|
17
|
+
exports.printable = printable;
|
|
18
|
+
async function getJson(url, token) {
|
|
19
|
+
let response;
|
|
20
|
+
try {
|
|
21
|
+
response = await fetch(url, {
|
|
22
|
+
headers: token ? { authorization: `Bearer ${token}` } : {},
|
|
23
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
throw new ServerError(`unable to reach ${url}: ${e instanceof Error ? e.message : String(e)}`);
|
|
28
|
+
}
|
|
29
|
+
if (response.status === 401) {
|
|
30
|
+
throw new ServerError(`${url} did not accept the token`);
|
|
31
|
+
}
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new ServerError(`${url} answered ${response.status}`);
|
|
34
|
+
}
|
|
35
|
+
return response.json();
|
|
36
|
+
}
|
|
37
|
+
// What the server says it is. The issuer registered with `remote add` has to be
|
|
38
|
+
// one it accepts, otherwise a login would send the user to an identity provider
|
|
39
|
+
// the server does not trust.
|
|
40
|
+
async function getDiscovery(server) {
|
|
41
|
+
const document = (await getJson(`${server.url}${discovery_1.DISCOVERY_PATH}`));
|
|
42
|
+
if (document?.name !== 'hammerkit' || !Array.isArray(document.issuers)) {
|
|
43
|
+
throw new ServerError(`${server.url} is not a hammerkit server`);
|
|
44
|
+
}
|
|
45
|
+
return document;
|
|
46
|
+
}
|
|
47
|
+
exports.getDiscovery = getDiscovery;
|
|
48
|
+
async function getMe(server, token) {
|
|
49
|
+
return (await getJson(`${server.url}/v1/me`, token));
|
|
50
|
+
}
|
|
51
|
+
exports.getMe = getMe;
|
|
52
|
+
// A call that changes something, or whose answer the caller tells apart by status.
|
|
53
|
+
// Anything but 2xx is an error carrying what the server said about it.
|
|
54
|
+
async function callServer(server, token, method, path, body) {
|
|
55
|
+
const url = `${server.url}${path}`;
|
|
56
|
+
let response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(url, {
|
|
59
|
+
method,
|
|
60
|
+
headers: {
|
|
61
|
+
authorization: `Bearer ${token}`,
|
|
62
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
63
|
+
},
|
|
64
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
65
|
+
signal: AbortSignal.timeout(method === 'GET' ? TIMEOUT_MS : WRITE_TIMEOUT_MS),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
throw new ServerError(`unable to reach ${url}: ${printable(e instanceof Error ? e.message : e)}`);
|
|
70
|
+
}
|
|
71
|
+
let answer;
|
|
72
|
+
try {
|
|
73
|
+
answer = await response.json();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
answer = undefined;
|
|
77
|
+
}
|
|
78
|
+
if (response.status === 401) {
|
|
79
|
+
throw new ServerError(`${server.url} did not accept the token`);
|
|
80
|
+
}
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const said = answer && typeof answer.error === 'string' ? `: ${printable(answer.error)}` : '';
|
|
83
|
+
throw new ServerError(`${server.url} answered ${response.status}${said}`);
|
|
84
|
+
}
|
|
85
|
+
return { status: response.status, body: answer };
|
|
86
|
+
}
|
|
87
|
+
exports.callServer = callServer;
|
|
88
|
+
//# sourceMappingURL=server-client.js.map
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deniedSourcePath = exports.hashSource = exports.sourceManifestSchema = exports.DIGEST_PATTERN = exports.MAX_MANIFEST_BODY_BYTES = exports.MAX_SOURCE_BYTES = exports.MAX_SOURCE_FILE_BYTES = exports.MAX_SOURCE_FILES = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const bundle_1 = require("./bundle");
|
|
7
|
+
exports.MAX_SOURCE_FILES = 20000;
|
|
8
|
+
exports.MAX_SOURCE_FILE_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
exports.MAX_SOURCE_BYTES = 1024 * 1024 * 1024;
|
|
10
|
+
// a manifest is about 200 bytes per file
|
|
11
|
+
exports.MAX_MANIFEST_BODY_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
exports.DIGEST_PATTERN = /^[0-9a-f]{64}$/;
|
|
13
|
+
const sourceFileSchema = zod_1.z
|
|
14
|
+
.object({
|
|
15
|
+
path: zod_1.z.string(),
|
|
16
|
+
sha256: zod_1.z.string().regex(exports.DIGEST_PATTERN, 'expected 64 lowercase hex digits'),
|
|
17
|
+
size: zod_1.z.number().int().min(0).max(exports.MAX_SOURCE_FILE_BYTES),
|
|
18
|
+
mode: zod_1.z.enum(['file', 'exec']).default('file'),
|
|
19
|
+
})
|
|
20
|
+
.strict();
|
|
21
|
+
exports.sourceManifestSchema = zod_1.z
|
|
22
|
+
.object({ files: zod_1.z.array(sourceFileSchema).min(1).max(exports.MAX_SOURCE_FILES) })
|
|
23
|
+
.strict()
|
|
24
|
+
.superRefine((manifest, ctx) => {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
let bytes = 0;
|
|
27
|
+
manifest.files.forEach((file, index) => {
|
|
28
|
+
if (!(0, bundle_1.isBundlePath)(file.path)) {
|
|
29
|
+
ctx.addIssue({
|
|
30
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
31
|
+
path: ['files', index, 'path'],
|
|
32
|
+
message: `"${file.path}" is not a path inside the project`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (seen.has(file.path)) {
|
|
36
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['files', index, 'path'], message: `${file.path} twice` });
|
|
37
|
+
}
|
|
38
|
+
seen.add(file.path);
|
|
39
|
+
bytes += file.size;
|
|
40
|
+
});
|
|
41
|
+
if (bytes > exports.MAX_SOURCE_BYTES) {
|
|
42
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['files'], message: `more than ${exports.MAX_SOURCE_BYTES} bytes` });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
// The name of a tree: the sha-256 of the JSON `[[path, sha-256, mode], ...]` in
|
|
46
|
+
// path order. The same files have the same name wherever it is computed, and the
|
|
47
|
+
// size is not part of it because the content digest already decides it.
|
|
48
|
+
function hashSource(manifest) {
|
|
49
|
+
const files = [...manifest.files]
|
|
50
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
51
|
+
.map((file) => [file.path, file.sha256, file.mode]);
|
|
52
|
+
return `sha256:${(0, crypto_1.createHash)('sha256').update(JSON.stringify(files)).digest('hex')}`;
|
|
53
|
+
}
|
|
54
|
+
exports.hashSource = hashSource;
|
|
55
|
+
const DENIED_DIRECTORIES = ['.git', '.ssh', '.aws', '.gnupg', '.kube'];
|
|
56
|
+
const DENIED_NAMES = [
|
|
57
|
+
/^\.env$/i,
|
|
58
|
+
/^\.env\.(?!example$|sample$|template$|dist$)/i,
|
|
59
|
+
/^\.(npmrc|netrc|pypirc|dockercfg|git-credentials)$/i,
|
|
60
|
+
/^id_(rsa|dsa|ecdsa|ed25519)$/i,
|
|
61
|
+
/\.(pem|p12|pfx)$/i,
|
|
62
|
+
];
|
|
63
|
+
// Files that hold credentials are not sent to a server even when a `src` pattern
|
|
64
|
+
// reaches them: a secret belongs in a secret provider, and a glob such as `**`
|
|
65
|
+
// must not be what decides that a key leaves the machine.
|
|
66
|
+
function deniedSourcePath(path) {
|
|
67
|
+
const parts = path.split('/');
|
|
68
|
+
const directory = parts.slice(0, -1).find((part) => DENIED_DIRECTORIES.includes(part));
|
|
69
|
+
if (directory !== undefined) {
|
|
70
|
+
return `${directory}/ holds credentials or history`;
|
|
71
|
+
}
|
|
72
|
+
const name = parts[parts.length - 1];
|
|
73
|
+
if (DENIED_NAMES.some((pattern) => pattern.test(name))) {
|
|
74
|
+
return `${name} looks like a credential file`;
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
exports.deniedSourcePath = deniedSourcePath;
|
|
79
|
+
//# sourceMappingURL=source-manifest.js.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.diffBundles = exports.diffLines = exports.visible = void 0;
|
|
4
|
+
// Text from a proposal is shown to somebody who decides on it, so nothing in it may
|
|
5
|
+
// rewrite the terminal or hide what it says: control and format characters (which
|
|
6
|
+
// include the ones that reorder text) are shown as `?`. Long lines are not cut,
|
|
7
|
+
// what is hidden behind a cut is what a reviewer needs to see.
|
|
8
|
+
function visible(line) {
|
|
9
|
+
return line.replace(/\t/g, ' ').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
|
|
10
|
+
}
|
|
11
|
+
exports.visible = visible;
|
|
12
|
+
// Beyond this many comparisons a file is shown whole instead of compared.
|
|
13
|
+
const MAX_CELLS = 4000000;
|
|
14
|
+
// The lines that differ, with `context` lines around each change.
|
|
15
|
+
function diffLines(before, after, context = 2) {
|
|
16
|
+
if ((before.length + 1) * (after.length + 1) > MAX_CELLS) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const n = before.length;
|
|
20
|
+
const m = after.length;
|
|
21
|
+
// lengths of the longest common suffixes
|
|
22
|
+
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
23
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
24
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
25
|
+
table[i][j] = before[i] === after[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const all = [];
|
|
29
|
+
let i = 0;
|
|
30
|
+
let j = 0;
|
|
31
|
+
while (i < n && j < m) {
|
|
32
|
+
if (before[i] === after[j]) {
|
|
33
|
+
all.push({ type: ' ', text: before[i] });
|
|
34
|
+
i++;
|
|
35
|
+
j++;
|
|
36
|
+
}
|
|
37
|
+
else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
38
|
+
all.push({ type: '-', text: before[i++] });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
all.push({ type: '+', text: after[j++] });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
while (i < n)
|
|
45
|
+
all.push({ type: '-', text: before[i++] });
|
|
46
|
+
while (j < m)
|
|
47
|
+
all.push({ type: '+', text: after[j++] });
|
|
48
|
+
const keep = new Array(all.length).fill(false);
|
|
49
|
+
all.forEach((line, index) => {
|
|
50
|
+
if (line.type !== ' ') {
|
|
51
|
+
for (let k = Math.max(0, index - context); k <= Math.min(all.length - 1, index + context); k++) {
|
|
52
|
+
keep[k] = true;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return all.filter((_, index) => keep[index]);
|
|
57
|
+
}
|
|
58
|
+
exports.diffLines = diffLines;
|
|
59
|
+
const lines = (text) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'));
|
|
60
|
+
// What a proposal changes against the version it follows, file by file.
|
|
61
|
+
function diffBundles(base, proposed) {
|
|
62
|
+
const out = [];
|
|
63
|
+
const before = base?.files ?? {};
|
|
64
|
+
const paths = [...new Set([...Object.keys(before), ...Object.keys(proposed.files)])].sort();
|
|
65
|
+
for (const path of paths) {
|
|
66
|
+
const old = before[path];
|
|
67
|
+
const next = proposed.files[path];
|
|
68
|
+
if (old === next) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const status = old === undefined ? 'added' : next === undefined ? 'removed' : 'changed';
|
|
72
|
+
out.push(`--- ${visible(path)} (${status})`);
|
|
73
|
+
const compared = old !== undefined && next !== undefined ? diffLines(lines(old), lines(next)) : undefined;
|
|
74
|
+
if (compared) {
|
|
75
|
+
compared.forEach((line) => out.push(`${line.type} ${visible(line.text)}`));
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// added, removed or too large to compare: all of the old, then all of the new
|
|
79
|
+
lines(old ?? '').forEach((line) => out.push(`- ${visible(line)}`));
|
|
80
|
+
lines(next ?? '').forEach((line) => out.push(`+ ${visible(line)}`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (base && base.entry !== proposed.entry) {
|
|
84
|
+
out.unshift(`entry file: ${visible(base.entry)} -> ${visible(proposed.entry)}`);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
exports.diffBundles = diffBundles;
|
|
89
|
+
//# sourceMappingURL=text-diff.js.map
|
|
@@ -11,6 +11,7 @@ const build_file_volume_schema_1 = require("./build-file-volume-schema");
|
|
|
11
11
|
const duration_schema_1 = require("./duration-schema");
|
|
12
12
|
const build_file_secret_schema_1 = require("./build-file-secret-schema");
|
|
13
13
|
const resources_schema_1 = require("./resources-schema");
|
|
14
|
+
const runner_schema_1 = require("./runner-schema");
|
|
14
15
|
// what `extend` adds to (the rest, e.g. `image` or `shell`, the task just overrides)
|
|
15
16
|
exports.RESET_PROPERTIES = ['deps', 'needs', 'src', 'generates', 'cmds', 'mounts', 'envs', 'labels'];
|
|
16
17
|
exports.buildFileBaseTaskSchema = (0, zod_1.object)({
|
|
@@ -38,5 +39,8 @@ exports.buildFileBaseTaskSchema = (0, zod_1.object)({
|
|
|
38
39
|
account: (0, zod_1.string)().optional(),
|
|
39
40
|
// CPU and memory limits of the container; a local task is not limited
|
|
40
41
|
resources: resources_schema_1.resourcesSchema.optional(),
|
|
42
|
+
// Name of the server (see `hammerkit remote`) the task has to run on; defaults
|
|
43
|
+
// to the build file's `runner`. Never a local fallback when it is unknown.
|
|
44
|
+
runner: runner_schema_1.runnerSchema.optional(),
|
|
41
45
|
});
|
|
42
46
|
//# sourceMappingURL=build-file-base-task-schema.js.map
|
|
@@ -10,6 +10,7 @@ const labels_schema_1 = require("./labels-schema");
|
|
|
10
10
|
const cache_schema_1 = require("./cache-schema");
|
|
11
11
|
const build_file_include_schema_1 = require("./build-file-include-schema");
|
|
12
12
|
const build_file_secret_provider_schema_1 = require("./build-file-secret-provider-schema");
|
|
13
|
+
const runner_schema_1 = require("./runner-schema");
|
|
13
14
|
exports.buildFileSchema = (0, zod_1.object)({
|
|
14
15
|
envs: envs_schema_1.envsSchema.optional(),
|
|
15
16
|
tasks: (0, zod_1.record)(build_file_task_schema_1.buildFileTaskSchema).optional(),
|
|
@@ -21,6 +22,8 @@ exports.buildFileSchema = (0, zod_1.object)({
|
|
|
21
22
|
secretProviders: build_file_secret_provider_schema_1.buildFileSecretProvidersSchema.optional(),
|
|
22
23
|
secretAccounts: build_file_secret_provider_schema_1.buildFileSecretAccountsSchema.optional(),
|
|
23
24
|
labels: labels_schema_1.labelsSchema.optional(),
|
|
25
|
+
// default `runner` of the tasks of this file
|
|
26
|
+
runner: runner_schema_1.runnerSchema.optional(),
|
|
24
27
|
})
|
|
25
28
|
.strict()
|
|
26
29
|
.describe('Build file with support for containerization\nhttps://hammerkit.dev/docs/build-file');
|
|
@@ -121,6 +121,7 @@ function applySchemaExtension(reference, task) {
|
|
|
121
121
|
task.schema.shell = task.schema.shell ?? extend.schema.shell;
|
|
122
122
|
task.schema.cache = task.schema.cache ?? extend.schema.cache;
|
|
123
123
|
task.schema.timeout = task.schema.timeout ?? extend.schema.timeout;
|
|
124
|
+
task.schema.runner = task.schema.runner ?? extend.schema.runner;
|
|
124
125
|
if (!reset.has('cmds'))
|
|
125
126
|
task.schema.cmds = extendArray(task.schema.cmds, extend.schema.cmds);
|
|
126
127
|
if (!reset.has('src'))
|