@inquir/compute-cli 0.4.0 → 0.4.2
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 +667 -619
- package/dist/commands/admin.d.ts.map +1 -1
- package/dist/commands/admin.js +7 -5
- 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 +108 -5
- package/dist/commands/apps.d.ts.map +1 -1
- package/dist/commands/apps.js +681 -67
- package/dist/commands/apps.js.map +1 -1
- package/dist/commands/deploy.d.ts +13 -0
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +80 -8
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/functions.d.ts +7 -0
- package/dist/commands/functions.d.ts.map +1 -0
- package/dist/commands/functions.js +80 -0
- package/dist/commands/functions.js.map +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +21 -16
- package/dist/commands/init.js.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/api.d.ts +34 -12
- package/dist/utils/api.d.ts.map +1 -1
- package/dist/utils/api.js +41 -5
- 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.d.ts +6 -0
- package/dist/utils/package-metadata.d.ts.map +1 -1
- package/dist/utils/package-metadata.js +9 -1
- package/dist/utils/package-metadata.js.map +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/dist/utils/system-config.d.ts +9 -0
- package/dist/utils/system-config.d.ts.map +1 -0
- package/dist/utils/system-config.js +48 -0
- package/dist/utils/system-config.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,
|
|
@@ -26,6 +29,7 @@ const VALUE_FLAGS = [
|
|
|
26
29
|
'--volume-runner',
|
|
27
30
|
'--env',
|
|
28
31
|
'--set',
|
|
32
|
+
'--unset',
|
|
29
33
|
'--memory',
|
|
30
34
|
'--cpu',
|
|
31
35
|
'--drain-grace',
|
|
@@ -52,6 +56,8 @@ const VALUE_FLAGS = [
|
|
|
52
56
|
'--stabilization',
|
|
53
57
|
'--scale-down-stabilization',
|
|
54
58
|
'--replicas',
|
|
59
|
+
'--node-selector',
|
|
60
|
+
'--location',
|
|
55
61
|
];
|
|
56
62
|
export async function appsCommand(args) {
|
|
57
63
|
const [subcommand, ...rest] = readPositionalArgs(args, { valueFlags: VALUE_FLAGS });
|
|
@@ -96,6 +102,8 @@ export async function appsCommand(args) {
|
|
|
96
102
|
return resourcesSubcommand(args, rest, apiOptions);
|
|
97
103
|
case 'describe':
|
|
98
104
|
return describeSubcommand(args, rest, apiOptions);
|
|
105
|
+
case 'instances':
|
|
106
|
+
return instancesSubcommand(args, rest, apiOptions);
|
|
99
107
|
case 'promote':
|
|
100
108
|
return promoteSubcommand(args, rest, apiOptions);
|
|
101
109
|
case 'rollback':
|
|
@@ -144,7 +152,8 @@ function printAppsHelp() {
|
|
|
144
152
|
'builds <app> List image builds',
|
|
145
153
|
'status <app> Endpoints and recent releases',
|
|
146
154
|
'describe <app> Why it is (not) running: conditions, placement, events',
|
|
147
|
-
'
|
|
155
|
+
'instances <app> Replicas (like pods): name, state, runner, CPU/RAM now',
|
|
156
|
+
'logs <app|release|build> Recent container (or build) logs, then follow (--no-follow exits)',
|
|
148
157
|
'log-history <app> Persisted runtime or HTTP logs with time/replica filters',
|
|
149
158
|
'metrics <app> CPU/RAM, network, disk I/O or HTTP history (--json)',
|
|
150
159
|
'alerts <app> [action] Configure resource/deployment alerts and read incidents',
|
|
@@ -152,29 +161,36 @@ function printAppsHelp() {
|
|
|
152
161
|
'promote <release> Send production traffic to a release',
|
|
153
162
|
'rollback <app> Return production to the previous release',
|
|
154
163
|
'stop <release> Stop a release that is not serving production',
|
|
155
|
-
'exec <
|
|
156
|
-
'update <app> Change env vars, ports, resources, egress',
|
|
164
|
+
'exec <app> [--replica <r>] -- <cmd> Run a command in production (one replica: name or index)',
|
|
165
|
+
'update <app> Change env vars, ports, resources, egress, node selector',
|
|
157
166
|
'scale <app> [mode] Set always/http/manual scaling (--replicas for always)',
|
|
158
167
|
'wake <app> Start a sleeping application',
|
|
159
168
|
'pause <app> Stop an http/manual app (HTTP wakes only http mode)',
|
|
160
|
-
|
|
169
|
+
"domain <app> [hostname] Bind a hostname (or list them); --verify / --remove; '*.example.com' needs --yes (not recommended)",
|
|
161
170
|
'delete <app> Archive an application (stops every release)',
|
|
162
171
|
'',
|
|
163
172
|
'Create/deploy flags:',
|
|
164
173
|
'--project <project> create: this project by id, slug or unique name (overrides projects use)',
|
|
165
174
|
'--image <ref> Container image, e.g. nginx:alpine (omit to build from source)',
|
|
166
175
|
'--promote deploy: take production by itself once healthy (needs apps:promote)',
|
|
167
|
-
'--template <name> postgres | redis: volume,
|
|
176
|
+
'--template <name> postgres | redis: volume, private TCP endpoint, generated password, first release',
|
|
168
177
|
'--dir <path> Source directory to build (default: current directory)',
|
|
169
178
|
'--dockerfile <path> Dockerfile relative to the context (default: Dockerfile)',
|
|
170
179
|
'--context <path> Build context inside the source tree (default: .)',
|
|
171
180
|
'--build-arg KEY=VALUE Docker build argument (repeatable)',
|
|
172
181
|
'--build <buildId> Release an existing successful build',
|
|
173
182
|
'--port <n> HTTP port (omit for a TCP-only service)',
|
|
174
|
-
'--tcp <name:port[:public]> Extra TCP port
|
|
183
|
+
'--tcp <name:port[:public]> Extra TCP port on the private network (repeatable); :public (or a\n' +
|
|
184
|
+
' numeric host-port pin, which implies it) is refused while the server\n' +
|
|
185
|
+
' offers no public TCP (inquir.org: not at the moment)',
|
|
175
186
|
'--volume <name:/path[:ro]> Persistent volume mount (repeatable)',
|
|
176
187
|
'--volume-runner <id> Pin volumes (and the container) to a worker runner',
|
|
177
|
-
'--
|
|
188
|
+
'--node-selector KEY=VALUE update: run only on servers with this node label (repeatable),',
|
|
189
|
+
' e.g. inquir.io/region=eu; --clear-node-selector removes it',
|
|
190
|
+
'--location <id|auto> update (experimental): run in this location, e.g. nl or ru; auto',
|
|
191
|
+
' runs wherever there is capacity. Applies from the next deploy',
|
|
192
|
+
'--set KEY=VALUE create/update: environment variable (repeatable)',
|
|
193
|
+
'--unset KEY update: remove an environment variable and its production-only marker (repeatable)',
|
|
178
194
|
'--memory <mb> Memory ceiling (default 512)',
|
|
179
195
|
'--cpu <vcpus> CPU ceiling (default 0.5)',
|
|
180
196
|
'--drain-grace <seconds> Time the old container gets after SIGTERM before it is killed (0-3600)',
|
|
@@ -195,8 +211,7 @@ function printAppsHelp() {
|
|
|
195
211
|
'inquir apps create web --port 3000',
|
|
196
212
|
'inquir apps deploy web # builds ./Dockerfile, then releases it',
|
|
197
213
|
'inquir apps deploy web --image nginx:alpine',
|
|
198
|
-
'inquir apps
|
|
199
|
-
' --health-command "redis-cli ping"',
|
|
214
|
+
'inquir apps create cache --template redis # volume + private TCP endpoint',
|
|
200
215
|
'inquir apps deploy db --image postgres:16 --volume data:/var/lib/postgresql/data',
|
|
201
216
|
'inquir apps scale web http --idle-timeout 15m',
|
|
202
217
|
'inquir apps wake web',
|
|
@@ -207,16 +222,24 @@ function printAppsHelp() {
|
|
|
207
222
|
]);
|
|
208
223
|
}
|
|
209
224
|
/**
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
225
|
+
* Public TCP availability, for hints. Grep marker for when cluster L4 ingress
|
|
226
|
+
* lands: PUBLIC_TCP_UNAVAILABLE (server error code, docs and copy say "not
|
|
227
|
+
* available at the moment").
|
|
228
|
+
*/
|
|
229
|
+
const PUBLIC_TCP_NOTE = 'public TCP ports are not available on inquir.org at the moment (PUBLIC_TCP_UNAVAILABLE), so a client outside the platform has no public URL: run it as an application of this workspace';
|
|
230
|
+
/**
|
|
231
|
+
* `--tcp name:port[:public][:hostPort]` — ordinary usage stops at `name:port`
|
|
232
|
+
* (private) or `public` (the platform leases a stable endpoint port). The
|
|
233
|
+
* final numeric modifier remains an API/CLI compatibility escape hatch for an
|
|
234
|
+
* existing installation; it implies `public`, so a server without public TCP
|
|
235
|
+
* refuses it exactly like `:public` (400 PUBLIC_TCP_UNAVAILABLE).
|
|
213
236
|
*/
|
|
214
237
|
export function parseTcpPortFlag(value) {
|
|
215
238
|
const parts = value.split(':');
|
|
216
239
|
const [name, portRaw, ...flags] = parts;
|
|
217
240
|
const port = Number(portRaw);
|
|
218
241
|
if (!name || !Number.isInteger(port) || port < 1 || port > 65535) {
|
|
219
|
-
throw usageError(`Invalid --tcp value '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432
|
|
242
|
+
throw usageError(`Invalid --tcp value '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432');
|
|
220
243
|
}
|
|
221
244
|
const spec = { name, port, protocol: 'tcp' };
|
|
222
245
|
for (const flag of flags) {
|
|
@@ -231,10 +254,81 @@ export function parseTcpPortFlag(value) {
|
|
|
231
254
|
spec.public = true;
|
|
232
255
|
continue;
|
|
233
256
|
}
|
|
234
|
-
throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432
|
|
257
|
+
throw usageError(`Invalid --tcp modifier '${flag}' in '${value}'`, 'inquir apps deploy <app> --tcp postgres:5432');
|
|
235
258
|
}
|
|
236
259
|
return spec;
|
|
237
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* `--location <id|auto>`: the service's location, verbatim; the server says
|
|
263
|
+
* which ids it offers. Undefined when the flag is absent.
|
|
264
|
+
*/
|
|
265
|
+
export function parseLocationFlag(value) {
|
|
266
|
+
if (value === undefined)
|
|
267
|
+
return undefined;
|
|
268
|
+
const location = value.trim().toLowerCase();
|
|
269
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/.test(location)) {
|
|
270
|
+
throw usageError(`Invalid --location value '${value}'`, 'inquir apps update <app> --location nl (or --location auto)');
|
|
271
|
+
}
|
|
272
|
+
return location;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The status line of a service's location: its setting by name, what
|
|
276
|
+
* production runs with while a change waits for the next deploy, and where
|
|
277
|
+
* the replicas run. Undefined while the platform offers no location choice.
|
|
278
|
+
*/
|
|
279
|
+
export function describeServiceLocation(status) {
|
|
280
|
+
if (!status.enabled)
|
|
281
|
+
return undefined;
|
|
282
|
+
const name = (id) => (id === 'auto' ? 'Auto' : status.locations.find((entry) => entry.id === id)?.name ?? id);
|
|
283
|
+
const parts = [status.setting === 'auto' ? 'Auto (wherever there is capacity)' : name(status.setting)];
|
|
284
|
+
if (status.pending && status.applied !== null) {
|
|
285
|
+
parts.push(`applies on the next deploy (production runs ${status.applied === 'auto' ? 'without a location' : `in ${name(status.applied)}`})`);
|
|
286
|
+
}
|
|
287
|
+
const running = [...new Set(status.replicas.map((replica) => (replica.location ? name(replica.location) : 'a server without a location')))];
|
|
288
|
+
if (running.length > 0)
|
|
289
|
+
parts.push(`running in ${running.join(', ')}`);
|
|
290
|
+
return parts.join(' — ');
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* `--node-selector KEY=VALUE` (repeatable): the node labels a server must
|
|
294
|
+
* carry, verbatim (`inquir.io/region=eu`, `tier=fast`). The server validates
|
|
295
|
+
* keys and values and says which keys it exposes.
|
|
296
|
+
*/
|
|
297
|
+
export function parseNodeSelectorFlags(values) {
|
|
298
|
+
const selector = {};
|
|
299
|
+
for (const entry of values) {
|
|
300
|
+
const index = entry.indexOf('=');
|
|
301
|
+
const key = index > 0 ? entry.slice(0, index).trim() : '';
|
|
302
|
+
if (!key) {
|
|
303
|
+
throw usageError(`Invalid --node-selector value '${entry}'`, 'inquir apps update <app> --node-selector inquir.io/region=eu');
|
|
304
|
+
}
|
|
305
|
+
if (Object.hasOwn(selector, key)) {
|
|
306
|
+
throw usageError(`--node-selector repeats the key '${key}'`, 'inquir apps update <app> --node-selector inquir.io/region=eu');
|
|
307
|
+
}
|
|
308
|
+
selector[key] = entry.slice(index + 1).trim();
|
|
309
|
+
}
|
|
310
|
+
return selector;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The runtime defaults with `--node-selector` / `--clear-node-selector`
|
|
314
|
+
* applied over `base` (the flags' runtime, else the stored one); undefined
|
|
315
|
+
* when neither flag is given.
|
|
316
|
+
*/
|
|
317
|
+
export function applyNodeSelectorFlags(args, base) {
|
|
318
|
+
const values = readMultiFlag(args, '--node-selector');
|
|
319
|
+
const clear = hasFlag(args, '--clear-node-selector');
|
|
320
|
+
if (values.length === 0 && !clear)
|
|
321
|
+
return undefined;
|
|
322
|
+
if (values.length > 0 && clear) {
|
|
323
|
+
throw usageError('Use either --node-selector or --clear-node-selector', 'inquir apps update <app> --clear-node-selector');
|
|
324
|
+
}
|
|
325
|
+
if (!base) {
|
|
326
|
+
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');
|
|
327
|
+
}
|
|
328
|
+
const rest = { ...base };
|
|
329
|
+
delete rest.nodeSelector;
|
|
330
|
+
return clear ? rest : { ...rest, nodeSelector: parseNodeSelectorFlags(values) };
|
|
331
|
+
}
|
|
238
332
|
export function parseSetFlags(values) {
|
|
239
333
|
const env = {};
|
|
240
334
|
for (const entry of values) {
|
|
@@ -340,7 +434,7 @@ export function parseVolumeFlag(value) {
|
|
|
340
434
|
let mountPath = separator > 0 ? value.slice(separator + 1) : '';
|
|
341
435
|
let readOnly = false;
|
|
342
436
|
if (/^[A-Za-z]:[\\/]/.test(mountPath)) {
|
|
343
|
-
throw usageError(`Invalid --volume mount path '${mountPath}'`,
|
|
437
|
+
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
438
|
}
|
|
345
439
|
const modifierAt = mountPath.lastIndexOf(':');
|
|
346
440
|
if (modifierAt >= 0) {
|
|
@@ -419,7 +513,12 @@ export function buildRuntimeFromFlags(args, baseRuntime) {
|
|
|
419
513
|
const memory = readFlagValue(args, '--memory');
|
|
420
514
|
const cpu = readFlagValue(args, '--cpu');
|
|
421
515
|
const drainGraceRaw = readFlagValue(args, '--drain-grace');
|
|
422
|
-
const
|
|
516
|
+
const healthPathRaw = readFlagValue(args, '--health-path');
|
|
517
|
+
const healthPathArgument = healthPathRaw !== undefined ? containerPathArgument(healthPathRaw, '--health-path') : undefined;
|
|
518
|
+
if (healthPathArgument?.recoveredFrom) {
|
|
519
|
+
printWarning(`--health-path: read '${healthPathArgument.recoveredFrom}' as ${healthPathArgument.value} (Git Bash path conversion). ${MSYS_PATH_HINT}`);
|
|
520
|
+
}
|
|
521
|
+
const healthPath = healthPathArgument?.value;
|
|
423
522
|
const healthCommand = readFlagValue(args, '--health-command');
|
|
424
523
|
const ports = tcpValues.map(parseTcpPortFlag);
|
|
425
524
|
const volumes = volumeValues.map(parseVolumeFlag);
|
|
@@ -589,15 +688,129 @@ async function landingProjectLines(app, requestedProject, apiOptions) {
|
|
|
589
688
|
}
|
|
590
689
|
const TEMPLATE_NAMES = ['postgres', 'redis'];
|
|
591
690
|
/** `apps <sub> --help` answered per subcommand; the global page is the fallback. */
|
|
592
|
-
const SUBCOMMAND_HELP = {
|
|
691
|
+
export const SUBCOMMAND_HELP = {
|
|
593
692
|
...OBSERVATION_HELP,
|
|
594
693
|
...RESOURCES_HELP,
|
|
595
694
|
...DESCRIBE_HELP,
|
|
695
|
+
...INSTANCES_HELP,
|
|
596
696
|
logs: [
|
|
597
|
-
'Usage: inquir apps logs <app|release|build> [--tail <n>]',
|
|
598
|
-
'
|
|
697
|
+
'Usage: inquir apps logs <app|release|build> [--tail <n>] [--no-follow] [--replica <name|index|allocation>]',
|
|
698
|
+
' Prints the last --tail lines (default 200, at most 2000), then follows new',
|
|
699
|
+
' ones until Ctrl-C.',
|
|
700
|
+
' --no-follow Print the last --tail lines and exit (for scripts and agents).',
|
|
701
|
+
' --replica Only one replica\'s lines (see apps instances <app>).',
|
|
599
702
|
' --history selects persisted history instead; see apps log-history --help.',
|
|
600
703
|
],
|
|
704
|
+
exec: [
|
|
705
|
+
'Usage: inquir apps exec <app> [--replica <name|index|allocation>] [--timeout <ms>] -- <command>',
|
|
706
|
+
' Runs one audited command in the production container. <app> is the',
|
|
707
|
+
' application name or id; the id of its production release works too.',
|
|
708
|
+
' --replica One serving replica (see apps instances <app>); default: the projected one.',
|
|
709
|
+
],
|
|
710
|
+
list: [
|
|
711
|
+
'Usage: inquir apps list [--json]',
|
|
712
|
+
'',
|
|
713
|
+
' The workspace\'s applications with their production status.',
|
|
714
|
+
],
|
|
715
|
+
deploy: [
|
|
716
|
+
'Usage: inquir apps deploy <app> [--image <ref> | --build <buildId> | --dir <path>] [--promote] [--no-wait]',
|
|
717
|
+
'',
|
|
718
|
+
' Without --image or --build, packs the source directory (default: the',
|
|
719
|
+
' current one), builds its Dockerfile and releases the image as a preview.',
|
|
720
|
+
' The upload honours .dockerignore and .inquirignore and never contains',
|
|
721
|
+
' .env files, private keys or literal registry tokens.',
|
|
722
|
+
' --image <ref> Release a container image instead of building',
|
|
723
|
+
' --build <buildId> Release an existing successful build',
|
|
724
|
+
' --dir <path> Source directory (default: current directory)',
|
|
725
|
+
' --dockerfile, --context, --target, --build-arg KEY=VALUE Build options',
|
|
726
|
+
' --promote Take production by itself once healthy (needs apps:promote)',
|
|
727
|
+
' --no-wait Return as soon as the release is queued',
|
|
728
|
+
' Runtime flags (--port, --tcp, --volume, --memory, --cpu, ...) as in apps --help.',
|
|
729
|
+
' Environment variables are not deploy flags: set them with',
|
|
730
|
+
' inquir apps update <app> --set KEY=VALUE, then deploy or redeploy.',
|
|
731
|
+
'',
|
|
732
|
+
' inquir.yaml services.web.env: non-secret defaults, applied by the next release',
|
|
733
|
+
' where the platform enables it (APPS_MANIFEST_ENV=on); an application variable',
|
|
734
|
+
' (apps update --set, dashboard, API) with the same name wins. Otherwise the deploy warns that',
|
|
735
|
+
' the section was not applied. Never put secrets in inquir.yaml.',
|
|
736
|
+
],
|
|
737
|
+
status: [
|
|
738
|
+
'Usage: inquir apps status <app> [--json]',
|
|
739
|
+
'',
|
|
740
|
+
' Production, endpoints (public URL, private address, TCP ports), volumes',
|
|
741
|
+
' and recent releases.',
|
|
742
|
+
],
|
|
743
|
+
rollback: [
|
|
744
|
+
'Usage: inquir apps rollback <app> [--json]',
|
|
745
|
+
'',
|
|
746
|
+
' Returns production to the previous release: instantly while its container',
|
|
747
|
+
' still runs, otherwise after it restarts and passes its health check.',
|
|
748
|
+
],
|
|
749
|
+
stop: [
|
|
750
|
+
'Usage: inquir apps stop <release>',
|
|
751
|
+
'',
|
|
752
|
+
' Stops a release that is not serving production; its container and preview',
|
|
753
|
+
' URL go away.',
|
|
754
|
+
],
|
|
755
|
+
build: [
|
|
756
|
+
'Usage: inquir apps build <app> [--dir <path>] [--dockerfile <path>] [--context <path>] [--target <stage>] [--build-arg KEY=VALUE]',
|
|
757
|
+
'',
|
|
758
|
+
' Builds an image from source without releasing it (release it later with',
|
|
759
|
+
' apps deploy <app> --build <buildId>). Uploads like apps deploy.',
|
|
760
|
+
],
|
|
761
|
+
builds: [
|
|
762
|
+
'Usage: inquir apps builds <app> [--cancel <buildId>] [--json]',
|
|
763
|
+
'',
|
|
764
|
+
' Lists the application\'s image builds; --cancel stops a queued or running one.',
|
|
765
|
+
],
|
|
766
|
+
update: [
|
|
767
|
+
'Usage: inquir apps update <app> [--set KEY=VALUE] [--unset KEY] [--port <n>] [--memory <mb>] [--cpu <vcpus>] [--ingress private|public] [flags]',
|
|
768
|
+
'',
|
|
769
|
+
' Applied now: --ingress (the generated public domain is created or removed',
|
|
770
|
+
' at once) and --description.',
|
|
771
|
+
' Applied by the next release (inquir apps redeploy <app>): --set, --unset, --port,',
|
|
772
|
+
' --tcp, --volume, --memory, --cpu, --drain-grace, --health-path,',
|
|
773
|
+
' --health-command, --egress, --location, --node-selector / --clear-node-selector.',
|
|
774
|
+
],
|
|
775
|
+
scale: [
|
|
776
|
+
'Usage: inquir apps scale <app> <always|http|manual> [--replicas <n>] [--idle-timeout <d>] [--activation-timeout <d>] [--stabilization <d>]',
|
|
777
|
+
'',
|
|
778
|
+
' always Keep --replicas replicas running (1..8)',
|
|
779
|
+
' http Sleep after --idle-timeout without requests (minimum 5m); a request wakes it',
|
|
780
|
+
' manual Run until paused; only wake starts it again',
|
|
781
|
+
' Durations: 90s, 15m, 1h30m. Takes effect now.',
|
|
782
|
+
'',
|
|
783
|
+
'Examples:',
|
|
784
|
+
' inquir apps scale web always --replicas 2',
|
|
785
|
+
' inquir apps scale web http --idle-timeout 15m',
|
|
786
|
+
],
|
|
787
|
+
wake: [
|
|
788
|
+
'Usage: inquir apps wake <app> [--json]',
|
|
789
|
+
'',
|
|
790
|
+
' Starts a sleeping (http) or paused (manual) application.',
|
|
791
|
+
],
|
|
792
|
+
pause: [
|
|
793
|
+
'Usage: inquir apps pause <app> [--json]',
|
|
794
|
+
'',
|
|
795
|
+
' Stops an http or manual application; an http one wakes on the next request.',
|
|
796
|
+
],
|
|
797
|
+
domain: [
|
|
798
|
+
'Usage: inquir apps domain <app> [hostname] [--verify | --remove] [--yes] [--json]',
|
|
799
|
+
'',
|
|
800
|
+
' Without a hostname, lists the bound domains. With one, binds it and prints',
|
|
801
|
+
' the DNS records to create; --verify checks them, --remove unbinds it.',
|
|
802
|
+
'',
|
|
803
|
+
" Advanced, not recommended: a quoted mask '*.example.com' binds every",
|
|
804
|
+
' first-level subdomain (a.example.com, not a.b.example.com) when the server',
|
|
805
|
+
' enables it. It asks for confirmation, or --yes. Prove it with a wildcard',
|
|
806
|
+
' CNAME *.example.com and TXT _inquir-verify.example.com.',
|
|
807
|
+
],
|
|
808
|
+
delete: [
|
|
809
|
+
'Usage: inquir apps delete <app>',
|
|
810
|
+
'',
|
|
811
|
+
' Archives the application and stops every release. Refused while another',
|
|
812
|
+
' application depends on it (apps connect).',
|
|
813
|
+
],
|
|
601
814
|
promote: [
|
|
602
815
|
'Usage: inquir apps promote <release> [--no-wait] [--json]',
|
|
603
816
|
'',
|
|
@@ -625,9 +838,10 @@ const SUBCOMMAND_HELP = {
|
|
|
625
838
|
' --project <project> Add to this project (id, slug or unique name). Otherwise',
|
|
626
839
|
' uses projects use; without a selection, creates a project',
|
|
627
840
|
' named after the app (group it later: inquir projects move).',
|
|
628
|
-
' --template <name> postgres | redis: volume,
|
|
629
|
-
'
|
|
630
|
-
'
|
|
841
|
+
' --template <name> postgres | redis: a volume, a private TCP port',
|
|
842
|
+
' (<slug>.apps.internal, reachable from your other',
|
|
843
|
+
' services), a generated password and the first release —',
|
|
844
|
+
' one command from nothing to a running database.',
|
|
631
845
|
' --volume-runner <id> Pin volumes + container to a worker runner (id or',
|
|
632
846
|
' unique prefix); the data lives on that machine. With',
|
|
633
847
|
' --template the worker runs the operator\'s digest-pinned',
|
|
@@ -677,8 +891,55 @@ const SUBCOMMAND_HELP = {
|
|
|
677
891
|
],
|
|
678
892
|
};
|
|
679
893
|
const BACKUPS_USAGE = 'inquir apps backups <app> [list|create|restore <backupId>|delete <backupId>]';
|
|
894
|
+
/**
|
|
895
|
+
* "2/3 ~10-08 14:00": the row's place among its volume's backups of the same
|
|
896
|
+
* kind, and roughly when it goes; "next cleanup" once past the limit.
|
|
897
|
+
*/
|
|
898
|
+
export function formatBackupKeeps(backup) {
|
|
899
|
+
const retention = backup.retention;
|
|
900
|
+
if (!retention)
|
|
901
|
+
return '';
|
|
902
|
+
const place = `${retention.position}/${retention.limit}`;
|
|
903
|
+
if (!retention.kept)
|
|
904
|
+
return `${place} next cleanup`;
|
|
905
|
+
if (!retention.expiresAt)
|
|
906
|
+
return place;
|
|
907
|
+
return `${place} ~${retention.expiresAt.slice(5, 16).replace('T', ' ')}`;
|
|
908
|
+
}
|
|
909
|
+
/** One line on what the workspace's backup policy keeps. */
|
|
910
|
+
export function formatBackupPolicy(policy) {
|
|
911
|
+
const owner = policy.source === 'override' ? 'This workspace' : `Plan ${policy.planCode}`;
|
|
912
|
+
const every = policy.frequencyHours === 24 ? 'daily' : `every ${policy.frequencyHours}h`;
|
|
913
|
+
const automatic = policy.automatic
|
|
914
|
+
? `the last ${policy.retainCount} automatic backup(s) (${every})`
|
|
915
|
+
: 'no automatic backups';
|
|
916
|
+
let line = `${owner} keeps ${automatic} and ${policy.maxManualBackups} manual backup(s) per volume`;
|
|
917
|
+
if (policy.grace)
|
|
918
|
+
line += `; after a plan change, up to ${policy.grace.retainCount} are kept until ${policy.grace.until.slice(0, 10)}`;
|
|
919
|
+
if (!policy.automaticAvailable && policy.automaticUnavailableReason === 'volume_on_worker') {
|
|
920
|
+
line += '; this app\'s volumes live on a worker, where the platform cannot back them up yet';
|
|
921
|
+
}
|
|
922
|
+
return line;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* An empty backup list. A volume on a worker runner cannot be snapshotted by
|
|
926
|
+
* the platform yet, and `create` is refused there too: say so instead of
|
|
927
|
+
* suggesting it.
|
|
928
|
+
*/
|
|
929
|
+
export function emptyBackupsLines(appName, policy) {
|
|
930
|
+
if (policy && !policy.automaticAvailable && policy.automaticUnavailableReason === 'volume_on_worker') {
|
|
931
|
+
return [
|
|
932
|
+
`No backups for ${appName}: its volume lives on a worker runner, where the platform cannot back up volumes yet`,
|
|
933
|
+
' (automatic or on demand; `backups create` is refused there too). Runner-side backups are in progress.',
|
|
934
|
+
' Until then, see "Backups before risky changes" in the Node.js + Postgres tutorial:',
|
|
935
|
+
' https://inquir.org/docs/node-postgres',
|
|
936
|
+
];
|
|
937
|
+
}
|
|
938
|
+
return [`No backups for ${appName} yet — create one: inquir apps backups ${appName} create`];
|
|
939
|
+
}
|
|
680
940
|
/** The one backup table: `create` prints it too, once the rows settle. */
|
|
681
941
|
function renderBackupTable(items) {
|
|
942
|
+
const keeps = items.some((b) => b.retention);
|
|
682
943
|
printLine(formatTable(items.map((b) => ({
|
|
683
944
|
id: b.id.slice(0, 8),
|
|
684
945
|
volume: b.volumeName,
|
|
@@ -686,7 +947,10 @@ function renderBackupTable(items) {
|
|
|
686
947
|
trigger: b.trigger,
|
|
687
948
|
size: formatBytes(b.sizeBytes ?? b.artifactSizeBytes),
|
|
688
949
|
created: b.createdAt.replace('T', ' ').slice(0, 19),
|
|
689
|
-
|
|
950
|
+
keeps: formatBackupKeeps(b),
|
|
951
|
+
})), keeps
|
|
952
|
+
? ['id', 'volume', 'status', 'trigger', 'size', 'created', 'keeps']
|
|
953
|
+
: ['id', 'volume', 'status', 'trigger', 'size', 'created']));
|
|
690
954
|
}
|
|
691
955
|
/** Accept a short id, the way the release commands do. */
|
|
692
956
|
async function resolveBackup(applicationId, needle, identifier, apiOptions) {
|
|
@@ -758,9 +1022,12 @@ async function backupsSubcommand(args, positionals, apiOptions) {
|
|
|
758
1022
|
if (json)
|
|
759
1023
|
return printLine(JSON.stringify(page, null, 2));
|
|
760
1024
|
if (page.items.length === 0) {
|
|
761
|
-
|
|
1025
|
+
for (const line of emptyBackupsLines(app.name, page.policy))
|
|
1026
|
+
printInfo(line);
|
|
762
1027
|
return;
|
|
763
1028
|
}
|
|
1029
|
+
if (page.policy)
|
|
1030
|
+
printInfo(dim(formatBackupPolicy(page.policy)));
|
|
764
1031
|
renderBackupTable(page.items);
|
|
765
1032
|
if (page.nextCursor) {
|
|
766
1033
|
printInfo(dim(` older: inquir apps backups ${app.name} list --cursor ${page.nextCursor}`));
|
|
@@ -883,8 +1150,10 @@ export function explainDatabaseConnectionError(error, context) {
|
|
|
883
1150
|
...base,
|
|
884
1151
|
cause: 'The control plane offers no private address for this database.',
|
|
885
1152
|
fix: target
|
|
886
|
-
? 'connect writes private URLs only.
|
|
887
|
-
|
|
1153
|
+
? 'connect writes private URLs only. If other apps have private addresses here, the database\'s may just not be verified yet: retry in a minute. ' +
|
|
1154
|
+
'A public URL exists only where the server offers public TCP and Public Access is on; ' + PUBLIC_TCP_NOTE + '.'
|
|
1155
|
+
: 'If other apps have private addresses here, the database\'s may just not be verified yet: retry in a minute. ' +
|
|
1156
|
+
'A public URL exists only where the server offers public TCP and Public Access is on; ' + PUBLIC_TCP_NOTE + '.',
|
|
888
1157
|
command: `inquir apps connection-url ${database} --public`,
|
|
889
1158
|
});
|
|
890
1159
|
case 'APP_NO_PRODUCTION_RELEASE':
|
|
@@ -981,10 +1250,10 @@ export function selectConnectionUrl(result, preference = 'auto') {
|
|
|
981
1250
|
const requested = preference === 'auto' ? 'private or public' : preference;
|
|
982
1251
|
const reason = preference !== 'public' ? result.privateUnavailable : undefined;
|
|
983
1252
|
throw usageError(`No ${requested} database connection URL is available${reason ? `: ${reason.message}` : ''}`, preference === 'public'
|
|
984
|
-
?
|
|
1253
|
+
? `A public URL needs public TCP and Public Access on the database; ${PUBLIC_TCP_NOTE}.`
|
|
985
1254
|
: reason?.code === 'APP_PRIVATE_ENDPOINT_NOT_READY'
|
|
986
1255
|
? 'Retry in a minute; the private address is verified once the database serves.'
|
|
987
|
-
:
|
|
1256
|
+
: `Configure private networking. --public works only where the server offers public TCP; ${PUBLIC_TCP_NOTE}.`);
|
|
988
1257
|
}
|
|
989
1258
|
async function deploySubcommand(args, positionals, apiOptions) {
|
|
990
1259
|
const [identifier] = positionals;
|
|
@@ -1033,6 +1302,7 @@ async function deploySubcommand(args, positionals, apiOptions) {
|
|
|
1033
1302
|
printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (${image ?? `build ${buildId.slice(0, 8)}`})`);
|
|
1034
1303
|
if (promote)
|
|
1035
1304
|
printInfo(dim(' it takes production by itself once healthy'));
|
|
1305
|
+
printManifestEnvOutcome(queued);
|
|
1036
1306
|
}
|
|
1037
1307
|
if (hasFlag(args, '--no-wait')) {
|
|
1038
1308
|
if (json)
|
|
@@ -1043,6 +1313,20 @@ async function deploySubcommand(args, positionals, apiOptions) {
|
|
|
1043
1313
|
}
|
|
1044
1314
|
await followQueuedRelease(app, queued, promote, json, apiOptions);
|
|
1045
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* What the server did with inquir.yaml `services.web.env` (docs/MANIFEST_ENV.md).
|
|
1318
|
+
* Read structurally: a control plane that predates manifest env sends neither
|
|
1319
|
+
* field, and then nothing is printed.
|
|
1320
|
+
*/
|
|
1321
|
+
export function printManifestEnvOutcome(queued) {
|
|
1322
|
+
const outcome = queued;
|
|
1323
|
+
const applied = Array.isArray(outcome.manifestEnv?.applied) ? outcome.manifestEnv.applied : [];
|
|
1324
|
+
if (applied.length > 0)
|
|
1325
|
+
printInfo(dim(` inquir.yaml env applied: ${applied.join(', ')}`));
|
|
1326
|
+
const warnings = Array.isArray(outcome.warnings) ? outcome.warnings : [];
|
|
1327
|
+
for (const warning of warnings)
|
|
1328
|
+
printWarning(` warning: ${warning}`);
|
|
1329
|
+
}
|
|
1046
1330
|
/**
|
|
1047
1331
|
* Wait for a queued release to settle and say where it ended up; with
|
|
1048
1332
|
* promote-on-ready, also wait for production to point at it.
|
|
@@ -1134,6 +1418,7 @@ async function redeploySubcommand(args, positionals, apiOptions) {
|
|
|
1134
1418
|
printInfo(`Release ${queued.releaseId.slice(0, 8)} queued for ${app.name} (redeploy of ${queued.redeployOf.slice(0, 8)})`);
|
|
1135
1419
|
if (promote)
|
|
1136
1420
|
printInfo(dim(' it takes production by itself once healthy'));
|
|
1421
|
+
printManifestEnvOutcome(queued);
|
|
1137
1422
|
}
|
|
1138
1423
|
if (hasFlag(args, '--no-wait')) {
|
|
1139
1424
|
if (json)
|
|
@@ -1154,7 +1439,11 @@ async function runSourceBuild(applicationId, appName, args, apiOptions) {
|
|
|
1154
1439
|
const buildArgs = parseSetFlags(readMultiFlag(args, '--build-arg'));
|
|
1155
1440
|
if (!json)
|
|
1156
1441
|
printInfo(dim(`— packing ${dir} —`));
|
|
1157
|
-
const sourceZipBase64 = await
|
|
1442
|
+
const sourceZipBase64 = await packSourceForUpload(dir, {
|
|
1443
|
+
json,
|
|
1444
|
+
...(readFlagValue(args, '--context') ? { context: readFlagValue(args, '--context') } : {}),
|
|
1445
|
+
...(readFlagValue(args, '--dockerfile') ? { dockerfile: readFlagValue(args, '--dockerfile') } : {}),
|
|
1446
|
+
});
|
|
1158
1447
|
const queued = await queueBuild(applicationId, {
|
|
1159
1448
|
sourceZipBase64,
|
|
1160
1449
|
source: 'cli',
|
|
@@ -1336,24 +1625,64 @@ export async function waitForBuild(buildId, apiOptions, options = {}) {
|
|
|
1336
1625
|
* reason instead of sitting out its whole timeout.
|
|
1337
1626
|
*/
|
|
1338
1627
|
const FAILED_STARTS_BEFORE_REPORTING = 3;
|
|
1628
|
+
/** Every how many release polls a running replacement's phase is re-read. */
|
|
1629
|
+
const REPLACEMENT_PROBE_EVERY = 5;
|
|
1630
|
+
/**
|
|
1631
|
+
* The phase of the stop-first production replacement a PENDING release is
|
|
1632
|
+
* the target of. Bounded: the operation is read on the first PENDING poll
|
|
1633
|
+
* and then every REPLACEMENT_PROBE_EVERY polls, and never again once a read
|
|
1634
|
+
* fails or names no replacement for this release (a preview, an older
|
|
1635
|
+
* server), so an ordinary deploy costs one extra request.
|
|
1636
|
+
*/
|
|
1637
|
+
export function createReplacementProbe(read, every = REPLACEMENT_PROBE_EVERY) {
|
|
1638
|
+
let polls = 0;
|
|
1639
|
+
let done = false;
|
|
1640
|
+
let phase = '';
|
|
1641
|
+
return {
|
|
1642
|
+
async phaseOf(release) {
|
|
1643
|
+
if (done)
|
|
1644
|
+
return '';
|
|
1645
|
+
polls += 1;
|
|
1646
|
+
if ((polls - 1) % every !== 0)
|
|
1647
|
+
return phase;
|
|
1648
|
+
const operation = await read(release.applicationId).catch(() => null);
|
|
1649
|
+
if (!operation || operation.targetReleaseId !== release.id) {
|
|
1650
|
+
done = true;
|
|
1651
|
+
phase = '';
|
|
1652
|
+
return '';
|
|
1653
|
+
}
|
|
1654
|
+
phase = operation.phase;
|
|
1655
|
+
return phase;
|
|
1656
|
+
},
|
|
1657
|
+
};
|
|
1658
|
+
}
|
|
1339
1659
|
/** Poll until the release settles; the platform decides when it is healthy. */
|
|
1340
1660
|
async function waitForRelease(releaseId, apiOptions, timeoutMs = 10 * 60_000) {
|
|
1341
1661
|
const deadline = Date.now() + timeoutMs;
|
|
1342
1662
|
const startedAt = Date.now();
|
|
1343
1663
|
let lastProgress = '';
|
|
1344
1664
|
const json = readJsonFlag(process.argv);
|
|
1665
|
+
let replacementClient;
|
|
1666
|
+
const replacement = createReplacementProbe(async (applicationId) => {
|
|
1667
|
+
replacementClient ??= await createApiClient(apiOptions);
|
|
1668
|
+
return getProductionReplacement(applicationId, apiOptions, replacementClient);
|
|
1669
|
+
});
|
|
1345
1670
|
for (;;) {
|
|
1346
1671
|
const release = await getRelease(releaseId, apiOptions);
|
|
1347
1672
|
// A failed start on the pinned runner changes the reason, not the status.
|
|
1348
1673
|
const reason = release.serving ? release.failureReason ?? '' : '';
|
|
1349
|
-
|
|
1350
|
-
|
|
1674
|
+
// PENDING is also the whole of a stop-first replacement of production,
|
|
1675
|
+
// which pulls nothing: its phase says what the wait is.
|
|
1676
|
+
const phase = release.status === 'PENDING' ? await replacement.phaseOf(release) : '';
|
|
1677
|
+
if (`${release.status}\n${reason}\n${phase}` !== lastProgress) {
|
|
1678
|
+
lastProgress = `${release.status}\n${reason}\n${phase}`;
|
|
1351
1679
|
// Say what is happening in words, with elapsed time — a two-minute health
|
|
1352
1680
|
// gate in silence reads as a hang. Under --json only stdout must stay
|
|
1353
1681
|
// parseable; progress goes to stderr.
|
|
1354
1682
|
const shown = presentRelease(release);
|
|
1683
|
+
const meaning = (phase && replacementPhaseMeaning(phase)) || shown.meaning;
|
|
1355
1684
|
const lines = [
|
|
1356
|
-
`— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${
|
|
1685
|
+
`— ${shown.label} (${formatElapsed(Date.now() - startedAt)})${meaning ? `: ${meaning}` : ''} —`,
|
|
1357
1686
|
...(reason ? [` ${reason}`] : []),
|
|
1358
1687
|
];
|
|
1359
1688
|
for (const line of lines) {
|
|
@@ -1437,9 +1766,16 @@ export function releaseEndpointLines(release, previewUrl, options = {}) {
|
|
|
1437
1766
|
*/
|
|
1438
1767
|
export function privateEndpointLines(detail, active, options = {}) {
|
|
1439
1768
|
if (!detail.privateDomain) {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1769
|
+
if (!options.privateNetworking)
|
|
1770
|
+
return [];
|
|
1771
|
+
// The address routes to production, so without one there is nothing to
|
|
1772
|
+
// verify yet, and with no serving replica it is down, not provisioning.
|
|
1773
|
+
if (!active)
|
|
1774
|
+
return ['(none yet — production gets one once a release is promoted)'];
|
|
1775
|
+
if (active.serving && active.serving.ready === 0) {
|
|
1776
|
+
return ['(unavailable — no production replica is serving; see inquir apps describe <app>)'];
|
|
1777
|
+
}
|
|
1778
|
+
return ['(provisioning — private DNS and routing are being verified)'];
|
|
1443
1779
|
}
|
|
1444
1780
|
const lines = [];
|
|
1445
1781
|
if (detail.privateBaseUrl)
|
|
@@ -1517,6 +1853,18 @@ function formatDurationSeconds(seconds) {
|
|
|
1517
1853
|
}
|
|
1518
1854
|
return parts.join(' ');
|
|
1519
1855
|
}
|
|
1856
|
+
/** One line for a startup probe; defaults as the server applies them (src/apps/startup-probe.ts). */
|
|
1857
|
+
export function describeStartupProbe(probe) {
|
|
1858
|
+
const period = probe.periodSeconds ?? 10;
|
|
1859
|
+
const threshold = probe.failureThreshold ?? 3;
|
|
1860
|
+
const delay = probe.initialDelaySeconds ?? 0;
|
|
1861
|
+
const mode = probe.type ?? (probe.command ? 'command' : undefined);
|
|
1862
|
+
const target = mode === 'command'
|
|
1863
|
+
? `command ${JSON.stringify(probe.command ?? '')}`
|
|
1864
|
+
: `${mode ?? 'probe'}${probe.port ? ` port ${probe.port}` : ''}${mode !== 'tcp' ? ` ${probe.path ?? '/'}` : ''}`;
|
|
1865
|
+
return `${target}, every ${period}s, ${threshold} failure${threshold === 1 ? '' : 's'} allowed` +
|
|
1866
|
+
`${delay ? ` after ${delay}s` : ''} (up to ${delay + period * threshold}s before the healthcheck)`;
|
|
1867
|
+
}
|
|
1520
1868
|
export function describeScalingPolicy(policy) {
|
|
1521
1869
|
const range = `${policy.minReplicas}–${policy.maxReplicas} replicas`;
|
|
1522
1870
|
if (policy.activation === 'http') {
|
|
@@ -1552,6 +1900,15 @@ async function statusSubcommand(args, positionals, apiOptions) {
|
|
|
1552
1900
|
const pinnedRunner = detail.volumeRunnerId;
|
|
1553
1901
|
if (pinnedRunner)
|
|
1554
1902
|
printLine(` volume pin: runner ${pinnedRunner}`);
|
|
1903
|
+
// Experimental service location: shown only where the platform offers it.
|
|
1904
|
+
if (limits?.features?.serviceLocations) {
|
|
1905
|
+
const location = await getAppLocation(app.id, apiOptions).catch(() => null);
|
|
1906
|
+
const line = location ? describeServiceLocation(location) : undefined;
|
|
1907
|
+
if (line)
|
|
1908
|
+
printLine(` location: ${line}`);
|
|
1909
|
+
}
|
|
1910
|
+
if (detail.runtime?.startupProbe)
|
|
1911
|
+
printLine(` startup: ${describeStartupProbe(detail.runtime.startupProbe)}`);
|
|
1555
1912
|
const servingRelease = detail.releases.find((r) => r.id === detail.production?.activeReleaseId);
|
|
1556
1913
|
if (servingRelease?.serving && servingRelease.serving.ready < servingRelease.serving.desired) {
|
|
1557
1914
|
// Production points at it, but its replica does not answer on the runner yet.
|
|
@@ -1692,29 +2049,74 @@ async function logsSubcommand(args, positionals, apiOptions) {
|
|
|
1692
2049
|
const build = await getBuild(identifier, apiOptions).catch(() => null);
|
|
1693
2050
|
if (build) {
|
|
1694
2051
|
printInfo(dim(`— build ${build.id.slice(0, 8)} (${build.status}) —`));
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
2052
|
+
// A finished build replays its log and the stream ends by itself. A
|
|
2053
|
+
// running one follows; with --no-follow, stop once the replay goes quiet.
|
|
2054
|
+
const stop = new AbortController();
|
|
2055
|
+
const quiet = hasFlag(args, '--no-follow') && !isTerminalBuildStatus(build.status)
|
|
2056
|
+
? createQuietStop(() => stop.abort())
|
|
2057
|
+
: null;
|
|
2058
|
+
try {
|
|
2059
|
+
for await (const event of streamBuildLogs(build.id, apiOptions, stop.signal)) {
|
|
2060
|
+
quiet?.heard();
|
|
2061
|
+
if (event.type === 'log' && event.line) {
|
|
2062
|
+
if (json)
|
|
2063
|
+
process.stderr.write(`${redact(event.line)}\n`);
|
|
2064
|
+
else
|
|
2065
|
+
printLine(event.line);
|
|
2066
|
+
}
|
|
2067
|
+
else if (event.type === 'complete') {
|
|
2068
|
+
printInfo(dim(`— build ${event.status ?? 'finished'} —`));
|
|
2069
|
+
break;
|
|
2070
|
+
}
|
|
2071
|
+
else if (event.type === 'error') {
|
|
2072
|
+
printLine(dim(`— ${event.message ?? 'stream error'} —`));
|
|
2073
|
+
break;
|
|
2074
|
+
}
|
|
1709
2075
|
}
|
|
1710
2076
|
}
|
|
2077
|
+
catch (error) {
|
|
2078
|
+
if (!stop.signal.aborted)
|
|
2079
|
+
throw error;
|
|
2080
|
+
}
|
|
2081
|
+
finally {
|
|
2082
|
+
quiet?.cancel();
|
|
2083
|
+
}
|
|
2084
|
+
if (stop.signal.aborted) {
|
|
2085
|
+
printInfo(dim(`— build ${build.id.slice(0, 8)} is still running; follow it: inquir apps logs ${build.id} —`));
|
|
2086
|
+
}
|
|
1711
2087
|
return;
|
|
1712
2088
|
}
|
|
1713
2089
|
const releaseId = await resolveReleaseId(identifier, apiOptions);
|
|
1714
2090
|
const tailRaw = readFlagValue(args, '--tail');
|
|
1715
2091
|
const tail = tailRaw ? Number(tailRaw) : 200;
|
|
1716
|
-
|
|
1717
|
-
|
|
2092
|
+
if (!Number.isInteger(tail) || tail < 1 || tail > MAX_LOG_TAIL) {
|
|
2093
|
+
throw usageError(`--tail must be an integer between 1 and ${MAX_LOG_TAIL}`, 'inquir apps logs <app> --tail 100');
|
|
2094
|
+
}
|
|
2095
|
+
const replica = readFlagValue(args, '--replica');
|
|
2096
|
+
if (hasFlag(args, '--replica') && !replica?.trim())
|
|
2097
|
+
throw usageError('Missing --replica value', 'inquir apps instances <app>');
|
|
2098
|
+
let allocationId;
|
|
2099
|
+
if (replica?.trim()) {
|
|
2100
|
+
const release = await getRelease(releaseId, apiOptions);
|
|
2101
|
+
// Only this release's replicas: an index or name of production would be another container.
|
|
2102
|
+
allocationId = await resolveReplicaAllocationId(release.applicationId, replica, apiOptions, { releaseId: release.id });
|
|
2103
|
+
}
|
|
2104
|
+
if (hasFlag(args, '--no-follow')) {
|
|
2105
|
+
const release = await getRelease(releaseId, apiOptions);
|
|
2106
|
+
const client = await createApiClient(apiOptions);
|
|
2107
|
+
const entries = await readRecentRuntimeLogs((query) => getAppLogHistory(client, release.applicationId, query), { releaseId, tail, ...(allocationId ? { allocationId } : {}) });
|
|
2108
|
+
if (json) {
|
|
2109
|
+
printLine(redact(JSON.stringify(entries, null, 2)));
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
for (const entry of entries)
|
|
2113
|
+
printLine(entry.message);
|
|
2114
|
+
if (entries.length === 0)
|
|
2115
|
+
printInfo(dim(`— no stored logs for release ${releaseId.slice(0, 8)} —`));
|
|
2116
|
+
return;
|
|
2117
|
+
}
|
|
2118
|
+
printInfo(dim(`— streaming logs for release ${releaseId.slice(0, 8)}${allocationId ? `, replica ${allocationId.slice(0, 8)}` : ''} (ctrl-c to stop) —`));
|
|
2119
|
+
for await (const event of streamReleaseLogs(releaseId, { tail, ...(allocationId ? { allocationId } : {}) }, apiOptions)) {
|
|
1718
2120
|
if (event.type === 'log' && event.line) {
|
|
1719
2121
|
// Under --json the log tail goes to stderr so stdout stays parseable.
|
|
1720
2122
|
if (json)
|
|
@@ -1731,6 +2133,63 @@ async function logsSubcommand(args, positionals, apiOptions) {
|
|
|
1731
2133
|
}
|
|
1732
2134
|
}
|
|
1733
2135
|
}
|
|
2136
|
+
/** The most lines `apps logs --tail` asks for (the API's own ceiling). */
|
|
2137
|
+
const MAX_LOG_TAIL = 2000;
|
|
2138
|
+
/**
|
|
2139
|
+
* How far back `--no-follow` reads: inside the 7-day runtime retention with
|
|
2140
|
+
* an hour to spare, so a client clock up to an hour off still names a window
|
|
2141
|
+
* the API accepts. `to` is left to the server's clock.
|
|
2142
|
+
*/
|
|
2143
|
+
const RUNTIME_LOG_WINDOW_MS = 7 * 86_400_000 - 3_600_000;
|
|
2144
|
+
/** `logs --no-follow` on a running build: stop once its replay is this quiet... */
|
|
2145
|
+
const BUILD_LOG_QUIET_MS = 2_000;
|
|
2146
|
+
/** ...or when nothing at all arrived this long after the request (a slow connection). */
|
|
2147
|
+
const BUILD_LOG_FIRST_EVENT_MS = 15_000;
|
|
2148
|
+
/**
|
|
2149
|
+
* Calls `stop` once a stream goes quiet: BUILD_LOG_FIRST_EVENT_MS before the
|
|
2150
|
+
* first event (connecting may be slow), BUILD_LOG_QUIET_MS after the latest.
|
|
2151
|
+
*/
|
|
2152
|
+
export function createQuietStop(stop, timings = { firstEventMs: BUILD_LOG_FIRST_EVENT_MS, quietMs: BUILD_LOG_QUIET_MS }) {
|
|
2153
|
+
let timer = setTimeout(stop, timings.firstEventMs);
|
|
2154
|
+
return {
|
|
2155
|
+
heard() {
|
|
2156
|
+
clearTimeout(timer);
|
|
2157
|
+
timer = setTimeout(stop, timings.quietMs);
|
|
2158
|
+
},
|
|
2159
|
+
cancel() {
|
|
2160
|
+
clearTimeout(timer);
|
|
2161
|
+
},
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
2164
|
+
const LOG_HISTORY_PAGE = 200;
|
|
2165
|
+
/**
|
|
2166
|
+
* `apps logs --no-follow`: the last `tail` stored lines of one release (one
|
|
2167
|
+
* replica with `allocationId`), oldest first — what the live stream prints
|
|
2168
|
+
* before it starts following, without the following.
|
|
2169
|
+
*/
|
|
2170
|
+
export async function readRecentRuntimeLogs(fetchPage, options) {
|
|
2171
|
+
const now = options.now ?? Date.now();
|
|
2172
|
+
// The first page fixes the window; later pages repeat it with the cursor.
|
|
2173
|
+
let window = { from: new Date(now - RUNTIME_LOG_WINDOW_MS).toISOString() };
|
|
2174
|
+
const newestFirst = [];
|
|
2175
|
+
let cursor;
|
|
2176
|
+
while (newestFirst.length < options.tail) {
|
|
2177
|
+
const page = await fetchPage({
|
|
2178
|
+
...window,
|
|
2179
|
+
releaseId: options.releaseId,
|
|
2180
|
+
...(options.allocationId ? { allocationId: options.allocationId } : {}),
|
|
2181
|
+
limit: Math.min(LOG_HISTORY_PAGE, options.tail - newestFirst.length),
|
|
2182
|
+
...(cursor ? { cursor } : {}),
|
|
2183
|
+
});
|
|
2184
|
+
newestFirst.push(...page.entries);
|
|
2185
|
+
if (!page.nextCursor || page.entries.length === 0)
|
|
2186
|
+
break;
|
|
2187
|
+
cursor = page.nextCursor;
|
|
2188
|
+
if (page.from && page.to)
|
|
2189
|
+
window = { from: page.from, to: page.to };
|
|
2190
|
+
}
|
|
2191
|
+
return newestFirst.slice(0, options.tail).reverse();
|
|
2192
|
+
}
|
|
1734
2193
|
async function promoteSubcommand(args, positionals, apiOptions) {
|
|
1735
2194
|
const [identifier] = positionals;
|
|
1736
2195
|
if (!identifier)
|
|
@@ -1741,9 +2200,11 @@ async function promoteSubcommand(args, positionals, apiOptions) {
|
|
|
1741
2200
|
// Say up front what promotion does to this release: a preview-profile
|
|
1742
2201
|
// release with production-only variables is restarted with them before
|
|
1743
2202
|
// traffic moves, and its preview URL retires.
|
|
2203
|
+
let release = null;
|
|
2204
|
+
let app = null;
|
|
1744
2205
|
if (!json) {
|
|
1745
|
-
|
|
1746
|
-
|
|
2206
|
+
release = await getRelease(releaseId, apiOptions).catch(() => null);
|
|
2207
|
+
app = release ? await getApplication(release.applicationId, apiOptions).catch(() => null) : null;
|
|
1747
2208
|
if (release && release.envProfile !== 'production' && (app?.productionOnlyEnvKeys?.length ?? 0) > 0) {
|
|
1748
2209
|
printInfo(dim(' the container restarts with production-only variables before traffic moves'));
|
|
1749
2210
|
}
|
|
@@ -1775,12 +2236,33 @@ async function promoteSubcommand(args, positionals, apiOptions) {
|
|
|
1775
2236
|
printLine(` ${url}`);
|
|
1776
2237
|
}
|
|
1777
2238
|
if ((result.productionUrls ?? []).length === 0 && !result.productionUrl) {
|
|
1778
|
-
|
|
2239
|
+
for (const line of promotedWithoutHostnameLines(release, app))
|
|
2240
|
+
printInfo(dim(line));
|
|
1779
2241
|
}
|
|
1780
2242
|
if (result.previousReleaseId) {
|
|
1781
2243
|
printInfo(dim(` previous release ${result.previousReleaseId.slice(0, 8)} kept for rollback`));
|
|
1782
2244
|
}
|
|
1783
2245
|
}
|
|
2246
|
+
/**
|
|
2247
|
+
* What to say after a promotion that produced no public URL. An HTTP service
|
|
2248
|
+
* is private, not unreachable: same-workspace services reach it at its
|
|
2249
|
+
* private address. Only a TCP-only service is reached at its TCP endpoints.
|
|
2250
|
+
*/
|
|
2251
|
+
export function promotedWithoutHostnameLines(release, app) {
|
|
2252
|
+
const name = app?.name ?? '<app>';
|
|
2253
|
+
// The server reads a port without a protocol as HTTP (src/apps/app-ports.ts).
|
|
2254
|
+
const http = typeof release?.runtime?.port === 'number'
|
|
2255
|
+
|| (release?.runtime?.ports ?? []).some((port) => (port.protocol ?? 'http') === 'http');
|
|
2256
|
+
if (http) {
|
|
2257
|
+
return [
|
|
2258
|
+
app?.privateBaseUrl
|
|
2259
|
+
? ` private — your other services reach it at ${app.privateBaseUrl}`
|
|
2260
|
+
: ` private — your other services reach it at http://<slug>.apps.internal, listed by inquir apps status ${name} once routing is verified`,
|
|
2261
|
+
` public URL: inquir apps update ${name} --ingress public`,
|
|
2262
|
+
];
|
|
2263
|
+
}
|
|
2264
|
+
return [` no hostname — reach it at its TCP endpoints (inquir apps status ${name})`];
|
|
2265
|
+
}
|
|
1784
2266
|
async function rollbackSubcommand(args, positionals, apiOptions) {
|
|
1785
2267
|
const [identifier] = positionals;
|
|
1786
2268
|
if (!identifier)
|
|
@@ -1920,7 +2402,23 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1920
2402
|
await requireAuth(apiOptions);
|
|
1921
2403
|
const app = await resolveApplication(identifier, apiOptions);
|
|
1922
2404
|
const envVars = parseSetFlags(readMultiFlag(args, '--set'));
|
|
1923
|
-
const
|
|
2405
|
+
const unset = readMultiFlag(args, '--unset');
|
|
2406
|
+
const productionOnly = app.productionOnlyEnvKeys ?? [];
|
|
2407
|
+
for (const key of unset) {
|
|
2408
|
+
if (Object.hasOwn(envVars, key))
|
|
2409
|
+
throw usageError(`--set and --unset both name ${key}`, `inquir apps update <app> --unset ${key}`);
|
|
2410
|
+
// A production-only marker without a value is still worth clearing: it
|
|
2411
|
+
// would refuse the same key from inquir.yaml (docs/MANIFEST_ENV.md).
|
|
2412
|
+
if (!Object.hasOwn(app.envVars ?? {}, key) && !productionOnly.includes(key)) {
|
|
2413
|
+
throw usageError(`${app.name} has no variable ${key}`, `inquir apps update ${app.name} --unset <KEY>`);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
const unsetVariables = unset.filter((key) => Object.hasOwn(app.envVars ?? {}, key));
|
|
2417
|
+
const envChanged = Object.keys(envVars).length > 0 || unsetVariables.length > 0;
|
|
2418
|
+
// --unset removes the production-only marker with the variable.
|
|
2419
|
+
const unsetMarkers = unset.filter((key) => productionOnly.includes(key));
|
|
2420
|
+
const flagsRuntime = buildRuntimeFromFlags(args, app.runtime);
|
|
2421
|
+
const runtimeSpec = applyNodeSelectorFlags(args, flagsRuntime ?? app.runtime) ?? flagsRuntime;
|
|
1924
2422
|
const description = readFlagValue(args, '--description');
|
|
1925
2423
|
const networkPolicy = buildNetworkPolicyPatch({
|
|
1926
2424
|
egress: readFlagValue(args, '--egress'),
|
|
@@ -1928,13 +2426,22 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1928
2426
|
current: app.networkPolicy,
|
|
1929
2427
|
});
|
|
1930
2428
|
const volumeRunner = readFlagValue(args, '--volume-runner');
|
|
2429
|
+
const location = parseLocationFlag(readFlagValue(args, '--location'));
|
|
1931
2430
|
const patch = {
|
|
1932
2431
|
...(description !== undefined ? { description } : {}),
|
|
1933
2432
|
...(volumeRunner ? { volumeRunner } : {}),
|
|
2433
|
+
...(location !== undefined ? { location } : {}),
|
|
1934
2434
|
...(runtimeSpec ? { runtime: runtimeSpec } : {}),
|
|
1935
2435
|
// Only the keys given here change; the server merges masked values, so
|
|
1936
2436
|
// untouched secrets survive.
|
|
1937
|
-
|
|
2437
|
+
// The map replaces the stored set, so a key left out is removed (--unset).
|
|
2438
|
+
...(envChanged ? {
|
|
2439
|
+
envVars: {
|
|
2440
|
+
...Object.fromEntries(Object.entries(app.envVars ?? {}).filter(([key]) => !unset.includes(key))),
|
|
2441
|
+
...envVars,
|
|
2442
|
+
},
|
|
2443
|
+
} : {}),
|
|
2444
|
+
...(unsetMarkers.length > 0 ? { productionOnlyEnvKeys: productionOnly.filter((key) => !unsetMarkers.includes(key)) } : {}),
|
|
1938
2445
|
...(networkPolicy ? { networkPolicy } : {}),
|
|
1939
2446
|
};
|
|
1940
2447
|
if (Object.keys(patch).length === 0) {
|
|
@@ -1946,7 +2453,43 @@ async function updateSubcommand(args, positionals, apiOptions) {
|
|
|
1946
2453
|
return;
|
|
1947
2454
|
}
|
|
1948
2455
|
printSuccess(`Updated ${updated.name}`);
|
|
1949
|
-
|
|
2456
|
+
// Advisory: e.g. a node selector no server matches right now.
|
|
2457
|
+
for (const warning of updated.warnings ?? [])
|
|
2458
|
+
printInfo(` warning: ${warning}`);
|
|
2459
|
+
const ingress = readFlagValue(args, '--ingress');
|
|
2460
|
+
for (const line of updateTimingLines(updated.name, {
|
|
2461
|
+
hasProduction: Boolean(updated.productionReleaseId ?? app.productionReleaseId),
|
|
2462
|
+
ingress: ingress === 'public' || ingress === 'private' ? ingress : undefined,
|
|
2463
|
+
nextRelease: envChanged || unsetMarkers.length > 0 || runtimeSpec !== undefined
|
|
2464
|
+
|| readFlagValue(args, '--egress') !== undefined || volumeRunner !== undefined
|
|
2465
|
+
|| location !== undefined,
|
|
2466
|
+
})) {
|
|
2467
|
+
printInfo(dim(line));
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* When an `apps update` takes effect. Public Networking is applied by the
|
|
2472
|
+
* server at once (the generated route is created or removed in the same
|
|
2473
|
+
* request); env, runtime, egress, placement and location are what the next
|
|
2474
|
+
* release is built from.
|
|
2475
|
+
*/
|
|
2476
|
+
export function updateTimingLines(appName, changed) {
|
|
2477
|
+
const lines = [];
|
|
2478
|
+
const hasProduction = changed.hasProduction !== false;
|
|
2479
|
+
if (changed.ingress === 'public') {
|
|
2480
|
+
lines.push(hasProduction
|
|
2481
|
+
? ` public networking: applied now — the generated domain serves production (inquir apps status ${appName})`
|
|
2482
|
+
: ' public networking: on — the generated domain serves production once a release is promoted');
|
|
2483
|
+
}
|
|
2484
|
+
else if (changed.ingress === 'private') {
|
|
2485
|
+
lines.push(' public networking: removed now — the generated domain no longer serves it; the private address stays');
|
|
2486
|
+
}
|
|
2487
|
+
if (changed.nextRelease) {
|
|
2488
|
+
// redeploy re-releases production and refuses without one.
|
|
2489
|
+
const next = hasProduction ? `inquir apps redeploy ${appName}` : `inquir apps deploy ${appName}`;
|
|
2490
|
+
lines.push(` ${changed.ingress ? 'the other changes apply' : 'changes apply'} to the next release: ${next}`);
|
|
2491
|
+
}
|
|
2492
|
+
return lines;
|
|
1950
2493
|
}
|
|
1951
2494
|
async function execSubcommand(args, positionals, apiOptions) {
|
|
1952
2495
|
const [identifier, ...commandParts] = positionals;
|
|
@@ -1956,14 +2499,15 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1956
2499
|
if (!command)
|
|
1957
2500
|
throw usageError('Missing command', 'inquir apps exec <app> -- ls -la');
|
|
1958
2501
|
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);
|
|
2502
|
+
const application = await resolveExecApplication(identifier, apiOptions, readJsonFlag(args));
|
|
1963
2503
|
const timeoutRaw = readFlagValue(args, '--timeout');
|
|
2504
|
+
const replica = readFlagValue(args.slice(0, args.includes('--') ? args.indexOf('--') : args.length), '--replica');
|
|
2505
|
+
if (hasFlag(args.slice(0, args.includes('--') ? args.indexOf('--') : args.length), '--replica') && !replica?.trim()) {
|
|
2506
|
+
throw usageError('Missing --replica value', 'inquir apps exec <app> --replica <name|index> -- <command>');
|
|
2507
|
+
}
|
|
1964
2508
|
let result;
|
|
1965
2509
|
try {
|
|
1966
|
-
result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions);
|
|
2510
|
+
result = await execInApplication(application.id, command, timeoutRaw ? Number(timeoutRaw) : undefined, apiOptions, replica?.trim() ? { replica: replica.trim() } : {});
|
|
1967
2511
|
}
|
|
1968
2512
|
catch (err) {
|
|
1969
2513
|
if (err instanceof Error)
|
|
@@ -1974,6 +2518,9 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1974
2518
|
printLine(JSON.stringify(result, null, 2));
|
|
1975
2519
|
}
|
|
1976
2520
|
else {
|
|
2521
|
+
if (replica && result.replicaIndex !== undefined) {
|
|
2522
|
+
printInfo(dim(`— replica ${result.replicaIndex}${result.allocationId ? ` (${result.allocationId.slice(0, 8)})` : ''} —`));
|
|
2523
|
+
}
|
|
1977
2524
|
if (result.stdout)
|
|
1978
2525
|
process.stdout.write(result.stdout);
|
|
1979
2526
|
if (result.stderr)
|
|
@@ -1984,8 +2531,62 @@ async function execSubcommand(args, positionals, apiOptions) {
|
|
|
1984
2531
|
if (result.exitCode !== 0)
|
|
1985
2532
|
process.exitCode = ExitCode.ERROR;
|
|
1986
2533
|
}
|
|
2534
|
+
/**
|
|
2535
|
+
* `apps exec` runs in production through the application-scoped endpoint. An
|
|
2536
|
+
* application name or id is the target; a release id is accepted only when it
|
|
2537
|
+
* is that application's production release. A stale or preview release id
|
|
2538
|
+
* would otherwise silently change the operator's target to production, so it
|
|
2539
|
+
* is refused with the command that does run.
|
|
2540
|
+
*/
|
|
2541
|
+
async function resolveExecApplication(identifier, apiOptions, json) {
|
|
2542
|
+
let appError;
|
|
2543
|
+
try {
|
|
2544
|
+
return await resolveApplication(identifier, apiOptions);
|
|
2545
|
+
}
|
|
2546
|
+
catch (error) {
|
|
2547
|
+
appError = error;
|
|
2548
|
+
}
|
|
2549
|
+
const release = await getRelease(identifier, apiOptions).catch(() => null);
|
|
2550
|
+
if (!release)
|
|
2551
|
+
throw appError;
|
|
2552
|
+
const detail = await getApplication(release.applicationId, apiOptions);
|
|
2553
|
+
const production = detail.production?.activeReleaseId;
|
|
2554
|
+
if (production !== release.id) {
|
|
2555
|
+
throw new CliError(`Release ${release.id.slice(0, 8)} is not ${detail.name}'s production release`, {
|
|
2556
|
+
code: 'EXEC_NOT_PRODUCTION',
|
|
2557
|
+
exitCode: ExitCode.USAGE,
|
|
2558
|
+
explanation: 'apps exec runs a command in the production container only.',
|
|
2559
|
+
fix: production
|
|
2560
|
+
? `Run it in production (release ${production.slice(0, 8)}): inquir apps exec ${detail.name} -- <command>`
|
|
2561
|
+
: `${detail.name} has no production release yet; promote one first: inquir apps promote ${release.id.slice(0, 8)}`,
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
const note = `— ${detail.name}, production release ${release.id.slice(0, 8)} —`;
|
|
2565
|
+
if (json)
|
|
2566
|
+
process.stderr.write(`${note}\n`);
|
|
2567
|
+
else
|
|
2568
|
+
printInfo(dim(note));
|
|
2569
|
+
return detail;
|
|
2570
|
+
}
|
|
2571
|
+
/** The warning a wildcard bind must be confirmed against (dashboard text, CLI words). */
|
|
2572
|
+
export function wildcardRiskWarning(hostname) {
|
|
2573
|
+
return [
|
|
2574
|
+
`${hostname} is a mask — not recommended.`,
|
|
2575
|
+
' It sends every first-level subdomain to this application, including services you may not',
|
|
2576
|
+
' know about (APIs, webhooks, integrations), and those stop working. Requests meant for them',
|
|
2577
|
+
' start arriving at the application with their cookies and tokens. Each subdomain gets its own',
|
|
2578
|
+
" certificate on its first request, and only a limited number of new ones per week (the Let's",
|
|
2579
|
+
' Encrypt limit is shared by the whole domain); anyone can use that allowance up by requesting',
|
|
2580
|
+
' random names, and new subdomains then get no certificate for up to a week.',
|
|
2581
|
+
' Prefer binding the subdomains you need one by one; a mask suits',
|
|
2582
|
+
' an application that serves many hosts itself (for example, a subdomain per customer).',
|
|
2583
|
+
];
|
|
2584
|
+
}
|
|
1987
2585
|
function printVerificationInstructions(domain) {
|
|
1988
2586
|
const v = domain.verification;
|
|
2587
|
+
if (domain.certificates) {
|
|
2588
|
+
printInfo(dim(` ${domain.hostname} — ${domain.certificates.issuedThisWeek} of ${domain.certificates.weeklyLimit} new certificates this week`));
|
|
2589
|
+
}
|
|
1989
2590
|
if (!v)
|
|
1990
2591
|
return;
|
|
1991
2592
|
// A verified hostname keeps its records on display: they are what gets compared with the
|
|
@@ -2059,7 +2660,20 @@ async function domainSubcommand(args, positionals, apiOptions) {
|
|
|
2059
2660
|
}
|
|
2060
2661
|
return;
|
|
2061
2662
|
}
|
|
2062
|
-
const
|
|
2663
|
+
const wildcard = hostname.trim().startsWith('*.');
|
|
2664
|
+
if (wildcard) {
|
|
2665
|
+
// Not recommended, so never by accident: the API refuses it unless the
|
|
2666
|
+
// request says the risks were confirmed, which only happens here.
|
|
2667
|
+
for (const line of wildcardRiskWarning(hostname.trim()))
|
|
2668
|
+
printWarning(line);
|
|
2669
|
+
if (!runtime().yes) {
|
|
2670
|
+
const approved = await confirm(`Bind every subdomain matched by ${hostname.trim()} to ${app.name}?`, false);
|
|
2671
|
+
if (!approved) {
|
|
2672
|
+
throw usageError(`Refusing to bind the mask ${hostname.trim()} without confirmation`, `inquir apps domain ${app.name} '${hostname.trim()}' --yes`);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
const domain = await addApplicationDomain(app.id, hostname, apiOptions, wildcard ? { acknowledgeWildcardRisks: true } : {});
|
|
2063
2677
|
if (readJsonFlag(args)) {
|
|
2064
2678
|
printLine(JSON.stringify(domain, null, 2));
|
|
2065
2679
|
return;
|