insta 0.1.23 → 0.1.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cron.js +2 -2
- package/dist/commands/deploy.js +34 -5
- package/dist/commands/postgres.js +20 -13
- package/dist/commands/private-network.js +101 -0
- package/dist/commands/secrets.js +24 -17
- package/dist/commands/services.js +41 -2
- package/dist/commands/template.js +223 -3
- package/dist/commands/upgrade.js +2 -2
- package/dist/deploy-archive.js +9 -2
- package/dist/index.js +26 -3
- package/dist/template-manifest.js +0 -1
- package/package.json +1 -1
package/dist/commands/cron.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// forward because it can (see buildRequest).
|
|
17
17
|
import { randomUUID } from 'node:crypto';
|
|
18
18
|
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
19
|
-
import { die, handleApproval, info, printJson, refuse } from '../util.js';
|
|
19
|
+
import { die, handleApproval, info, isWebUrl, printJson, refuse } from '../util.js';
|
|
20
20
|
import { q, resolveSoleService } from './services.js';
|
|
21
21
|
// ---- pure, unit-tested helpers (throw plain Errors; the CLI guard turns them into clean output) ----
|
|
22
22
|
// The API's own bounds (openapi/schemas/cron.ts), enforced here so junk fails before any network or
|
|
@@ -245,7 +245,7 @@ export function assertTargetFlags(o, partial = false) {
|
|
|
245
245
|
throw new Error('--path applies to --service (an external target carries its path in the --url)');
|
|
246
246
|
if (!partial && !o.url && !o.service)
|
|
247
247
|
throw new Error('name a target: --url <https://…> or --service <name> [--path /api/cron]');
|
|
248
|
-
if (o.url !== undefined &&
|
|
248
|
+
if (o.url !== undefined && !isWebUrl(o.url))
|
|
249
249
|
throw new Error(`--url must be an absolute http(s) URL, got: ${o.url}`);
|
|
250
250
|
if (o.path !== undefined && !o.path.startsWith('/'))
|
|
251
251
|
throw new Error(`--path must start with /, got: ${o.path}`);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -6,7 +6,7 @@ import { info, die, printJson, handleApproval, renderNextActions, CliExit } from
|
|
|
6
6
|
import { flyctlBuildAndPush, ensureFlyctl, defaultBuildRunner, stderrBuildRunner } from '../flyctl-build.js';
|
|
7
7
|
import { packDirectory, windowsModeCaveat } from '../pack.js';
|
|
8
8
|
import { deployArchive, uploadArchive } from '../deploy-archive.js';
|
|
9
|
-
import { parsePort } from './services.js';
|
|
9
|
+
import { isV6Endpoint, parseEndpoint, parsePort } from './services.js';
|
|
10
10
|
// With --json, stdout must carry exactly one JSON document (the deploy result), so every progress
|
|
11
11
|
// line moves to stderr.
|
|
12
12
|
const note = (opts) => (opts.json ? (m) => void process.stderr.write(m + '\n') : info);
|
|
@@ -19,9 +19,25 @@ export function deployRequestBody(source, branch, opts) {
|
|
|
19
19
|
group: opts.group,
|
|
20
20
|
port: opts.port ? Number(opts.port) : undefined,
|
|
21
21
|
websocket: opts.websocket ? true : undefined,
|
|
22
|
+
endpoint: opts.endpoint !== undefined ? parseEndpoint(opts.endpoint) : undefined,
|
|
22
23
|
replaceSource: opts.replaceSource ? true : undefined,
|
|
23
24
|
};
|
|
24
25
|
}
|
|
26
|
+
// Where the deploy answers: the HTTPS URL, or for a v6-lane service (tcp, udp, tcp+udp) its
|
|
27
|
+
// address, one scheme per protocol (the platform reports no URL for one). The scheme comes from the
|
|
28
|
+
// RESULT's `endpoint` (instacloud-platform#681 reports it beside endpointHost, including on a
|
|
29
|
+
// redeploy that passed none -- the endpoint the service kept); --endpoint is only the fallback for
|
|
30
|
+
// an older platform that names the host but not the endpoint. With neither, the address prints
|
|
31
|
+
// without a scheme rather than guessing tcp for what may be a udp service. Pure, so it's unit-tested.
|
|
32
|
+
export function deployedTarget(r, port, flag) {
|
|
33
|
+
if (r.endpointHost) {
|
|
34
|
+
const endpoint = isV6Endpoint(r.endpoint) ? r.endpoint : isV6Endpoint(flag) ? flag : undefined;
|
|
35
|
+
if (endpoint === undefined)
|
|
36
|
+
return `${r.endpointHost}:${port ?? '?'} (endpoint unchanged; \`insta service list\` shows its protocol)`;
|
|
37
|
+
return (endpoint === 'tcp+udp' ? ['tcp', 'udp'] : [endpoint]).map((pr) => `${pr}://${r.endpointHost}:${port ?? '?'}`).join(', ');
|
|
38
|
+
}
|
|
39
|
+
return r.url || '(no URL: a worker)';
|
|
40
|
+
}
|
|
25
41
|
// Ask the platform which lane serves this target, so the CLI stops knowing which provider backs
|
|
26
42
|
// its service. A 404 means the platform predates the contract — and because this is a GET, it
|
|
27
43
|
// 404s the same way for a human and an agent, which a POST would not.
|
|
@@ -143,6 +159,19 @@ export async function deploy(dir, opts) {
|
|
|
143
159
|
die('pick one: a source <dir> OR --image <url>, not both');
|
|
144
160
|
if (!dir && !opts.image)
|
|
145
161
|
die('usage: insta deploy <dir> | --image <url> [--branch <b>] [--group <g>] [--port <n>]');
|
|
162
|
+
// Normalized here, once, so both lanes get the same validated value: the archive lane's body
|
|
163
|
+
// (deployArchive) otherwise silently drops anything that isn't exactly one of the endpoints to
|
|
164
|
+
// undefined, which let a typo like --endpoint tpc build and deploy using the service's existing
|
|
165
|
+
// endpoint instead of failing locally. Checked before ApiClient.load()/requireProject(): those
|
|
166
|
+
// can resolve a project and persist an automatic link for an unlinked directory, so a typo must
|
|
167
|
+
// die before either runs, not after.
|
|
168
|
+
let endpoint;
|
|
169
|
+
try {
|
|
170
|
+
endpoint = opts.endpoint === undefined ? undefined : parseEndpoint(opts.endpoint);
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
die(e.message);
|
|
174
|
+
}
|
|
146
175
|
const api = await ApiClient.load();
|
|
147
176
|
const p = await requireProject();
|
|
148
177
|
const branch = opts.branch ?? p.branch;
|
|
@@ -162,7 +191,7 @@ export async function deploy(dir, opts) {
|
|
|
162
191
|
log(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`);
|
|
163
192
|
}
|
|
164
193
|
}
|
|
165
|
-
const effOpts = { ...opts, port: port?.toString() };
|
|
194
|
+
const effOpts = { ...opts, port: port?.toString(), endpoint };
|
|
166
195
|
const source = dir ? await prepareSource(api, p.projectId, dir, branch, effOpts) : { image: opts.image };
|
|
167
196
|
if (!source)
|
|
168
197
|
return; // an approval is pending; the user approves and re-runs
|
|
@@ -171,8 +200,8 @@ export async function deploy(dir, opts) {
|
|
|
171
200
|
// nextActions, which the operation read does not carry.
|
|
172
201
|
const d = source.deployed;
|
|
173
202
|
if (opts.json)
|
|
174
|
-
return printJson({ image: d.image, url: d.url, branch: d.branch, group: d.group, machineId: d.machineId });
|
|
175
|
-
info(`deployed ${d.image} -> ${d.
|
|
203
|
+
return printJson({ image: d.image, url: d.url, ...(d.endpointHost ? { endpointHost: d.endpointHost } : {}), ...(d.endpoint ? { endpoint: d.endpoint } : {}), branch: d.branch, group: d.group, machineId: d.machineId });
|
|
204
|
+
info(`deployed ${d.image} -> ${deployedTarget(d, effOpts.port, endpoint)} (branch ${d.branch}, group ${d.group})`);
|
|
176
205
|
return;
|
|
177
206
|
}
|
|
178
207
|
const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts))
|
|
@@ -182,7 +211,7 @@ export async function deploy(dir, opts) {
|
|
|
182
211
|
const what = source.image;
|
|
183
212
|
if (opts.json)
|
|
184
213
|
return printJson({ image: source.image, ...res.body });
|
|
185
|
-
info(`deployed ${what} -> ${res.body.
|
|
214
|
+
info(`deployed ${what} -> ${deployedTarget(res.body, effOpts.port, endpoint)} (branch ${res.body.branch}, group ${res.body.group})`);
|
|
186
215
|
renderNextActions(res.body.nextActions);
|
|
187
216
|
}
|
|
188
217
|
// The platform refuses an image deploy onto a repo-connected service and names the body field it
|
|
@@ -14,9 +14,9 @@ export function alwaysOnArgs(first, second) {
|
|
|
14
14
|
export function alwaysOnLine(group, scaleToZero) {
|
|
15
15
|
return `postgres ${group}: always-on ${scaleToZero === false ? 'on — instance stays warm' : scaleToZero === true ? 'off — scales to zero when idle' : 'unknown — the provider did not report it'}`;
|
|
16
16
|
}
|
|
17
|
-
// Show or set a postgres service's idle mode:
|
|
18
|
-
// idle
|
|
19
|
-
//
|
|
17
|
+
// Show or set a postgres service's idle mode: always-on (the default for databases since platform
|
|
18
|
+
// #666: instance stays warm; idle RAM bills at actual usage) or scale-to-zero (instance suspends
|
|
19
|
+
// when idle, cold-starts on the next connection). Reads GET /database/instance, sets PATCH /database/settings {scaleToZero} —
|
|
20
20
|
// insta-db-backed postgres only.
|
|
21
21
|
export async function dbAlwaysOn(first, second, opts) {
|
|
22
22
|
const { mode, service } = alwaysOnArgs(first, second);
|
|
@@ -70,7 +70,7 @@ export function alwaysOnSetLine(service, branch, mode, scaleToZero) {
|
|
|
70
70
|
const group = service ?? 'default';
|
|
71
71
|
if (scaleToZero !== (mode === 'off'))
|
|
72
72
|
return `postgres ${group}: always-on ${mode} requested — the database has not applied it yet; confirm with \`insta postgres always-on${service ? ` ${service}` : ''}${branch ? ` --branch ${branch}` : ''}\``;
|
|
73
|
-
return `postgres ${group}: always-on ${mode === 'on' ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage)' : 'disabled — scales to zero when idle (
|
|
73
|
+
return `postgres ${group}: always-on ${mode === 'on' ? 'ENABLED — instance stays warm (no cold starts; idle RAM bills at actual usage; the default for databases)' : 'disabled — scales to zero when idle (first connection after idle cold-starts)'}`;
|
|
74
74
|
}
|
|
75
75
|
// Validated pass-throughs for the provider's quantity strings. The insta-db resize API takes
|
|
76
76
|
// k8s-style quantities (cpu: "2", "2500m"; memory: "4Gi", "2048Mi"), so unlike the compute path
|
|
@@ -160,9 +160,20 @@ export async function dbLimits(service, opts) {
|
|
|
160
160
|
return;
|
|
161
161
|
if (opts.json)
|
|
162
162
|
return printJson(res.body);
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
163
|
+
info(limitsSetLine(service, opts.branch, { cpu: body.cpu, memory: body.memory }, res));
|
|
164
|
+
}
|
|
165
|
+
// insta-db queues the resize and answers 202 (the platform adds pending: true) with the row from
|
|
166
|
+
// BEFORE the patch, so on pending the body's cpuMilli/memoryMib are the old ceiling: print what was
|
|
167
|
+
// asked for and point at the read-back, the way alwaysOnSetLine does.
|
|
168
|
+
export function limitsSetLine(service, branch, requested, res) {
|
|
169
|
+
const group = service ?? 'default';
|
|
170
|
+
if (res.status === 202 || res.body?.pending === true) {
|
|
171
|
+
const asked = [requested.cpu && `${requested.cpu} vCPU`, requested.memory].filter(Boolean).join(' / ');
|
|
172
|
+
return `postgres ${group}: ceiling ${asked} requested, not applied yet — confirm with \`insta postgres limits${service ? ` ${service}` : ''}${branch ? ` --branch ${branch}` : ''}\``;
|
|
173
|
+
}
|
|
174
|
+
const cpu = typeof res.body?.cpuMilli === 'number' ? `${res.body.cpuMilli / 1000} vCPU` : (requested.cpu ?? 'unchanged');
|
|
175
|
+
const mem = typeof res.body?.memoryMib === 'number' ? fmtMib(res.body.memoryMib) : (requested.memory ?? 'unchanged');
|
|
176
|
+
return `postgres ${group}: ceiling set to ${cpu} / ${mem}`;
|
|
166
177
|
}
|
|
167
178
|
// Bytes → human units, one decimal above KiB. Local because the metrics payload is the only
|
|
168
179
|
// bytes-denominated read in this file (fmtMib serves the MiB-denominated resize path).
|
|
@@ -204,12 +215,8 @@ export function dbStatsLines(group, body) {
|
|
|
204
215
|
];
|
|
205
216
|
}
|
|
206
217
|
// Point-in-time stats snapshot for a postgres service: connections vs the server's ceiling, cache
|
|
207
|
-
// hit rate, database size. Read-only.
|
|
208
|
-
//
|
|
209
|
-
// every environment today — the Neon-backed contrast below is historical: Neon is no longer used
|
|
210
|
-
// anywhere, and the code that handled it is retained, not live. Neon-backed: the platform read
|
|
211
|
-
// over a direct SQL connection, so a one-shot call could wake a suspended endpoint — acceptable
|
|
212
|
-
// for an explicit command, which is why nothing here polls.
|
|
218
|
+
// hit rate, database size. Read-only. A suspended instance answers from the provider's control
|
|
219
|
+
// plane (shown as "(suspended)" with structural zeros), never dialed.
|
|
213
220
|
export async function dbStats(service, opts) {
|
|
214
221
|
const api = await ApiClient.load();
|
|
215
222
|
const p = await requireProject();
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// `insta compute public-access` and `insta <redis|mysql|mongodb> public-access`: show, close or
|
|
2
|
+
// re-open a compute-plane service's public access (the platform's PUT .../services/:id/access).
|
|
3
|
+
//
|
|
4
|
+
// Every insta-compute service has a private address in its project branch: `<name>.insta.internal`
|
|
5
|
+
// for compute, `<name>-<type>.insta.internal` for a managed database (the platform's private_host;
|
|
6
|
+
// this file only prints what the platform reports, never composes one). Closing public access makes
|
|
7
|
+
// the service private-only: its URL answers 403 (a database's public *_URL stops connecting) and
|
|
8
|
+
// only services in the same branch reach it. Like `insta postgres public-access off`, closing asks
|
|
9
|
+
// first, and a non-interactive caller (agent, CI, --json) must pass --yes.
|
|
10
|
+
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
11
|
+
import { handleApproval, info, printJson, refuse } from '../util.js';
|
|
12
|
+
import { q, resolveSoleService } from './services.js';
|
|
13
|
+
import { alwaysOnArgs, confirmOnTerminal } from './postgres.js';
|
|
14
|
+
const isDb = (type) => type !== 'compute';
|
|
15
|
+
const credential = (type) => `${type.toUpperCase()}_URL`;
|
|
16
|
+
const privateCredential = (type) => `${type.toUpperCase()}_PRIVATE_URL`;
|
|
17
|
+
/** `<host>:<port>` for the private address, or undefined when the platform reports none. */
|
|
18
|
+
export function privateAddress(s) {
|
|
19
|
+
return s.private_host ? `${s.private_host}${s.port ? `:${s.port}` : ''}` : undefined;
|
|
20
|
+
}
|
|
21
|
+
// The show block. Pure, exported for tests.
|
|
22
|
+
export function publicAccessLines(s) {
|
|
23
|
+
const pub = s.public;
|
|
24
|
+
const head = `${s.type} ${s.name}: public access ${pub === false ? 'off' : pub === true ? 'on' : 'unknown'}`;
|
|
25
|
+
const why = pub === false
|
|
26
|
+
? (isDb(s.type) ? ` — ${credential(s.type)} does not connect; apps in this branch use ${privateCredential(s.type)}` : ' — its URL answers 403; only services in this branch reach it')
|
|
27
|
+
: pub === true ? (isDb(s.type) ? '' : ' — reachable from the internet at its URL') : '';
|
|
28
|
+
const lines = [head + why];
|
|
29
|
+
const addr = privateAddress(s);
|
|
30
|
+
if (addr) {
|
|
31
|
+
lines.push(` private: ${addr}${s.type === 'compute' ? ' (TCP and UDP, from services in this branch)' : ` (from services in this branch; ${privateCredential(s.type)} holds the connection string)`}`);
|
|
32
|
+
if (s.private_fqdn)
|
|
33
|
+
lines.push(` fqdn: ${s.private_fqdn} (for tools that skip the search domain, e.g. nslookup, dig)`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
lines.push(' private: none yet — the platform reports no private address for this service (not yet back-filled, a worker, or a region without the private network)');
|
|
37
|
+
}
|
|
38
|
+
return lines;
|
|
39
|
+
}
|
|
40
|
+
// Map a refusal to a CLI-shaped message. Pure, exported for tests.
|
|
41
|
+
export function publicAccessErrorMessage(e, ctx) {
|
|
42
|
+
const code = e.body?.code;
|
|
43
|
+
const b = ctx.branch ? ` --branch ${ctx.branch}` : '';
|
|
44
|
+
if (code === 'private_network_disabled')
|
|
45
|
+
return `the private network is not yet available on this deployment — ${ctx.type} ${ctx.service} stays public`;
|
|
46
|
+
if (code === 'private_network_unavailable_in_region')
|
|
47
|
+
return `the region of ${ctx.type} ${ctx.service} has no private network yet — it stays public`;
|
|
48
|
+
if (code === 'no_private_name')
|
|
49
|
+
return `${ctx.type} ${ctx.service} has no private network name, so closing public access would leave it unreachable — rename it first: \`insta service rename ${ctx.type} ${ctx.service} <new-name>${b}\``;
|
|
50
|
+
if (code === 'private_name_taken')
|
|
51
|
+
return `${e.message} — rename one of the services: \`insta service rename ${ctx.type} ${ctx.service} <new-name>${b}\``;
|
|
52
|
+
return `${ctx.what} failed (${e.status}): ${e.message}`;
|
|
53
|
+
}
|
|
54
|
+
// What closing will break, said before asking. Pure, exported for tests.
|
|
55
|
+
export function closeWarning(s) {
|
|
56
|
+
return isDb(s.type)
|
|
57
|
+
? `closing public access: ${credential(s.type)} (the public address) stops connecting — external clients, CI and local development can no longer reach ${s.type} ${s.name}, and apps in this branch must use ${privateCredential(s.type)} (bind it and redeploy them)`
|
|
58
|
+
: `closing public access: ${s.type} ${s.name}'s URL will answer 403 and stop waking it — browsers, webhooks and every caller outside this branch lose it; services in this branch keep reaching it at its private address`;
|
|
59
|
+
}
|
|
60
|
+
export async function servicePublicAccess(type, first, second, opts, deps = {}) {
|
|
61
|
+
const { mode, service } = alwaysOnArgs(first, second);
|
|
62
|
+
const api = await ApiClient.load();
|
|
63
|
+
const p = await requireProject();
|
|
64
|
+
const branch = opts.branch ?? p.branch;
|
|
65
|
+
const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`);
|
|
66
|
+
const svc = resolveSoleService(services, type, service);
|
|
67
|
+
if (!mode) {
|
|
68
|
+
if (opts.json)
|
|
69
|
+
return printJson({ name: svc.name, type: svc.type, public: svc.public ?? null, private_host: svc.private_host ?? null, private_fqdn: svc.private_fqdn ?? null, port: svc.port ?? null });
|
|
70
|
+
for (const l of publicAccessLines(svc))
|
|
71
|
+
info(l);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (mode === 'off' && !opts.yes) {
|
|
75
|
+
const tty = deps.tty ?? (!!process.stdin.isTTY && !!process.stdout.isTTY);
|
|
76
|
+
if (opts.json || !tty)
|
|
77
|
+
refuse([closeWarning(svc), `${type} ${svc.name}: re-run with --yes to confirm`]);
|
|
78
|
+
info(closeWarning(svc));
|
|
79
|
+
const ok = await (deps.confirm ?? confirmOnTerminal)(`Close public access to ${type} ${svc.name}?`);
|
|
80
|
+
if (!ok)
|
|
81
|
+
return info(`${type} ${svc.name}: public access unchanged`);
|
|
82
|
+
}
|
|
83
|
+
let res;
|
|
84
|
+
try {
|
|
85
|
+
res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/access`, { public: mode === 'on' });
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
if (e instanceof ApiError)
|
|
89
|
+
throw new Error(publicAccessErrorMessage(e, { type, service: svc.name, branch: opts.branch, what: mode === 'off' ? 'closing public access' : 'opening public access' }));
|
|
90
|
+
throw e;
|
|
91
|
+
}
|
|
92
|
+
if (handleApproval(res, opts.json))
|
|
93
|
+
return;
|
|
94
|
+
if (opts.json)
|
|
95
|
+
return printJson(res.body);
|
|
96
|
+
const notice = typeof res.body?.notice === 'string' && res.body.notice ? res.body.notice : undefined;
|
|
97
|
+
info(`${type} ${svc.name}: public access ${mode === 'off' ? 'CLOSED' : 'open'}`);
|
|
98
|
+
if (notice)
|
|
99
|
+
info(` ${notice}`);
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=private-network.js.map
|
package/dist/commands/secrets.js
CHANGED
|
@@ -190,6 +190,23 @@ export function applyServiceLines(services) {
|
|
|
190
190
|
return ` = ${s.serviceId}${s.reason ? ` (${s.reason})` : ''}`;
|
|
191
191
|
});
|
|
192
192
|
}
|
|
193
|
+
// POST one entry to /apply and settle what `secrets set` and `secrets unset` share: the approval
|
|
194
|
+
// gate, the exit code, and the --json document. Returns null when the caller has nothing left to
|
|
195
|
+
// print — the platform gated the write, or --json already carried the outcome.
|
|
196
|
+
async function applyEntry(d, name, branch, entry, opts) {
|
|
197
|
+
const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
|
|
198
|
+
if (handleApproval(res, opts.json))
|
|
199
|
+
return null;
|
|
200
|
+
const entries = res.body.entries ?? [];
|
|
201
|
+
const services = res.body.services ?? [];
|
|
202
|
+
const verdict = applyVerdict(entries, services);
|
|
203
|
+
process.exitCode = applyExitCode(verdict);
|
|
204
|
+
if (opts.json) {
|
|
205
|
+
printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
return { verdict, services };
|
|
209
|
+
}
|
|
193
210
|
// Set a user secret. Project-wide by default; --branch scopes it to one branch. --service binds
|
|
194
211
|
// it to a branch service instead, which implies the current branch (binding requires one). Value
|
|
195
212
|
// comes from the argument, or stdin when omitted (keeps secret values out of shell history).
|
|
@@ -205,15 +222,10 @@ export async function secretsSet(name, value, opts, deps) {
|
|
|
205
222
|
const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
|
|
206
223
|
// The batch always deploys on ONE branch, so the top level falls back to the linked one even when the entry itself is project-wide.
|
|
207
224
|
const entry = { kind: 'set', name, value: v, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
|
|
208
|
-
const
|
|
209
|
-
if (
|
|
225
|
+
const outcome = await applyEntry(d, name, branch, entry, opts);
|
|
226
|
+
if (!outcome)
|
|
210
227
|
return;
|
|
211
|
-
const
|
|
212
|
-
const services = res.body.services ?? [];
|
|
213
|
-
const verdict = applyVerdict(entries, services);
|
|
214
|
-
process.exitCode = applyExitCode(verdict);
|
|
215
|
-
if (opts.json)
|
|
216
|
-
return printJson({ ok: verdict === 'ok', verdict, name, branch: branch ?? null, service: opts.service ?? null, entries, services });
|
|
228
|
+
const { verdict, services } = outcome;
|
|
217
229
|
info(`set ${name}${opts.service ? ` → ${opts.service}` : ''} (${branch ? `branch ${branch}` : 'project-wide'})`);
|
|
218
230
|
for (const line of applyServiceLines(services))
|
|
219
231
|
info(line);
|
|
@@ -232,17 +244,12 @@ export async function secretsUnset(name, opts, deps) {
|
|
|
232
244
|
// pair without one) — so --service defaults to the linked branch, exactly as `secrets set` does.
|
|
233
245
|
const branch = opts.service ? (opts.branch ?? d.linkedBranch) : opts.branch;
|
|
234
246
|
const entry = { kind: 'delete', name, ...(branch ? { branch } : {}), ...(opts.service ? { service: opts.service } : {}) };
|
|
235
|
-
const res = await d.api.rawRequest('POST', `/projects/${d.projectId}/apply`, { branch: branch ?? d.linkedBranch, entries: [entry] });
|
|
236
|
-
if (handleApproval(res, opts.json))
|
|
237
|
-
return;
|
|
238
|
-
const entries = res.body.entries ?? [];
|
|
239
|
-
const services = res.body.services ?? [];
|
|
240
|
-
const verdict = applyVerdict(entries, services);
|
|
241
|
-
process.exitCode = applyExitCode(verdict);
|
|
242
247
|
// The EFFECTIVE branch, not the flag: with --service and no --branch the scope that was deleted
|
|
243
248
|
// is the linked branch's, and the output has to say which scope it actually touched.
|
|
244
|
-
|
|
245
|
-
|
|
249
|
+
const outcome = await applyEntry(d, name, branch, entry, opts);
|
|
250
|
+
if (!outcome)
|
|
251
|
+
return;
|
|
252
|
+
const { verdict, services } = outcome;
|
|
246
253
|
const scope = opts.service ? `${opts.service}, branch ${branch}` : branch ? `branch ${branch}` : 'project-wide';
|
|
247
254
|
info(`unset ${name} (${scope})`);
|
|
248
255
|
for (const line of applyServiceLines(services))
|
|
@@ -105,6 +105,32 @@ export function resolveComputeServiceId(services, name) {
|
|
|
105
105
|
function defaultDatabasePort(type) {
|
|
106
106
|
return type === 'mysql' ? 3306 : type === 'mongodb' ? 27017 : 6379;
|
|
107
107
|
}
|
|
108
|
+
export const ENDPOINTS = ['http', 'tcp', 'udp', 'tcp+udp'];
|
|
109
|
+
export function parseEndpoint(raw) {
|
|
110
|
+
const v = raw.trim().toLowerCase();
|
|
111
|
+
if (!ENDPOINTS.includes(v))
|
|
112
|
+
throw new Error(`--endpoint must be http, tcp, udp or tcp+udp, got: ${raw}`);
|
|
113
|
+
return v;
|
|
114
|
+
}
|
|
115
|
+
// tcp, udp and tcp+udp are the same v6 lane with a different protocol set: no HTTP domain, and the
|
|
116
|
+
// address is endpoint_host on the service port.
|
|
117
|
+
export const isV6Endpoint = (e) => e === 'tcp' || e === 'udp' || e === 'tcp+udp';
|
|
118
|
+
// Where a tcp service is dialed. The host is set once a deploy has confirmed it; before that the
|
|
119
|
+
// row only knows its port.
|
|
120
|
+
export function tcpAddress(s) {
|
|
121
|
+
return s.endpoint_host ? `tcp://${s.endpoint_host}:${s.port}` : `tcp/${s.port ?? '?'} (address after the first deploy)`;
|
|
122
|
+
}
|
|
123
|
+
// Where a v6-lane service (tcp, udp or tcp+udp) is reached, one scheme per protocol it serves:
|
|
124
|
+
// tcp://host:port, udp://host:port, or both for tcp+udp. tcp is exactly tcpAddress. Before a
|
|
125
|
+
// deploy has confirmed the host, the row only knows the protocol and port.
|
|
126
|
+
export function v6Address(s) {
|
|
127
|
+
if (s.endpoint === 'tcp')
|
|
128
|
+
return tcpAddress(s);
|
|
129
|
+
const protos = s.endpoint === 'tcp+udp' ? ['tcp', 'udp'] : ['udp'];
|
|
130
|
+
if (!s.endpoint_host)
|
|
131
|
+
return `${s.endpoint === 'tcp+udp' ? 'tcp+udp' : 'udp'}/${s.port ?? '?'} (address after the first deploy)`;
|
|
132
|
+
return protos.map((pr) => `${pr}://${s.endpoint_host}:${s.port}`).join(', ');
|
|
133
|
+
}
|
|
108
134
|
// Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
|
|
109
135
|
// mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
|
|
110
136
|
// type) stays in servicesAdd, ahead of any network/config access.
|
|
@@ -112,6 +138,7 @@ export function servicesAddRequestBody(type, name, branch, opts) {
|
|
|
112
138
|
return {
|
|
113
139
|
type, name, ...(branch ? { branch } : {}), public: !!opts.public,
|
|
114
140
|
...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}),
|
|
141
|
+
...(opts.endpoint !== undefined ? { endpoint: parseEndpoint(opts.endpoint) } : {}),
|
|
115
142
|
...(opts.region ? { region: opts.region } : {}),
|
|
116
143
|
// Sent whenever the flag was given, false included: the birth default is platform config
|
|
117
144
|
// (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on
|
|
@@ -135,6 +162,11 @@ export async function servicesAdd(type, name, opts = {}) {
|
|
|
135
162
|
throw new Error('--port is only valid for compute services');
|
|
136
163
|
parsePort(opts.port, { allowZero: true });
|
|
137
164
|
}
|
|
165
|
+
if (opts.endpoint !== undefined) {
|
|
166
|
+
if (type !== 'compute')
|
|
167
|
+
throw new Error('--endpoint is only valid for compute services');
|
|
168
|
+
parseEndpoint(opts.endpoint);
|
|
169
|
+
}
|
|
138
170
|
// Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
|
|
139
171
|
if (opts.alwaysOn !== undefined && type !== 'compute')
|
|
140
172
|
throw new Error(alwaysOnTypeError(type));
|
|
@@ -179,7 +211,8 @@ export function serviceAddedLine(type, name, branch, svc) {
|
|
|
179
211
|
const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at ${svc.volume_mount_path ?? "/data"}` : '';
|
|
180
212
|
// The major belongs next to the connect hint: it decides which psql/pg_dump to reach for.
|
|
181
213
|
const pg = svc.type === 'postgres' ? pgBadge(svc.pg_version) : '';
|
|
182
|
-
|
|
214
|
+
const where = isV6Endpoint(svc.endpoint) ? ` — ${v6Address(svc)}` : svc.domain ? ` — ${svc.domain}` : '';
|
|
215
|
+
return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${where}`;
|
|
183
216
|
}
|
|
184
217
|
// ` pg <major>` for a postgres row, or '' when the platform sent nothing usable. The field arrives
|
|
185
218
|
// as untyped API JSON: only a positive integer renders, so a malformed or nonsense value (true,
|
|
@@ -197,7 +230,13 @@ export function serviceListLine(s) {
|
|
|
197
230
|
// Postgres major, so the reader picks matching pg_dump/psql BEFORE connecting (a newer client
|
|
198
231
|
// dumps statements an older server cannot restore). Older platforms send no pg_version.
|
|
199
232
|
: s.type === 'postgres' ? pgBadge(s.pg_version) : '';
|
|
200
|
-
|
|
233
|
+
// A tcp/udp/tcp+udp compute service has no HTTP domain; its address is the v6-lane one.
|
|
234
|
+
const where = s.type === 'compute' && isV6Endpoint(s.endpoint) ? ` ${v6Address(s)}` : s.domain ? ` ${s.domain}` : '';
|
|
235
|
+
// The private address the platform reports (never composed here), and a closed compute service
|
|
236
|
+
// says so beside the URL it no longer serves.
|
|
237
|
+
const closed = s.type === 'compute' && s.public === false ? ' public off' : '';
|
|
238
|
+
const priv = s.private_host ? ` private ${s.private_host}${s.port ? `:${s.port}` : ''}` : '';
|
|
239
|
+
return `${s.type}/${s.name} [${s.status}]${extra}${where}${closed}${priv} ${s.id}`;
|
|
201
240
|
}
|
|
202
241
|
export async function servicesList(opts) {
|
|
203
242
|
const api = await ApiClient.load();
|
|
@@ -129,11 +129,17 @@ export function parseSetFlags(pairs) {
|
|
|
129
129
|
for (const pair of pairs) {
|
|
130
130
|
const m = /^([A-Z][A-Z0-9_]{0,63})=([\s\S]*)$/.exec(pair);
|
|
131
131
|
if (!m)
|
|
132
|
-
throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${pair}`);
|
|
132
|
+
throw new Error(`--set expects NAME=value (NAME matching ^[A-Z][A-Z0-9_]{0,63}$), got: ${redactSetPair(pair)}`);
|
|
133
133
|
values[m[1]] = m[2];
|
|
134
134
|
}
|
|
135
135
|
return values;
|
|
136
136
|
}
|
|
137
|
+
// --set carries secrets and this error reaches stderr and CI logs, so only the name is ever shown.
|
|
138
|
+
function redactSetPair(pair) {
|
|
139
|
+
const eq = pair.indexOf('=');
|
|
140
|
+
// Nothing of a pair with no `=`: it was never split, so the whole token may be the value itself.
|
|
141
|
+
return eq >= 0 ? `${pair.slice(0, eq)}=<value hidden>` : '<value hidden: no NAME= in front of it>';
|
|
142
|
+
}
|
|
137
143
|
export function missingVariablesMessage(missing) {
|
|
138
144
|
return [
|
|
139
145
|
'missing required template variables:',
|
|
@@ -236,8 +242,13 @@ export function deploymentUrls(dep) {
|
|
|
236
242
|
return lines;
|
|
237
243
|
}
|
|
238
244
|
// One line per service with its terminal state — the anatomy of a partial/failed run.
|
|
245
|
+
// `skipped` is the platform saying the user deleted that service and the run stepped over it. Its
|
|
246
|
+
// `state` stays `pending`, which under the marks below would read as "not reached yet" — a run that
|
|
247
|
+
// is still going — so it gets its own mark and names the reason instead.
|
|
239
248
|
export function serviceStateLines(dep) {
|
|
240
249
|
return (dep?.services ?? []).map((s) => {
|
|
250
|
+
if (s?.skipped)
|
|
251
|
+
return ` - ${s?.name ?? 'service'} [skipped: it no longer exists, so the run stepped over it]`;
|
|
241
252
|
const mark = s?.state === 'healthy' ? '✓' : s?.state === 'failed' ? '✗' : '•';
|
|
242
253
|
return ` ${mark} ${s?.name ?? 'service'}${s?.url ? ` — ${s.url}` : ''}${s?.state && s.state !== 'healthy' ? ` [${s.state}]` : ''}`;
|
|
243
254
|
});
|
|
@@ -247,9 +258,13 @@ export function serviceStateLines(dep) {
|
|
|
247
258
|
// deploy, or clean up), not just that something went wrong.
|
|
248
259
|
export function partialMessage(dep) {
|
|
249
260
|
const services = dep?.services ?? [];
|
|
250
|
-
|
|
261
|
+
// A skipped service was never part of this run — the user had deleted it — so counting it in the
|
|
262
|
+
// denominator would report a run that did everything it could as having left something behind.
|
|
263
|
+
// It is still LISTED below, because "where did it go" is the question the count then raises.
|
|
264
|
+
const ran = services.filter((s) => !s?.skipped);
|
|
265
|
+
const healthy = ran.filter((s) => s?.state === 'healthy').length;
|
|
251
266
|
return [
|
|
252
|
-
`template deployment finished partial: ${healthy}/${
|
|
267
|
+
`template deployment finished partial: ${healthy}/${ran.length} services healthy`,
|
|
253
268
|
...serviceStateLines(dep),
|
|
254
269
|
...(dep?.error ? [` ${dep.error}`] : []),
|
|
255
270
|
...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []),
|
|
@@ -421,4 +436,209 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
|
|
|
421
436
|
info('next: `insta postgres url` prints the postgres DSN; bind service credentials into compute with `insta secrets bind`; `insta secrets` refreshes user-defined secrets in .env');
|
|
422
437
|
renderNextActions(dep.nextActions);
|
|
423
438
|
}
|
|
439
|
+
/** The review, as lines. Current is the LIVE value: what the upgrade will overwrite. */
|
|
440
|
+
export function upgradePlanLines(plan) {
|
|
441
|
+
if (plan.refusals.length) {
|
|
442
|
+
return ['This upgrade cannot run:', ...plan.refusals.map((r) => ` ${r}`)];
|
|
443
|
+
}
|
|
444
|
+
const lines = [`${plan.from_version} → ${plan.to_version}`];
|
|
445
|
+
for (const s of plan.services) {
|
|
446
|
+
const who = s.service_name ?? s.key;
|
|
447
|
+
if (s.gone) {
|
|
448
|
+
lines.push(` ${who} no longer exists, skipped`);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
// Created, not reconciled: there is no live value to compare, so its rows follow below as they come.
|
|
452
|
+
if (s.added)
|
|
453
|
+
lines.push(` ${who} added by ${plan.to_version}, created by this upgrade`);
|
|
454
|
+
for (const f of s.fields) {
|
|
455
|
+
const drift = f.drifted ? ' (you changed this, the upgrade will overwrite it)' : '';
|
|
456
|
+
lines.push(` ${who} ${f.label} ${f.live ?? '(none)'} → ${f.next}${drift}`);
|
|
457
|
+
}
|
|
458
|
+
for (const name of s.missing_variables) {
|
|
459
|
+
lines.push(` ${who} ${name} is required by ${plan.to_version} and has no value, pass --set ${name}=...`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
for (const key of plan.removed)
|
|
463
|
+
lines.push(` ${key} dropped by ${plan.to_version}, left running`);
|
|
464
|
+
return lines;
|
|
465
|
+
}
|
|
466
|
+
// The 409 codes a user will actually hit, in a sentence that says what to do next.
|
|
467
|
+
const UPGRADE_409 = {
|
|
468
|
+
template_version_changed: 'The template was republished since the plan was shown. Nothing was changed. Run the command again to review the new version.',
|
|
469
|
+
};
|
|
470
|
+
const ROLLBACK_409 = {
|
|
471
|
+
template_version_not_recorded: 'The earlier version of this template is no longer recorded, so it cannot be restored. Nothing was changed.',
|
|
472
|
+
};
|
|
473
|
+
/**
|
|
474
|
+
* Codes whose platform sentence names the deployment or the version to act on. Replacing it with
|
|
475
|
+
* wording of our own would drop the one name that makes it actionable, so the platform's sentence
|
|
476
|
+
* is printed as given and only the next step is appended.
|
|
477
|
+
*/
|
|
478
|
+
const KEEP_PLATFORM_SENTENCE = {
|
|
479
|
+
// "this deployment was already replaced by <code>@<version> — act on that deployment instead".
|
|
480
|
+
// Reachable here only for a service a NEWER version dropped: it keeps pointing at the deployment
|
|
481
|
+
// the instance has moved off, which is the one this CLI resolves from the service name.
|
|
482
|
+
template_superseded: 'Nothing was changed. This command addresses a service, so name one that the newer deployment carries.',
|
|
483
|
+
// "<code>@<version> was republished with different content after this instance ran it …".
|
|
484
|
+
template_version_content_changed: 'Nothing was changed.',
|
|
485
|
+
// Two causes now, and the second one names the version: "this instance was already rolled back
|
|
486
|
+
// to <version> … upgrade it to move forward", beside the original "was not upgraded from
|
|
487
|
+
// anything". Our own wording could only say one of them, and said the wrong one for a rollback.
|
|
488
|
+
template_no_step_back: 'Nothing was changed.',
|
|
489
|
+
};
|
|
490
|
+
/** The sentence to raise for a coded 409, or undefined when this is not one we explain. */
|
|
491
|
+
function coded409(e, advice) {
|
|
492
|
+
if (!(e instanceof ApiError) || e.status !== 409)
|
|
493
|
+
return undefined;
|
|
494
|
+
const code = typeof e.body?.code === 'string' ? e.body.code : '';
|
|
495
|
+
if (advice[code])
|
|
496
|
+
return new Error(advice[code]);
|
|
497
|
+
const next = KEEP_PLATFORM_SENTENCE[code];
|
|
498
|
+
return next ? new Error(`${e.message}. ${next}`) : undefined;
|
|
499
|
+
}
|
|
500
|
+
async function postWithAdvice(api, path, body, advice) {
|
|
501
|
+
try {
|
|
502
|
+
return await api.rawRequest('POST', path, body);
|
|
503
|
+
}
|
|
504
|
+
catch (e) {
|
|
505
|
+
throw coded409(e, advice) ?? e;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* A service name is what a person has; a deployment uuid is not. The services list already carries
|
|
510
|
+
* the attribution, so the lookup lives here rather than in the user's head.
|
|
511
|
+
*/
|
|
512
|
+
async function deploymentOfService(api, projectId, branch, service, verb) {
|
|
513
|
+
const body = await api.request('GET', `/projects/${projectId}/services?branch=${encodeURIComponent(branch)}`);
|
|
514
|
+
const row = (body.services ?? []).find((s) => s.name === service);
|
|
515
|
+
if (!row)
|
|
516
|
+
throw new Error(`no service named ${service} on branch ${branch}`);
|
|
517
|
+
// Named by the caller: the same sentence under `rollback` would send the user to upgrade instead.
|
|
518
|
+
if (!row.template_deployment_id)
|
|
519
|
+
throw new Error(`${service} was not deployed from a template, so there is nothing to ${verb}`);
|
|
520
|
+
return { deploymentId: row.template_deployment_id };
|
|
521
|
+
}
|
|
522
|
+
export async function templateUpgrade(service, opts = {}, deps = {}) {
|
|
523
|
+
const given = parseSetFlags(opts.set ?? []); // a typo'd --set fails before any network access
|
|
524
|
+
const api = deps.api ?? (await ApiClient.load());
|
|
525
|
+
const p = deps.project ?? (await requireProject());
|
|
526
|
+
const branchName = opts.branch ?? p.branch;
|
|
527
|
+
const ask = deps.ask ?? promptVariable;
|
|
528
|
+
const target = await deploymentOfService(api, p.projectId, branchName, service, 'upgrade');
|
|
529
|
+
// The poll and preview routes are keyed by deployment id, not project: name the project so agent
|
|
530
|
+
// mode signs with the project-bound session, exactly as templateDeploy does.
|
|
531
|
+
// The preview is a GET and raises coded 409s of its own (template_superseded among them), so it
|
|
532
|
+
// gets the same reading as the POST rather than the raw "<sentence> (HTTP 409)" the guard prints.
|
|
533
|
+
let plan;
|
|
534
|
+
try {
|
|
535
|
+
;
|
|
536
|
+
({ plan } = await api.request('GET', `/template-deployments/${target.deploymentId}/upgrade`, undefined, { projectId: p.projectId }));
|
|
537
|
+
}
|
|
538
|
+
catch (e) {
|
|
539
|
+
throw coded409(e, UPGRADE_409) ?? e;
|
|
540
|
+
}
|
|
541
|
+
// --json alone is "tell me" (the plan, no upgrade); --json --yes is "do it and tell me".
|
|
542
|
+
const quiet = !!opts.json;
|
|
543
|
+
if (opts.json && (!opts.yes || plan.refusals.length)) {
|
|
544
|
+
printJson(plan);
|
|
545
|
+
if (plan.refusals.length)
|
|
546
|
+
process.exitCode = 1;
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (!quiet)
|
|
550
|
+
for (const line of upgradePlanLines(plan))
|
|
551
|
+
info(line);
|
|
552
|
+
// A refusal is not a prompt to confirm past: there is nothing to confirm.
|
|
553
|
+
if (plan.refusals.length) {
|
|
554
|
+
process.exitCode = 1;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
// Only what the new version ADDS is ever asked for: the platform recovers every value the
|
|
558
|
+
// instance already holds, so re-prompting would invite the user to overwrite a live credential.
|
|
559
|
+
const missing = plan.services.flatMap((s) => s.missing_variables.map((name) => ({ name, required: true })));
|
|
560
|
+
const tty = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
561
|
+
const variables = missing.length ? await resolveVariables(missing, given, { tty, ask }) : given;
|
|
562
|
+
if (!opts.yes && tty) {
|
|
563
|
+
const go = await clack.confirm({ message: `Upgrade ${service} from ${plan.from_version} to ${plan.to_version}?` });
|
|
564
|
+
if (clack.isCancel(go))
|
|
565
|
+
throw new CliCancel();
|
|
566
|
+
if (!go)
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
// The version and digest the user just read. A republish in between answers 409 rather than
|
|
570
|
+
// running something they never saw.
|
|
571
|
+
const res = await postWithAdvice(api, `/projects/${p.projectId}/template-deployments/${target.deploymentId}/upgrade`, {
|
|
572
|
+
variables, expectedVersion: plan.to_version, expectedDigest: plan.to_digest,
|
|
573
|
+
}, UPGRADE_409);
|
|
574
|
+
if (handleApproval(res, opts.json))
|
|
575
|
+
return;
|
|
576
|
+
const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id;
|
|
577
|
+
if (!quiet)
|
|
578
|
+
info(`upgrading ${service} to ${plan.to_version} (${deploymentId})`);
|
|
579
|
+
const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, quiet ? () => { } : info, deps.wait);
|
|
580
|
+
if (quiet)
|
|
581
|
+
return printJson(dep);
|
|
582
|
+
info(`${service} is on ${plan.to_version}`);
|
|
583
|
+
info(`next: \`insta template rollback ${service}\` returns it to ${plan.from_version}`);
|
|
584
|
+
renderNextActions(dep.nextActions);
|
|
585
|
+
}
|
|
586
|
+
export async function templateRollback(service, opts = {}, deps = {}) {
|
|
587
|
+
const given = parseSetFlags(opts.set ?? []); // a typo'd --set fails before any network access
|
|
588
|
+
const quiet = !!opts.json;
|
|
589
|
+
const out = quiet ? () => { } : info;
|
|
590
|
+
// Destructive, and its two warnings are load-bearing: a machine-readable run must say --yes out
|
|
591
|
+
// loud rather than roll back unseen through a pipe.
|
|
592
|
+
// Same rule as `template publish`: ask on a terminal, anywhere else (a pipe, --json) say --yes.
|
|
593
|
+
const tty = !opts.json && !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
594
|
+
if (!opts.yes && !tty)
|
|
595
|
+
throw new Error('rollback needs --yes without a terminal to confirm on: it does not restore data the app migrated, so nothing was rolled back');
|
|
596
|
+
const api = deps.api ?? (await ApiClient.load());
|
|
597
|
+
const p = deps.project ?? (await requireProject());
|
|
598
|
+
const branchName = opts.branch ?? p.branch;
|
|
599
|
+
const ask = deps.ask ?? promptVariable;
|
|
600
|
+
const target = await deploymentOfService(api, p.projectId, branchName, service, 'roll back');
|
|
601
|
+
// Said BEFORE the confirmation, not after the fact: rollback prints no plan, so these lines are
|
|
602
|
+
// the whole disclosure, and what they warn of is not recoverable by running the command again.
|
|
603
|
+
out('Going back restores every setting that version declares: the image, the start command, the port, always-on, public access, the volume mount path and the recorded variables.');
|
|
604
|
+
out('A setting the older version does not declare keeps its current value.');
|
|
605
|
+
out('Public access on a storage bucket reaches that bucket on every branch that has a copy, not only this one.');
|
|
606
|
+
out('It does not restore data the app migrated under the newer version, and a volume only grows.');
|
|
607
|
+
if (tty) {
|
|
608
|
+
const go = await clack.confirm({ message: `Return ${service} to the version it was upgraded from?` });
|
|
609
|
+
if (clack.isCancel(go))
|
|
610
|
+
throw new CliCancel();
|
|
611
|
+
if (!go)
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
// The platform recovers every value the services still hold, so a rollback normally sends
|
|
615
|
+
// nothing. What it cannot recover is a variable the NEWER version stopped declaring: the upgrade
|
|
616
|
+
// deleted that value and the older manifest still requires it. Rollback prints no plan, so the
|
|
617
|
+
// platform's 400 naming them is the only place those names exist — prompt from it and retry
|
|
618
|
+
// once, exactly as templateDeploy does with its own missing_variables.
|
|
619
|
+
const rollbackPath = `/projects/${p.projectId}/template-deployments/${target.deploymentId}/rollback`;
|
|
620
|
+
const variables = { ...given };
|
|
621
|
+
let res;
|
|
622
|
+
try {
|
|
623
|
+
res = await postWithAdvice(api, rollbackPath, { variables }, ROLLBACK_409);
|
|
624
|
+
}
|
|
625
|
+
catch (e) {
|
|
626
|
+
const missing = e instanceof ApiError ? missingVariablesFrom(e.body) : null;
|
|
627
|
+
if (!missing?.length)
|
|
628
|
+
throw e;
|
|
629
|
+
// No TTY (or --yes) fails here with the `--set NAME=value` list, the same rule the deploy and
|
|
630
|
+
// upgrade paths follow — nothing has been rolled back at this point.
|
|
631
|
+
Object.assign(variables, await resolveVariables(missing, {}, { tty, ask }));
|
|
632
|
+
res = await postWithAdvice(api, rollbackPath, { variables }, ROLLBACK_409);
|
|
633
|
+
}
|
|
634
|
+
if (handleApproval(res, opts.json))
|
|
635
|
+
return;
|
|
636
|
+
const deploymentId = res.body.deploymentId ?? (res.body.deployment ?? res.body).id;
|
|
637
|
+
out(`rolling ${service} back (${deploymentId})`);
|
|
638
|
+
const dep = await watchDeployment((id) => api.request('GET', `/template-deployments/${id}`, undefined, { projectId: p.projectId }), deploymentId, out, deps.wait);
|
|
639
|
+
if (quiet)
|
|
640
|
+
return printJson(dep);
|
|
641
|
+
info(`${service} is back on ${(dep.deployment ?? dep).template_version ?? 'its previous version'}`);
|
|
642
|
+
renderNextActions(dep.nextActions);
|
|
643
|
+
}
|
|
424
644
|
//# sourceMappingURL=template.js.map
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -346,8 +346,6 @@ export async function autoupdate(mode) {
|
|
|
346
346
|
const enabled = cfg.autoUpdate !== false && !process.env.INSTA_NO_AUTOUPDATE;
|
|
347
347
|
info(`autoupdate: ${enabled ? 'on' : 'off'} (default on while the CLI is pre-1.0 — \`insta config autoupdate off\` to disable)`);
|
|
348
348
|
}
|
|
349
|
-
// Called once at CLI start-up. Never blocks: reads the cache synchronously, prints at most one
|
|
350
|
-
// stderr line, and (when due) spawns detached children for the registry check / quiet upgrade.
|
|
351
349
|
/** Commands that must never nudge about an update or spawn a background one.
|
|
352
350
|
*
|
|
353
351
|
* `upgrade`/`autoupdate` are the update machinery itself. Every `__` command
|
|
@@ -368,6 +366,8 @@ export function skipsUpdateCheck(cmd, sub) {
|
|
|
368
366
|
return true;
|
|
369
367
|
return cmd === 'upgrade' || cmd === 'autoupdate' || !!cmd?.startsWith('__');
|
|
370
368
|
}
|
|
369
|
+
// Called once at CLI start-up. Never blocks: reads the cache synchronously, prints at most one
|
|
370
|
+
// stderr line, and (when due) spawns detached children for the registry check / quiet upgrade.
|
|
371
371
|
export function maybeUpdate(current, argv) {
|
|
372
372
|
if (skipsUpdateCheck(argv[2], argv[3]))
|
|
373
373
|
return;
|
package/dist/deploy-archive.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
2
|
import { handleApproval } from './util.js';
|
|
3
|
+
import { ENDPOINTS, isV6Endpoint } from './commands/services.js';
|
|
3
4
|
export function archiveBuildSpec(hasDockerfile) {
|
|
4
5
|
return hasDockerfile ? { type: 'dockerfile' } : { type: 'nixpacks' };
|
|
5
6
|
}
|
|
@@ -72,6 +73,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
72
73
|
archive: ref,
|
|
73
74
|
port: opts.port ? Number(opts.port) : undefined,
|
|
74
75
|
websocket: typeof opts.websocket === 'boolean' ? opts.websocket : undefined,
|
|
76
|
+
endpoint: ENDPOINTS.includes(opts.endpoint ?? '') ? opts.endpoint : undefined,
|
|
75
77
|
replaceSource: opts.replaceSource === true ? true : undefined,
|
|
76
78
|
});
|
|
77
79
|
// An approval is a 202 too, told apart by its status word; anything else here is our operation.
|
|
@@ -121,7 +123,12 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
121
123
|
if (state === 'live') {
|
|
122
124
|
const image = res.body?.imageRef;
|
|
123
125
|
const url = res.body?.url;
|
|
124
|
-
|
|
126
|
+
// A tcp/udp/tcp+udp (v6-lane) service has no URL: its answer is the v6 host instead. A worker has neither.
|
|
127
|
+
const endpointHost = typeof res.body?.endpointHost === 'string' && res.body.endpointHost ? res.body.endpointHost : undefined;
|
|
128
|
+
// The endpoint the service is on (instacloud-platform#681), so the address gets its scheme;
|
|
129
|
+
// an older platform omits it, and a value this CLI does not know is treated as absent.
|
|
130
|
+
const endpoint = endpointHost && isV6Endpoint(res.body?.endpoint) ? res.body.endpoint : undefined;
|
|
131
|
+
if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0' && !endpointHost)) {
|
|
125
132
|
throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
|
|
126
133
|
}
|
|
127
134
|
const optionalString = (field, v) => {
|
|
@@ -132,7 +139,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
132
139
|
return v;
|
|
133
140
|
};
|
|
134
141
|
return {
|
|
135
|
-
image, url,
|
|
142
|
+
image, url, ...(endpointHost ? { endpointHost } : {}), ...(endpoint ? { endpoint } : {}),
|
|
136
143
|
branch: optionalString('branch', res.body.branch) ?? branch,
|
|
137
144
|
group: optionalString('group', res.body.group) ?? opts.group ?? '',
|
|
138
145
|
machineId: optionalString('machineId', res.body.machineId),
|
package/dist/index.js
CHANGED
|
@@ -28,6 +28,7 @@ import { buildLogs } from './commands/build-logs.js';
|
|
|
28
28
|
import * as computeCmd from './commands/compute.js';
|
|
29
29
|
import * as githubCmd from './commands/github.js';
|
|
30
30
|
import * as pgCmd from './commands/postgres.js';
|
|
31
|
+
import * as privateNet from './commands/private-network.js';
|
|
31
32
|
import * as dbQueryCmd from './commands/db-query.js';
|
|
32
33
|
import * as managedDb from './commands/managed-db.js';
|
|
33
34
|
import * as storageCmd from './commands/storage.js';
|
|
@@ -155,6 +156,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
|
|
|
155
156
|
.option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
|
|
156
157
|
.option('--image <url>', 'compute only: run this container image at creation')
|
|
157
158
|
.option('--port <n>', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)')
|
|
159
|
+
.option('--endpoint <http|tcp|udp|tcp+udp>', 'compute only: how the service is reached — http (default: HTTPS at its domain), or tcp / udp / tcp+udp (raw TCP, UDP or both on --port at its own public IPv6 address; IPv6 clients only, no platform TLS, no custom domain). tcp needs --port and refuses commonly scanned, web (80/443) and platform-reserved ports; udp refuses amplification-prone (DNS, NTP, SNMP, memcached, …) and platform-reserved ports; tcp+udp refuses both lists. A udp datagram wakes a suspended service and what the wake held is re-sent until the service first replies (an app may see it twice); a receive-only udp service (never replies) needs --always-on to stay awake')
|
|
158
160
|
.option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way). A worker (--port 0) is always-on regardless')
|
|
159
161
|
.option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request (the default for new compute services; refused with --port 0, a worker cannot scale to zero)')
|
|
160
162
|
.option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
|
|
@@ -364,7 +366,7 @@ function addObservability(group, component, noun) {
|
|
|
364
366
|
// optional makes commander unable to hold that boundary itself).
|
|
365
367
|
const { argv: computeArgv, command: execCommand, windowsFallback: execWindowsFallback, } = computeCmd.splitExecArgs(process.argv);
|
|
366
368
|
// ---- compute ----
|
|
367
|
-
const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), scale, limits, volume, always-on, exec, ssh, GitHub source, logs, metrics');
|
|
369
|
+
const compute = program.command('compute').description('Compute services: lifecycle (start/stop/suspend/restart/status), scale, limits, volume, public access, always-on, exec, ssh, GitHub source, logs, metrics');
|
|
368
370
|
compute.command('start [service]').description('Bring a compute service online (persistent — re-enables auto-wake)')
|
|
369
371
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeStart(service, o)));
|
|
370
372
|
compute.command('stop [service]').description('Take a compute service offline; traffic will NOT wake it until `start`')
|
|
@@ -383,6 +385,10 @@ compute.command('scale [count] [service]').description('Set a compute service sa
|
|
|
383
385
|
compute.command('limits [service]').description("Show or set a compute service's resource ceiling (any plan within the free cap; raising above it needs a paid plan). --memory is the dial; cpu derives from it unless --cpu is given. Billing is actual usage — the ceiling caps what the app may burn, it is not a price")
|
|
384
386
|
.option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
|
|
385
387
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
|
|
388
|
+
compute.command('public-access [mode] [service]').description("Show, close or re-open a compute service's public access (mode: on|off; no mode = print it and the service's private address). Every compute service is reachable by the other services in its project branch at <name>.insta.internal:<port> (TCP and UDP); off = private-only: its URL answers 403 and no longer wakes it, so only services in the same branch reach it. Immediate, no redeploy. Asks first; --yes when there is no terminal or with --json. insta-compute only (gated: service.setAccess)")
|
|
389
|
+
.option('-y, --yes', 'close without asking (required when there is no terminal, or with --json)')
|
|
390
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
391
|
+
.action(guard((mode, service, o) => privateNet.servicePublicAccess('compute', mode, service, o)));
|
|
386
392
|
compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero (the default for new compute services; refused on a worker, port 0). All plans; billing is actual usage either way')
|
|
387
393
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
|
|
388
394
|
compute.command('ssh [service]')
|
|
@@ -449,7 +455,7 @@ pg.command('volume [service]').description("Show or grow a postgres service's pr
|
|
|
449
455
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
450
456
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
451
457
|
.action(guard((service, o) => pgCmd.dbVolume(service, o)));
|
|
452
|
-
pg.command('always-on [mode] [service]').description('Show or set a postgres service always-on (mode: on|off; no mode = print the current setting). on = instance stays warm, no cold starts; off =
|
|
458
|
+
pg.command('always-on [mode] [service]').description('Show or set a postgres service always-on (mode: on|off; no mode = print the current setting). on = instance stays warm, no cold starts (the default for databases); off = scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
|
|
453
459
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
454
460
|
.action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o)));
|
|
455
461
|
pg.command('private-access [mode] [service]').description('Show or set private network access (mode: on|off; no mode = print public and private access). on = mint DATABASE_PRIVATE_URL beside DATABASE_URL (unchanged) — it resolves only inside InstaCloud compute, not from a laptop or CI; bind it into a compute with `insta secrets bind … --source-name DATABASE_PRIVATE_URL`. off = retract it (refused while public access is closed). insta-db-backed only (gated: service.setAccess)')
|
|
@@ -462,7 +468,7 @@ pg.command('public-access [mode] [service]').description("Show, close or re-open
|
|
|
462
468
|
addObservability(pg, 'db', 'postgres');
|
|
463
469
|
// ---- redis / mysql / mongodb (managed databases) ----
|
|
464
470
|
for (const type of ['redis', 'mysql', 'mongodb']) {
|
|
465
|
-
const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, always-on, logs, metrics`);
|
|
471
|
+
const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, public access, always-on, logs, metrics`);
|
|
466
472
|
const query = g.command('query <service> [args...]').description(type === 'redis'
|
|
467
473
|
? 'Run a redis command against the service via the console exec API — a pre-tokenized argv, e.g. `GET mykey`'
|
|
468
474
|
: `Run one quoted ${type} statement against the service via the console exec API`)
|
|
@@ -481,6 +487,10 @@ for (const type of ['redis', 'mysql', 'mongodb']) {
|
|
|
481
487
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
482
488
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
483
489
|
.action(guard((service, o) => computeCmd.serviceVolume(type, service, o)));
|
|
490
|
+
g.command('public-access [mode] [service]').description(`Show, close or re-open a ${type} service's public access (mode: on|off; no mode = print it and the private address). On insta-compute every ${type} is reachable from services in its project branch at <name>-${type}.insta.internal (${type.toUpperCase()}_PRIVATE_URL); off = ${type.toUpperCase()}_URL stops connecting and only apps in the branch reach it, through ${type.toUpperCase()}_PRIVATE_URL. Asks first; --yes when there is no terminal or with --json (gated: service.setAccess)`)
|
|
491
|
+
.option('-y, --yes', 'close without asking (required when there is no terminal, or with --json)')
|
|
492
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
493
|
+
.action(guard((mode, service, o) => privateNet.servicePublicAccess(type, mode, service, o)));
|
|
484
494
|
g.command('always-on <mode> [service]').description(`Set a ${type} service always-on (mode: on|off). on = machines never scale to zero; off = scale-to-zero. Billing is actual usage either way`)
|
|
485
495
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
486
496
|
.action(guard((mode, service, o) => computeCmd.serviceAlwaysOn(type, mode, service, o)));
|
|
@@ -525,6 +535,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
|
|
|
525
535
|
program.command('deploy [dir]').description('Deploy a source directory (built remotely; on insta-compute a Dockerfile is optional and nixpacks detects the runtime) or a prebuilt --image to a branch compute group')
|
|
526
536
|
.option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>', 'port the app listens on (0 for a worker without a routed port)')
|
|
527
537
|
.option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
|
|
538
|
+
.option('--endpoint <http|tcp|udp|tcp+udp>', 'switch how the service is reached from this deploy on: tcp / udp / tcp+udp (raw TCP, UDP or both on --port at its own public IPv6 address; IPv6 clients only) or http (back to its HTTPS domain). Omitted: keep the current endpoint')
|
|
528
539
|
.option('--replace-source', 'the service deploys from a connected GitHub repo: switch it to this image and remove the repo connection (admin); without it such a deploy is refused')
|
|
529
540
|
.option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
|
|
530
541
|
.action(guard((dir, o) => deploy(dir, o)));
|
|
@@ -547,6 +558,18 @@ tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a
|
|
|
547
558
|
.option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting')
|
|
548
559
|
.option('--json')
|
|
549
560
|
.action(guard((target, o) => template.templateDeploy(target, o)));
|
|
561
|
+
tpl.command('upgrade <service>').description('Move a service deployed from a template to the version the registry serves now. Shows what will change and asks before it runs. A service that already exists is reused, and every setting the new version declares is applied: image, start command, port, always-on, public access, the volume and its mount path, and the variables, each of them listed in the plan first. A service the new version ADDS is created by the upgrade: the plan names it as added and then lists every setting it will be created with, each written `(none) -> <new value>` because there is no live service to compare against, followed by the variables it requires. A service the new version no longer declares is left running, named in the plan as dropped. A volume the new version adds is attached at your plan\'s size and mounted where the manifest says; an existing one is never resized. For a storage bucket, a public-access change reaches that bucket on every branch that has a copy. Refused with a 409 when the template was republished since the plan, or when this instance has already moved off this deployment')
|
|
562
|
+
.option('--branch <b>', 'branch the service is on (default: current)')
|
|
563
|
+
.option('--set <NAME=value>', 'set a template variable the new version requires (repeatable)', (v, prev) => [...prev, v], [])
|
|
564
|
+
.option('-y, --yes', 'do not ask for confirmation')
|
|
565
|
+
.option('--json', 'machine-readable output: alone it prints the plan and does not upgrade; with --yes it upgrades and prints the finished deployment')
|
|
566
|
+
.action(guard((service, o) => template.templateUpgrade(service, o)));
|
|
567
|
+
tpl.command('rollback <service>').description('Return a service to the template version it was upgraded from, one step. Restores every setting that version declares: image, start command, port, always-on, public access, the volume mount path and the recorded variables, while a setting it does not declare keeps its current value. For a storage bucket, a public-access change reaches that bucket on every branch that has a copy. It does not restore data the app migrated, and a volume only grows. Refused with a 409 when the service was never upgraded, when that version\'s manifest is no longer recorded, or when that version was republished with different content after this instance ran it')
|
|
568
|
+
.option('--branch <b>', 'branch the service is on (default: current)')
|
|
569
|
+
.option('--set <NAME=value>', "set a template variable the older version requires that the instance no longer holds — an upgrade to a version that stopped declaring it deleted the value (repeatable)", (v, prev) => [...prev, v], [])
|
|
570
|
+
.option('-y, --yes', 'do not ask for confirmation')
|
|
571
|
+
.option('--json', 'print the finished deployment as JSON and suppress progress lines; requires --yes')
|
|
572
|
+
.action(guard((service, o) => template.templateRollback(service, o)));
|
|
550
573
|
// Authoring: the org's community templates, the same drafts the console editor changes.
|
|
551
574
|
tpl.command('create').description("Create a community template draft from the linked project (or --project), or an empty one with --blank. Prints its code, status and the console editor link")
|
|
552
575
|
.option('--project <id>', 'project to generate the draft from (default: the linked project)')
|
|
@@ -139,7 +139,6 @@ export function validateManifest(m) {
|
|
|
139
139
|
if (authored.spec !== undefined) {
|
|
140
140
|
problems.push(`${where}: compute size is the platform's to choose — remove spec`);
|
|
141
141
|
}
|
|
142
|
-
// Validate runtime fields: command and mountPath
|
|
143
142
|
if (svc.command !== undefined && (typeof svc.command !== 'string' || !svc.command.trim())) {
|
|
144
143
|
problems.push(`${where}.command must be a non-empty string`);
|
|
145
144
|
}
|