@inquir/compute-cli 0.3.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 +528 -406
- 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 +125 -0
- package/dist/commands/apps-describe.js.map +1 -0
- 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 +15 -0
- package/dist/commands/apps-observability.d.ts.map +1 -0
- package/dist/commands/apps-observability.js +310 -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 +118 -2
- package/dist/commands/apps.d.ts.map +1 -1
- package/dist/commands/apps.js +999 -110
- 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/deploy.js +3 -3
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/init.js +15 -15
- 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 +39 -14
- package/dist/utils/api.d.ts.map +1 -1
- package/dist/utils/api.js +43 -6
- package/dist/utils/api.js.map +1 -1
- package/dist/utils/apps-presentation.d.ts +8 -1
- package/dist/utils/apps-presentation.d.ts.map +1 -1
- package/dist/utils/apps-presentation.js +35 -1
- 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/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/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/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 +26 -26
- package/skill/SKILL.md +73 -67
package/dist/commands/apps.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
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 { INSTANCES_HELP, instancesSubcommand, resolveReplicaAllocationId } from './apps-instances.js';
|
|
6
|
+
import { followPromoteRollout } from './apps-promote.js';
|
|
2
7
|
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 {
|
|
5
|
-
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';
|
|
6
11
|
import { buildApiOptions, runtime } from '../utils/context.js';
|
|
7
12
|
import { confirm } from '../utils/prompt.js';
|
|
8
13
|
import { resolveCreateProject } from '../utils/project-context.js';
|
|
9
|
-
import { CliError, ExitCode, usageError } from '../utils/errors.js';
|
|
14
|
+
import { CliError, ExitCode, toCliError, usageError } from '../utils/errors.js';
|
|
10
15
|
import { redact, registerSecret } from '../utils/redact.js';
|
|
11
|
-
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';
|
|
12
19
|
/** Value flags across every subcommand — readPositionalArgs must know them all. */
|
|
13
20
|
const VALUE_FLAGS = [
|
|
21
|
+
...OBSERVATION_VALUE_FLAGS,
|
|
22
|
+
...RESOURCES_VALUE_FLAGS,
|
|
14
23
|
'--project',
|
|
15
24
|
'--template',
|
|
16
25
|
'--image',
|
|
@@ -22,6 +31,7 @@ const VALUE_FLAGS = [
|
|
|
22
31
|
'--set',
|
|
23
32
|
'--memory',
|
|
24
33
|
'--cpu',
|
|
34
|
+
'--drain-grace',
|
|
25
35
|
'--health-path',
|
|
26
36
|
'--health-command',
|
|
27
37
|
'--description',
|
|
@@ -45,6 +55,8 @@ const VALUE_FLAGS = [
|
|
|
45
55
|
'--stabilization',
|
|
46
56
|
'--scale-down-stabilization',
|
|
47
57
|
'--replicas',
|
|
58
|
+
'--node-selector',
|
|
59
|
+
'--location',
|
|
48
60
|
];
|
|
49
61
|
export async function appsCommand(args) {
|
|
50
62
|
const [subcommand, ...rest] = readPositionalArgs(args, { valueFlags: VALUE_FLAGS });
|
|
@@ -76,7 +88,21 @@ export async function appsCommand(args) {
|
|
|
76
88
|
case 'status':
|
|
77
89
|
return statusSubcommand(args, rest, apiOptions);
|
|
78
90
|
case 'logs':
|
|
91
|
+
if (hasFlag(args, '--history'))
|
|
92
|
+
return logHistorySubcommand(args, rest, apiOptions);
|
|
79
93
|
return logsSubcommand(args, rest, apiOptions);
|
|
94
|
+
case 'metrics':
|
|
95
|
+
return metricsSubcommand(args, rest, apiOptions);
|
|
96
|
+
case 'log-history':
|
|
97
|
+
return logHistorySubcommand(args, rest, apiOptions);
|
|
98
|
+
case 'alerts':
|
|
99
|
+
return alertsSubcommand(args, rest, apiOptions);
|
|
100
|
+
case 'resources':
|
|
101
|
+
return resourcesSubcommand(args, rest, apiOptions);
|
|
102
|
+
case 'describe':
|
|
103
|
+
return describeSubcommand(args, rest, apiOptions);
|
|
104
|
+
case 'instances':
|
|
105
|
+
return instancesSubcommand(args, rest, apiOptions);
|
|
80
106
|
case 'promote':
|
|
81
107
|
return promoteSubcommand(args, rest, apiOptions);
|
|
82
108
|
case 'rollback':
|
|
@@ -124,12 +150,18 @@ function printAppsHelp() {
|
|
|
124
150
|
'build <app> [--dir <path>] Build an image from source (Dockerfile) without releasing',
|
|
125
151
|
'builds <app> List image builds',
|
|
126
152
|
'status <app> Endpoints and recent releases',
|
|
127
|
-
'
|
|
153
|
+
'describe <app> Why it is (not) running: conditions, placement, events',
|
|
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)',
|
|
156
|
+
'log-history <app> Persisted runtime or HTTP logs with time/replica filters',
|
|
157
|
+
'metrics <app> CPU/RAM, network, disk I/O or HTTP history (--json)',
|
|
158
|
+
'alerts <app> [action] Configure resource/deployment alerts and read incidents',
|
|
159
|
+
'resources recommend <app> CPU/RAM right-sizing from observed usage (read-only)',
|
|
128
160
|
'promote <release> Send production traffic to a release',
|
|
129
161
|
'rollback <app> Return production to the previous release',
|
|
130
162
|
'stop <release> Stop a release that is not serving production',
|
|
131
|
-
'exec <
|
|
132
|
-
'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',
|
|
133
165
|
'scale <app> [mode] Set always/http/manual scaling (--replicas for always)',
|
|
134
166
|
'wake <app> Start a sleeping application',
|
|
135
167
|
'pause <app> Stop an http/manual app (HTTP wakes only http mode)',
|
|
@@ -137,10 +169,10 @@ function printAppsHelp() {
|
|
|
137
169
|
'delete <app> Archive an application (stops every release)',
|
|
138
170
|
'',
|
|
139
171
|
'Create/deploy flags:',
|
|
140
|
-
'--project <
|
|
172
|
+
'--project <project> create: this project by id, slug or unique name (overrides projects use)',
|
|
141
173
|
'--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
|
|
142
174
|
'--promote deploy: take production by itself once healthy (needs apps:promote)',
|
|
143
|
-
'--template <name> postgres | redis: volume,
|
|
175
|
+
'--template <name> postgres | redis: volume, private TCP endpoint, generated password, first release',
|
|
144
176
|
'--dir <path> Source directory to build (default: current directory)',
|
|
145
177
|
'--dockerfile <path> Dockerfile relative to the context (default: Dockerfile)',
|
|
146
178
|
'--context <path> Build context inside the source tree (default: .)',
|
|
@@ -150,9 +182,14 @@ function printAppsHelp() {
|
|
|
150
182
|
'--tcp <name:port[:public]> Extra TCP port; endpoint port is platform-managed (repeatable)',
|
|
151
183
|
'--volume <name:/path[:ro]> Persistent volume mount (repeatable)',
|
|
152
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',
|
|
153
189
|
'--set KEY=VALUE Environment variable (repeatable)',
|
|
154
190
|
'--memory <mb> Memory ceiling (default 512)',
|
|
155
191
|
'--cpu <vcpus> CPU ceiling (default 0.5)',
|
|
192
|
+
'--drain-grace <seconds> Time the old container gets after SIGTERM before it is killed (0-3600)',
|
|
156
193
|
'--egress <none|full> Outbound network policy',
|
|
157
194
|
'--ingress <private|public> Public Networking: public creates the generated domain (new apps are private)',
|
|
158
195
|
'--health-path </path> HTTP health check path',
|
|
@@ -210,6 +247,77 @@ export function parseTcpPortFlag(value) {
|
|
|
210
247
|
}
|
|
211
248
|
return spec;
|
|
212
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
|
+
}
|
|
213
321
|
export function parseSetFlags(values) {
|
|
214
322
|
const env = {};
|
|
215
323
|
for (const entry of values) {
|
|
@@ -306,6 +414,7 @@ export function buildScalingPatchFromFlags(args, positionalActivation) {
|
|
|
306
414
|
}
|
|
307
415
|
const VOLUME_NAME_RE = /^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$/;
|
|
308
416
|
const MAX_APP_VOLUMES = 16;
|
|
417
|
+
// eslint-disable-next-line no-control-regex -- Container mount paths must reject control characters.
|
|
309
418
|
const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f]/;
|
|
310
419
|
/** `--volume name:/absolute/container/path[:ro]`; host paths are never accepted. */
|
|
311
420
|
export function parseVolumeFlag(value) {
|
|
@@ -314,7 +423,7 @@ export function parseVolumeFlag(value) {
|
|
|
314
423
|
let mountPath = separator > 0 ? value.slice(separator + 1) : '';
|
|
315
424
|
let readOnly = false;
|
|
316
425
|
if (/^[A-Za-z]:[\\/]/.test(mountPath)) {
|
|
317
|
-
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}`);
|
|
318
427
|
}
|
|
319
428
|
const modifierAt = mountPath.lastIndexOf(':');
|
|
320
429
|
if (modifierAt >= 0) {
|
|
@@ -375,6 +484,16 @@ function mergeVolumes(base, overrides) {
|
|
|
375
484
|
assertVolumeSet(merged);
|
|
376
485
|
return merged;
|
|
377
486
|
}
|
|
487
|
+
/** Largest `runtimeSpec.drainGraceSeconds` the API accepts. */
|
|
488
|
+
const MAX_DRAIN_GRACE_SECONDS = 3600;
|
|
489
|
+
/** `--drain-grace <seconds>`: whole seconds, 0..3600, as the API's runtime schema. */
|
|
490
|
+
export function parseDrainGraceFlag(value) {
|
|
491
|
+
const seconds = /^\d{1,4}$/.test(value) ? Number(value) : Number.NaN;
|
|
492
|
+
if (!(seconds <= MAX_DRAIN_GRACE_SECONDS)) {
|
|
493
|
+
throw usageError(`Invalid --drain-grace value '${value}' (whole seconds, 0-${MAX_DRAIN_GRACE_SECONDS})`, 'inquir apps deploy <app> --drain-grace 30');
|
|
494
|
+
}
|
|
495
|
+
return seconds;
|
|
496
|
+
}
|
|
378
497
|
/** Runtime flags patch the saved application runtime rather than replacing unrelated fields. */
|
|
379
498
|
export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
380
499
|
const portRaw = readFlagValue(args, '--port');
|
|
@@ -382,7 +501,13 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
|
382
501
|
const volumeValues = readMultiFlag(args, '--volume');
|
|
383
502
|
const memory = readFlagValue(args, '--memory');
|
|
384
503
|
const cpu = readFlagValue(args, '--cpu');
|
|
385
|
-
const
|
|
504
|
+
const drainGraceRaw = readFlagValue(args, '--drain-grace');
|
|
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;
|
|
386
511
|
const healthCommand = readFlagValue(args, '--health-command');
|
|
387
512
|
const ports = tcpValues.map(parseTcpPortFlag);
|
|
388
513
|
const volumes = volumeValues.map(parseVolumeFlag);
|
|
@@ -390,7 +515,13 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
|
390
515
|
if (portRaw && (!Number.isInteger(port) || port < 1 || port > 65535)) {
|
|
391
516
|
throw usageError(`Invalid --port value '${portRaw}'`, 'inquir apps create <name> --port 3000');
|
|
392
517
|
}
|
|
393
|
-
|
|
518
|
+
// `--drain-grace` without a value reads as undefined; refuse it rather than
|
|
519
|
+
// silently deploying without the setting the caller asked for.
|
|
520
|
+
if (drainGraceRaw === undefined && hasFlag(args, '--drain-grace'))
|
|
521
|
+
parseDrainGraceFlag('');
|
|
522
|
+
const drainGraceSeconds = drainGraceRaw !== undefined ? parseDrainGraceFlag(drainGraceRaw) : undefined;
|
|
523
|
+
if (!portRaw && tcpValues.length === 0 && volumeValues.length === 0 && !memory && !cpu &&
|
|
524
|
+
drainGraceSeconds === undefined && !healthPath && !healthCommand) {
|
|
394
525
|
return undefined;
|
|
395
526
|
}
|
|
396
527
|
const spec = {
|
|
@@ -398,6 +529,7 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
|
398
529
|
...(portRaw ? { port } : {}),
|
|
399
530
|
...(tcpValues.length > 0 ? { ports } : {}),
|
|
400
531
|
...(volumeValues.length > 0 ? { volumes: mergeVolumes(baseRuntime?.volumes, volumes) } : {}),
|
|
532
|
+
...(drainGraceSeconds !== undefined ? { drainGraceSeconds } : {}),
|
|
401
533
|
};
|
|
402
534
|
if (memory || cpu) {
|
|
403
535
|
const parsedMemory = memory ? Number(memory) : (baseRuntime?.resources?.memoryMb ?? 512);
|
|
@@ -453,7 +585,7 @@ async function createAppSubcommand(args, positionals, apiOptions) {
|
|
|
453
585
|
if (!name)
|
|
454
586
|
throw usageError('Missing application name', 'inquir apps create <name> --port 3000');
|
|
455
587
|
if (positionals.length > 1)
|
|
456
|
-
throw usageError('Expected one application name', 'inquir apps create <name> --project <
|
|
588
|
+
throw usageError('Expected one application name', 'inquir apps create <name> --project <project>');
|
|
457
589
|
await requireAuth(apiOptions);
|
|
458
590
|
const template = readFlagValue(args, '--template');
|
|
459
591
|
if (template && !TEMPLATE_NAMES.includes(template)) {
|
|
@@ -477,7 +609,22 @@ async function createAppSubcommand(args, positionals, apiOptions) {
|
|
|
477
609
|
runtime: buildRuntimeFromFlags(args),
|
|
478
610
|
...(Object.keys(envVars).length > 0 ? { envVars } : {}),
|
|
479
611
|
...(networkPolicy ? { networkPolicy } : {}),
|
|
480
|
-
}, project.apiOptions)
|
|
612
|
+
}, project.apiOptions).catch((error) => {
|
|
613
|
+
// A worker runner pulls only a digest-pinned registry image, and this
|
|
614
|
+
// installation has no mirror of the template's image yet.
|
|
615
|
+
if (error instanceof ApiError && error.code === 'APP_TEMPLATE_IMAGE_NOT_MIRRORED') {
|
|
616
|
+
throw new CliError(`The ${template} template cannot run on a worker runner on this installation yet`, {
|
|
617
|
+
code: error.code,
|
|
618
|
+
exitCode: ExitCode.ERROR,
|
|
619
|
+
requestId: error.requestId,
|
|
620
|
+
details: error.payload,
|
|
621
|
+
explanation: error.message,
|
|
622
|
+
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.',
|
|
623
|
+
command: `inquir apps create ${name} --template ${template}`,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
throw error;
|
|
627
|
+
});
|
|
481
628
|
const firstRelease = app.firstRelease;
|
|
482
629
|
if (readJsonFlag(args)) {
|
|
483
630
|
// A scripted create must answer the same question the human one does:
|
|
@@ -490,8 +637,9 @@ async function createAppSubcommand(args, positionals, apiOptions) {
|
|
|
490
637
|
}
|
|
491
638
|
printSuccess(`Created application ${app.name}`);
|
|
492
639
|
printInfo(dim(` id: ${app.id}`));
|
|
493
|
-
|
|
494
|
-
printInfo(dim(`
|
|
640
|
+
for (const line of await landingProjectLines(app, project.projectId, project.apiOptions)) {
|
|
641
|
+
printInfo(dim(` ${line}`));
|
|
642
|
+
}
|
|
495
643
|
if (template && firstRelease) {
|
|
496
644
|
printInfo(dim(` template: ${template} — first release ${firstRelease.releaseId.slice(0, 8)} is starting`));
|
|
497
645
|
const release = await waitForRelease(firstRelease.releaseId, project.apiOptions);
|
|
@@ -507,9 +655,148 @@ async function createAppSubcommand(args, positionals, apiOptions) {
|
|
|
507
655
|
printInfo(dim(` deploy it: inquir apps deploy ${app.name} (builds the Dockerfile in the current directory)`));
|
|
508
656
|
printInfo(dim(` or: inquir apps deploy ${app.name} --image <ref>`));
|
|
509
657
|
}
|
|
658
|
+
/**
|
|
659
|
+
* Which project a new application landed in, by name. With neither --project
|
|
660
|
+
* nor a selection the server creates one named after the application, so a
|
|
661
|
+
* database and the app using it land in two projects: say so, and how to
|
|
662
|
+
* group them.
|
|
663
|
+
*/
|
|
664
|
+
async function landingProjectLines(app, requestedProject, apiOptions) {
|
|
665
|
+
const projectId = app.projectIds?.[0];
|
|
666
|
+
if (!projectId)
|
|
667
|
+
return [];
|
|
668
|
+
const project = await getProject(projectId, apiOptions).catch(() => null);
|
|
669
|
+
const label = project ? `${project.name} (${project.slug})` : projectId;
|
|
670
|
+
if (requestedProject)
|
|
671
|
+
return [`project: ${label}`];
|
|
672
|
+
return [
|
|
673
|
+
`project: ${label}, created for it since no project is selected here`,
|
|
674
|
+
` group it with another application: inquir projects move <project> ${app.name}`,
|
|
675
|
+
' put new applications in one project: inquir projects use <project>',
|
|
676
|
+
];
|
|
677
|
+
}
|
|
510
678
|
const TEMPLATE_NAMES = ['postgres', 'redis'];
|
|
511
679
|
/** `apps <sub> --help` answered per subcommand; the global page is the fallback. */
|
|
512
|
-
const SUBCOMMAND_HELP = {
|
|
680
|
+
export const SUBCOMMAND_HELP = {
|
|
681
|
+
...OBSERVATION_HELP,
|
|
682
|
+
...RESOURCES_HELP,
|
|
683
|
+
...DESCRIBE_HELP,
|
|
684
|
+
...INSTANCES_HELP,
|
|
685
|
+
logs: [
|
|
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>).',
|
|
691
|
+
' --history selects persisted history instead; see apps log-history --help.',
|
|
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
|
+
],
|
|
791
|
+
promote: [
|
|
792
|
+
'Usage: inquir apps promote <release> [--no-wait] [--json]',
|
|
793
|
+
'',
|
|
794
|
+
' Sends production traffic to a healthy release. A multi-replica service',
|
|
795
|
+
' may be promoted as a rolling rollout: the server starts the new replicas a',
|
|
796
|
+
' few at a time and switches production once enough of them serve, and the',
|
|
797
|
+
' CLI follows it until then.',
|
|
798
|
+
' --no-wait Return as soon as the rollout has started',
|
|
799
|
+
],
|
|
513
800
|
redeploy: [
|
|
514
801
|
'Usage: inquir apps redeploy <app> [--no-promote] [--no-wait]',
|
|
515
802
|
'',
|
|
@@ -525,14 +812,18 @@ const SUBCOMMAND_HELP = {
|
|
|
525
812
|
create: [
|
|
526
813
|
'Usage: inquir apps create <name> [--template postgres|redis] [flags]',
|
|
527
814
|
'',
|
|
528
|
-
' --project <
|
|
529
|
-
' without a selection, creates a project
|
|
530
|
-
'
|
|
531
|
-
'
|
|
532
|
-
'
|
|
815
|
+
' --project <project> Add to this project (id, slug or unique name). Otherwise',
|
|
816
|
+
' uses projects use; without a selection, creates a project',
|
|
817
|
+
' named after the app (group it later: inquir projects move).',
|
|
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.',
|
|
533
822
|
' --volume-runner <id> Pin volumes + container to a worker runner (id or',
|
|
534
|
-
' unique prefix); the data lives on that machine.',
|
|
535
|
-
'
|
|
823
|
+
' unique prefix); the data lives on that machine. With',
|
|
824
|
+
' --template the worker runs the operator\'s digest-pinned',
|
|
825
|
+
' mirror of the template image (APPS_TEMPLATE_IMAGES).',
|
|
826
|
+
' --port, --tcp, --volume, --memory, --cpu, --drain-grace, --health-command, --set KEY=VALUE',
|
|
536
827
|
'',
|
|
537
828
|
'Examples:',
|
|
538
829
|
' inquir apps create mydb --template postgres',
|
|
@@ -559,6 +850,9 @@ const SUBCOMMAND_HELP = {
|
|
|
559
850
|
'Writes the database connection URL into the target app\'s environment',
|
|
560
851
|
'(DATABASE_URL for postgres, REDIS_URL for redis). The password never',
|
|
561
852
|
'passes through your terminal. Takes effect on the target\'s next release.',
|
|
853
|
+
'The URL names the database\'s private address, <slug>.apps.internal:<port>,',
|
|
854
|
+
'on the control-plane runner and on a worker alike, once the platform has',
|
|
855
|
+
'verified it (`inquir apps status <db>` lists it).',
|
|
562
856
|
'',
|
|
563
857
|
' --overwrite Replace a DATABASE_URL/REDIS_URL the target already has —',
|
|
564
858
|
' e.g. to repoint it at the database\'s private address',
|
|
@@ -574,8 +868,55 @@ const SUBCOMMAND_HELP = {
|
|
|
574
868
|
],
|
|
575
869
|
};
|
|
576
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
|
+
}
|
|
577
917
|
/** The one backup table: `create` prints it too, once the rows settle. */
|
|
578
918
|
function renderBackupTable(items) {
|
|
919
|
+
const keeps = items.some((b) => b.retention);
|
|
579
920
|
printLine(formatTable(items.map((b) => ({
|
|
580
921
|
id: b.id.slice(0, 8),
|
|
581
922
|
volume: b.volumeName,
|
|
@@ -583,7 +924,10 @@ function renderBackupTable(items) {
|
|
|
583
924
|
trigger: b.trigger,
|
|
584
925
|
size: formatBytes(b.sizeBytes ?? b.artifactSizeBytes),
|
|
585
926
|
created: b.createdAt.replace('T', ' ').slice(0, 19),
|
|
586
|
-
|
|
927
|
+
keeps: formatBackupKeeps(b),
|
|
928
|
+
})), keeps
|
|
929
|
+
? ['id', 'volume', 'status', 'trigger', 'size', 'created', 'keeps']
|
|
930
|
+
: ['id', 'volume', 'status', 'trigger', 'size', 'created']));
|
|
587
931
|
}
|
|
588
932
|
/** Accept a short id, the way the release commands do. */
|
|
589
933
|
async function resolveBackup(applicationId, needle, identifier, apiOptions) {
|
|
@@ -655,9 +999,12 @@ async function backupsSubcommand(args, positionals, apiOptions) {
|
|
|
655
999
|
if (json)
|
|
656
1000
|
return printLine(JSON.stringify(page, null, 2));
|
|
657
1001
|
if (page.items.length === 0) {
|
|
658
|
-
|
|
1002
|
+
for (const line of emptyBackupsLines(app.name, page.policy))
|
|
1003
|
+
printInfo(line);
|
|
659
1004
|
return;
|
|
660
1005
|
}
|
|
1006
|
+
if (page.policy)
|
|
1007
|
+
printInfo(dim(formatBackupPolicy(page.policy)));
|
|
661
1008
|
renderBackupTable(page.items);
|
|
662
1009
|
if (page.nextCursor) {
|
|
663
1010
|
printInfo(dim(` older: inquir apps backups ${app.name} list --cursor ${page.nextCursor}`));
|
|
@@ -729,6 +1076,90 @@ async function backupsSubcommand(args, positionals, apiOptions) {
|
|
|
729
1076
|
}
|
|
730
1077
|
throw usageError(`Unknown backups action '${verb}'`, BACKUPS_USAGE);
|
|
731
1078
|
}
|
|
1079
|
+
/**
|
|
1080
|
+
* The refusals of `apps connect` and `apps connection-url`, told as what to do
|
|
1081
|
+
* next. The server's code and message are kept; the cause, the fix and the
|
|
1082
|
+
* command to try are the CLI's. Anything else passes through unchanged to the
|
|
1083
|
+
* generic rendering.
|
|
1084
|
+
*/
|
|
1085
|
+
export function explainDatabaseConnectionError(error, context) {
|
|
1086
|
+
if (!(error instanceof ApiError) || !error.code)
|
|
1087
|
+
return error;
|
|
1088
|
+
const { database, target } = context;
|
|
1089
|
+
const base = {
|
|
1090
|
+
code: error.code,
|
|
1091
|
+
exitCode: ExitCode.ERROR,
|
|
1092
|
+
requestId: error.requestId,
|
|
1093
|
+
details: error.payload,
|
|
1094
|
+
};
|
|
1095
|
+
const status = `inquir apps status ${database}`;
|
|
1096
|
+
const retry = target ? `inquir apps connect ${database} ${target}` : `inquir apps connection-url ${database}`;
|
|
1097
|
+
switch (error.code) {
|
|
1098
|
+
case 'APP_NOT_A_TEMPLATE':
|
|
1099
|
+
return new CliError(`${database} was not created from a database template`, {
|
|
1100
|
+
...base,
|
|
1101
|
+
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.',
|
|
1102
|
+
fix: target
|
|
1103
|
+
? `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.`
|
|
1104
|
+
: '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.',
|
|
1105
|
+
command: target ? `inquir apps update ${target} --set DATABASE_URL=<url>` : status,
|
|
1106
|
+
});
|
|
1107
|
+
case 'APP_PRIVATE_ENDPOINT_NOT_READY':
|
|
1108
|
+
return new CliError(`${database} has no verified private address yet`, {
|
|
1109
|
+
...base,
|
|
1110
|
+
explanation: error.message,
|
|
1111
|
+
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.',
|
|
1112
|
+
fix: `Retry in a minute. If it lasts, check that the database is serving: ${status}`,
|
|
1113
|
+
command: retry,
|
|
1114
|
+
});
|
|
1115
|
+
case 'APP_NO_TCP_ENDPOINT':
|
|
1116
|
+
return new CliError(error.message, {
|
|
1117
|
+
...base,
|
|
1118
|
+
// An older control plane also answers this for a database a worker
|
|
1119
|
+
// serves: it looked for the port only in the release's port mappings,
|
|
1120
|
+
// which such a release never has (src/apps/app-connection-endpoint.ts).
|
|
1121
|
+
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.',
|
|
1122
|
+
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.`,
|
|
1123
|
+
command: status,
|
|
1124
|
+
});
|
|
1125
|
+
case 'PRIVATE_NETWORK_NOT_CONFIGURED':
|
|
1126
|
+
return new CliError(error.message, {
|
|
1127
|
+
...base,
|
|
1128
|
+
cause: 'The control plane offers no private address for this database.',
|
|
1129
|
+
fix: target
|
|
1130
|
+
? '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.'
|
|
1131
|
+
: '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.',
|
|
1132
|
+
command: `inquir apps connection-url ${database} --public`,
|
|
1133
|
+
});
|
|
1134
|
+
case 'APP_NO_PRODUCTION_RELEASE':
|
|
1135
|
+
return new CliError(error.message, {
|
|
1136
|
+
...base,
|
|
1137
|
+
fix: 'Deploy the database first; a template database ships its first release when it is created.',
|
|
1138
|
+
command: status,
|
|
1139
|
+
});
|
|
1140
|
+
case 'APP_PRODUCTION_RELEASE_NOT_ACTIVE':
|
|
1141
|
+
return new CliError(error.message, {
|
|
1142
|
+
...base,
|
|
1143
|
+
fix: 'Wait until its release serves: on a worker runner the replica must start and pass its health check first.',
|
|
1144
|
+
command: status,
|
|
1145
|
+
});
|
|
1146
|
+
case 'APP_CREDENTIALS_MISSING':
|
|
1147
|
+
return new CliError(error.message, {
|
|
1148
|
+
...base,
|
|
1149
|
+
cause: 'The running release\'s env has no password under the template\'s key (POSTGRES_PASSWORD, REDIS_PASSWORD).',
|
|
1150
|
+
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.',
|
|
1151
|
+
command: `inquir apps update ${database} --set <POSTGRES_PASSWORD|REDIS_PASSWORD>=<password>`,
|
|
1152
|
+
});
|
|
1153
|
+
case 'ENV_KEY_ALREADY_SET':
|
|
1154
|
+
return new CliError(error.message, {
|
|
1155
|
+
...base,
|
|
1156
|
+
fix: 'Replace it on purpose: the new URL applies on the target\'s next release.',
|
|
1157
|
+
command: `${retry} --overwrite`,
|
|
1158
|
+
});
|
|
1159
|
+
default:
|
|
1160
|
+
return error;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
732
1163
|
async function connectSubcommand(args, positionals, apiOptions) {
|
|
733
1164
|
const [dbIdentifier, targetIdentifier] = positionals;
|
|
734
1165
|
if (!dbIdentifier || !targetIdentifier) {
|
|
@@ -740,7 +1171,9 @@ async function connectSubcommand(args, positionals, apiOptions) {
|
|
|
740
1171
|
// is how an app is repointed at the database's current private address
|
|
741
1172
|
// (the composed URL never passes through this terminal).
|
|
742
1173
|
const overwrite = args.includes('--overwrite');
|
|
743
|
-
const result = await connectApps(db.id, targetIdentifier, { ...apiOptions, overwrite })
|
|
1174
|
+
const result = await connectApps(db.id, targetIdentifier, { ...apiOptions, overwrite }).catch((error) => {
|
|
1175
|
+
throw explainDatabaseConnectionError(error, { database: db.name, target: targetIdentifier });
|
|
1176
|
+
});
|
|
744
1177
|
if (readJsonFlag(args)) {
|
|
745
1178
|
return printLine(JSON.stringify(result, null, 2));
|
|
746
1179
|
}
|
|
@@ -753,7 +1186,9 @@ async function connectionUrlSubcommand(args, positionals, apiOptions) {
|
|
|
753
1186
|
throw usageError('Missing application', 'inquir apps connection-url mydb');
|
|
754
1187
|
await requireAuth(apiOptions);
|
|
755
1188
|
const app = await resolveApplication(identifier, apiOptions);
|
|
756
|
-
const result = await getAppConnectionUrl(app.id, apiOptions)
|
|
1189
|
+
const result = await getAppConnectionUrl(app.id, apiOptions).catch((error) => {
|
|
1190
|
+
throw explainDatabaseConnectionError(error, { database: app.name });
|
|
1191
|
+
});
|
|
757
1192
|
const requirePrivate = args.includes('--private');
|
|
758
1193
|
const requirePublic = args.includes('--public');
|
|
759
1194
|
if (requirePrivate && requirePublic) {
|
|
@@ -788,9 +1223,12 @@ export function selectConnectionUrl(result, preference = 'auto') {
|
|
|
788
1223
|
return { url: result.publicUrl, visibility: 'public' };
|
|
789
1224
|
}
|
|
790
1225
|
const requested = preference === 'auto' ? 'private or public' : preference;
|
|
791
|
-
|
|
1226
|
+
const reason = preference !== 'public' ? result.privateUnavailable : undefined;
|
|
1227
|
+
throw usageError(`No ${requested} database connection URL is available${reason ? `: ${reason.message}` : ''}`, preference === 'public'
|
|
792
1228
|
? 'Enable Public Access in the application Networking settings.'
|
|
793
|
-
:
|
|
1229
|
+
: reason?.code === 'APP_PRIVATE_ENDPOINT_NOT_READY'
|
|
1230
|
+
? 'Retry in a minute; the private address is verified once the database serves.'
|
|
1231
|
+
: 'Configure private networking, or use --public after explicitly enabling Public Access.');
|
|
794
1232
|
}
|
|
795
1233
|
async function deploySubcommand(args, positionals, apiOptions) {
|
|
796
1234
|
const [identifier] = positionals;
|
|
@@ -960,7 +1398,11 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
|
|
|
960
1398
|
const buildArgs = parseSetFlags(readMultiFlag(args, '--build-arg'));
|
|
961
1399
|
if (!json)
|
|
962
1400
|
printInfo(dim(`— packing ${dir} —`));
|
|
963
|
-
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
|
+
});
|
|
964
1406
|
const queued = await queueBuild(applicationId, {
|
|
965
1407
|
sourceZipBase64,
|
|
966
1408
|
source: 'cli',
|
|
@@ -977,65 +1419,242 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
|
|
|
977
1419
|
? `Build ${queued.buildId.slice(0, 8)} reused (identical source already built)`
|
|
978
1420
|
: `Build ${queued.buildId.slice(0, 8)} queued for ${appName}`);
|
|
979
1421
|
}
|
|
980
|
-
|
|
981
|
-
|
|
1422
|
+
return waitForBuild(queued.buildId, apiOptions, {
|
|
1423
|
+
json, initial: queued, noLogs: queued.deduplicated || isTerminalBuildStatus(queued.status),
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
const BUILD_WAIT_PHASE = { QUEUED: 'queued', EXECUTING: 'building' };
|
|
1427
|
+
const BUILD_WAIT_ERROR = { QUEUE: 'BUILD_QUEUE_TIMEOUT', EXECUTION: 'BUILD_TIMEOUT', STATUS_REQUEST: 'BUILD_STATUS_TIMEOUT' };
|
|
1428
|
+
const BUILD_STATUS_REQUEST_TIMEOUT_MS = 15_000;
|
|
1429
|
+
const BUILD_STATUS_POLL_INTERVAL_MS = 1_000;
|
|
1430
|
+
const BUILD_STATUS_RETRY_BACKOFF_MS = 2_000;
|
|
1431
|
+
const BUILD_LOG_DRAIN_TIMEOUT_MS = 250;
|
|
1432
|
+
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
1433
|
+
const HTTP_TOO_MANY_REQUESTS = 429;
|
|
1434
|
+
const HTTP_SERVER_ERROR_MIN = 500;
|
|
1435
|
+
function buildWaitPolicy(value) {
|
|
1436
|
+
if (!value || !Number.isSafeInteger(value.executionTimeoutMs) || value.executionTimeoutMs < 1 ||
|
|
1437
|
+
!Number.isSafeInteger(value.queueTimeoutMs) || value.queueTimeoutMs < 1 ||
|
|
1438
|
+
!Number.isSafeInteger(value.completionGraceMs) || value.completionGraceMs < 0 ||
|
|
1439
|
+
value.executionTimeoutMs > MAX_TIMER_DELAY_MS || value.queueTimeoutMs > MAX_TIMER_DELAY_MS ||
|
|
1440
|
+
value.completionGraceMs > MAX_TIMER_DELAY_MS) {
|
|
1441
|
+
return DEFAULT_BUILD_TIMEOUT_POLICY;
|
|
982
1442
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1443
|
+
return value;
|
|
1444
|
+
}
|
|
1445
|
+
function buildWaitSleep(ms, signal) {
|
|
1446
|
+
signal.throwIfAborted();
|
|
1447
|
+
return new Promise((resolve, reject) => {
|
|
1448
|
+
const onAbort = () => { clearTimeout(timer); signal.removeEventListener('abort', onAbort); reject(signal.reason); };
|
|
1449
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms);
|
|
1450
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1451
|
+
});
|
|
1452
|
+
}
|
|
1453
|
+
/** Authoritative phase observation, independent of an open or broken log stream. */
|
|
1454
|
+
export async function waitForBuild(buildId, apiOptions, options = {}) {
|
|
1455
|
+
const clock = () => globalThis.performance.now();
|
|
1456
|
+
const controller = new AbortController();
|
|
1457
|
+
const initialPolicy = buildWaitPolicy(options.initial?.timeoutPolicy);
|
|
1458
|
+
const overallDeadline = clock() + initialPolicy.queueTimeoutMs + initialPolicy.executionTimeoutMs + initialPolicy.completionGraceMs;
|
|
1459
|
+
let phase = options.initial?.status === BUILD_WAIT_PHASE.EXECUTING
|
|
1460
|
+
? BUILD_WAIT_PHASE.EXECUTING : BUILD_WAIT_PHASE.QUEUED;
|
|
1461
|
+
let deadline = overallDeadline;
|
|
1462
|
+
let timer;
|
|
1463
|
+
let expire;
|
|
1464
|
+
const expired = new Promise((_, reject) => { expire = reject; });
|
|
1465
|
+
function observe(value) {
|
|
1466
|
+
const next = value.status === BUILD_WAIT_PHASE.EXECUTING ? BUILD_WAIT_PHASE.EXECUTING : BUILD_WAIT_PHASE.QUEUED;
|
|
1467
|
+
// A stale queued observation cannot restart an already-running budget.
|
|
1468
|
+
if (phase === BUILD_WAIT_PHASE.EXECUTING && next === BUILD_WAIT_PHASE.QUEUED)
|
|
1469
|
+
return;
|
|
1470
|
+
const policy = buildWaitPolicy(value.timeoutPolicy ?? initialPolicy);
|
|
1471
|
+
const cap = next === BUILD_WAIT_PHASE.QUEUED ? policy.queueTimeoutMs : policy.executionTimeoutMs + policy.completionGraceMs;
|
|
1472
|
+
const remaining = typeof value.timeoutRemainingMs === 'number' && Number.isFinite(value.timeoutRemainingMs) && value.timeoutRemainingMs >= 0
|
|
1473
|
+
? Math.min(value.timeoutRemainingMs, cap) : cap;
|
|
1474
|
+
const candidate = Math.min(overallDeadline, clock() + remaining);
|
|
1475
|
+
deadline = next === phase ? Math.min(deadline, candidate) : candidate;
|
|
1476
|
+
phase = next;
|
|
1477
|
+
armDeadline();
|
|
1478
|
+
}
|
|
1479
|
+
function armDeadline() {
|
|
1480
|
+
clearTimeout(timer);
|
|
1481
|
+
timer = setTimeout(() => {
|
|
1482
|
+
// Node clamps oversized delays to 1ms. Keep long valid policies bounded
|
|
1483
|
+
// by scheduling slices, and tolerate a timer waking before its deadline.
|
|
1484
|
+
if (clock() < deadline) {
|
|
1485
|
+
armDeadline();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
const error = new CliError(`Build ${buildId} exceeded the CLI ${phase === BUILD_WAIT_PHASE.QUEUED ? 'queue' : 'execution'} wait deadline`, {
|
|
1489
|
+
code: phase === BUILD_WAIT_PHASE.QUEUED ? BUILD_WAIT_ERROR.QUEUE : BUILD_WAIT_ERROR.EXECUTION, exitCode: ExitCode.ERROR,
|
|
1490
|
+
explanation: `The CLI stopped waiting for build ${buildId}; it has not cancelled the remote build. Check this build before retrying.`,
|
|
1491
|
+
fix: `Inspect its logs: inquir apps logs ${buildId}`,
|
|
1492
|
+
});
|
|
1493
|
+
expire(error);
|
|
1494
|
+
controller.abort(error);
|
|
1495
|
+
}, Math.min(MAX_TIMER_DELAY_MS, Math.max(0, deadline - clock())));
|
|
1496
|
+
}
|
|
1497
|
+
async function statusRequest() {
|
|
1498
|
+
controller.signal.throwIfAborted();
|
|
1499
|
+
const request = new AbortController();
|
|
1500
|
+
const onAbort = () => request.abort(controller.signal.reason);
|
|
1501
|
+
controller.signal.addEventListener('abort', onAbort, { once: true });
|
|
1502
|
+
let requestTimer;
|
|
1503
|
+
const timedOut = new Promise((_, reject) => {
|
|
1504
|
+
requestTimer = setTimeout(() => {
|
|
1505
|
+
const error = new CliError('Build status request timed out', { code: BUILD_WAIT_ERROR.STATUS_REQUEST, exitCode: ExitCode.NETWORK });
|
|
1506
|
+
reject(error);
|
|
1507
|
+
request.abort(error);
|
|
1508
|
+
}, Math.min(BUILD_STATUS_REQUEST_TIMEOUT_MS, Math.max(0, deadline - clock())));
|
|
1509
|
+
});
|
|
1510
|
+
try {
|
|
1511
|
+
return await Promise.race([getBuild(buildId, apiOptions, request.signal), timedOut]);
|
|
990
1512
|
}
|
|
991
|
-
|
|
992
|
-
|
|
1513
|
+
finally {
|
|
1514
|
+
clearTimeout(requestTimer);
|
|
1515
|
+
controller.signal.removeEventListener('abort', onAbort);
|
|
1516
|
+
request.abort();
|
|
993
1517
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
1518
|
+
}
|
|
1519
|
+
async function poll() {
|
|
1520
|
+
for (;;) {
|
|
1521
|
+
controller.signal.throwIfAborted();
|
|
1522
|
+
try {
|
|
1523
|
+
const build = await statusRequest();
|
|
1524
|
+
controller.signal.throwIfAborted();
|
|
1525
|
+
if (isTerminalBuildStatus(build.status))
|
|
1526
|
+
return build;
|
|
1527
|
+
observe(build);
|
|
1528
|
+
}
|
|
1529
|
+
catch (error) {
|
|
1530
|
+
controller.signal.throwIfAborted();
|
|
1531
|
+
const retryable = error instanceof ApiError ? error.status === HTTP_TOO_MANY_REQUESTS || error.status >= HTTP_SERVER_ERROR_MIN : toCliError(error).exitCode === ExitCode.NETWORK;
|
|
1532
|
+
if (!retryable)
|
|
1533
|
+
throw error;
|
|
1534
|
+
await buildWaitSleep(BUILD_STATUS_RETRY_BACKOFF_MS, controller.signal);
|
|
1535
|
+
}
|
|
1536
|
+
await buildWaitSleep(BUILD_STATUS_POLL_INTERVAL_MS, controller.signal);
|
|
997
1537
|
}
|
|
998
1538
|
}
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1539
|
+
async function consumeLogs() {
|
|
1540
|
+
if (options.noLogs)
|
|
1541
|
+
return;
|
|
1542
|
+
try {
|
|
1543
|
+
for await (const event of streamBuildLogs(buildId, apiOptions, controller.signal)) {
|
|
1544
|
+
if (controller.signal.aborted)
|
|
1545
|
+
break;
|
|
1546
|
+
if (event.type === 'log' && event.line) {
|
|
1547
|
+
if (options.json)
|
|
1548
|
+
process.stderr.write(`${redact(event.line)}\n`);
|
|
1549
|
+
else
|
|
1550
|
+
printLine(dim(` ${event.line}`));
|
|
1551
|
+
}
|
|
1552
|
+
else if (event.type === 'complete' || event.type === 'error')
|
|
1553
|
+
break;
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
catch {
|
|
1557
|
+
// Logs are best effort. Only the build row decides the result.
|
|
1013
1558
|
}
|
|
1014
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
1015
1559
|
}
|
|
1560
|
+
observe(options.initial ?? { status: BUILD_WAIT_PHASE.QUEUED });
|
|
1561
|
+
const logs = consumeLogs();
|
|
1562
|
+
try {
|
|
1563
|
+
const result = await Promise.race([poll(), expired]);
|
|
1564
|
+
let drainTimer;
|
|
1565
|
+
try {
|
|
1566
|
+
await Promise.race([logs, new Promise((resolve) => {
|
|
1567
|
+
drainTimer = setTimeout(resolve, Math.max(0, Math.min(BUILD_LOG_DRAIN_TIMEOUT_MS, deadline - clock())));
|
|
1568
|
+
})]);
|
|
1569
|
+
}
|
|
1570
|
+
finally {
|
|
1571
|
+
clearTimeout(drainTimer);
|
|
1572
|
+
}
|
|
1573
|
+
return result;
|
|
1574
|
+
}
|
|
1575
|
+
finally {
|
|
1576
|
+
clearTimeout(timer);
|
|
1577
|
+
controller.abort();
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* A release only the allocation plane serves (volumes pinned to a worker) stays
|
|
1582
|
+
* STARTING while that runner refuses its starts, and the plane retries them
|
|
1583
|
+
* forever with backoff. After this many failed attempts the wait reports the
|
|
1584
|
+
* reason instead of sitting out its whole timeout.
|
|
1585
|
+
*/
|
|
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
|
+
};
|
|
1016
1617
|
}
|
|
1017
1618
|
/** Poll until the release settles; the platform decides when it is healthy. */
|
|
1018
1619
|
async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
|
|
1019
1620
|
const deadline = Date.now() + timeoutMs;
|
|
1020
1621
|
const startedAt = Date.now();
|
|
1021
|
-
let
|
|
1622
|
+
let lastProgress = '';
|
|
1022
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
|
+
});
|
|
1023
1629
|
for (;;) {
|
|
1024
1630
|
const release = await getRelease(releaseId, apiOptions);
|
|
1025
|
-
|
|
1026
|
-
|
|
1631
|
+
// A failed start on the pinned runner changes the reason, not the status.
|
|
1632
|
+
const reason = release.serving ? release.failureReason ?? '' : '';
|
|
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}`;
|
|
1027
1638
|
// Say what is happening in words, with elapsed time — a two-minute health
|
|
1028
1639
|
// gate in silence reads as a hang. Under --json only stdout must stay
|
|
1029
1640
|
// parseable; progress goes to stderr.
|
|
1030
1641
|
const shown = presentRelease(release);
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1642
|
+
const meaning = (phase && replacementPhaseMeaning(phase)) || shown.meaning;
|
|
1643
|
+
const lines = [
|
|
1644
|
+
`— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${meaning ? `: ${meaning}` : ''} —`,
|
|
1645
|
+
...(reason ? [` ${reason}`] : []),
|
|
1646
|
+
];
|
|
1647
|
+
for (const line of lines) {
|
|
1648
|
+
if (json)
|
|
1649
|
+
process.stderr.write(`${line}\n`);
|
|
1650
|
+
else
|
|
1651
|
+
printInfo(dim(line));
|
|
1652
|
+
}
|
|
1036
1653
|
}
|
|
1037
1654
|
if (isSettledReleaseStatus(release.status))
|
|
1038
1655
|
return release;
|
|
1656
|
+
if ((release.serving?.failedStarts ?? 0) >= FAILED_STARTS_BEFORE_REPORTING)
|
|
1657
|
+
return release;
|
|
1039
1658
|
if (Date.now() > deadline) {
|
|
1040
1659
|
throw new CliError(`Release ${releaseId} did not settle within ${Math.round(timeoutMs / 1000)}s`, {
|
|
1041
1660
|
code: 'RELEASE_TIMEOUT',
|
|
@@ -1106,17 +1725,30 @@ export function releaseEndpointLines(release, previewUrl, options = {}) {
|
|
|
1106
1725
|
*/
|
|
1107
1726
|
export function privateEndpointLines(detail, active, options = {}) {
|
|
1108
1727
|
if (!detail.privateDomain) {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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)'];
|
|
1112
1738
|
}
|
|
1113
1739
|
const lines = [];
|
|
1114
1740
|
if (detail.privateBaseUrl)
|
|
1115
1741
|
lines.push(detail.privateBaseUrl);
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1742
|
+
// The private proxy listens on a TCP port's container port. A container the
|
|
1743
|
+
// control plane started reports its ports on the release; a release the
|
|
1744
|
+
// allocation plane serves on a worker reports none (its endpoints live on
|
|
1745
|
+
// its allocations), so its declared TCP ports stand in.
|
|
1746
|
+
const reported = (active?.ports ?? []).filter((port) => port.protocol === 'tcp' && port.containerPort);
|
|
1747
|
+
const tcpPorts = reported.length > 0
|
|
1748
|
+
? reported.map((port) => ({ name: port.name, port: port.containerPort }))
|
|
1749
|
+
: (active?.runtime?.ports ?? []).filter((port) => port.protocol === 'tcp');
|
|
1750
|
+
for (const port of tcpPorts) {
|
|
1751
|
+
lines.push(`${detail.privateDomain}:${port.port} (${port.name})`);
|
|
1120
1752
|
}
|
|
1121
1753
|
return lines;
|
|
1122
1754
|
}
|
|
@@ -1180,6 +1812,18 @@ function formatDurationSeconds(seconds) {
|
|
|
1180
1812
|
}
|
|
1181
1813
|
return parts.join(' ');
|
|
1182
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
|
+
}
|
|
1183
1827
|
export function describeScalingPolicy(policy) {
|
|
1184
1828
|
const range = `${policy.minReplicas}–${policy.maxReplicas} replicas`;
|
|
1185
1829
|
if (policy.activation === 'http') {
|
|
@@ -1215,6 +1859,20 @@ async function statusSubcommand(args, positionals, apiOptions) {
|
|
|
1215
1859
|
const pinnedRunner = detail.volumeRunnerId;
|
|
1216
1860
|
if (pinnedRunner)
|
|
1217
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)}`);
|
|
1871
|
+
const servingRelease = detail.releases.find((r) => r.id === detail.production?.activeReleaseId);
|
|
1872
|
+
if (servingRelease?.serving && servingRelease.serving.ready < servingRelease.serving.desired) {
|
|
1873
|
+
// Production points at it, but its replica does not answer on the runner yet.
|
|
1874
|
+
printLine(` serving: not yet — ${servingRelease.failureReason ?? 'its replica is starting on the pinned runner'}`);
|
|
1875
|
+
}
|
|
1218
1876
|
if (detail.lastActivityAt)
|
|
1219
1877
|
printLine(` activity: ${detail.lastActivityAt}`);
|
|
1220
1878
|
if (detail.lastScaledAt)
|
|
@@ -1350,29 +2008,74 @@ async function logsSubcommand(args, positionals, apiOptions) {
|
|
|
1350
2008
|
const build = await getBuild(identifier, apiOptions).catch(() => null);
|
|
1351
2009
|
if (build) {
|
|
1352
2010
|
printInfo(dim(`— build ${build.id.slice(0, 8)} (${build.status}) —`));
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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
|
+
}
|
|
1367
2034
|
}
|
|
1368
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
|
+
}
|
|
1369
2046
|
return;
|
|
1370
2047
|
}
|
|
1371
2048
|
const releaseId = await resolveReleaseId(identifier, apiOptions);
|
|
1372
2049
|
const tailRaw = readFlagValue(args, '--tail');
|
|
1373
2050
|
const tail = tailRaw ? Number(tailRaw) : 200;
|
|
1374
|
-
|
|
1375
|
-
|
|
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)) {
|
|
1376
2079
|
if (event.type === 'log' && event.line) {
|
|
1377
2080
|
// Under --json the log tail goes to stderr so stdout stays parseable.
|
|
1378
2081
|
if (json)
|
|
@@ -1389,6 +2092,63 @@ async function logsSubcommand(args, positionals, apiOptions) {
|
|
|
1389
2092
|
}
|
|
1390
2093
|
}
|
|
1391
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
|
+
}
|
|
1392
2152
|
async function promoteSubcommand(args, positionals, apiOptions) {
|
|
1393
2153
|
const [identifier] = positionals;
|
|
1394
2154
|
if (!identifier)
|
|
@@ -1399,9 +2159,11 @@ async function promoteSubcommand(args, positionals, apiOptions) {
|
|
|
1399
2159
|
// Say up front what promotion does to this release: a preview-profile
|
|
1400
2160
|
// release with production-only variables is restarted with them before
|
|
1401
2161
|
// traffic moves, and its preview URL retires.
|
|
2162
|
+
let release = null;
|
|
2163
|
+
let app = null;
|
|
1402
2164
|
if (!json) {
|
|
1403
|
-
|
|
1404
|
-
|
|
2165
|
+
release = await getRelease(releaseId, apiOptions).catch(() => null);
|
|
2166
|
+
app = release ? await getApplication(release.applicationId, apiOptions).catch(() => null) : null;
|
|
1405
2167
|
if (release && release.envProfile !== 'production' && (app?.productionOnlyEnvKeys?.length ?? 0) > 0) {
|
|
1406
2168
|
printInfo(dim(' the container restarts with production-only variables before traffic moves'));
|
|
1407
2169
|
}
|
|
@@ -1418,6 +2180,12 @@ async function promoteSubcommand(args, positionals, apiOptions) {
|
|
|
1418
2180
|
err.message = humanizeApiError(err.message);
|
|
1419
2181
|
throw err;
|
|
1420
2182
|
}
|
|
2183
|
+
// 202: the server runs it as a rolling rollout and goes on in the
|
|
2184
|
+
// background. An older server always answers with the outcome below.
|
|
2185
|
+
if (isPromoteAccepted(result)) {
|
|
2186
|
+
await followPromoteRollout(releaseId, result, { json, wait: !hasFlag(args, '--no-wait') }, apiOptions);
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
1421
2189
|
if (json) {
|
|
1422
2190
|
printLine(JSON.stringify(result, null, 2));
|
|
1423
2191
|
return;
|
|
@@ -1427,12 +2195,33 @@ async function promoteSubcommand(args, positionals, apiOptions) {
|
|
|
1427
2195
|
printLine(` ${url}`);
|
|
1428
2196
|
}
|
|
1429
2197
|
if ((result.productionUrls ?? []).length === 0 && !result.productionUrl) {
|
|
1430
|
-
|
|
2198
|
+
for (const line of promotedWithoutHostnameLines(release, app))
|
|
2199
|
+
printInfo(dim(line));
|
|
1431
2200
|
}
|
|
1432
2201
|
if (result.previousReleaseId) {
|
|
1433
2202
|
printInfo(dim(` previous release ${result.previousReleaseId.slice(0, 8)} kept for rollback`));
|
|
1434
2203
|
}
|
|
1435
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
|
+
}
|
|
1436
2225
|
async function rollbackSubcommand(args, positionals, apiOptions) {
|
|
1437
2226
|
const [identifier] = positionals;
|
|
1438
2227
|
if (!identifier)
|
|
@@ -1572,7 +2361,8 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1572
2361
|
await requireAuth(apiOptions);
|
|
1573
2362
|
const app = await resolveApplication(identifier, apiOptions);
|
|
1574
2363
|
const envVars = parseSetFlags(readMultiFlag(args, '--set'));
|
|
1575
|
-
const
|
|
2364
|
+
const flagsRuntime = buildRuntimeFromFlags(args, app.runtime);
|
|
2365
|
+
const runtimeSpec = applyNodeSelectorFlags(args, flagsRuntime ?? app.runtime) ?? flagsRuntime;
|
|
1576
2366
|
const description = readFlagValue(args, '--description');
|
|
1577
2367
|
const networkPolicy = buildNetworkPolicyPatch({
|
|
1578
2368
|
egress: readFlagValue(args, '--egress'),
|
|
@@ -1580,9 +2370,11 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1580
2370
|
current: app.networkPolicy,
|
|
1581
2371
|
});
|
|
1582
2372
|
const volumeRunner = readFlagValue(args, '--volume-runner');
|
|
2373
|
+
const location = parseLocationFlag(readFlagValue(args, '--location'));
|
|
1583
2374
|
const patch = {
|
|
1584
2375
|
...(description !== undefined ? { description } : {}),
|
|
1585
2376
|
...(volumeRunner ? { volumeRunner } : {}),
|
|
2377
|
+
...(location !== undefined ? { location } : {}),
|
|
1586
2378
|
...(runtimeSpec ? { runtime: runtimeSpec } : {}),
|
|
1587
2379
|
// Only the keys given here change; the server merges masked values, so
|
|
1588
2380
|
// untouched secrets survive.
|
|
@@ -1598,7 +2390,43 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1598
2390
|
return;
|
|
1599
2391
|
}
|
|
1600
2392
|
printSuccess(`Updated ${updated.name}`);
|
|
1601
|
-
|
|
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;
|
|
1602
2430
|
}
|
|
1603
2431
|
async function execSubcommand(args, positionals, apiOptions) {
|
|
1604
2432
|
const [identifier, ...commandParts] = positionals;
|
|
@@ -1608,14 +2436,15 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1608
2436
|
if (!command)
|
|
1609
2437
|
throw usageError('Missing command', 'inquir apps exec <app> -- ls -la');
|
|
1610
2438
|
await requireAuth(apiOptions);
|
|
1611
|
-
|
|
1612
|
-
// preview release id here and then calling the production-only app endpoint
|
|
1613
|
-
// would silently change the operator's target to production.
|
|
1614
|
-
const application = await resolveApplication(identifier, apiOptions);
|
|
2439
|
+
const application = await resolveExecApplication(identifier, apiOptions, readJsonFlag(args));
|
|
1615
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
|
+
}
|
|
1616
2445
|
let result;
|
|
1617
2446
|
try {
|
|
1618
|
-
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() } : {});
|
|
1619
2448
|
}
|
|
1620
2449
|
catch (err) {
|
|
1621
2450
|
if (err instanceof Error)
|
|
@@ -1626,6 +2455,9 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1626
2455
|
printLine(JSON.stringify(result, null, 2));
|
|
1627
2456
|
}
|
|
1628
2457
|
else {
|
|
2458
|
+
if (replica && result.replicaIndex !== undefined) {
|
|
2459
|
+
printInfo(dim(`— replica ${result.replicaIndex}${result.allocationId ? ` (${result.allocationId.slice(0, 8)})` : ''} —`));
|
|
2460
|
+
}
|
|
1629
2461
|
if (result.stdout)
|
|
1630
2462
|
process.stdout.write(result.stdout);
|
|
1631
2463
|
if (result.stderr)
|
|
@@ -1636,18 +2468,68 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1636
2468
|
if (result.exitCode !== 0)
|
|
1637
2469
|
process.exitCode = ExitCode.ERROR;
|
|
1638
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
|
+
}
|
|
1639
2508
|
function printVerificationInstructions(domain) {
|
|
1640
2509
|
const v = domain.verification;
|
|
1641
2510
|
if (!v)
|
|
1642
2511
|
return;
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2512
|
+
// A verified hostname keeps its records on display: they are what gets compared with the
|
|
2513
|
+
// registrar when the name stops resolving.
|
|
2514
|
+
printInfo(dim(domain.verified
|
|
2515
|
+
? ` ${domain.hostname} — verified with these records (the routing record must stay in place):`
|
|
2516
|
+
: ` ${domain.hostname} — prove ownership, then verify:`));
|
|
2517
|
+
const row = (label, name, value) => printLine(` ${label.padEnd(8)} ${name} → ${value}`);
|
|
2518
|
+
row('TXT', v.txtName, v.txtValue);
|
|
2519
|
+
// One target, not a menu: every entry is accepted by verify and leads to the same ingress. The first is the
|
|
2520
|
+
// app's own hostname when an apps suffix is configured, otherwise the platform target.
|
|
2521
|
+
if (v.cnameTargets[0])
|
|
2522
|
+
row('CNAME', domain.hostname, v.cnameTargets[0]);
|
|
1647
2523
|
for (const ip of v.ingressIps)
|
|
1648
|
-
|
|
2524
|
+
row('A', domain.hostname, ip);
|
|
1649
2525
|
if (v.ingressAliasTarget)
|
|
1650
|
-
|
|
2526
|
+
row('ALIAS', domain.hostname, v.ingressAliasTarget);
|
|
2527
|
+
if (v.apexCnameTargets?.length) {
|
|
2528
|
+
printInfo(dim(domain.verified
|
|
2529
|
+
? ' verified by the TXT record alone — where the apex points was never checked; traffic reaches the app only through ALIAS/ANAME/CNAME flattening onto:'
|
|
2530
|
+
: ' an apex domain is verified by the TXT record alone; point it with ALIAS/ANAME/CNAME flattening so traffic reaches the app:'));
|
|
2531
|
+
row('ALIAS', domain.hostname, v.apexCnameTargets[0]);
|
|
2532
|
+
}
|
|
1651
2533
|
}
|
|
1652
2534
|
/**
|
|
1653
2535
|
* `apps domain <app> [hostname] [--verify|--remove]` — bind a custom hostname
|
|
@@ -1678,8 +2560,7 @@ async function domainSubcommand(args, positionals, apiOptions) {
|
|
|
1678
2560
|
verified: d.verified ? 'yes' : 'no',
|
|
1679
2561
|
})), ['hostname', 'kind', 'source', 'verified']));
|
|
1680
2562
|
for (const d of items)
|
|
1681
|
-
|
|
1682
|
-
printVerificationInstructions(d);
|
|
2563
|
+
printVerificationInstructions(d);
|
|
1683
2564
|
return;
|
|
1684
2565
|
}
|
|
1685
2566
|
if (hasFlag(args, '--remove')) {
|
|
@@ -1704,6 +2585,14 @@ async function domainSubcommand(args, positionals, apiOptions) {
|
|
|
1704
2585
|
printLine(JSON.stringify(domain, null, 2));
|
|
1705
2586
|
return;
|
|
1706
2587
|
}
|
|
2588
|
+
if (domain.verified) {
|
|
2589
|
+
// An operator-granted platform hostname verifies on bind: no records to publish.
|
|
2590
|
+
printSuccess(`${domain.hostname} bound to ${app.name} and verified — it now serves ${app.name}`);
|
|
2591
|
+
if (!domain.activeReleaseId) {
|
|
2592
|
+
printInfo(dim(' no release is promoted yet: inquir apps promote <release>'));
|
|
2593
|
+
}
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
1707
2596
|
printSuccess(`${domain.hostname} bound to ${app.name} (unverified)`);
|
|
1708
2597
|
printVerificationInstructions(domain);
|
|
1709
2598
|
printInfo(dim(` then: inquir apps domain ${app.name} ${domain.hostname} --verify`));
|