@inquir/compute-cli 0.4.0 → 0.4.1
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 +632 -619
- package/dist/commands/admin.js +2 -2
- package/dist/commands/admin.js.map +1 -1
- package/dist/commands/apps-describe.d.ts.map +1 -1
- package/dist/commands/apps-describe.js +8 -1
- package/dist/commands/apps-describe.js.map +1 -1
- package/dist/commands/apps-instances.d.ts +18 -0
- package/dist/commands/apps-instances.d.ts.map +1 -0
- package/dist/commands/apps-instances.js +87 -0
- package/dist/commands/apps-instances.js.map +1 -0
- package/dist/commands/apps-observability.d.ts +6 -0
- package/dist/commands/apps-observability.d.ts.map +1 -1
- package/dist/commands/apps-observability.js +38 -9
- package/dist/commands/apps-observability.js.map +1 -1
- package/dist/commands/apps.d.ts +95 -2
- package/dist/commands/apps.d.ts.map +1 -1
- package/dist/commands/apps.js +572 -51
- package/dist/commands/apps.js.map +1 -1
- package/dist/commands/deploy.js +3 -3
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/init.js +15 -15
- package/dist/utils/api.d.ts +22 -6
- package/dist/utils/api.d.ts.map +1 -1
- package/dist/utils/api.js +22 -3
- package/dist/utils/api.js.map +1 -1
- package/dist/utils/apps-presentation.d.ts +7 -0
- package/dist/utils/apps-presentation.d.ts.map +1 -1
- package/dist/utils/apps-presentation.js +25 -1
- package/dist/utils/apps-presentation.js.map +1 -1
- package/dist/utils/errors.d.ts.map +1 -1
- package/dist/utils/errors.js +41 -0
- package/dist/utils/errors.js.map +1 -1
- package/dist/utils/msys-path.d.ts +14 -0
- package/dist/utils/msys-path.d.ts.map +1 -0
- package/dist/utils/msys-path.js +55 -0
- package/dist/utils/msys-path.js.map +1 -0
- package/dist/utils/package-metadata.js +1 -1
- package/dist/utils/source-pack.d.ts +24 -0
- package/dist/utils/source-pack.d.ts.map +1 -0
- package/dist/utils/source-pack.js +63 -0
- package/dist/utils/source-pack.js.map +1 -0
- package/package.json +34 -34
- package/skill/SKILL.md +73 -73
package/dist/commands/apps.js
CHANGED
|
@@ -2,17 +2,20 @@ import path from 'node:path';
|
|
|
2
2
|
import { alertsSubcommand, logHistorySubcommand, metricsSubcommand, OBSERVATION_HELP, OBSERVATION_VALUE_FLAGS } from './apps-observability.js';
|
|
3
3
|
import { RESOURCES_HELP, RESOURCES_VALUE_FLAGS, resourcesSubcommand } from './apps-resources.js';
|
|
4
4
|
import { DESCRIBE_HELP, describeSubcommand } from './apps-describe.js';
|
|
5
|
+
import { INSTANCES_HELP, instancesSubcommand, resolveReplicaAllocationId } from './apps-instances.js';
|
|
5
6
|
import { followPromoteRollout } from './apps-promote.js';
|
|
6
7
|
import { hasFlag, readFlagValue, readJsonFlag, readLimitFlag, readMultiFlag, readPositionalArgs, } from '../utils/args.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,
|
|
9
|
-
import { formatBytes, formatElapsed, humanizeApiError, permanentAddress, presentRelease, } from '../utils/apps-presentation.js';
|
|
8
|
+
import { listAppBackups, getAppBackup, createAppBackup, deleteAppBackup, restoreAppBackup, getAppConnectionUrl, connectApps, createApiClient, addApplicationDomain, archiveApplication, cancelBuild, createApplication, getBuild, listApplicationDomains, listBuilds, queueBuild, removeApplicationDomain, createRelease, redeployApplication, execInApplication, getAppLimits, getAppLocation, getApplication, getProject, getProductionReplacement, getRelease, listApplications, promoteRelease, isPromoteAccepted, requireAuth, resolveApplication, rollbackApplication, stopRelease, streamBuildLogs, streamReleaseLogs, updateApplication, wakeApplication, pauseApplication, verifyApplicationDomain, } from '../utils/api.js';
|
|
9
|
+
import { ApiError, DEFAULT_BUILD_TIMEOUT_POLICY, getAppLogHistory, isSettledReleaseStatus, isTerminalBuildStatus } from '../sdk.js';
|
|
10
|
+
import { formatBytes, formatElapsed, humanizeApiError, permanentAddress, presentRelease, replacementPhaseMeaning, } from '../utils/apps-presentation.js';
|
|
10
11
|
import { buildApiOptions, runtime } from '../utils/context.js';
|
|
11
12
|
import { confirm } from '../utils/prompt.js';
|
|
12
13
|
import { resolveCreateProject } from '../utils/project-context.js';
|
|
13
14
|
import { CliError, ExitCode, toCliError, usageError } from '../utils/errors.js';
|
|
14
15
|
import { redact, registerSecret } from '../utils/redact.js';
|
|
15
|
-
import { dim, formatTable, printCommandHelp, printError, printInfo, printLine, printSuccess } from '../utils/output.js';
|
|
16
|
+
import { dim, formatTable, printCommandHelp, printError, printInfo, printLine, printSuccess, printWarning } from '../utils/output.js';
|
|
17
|
+
import { containerPathArgument, MSYS_PATH_HINT } from '../utils/msys-path.js';
|
|
18
|
+
import { packSourceForUpload } from '../utils/source-pack.js';
|
|
16
19
|
/** Value flags across every subcommand — readPositionalArgs must know them all. */
|
|
17
20
|
const VALUE_FLAGS = [
|
|
18
21
|
...OBSERVATION_VALUE_FLAGS,
|
|
@@ -52,6 +55,8 @@ const VALUE_FLAGS = [
|
|
|
52
55
|
'--stabilization',
|
|
53
56
|
'--scale-down-stabilization',
|
|
54
57
|
'--replicas',
|
|
58
|
+
'--node-selector',
|
|
59
|
+
'--location',
|
|
55
60
|
];
|
|
56
61
|
export async function appsCommand(args) {
|
|
57
62
|
const [subcommand, ...rest] = readPositionalArgs(args, { valueFlags: VALUE_FLAGS });
|
|
@@ -96,6 +101,8 @@ export async function appsCommand(args) {
|
|
|
96
101
|
return resourcesSubcommand(args, rest, apiOptions);
|
|
97
102
|
case 'describe':
|
|
98
103
|
return describeSubcommand(args, rest, apiOptions);
|
|
104
|
+
case 'instances':
|
|
105
|
+
return instancesSubcommand(args, rest, apiOptions);
|
|
99
106
|
case 'promote':
|
|
100
107
|
return promoteSubcommand(args, rest, apiOptions);
|
|
101
108
|
case 'rollback':
|
|
@@ -144,7 +151,8 @@ function printAppsHelp() {
|
|
|
144
151
|
'builds <app> List image builds',
|
|
145
152
|
'status <app> Endpoints and recent releases',
|
|
146
153
|
'describe <app> Why it is (not) running: conditions, placement, events',
|
|
147
|
-
'
|
|
154
|
+
'instances <app> Replicas (like pods): name, state, runner, CPU/RAM now',
|
|
155
|
+
'logs <app|release|build> Recent container (or build) logs, then follow (--no-follow exits)',
|
|
148
156
|
'log-history <app> Persisted runtime or HTTP logs with time/replica filters',
|
|
149
157
|
'metrics <app> CPU/RAM, network, disk I/O or HTTP history (--json)',
|
|
150
158
|
'alerts <app> [action] Configure resource/deployment alerts and read incidents',
|
|
@@ -152,8 +160,8 @@ function printAppsHelp() {
|
|
|
152
160
|
'promote <release> Send production traffic to a release',
|
|
153
161
|
'rollback <app> Return production to the previous release',
|
|
154
162
|
'stop <release> Stop a release that is not serving production',
|
|
155
|
-
'exec <
|
|
156
|
-
'update <app> Change env vars, ports, resources, egress',
|
|
163
|
+
'exec <app> [--replica <r>] -- <cmd> Run a command in production (one replica: name or index)',
|
|
164
|
+
'update <app> Change env vars, ports, resources, egress, node selector',
|
|
157
165
|
'scale <app> [mode] Set always/http/manual scaling (--replicas for always)',
|
|
158
166
|
'wake <app> Start a sleeping application',
|
|
159
167
|
'pause <app> Stop an http/manual app (HTTP wakes only http mode)',
|
|
@@ -164,7 +172,7 @@ function printAppsHelp() {
|
|
|
164
172
|
'--project <project> create: this project by id, slug or unique name (overrides projects use)',
|
|
165
173
|
'--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
|
|
166
174
|
'--promote deploy: take production by itself once healthy (needs apps:promote)',
|
|
167
|
-
'--template <name> postgres | redis: volume,
|
|
175
|
+
'--template <name> postgres | redis: volume, private TCP endpoint, generated password, first release',
|
|
168
176
|
'--dir <path> Source directory to build (default: current directory)',
|
|
169
177
|
'--dockerfile <path> Dockerfile relative to the context (default: Dockerfile)',
|
|
170
178
|
'--context <path> Build context inside the source tree (default: .)',
|
|
@@ -174,6 +182,10 @@ function printAppsHelp() {
|
|
|
174
182
|
'--tcp <name:port[:public]> Extra TCP port; endpoint port is platform-managed (repeatable)',
|
|
175
183
|
'--volume <name:/path[:ro]> Persistent volume mount (repeatable)',
|
|
176
184
|
'--volume-runner <id> Pin volumes (and the container) to a worker runner',
|
|
185
|
+
'--node-selector KEY=VALUE update: run only on servers with this node label (repeatable),',
|
|
186
|
+
' e.g. inquir.io/region=eu; --clear-node-selector removes it',
|
|
187
|
+
'--location <id|auto> update (experimental): run in this location, e.g. nl or ru; auto',
|
|
188
|
+
' runs wherever there is capacity. Applies from the next deploy',
|
|
177
189
|
'--set KEY=VALUE Environment variable (repeatable)',
|
|
178
190
|
'--memory <mb> Memory ceiling (default 512)',
|
|
179
191
|
'--cpu <vcpus> CPU ceiling (default 0.5)',
|
|
@@ -235,6 +247,77 @@ export function parseTcpPortFlag(value) {
|
|
|
235
247
|
}
|
|
236
248
|
return spec;
|
|
237
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* `--location <id|auto>`: the service's location, verbatim; the server says
|
|
252
|
+
* which ids it offers. Undefined when the flag is absent.
|
|
253
|
+
*/
|
|
254
|
+
export function parseLocationFlag(value) {
|
|
255
|
+
if (value === undefined)
|
|
256
|
+
return undefined;
|
|
257
|
+
const location = value.trim().toLowerCase();
|
|
258
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(location)) {
|
|
259
|
+
throw usageError(`Invalid --location value '${value}'`, 'inquir apps update <app> --location nl (or --location auto)');
|
|
260
|
+
}
|
|
261
|
+
return location;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The status line of a service's location: its setting by name, what
|
|
265
|
+
* production runs with while a change waits for the next deploy, and where
|
|
266
|
+
* the replicas run. Undefined while the platform offers no location choice.
|
|
267
|
+
*/
|
|
268
|
+
export function describeServiceLocation(status) {
|
|
269
|
+
if (!status.enabled)
|
|
270
|
+
return undefined;
|
|
271
|
+
const name = (id) => (id === 'auto' ? 'Auto' : status.locations.find((entry) => entry.id === id)?.name ?? id);
|
|
272
|
+
const parts = [status.setting === 'auto' ? 'Auto (wherever there is capacity)' : name(status.setting)];
|
|
273
|
+
if (status.pending && status.applied !== null) {
|
|
274
|
+
parts.push(`applies on the next deploy (production runs ${status.applied === 'auto' ? 'without a location' : `in ${name(status.applied)}`})`);
|
|
275
|
+
}
|
|
276
|
+
const running = [...new Set(status.replicas.map((replica) => (replica.location ? name(replica.location) : 'a server without a location')))];
|
|
277
|
+
if (running.length > 0)
|
|
278
|
+
parts.push(`running in ${running.join(', ')}`);
|
|
279
|
+
return parts.join(' — ');
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* `--node-selector KEY=VALUE` (repeatable): the node labels a server must
|
|
283
|
+
* carry, verbatim (`inquir.io/region=eu`, `tier=fast`). The server validates
|
|
284
|
+
* keys and values and says which keys it exposes.
|
|
285
|
+
*/
|
|
286
|
+
export function parseNodeSelectorFlags(values) {
|
|
287
|
+
const selector = {};
|
|
288
|
+
for (const entry of values) {
|
|
289
|
+
const index = entry.indexOf('=');
|
|
290
|
+
const key = index > 0 ? entry.slice(0, index).trim() : '';
|
|
291
|
+
if (!key) {
|
|
292
|
+
throw usageError(`Invalid --node-selector value '${entry}'`, 'inquir apps update <app> --node-selector inquir.io/region=eu');
|
|
293
|
+
}
|
|
294
|
+
if (Object.hasOwn(selector, key)) {
|
|
295
|
+
throw usageError(`--node-selector repeats the key '${key}'`, 'inquir apps update <app> --node-selector inquir.io/region=eu');
|
|
296
|
+
}
|
|
297
|
+
selector[key] = entry.slice(index + 1).trim();
|
|
298
|
+
}
|
|
299
|
+
return selector;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* The runtime defaults with `--node-selector` / `--clear-node-selector`
|
|
303
|
+
* applied over `base` (the flags' runtime, else the stored one); undefined
|
|
304
|
+
* when neither flag is given.
|
|
305
|
+
*/
|
|
306
|
+
export function applyNodeSelectorFlags(args, base) {
|
|
307
|
+
const values = readMultiFlag(args, '--node-selector');
|
|
308
|
+
const clear = hasFlag(args, '--clear-node-selector');
|
|
309
|
+
if (values.length === 0 && !clear)
|
|
310
|
+
return undefined;
|
|
311
|
+
if (values.length > 0 && clear) {
|
|
312
|
+
throw usageError('Use either --node-selector or --clear-node-selector', 'inquir apps update <app> --clear-node-selector');
|
|
313
|
+
}
|
|
314
|
+
if (!base) {
|
|
315
|
+
throw usageError('This application has no runtime defaults to place; set its port first', 'inquir apps update <app> --port 3000 --node-selector inquir.io/region=eu');
|
|
316
|
+
}
|
|
317
|
+
const rest = { ...base };
|
|
318
|
+
delete rest.nodeSelector;
|
|
319
|
+
return clear ? rest : { ...rest, nodeSelector: parseNodeSelectorFlags(values) };
|
|
320
|
+
}
|
|
238
321
|
export function parseSetFlags(values) {
|
|
239
322
|
const env = {};
|
|
240
323
|
for (const entry of values) {
|
|
@@ -340,7 +423,7 @@ export function parseVolumeFlag(value) {
|
|
|
340
423
|
let mountPath = separator > 0 ? value.slice(separator + 1) : '';
|
|
341
424
|
let readOnly = false;
|
|
342
425
|
if (/^[A-Za-z]:[\\/]/.test(mountPath)) {
|
|
343
|
-
throw usageError(`Invalid --volume mount path '${mountPath}'`,
|
|
426
|
+
throw usageError(`Invalid --volume mount path '${mountPath}'`, `Volume targets are absolute paths inside the Linux container, for example data:/var/lib/data. ${MSYS_PATH_HINT}`);
|
|
344
427
|
}
|
|
345
428
|
const modifierAt = mountPath.lastIndexOf(':');
|
|
346
429
|
if (modifierAt >= 0) {
|
|
@@ -419,7 +502,12 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
|
419
502
|
const memory = readFlagValue(args, '--memory');
|
|
420
503
|
const cpu = readFlagValue(args, '--cpu');
|
|
421
504
|
const drainGraceRaw = readFlagValue(args, '--drain-grace');
|
|
422
|
-
const
|
|
505
|
+
const healthPathRaw = readFlagValue(args, '--health-path');
|
|
506
|
+
const healthPathArgument = healthPathRaw !== undefined ? containerPathArgument(healthPathRaw, '--health-path') : undefined;
|
|
507
|
+
if (healthPathArgument?.recoveredFrom) {
|
|
508
|
+
printWarning(`--health-path: read '${healthPathArgument.recoveredFrom}' as ${healthPathArgument.value} (Git Bash path conversion). ${MSYS_PATH_HINT}`);
|
|
509
|
+
}
|
|
510
|
+
const healthPath = healthPathArgument?.value;
|
|
423
511
|
const healthCommand = readFlagValue(args, '--health-command');
|
|
424
512
|
const ports = tcpValues.map(parseTcpPortFlag);
|
|
425
513
|
const volumes = volumeValues.map(parseVolumeFlag);
|
|
@@ -589,15 +677,117 @@ async function landingProjectLines(app, requestedProject, apiOptions) {
|
|
|
589
677
|
}
|
|
590
678
|
const TEMPLATE_NAMES = ['postgres', 'redis'];
|
|
591
679
|
/** `apps <sub> --help` answered per subcommand; the global page is the fallback. */
|
|
592
|
-
const SUBCOMMAND_HELP = {
|
|
680
|
+
export const SUBCOMMAND_HELP = {
|
|
593
681
|
...OBSERVATION_HELP,
|
|
594
682
|
...RESOURCES_HELP,
|
|
595
683
|
...DESCRIBE_HELP,
|
|
684
|
+
...INSTANCES_HELP,
|
|
596
685
|
logs: [
|
|
597
|
-
'Usage: inquir apps logs <app|release|build> [--tail <n>]',
|
|
598
|
-
'
|
|
686
|
+
'Usage: inquir apps logs <app|release|build> [--tail <n>] [--no-follow] [--replica <name|index|allocation>]',
|
|
687
|
+
' Prints the last --tail lines (default 200, at most 2000), then follows new',
|
|
688
|
+
' ones until Ctrl-C.',
|
|
689
|
+
' --no-follow Print the last --tail lines and exit (for scripts and agents).',
|
|
690
|
+
' --replica Only one replica\'s lines (see apps instances <app>).',
|
|
599
691
|
' --history selects persisted history instead; see apps log-history --help.',
|
|
600
692
|
],
|
|
693
|
+
exec: [
|
|
694
|
+
'Usage: inquir apps exec <app> [--replica <name|index|allocation>] [--timeout <ms>] -- <command>',
|
|
695
|
+
' Runs one audited command in the production container. <app> is the',
|
|
696
|
+
' application name or id; the id of its production release works too.',
|
|
697
|
+
' --replica One serving replica (see apps instances <app>); default: the projected one.',
|
|
698
|
+
],
|
|
699
|
+
list: [
|
|
700
|
+
'Usage: inquir apps list [--json]',
|
|
701
|
+
'',
|
|
702
|
+
' The workspace\'s applications with their production status.',
|
|
703
|
+
],
|
|
704
|
+
deploy: [
|
|
705
|
+
'Usage: inquir apps deploy <app> [--image <ref> | --build <buildId> | --dir <path>] [--promote] [--no-wait]',
|
|
706
|
+
'',
|
|
707
|
+
' Without --image or --build, packs the source directory (default: the',
|
|
708
|
+
' current one), builds its Dockerfile and releases the image as a preview.',
|
|
709
|
+
' The upload honours .dockerignore and .inquirignore and never contains',
|
|
710
|
+
' .env files, private keys or literal registry tokens.',
|
|
711
|
+
' --image <ref> Release a container image instead of building',
|
|
712
|
+
' --build <buildId> Release an existing successful build',
|
|
713
|
+
' --dir <path> Source directory (default: current directory)',
|
|
714
|
+
' --dockerfile, --context, --target, --build-arg KEY=VALUE Build options',
|
|
715
|
+
' --promote Take production by itself once healthy (needs apps:promote)',
|
|
716
|
+
' --no-wait Return as soon as the release is queued',
|
|
717
|
+
' Runtime flags (--port, --tcp, --volume, --set, --memory, --cpu, ...) as in apps --help.',
|
|
718
|
+
],
|
|
719
|
+
status: [
|
|
720
|
+
'Usage: inquir apps status <app> [--json]',
|
|
721
|
+
'',
|
|
722
|
+
' Production, endpoints (public URL, private address, TCP ports), volumes',
|
|
723
|
+
' and recent releases.',
|
|
724
|
+
],
|
|
725
|
+
rollback: [
|
|
726
|
+
'Usage: inquir apps rollback <app> [--json]',
|
|
727
|
+
'',
|
|
728
|
+
' Returns production to the previous release: instantly while its container',
|
|
729
|
+
' still runs, otherwise after it restarts and passes its health check.',
|
|
730
|
+
],
|
|
731
|
+
stop: [
|
|
732
|
+
'Usage: inquir apps stop <release>',
|
|
733
|
+
'',
|
|
734
|
+
' Stops a release that is not serving production; its container and preview',
|
|
735
|
+
' URL go away.',
|
|
736
|
+
],
|
|
737
|
+
build: [
|
|
738
|
+
'Usage: inquir apps build <app> [--dir <path>] [--dockerfile <path>] [--context <path>] [--target <stage>] [--build-arg KEY=VALUE]',
|
|
739
|
+
'',
|
|
740
|
+
' Builds an image from source without releasing it (release it later with',
|
|
741
|
+
' apps deploy <app> --build <buildId>). Uploads like apps deploy.',
|
|
742
|
+
],
|
|
743
|
+
builds: [
|
|
744
|
+
'Usage: inquir apps builds <app> [--cancel <buildId>] [--json]',
|
|
745
|
+
'',
|
|
746
|
+
' Lists the application\'s image builds; --cancel stops a queued or running one.',
|
|
747
|
+
],
|
|
748
|
+
update: [
|
|
749
|
+
'Usage: inquir apps update <app> [--set KEY=VALUE] [--port <n>] [--memory <mb>] [--cpu <vcpus>] [--ingress private|public] [flags]',
|
|
750
|
+
'',
|
|
751
|
+
' Applied now: --ingress (the generated public domain is created or removed',
|
|
752
|
+
' at once) and --description.',
|
|
753
|
+
' Applied by the next release (inquir apps redeploy <app>): --set, --port,',
|
|
754
|
+
' --tcp, --volume, --memory, --cpu, --drain-grace, --health-path,',
|
|
755
|
+
' --health-command, --egress, --location, --node-selector / --clear-node-selector.',
|
|
756
|
+
],
|
|
757
|
+
scale: [
|
|
758
|
+
'Usage: inquir apps scale <app> <always|http|manual> [--replicas <n>] [--idle-timeout <d>] [--activation-timeout <d>] [--stabilization <d>]',
|
|
759
|
+
'',
|
|
760
|
+
' always Keep --replicas replicas running (1..8)',
|
|
761
|
+
' http Sleep after --idle-timeout without requests (minimum 5m); a request wakes it',
|
|
762
|
+
' manual Run until paused; only wake starts it again',
|
|
763
|
+
' Durations: 90s, 15m, 1h30m. Takes effect now.',
|
|
764
|
+
'',
|
|
765
|
+
'Examples:',
|
|
766
|
+
' inquir apps scale web always --replicas 2',
|
|
767
|
+
' inquir apps scale web http --idle-timeout 15m',
|
|
768
|
+
],
|
|
769
|
+
wake: [
|
|
770
|
+
'Usage: inquir apps wake <app> [--json]',
|
|
771
|
+
'',
|
|
772
|
+
' Starts a sleeping (http) or paused (manual) application.',
|
|
773
|
+
],
|
|
774
|
+
pause: [
|
|
775
|
+
'Usage: inquir apps pause <app> [--json]',
|
|
776
|
+
'',
|
|
777
|
+
' Stops an http or manual application; an http one wakes on the next request.',
|
|
778
|
+
],
|
|
779
|
+
domain: [
|
|
780
|
+
'Usage: inquir apps domain <app> [hostname] [--verify | --remove] [--json]',
|
|
781
|
+
'',
|
|
782
|
+
' Without a hostname, lists the bound domains. With one, binds it and prints',
|
|
783
|
+
' the DNS records to create; --verify checks them, --remove unbinds it.',
|
|
784
|
+
],
|
|
785
|
+
delete: [
|
|
786
|
+
'Usage: inquir apps delete <app>',
|
|
787
|
+
'',
|
|
788
|
+
' Archives the application and stops every release. Refused while another',
|
|
789
|
+
' application depends on it (apps connect).',
|
|
790
|
+
],
|
|
601
791
|
promote: [
|
|
602
792
|
'Usage: inquir apps promote <release> [--no-wait] [--json]',
|
|
603
793
|
'',
|
|
@@ -625,9 +815,10 @@ const SUBCOMMAND_HELP = {
|
|
|
625
815
|
' --project <project> Add to this project (id, slug or unique name). Otherwise',
|
|
626
816
|
' uses projects use; without a selection, creates a project',
|
|
627
817
|
' named after the app (group it later: inquir projects move).',
|
|
628
|
-
' --template <name> postgres | redis: volume,
|
|
629
|
-
'
|
|
630
|
-
'
|
|
818
|
+
' --template <name> postgres | redis: a volume, a private TCP port',
|
|
819
|
+
' (<slug>.apps.internal, reachable from your other',
|
|
820
|
+
' services), a generated password and the first release —',
|
|
821
|
+
' one command from nothing to a running database.',
|
|
631
822
|
' --volume-runner <id> Pin volumes + container to a worker runner (id or',
|
|
632
823
|
' unique prefix); the data lives on that machine. With',
|
|
633
824
|
' --template the worker runs the operator\'s digest-pinned',
|
|
@@ -677,8 +868,55 @@ const SUBCOMMAND_HELP = {
|
|
|
677
868
|
],
|
|
678
869
|
};
|
|
679
870
|
const BACKUPS_USAGE = 'inquir apps backups <app> [list|create|restore <backupId>|delete <backupId>]';
|
|
871
|
+
/**
|
|
872
|
+
* "2/3 ~10-08 14:00": the row's place among its volume's backups of the same
|
|
873
|
+
* kind, and roughly when it goes; "next cleanup" once past the limit.
|
|
874
|
+
*/
|
|
875
|
+
export function formatBackupKeeps(backup) {
|
|
876
|
+
const retention = backup.retention;
|
|
877
|
+
if (!retention)
|
|
878
|
+
return '';
|
|
879
|
+
const place = `${retention.position}/${retention.limit}`;
|
|
880
|
+
if (!retention.kept)
|
|
881
|
+
return `${place} next cleanup`;
|
|
882
|
+
if (!retention.expiresAt)
|
|
883
|
+
return place;
|
|
884
|
+
return `${place} ~${retention.expiresAt.slice(5, 16).replace('T', ' ')}`;
|
|
885
|
+
}
|
|
886
|
+
/** One line on what the workspace's backup policy keeps. */
|
|
887
|
+
export function formatBackupPolicy(policy) {
|
|
888
|
+
const owner = policy.source === 'override' ? 'This workspace' : `Plan ${policy.planCode}`;
|
|
889
|
+
const every = policy.frequencyHours === 24 ? 'daily' : `every ${policy.frequencyHours}h`;
|
|
890
|
+
const automatic = policy.automatic
|
|
891
|
+
? `the last ${policy.retainCount} automatic backup(s) (${every})`
|
|
892
|
+
: 'no automatic backups';
|
|
893
|
+
let line = `${owner} keeps ${automatic} and ${policy.maxManualBackups} manual backup(s) per volume`;
|
|
894
|
+
if (policy.grace)
|
|
895
|
+
line += `; after a plan change, up to ${policy.grace.retainCount} are kept until ${policy.grace.until.slice(0, 10)}`;
|
|
896
|
+
if (!policy.automaticAvailable && policy.automaticUnavailableReason === 'volume_on_worker') {
|
|
897
|
+
line += '; this app\'s volumes live on a worker, where the platform cannot back them up yet';
|
|
898
|
+
}
|
|
899
|
+
return line;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* An empty backup list. A volume on a worker runner cannot be snapshotted by
|
|
903
|
+
* the platform yet, and `create` is refused there too: say so instead of
|
|
904
|
+
* suggesting it.
|
|
905
|
+
*/
|
|
906
|
+
export function emptyBackupsLines(appName, policy) {
|
|
907
|
+
if (policy && !policy.automaticAvailable && policy.automaticUnavailableReason === 'volume_on_worker') {
|
|
908
|
+
return [
|
|
909
|
+
`No backups for ${appName}: its volume lives on a worker runner, where the platform cannot back up volumes yet`,
|
|
910
|
+
' (automatic or on demand; `backups create` is refused there too). Runner-side backups are in progress.',
|
|
911
|
+
' Until then, see "Backups before risky changes" in the Node.js + Postgres tutorial:',
|
|
912
|
+
' https://inquir.org/docs/node-postgres',
|
|
913
|
+
];
|
|
914
|
+
}
|
|
915
|
+
return [`No backups for ${appName} yet — create one: inquir apps backups ${appName} create`];
|
|
916
|
+
}
|
|
680
917
|
/** The one backup table: `create` prints it too, once the rows settle. */
|
|
681
918
|
function renderBackupTable(items) {
|
|
919
|
+
const keeps = items.some((b) => b.retention);
|
|
682
920
|
printLine(formatTable(items.map((b) => ({
|
|
683
921
|
id: b.id.slice(0, 8),
|
|
684
922
|
volume: b.volumeName,
|
|
@@ -686,7 +924,10 @@ function renderBackupTable(items) {
|
|
|
686
924
|
trigger: b.trigger,
|
|
687
925
|
size: formatBytes(b.sizeBytes ?? b.artifactSizeBytes),
|
|
688
926
|
created: b.createdAt.replace('T', ' ').slice(0, 19),
|
|
689
|
-
|
|
927
|
+
keeps: formatBackupKeeps(b),
|
|
928
|
+
})), keeps
|
|
929
|
+
? ['id', 'volume', 'status', 'trigger', 'size', 'created', 'keeps']
|
|
930
|
+
: ['id', 'volume', 'status', 'trigger', 'size', 'created']));
|
|
690
931
|
}
|
|
691
932
|
/** Accept a short id, the way the release commands do. */
|
|
692
933
|
async function resolveBackup(applicationId, needle, identifier, apiOptions) {
|
|
@@ -758,9 +999,12 @@ async function backupsSubcommand(args, positionals, apiOptions) {
|
|
|
758
999
|
if (json)
|
|
759
1000
|
return printLine(JSON.stringify(page, null, 2));
|
|
760
1001
|
if (page.items.length === 0) {
|
|
761
|
-
|
|
1002
|
+
for (const line of emptyBackupsLines(app.name, page.policy))
|
|
1003
|
+
printInfo(line);
|
|
762
1004
|
return;
|
|
763
1005
|
}
|
|
1006
|
+
if (page.policy)
|
|
1007
|
+
printInfo(dim(formatBackupPolicy(page.policy)));
|
|
764
1008
|
renderBackupTable(page.items);
|
|
765
1009
|
if (page.nextCursor) {
|
|
766
1010
|
printInfo(dim(` older: inquir apps backups ${app.name} list --cursor ${page.nextCursor}`));
|
|
@@ -1154,7 +1398,11 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
|
|
|
1154
1398
|
const buildArgs = parseSetFlags(readMultiFlag(args, '--build-arg'));
|
|
1155
1399
|
if (!json)
|
|
1156
1400
|
printInfo(dim(`— packing ${dir} —`));
|
|
1157
|
-
const sourceZipBase64 = await
|
|
1401
|
+
const sourceZipBase64 = await packSourceForUpload(dir, {
|
|
1402
|
+
json,
|
|
1403
|
+
...(readFlagValue(args, '--context') ? { context: readFlagValue(args, '--context') } : {}),
|
|
1404
|
+
...(readFlagValue(args, '--dockerfile') ? { dockerfile: readFlagValue(args, '--dockerfile') } : {}),
|
|
1405
|
+
});
|
|
1158
1406
|
const queued = await queueBuild(applicationId, {
|
|
1159
1407
|
sourceZipBase64,
|
|
1160
1408
|
source: 'cli',
|
|
@@ -1336,24 +1584,64 @@ export async function waitForBuild(buildId, apiOptions, options = {}) {
|
|
|
1336
1584
|
* reason instead of sitting out its whole timeout.
|
|
1337
1585
|
*/
|
|
1338
1586
|
const FAILED_STARTS_BEFORE_REPORTING = 3;
|
|
1587
|
+
/** Every how many release polls a running replacement's phase is re-read. */
|
|
1588
|
+
const REPLACEMENT_PROBE_EVERY = 5;
|
|
1589
|
+
/**
|
|
1590
|
+
* The phase of the stop-first production replacement a PENDING release is
|
|
1591
|
+
* the target of. Bounded: the operation is read on the first PENDING poll
|
|
1592
|
+
* and then every REPLACEMENT_PROBE_EVERY polls, and never again once a read
|
|
1593
|
+
* fails or names no replacement for this release (a preview, an older
|
|
1594
|
+
* server), so an ordinary deploy costs one extra request.
|
|
1595
|
+
*/
|
|
1596
|
+
export function createReplacementProbe(read, every = REPLACEMENT_PROBE_EVERY) {
|
|
1597
|
+
let polls = 0;
|
|
1598
|
+
let done = false;
|
|
1599
|
+
let phase = '';
|
|
1600
|
+
return {
|
|
1601
|
+
async phaseOf(release) {
|
|
1602
|
+
if (done)
|
|
1603
|
+
return '';
|
|
1604
|
+
polls += 1;
|
|
1605
|
+
if ((polls - 1) % every !== 0)
|
|
1606
|
+
return phase;
|
|
1607
|
+
const operation = await read(release.applicationId).catch(() => null);
|
|
1608
|
+
if (!operation || operation.targetReleaseId !== release.id) {
|
|
1609
|
+
done = true;
|
|
1610
|
+
phase = '';
|
|
1611
|
+
return '';
|
|
1612
|
+
}
|
|
1613
|
+
phase = operation.phase;
|
|
1614
|
+
return phase;
|
|
1615
|
+
},
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1339
1618
|
/** Poll until the release settles; the platform decides when it is healthy. */
|
|
1340
1619
|
async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
|
|
1341
1620
|
const deadline = Date.now() + timeoutMs;
|
|
1342
1621
|
const startedAt = Date.now();
|
|
1343
1622
|
let lastProgress = '';
|
|
1344
1623
|
const json = readJsonFlag(process.argv);
|
|
1624
|
+
let replacementClient;
|
|
1625
|
+
const replacement = createReplacementProbe(async (applicationId) => {
|
|
1626
|
+
replacementClient ??= await createApiClient(apiOptions);
|
|
1627
|
+
return getProductionReplacement(applicationId, apiOptions, replacementClient);
|
|
1628
|
+
});
|
|
1345
1629
|
for (;;) {
|
|
1346
1630
|
const release = await getRelease(releaseId, apiOptions);
|
|
1347
1631
|
// A failed start on the pinned runner changes the reason, not the status.
|
|
1348
1632
|
const reason = release.serving ? release.failureReason ?? '' : '';
|
|
1349
|
-
|
|
1350
|
-
|
|
1633
|
+
// PENDING is also the whole of a stop-first replacement of production,
|
|
1634
|
+
// which pulls nothing: its phase says what the wait is.
|
|
1635
|
+
const phase = release.status === 'PENDING' ? await replacement.phaseOf(release) : '';
|
|
1636
|
+
if (`${release.status}\n${reason}\n${phase}` !== lastProgress) {
|
|
1637
|
+
lastProgress = `${release.status}\n${reason}\n${phase}`;
|
|
1351
1638
|
// Say what is happening in words, with elapsed time — a two-minute health
|
|
1352
1639
|
// gate in silence reads as a hang. Under --json only stdout must stay
|
|
1353
1640
|
// parseable; progress goes to stderr.
|
|
1354
1641
|
const shown = presentRelease(release);
|
|
1642
|
+
const meaning = (phase && replacementPhaseMeaning(phase)) || shown.meaning;
|
|
1355
1643
|
const lines = [
|
|
1356
|
-
`— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${
|
|
1644
|
+
`— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${meaning ? `: ${meaning}` : ''} —`,
|
|
1357
1645
|
...(reason ? [` ${reason}`] : []),
|
|
1358
1646
|
];
|
|
1359
1647
|
for (const line of lines) {
|
|
@@ -1437,9 +1725,16 @@ export function releaseEndpointLines(release, previewUrl, options = {}) {
|
|
|
1437
1725
|
*/
|
|
1438
1726
|
export function privateEndpointLines(detail, active, options = {}) {
|
|
1439
1727
|
if (!detail.privateDomain) {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1728
|
+
if (!options.privateNetworking)
|
|
1729
|
+
return [];
|
|
1730
|
+
// The address routes to production, so without one there is nothing to
|
|
1731
|
+
// verify yet, and with no serving replica it is down, not provisioning.
|
|
1732
|
+
if (!active)
|
|
1733
|
+
return ['(none yet — production gets one once a release is promoted)'];
|
|
1734
|
+
if (active.serving && active.serving.ready === 0) {
|
|
1735
|
+
return ['(unavailable — no production replica is serving; see inquir apps describe <app>)'];
|
|
1736
|
+
}
|
|
1737
|
+
return ['(provisioning — private DNS and routing are being verified)'];
|
|
1443
1738
|
}
|
|
1444
1739
|
const lines = [];
|
|
1445
1740
|
if (detail.privateBaseUrl)
|
|
@@ -1517,6 +1812,18 @@ function formatDurationSeconds(seconds) {
|
|
|
1517
1812
|
}
|
|
1518
1813
|
return parts.join(' ');
|
|
1519
1814
|
}
|
|
1815
|
+
/** One line for a startup probe; defaults as the server applies them (src/apps/startup-probe.ts). */
|
|
1816
|
+
export function describeStartupProbe(probe) {
|
|
1817
|
+
const period = probe.periodSeconds ?? 10;
|
|
1818
|
+
const threshold = probe.failureThreshold ?? 3;
|
|
1819
|
+
const delay = probe.initialDelaySeconds ?? 0;
|
|
1820
|
+
const mode = probe.type ?? (probe.command ? 'command' : undefined);
|
|
1821
|
+
const target = mode === 'command'
|
|
1822
|
+
? `command ${JSON.stringify(probe.command ?? '')}`
|
|
1823
|
+
: `${mode ?? 'probe'}${probe.port ? ` port ${probe.port}` : ''}${mode !== 'tcp' ? ` ${probe.path ?? '/'}` : ''}`;
|
|
1824
|
+
return `${target}, every ${period}s, ${threshold} failure${threshold === 1 ? '' : 's'} allowed` +
|
|
1825
|
+
`${delay ? ` after ${delay}s` : ''} (up to ${delay + period * threshold}s before the healthcheck)`;
|
|
1826
|
+
}
|
|
1520
1827
|
export function describeScalingPolicy(policy) {
|
|
1521
1828
|
const range = `${policy.minReplicas}–${policy.maxReplicas} replicas`;
|
|
1522
1829
|
if (policy.activation === 'http') {
|
|
@@ -1552,6 +1859,15 @@ async function statusSubcommand(args, positionals, apiOptions) {
|
|
|
1552
1859
|
const pinnedRunner = detail.volumeRunnerId;
|
|
1553
1860
|
if (pinnedRunner)
|
|
1554
1861
|
printLine(` volume pin: runner ${pinnedRunner}`);
|
|
1862
|
+
// Experimental service location: shown only where the platform offers it.
|
|
1863
|
+
if (limits?.features?.serviceLocations) {
|
|
1864
|
+
const location = await getAppLocation(app.id, apiOptions).catch(() => null);
|
|
1865
|
+
const line = location ? describeServiceLocation(location) : undefined;
|
|
1866
|
+
if (line)
|
|
1867
|
+
printLine(` location: ${line}`);
|
|
1868
|
+
}
|
|
1869
|
+
if (detail.runtime?.startupProbe)
|
|
1870
|
+
printLine(` startup: ${describeStartupProbe(detail.runtime.startupProbe)}`);
|
|
1555
1871
|
const servingRelease = detail.releases.find((r) => r.id === detail.production?.activeReleaseId);
|
|
1556
1872
|
if (servingRelease?.serving && servingRelease.serving.ready < servingRelease.serving.desired) {
|
|
1557
1873
|
// Production points at it, but its replica does not answer on the runner yet.
|
|
@@ -1692,29 +2008,74 @@ async function logsSubcommand(args, positionals, apiOptions) {
|
|
|
1692
2008
|
const build = await getBuild(identifier, apiOptions).catch(() => null);
|
|
1693
2009
|
if (build) {
|
|
1694
2010
|
printInfo(dim(`— build ${build.id.slice(0, 8)} (${build.status}) —`));
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
2011
|
+
// A finished build replays its log and the stream ends by itself. A
|
|
2012
|
+
// running one follows; with --no-follow, stop once the replay goes quiet.
|
|
2013
|
+
const stop = new AbortController();
|
|
2014
|
+
const quiet = hasFlag(args, '--no-follow') && !isTerminalBuildStatus(build.status)
|
|
2015
|
+
? createQuietStop(() => stop.abort())
|
|
2016
|
+
: null;
|
|
2017
|
+
try {
|
|
2018
|
+
for await (const event of streamBuildLogs(build.id, apiOptions, stop.signal)) {
|
|
2019
|
+
quiet?.heard();
|
|
2020
|
+
if (event.type === 'log' && event.line) {
|
|
2021
|
+
if (json)
|
|
2022
|
+
process.stderr.write(`${redact(event.line)}\n`);
|
|
2023
|
+
else
|
|
2024
|
+
printLine(event.line);
|
|
2025
|
+
}
|
|
2026
|
+
else if (event.type === 'complete') {
|
|
2027
|
+
printInfo(dim(`— build ${event.status ?? 'finished'} —`));
|
|
2028
|
+
break;
|
|
2029
|
+
}
|
|
2030
|
+
else if (event.type === 'error') {
|
|
2031
|
+
printLine(dim(`— ${event.message ?? 'stream error'} —`));
|
|
2032
|
+
break;
|
|
2033
|
+
}
|
|
1709
2034
|
}
|
|
1710
2035
|
}
|
|
2036
|
+
catch (error) {
|
|
2037
|
+
if (!stop.signal.aborted)
|
|
2038
|
+
throw error;
|
|
2039
|
+
}
|
|
2040
|
+
finally {
|
|
2041
|
+
quiet?.cancel();
|
|
2042
|
+
}
|
|
2043
|
+
if (stop.signal.aborted) {
|
|
2044
|
+
printInfo(dim(`— build ${build.id.slice(0, 8)} is still running; follow it: inquir apps logs ${build.id} —`));
|
|
2045
|
+
}
|
|
1711
2046
|
return;
|
|
1712
2047
|
}
|
|
1713
2048
|
const releaseId = await resolveReleaseId(identifier, apiOptions);
|
|
1714
2049
|
const tailRaw = readFlagValue(args, '--tail');
|
|
1715
2050
|
const tail = tailRaw ? Number(tailRaw) : 200;
|
|
1716
|
-
|
|
1717
|
-
|
|
2051
|
+
if (!Number.isInteger(tail) || tail < 1 || tail > MAX_LOG_TAIL) {
|
|
2052
|
+
throw usageError(`--tail must be an integer between 1 and ${MAX_LOG_TAIL}`, 'inquir apps logs <app> --tail 100');
|
|
2053
|
+
}
|
|
2054
|
+
const replica = readFlagValue(args, '--replica');
|
|
2055
|
+
if (hasFlag(args, '--replica') && !replica?.trim())
|
|
2056
|
+
throw usageError('Missing --replica value', 'inquir apps instances <app>');
|
|
2057
|
+
let allocationId;
|
|
2058
|
+
if (replica?.trim()) {
|
|
2059
|
+
const release = await getRelease(releaseId, apiOptions);
|
|
2060
|
+
// Only this release's replicas: an index or name of production would be another container.
|
|
2061
|
+
allocationId = await resolveReplicaAllocationId(release.applicationId, replica, apiOptions, { releaseId: release.id });
|
|
2062
|
+
}
|
|
2063
|
+
if (hasFlag(args, '--no-follow')) {
|
|
2064
|
+
const release = await getRelease(releaseId, apiOptions);
|
|
2065
|
+
const client = await createApiClient(apiOptions);
|
|
2066
|
+
const entries = await readRecentRuntimeLogs((query) => getAppLogHistory(client, release.applicationId, query), { releaseId, tail, ...(allocationId ? { allocationId } : {}) });
|
|
2067
|
+
if (json) {
|
|
2068
|
+
printLine(redact(JSON.stringify(entries, null, 2)));
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
for (const entry of entries)
|
|
2072
|
+
printLine(entry.message);
|
|
2073
|
+
if (entries.length === 0)
|
|
2074
|
+
printInfo(dim(`— no stored logs for release ${releaseId.slice(0, 8)} —`));
|
|
2075
|
+
return;
|
|
2076
|
+
}
|
|
2077
|
+
printInfo(dim(`— streaming logs for release ${releaseId.slice(0, 8)}${allocationId ? `, replica ${allocationId.slice(0, 8)}` : ''} (ctrl-c to stop) —`));
|
|
2078
|
+
for await (const event of streamReleaseLogs(releaseId, { tail, ...(allocationId ? { allocationId } : {}) }, apiOptions)) {
|
|
1718
2079
|
if (event.type === 'log' && event.line) {
|
|
1719
2080
|
// Under --json the log tail goes to stderr so stdout stays parseable.
|
|
1720
2081
|
if (json)
|
|
@@ -1731,6 +2092,63 @@ async function logsSubcommand(args, positionals, apiOptions) {
|
|
|
1731
2092
|
}
|
|
1732
2093
|
}
|
|
1733
2094
|
}
|
|
2095
|
+
/** The most lines `apps logs --tail` asks for (the API's own ceiling). */
|
|
2096
|
+
const MAX_LOG_TAIL = 2000;
|
|
2097
|
+
/**
|
|
2098
|
+
* How far back `--no-follow` reads: inside the 7-day runtime retention with
|
|
2099
|
+
* an hour to spare, so a client clock up to an hour off still names a window
|
|
2100
|
+
* the API accepts. `to` is left to the server's clock.
|
|
2101
|
+
*/
|
|
2102
|
+
const RUNTIME_LOG_WINDOW_MS = 7 * 86_400_000 - 3_600_000;
|
|
2103
|
+
/** `logs --no-follow` on a running build: stop once its replay is this quiet... */
|
|
2104
|
+
const BUILD_LOG_QUIET_MS = 2_000;
|
|
2105
|
+
/** ...or when nothing at all arrived this long after the request (a slow connection). */
|
|
2106
|
+
const BUILD_LOG_FIRST_EVENT_MS = 15_000;
|
|
2107
|
+
/**
|
|
2108
|
+
* Calls `stop` once a stream goes quiet: BUILD_LOG_FIRST_EVENT_MS before the
|
|
2109
|
+
* first event (connecting may be slow), BUILD_LOG_QUIET_MS after the latest.
|
|
2110
|
+
*/
|
|
2111
|
+
export function createQuietStop(stop, timings = { firstEventMs: BUILD_LOG_FIRST_EVENT_MS, quietMs: BUILD_LOG_QUIET_MS }) {
|
|
2112
|
+
let timer = setTimeout(stop, timings.firstEventMs);
|
|
2113
|
+
return {
|
|
2114
|
+
heard() {
|
|
2115
|
+
clearTimeout(timer);
|
|
2116
|
+
timer = setTimeout(stop, timings.quietMs);
|
|
2117
|
+
},
|
|
2118
|
+
cancel() {
|
|
2119
|
+
clearTimeout(timer);
|
|
2120
|
+
},
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
const LOG_HISTORY_PAGE = 200;
|
|
2124
|
+
/**
|
|
2125
|
+
* `apps logs --no-follow`: the last `tail` stored lines of one release (one
|
|
2126
|
+
* replica with `allocationId`), oldest first — what the live stream prints
|
|
2127
|
+
* before it starts following, without the following.
|
|
2128
|
+
*/
|
|
2129
|
+
export async function readRecentRuntimeLogs(fetchPage, options) {
|
|
2130
|
+
const now = options.now ?? Date.now();
|
|
2131
|
+
// The first page fixes the window; later pages repeat it with the cursor.
|
|
2132
|
+
let window = { from: new Date(now - RUNTIME_LOG_WINDOW_MS).toISOString() };
|
|
2133
|
+
const newestFirst = [];
|
|
2134
|
+
let cursor;
|
|
2135
|
+
while (newestFirst.length < options.tail) {
|
|
2136
|
+
const page = await fetchPage({
|
|
2137
|
+
...window,
|
|
2138
|
+
releaseId: options.releaseId,
|
|
2139
|
+
...(options.allocationId ? { allocationId: options.allocationId } : {}),
|
|
2140
|
+
limit: Math.min(LOG_HISTORY_PAGE, options.tail - newestFirst.length),
|
|
2141
|
+
...(cursor ? { cursor } : {}),
|
|
2142
|
+
});
|
|
2143
|
+
newestFirst.push(...page.entries);
|
|
2144
|
+
if (!page.nextCursor || page.entries.length === 0)
|
|
2145
|
+
break;
|
|
2146
|
+
cursor = page.nextCursor;
|
|
2147
|
+
if (page.from && page.to)
|
|
2148
|
+
window = { from: page.from, to: page.to };
|
|
2149
|
+
}
|
|
2150
|
+
return newestFirst.slice(0, options.tail).reverse();
|
|
2151
|
+
}
|
|
1734
2152
|
async function promoteSubcommand(args, positionals, apiOptions) {
|
|
1735
2153
|
const [identifier] = positionals;
|
|
1736
2154
|
if (!identifier)
|
|
@@ -1741,9 +2159,11 @@ async function promoteSubcommand(args, positionals, apiOptions) {
|
|
|
1741
2159
|
// Say up front what promotion does to this release: a preview-profile
|
|
1742
2160
|
// release with production-only variables is restarted with them before
|
|
1743
2161
|
// traffic moves, and its preview URL retires.
|
|
2162
|
+
let release = null;
|
|
2163
|
+
let app = null;
|
|
1744
2164
|
if (!json) {
|
|
1745
|
-
|
|
1746
|
-
|
|
2165
|
+
release = await getRelease(releaseId, apiOptions).catch(() => null);
|
|
2166
|
+
app = release ? await getApplication(release.applicationId, apiOptions).catch(() => null) : null;
|
|
1747
2167
|
if (release && release.envProfile !== 'production' && (app?.productionOnlyEnvKeys?.length ?? 0) > 0) {
|
|
1748
2168
|
printInfo(dim(' the container restarts with production-only variables before traffic moves'));
|
|
1749
2169
|
}
|
|
@@ -1775,12 +2195,33 @@ async function promoteSubcommand(args, positionals, apiOptions) {
|
|
|
1775
2195
|
printLine(` ${url}`);
|
|
1776
2196
|
}
|
|
1777
2197
|
if ((result.productionUrls ?? []).length === 0 && !result.productionUrl) {
|
|
1778
|
-
|
|
2198
|
+
for (const line of promotedWithoutHostnameLines(release, app))
|
|
2199
|
+
printInfo(dim(line));
|
|
1779
2200
|
}
|
|
1780
2201
|
if (result.previousReleaseId) {
|
|
1781
2202
|
printInfo(dim(` previous release ${result.previousReleaseId.slice(0, 8)} kept for rollback`));
|
|
1782
2203
|
}
|
|
1783
2204
|
}
|
|
2205
|
+
/**
|
|
2206
|
+
* What to say after a promotion that produced no public URL. An HTTP service
|
|
2207
|
+
* is private, not unreachable: same-workspace services reach it at its
|
|
2208
|
+
* private address. Only a TCP-only service is reached at its TCP endpoints.
|
|
2209
|
+
*/
|
|
2210
|
+
export function promotedWithoutHostnameLines(release, app) {
|
|
2211
|
+
const name = app?.name ?? '<app>';
|
|
2212
|
+
// The server reads a port without a protocol as HTTP (src/apps/app-ports.ts).
|
|
2213
|
+
const http = typeof release?.runtime?.port === 'number'
|
|
2214
|
+
|| (release?.runtime?.ports ?? []).some((port) => (port.protocol ?? 'http') === 'http');
|
|
2215
|
+
if (http) {
|
|
2216
|
+
return [
|
|
2217
|
+
app?.privateBaseUrl
|
|
2218
|
+
? ` private — your other services reach it at ${app.privateBaseUrl}`
|
|
2219
|
+
: ` private — your other services reach it at http://<slug>.apps.internal, listed by inquir apps status ${name} once routing is verified`,
|
|
2220
|
+
` public URL: inquir apps update ${name} --ingress public`,
|
|
2221
|
+
];
|
|
2222
|
+
}
|
|
2223
|
+
return [` no hostname — reach it at its TCP endpoints (inquir apps status ${name})`];
|
|
2224
|
+
}
|
|
1784
2225
|
async function rollbackSubcommand(args, positionals, apiOptions) {
|
|
1785
2226
|
const [identifier] = positionals;
|
|
1786
2227
|
if (!identifier)
|
|
@@ -1920,7 +2361,8 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1920
2361
|
await requireAuth(apiOptions);
|
|
1921
2362
|
const app = await resolveApplication(identifier, apiOptions);
|
|
1922
2363
|
const envVars = parseSetFlags(readMultiFlag(args, '--set'));
|
|
1923
|
-
const
|
|
2364
|
+
const flagsRuntime = buildRuntimeFromFlags(args, app.runtime);
|
|
2365
|
+
const runtimeSpec = applyNodeSelectorFlags(args, flagsRuntime ?? app.runtime) ?? flagsRuntime;
|
|
1924
2366
|
const description = readFlagValue(args, '--description');
|
|
1925
2367
|
const networkPolicy = buildNetworkPolicyPatch({
|
|
1926
2368
|
egress: readFlagValue(args, '--egress'),
|
|
@@ -1928,9 +2370,11 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1928
2370
|
current: app.networkPolicy,
|
|
1929
2371
|
});
|
|
1930
2372
|
const volumeRunner = readFlagValue(args, '--volume-runner');
|
|
2373
|
+
const location = parseLocationFlag(readFlagValue(args, '--location'));
|
|
1931
2374
|
const patch = {
|
|
1932
2375
|
...(description !== undefined ? { description } : {}),
|
|
1933
2376
|
...(volumeRunner ? { volumeRunner } : {}),
|
|
2377
|
+
...(location !== undefined ? { location } : {}),
|
|
1934
2378
|
...(runtimeSpec ? { runtime: runtimeSpec } : {}),
|
|
1935
2379
|
// Only the keys given here change; the server merges masked values, so
|
|
1936
2380
|
// untouched secrets survive.
|
|
@@ -1946,7 +2390,43 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1946
2390
|
return;
|
|
1947
2391
|
}
|
|
1948
2392
|
printSuccess(`Updated ${updated.name}`);
|
|
1949
|
-
|
|
2393
|
+
// Advisory: e.g. a node selector no server matches right now.
|
|
2394
|
+
for (const warning of updated.warnings ?? [])
|
|
2395
|
+
printInfo(` warning: ${warning}`);
|
|
2396
|
+
const ingress = readFlagValue(args, '--ingress');
|
|
2397
|
+
for (const line of updateTimingLines(updated.name, {
|
|
2398
|
+
hasProduction: Boolean(updated.productionReleaseId ?? app.productionReleaseId),
|
|
2399
|
+
ingress: ingress === 'public' || ingress === 'private' ? ingress : undefined,
|
|
2400
|
+
nextRelease: Object.keys(envVars).length > 0 || runtimeSpec !== undefined
|
|
2401
|
+
|| readFlagValue(args, '--egress') !== undefined || volumeRunner !== undefined
|
|
2402
|
+
|| location !== undefined,
|
|
2403
|
+
})) {
|
|
2404
|
+
printInfo(dim(line));
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
/**
|
|
2408
|
+
* When an `apps update` takes effect. Public Networking is applied by the
|
|
2409
|
+
* server at once (the generated route is created or removed in the same
|
|
2410
|
+
* request); env, runtime, egress, placement and location are what the next
|
|
2411
|
+
* release is built from.
|
|
2412
|
+
*/
|
|
2413
|
+
export function updateTimingLines(appName, changed) {
|
|
2414
|
+
const lines = [];
|
|
2415
|
+
const hasProduction = changed.hasProduction !== false;
|
|
2416
|
+
if (changed.ingress === 'public') {
|
|
2417
|
+
lines.push(hasProduction
|
|
2418
|
+
? ` public networking: applied now — the generated domain serves production (inquir apps status ${appName})`
|
|
2419
|
+
: ' public networking: on — the generated domain serves production once a release is promoted');
|
|
2420
|
+
}
|
|
2421
|
+
else if (changed.ingress === 'private') {
|
|
2422
|
+
lines.push(' public networking: removed now — the generated domain no longer serves it; the private address stays');
|
|
2423
|
+
}
|
|
2424
|
+
if (changed.nextRelease) {
|
|
2425
|
+
// redeploy re-releases production and refuses without one.
|
|
2426
|
+
const next = hasProduction ? `inquir apps redeploy ${appName}` : `inquir apps deploy ${appName}`;
|
|
2427
|
+
lines.push(` ${changed.ingress ? 'the other changes apply' : 'changes apply'} to the next release: ${next}`);
|
|
2428
|
+
}
|
|
2429
|
+
return lines;
|
|
1950
2430
|
}
|
|
1951
2431
|
async function execSubcommand(args, positionals, apiOptions) {
|
|
1952
2432
|
const [identifier, ...commandParts] = positionals;
|
|
@@ -1956,14 +2436,15 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1956
2436
|
if (!command)
|
|
1957
2437
|
throw usageError('Missing command', 'inquir apps exec <app> -- ls -la');
|
|
1958
2438
|
await requireAuth(apiOptions);
|
|
1959
|
-
|
|
1960
|
-
// preview release id here and then calling the production-only app endpoint
|
|
1961
|
-
// would silently change the operator's target to production.
|
|
1962
|
-
const application = await resolveApplication(identifier, apiOptions);
|
|
2439
|
+
const application = await resolveExecApplication(identifier, apiOptions, readJsonFlag(args));
|
|
1963
2440
|
const timeoutRaw = readFlagValue(args, '--timeout');
|
|
2441
|
+
const replica = readFlagValue(args.slice(0, args.includes('--') ? args.indexOf('--') : args.length), '--replica');
|
|
2442
|
+
if (hasFlag(args.slice(0, args.includes('--') ? args.indexOf('--') : args.length), '--replica') && !replica?.trim()) {
|
|
2443
|
+
throw usageError('Missing --replica value', 'inquir apps exec <app> --replica <name|index> -- <command>');
|
|
2444
|
+
}
|
|
1964
2445
|
let result;
|
|
1965
2446
|
try {
|
|
1966
|
-
result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
|
|
2447
|
+
result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions, replica?.trim() ? { replica: replica.trim() } : {});
|
|
1967
2448
|
}
|
|
1968
2449
|
catch (err) {
|
|
1969
2450
|
if (err instanceof Error)
|
|
@@ -1974,6 +2455,9 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1974
2455
|
printLine(JSON.stringify(result, null, 2));
|
|
1975
2456
|
}
|
|
1976
2457
|
else {
|
|
2458
|
+
if (replica && result.replicaIndex !== undefined) {
|
|
2459
|
+
printInfo(dim(`— replica ${result.replicaIndex}${result.allocationId ? ` (${result.allocationId.slice(0, 8)})` : ''} —`));
|
|
2460
|
+
}
|
|
1977
2461
|
if (result.stdout)
|
|
1978
2462
|
process.stdout.write(result.stdout);
|
|
1979
2463
|
if (result.stderr)
|
|
@@ -1984,6 +2468,43 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1984
2468
|
if (result.exitCode !== 0)
|
|
1985
2469
|
process.exitCode = ExitCode.ERROR;
|
|
1986
2470
|
}
|
|
2471
|
+
/**
|
|
2472
|
+
* `apps exec` runs in production through the application-scoped endpoint. An
|
|
2473
|
+
* application name or id is the target; a release id is accepted only when it
|
|
2474
|
+
* is that application's production release. A stale or preview release id
|
|
2475
|
+
* would otherwise silently change the operator's target to production, so it
|
|
2476
|
+
* is refused with the command that does run.
|
|
2477
|
+
*/
|
|
2478
|
+
async function resolveExecApplication(identifier, apiOptions, json) {
|
|
2479
|
+
let appError;
|
|
2480
|
+
try {
|
|
2481
|
+
return await resolveApplication(identifier, apiOptions);
|
|
2482
|
+
}
|
|
2483
|
+
catch (error) {
|
|
2484
|
+
appError = error;
|
|
2485
|
+
}
|
|
2486
|
+
const release = await getRelease(identifier, apiOptions).catch(() => null);
|
|
2487
|
+
if (!release)
|
|
2488
|
+
throw appError;
|
|
2489
|
+
const detail = await getApplication(release.applicationId, apiOptions);
|
|
2490
|
+
const production = detail.production?.activeReleaseId;
|
|
2491
|
+
if (production !== release.id) {
|
|
2492
|
+
throw new CliError(`Release ${release.id.slice(0, 8)} is not ${detail.name}'s production release`, {
|
|
2493
|
+
code: 'EXEC_NOT_PRODUCTION',
|
|
2494
|
+
exitCode: ExitCode.USAGE,
|
|
2495
|
+
explanation: 'apps exec runs a command in the production container only.',
|
|
2496
|
+
fix: production
|
|
2497
|
+
? `Run it in production (release ${production.slice(0, 8)}): inquir apps exec ${detail.name} -- <command>`
|
|
2498
|
+
: `${detail.name} has no production release yet; promote one first: inquir apps promote ${release.id.slice(0, 8)}`,
|
|
2499
|
+
});
|
|
2500
|
+
}
|
|
2501
|
+
const note = `— ${detail.name}, production release ${release.id.slice(0, 8)} —`;
|
|
2502
|
+
if (json)
|
|
2503
|
+
process.stderr.write(`${note}\n`);
|
|
2504
|
+
else
|
|
2505
|
+
printInfo(dim(note));
|
|
2506
|
+
return detail;
|
|
2507
|
+
}
|
|
1987
2508
|
function printVerificationInstructions(domain) {
|
|
1988
2509
|
const v = domain.verification;
|
|
1989
2510
|
if (!v)
|