@inquir/compute-cli 0.3.0 → 0.4.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/README.md +223 -114
- package/dist/commands/admin-tenants.d.ts +11 -0
- package/dist/commands/admin-tenants.d.ts.map +1 -0
- package/dist/commands/admin-tenants.js +318 -0
- package/dist/commands/admin-tenants.js.map +1 -0
- package/dist/commands/admin.d.ts +2 -0
- package/dist/commands/admin.d.ts.map +1 -0
- package/dist/commands/admin.js +445 -0
- package/dist/commands/admin.js.map +1 -0
- package/dist/commands/apps-describe.d.ts +9 -0
- package/dist/commands/apps-describe.d.ts.map +1 -0
- package/dist/commands/apps-describe.js +118 -0
- package/dist/commands/apps-describe.js.map +1 -0
- package/dist/commands/apps-observability.d.ts +9 -0
- package/dist/commands/apps-observability.d.ts.map +1 -0
- package/dist/commands/apps-observability.js +281 -0
- package/dist/commands/apps-observability.js.map +1 -0
- package/dist/commands/apps-promote.d.ts +31 -0
- package/dist/commands/apps-promote.d.ts.map +1 -0
- package/dist/commands/apps-promote.js +115 -0
- package/dist/commands/apps-promote.js.map +1 -0
- package/dist/commands/apps-resources.d.ts +6 -0
- package/dist/commands/apps-resources.d.ts.map +1 -0
- package/dist/commands/apps-resources.js +107 -0
- package/dist/commands/apps-resources.js.map +1 -0
- package/dist/commands/apps.d.ts +25 -2
- package/dist/commands/apps.d.ts.map +1 -1
- package/dist/commands/apps.js +434 -66
- package/dist/commands/apps.js.map +1 -1
- package/dist/commands/config.d.ts.map +1 -1
- package/dist/commands/config.js +7 -3
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/delete.d.ts.map +1 -1
- package/dist/commands/delete.js +10 -2
- package/dist/commands/delete.js.map +1 -1
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +78 -33
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/logout.d.ts.map +1 -1
- package/dist/commands/logout.js +37 -5
- package/dist/commands/logout.js.map +1 -1
- package/dist/commands/mcp.d.ts +13 -0
- package/dist/commands/mcp.d.ts.map +1 -1
- package/dist/commands/mcp.js +184 -77
- package/dist/commands/mcp.js.map +1 -1
- package/dist/commands/projects.d.ts.map +1 -1
- package/dist/commands/projects.js +107 -11
- package/dist/commands/projects.js.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/finite-source-command-client.d.ts +3 -0
- package/dist/lib/finite-source-command-client.d.ts.map +1 -0
- package/dist/lib/finite-source-command-client.js +3 -0
- package/dist/lib/finite-source-command-client.js.map +1 -0
- package/dist/lib/mcp-io.d.ts +23 -0
- package/dist/lib/mcp-io.d.ts.map +1 -0
- package/dist/lib/mcp-io.js +185 -0
- package/dist/lib/mcp-io.js.map +1 -0
- package/dist/lib/mcp-source-commands.d.ts +17 -0
- package/dist/lib/mcp-source-commands.d.ts.map +1 -0
- package/dist/lib/mcp-source-commands.js +345 -0
- package/dist/lib/mcp-source-commands.js.map +1 -0
- package/dist/utils/api.d.ts +19 -10
- package/dist/utils/api.d.ts.map +1 -1
- package/dist/utils/api.js +22 -4
- package/dist/utils/api.js.map +1 -1
- package/dist/utils/apps-presentation.d.ts +1 -1
- package/dist/utils/apps-presentation.d.ts.map +1 -1
- package/dist/utils/apps-presentation.js +10 -0
- package/dist/utils/apps-presentation.js.map +1 -1
- package/dist/utils/auth.d.ts +21 -1
- package/dist/utils/auth.d.ts.map +1 -1
- package/dist/utils/auth.js +119 -22
- package/dist/utils/auth.js.map +1 -1
- package/dist/utils/browser.d.ts +12 -0
- package/dist/utils/browser.d.ts.map +1 -1
- package/dist/utils/browser.js +37 -16
- package/dist/utils/browser.js.map +1 -1
- package/dist/utils/package-metadata.js +1 -1
- package/dist/utils/project-context.d.ts.map +1 -1
- package/dist/utils/project-context.js +7 -5
- package/dist/utils/project-context.js.map +1 -1
- package/dist/utils/project-lookup.d.ts +10 -0
- package/dist/utils/project-lookup.d.ts.map +1 -0
- package/dist/utils/project-lookup.js +52 -0
- package/dist/utils/project-lookup.js.map +1 -0
- package/package.json +10 -10
- package/skill/SKILL.md +9 -3
package/dist/commands/apps.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { alertsSubcommand, logHistorySubcommand, metricsSubcommand, OBSERVATION_HELP, OBSERVATION_VALUE_FLAGS } from './apps-observability.js';
|
|
3
|
+
import { RESOURCES_HELP, RESOURCES_VALUE_FLAGS, resourcesSubcommand } from './apps-resources.js';
|
|
4
|
+
import { DESCRIBE_HELP, describeSubcommand } from './apps-describe.js';
|
|
5
|
+
import { followPromoteRollout } from './apps-promote.js';
|
|
2
6
|
import { hasFlag, readFlagValue, readJsonFlag, readLimitFlag, readMultiFlag, readPositionalArgs, } from '../utils/args.js';
|
|
3
|
-
import { listAppBackups, getAppBackup, createAppBackup, deleteAppBackup, restoreAppBackup, getAppConnectionUrl, connectApps, addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, redeployApplication, execInApplication, getAppLimits, getApplication, getRelease, listApplications, promoteRelease, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, wakeApplication, pauseApplication, verifyApplicationDomain, } from '../utils/api.js';
|
|
4
|
-
import { createZipBase64, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
|
|
7
|
+
import { listAppBackups, getAppBackup, createAppBackup, deleteAppBackup, restoreAppBackup, getAppConnectionUrl, connectApps, addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, redeployApplication, execInApplication, getAppLimits, getApplication, getProject, getRelease, listApplications, promoteRelease, isPromoteAccepted, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, wakeApplication, pauseApplication, verifyApplicationDomain, } from '../utils/api.js';
|
|
8
|
+
import { ApiError, DEFAULT_BUILD_TIMEOUT_POLICY, createZipBase64, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
|
|
5
9
|
import { formatBytes, formatElapsed, humanizeApiError, permanentAddress, presentRelease, } from '../utils/apps-presentation.js';
|
|
6
10
|
import { buildApiOptions, runtime } from '../utils/context.js';
|
|
7
11
|
import { confirm } from '../utils/prompt.js';
|
|
8
12
|
import { resolveCreateProject } from '../utils/project-context.js';
|
|
9
|
-
import { CliError, ExitCode, usageError } from '../utils/errors.js';
|
|
13
|
+
import { CliError, ExitCode, toCliError, usageError } from '../utils/errors.js';
|
|
10
14
|
import { redact, registerSecret } from '../utils/redact.js';
|
|
11
15
|
import { dim, formatTable, printCommandHelp, printError, printInfo, printLine, printSuccess } from '../utils/output.js';
|
|
12
16
|
/** Value flags across every subcommand — readPositionalArgs must know them all. */
|
|
13
17
|
const VALUE_FLAGS = [
|
|
18
|
+
...OBSERVATION_VALUE_FLAGS,
|
|
19
|
+
...RESOURCES_VALUE_FLAGS,
|
|
14
20
|
'--project',
|
|
15
21
|
'--template',
|
|
16
22
|
'--image',
|
|
@@ -22,6 +28,7 @@ const VALUE_FLAGS = [
|
|
|
22
28
|
'--set',
|
|
23
29
|
'--memory',
|
|
24
30
|
'--cpu',
|
|
31
|
+
'--drain-grace',
|
|
25
32
|
'--health-path',
|
|
26
33
|
'--health-command',
|
|
27
34
|
'--description',
|
|
@@ -76,7 +83,19 @@ export async function appsCommand(args) {
|
|
|
76
83
|
case 'status':
|
|
77
84
|
return statusSubcommand(args, rest, apiOptions);
|
|
78
85
|
case 'logs':
|
|
86
|
+
if (hasFlag(args, '--history'))
|
|
87
|
+
return logHistorySubcommand(args, rest, apiOptions);
|
|
79
88
|
return logsSubcommand(args, rest, apiOptions);
|
|
89
|
+
case 'metrics':
|
|
90
|
+
return metricsSubcommand(args, rest, apiOptions);
|
|
91
|
+
case 'log-history':
|
|
92
|
+
return logHistorySubcommand(args, rest, apiOptions);
|
|
93
|
+
case 'alerts':
|
|
94
|
+
return alertsSubcommand(args, rest, apiOptions);
|
|
95
|
+
case 'resources':
|
|
96
|
+
return resourcesSubcommand(args, rest, apiOptions);
|
|
97
|
+
case 'describe':
|
|
98
|
+
return describeSubcommand(args, rest, apiOptions);
|
|
80
99
|
case 'promote':
|
|
81
100
|
return promoteSubcommand(args, rest, apiOptions);
|
|
82
101
|
case 'rollback':
|
|
@@ -124,7 +143,12 @@ function printAppsHelp() {
|
|
|
124
143
|
'build <app> [--dir <path>] Build an image from source (Dockerfile) without releasing',
|
|
125
144
|
'builds <app> List image builds',
|
|
126
145
|
'status <app> Endpoints and recent releases',
|
|
146
|
+
'describe <app> Why it is (not) running: conditions, placement, events',
|
|
127
147
|
'logs <app|release|build> Stream container (or build) logs',
|
|
148
|
+
'log-history <app> Persisted runtime or HTTP logs with time/replica filters',
|
|
149
|
+
'metrics <app> CPU/RAM, network, disk I/O or HTTP history (--json)',
|
|
150
|
+
'alerts <app> [action] Configure resource/deployment alerts and read incidents',
|
|
151
|
+
'resources recommend <app> CPU/RAM right-sizing from observed usage (read-only)',
|
|
128
152
|
'promote <release> Send production traffic to a release',
|
|
129
153
|
'rollback <app> Return production to the previous release',
|
|
130
154
|
'stop <release> Stop a release that is not serving production',
|
|
@@ -137,7 +161,7 @@ function printAppsHelp() {
|
|
|
137
161
|
'delete <app> Archive an application (stops every release)',
|
|
138
162
|
'',
|
|
139
163
|
'Create/deploy flags:',
|
|
140
|
-
'--project <
|
|
164
|
+
'--project <project> create: this project by id, slug or unique name (overrides projects use)',
|
|
141
165
|
'--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
|
|
142
166
|
'--promote deploy: take production by itself once healthy (needs apps:promote)',
|
|
143
167
|
'--template <name> postgres | redis: volume, stable TCP endpoint, generated password, first release',
|
|
@@ -153,6 +177,7 @@ function printAppsHelp() {
|
|
|
153
177
|
'--set KEY=VALUE Environment variable (repeatable)',
|
|
154
178
|
'--memory <mb> Memory ceiling (default 512)',
|
|
155
179
|
'--cpu <vcpus> CPU ceiling (default 0.5)',
|
|
180
|
+
'--drain-grace <seconds> Time the old container gets after SIGTERM before it is killed (0-3600)',
|
|
156
181
|
'--egress <none|full> Outbound network policy',
|
|
157
182
|
'--ingress <private|public> Public Networking: public creates the generated domain (new apps are private)',
|
|
158
183
|
'--health-path </path> HTTP health check path',
|
|
@@ -306,6 +331,7 @@ export function buildScalingPatchFromFlags(args, positionalActivation) {
|
|
|
306
331
|
}
|
|
307
332
|
const VOLUME_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
308
333
|
const MAX_APP_VOLUMES = 16;
|
|
334
|
+
// eslint-disable-next-line no-control-regex -- Container mount paths must reject control characters.
|
|
309
335
|
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
|
|
310
336
|
/** `--volume name:/absolute/container/path[:ro]`; host paths are never accepted. */
|
|
311
337
|
export function parseVolumeFlag(value) {
|
|
@@ -375,6 +401,16 @@ function mergeVolumes(base, overrides) {
|
|
|
375
401
|
assertVolumeSet(merged);
|
|
376
402
|
return merged;
|
|
377
403
|
}
|
|
404
|
+
/** Largest `runtimeSpec.drainGraceSeconds` the API accepts. */
|
|
405
|
+
const MAX_DRAIN_GRACE_SECONDS = 3600;
|
|
406
|
+
/** `--drain-grace <seconds>`: whole seconds, 0..3600, as the API's runtime schema. */
|
|
407
|
+
export function parseDrainGraceFlag(value) {
|
|
408
|
+
const seconds = /^\d{1,4}$/.test(value) ? Number(value) : Number.NaN;
|
|
409
|
+
if (!(seconds <= MAX_DRAIN_GRACE_SECONDS)) {
|
|
410
|
+
throw usageError(`Invalid --drain-grace value '${value}' (whole seconds, 0-${MAX_DRAIN_GRACE_SECONDS})`, 'inquir apps deploy <app> --drain-grace 30');
|
|
411
|
+
}
|
|
412
|
+
return seconds;
|
|
413
|
+
}
|
|
378
414
|
/** Runtime flags patch the saved application runtime rather than replacing unrelated fields. */
|
|
379
415
|
export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
380
416
|
const portRaw = readFlagValue(args, '--port');
|
|
@@ -382,6 +418,7 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
|
382
418
|
const volumeValues = readMultiFlag(args, '--volume');
|
|
383
419
|
const memory = readFlagValue(args, '--memory');
|
|
384
420
|
const cpu = readFlagValue(args, '--cpu');
|
|
421
|
+
const drainGraceRaw = readFlagValue(args, '--drain-grace');
|
|
385
422
|
const healthPath = readFlagValue(args, '--health-path');
|
|
386
423
|
const healthCommand = readFlagValue(args, '--health-command');
|
|
387
424
|
const ports = tcpValues.map(parseTcpPortFlag);
|
|
@@ -390,7 +427,13 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
|
390
427
|
if (portRaw && (!Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
391
428
|
throw usageError(`Invalid --port value '${portRaw}'`, 'inquir apps create <name> --port 3000');
|
|
392
429
|
}
|
|
393
|
-
|
|
430
|
+
// `--drain-grace` without a value reads as undefined; refuse it rather than
|
|
431
|
+
// silently deploying without the setting the caller asked for.
|
|
432
|
+
if (drainGraceRaw === undefined && hasFlag(args, '--drain-grace'))
|
|
433
|
+
parseDrainGraceFlag('');
|
|
434
|
+
const drainGraceSeconds = drainGraceRaw !== undefined ? parseDrainGraceFlag(drainGraceRaw) : undefined;
|
|
435
|
+
if (!portRaw && tcpValues.length === 0 && volumeValues.length === 0 && !memory && !cpu &&
|
|
436
|
+
drainGraceSeconds === undefined && !healthPath && !healthCommand) {
|
|
394
437
|
return undefined;
|
|
395
438
|
}
|
|
396
439
|
const spec = {
|
|
@@ -398,6 +441,7 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
|
398
441
|
...(portRaw ? { port } : {}),
|
|
399
442
|
...(tcpValues.length > 0 ? { ports } : {}),
|
|
400
443
|
...(volumeValues.length > 0 ? { volumes: mergeVolumes(baseRuntime?.volumes, volumes) } : {}),
|
|
444
|
+
...(drainGraceSeconds !== undefined ? { drainGraceSeconds } : {}),
|
|
401
445
|
};
|
|
402
446
|
if (memory || cpu) {
|
|
403
447
|
const parsedMemory = memory ? Number(memory) : (baseRuntime?.resources?.memoryMb ?? 512);
|
|
@@ -453,7 +497,7 @@ async function createAppSubcommand(args, positionals, apiOptions) {
|
|
|
453
497
|
if (!name)
|
|
454
498
|
throw usageError('Missing application name', 'inquir apps create <name> --port 3000');
|
|
455
499
|
if (positionals.length > 1)
|
|
456
|
-
throw usageError('Expected one application name', 'inquir apps create <name> --project <
|
|
500
|
+
throw usageError('Expected one application name', 'inquir apps create <name> --project <project>');
|
|
457
501
|
await requireAuth(apiOptions);
|
|
458
502
|
const template = readFlagValue(args, '--template');
|
|
459
503
|
if (template && !TEMPLATE_NAMES.includes(template)) {
|
|
@@ -477,7 +521,22 @@ async function createAppSubcommand(args, positionals, apiOptions) {
|
|
|
477
521
|
runtime: buildRuntimeFromFlags(args),
|
|
478
522
|
...(Object.keys(envVars).length > 0 ? { envVars } : {}),
|
|
479
523
|
...(networkPolicy ? { networkPolicy } : {}),
|
|
480
|
-
}, project.apiOptions)
|
|
524
|
+
}, project.apiOptions).catch((error) => {
|
|
525
|
+
// A worker runner pulls only a digest-pinned registry image, and this
|
|
526
|
+
// installation has no mirror of the template's image yet.
|
|
527
|
+
if (error instanceof ApiError && error.code === 'APP_TEMPLATE_IMAGE_NOT_MIRRORED') {
|
|
528
|
+
throw new CliError(`The ${template} template cannot run on a worker runner on this installation yet`, {
|
|
529
|
+
code: error.code,
|
|
530
|
+
exitCode: ExitCode.ERROR,
|
|
531
|
+
requestId: error.requestId,
|
|
532
|
+
details: error.payload,
|
|
533
|
+
explanation: error.message,
|
|
534
|
+
fix: 'An operator mirrors the template image into the platform registry and sets APPS_TEMPLATE_IMAGES (docs/operations/template-images.md). Until then, create the database without --volume-runner: it runs on the control-plane runner.',
|
|
535
|
+
command: `inquir apps create ${name} --template ${template}`,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
throw error;
|
|
539
|
+
});
|
|
481
540
|
const firstRelease = app.firstRelease;
|
|
482
541
|
if (readJsonFlag(args)) {
|
|
483
542
|
// A scripted create must answer the same question the human one does:
|
|
@@ -490,8 +549,9 @@ async function createAppSubcommand(args, positionals, apiOptions) {
|
|
|
490
549
|
}
|
|
491
550
|
printSuccess(`Created application ${app.name}`);
|
|
492
551
|
printInfo(dim(` id: ${app.id}`));
|
|
493
|
-
|
|
494
|
-
printInfo(dim(`
|
|
552
|
+
for (const line of await landingProjectLines(app, project.projectId, project.apiOptions)) {
|
|
553
|
+
printInfo(dim(` ${line}`));
|
|
554
|
+
}
|
|
495
555
|
if (template && firstRelease) {
|
|
496
556
|
printInfo(dim(` template: ${template} — first release ${firstRelease.releaseId.slice(0, 8)} is starting`));
|
|
497
557
|
const release = await waitForRelease(firstRelease.releaseId, project.apiOptions);
|
|
@@ -507,9 +567,46 @@ async function createAppSubcommand(args, positionals, apiOptions) {
|
|
|
507
567
|
printInfo(dim(` deploy it: inquir apps deploy ${app.name} (builds the Dockerfile in the current directory)`));
|
|
508
568
|
printInfo(dim(` or: inquir apps deploy ${app.name} --image <ref>`));
|
|
509
569
|
}
|
|
570
|
+
/**
|
|
571
|
+
* Which project a new application landed in, by name. With neither --project
|
|
572
|
+
* nor a selection the server creates one named after the application, so a
|
|
573
|
+
* database and the app using it land in two projects: say so, and how to
|
|
574
|
+
* group them.
|
|
575
|
+
*/
|
|
576
|
+
async function landingProjectLines(app, requestedProject, apiOptions) {
|
|
577
|
+
const projectId = app.projectIds?.[0];
|
|
578
|
+
if (!projectId)
|
|
579
|
+
return [];
|
|
580
|
+
const project = await getProject(projectId, apiOptions).catch(() => null);
|
|
581
|
+
const label = project ? `${project.name} (${project.slug})` : projectId;
|
|
582
|
+
if (requestedProject)
|
|
583
|
+
return [`project: ${label}`];
|
|
584
|
+
return [
|
|
585
|
+
`project: ${label}, created for it since no project is selected here`,
|
|
586
|
+
` group it with another application: inquir projects move <project> ${app.name}`,
|
|
587
|
+
' put new applications in one project: inquir projects use <project>',
|
|
588
|
+
];
|
|
589
|
+
}
|
|
510
590
|
const TEMPLATE_NAMES = ['postgres', 'redis'];
|
|
511
591
|
/** `apps <sub> --help` answered per subcommand; the global page is the fallback. */
|
|
512
592
|
const SUBCOMMAND_HELP = {
|
|
593
|
+
...OBSERVATION_HELP,
|
|
594
|
+
...RESOURCES_HELP,
|
|
595
|
+
...DESCRIBE_HELP,
|
|
596
|
+
logs: [
|
|
597
|
+
'Usage: inquir apps logs <app|release|build> [--tail <n>]',
|
|
598
|
+
' Streams container or build logs. Ctrl-C stops following.',
|
|
599
|
+
' --history selects persisted history instead; see apps log-history --help.',
|
|
600
|
+
],
|
|
601
|
+
promote: [
|
|
602
|
+
'Usage: inquir apps promote <release> [--no-wait] [--json]',
|
|
603
|
+
'',
|
|
604
|
+
' Sends production traffic to a healthy release. A multi-replica service',
|
|
605
|
+
' may be promoted as a rolling rollout: the server starts the new replicas a',
|
|
606
|
+
' few at a time and switches production once enough of them serve, and the',
|
|
607
|
+
' CLI follows it until then.',
|
|
608
|
+
' --no-wait Return as soon as the rollout has started',
|
|
609
|
+
],
|
|
513
610
|
redeploy: [
|
|
514
611
|
'Usage: inquir apps redeploy <app> [--no-promote] [--no-wait]',
|
|
515
612
|
'',
|
|
@@ -525,14 +622,17 @@ const SUBCOMMAND_HELP = {
|
|
|
525
622
|
create: [
|
|
526
623
|
'Usage: inquir apps create <name> [--template postgres|redis] [flags]',
|
|
527
624
|
'',
|
|
528
|
-
' --project <
|
|
529
|
-
' without a selection, creates a project
|
|
625
|
+
' --project <project> Add to this project (id, slug or unique name). Otherwise',
|
|
626
|
+
' uses projects use; without a selection, creates a project',
|
|
627
|
+
' named after the app (group it later: inquir projects move).',
|
|
530
628
|
' --template <name> postgres | redis: volume, pinned public TCP port,',
|
|
531
629
|
' generated password, and the first release — one command',
|
|
532
630
|
' from nothing to a running database.',
|
|
533
631
|
' --volume-runner <id> Pin volumes + container to a worker runner (id or',
|
|
534
|
-
' unique prefix); the data lives on that machine.',
|
|
535
|
-
'
|
|
632
|
+
' unique prefix); the data lives on that machine. With',
|
|
633
|
+
' --template the worker runs the operator\'s digest-pinned',
|
|
634
|
+
' mirror of the template image (APPS_TEMPLATE_IMAGES).',
|
|
635
|
+
' --port, --tcp, --volume, --memory, --cpu, --drain-grace, --health-command, --set KEY=VALUE',
|
|
536
636
|
'',
|
|
537
637
|
'Examples:',
|
|
538
638
|
' inquir apps create mydb --template postgres',
|
|
@@ -559,6 +659,9 @@ const SUBCOMMAND_HELP = {
|
|
|
559
659
|
'Writes the database connection URL into the target app\'s environment',
|
|
560
660
|
'(DATABASE_URL for postgres, REDIS_URL for redis). The password never',
|
|
561
661
|
'passes through your terminal. Takes effect on the target\'s next release.',
|
|
662
|
+
'The URL names the database\'s private address, <slug>.apps.internal:<port>,',
|
|
663
|
+
'on the control-plane runner and on a worker alike, once the platform has',
|
|
664
|
+
'verified it (`inquir apps status <db>` lists it).',
|
|
562
665
|
'',
|
|
563
666
|
' --overwrite Replace a DATABASE_URL/REDIS_URL the target already has —',
|
|
564
667
|
' e.g. to repoint it at the database\'s private address',
|
|
@@ -729,6 +832,90 @@ async function backupsSubcommand(args, positionals, apiOptions) {
|
|
|
729
832
|
}
|
|
730
833
|
throw usageError(`Unknown backups action '${verb}'`, BACKUPS_USAGE);
|
|
731
834
|
}
|
|
835
|
+
/**
|
|
836
|
+
* The refusals of `apps connect` and `apps connection-url`, told as what to do
|
|
837
|
+
* next. The server's code and message are kept; the cause, the fix and the
|
|
838
|
+
* command to try are the CLI's. Anything else passes through unchanged to the
|
|
839
|
+
* generic rendering.
|
|
840
|
+
*/
|
|
841
|
+
export function explainDatabaseConnectionError(error, context) {
|
|
842
|
+
if (!(error instanceof ApiError) || !error.code)
|
|
843
|
+
return error;
|
|
844
|
+
const { database, target } = context;
|
|
845
|
+
const base = {
|
|
846
|
+
code: error.code,
|
|
847
|
+
exitCode: ExitCode.ERROR,
|
|
848
|
+
requestId: error.requestId,
|
|
849
|
+
details: error.payload,
|
|
850
|
+
};
|
|
851
|
+
const status = `inquir apps status ${database}`;
|
|
852
|
+
const retry = target ? `inquir apps connect ${database} ${target}` : `inquir apps connection-url ${database}`;
|
|
853
|
+
switch (error.code) {
|
|
854
|
+
case 'APP_NOT_A_TEMPLATE':
|
|
855
|
+
return new CliError(`${database} was not created from a database template`, {
|
|
856
|
+
...base,
|
|
857
|
+
explanation: 'connect and connection-url compose URLs only for postgres and redis template databases: the template says which variables hold the user, the password and the database.',
|
|
858
|
+
fix: target
|
|
859
|
+
? `Create the database with --template (on a worker too: --template postgres --volume-runner <runner>), or set the URL on ${target} yourself; a URL set by hand is not recorded as a dependency.`
|
|
860
|
+
: 'Create the database with --template (on a worker too: --template postgres --volume-runner <runner>), or compose the URL from its own credentials and its private address.',
|
|
861
|
+
command: target ? `inquir apps update ${target} --set DATABASE_URL=<url>` : status,
|
|
862
|
+
});
|
|
863
|
+
case 'APP_PRIVATE_ENDPOINT_NOT_READY':
|
|
864
|
+
return new CliError(`${database} has no verified private address yet`, {
|
|
865
|
+
...base,
|
|
866
|
+
explanation: error.message,
|
|
867
|
+
cause: 'The platform verifies a database\'s private proxy once its replica serves, on a worker runner too, and re-checks it about every 20 seconds.',
|
|
868
|
+
fix: `Retry in a minute. If it lasts, check that the database is serving: ${status}`,
|
|
869
|
+
command: retry,
|
|
870
|
+
});
|
|
871
|
+
case 'APP_NO_TCP_ENDPOINT':
|
|
872
|
+
return new CliError(error.message, {
|
|
873
|
+
...base,
|
|
874
|
+
// An older control plane also answers this for a database a worker
|
|
875
|
+
// serves: it looked for the port only in the release's port mappings,
|
|
876
|
+
// which such a release never has (src/apps/app-connection-endpoint.ts).
|
|
877
|
+
cause: 'The server found no private TCP port on the database\'s production release: it declares none, or it runs on a worker runner and the control plane predates reading those ports.',
|
|
878
|
+
fix: `Check the release's ports with ${status}. If it declares one (e.g. --tcp postgres:5432), the control plane needs the update that reads worker-served ports; otherwise declare the port and deploy the database again.`,
|
|
879
|
+
command: status,
|
|
880
|
+
});
|
|
881
|
+
case 'PRIVATE_NETWORK_NOT_CONFIGURED':
|
|
882
|
+
return new CliError(error.message, {
|
|
883
|
+
...base,
|
|
884
|
+
cause: 'The control plane offers no private address for this database.',
|
|
885
|
+
fix: target
|
|
886
|
+
? 'connect writes private URLs only. A client outside the platform can use the public URL once Public Access is enabled on the database. If other apps have private addresses here, the database\'s may just not be verified yet: retry in a minute.'
|
|
887
|
+
: 'Enable Public Access on the database and ask for its public URL. If other apps have private addresses here, the database\'s may just not be verified yet: retry in a minute.',
|
|
888
|
+
command: `inquir apps connection-url ${database} --public`,
|
|
889
|
+
});
|
|
890
|
+
case 'APP_NO_PRODUCTION_RELEASE':
|
|
891
|
+
return new CliError(error.message, {
|
|
892
|
+
...base,
|
|
893
|
+
fix: 'Deploy the database first; a template database ships its first release when it is created.',
|
|
894
|
+
command: status,
|
|
895
|
+
});
|
|
896
|
+
case 'APP_PRODUCTION_RELEASE_NOT_ACTIVE':
|
|
897
|
+
return new CliError(error.message, {
|
|
898
|
+
...base,
|
|
899
|
+
fix: 'Wait until its release serves: on a worker runner the replica must start and pass its health check first.',
|
|
900
|
+
command: status,
|
|
901
|
+
});
|
|
902
|
+
case 'APP_CREDENTIALS_MISSING':
|
|
903
|
+
return new CliError(error.message, {
|
|
904
|
+
...base,
|
|
905
|
+
cause: 'The running release\'s env has no password under the template\'s key (POSTGRES_PASSWORD, REDIS_PASSWORD).',
|
|
906
|
+
fix: 'Set it on the database to the password its data actually uses, then deploy the database again; a new value does not change the password of existing data.',
|
|
907
|
+
command: `inquir apps update ${database} --set <POSTGRES_PASSWORD|REDIS_PASSWORD>=<password>`,
|
|
908
|
+
});
|
|
909
|
+
case 'ENV_KEY_ALREADY_SET':
|
|
910
|
+
return new CliError(error.message, {
|
|
911
|
+
...base,
|
|
912
|
+
fix: 'Replace it on purpose: the new URL applies on the target\'s next release.',
|
|
913
|
+
command: `${retry} --overwrite`,
|
|
914
|
+
});
|
|
915
|
+
default:
|
|
916
|
+
return error;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
732
919
|
async function connectSubcommand(args, positionals, apiOptions) {
|
|
733
920
|
const [dbIdentifier, targetIdentifier] = positionals;
|
|
734
921
|
if (!dbIdentifier || !targetIdentifier) {
|
|
@@ -740,7 +927,9 @@ async function connectSubcommand(args, positionals, apiOptions) {
|
|
|
740
927
|
// is how an app is repointed at the database's current private address
|
|
741
928
|
// (the composed URL never passes through this terminal).
|
|
742
929
|
const overwrite = args.includes('--overwrite');
|
|
743
|
-
const result = await connectApps(db.id, targetIdentifier, { ...apiOptions, overwrite })
|
|
930
|
+
const result = await connectApps(db.id, targetIdentifier, { ...apiOptions, overwrite }).catch((error) => {
|
|
931
|
+
throw explainDatabaseConnectionError(error, { database: db.name, target: targetIdentifier });
|
|
932
|
+
});
|
|
744
933
|
if (readJsonFlag(args)) {
|
|
745
934
|
return printLine(JSON.stringify(result, null, 2));
|
|
746
935
|
}
|
|
@@ -753,7 +942,9 @@ async function connectionUrlSubcommand(args, positionals, apiOptions) {
|
|
|
753
942
|
throw usageError('Missing application', 'inquir apps connection-url mydb');
|
|
754
943
|
await requireAuth(apiOptions);
|
|
755
944
|
const app = await resolveApplication(identifier, apiOptions);
|
|
756
|
-
const result = await getAppConnectionUrl(app.id, apiOptions)
|
|
945
|
+
const result = await getAppConnectionUrl(app.id, apiOptions).catch((error) => {
|
|
946
|
+
throw explainDatabaseConnectionError(error, { database: app.name });
|
|
947
|
+
});
|
|
757
948
|
const requirePrivate = args.includes('--private');
|
|
758
949
|
const requirePublic = args.includes('--public');
|
|
759
950
|
if (requirePrivate && requirePublic) {
|
|
@@ -788,9 +979,12 @@ export function selectConnectionUrl(result, preference = 'auto') {
|
|
|
788
979
|
return { url: result.publicUrl, visibility: 'public' };
|
|
789
980
|
}
|
|
790
981
|
const requested = preference === 'auto' ? 'private or public' : preference;
|
|
791
|
-
|
|
982
|
+
const reason = preference !== 'public' ? result.privateUnavailable : undefined;
|
|
983
|
+
throw usageError(`No ${requested} database connection URL is available${reason ? `: ${reason.message}` : ''}`, preference === 'public'
|
|
792
984
|
? 'Enable Public Access in the application Networking settings.'
|
|
793
|
-
:
|
|
985
|
+
: reason?.code === 'APP_PRIVATE_ENDPOINT_NOT_READY'
|
|
986
|
+
? 'Retry in a minute; the private address is verified once the database serves.'
|
|
987
|
+
: 'Configure private networking, or use --public after explicitly enabling Public Access.');
|
|
794
988
|
}
|
|
795
989
|
async function deploySubcommand(args, positionals, apiOptions) {
|
|
796
990
|
const [identifier] = positionals;
|
|
@@ -977,65 +1171,202 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
|
|
|
977
1171
|
? `Build ${queued.buildId.slice(0, 8)} reused (identical source already built)`
|
|
978
1172
|
: `Build ${queued.buildId.slice(0, 8)} queued for ${appName}`);
|
|
979
1173
|
}
|
|
980
|
-
|
|
981
|
-
|
|
1174
|
+
return waitForBuild(queued.buildId, apiOptions, {
|
|
1175
|
+
json, initial: queued, noLogs: queued.deduplicated || isTerminalBuildStatus(queued.status),
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
const BUILD_WAIT_PHASE = { QUEUED: 'queued', EXECUTING: 'building' };
|
|
1179
|
+
const BUILD_WAIT_ERROR = { QUEUE: 'BUILD_QUEUE_TIMEOUT', EXECUTION: 'BUILD_TIMEOUT', STATUS_REQUEST: 'BUILD_STATUS_TIMEOUT' };
|
|
1180
|
+
const BUILD_STATUS_REQUEST_TIMEOUT_MS = 15_000;
|
|
1181
|
+
const BUILD_STATUS_POLL_INTERVAL_MS = 1_000;
|
|
1182
|
+
const BUILD_STATUS_RETRY_BACKOFF_MS = 2_000;
|
|
1183
|
+
const BUILD_LOG_DRAIN_TIMEOUT_MS = 250;
|
|
1184
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
1185
|
+
const HTTP_TOO_MANY_REQUESTS = 429;
|
|
1186
|
+
const HTTP_SERVER_ERROR_MIN = 500;
|
|
1187
|
+
function buildWaitPolicy(value) {
|
|
1188
|
+
if (!value || !Number.isSafeInteger(value.executionTimeoutMs) || value.executionTimeoutMs < 1 ||
|
|
1189
|
+
!Number.isSafeInteger(value.queueTimeoutMs) || value.queueTimeoutMs < 1 ||
|
|
1190
|
+
!Number.isSafeInteger(value.completionGraceMs) || value.completionGraceMs < 0 ||
|
|
1191
|
+
value.executionTimeoutMs > MAX_TIMER_DELAY_MS || value.queueTimeoutMs > MAX_TIMER_DELAY_MS ||
|
|
1192
|
+
value.completionGraceMs > MAX_TIMER_DELAY_MS) {
|
|
1193
|
+
return DEFAULT_BUILD_TIMEOUT_POLICY;
|
|
982
1194
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1195
|
+
return value;
|
|
1196
|
+
}
|
|
1197
|
+
function buildWaitSleep(ms, signal) {
|
|
1198
|
+
signal.throwIfAborted();
|
|
1199
|
+
return new Promise((resolve, reject) => {
|
|
1200
|
+
const onAbort = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); reject(signal.reason); };
|
|
1201
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
1202
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1203
|
+
});
|
|
1204
|
+
}
|
|
1205
|
+
/** Authoritative phase observation, independent of an open or broken log stream. */
|
|
1206
|
+
export async function waitForBuild(buildId, apiOptions, options = {}) {
|
|
1207
|
+
const clock = () => globalThis.performance.now();
|
|
1208
|
+
const controller = new AbortController();
|
|
1209
|
+
const initialPolicy = buildWaitPolicy(options.initial?.timeoutPolicy);
|
|
1210
|
+
const overallDeadline = clock() + initialPolicy.queueTimeoutMs + initialPolicy.executionTimeoutMs + initialPolicy.completionGraceMs;
|
|
1211
|
+
let phase = options.initial?.status === BUILD_WAIT_PHASE.EXECUTING
|
|
1212
|
+
? BUILD_WAIT_PHASE.EXECUTING : BUILD_WAIT_PHASE.QUEUED;
|
|
1213
|
+
let deadline = overallDeadline;
|
|
1214
|
+
let timer;
|
|
1215
|
+
let expire;
|
|
1216
|
+
const expired = new Promise((_, reject) => { expire = reject; });
|
|
1217
|
+
function observe(value) {
|
|
1218
|
+
const next = value.status === BUILD_WAIT_PHASE.EXECUTING ? BUILD_WAIT_PHASE.EXECUTING : BUILD_WAIT_PHASE.QUEUED;
|
|
1219
|
+
// A stale queued observation cannot restart an already-running budget.
|
|
1220
|
+
if (phase === BUILD_WAIT_PHASE.EXECUTING && next === BUILD_WAIT_PHASE.QUEUED)
|
|
1221
|
+
return;
|
|
1222
|
+
const policy = buildWaitPolicy(value.timeoutPolicy ?? initialPolicy);
|
|
1223
|
+
const cap = next === BUILD_WAIT_PHASE.QUEUED ? policy.queueTimeoutMs : policy.executionTimeoutMs + policy.completionGraceMs;
|
|
1224
|
+
const remaining = typeof value.timeoutRemainingMs === 'number' && Number.isFinite(value.timeoutRemainingMs) && value.timeoutRemainingMs >= 0
|
|
1225
|
+
? Math.min(value.timeoutRemainingMs, cap) : cap;
|
|
1226
|
+
const candidate = Math.min(overallDeadline, clock() + remaining);
|
|
1227
|
+
deadline = next === phase ? Math.min(deadline, candidate) : candidate;
|
|
1228
|
+
phase = next;
|
|
1229
|
+
armDeadline();
|
|
1230
|
+
}
|
|
1231
|
+
function armDeadline() {
|
|
1232
|
+
clearTimeout(timer);
|
|
1233
|
+
timer = setTimeout(() => {
|
|
1234
|
+
// Node clamps oversized delays to 1ms. Keep long valid policies bounded
|
|
1235
|
+
// by scheduling slices, and tolerate a timer waking before its deadline.
|
|
1236
|
+
if (clock() < deadline) {
|
|
1237
|
+
armDeadline();
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
const error = new CliError(`Build ${buildId} exceeded the CLI ${phase === BUILD_WAIT_PHASE.QUEUED ? 'queue' : 'execution'} wait deadline`, {
|
|
1241
|
+
code: phase === BUILD_WAIT_PHASE.QUEUED ? BUILD_WAIT_ERROR.QUEUE : BUILD_WAIT_ERROR.EXECUTION, exitCode: ExitCode.ERROR,
|
|
1242
|
+
explanation: `The CLI stopped waiting for build ${buildId}; it has not cancelled the remote build. Check this build before retrying.`,
|
|
1243
|
+
fix: `Inspect its logs: inquir apps logs ${buildId}`,
|
|
1244
|
+
});
|
|
1245
|
+
expire(error);
|
|
1246
|
+
controller.abort(error);
|
|
1247
|
+
}, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, deadline - clock())));
|
|
1248
|
+
}
|
|
1249
|
+
async function statusRequest() {
|
|
1250
|
+
controller.signal.throwIfAborted();
|
|
1251
|
+
const request = new AbortController();
|
|
1252
|
+
const onAbort = () => request.abort(controller.signal.reason);
|
|
1253
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
1254
|
+
let requestTimer;
|
|
1255
|
+
const timedOut = new Promise((_, reject) => {
|
|
1256
|
+
requestTimer = setTimeout(() => {
|
|
1257
|
+
const error = new CliError('Build status request timed out', { code: BUILD_WAIT_ERROR.STATUS_REQUEST, exitCode: ExitCode.NETWORK });
|
|
1258
|
+
reject(error);
|
|
1259
|
+
request.abort(error);
|
|
1260
|
+
}, Math.min(BUILD_STATUS_REQUEST_TIMEOUT_MS, Math.max(0, deadline - clock())));
|
|
1261
|
+
});
|
|
1262
|
+
try {
|
|
1263
|
+
return await Promise.race([getBuild(buildId, apiOptions, request.signal), timedOut]);
|
|
990
1264
|
}
|
|
991
|
-
|
|
992
|
-
|
|
1265
|
+
finally {
|
|
1266
|
+
clearTimeout(requestTimer);
|
|
1267
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
1268
|
+
request.abort();
|
|
993
1269
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1270
|
+
}
|
|
1271
|
+
async function poll() {
|
|
1272
|
+
for (;;) {
|
|
1273
|
+
controller.signal.throwIfAborted();
|
|
1274
|
+
try {
|
|
1275
|
+
const build = await statusRequest();
|
|
1276
|
+
controller.signal.throwIfAborted();
|
|
1277
|
+
if (isTerminalBuildStatus(build.status))
|
|
1278
|
+
return build;
|
|
1279
|
+
observe(build);
|
|
1280
|
+
}
|
|
1281
|
+
catch (error) {
|
|
1282
|
+
controller.signal.throwIfAborted();
|
|
1283
|
+
const retryable = error instanceof ApiError ? error.status === HTTP_TOO_MANY_REQUESTS || error.status >= HTTP_SERVER_ERROR_MIN : toCliError(error).exitCode === ExitCode.NETWORK;
|
|
1284
|
+
if (!retryable)
|
|
1285
|
+
throw error;
|
|
1286
|
+
await buildWaitSleep(BUILD_STATUS_RETRY_BACKOFF_MS, controller.signal);
|
|
1287
|
+
}
|
|
1288
|
+
await buildWaitSleep(BUILD_STATUS_POLL_INTERVAL_MS, controller.signal);
|
|
997
1289
|
}
|
|
998
1290
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1291
|
+
async function consumeLogs() {
|
|
1292
|
+
if (options.noLogs)
|
|
1293
|
+
return;
|
|
1294
|
+
try {
|
|
1295
|
+
for await (const event of streamBuildLogs(buildId, apiOptions, controller.signal)) {
|
|
1296
|
+
if (controller.signal.aborted)
|
|
1297
|
+
break;
|
|
1298
|
+
if (event.type === 'log' && event.line) {
|
|
1299
|
+
if (options.json)
|
|
1300
|
+
process.stderr.write(`${redact(event.line)}\n`);
|
|
1301
|
+
else
|
|
1302
|
+
printLine(dim(` ${event.line}`));
|
|
1303
|
+
}
|
|
1304
|
+
else if (event.type === 'complete' || event.type === 'error')
|
|
1305
|
+
break;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
catch {
|
|
1309
|
+
// Logs are best effort. Only the build row decides the result.
|
|
1013
1310
|
}
|
|
1014
|
-
|
|
1311
|
+
}
|
|
1312
|
+
observe(options.initial ?? { status: BUILD_WAIT_PHASE.QUEUED });
|
|
1313
|
+
const logs = consumeLogs();
|
|
1314
|
+
try {
|
|
1315
|
+
const result = await Promise.race([poll(), expired]);
|
|
1316
|
+
let drainTimer;
|
|
1317
|
+
try {
|
|
1318
|
+
await Promise.race([logs, new Promise((resolve) => {
|
|
1319
|
+
drainTimer = setTimeout(resolve, Math.max(0, Math.min(BUILD_LOG_DRAIN_TIMEOUT_MS, deadline - clock())));
|
|
1320
|
+
})]);
|
|
1321
|
+
}
|
|
1322
|
+
finally {
|
|
1323
|
+
clearTimeout(drainTimer);
|
|
1324
|
+
}
|
|
1325
|
+
return result;
|
|
1326
|
+
}
|
|
1327
|
+
finally {
|
|
1328
|
+
clearTimeout(timer);
|
|
1329
|
+
controller.abort();
|
|
1015
1330
|
}
|
|
1016
1331
|
}
|
|
1332
|
+
/**
|
|
1333
|
+
* A release only the allocation plane serves (volumes pinned to a worker) stays
|
|
1334
|
+
* STARTING while that runner refuses its starts, and the plane retries them
|
|
1335
|
+
* forever with backoff. After this many failed attempts the wait reports the
|
|
1336
|
+
* reason instead of sitting out its whole timeout.
|
|
1337
|
+
*/
|
|
1338
|
+
const FAILED_STARTS_BEFORE_REPORTING = 3;
|
|
1017
1339
|
/** Poll until the release settles; the platform decides when it is healthy. */
|
|
1018
1340
|
async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
|
|
1019
1341
|
const deadline = Date.now() + timeoutMs;
|
|
1020
1342
|
const startedAt = Date.now();
|
|
1021
|
-
let
|
|
1343
|
+
let lastProgress = '';
|
|
1022
1344
|
const json = readJsonFlag(process.argv);
|
|
1023
1345
|
for (;;) {
|
|
1024
1346
|
const release = await getRelease(releaseId, apiOptions);
|
|
1025
|
-
|
|
1026
|
-
|
|
1347
|
+
// A failed start on the pinned runner changes the reason, not the status.
|
|
1348
|
+
const reason = release.serving ? release.failureReason ?? '' : '';
|
|
1349
|
+
if (`${release.status}\n${reason}` !== lastProgress) {
|
|
1350
|
+
lastProgress = `${release.status}\n${reason}`;
|
|
1027
1351
|
// Say what is happening in words, with elapsed time — a two-minute health
|
|
1028
1352
|
// gate in silence reads as a hang. Under --json only stdout must stay
|
|
1029
1353
|
// parseable; progress goes to stderr.
|
|
1030
1354
|
const shown = presentRelease(release);
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1355
|
+
const lines = [
|
|
1356
|
+
`— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${shown.meaning ? `: ${shown.meaning}` : ''} —`,
|
|
1357
|
+
...(reason ? [` ${reason}`] : []),
|
|
1358
|
+
];
|
|
1359
|
+
for (const line of lines) {
|
|
1360
|
+
if (json)
|
|
1361
|
+
process.stderr.write(`${line}\n`);
|
|
1362
|
+
else
|
|
1363
|
+
printInfo(dim(line));
|
|
1364
|
+
}
|
|
1036
1365
|
}
|
|
1037
1366
|
if (isSettledReleaseStatus(release.status))
|
|
1038
1367
|
return release;
|
|
1368
|
+
if ((release.serving?.failedStarts ?? 0) >= FAILED_STARTS_BEFORE_REPORTING)
|
|
1369
|
+
return release;
|
|
1039
1370
|
if (Date.now() > deadline) {
|
|
1040
1371
|
throw new CliError(`Release ${releaseId} did not settle within ${Math.round(timeoutMs / 1000)}s`, {
|
|
1041
1372
|
code: 'RELEASE_TIMEOUT',
|
|
@@ -1113,10 +1444,16 @@ export function privateEndpointLines(detail, active, options = {}) {
|
|
|
1113
1444
|
const lines = [];
|
|
1114
1445
|
if (detail.privateBaseUrl)
|
|
1115
1446
|
lines.push(detail.privateBaseUrl);
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1447
|
+
// The private proxy listens on a TCP port's container port. A container the
|
|
1448
|
+
// control plane started reports its ports on the release; a release the
|
|
1449
|
+
// allocation plane serves on a worker reports none (its endpoints live on
|
|
1450
|
+
// its allocations), so its declared TCP ports stand in.
|
|
1451
|
+
const reported = (active?.ports ?? []).filter((port) => port.protocol === 'tcp' && port.containerPort);
|
|
1452
|
+
const tcpPorts = reported.length > 0
|
|
1453
|
+
? reported.map((port) => ({ name: port.name, port: port.containerPort }))
|
|
1454
|
+
: (active?.runtime?.ports ?? []).filter((port) => port.protocol === 'tcp');
|
|
1455
|
+
for (const port of tcpPorts) {
|
|
1456
|
+
lines.push(`${detail.privateDomain}:${port.port} (${port.name})`);
|
|
1120
1457
|
}
|
|
1121
1458
|
return lines;
|
|
1122
1459
|
}
|
|
@@ -1215,6 +1552,11 @@ async function statusSubcommand(args, positionals, apiOptions) {
|
|
|
1215
1552
|
const pinnedRunner = detail.volumeRunnerId;
|
|
1216
1553
|
if (pinnedRunner)
|
|
1217
1554
|
printLine(` volume pin: runner ${pinnedRunner}`);
|
|
1555
|
+
const servingRelease = detail.releases.find((r) => r.id === detail.production?.activeReleaseId);
|
|
1556
|
+
if (servingRelease?.serving && servingRelease.serving.ready < servingRelease.serving.desired) {
|
|
1557
|
+
// Production points at it, but its replica does not answer on the runner yet.
|
|
1558
|
+
printLine(` serving: not yet — ${servingRelease.failureReason ?? 'its replica is starting on the pinned runner'}`);
|
|
1559
|
+
}
|
|
1218
1560
|
if (detail.lastActivityAt)
|
|
1219
1561
|
printLine(` activity: ${detail.lastActivityAt}`);
|
|
1220
1562
|
if (detail.lastScaledAt)
|
|
@@ -1418,6 +1760,12 @@ async function promoteSubcommand(args, positionals, apiOptions) {
|
|
|
1418
1760
|
err.message = humanizeApiError(err.message);
|
|
1419
1761
|
throw err;
|
|
1420
1762
|
}
|
|
1763
|
+
// 202: the server runs it as a rolling rollout and goes on in the
|
|
1764
|
+
// background. An older server always answers with the outcome below.
|
|
1765
|
+
if (isPromoteAccepted(result)) {
|
|
1766
|
+
await followPromoteRollout(releaseId, result, { json, wait: !hasFlag(args, '--no-wait') }, apiOptions);
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1421
1769
|
if (json) {
|
|
1422
1770
|
printLine(JSON.stringify(result, null, 2));
|
|
1423
1771
|
return;
|
|
@@ -1640,14 +1988,27 @@ function printVerificationInstructions(domain) {
|
|
|
1640
1988
|
const v = domain.verification;
|
|
1641
1989
|
if (!v)
|
|
1642
1990
|
return;
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1991
|
+
// A verified hostname keeps its records on display: they are what gets compared with the
|
|
1992
|
+
// registrar when the name stops resolving.
|
|
1993
|
+
printInfo(dim(domain.verified
|
|
1994
|
+
? ` ${domain.hostname} — verified with these records (the routing record must stay in place):`
|
|
1995
|
+
: ` ${domain.hostname} — prove ownership, then verify:`));
|
|
1996
|
+
const row = (label, name, value) => printLine(` ${label.padEnd(8)} ${name} → ${value}`);
|
|
1997
|
+
row('TXT', v.txtName, v.txtValue);
|
|
1998
|
+
// One target, not a menu: every entry is accepted by verify and leads to the same ingress. The first is the
|
|
1999
|
+
// app's own hostname when an apps suffix is configured, otherwise the platform target.
|
|
2000
|
+
if (v.cnameTargets[0])
|
|
2001
|
+
row('CNAME', domain.hostname, v.cnameTargets[0]);
|
|
1647
2002
|
for (const ip of v.ingressIps)
|
|
1648
|
-
|
|
2003
|
+
row('A', domain.hostname, ip);
|
|
1649
2004
|
if (v.ingressAliasTarget)
|
|
1650
|
-
|
|
2005
|
+
row('ALIAS', domain.hostname, v.ingressAliasTarget);
|
|
2006
|
+
if (v.apexCnameTargets?.length) {
|
|
2007
|
+
printInfo(dim(domain.verified
|
|
2008
|
+
? ' verified by the TXT record alone — where the apex points was never checked; traffic reaches the app only through ALIAS/ANAME/CNAME flattening onto:'
|
|
2009
|
+
: ' an apex domain is verified by the TXT record alone; point it with ALIAS/ANAME/CNAME flattening so traffic reaches the app:'));
|
|
2010
|
+
row('ALIAS', domain.hostname, v.apexCnameTargets[0]);
|
|
2011
|
+
}
|
|
1651
2012
|
}
|
|
1652
2013
|
/**
|
|
1653
2014
|
* `apps domain <app> [hostname] [--verify|--remove]` — bind a custom hostname
|
|
@@ -1678,8 +2039,7 @@ async function domainSubcommand(args, positionals, apiOptions) {
|
|
|
1678
2039
|
verified: d.verified ? 'yes' : 'no',
|
|
1679
2040
|
})), ['hostname', 'kind', 'source', 'verified']));
|
|
1680
2041
|
for (const d of items)
|
|
1681
|
-
|
|
1682
|
-
printVerificationInstructions(d);
|
|
2042
|
+
printVerificationInstructions(d);
|
|
1683
2043
|
return;
|
|
1684
2044
|
}
|
|
1685
2045
|
if (hasFlag(args, '--remove')) {
|
|
@@ -1704,6 +2064,14 @@ async function domainSubcommand(args, positionals, apiOptions) {
|
|
|
1704
2064
|
printLine(JSON.stringify(domain, null, 2));
|
|
1705
2065
|
return;
|
|
1706
2066
|
}
|
|
2067
|
+
if (domain.verified) {
|
|
2068
|
+
// An operator-granted platform hostname verifies on bind: no records to publish.
|
|
2069
|
+
printSuccess(`${domain.hostname} bound to ${app.name} and verified — it now serves ${app.name}`);
|
|
2070
|
+
if (!domain.activeReleaseId) {
|
|
2071
|
+
printInfo(dim(' no release is promoted yet: inquir apps promote <release>'));
|
|
2072
|
+
}
|
|
2073
|
+
return;
|
|
2074
|
+
}
|
|
1707
2075
|
printSuccess(`${domain.hostname} bound to ${app.name} (unverified)`);
|
|
1708
2076
|
printVerificationInstructions(domain);
|
|
1709
2077
|
printInfo(dim(` then: inquir apps domain ${app.name} ${domain.hostname} --verify`));
|