hammerkit 1.27.0-next.26 → 1.27.0-next.28
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/main.js +2 -0
- package/dist/remote/remote-work-commands.js +128 -44
- package/dist/server/endpoints.js +3 -0
- package/dist/server/runs.js +76 -13
- package/dist/server/server-config.js +3 -0
- package/dist/server/server.js +1 -0
- package/dist/utils/terminal-prompt.js +53 -0
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -8,6 +8,7 @@ const redact_1 = require("./utils/redact");
|
|
|
8
8
|
const empty_writable_1 = require("./utils/empty-writable");
|
|
9
9
|
const run_program_1 = require("./run-program");
|
|
10
10
|
const abort_on_signals_1 = require("./utils/abort-on-signals");
|
|
11
|
+
const terminal_prompt_1 = require("./utils/terminal-prompt");
|
|
11
12
|
const abortCtrl = new AbortController();
|
|
12
13
|
(0, abort_on_signals_1.abortOnSignals)(process, abortCtrl);
|
|
13
14
|
let settled = false;
|
|
@@ -32,6 +33,7 @@ const secrets = (0, redact_1.createSecretRegistry)();
|
|
|
32
33
|
stdout: process.stdout,
|
|
33
34
|
stderr: process.stderr,
|
|
34
35
|
stdoutColumns: process.stdout.columns,
|
|
36
|
+
prompt: (0, terminal_prompt_1.getTerminalPrompt)(process.env, abortCtrl.signal),
|
|
35
37
|
}, process.argv, false).then(() => {
|
|
36
38
|
settled = true;
|
|
37
39
|
}, () => {
|
|
@@ -17,6 +17,7 @@ const units_1 = require("../utils/units");
|
|
|
17
17
|
const service_tools_1 = require("../harness/service-tools");
|
|
18
18
|
const path_1 = require("path");
|
|
19
19
|
const check_keys_1 = require("../optimizer/check-keys");
|
|
20
|
+
const terminal_prompt_1 = require("../utils/terminal-prompt");
|
|
20
21
|
function describeOutcome(planned) {
|
|
21
22
|
switch (planned.outcome) {
|
|
22
23
|
case 'published':
|
|
@@ -174,6 +175,125 @@ function registerWorkCommands(remote, context) {
|
|
|
174
175
|
const token = await (0, token_source_1.getTokenSource)(environment, chosen.name, chosen.server).token();
|
|
175
176
|
return { ...chosen, token };
|
|
176
177
|
};
|
|
178
|
+
// publishes the bundle with where it comes from and says what it is now: published,
|
|
179
|
+
// already published, or only proposed
|
|
180
|
+
const publishBundle = async (serverName, server, token, bundle, annotations) => {
|
|
181
|
+
const answer = await (0, server_client_1.callServer)(server, token, 'POST', '/v1/definitions', {
|
|
182
|
+
...bundle,
|
|
183
|
+
annotations,
|
|
184
|
+
});
|
|
185
|
+
const version = answer.body;
|
|
186
|
+
const files = Object.keys(bundle.files).length;
|
|
187
|
+
// a proposal that already waits answers 200 like a published version, with its state
|
|
188
|
+
const proposed = answer.status === 202 || version.state !== undefined;
|
|
189
|
+
const verb = answer.status === 202
|
|
190
|
+
? 'proposed'
|
|
191
|
+
: proposed
|
|
192
|
+
? 'already proposed'
|
|
193
|
+
: answer.status === 201
|
|
194
|
+
? 'published'
|
|
195
|
+
: 'already published';
|
|
196
|
+
environment.stdout.write(`${verb} to ${serverName}: ${(0, server_client_1.printable)(version.hash)} (${files} ${files === 1 ? 'file' : 'files'})\n`);
|
|
197
|
+
const names = [...(version.tasks ?? []), ...(version.services ?? [])];
|
|
198
|
+
environment.stdout.write(`tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
|
|
199
|
+
for (const line of annotationLines(version.annotations, 'from')) {
|
|
200
|
+
environment.stdout.write(`${line.trimStart()}\n`);
|
|
201
|
+
}
|
|
202
|
+
if (proposed) {
|
|
203
|
+
environment.stdout.write(`it can not run before somebody who may publish approves it, until ${(0, server_client_1.printable)(version.expiresAt)}\n`);
|
|
204
|
+
}
|
|
205
|
+
return { version, proposed };
|
|
206
|
+
};
|
|
207
|
+
// what publishing the bundle would do, undefined from a server that can't say (before 1.27)
|
|
208
|
+
const planBundle = async (server, token, bundle) => {
|
|
209
|
+
try {
|
|
210
|
+
return (await (0, server_client_1.callServer)(server, token, 'POST', '/v1/definitions/plan', bundle)).body;
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
if (e instanceof server_client_1.ServerError && e.status === 404) {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
throw e;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
// The build file a run or up names, and what `--publish` may publish: the hash of the
|
|
220
|
+
// build file here, unless `--build` or HAMMERKIT_BUILD names a version.
|
|
221
|
+
const runDefinition = async (options) => {
|
|
222
|
+
const named = options.build ?? environment.processEnvs.HAMMERKIT_BUILD;
|
|
223
|
+
if (named !== undefined) {
|
|
224
|
+
if (options.publish) {
|
|
225
|
+
throw new Error(`--publish publishes the build file here, it does not go with ${options.build !== undefined ? '--build' : 'HAMMERKIT_BUILD'}`);
|
|
226
|
+
}
|
|
227
|
+
if (!bundle_1.HASH_PATTERN.test(named)) {
|
|
228
|
+
throw new Error(`${(0, server_client_1.printable)(named)} is not the hash of a published version (sha256:...)`);
|
|
229
|
+
}
|
|
230
|
+
return { definition: named };
|
|
231
|
+
}
|
|
232
|
+
const local = await (0, collect_bundle_1.collectBundle)(buildFile, environment);
|
|
233
|
+
return { definition: (0, bundle_1.hashBundle)(local), local };
|
|
234
|
+
};
|
|
235
|
+
// Submits a run of a published version. When the server does not have the build file
|
|
236
|
+
// here, it is published first: with --publish at once, else when the person at the
|
|
237
|
+
// terminal says so. Without a terminal and without --publish the run fails and says how
|
|
238
|
+
// to publish it, so CI never waits on a question.
|
|
239
|
+
const submitPublishing = async (target, request, local, publish) => {
|
|
240
|
+
const { serverName, server, connection } = target;
|
|
241
|
+
try {
|
|
242
|
+
return await (0, sandbox_client_1.submitRun)(connection, request);
|
|
243
|
+
}
|
|
244
|
+
catch (e) {
|
|
245
|
+
const status = e.status;
|
|
246
|
+
if (!local || status !== 400 || !/no published build file/.test(String(e))) {
|
|
247
|
+
throw e;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const definition = request.definition;
|
|
251
|
+
const notPublished = `the build file here is not published on ${serverName} (${definition})`;
|
|
252
|
+
const token = await connection.token();
|
|
253
|
+
if (!publish) {
|
|
254
|
+
if (!environment.prompt) {
|
|
255
|
+
throw new Error(`${notPublished}, publish it first with: hammerkit remote publish ${serverName}, or pass --publish`);
|
|
256
|
+
}
|
|
257
|
+
// a server before 1.27 can't say what publishing would do: it is asked all the same,
|
|
258
|
+
// and refuses then what the caller may not do
|
|
259
|
+
const planned = await planBundle(server, token, local);
|
|
260
|
+
if (planned && planned.outcome === null) {
|
|
261
|
+
throw new Error(`${notPublished}, and you may only run published build files (builds: run): ` +
|
|
262
|
+
`somebody who may publish publishes it with: hammerkit remote publish ${serverName}`);
|
|
263
|
+
}
|
|
264
|
+
if (planned?.outcome !== 'already published') {
|
|
265
|
+
const files = Object.keys(local.files).length;
|
|
266
|
+
environment.stdout.write(`${notPublished}, ${files} ${files === 1 ? 'file' : 'files'}\n`);
|
|
267
|
+
if (planned) {
|
|
268
|
+
const names = [...(planned.tasks ?? []), ...(planned.services ?? [])];
|
|
269
|
+
environment.stdout.write(`tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
|
|
270
|
+
}
|
|
271
|
+
const proposing = planned?.outcome === 'proposed';
|
|
272
|
+
if (proposing) {
|
|
273
|
+
environment.stdout.write(`${describeOutcome(planned)}\n`);
|
|
274
|
+
}
|
|
275
|
+
const answer = await environment.prompt(proposing ? `propose it to ${serverName} now? [y/N] ` : `publish it to ${serverName} now and go on? [y/N] `);
|
|
276
|
+
if (environment.abortCtrl.signal.aborted) {
|
|
277
|
+
throw new Error(`stopped, nothing was published`);
|
|
278
|
+
}
|
|
279
|
+
if (!(0, terminal_prompt_1.isYes)(answer)) {
|
|
280
|
+
throw new Error(`${notPublished}, nothing was published`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const annotations = await (0, annotations_1.collectAnnotations)(buildFile, Object.keys(local.files));
|
|
285
|
+
const { version, proposed } = await publishBundle(serverName, server, token, local, annotations).catch((e) => {
|
|
286
|
+
throw e instanceof server_client_1.ServerError && e.status === 403 ? new Error(`${notPublished}, ${e.message}`) : e;
|
|
287
|
+
});
|
|
288
|
+
if (version.hash !== definition) {
|
|
289
|
+
throw new Error(`${serverName} published ${(0, server_client_1.printable)(version.hash)}, not ${definition} as computed here, nothing was started`);
|
|
290
|
+
}
|
|
291
|
+
if (proposed) {
|
|
292
|
+
throw new Error(`${definition} is only proposed on ${serverName}, nothing was started: ` +
|
|
293
|
+
'run this again once somebody who may publish approves it');
|
|
294
|
+
}
|
|
295
|
+
return (0, sandbox_client_1.submitRun)(connection, request);
|
|
296
|
+
};
|
|
177
297
|
remote
|
|
178
298
|
.command('publish [server]')
|
|
179
299
|
.description('publish the build file and the files it includes to a server, runs refer to it by its hash')
|
|
@@ -198,23 +318,7 @@ function registerWorkCommands(remote, context) {
|
|
|
198
318
|
}
|
|
199
319
|
const annotations = (0, annotations_1.applyAnnotations)(await (0, annotations_1.collectAnnotations)(buildFile, Object.keys(bundle.files)), options.annotation ?? []);
|
|
200
320
|
const { name: serverName, server, token } = await connect(name);
|
|
201
|
-
|
|
202
|
-
...bundle,
|
|
203
|
-
annotations,
|
|
204
|
-
});
|
|
205
|
-
const version = answer.body;
|
|
206
|
-
const files = Object.keys(bundle.files).length;
|
|
207
|
-
const proposed = answer.status === 202;
|
|
208
|
-
const verb = proposed ? 'proposed' : answer.status === 201 ? 'published' : 'already published';
|
|
209
|
-
environment.stdout.write(`${verb} to ${serverName}: ${(0, server_client_1.printable)(version.hash)} (${files} ${files === 1 ? 'file' : 'files'})\n`);
|
|
210
|
-
const names = [...(version.tasks ?? []), ...(version.services ?? [])];
|
|
211
|
-
environment.stdout.write(`tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
|
|
212
|
-
for (const line of annotationLines(version.annotations, 'from')) {
|
|
213
|
-
environment.stdout.write(`${line.trimStart()}\n`);
|
|
214
|
-
}
|
|
215
|
-
if (proposed) {
|
|
216
|
-
environment.stdout.write(`it can not run before somebody who may publish approves it, until ${(0, server_client_1.printable)(version.expiresAt)}\n`);
|
|
217
|
-
}
|
|
321
|
+
await publishBundle(serverName, server, token, bundle, annotations);
|
|
218
322
|
}));
|
|
219
323
|
remote
|
|
220
324
|
.command('run <tasks...>')
|
|
@@ -229,12 +333,9 @@ function registerWorkCommands(remote, context) {
|
|
|
229
333
|
.addOption(new commander_1.Option('--timeout <duration>', 'as --timeout of run: for tasks without their own, such as 30m'))
|
|
230
334
|
.addOption(new commander_1.Option('--max-timeout <duration>', 'as --max-timeout of run: for every task, also one with its own timeout'))
|
|
231
335
|
.addOption(new commander_1.Option('--detach', 'submit the run and exit, without following it: for a check that CI verifies later'))
|
|
336
|
+
.addOption(new commander_1.Option('--publish', 'publish the build file here first when the server does not have it, without asking; for CI and scripts'))
|
|
232
337
|
.action(guarded('Remote run was not successful', async (tasks, options) => {
|
|
233
|
-
const
|
|
234
|
-
const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
|
|
235
|
-
if (!bundle_1.HASH_PATTERN.test(definition)) {
|
|
236
|
-
throw new Error(`${(0, server_client_1.printable)(definition)} is not the hash of a published version (sha256:...)`);
|
|
237
|
-
}
|
|
338
|
+
const { definition, local } = await runDefinition(options);
|
|
238
339
|
const source = await refSource(options);
|
|
239
340
|
const ref = source.ref;
|
|
240
341
|
const { name: serverName, server } = await getServer(options.server);
|
|
@@ -248,21 +349,7 @@ function registerWorkCommands(remote, context) {
|
|
|
248
349
|
if (passthrough.length > 0) {
|
|
249
350
|
runOptions.args = passthrough;
|
|
250
351
|
}
|
|
251
|
-
|
|
252
|
-
try {
|
|
253
|
-
submitted = await (0, sandbox_client_1.submitRun)(connection, {
|
|
254
|
-
definition,
|
|
255
|
-
tasks,
|
|
256
|
-
source,
|
|
257
|
-
options: runOptions,
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
catch (e) {
|
|
261
|
-
if (e.status === 400 && !named && /no published build file/.test(String(e))) {
|
|
262
|
-
throw new Error(`the build file here is not published on ${serverName} (${definition}), publish it first`);
|
|
263
|
-
}
|
|
264
|
-
throw e;
|
|
265
|
-
}
|
|
352
|
+
const submitted = await submitPublishing({ serverName, server, connection }, { definition, tasks, source, options: runOptions }, local, !!options.publish);
|
|
266
353
|
const at = submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : '';
|
|
267
354
|
environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${tasks.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(ref)}${at}\n`);
|
|
268
355
|
if (options.detach) {
|
|
@@ -314,17 +401,14 @@ function registerWorkCommands(remote, context) {
|
|
|
314
401
|
.addOption(new commander_1.Option('--ref <ref>', 'the branch or tag to start; the branch checked out here when left out'))
|
|
315
402
|
.addOption(new commander_1.Option('--commit <sha>', 'the commit the ref has to be at; the commit checked out here when --ref is left out'))
|
|
316
403
|
.addOption(new commander_1.Option('--env <name>', 'the environment of the build file, as --env of run'))
|
|
404
|
+
.addOption(new commander_1.Option('--publish', 'publish the build file here first when the server does not have it, without asking; for CI and scripts'))
|
|
317
405
|
.action(guarded('Remote up was not successful', async (services, options) => {
|
|
318
|
-
const
|
|
319
|
-
const definition = named ?? (0, bundle_1.hashBundle)(await (0, collect_bundle_1.collectBundle)(buildFile, environment));
|
|
320
|
-
if (!bundle_1.HASH_PATTERN.test(definition)) {
|
|
321
|
-
throw new Error(`${(0, server_client_1.printable)(definition)} is not the hash of a published version (sha256:...)`);
|
|
322
|
-
}
|
|
406
|
+
const { definition, local } = await runDefinition(options);
|
|
323
407
|
const endpoints = (options.endpoint ?? []).map(service_tools_1.parseEndpoint);
|
|
324
408
|
const source = await refSource(options);
|
|
325
409
|
const { name: serverName, server } = await getServer(options.server);
|
|
326
410
|
const connection = { url: server.url, ...(0, token_source_1.getTokenSource)(environment, serverName, server) };
|
|
327
|
-
const submitted = await (
|
|
411
|
+
const submitted = await submitPublishing({ serverName, server, connection }, {
|
|
328
412
|
definition,
|
|
329
413
|
services,
|
|
330
414
|
endpoints,
|
|
@@ -333,7 +417,7 @@ function registerWorkCommands(remote, context) {
|
|
|
333
417
|
...(options.name ? { name: options.name } : {}),
|
|
334
418
|
source,
|
|
335
419
|
options: options.env ? { env: options.env } : {},
|
|
336
|
-
});
|
|
420
|
+
}, local, !!options.publish);
|
|
337
421
|
environment.stdout.write(`${(0, server_client_1.printable)(submitted.id)} on ${serverName}: ${services.map(server_client_1.printable).join(', ')} from ${(0, server_client_1.printable)(source.ref)}${submitted.source.type === 'ref' ? ` at ${(0, server_client_1.printable)(submitted.source.commit)}` : ''}\n`);
|
|
338
422
|
const followed = await (0, sandbox_client_1.followRun)(connection, submitted, {
|
|
339
423
|
signal: environment.abortCtrl.signal,
|
package/dist/server/endpoints.js
CHANGED
|
@@ -288,6 +288,9 @@ class EndpointGateway {
|
|
|
288
288
|
return page(res, 503, 'Starting', `<p>The services of this endpoint are ${run.wakes ? 'waking up' : 'starting'} (${escape(run.state)}), this page reloads until they are up.</p>`);
|
|
289
289
|
}
|
|
290
290
|
if (run.state === 'asleep') {
|
|
291
|
+
// it may wake on a later request, once there is a place for it
|
|
292
|
+
res.setHeader('refresh', '30');
|
|
293
|
+
res.setHeader('retry-after', '30');
|
|
291
294
|
return page(res, 503, 'Asleep', `<p>The services of this endpoint sleep and can not wake now: ${escape(refused ?? 'try again')}.</p>`);
|
|
292
295
|
}
|
|
293
296
|
const why = run.stoppedBy === 'ttl'
|
package/dist/server/runs.js
CHANGED
|
@@ -124,6 +124,11 @@ class RunRegistry {
|
|
|
124
124
|
this.expireLater(entry);
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
+
else if (run.state === 'asleep') {
|
|
128
|
+
// its time ran out while the server was down: it ends now, and gives its place back
|
|
129
|
+
run.stoppedBy ?? (run.stoppedBy = 'ttl');
|
|
130
|
+
this.finish(entry, 'succeeded');
|
|
131
|
+
}
|
|
127
132
|
else if (run.state === 'queued' || run.state === 'running') {
|
|
128
133
|
this.finish(entry, 'failed', `the server stopped while the run was ${run.state}, submit it again`);
|
|
129
134
|
}
|
|
@@ -146,7 +151,7 @@ class RunRegistry {
|
|
|
146
151
|
}
|
|
147
152
|
this.checkLimits(owner, waiting);
|
|
148
153
|
if (spec.up) {
|
|
149
|
-
this.checkUpLimits(owner);
|
|
154
|
+
this.checkUpLimits(owner, spec.up.name);
|
|
150
155
|
this.checkHosts(owner, spec.up);
|
|
151
156
|
}
|
|
152
157
|
const run = {
|
|
@@ -515,20 +520,59 @@ class RunRegistry {
|
|
|
515
520
|
throw new QuotaError(`you have ${limits.queued} runs in the queue, wait for one to start or cancel one`);
|
|
516
521
|
}
|
|
517
522
|
}
|
|
518
|
-
// Service runs that are
|
|
519
|
-
//
|
|
520
|
-
|
|
523
|
+
// Service runs that are starting or up, of the caller and of everybody, against what may
|
|
524
|
+
// be. One that is asleep does not count: it asks for a place when it wakes (visit). The runs
|
|
525
|
+
// of a caller with one name take one place: the newest replaces the others once it is up,
|
|
526
|
+
// and an older one that gets up after it goes (markUp), so a new run of a name the caller
|
|
527
|
+
// has does not need another. Several of one name that are still starting share it too:
|
|
528
|
+
// what they take while they start is held by runs.limits.running and maxConcurrent.
|
|
529
|
+
checkUpLimits(owner, name) {
|
|
530
|
+
// what a caller keeps asleep is held by a limit of its own: a run that sleeps may keep the
|
|
531
|
+
// volumes of its services. A run of a name the caller has a run of (asleep, up or
|
|
532
|
+
// starting) replaces it, it is not one more.
|
|
533
|
+
const asleep = this.options.up?.asleep;
|
|
534
|
+
if (asleep !== undefined) {
|
|
535
|
+
const open = [...this.entries.values()].filter((entry) => entry.run.up && !isFinal(entry.run.state) && this.owns(owner, entry));
|
|
536
|
+
const sleeping = new Set(open.filter((entry) => entry.run.state === 'asleep').map((entry) => entry.run.up?.name));
|
|
537
|
+
if (!open.some((entry) => entry.run.up?.name === name) && sleeping.size >= asleep) {
|
|
538
|
+
throw new QuotaError(`you have ${asleep} service runs asleep, stop one or wait for its end`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const full = this.upLimitReached(owner, name);
|
|
542
|
+
if (full === 'caller') {
|
|
543
|
+
throw new QuotaError(`you have ${this.options.up?.perCaller} service runs starting or up, stop one or wait for its end`);
|
|
544
|
+
}
|
|
545
|
+
if (full === 'server') {
|
|
546
|
+
throw new RunError(`the server has ${this.options.up?.total} service runs starting or up, try again later`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// Whose limit a service run of the caller with this name would go over, if any
|
|
550
|
+
upLimitReached(owner, name) {
|
|
521
551
|
const limits = this.options.up;
|
|
522
552
|
if (!limits) {
|
|
523
|
-
return;
|
|
553
|
+
return undefined;
|
|
524
554
|
}
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
555
|
+
const places = new Set();
|
|
556
|
+
const mine = new Set();
|
|
557
|
+
for (const entry of this.entries.values()) {
|
|
558
|
+
if (!entry.run.up || isFinal(entry.run.state) || entry.run.state === 'asleep') {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
places.add(JSON.stringify([entry.run.owner.iss, entry.run.owner.sub, entry.run.up.name]));
|
|
562
|
+
if (this.owns(owner, entry)) {
|
|
563
|
+
mine.add(entry.run.up.name);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (mine.has(name)) {
|
|
567
|
+
return undefined;
|
|
568
|
+
}
|
|
569
|
+
if (mine.size >= limits.perCaller) {
|
|
570
|
+
return 'caller';
|
|
528
571
|
}
|
|
529
|
-
if (
|
|
530
|
-
|
|
572
|
+
if (places.size >= limits.total) {
|
|
573
|
+
return 'server';
|
|
531
574
|
}
|
|
575
|
+
return undefined;
|
|
532
576
|
}
|
|
533
577
|
// A host is one run's, or a run's that replaces it (same owner and name) once it is up:
|
|
534
578
|
// two labels that come out the same are refused, they would reach each other's services.
|
|
@@ -666,6 +710,18 @@ class RunRegistry {
|
|
|
666
710
|
this.stop(other, `replaced by ${entry.run.id}`);
|
|
667
711
|
}
|
|
668
712
|
}
|
|
713
|
+
// A newer run of the name that is up or asleep already (this one waited for an approval
|
|
714
|
+
// while it got up) is what the owner started last: this one goes, so the runs of a name,
|
|
715
|
+
// which take one place of runs.up.limits, never hold more than one set of services.
|
|
716
|
+
const newer = entries
|
|
717
|
+
.slice(entries.indexOf(entry) + 1)
|
|
718
|
+
.find((other) => other.run.up?.name === spec.name &&
|
|
719
|
+
this.owns(entry.run.owner, other) &&
|
|
720
|
+
(other.run.state === 'up' || other.run.state === 'asleep'));
|
|
721
|
+
if (newer) {
|
|
722
|
+
entry.run.stoppedBy = 'replaced';
|
|
723
|
+
this.stop(entry, `replaced by ${newer.run.id}`);
|
|
724
|
+
}
|
|
669
725
|
this.dispatch();
|
|
670
726
|
}
|
|
671
727
|
// Stops a service run at its expiry, and puts one that is up to sleep at its sleepsAt. A
|
|
@@ -719,9 +775,9 @@ class RunRegistry {
|
|
|
719
775
|
}
|
|
720
776
|
/**
|
|
721
777
|
* A request to an endpoint of a service run: one that is up stays up for its sleepTtl from
|
|
722
|
-
* now, one that is asleep is started again
|
|
723
|
-
*
|
|
724
|
-
* not woken, or undefined.
|
|
778
|
+
* now, one that is asleep is started again, if `mayWake` finds its owner may still run it
|
|
779
|
+
* (else it ends) and there is a place of runs.up.limits for it (else it sleeps on, and the
|
|
780
|
+
* next request asks again). Answers why it was not woken, or undefined.
|
|
725
781
|
*/
|
|
726
782
|
visit(id, mayWake) {
|
|
727
783
|
const entry = this.entries.get(id);
|
|
@@ -746,6 +802,13 @@ class RunRegistry {
|
|
|
746
802
|
this.finish(entry, 'failed', refused);
|
|
747
803
|
return refused;
|
|
748
804
|
}
|
|
805
|
+
const full = this.upLimitReached(entry.run.owner, entry.run.up.name);
|
|
806
|
+
if (full === 'caller') {
|
|
807
|
+
return 'its owner has as many service runs starting or up as they may, it wakes once one of them stops or sleeps';
|
|
808
|
+
}
|
|
809
|
+
if (full === 'server') {
|
|
810
|
+
return 'the server has as many service runs starting or up as it may, it wakes once one of them stops or sleeps';
|
|
811
|
+
}
|
|
749
812
|
this.clearTimer(entry);
|
|
750
813
|
entry.abort = new AbortController();
|
|
751
814
|
entry.released = false;
|
|
@@ -525,6 +525,9 @@ exports.serverConfigSchema = zod_1.z
|
|
|
525
525
|
.object({
|
|
526
526
|
// service runs one caller has starting or up at a time; one more is 429
|
|
527
527
|
up: zod_1.z.number().int().min(1).default(3),
|
|
528
|
+
// service runs one caller has asleep, which take no place of `up`; a new run
|
|
529
|
+
// when there are this many is 429
|
|
530
|
+
asleep: zod_1.z.number().int().min(1).default(10),
|
|
528
531
|
})
|
|
529
532
|
.strict()
|
|
530
533
|
.default({}),
|
package/dist/server/server.js
CHANGED
|
@@ -707,6 +707,7 @@ async function startServer(config, environment, options = {}) {
|
|
|
707
707
|
up: {
|
|
708
708
|
maxTtl: (0, units_1.parseDuration)(config.runs.up.maxTtl),
|
|
709
709
|
perCaller: config.runs.up.limits.up,
|
|
710
|
+
asleep: config.runs.up.limits.asleep,
|
|
710
711
|
total: config.runs.up.total,
|
|
711
712
|
},
|
|
712
713
|
store: new run_store_1.FileRunStore((0, path_1.join)(storage, 'records')),
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isYes = exports.getTerminalPrompt = exports.terminalPrompt = void 0;
|
|
4
|
+
const readline_1 = require("readline");
|
|
5
|
+
/**
|
|
6
|
+
* Asks the person at the terminal one question and answers the line they typed, or
|
|
7
|
+
* undefined when the input ended or the signal stopped the command first (Ctrl-C).
|
|
8
|
+
* The terminal stays in its own line mode, so Ctrl-C is a signal as anywhere else.
|
|
9
|
+
*/
|
|
10
|
+
function terminalPrompt(input, output, signal) {
|
|
11
|
+
return (question) => new Promise((resolve) => {
|
|
12
|
+
if (signal.aborted) {
|
|
13
|
+
resolve(undefined);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const lines = (0, readline_1.createInterface)({ input, output, terminal: false });
|
|
17
|
+
let answer;
|
|
18
|
+
const stop = () => lines.close();
|
|
19
|
+
signal.addEventListener('abort', stop, { once: true });
|
|
20
|
+
lines.once('close', () => {
|
|
21
|
+
signal.removeEventListener('abort', stop);
|
|
22
|
+
if (answer === undefined) {
|
|
23
|
+
// what was asked ends on a line of its own
|
|
24
|
+
output.write('\n');
|
|
25
|
+
}
|
|
26
|
+
resolve(answer);
|
|
27
|
+
});
|
|
28
|
+
lines.question(question, (line) => {
|
|
29
|
+
answer = line;
|
|
30
|
+
lines.close();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
exports.terminalPrompt = terminalPrompt;
|
|
35
|
+
/**
|
|
36
|
+
* The prompt of the CLI: only when somebody sits at a terminal to answer it, never in
|
|
37
|
+
* CI, where a question would wait until the job times out.
|
|
38
|
+
*/
|
|
39
|
+
function getTerminalPrompt(processEnvs, signal, stdio = process) {
|
|
40
|
+
if (!stdio.stdin.isTTY || !stdio.stdout.isTTY || isSet(processEnvs.CI)) {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
return terminalPrompt(stdio.stdin, stdio.stdout, signal);
|
|
44
|
+
}
|
|
45
|
+
exports.getTerminalPrompt = getTerminalPrompt;
|
|
46
|
+
function isSet(value) {
|
|
47
|
+
return value !== undefined && value !== '' && value !== '0' && value.toLowerCase() !== 'false';
|
|
48
|
+
}
|
|
49
|
+
function isYes(answer) {
|
|
50
|
+
return /^\s*y(es)?\s*$/i.test(answer ?? '');
|
|
51
|
+
}
|
|
52
|
+
exports.isYes = isYes;
|
|
53
|
+
//# sourceMappingURL=terminal-prompt.js.map
|
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.28",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"build",
|
|
7
7
|
"build-tool",
|