hammerkit 1.27.0-next.19 → 1.27.0-next.20
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/dist/harness/connect.js
CHANGED
|
@@ -49,7 +49,7 @@ async function connectServer(environment, options) {
|
|
|
49
49
|
if (!bundle_1.HASH_PATTERN.test(options.build)) {
|
|
50
50
|
throw new Error(`${options.build} is not the hash of a published version (sha256:...), name it as hammerkit remote publish printed it (HAMMERKIT_BUILD or --build)`);
|
|
51
51
|
}
|
|
52
|
-
const server = config
|
|
52
|
+
const server = (0, host_config_1.getRegisteredServer)(config, name);
|
|
53
53
|
return { name, connection: { url: server.url, ...(0, token_source_1.getTokenSource)(environment, name, server) }, build: options.build };
|
|
54
54
|
}
|
|
55
55
|
exports.connectServer = connectServer;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.deviceLogin = void 0;
|
|
3
|
+
exports.deviceLogin = exports.NoSignInError = void 0;
|
|
4
4
|
const server_client_1 = require("./server-client");
|
|
5
5
|
const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
6
6
|
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
@@ -8,6 +8,11 @@ const SLOW_DOWN_SECONDS = 5;
|
|
|
8
8
|
const FETCH_TIMEOUT_MS = 10000;
|
|
9
9
|
const MIN_INTERVAL_SECONDS = 1;
|
|
10
10
|
const MAX_LIFETIME_SECONDS = 30 * 60;
|
|
11
|
+
// The server takes tokens of this issuer but offers no device sign-in for it: the token has
|
|
12
|
+
// to come from elsewhere, such as a token command.
|
|
13
|
+
class NoSignInError extends server_client_1.ServerError {
|
|
14
|
+
}
|
|
15
|
+
exports.NoSignInError = NoSignInError;
|
|
11
16
|
const isLoopbackHost = (hostname) => ['localhost', '127.0.0.1', '[::1]'].includes(hostname);
|
|
12
17
|
// An endpoint must use the scheme of the issuer: loopback http is for a test
|
|
13
18
|
// identity provider on this machine only, never beside an https issuer.
|
|
@@ -89,7 +94,7 @@ async function deviceLogin(server, options) {
|
|
|
89
94
|
throw new server_client_1.ServerError(`${server.url} does not accept tokens from ${server.issuer} (it accepts ${accepted})`);
|
|
90
95
|
}
|
|
91
96
|
if (!offered.clientId) {
|
|
92
|
-
throw new
|
|
97
|
+
throw new NoSignInError(`${server.url} offers no sign-in for ${server.issuer}, it needs a token obtained elsewhere`);
|
|
93
98
|
}
|
|
94
99
|
// What was pinned (by `remote add` or an earlier login) wins; the server can
|
|
95
100
|
// only offer the same, never something else to sign in with.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.pinLogin = exports.setDefaultLocation = exports.removeServer = exports.addServer = exports.normalizeServerUrl = exports.validateServerName = exports.writeHostConfig = exports.readHostConfig = exports.getHostConfigPath = exports.hasServer = exports.hostConfigSchema = exports.hostServerSchema = exports.RESERVED_SERVER_NAMES = void 0;
|
|
3
|
+
exports.pinLogin = exports.setDefaultLocation = exports.removeServer = exports.addServer = exports.normalizeServerUrl = exports.validateServerName = exports.writeHostConfig = exports.readHostConfig = exports.getHostConfigPath = exports.getRegisteredServer = exports.getTokenCommand = exports.hasServer = exports.hostConfigSchema = exports.hostServerSchema = exports.RESERVED_SERVER_NAMES = void 0;
|
|
4
4
|
const path_1 = require("path");
|
|
5
5
|
const zod_1 = require("zod");
|
|
6
6
|
const yaml_1 = require("yaml");
|
|
@@ -28,11 +28,20 @@ exports.hostServerSchema = zod_1.z
|
|
|
28
28
|
scope: zod_1.z.string().min(1).optional(),
|
|
29
29
|
})
|
|
30
30
|
.strict();
|
|
31
|
+
// A command that prints the token for a server instead of a login, see token-source.ts.
|
|
32
|
+
// It is bound to the url and issuer it was registered for, like a sign-in, and lives
|
|
33
|
+
// beside `servers` rather than in it: hammerkit before 1.27 refuses unknown keys of a
|
|
34
|
+
// server, but keeps unknown top-level keys. It is only ever read from the host config,
|
|
35
|
+
// a build file can't name one.
|
|
36
|
+
const tokenCommandSchema = zod_1.z
|
|
37
|
+
.object({ url: zod_1.z.string(), issuer: zod_1.z.string(), command: zod_1.z.string().trim().min(1) })
|
|
38
|
+
.passthrough();
|
|
31
39
|
// Unknown top-level keys are kept when the file is written back, so a newer
|
|
32
40
|
// hammerkit's settings survive an older one editing the file.
|
|
33
41
|
exports.hostConfigSchema = zod_1.z
|
|
34
42
|
.object({
|
|
35
43
|
servers: zod_1.z.record(exports.hostServerSchema).default({}),
|
|
44
|
+
tokenCommands: zod_1.z.record(tokenCommandSchema).optional(),
|
|
36
45
|
run: zod_1.z.object({ on: zod_1.z.string().optional() }).passthrough().default({}),
|
|
37
46
|
})
|
|
38
47
|
.passthrough();
|
|
@@ -41,6 +50,35 @@ function hasServer(servers, name) {
|
|
|
41
50
|
return Object.prototype.hasOwnProperty.call(servers, name);
|
|
42
51
|
}
|
|
43
52
|
exports.hasServer = hasServer;
|
|
53
|
+
// The token command of a server, unless it was registered for another url or issuer (an
|
|
54
|
+
// older hammerkit that pointed the name elsewhere leaves it behind).
|
|
55
|
+
function getTokenCommand(config, name) {
|
|
56
|
+
const commands = config.tokenCommands ?? {};
|
|
57
|
+
const entry = Object.prototype.hasOwnProperty.call(commands, name) ? commands[name] : undefined;
|
|
58
|
+
const server = hasServer(config.servers, name) ? config.servers[name] : undefined;
|
|
59
|
+
return entry && server && entry.url === server.url && entry.issuer === server.issuer ? entry.command : undefined;
|
|
60
|
+
}
|
|
61
|
+
exports.getTokenCommand = getTokenCommand;
|
|
62
|
+
function getRegisteredServer(config, name) {
|
|
63
|
+
const tokenCommand = getTokenCommand(config, name);
|
|
64
|
+
return { ...config.servers[name], ...(tokenCommand ? { tokenCommand } : {}) };
|
|
65
|
+
}
|
|
66
|
+
exports.getRegisteredServer = getRegisteredServer;
|
|
67
|
+
function setTokenCommand(config, name, entry) {
|
|
68
|
+
const commands = { ...(config.tokenCommands ?? {}) };
|
|
69
|
+
if (entry) {
|
|
70
|
+
commands[name] = entry;
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
delete commands[name];
|
|
74
|
+
}
|
|
75
|
+
if (Object.keys(commands).length > 0) {
|
|
76
|
+
config.tokenCommands = commands;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
delete config.tokenCommands;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
44
82
|
function getHostConfigPath(environment) {
|
|
45
83
|
return environment.processEnvs.HAMMERKIT_CONFIG ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'config.yaml');
|
|
46
84
|
}
|
|
@@ -121,16 +159,38 @@ async function addServer(environment, name, url, options) {
|
|
|
121
159
|
server[key] = options[key];
|
|
122
160
|
}
|
|
123
161
|
}
|
|
162
|
+
if (typeof options.tokenCommand === 'string' && !options.tokenCommand.trim()) {
|
|
163
|
+
throw new Error('the token command must not be empty, use --no-token-command to remove it');
|
|
164
|
+
}
|
|
124
165
|
const config = await readHostConfig(environment);
|
|
125
166
|
const existing = hasServer(config.servers, name) ? config.servers[name] : undefined;
|
|
126
|
-
const
|
|
167
|
+
const sameServer = (a, b) => a.url === b.url && a.issuer === b.issuer;
|
|
168
|
+
// Adding the same server again keeps what was pinned (by a login, too) unless it is given,
|
|
169
|
+
// so a later `remote add ... --token-command` needs no --force.
|
|
170
|
+
if (existing && sameServer(existing, server)) {
|
|
171
|
+
for (const key of PINNED) {
|
|
172
|
+
if (!server[key] && existing[key]) {
|
|
173
|
+
server[key] = existing[key];
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const changed = (a, b) => !sameServer(a, b) || PINNED.some((key) => a[key] !== b[key]);
|
|
127
178
|
if (existing && changed(existing, server) && !options.force) {
|
|
128
179
|
throw new Error(`server "${name}" is already registered as ${existing.url} (issuer ${existing.issuer}); ` +
|
|
129
180
|
`use --force to point it somewhere else`);
|
|
130
181
|
}
|
|
182
|
+
// The command decides which identity's token goes to the server: changing it does not
|
|
183
|
+
// redirect anything, but a name pointed at another server does not keep the old one.
|
|
184
|
+
const kept = existing && sameServer(existing, server) ? getTokenCommand(config, name) : undefined;
|
|
185
|
+
const tokenCommand = typeof options.tokenCommand === 'string'
|
|
186
|
+
? options.tokenCommand.trim()
|
|
187
|
+
: options.tokenCommand === undefined
|
|
188
|
+
? kept
|
|
189
|
+
: undefined;
|
|
131
190
|
config.servers[name] = server;
|
|
191
|
+
setTokenCommand(config, name, tokenCommand ? { url: server.url, issuer: server.issuer, command: tokenCommand } : undefined);
|
|
132
192
|
await writeHostConfig(environment, config);
|
|
133
|
-
return server;
|
|
193
|
+
return { ...server, ...(tokenCommand ? { tokenCommand } : {}) };
|
|
134
194
|
}
|
|
135
195
|
exports.addServer = addServer;
|
|
136
196
|
async function removeServer(environment, name) {
|
|
@@ -139,6 +199,7 @@ async function removeServer(environment, name) {
|
|
|
139
199
|
throw new Error(`server "${name}" is not registered`);
|
|
140
200
|
}
|
|
141
201
|
delete config.servers[name];
|
|
202
|
+
setTokenCommand(config, name, undefined);
|
|
142
203
|
const resetDefault = config.run.on === name;
|
|
143
204
|
if (resetDefault) {
|
|
144
205
|
delete config.run.on;
|
|
@@ -40,6 +40,8 @@ passthrough = []) {
|
|
|
40
40
|
.addOption(new commander_1.Option('--client-id <id>', 'OAuth client to sign in with (pinned; default: what the server offers)'))
|
|
41
41
|
.addOption(new commander_1.Option('--audience <audience>', 'audience of the token (pinned; default: what the server offers)'))
|
|
42
42
|
.addOption(new commander_1.Option('--scope <scope>', 'scopes to request (pinned; default: what the server offers)'))
|
|
43
|
+
.addOption(new commander_1.Option('--token-command <command>', 'a command that prints the token for this server, instead of remote login (e.g. of a service account)'))
|
|
44
|
+
.addOption(new commander_1.Option('--no-token-command', 'remove the token command, sign in with remote login again'))
|
|
43
45
|
.addOption(new commander_1.Option('--force', 'point an already registered name at a different server').default(false))
|
|
44
46
|
.action(guarded('Remote add was not successful', async (name, url, options) => {
|
|
45
47
|
const server = await (0, host_config_1.addServer)(environment, name, url, {
|
|
@@ -47,9 +49,13 @@ passthrough = []) {
|
|
|
47
49
|
clientId: options.clientId,
|
|
48
50
|
audience: options.audience,
|
|
49
51
|
scope: options.scope,
|
|
52
|
+
tokenCommand: options.tokenCommand,
|
|
50
53
|
force: options.force,
|
|
51
54
|
});
|
|
52
55
|
environment.stdout.write(`added ${name}: ${server.url} (issuer ${server.issuer})\n`);
|
|
56
|
+
if (server.tokenCommand) {
|
|
57
|
+
environment.stdout.write(`tokens for ${name} come from: ${server.tokenCommand}\n`);
|
|
58
|
+
}
|
|
53
59
|
}));
|
|
54
60
|
remote
|
|
55
61
|
.command('list')
|
|
@@ -62,9 +68,10 @@ passthrough = []) {
|
|
|
62
68
|
return;
|
|
63
69
|
}
|
|
64
70
|
for (const name of names) {
|
|
65
|
-
const server = config
|
|
71
|
+
const server = (0, host_config_1.getRegisteredServer)(config, name);
|
|
66
72
|
const marker = config.run.on === name ? colors_1.default.green(' (default)') : '';
|
|
67
|
-
|
|
73
|
+
const token = server.tokenCommand ? ` token from: ${server.tokenCommand}` : '';
|
|
74
|
+
environment.stdout.write(`${colors_1.default.bold(name)}${marker} ${server.url} issuer ${server.issuer}${token}\n`);
|
|
68
75
|
}
|
|
69
76
|
if (config.run.on === 'auto' || config.run.on === 'local') {
|
|
70
77
|
environment.stdout.write(`default location: ${config.run.on}\n`);
|
|
@@ -95,17 +102,33 @@ passthrough = []) {
|
|
|
95
102
|
? `server ${name} is not registered, add it with: hammerkit remote add ${name} <url> --issuer <url>`
|
|
96
103
|
: 'name a server, or set one as the default with: hammerkit remote use <name>');
|
|
97
104
|
}
|
|
98
|
-
return { name: chosen, server: config
|
|
105
|
+
return { name: chosen, server: (0, host_config_1.getRegisteredServer)(config, chosen) };
|
|
99
106
|
};
|
|
100
107
|
remote
|
|
101
108
|
.command('login <name>')
|
|
102
109
|
.description('sign in to a registered server with your identity provider (device code, works headless)')
|
|
103
110
|
.action(guarded('Remote login was not successful', async (name) => {
|
|
104
111
|
const { server } = await getServer(name);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
if (server.tokenCommand) {
|
|
113
|
+
// a sign-in would never be used: the command's token goes first
|
|
114
|
+
throw new Error(`${name} takes its tokens from its token command (${server.tokenCommand}), it needs no sign-in; ` +
|
|
115
|
+
`to sign in instead, remove it with: hammerkit remote add ${name} ${server.url} --issuer ${server.issuer} --no-token-command`);
|
|
116
|
+
}
|
|
117
|
+
let login;
|
|
118
|
+
try {
|
|
119
|
+
login = await (0, device_login_1.deviceLogin)(server, {
|
|
120
|
+
prompt: (message) => environment.stdout.write(`${message}\n`),
|
|
121
|
+
signal: environment.abortCtrl.signal,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (e instanceof device_login_1.NoSignInError) {
|
|
126
|
+
throw new Error(`${e.message}: register a command that prints one, such as the token of a service account, with: ` +
|
|
127
|
+
`hammerkit remote add ${name} ${server.url} --issuer ${server.issuer} --token-command '<command>'`);
|
|
128
|
+
}
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
const { pinned, ...result } = login;
|
|
109
132
|
await (0, host_config_1.pinLogin)(environment, name, pinned);
|
|
110
133
|
await (0, credentials_1.saveToken)(environment, name, { url: server.url, issuer: server.issuer, ...result });
|
|
111
134
|
try {
|
|
@@ -9,6 +9,54 @@ function isToken(value) {
|
|
|
9
9
|
return /^[\x21-\x7e]+$/.test(value);
|
|
10
10
|
}
|
|
11
11
|
exports.isToken = isToken;
|
|
12
|
+
// What a failed token command said on stderr, for the user's own terminal: never its stdout,
|
|
13
|
+
// and never in an error message, which a harness hands on to an agent.
|
|
14
|
+
const MAX_STDERR_LINES = 20;
|
|
15
|
+
const MAX_STDERR_LINE = 300;
|
|
16
|
+
function writeStderr(environment, what, stderr) {
|
|
17
|
+
const lines = stderr
|
|
18
|
+
.split(/\r?\n/)
|
|
19
|
+
.map((line) => line.replace(/[^\x20-\x7e\t]/g, '?').slice(0, MAX_STDERR_LINE))
|
|
20
|
+
.filter((line) => line.trim())
|
|
21
|
+
.slice(-MAX_STDERR_LINES);
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
environment.stderr.write(`${what}: ${line}\n`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function commandSource(environment, command, what) {
|
|
27
|
+
let current;
|
|
28
|
+
const run = () => new Promise((resolve, reject) => {
|
|
29
|
+
(0, child_process_1.exec)(command, { env: environment.processEnvs, timeout: 60000 }, (error, stdout, stderr) => {
|
|
30
|
+
const token = String(stdout).trim();
|
|
31
|
+
if (error || !token) {
|
|
32
|
+
writeStderr(environment, what, String(stderr));
|
|
33
|
+
const status = error?.killed
|
|
34
|
+
? ', it did not end within a minute'
|
|
35
|
+
: typeof error?.code === 'number'
|
|
36
|
+
? `, it exited with ${error.code}`
|
|
37
|
+
: '';
|
|
38
|
+
reject(new Error(`${what} printed no token${status}`));
|
|
39
|
+
}
|
|
40
|
+
else if (!isToken(token)) {
|
|
41
|
+
// never the output itself: it can hold more secrets than the token
|
|
42
|
+
reject(new Error(`${what} printed more than a token, it has to print the token alone`));
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
resolve(token);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
return {
|
|
50
|
+
token: () => {
|
|
51
|
+
current = current ?? run();
|
|
52
|
+
current.catch(() => (current = undefined));
|
|
53
|
+
return current;
|
|
54
|
+
},
|
|
55
|
+
renew: async () => {
|
|
56
|
+
current = undefined;
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
12
60
|
/**
|
|
13
61
|
* The token a client of a server sends: a harness, and the `remote` commands. In this order:
|
|
14
62
|
*
|
|
@@ -16,42 +64,29 @@ exports.isToken = isToken;
|
|
|
16
64
|
* identity, such as a workload identity or a service account of the identity provider. It
|
|
17
65
|
* runs again when the server no longer accepts the token.
|
|
18
66
|
* - `HAMMERKIT_TOKEN`: such a token, as the agent's platform hands it over.
|
|
67
|
+
* - the token command of the server in the host config (`remote add --token-command`): the
|
|
68
|
+
* same as `HAMMERKIT_TOKEN_COMMAND`, for one server, so each server can have the service
|
|
69
|
+
* account and audience it accepts. It only ever comes from the host config, never from a
|
|
70
|
+
* build file: whoever writes it decides what runs on this machine.
|
|
19
71
|
* - the sign-in of `hammerkit remote login <server>`.
|
|
20
72
|
*
|
|
21
73
|
* The token only goes to the URL the server is registered with in the host config.
|
|
22
74
|
*/
|
|
23
75
|
function getTokenSource(environment, name, server) {
|
|
76
|
+
// a variable holds for every server: say so when it takes the place of the one registered
|
|
77
|
+
const overrides = (variable) => {
|
|
78
|
+
if (server.tokenCommand) {
|
|
79
|
+
environment.stderr.write(`${variable} is set, so the token command of ${name} is not used\n`);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
24
82
|
const command = environment.processEnvs.HAMMERKIT_TOKEN_COMMAND;
|
|
25
83
|
if (command) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
(0, child_process_1.exec)(command, { env: environment.processEnvs, timeout: 60000 }, (error, stdout) => {
|
|
29
|
-
const token = String(stdout).trim();
|
|
30
|
-
if (error || !token) {
|
|
31
|
-
reject(new Error('HAMMERKIT_TOKEN_COMMAND printed no token'));
|
|
32
|
-
}
|
|
33
|
-
else if (!isToken(token)) {
|
|
34
|
-
// never the output itself: it can hold more secrets than the token
|
|
35
|
-
reject(new Error('HAMMERKIT_TOKEN_COMMAND printed more than a token, it has to print the token alone'));
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
resolve(token);
|
|
39
|
-
}
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
return {
|
|
43
|
-
token: () => {
|
|
44
|
-
current = current ?? run();
|
|
45
|
-
current.catch(() => (current = undefined));
|
|
46
|
-
return current;
|
|
47
|
-
},
|
|
48
|
-
renew: async () => {
|
|
49
|
-
current = undefined;
|
|
50
|
-
},
|
|
51
|
-
};
|
|
84
|
+
overrides('HAMMERKIT_TOKEN_COMMAND');
|
|
85
|
+
return commandSource(environment, command, 'HAMMERKIT_TOKEN_COMMAND');
|
|
52
86
|
}
|
|
53
87
|
const fixed = environment.processEnvs.HAMMERKIT_TOKEN?.trim();
|
|
54
88
|
if (fixed) {
|
|
89
|
+
overrides('HAMMERKIT_TOKEN');
|
|
55
90
|
return {
|
|
56
91
|
token: async () => {
|
|
57
92
|
if (!isToken(fixed)) {
|
|
@@ -61,6 +96,9 @@ function getTokenSource(environment, name, server) {
|
|
|
61
96
|
},
|
|
62
97
|
};
|
|
63
98
|
}
|
|
99
|
+
if (server.tokenCommand) {
|
|
100
|
+
return commandSource(environment, server.tokenCommand, `the token command of ${name}`);
|
|
101
|
+
}
|
|
64
102
|
return { token: () => (0, credentials_1.getAccessToken)(environment, name, server) };
|
|
65
103
|
}
|
|
66
104
|
exports.getTokenSource = getTokenSource;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hammerkit",
|
|
3
3
|
"description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
|
|
4
|
-
"version": "1.27.0-next.
|
|
4
|
+
"version": "1.27.0-next.20",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"build",
|
|
7
7
|
"build-tool",
|