insta 0.1.22 → 0.1.24
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/compute.js +33 -7
- package/dist/commands/cron.js +2 -2
- package/dist/commands/deploy.js +26 -5
- package/dist/commands/postgres.js +20 -13
- package/dist/commands/secrets.js +24 -17
- package/dist/commands/services.js +25 -2
- package/dist/commands/template-author.js +2 -0
- package/dist/commands/template.js +227 -4
- package/dist/commands/upgrade.js +2 -2
- package/dist/deploy-archive.js +5 -2
- package/dist/index.js +17 -2
- package/dist/template-manifest.js +12 -5
- package/package.json +1 -1
package/dist/commands/compute.js
CHANGED
|
@@ -1723,11 +1723,30 @@ export function acquireLockFile(path, now, staleMs) {
|
|
|
1723
1723
|
// ask whether the holder still exists.
|
|
1724
1724
|
const token = `${process.pid}:${randomUUID()}`;
|
|
1725
1725
|
const release = () => {
|
|
1726
|
+
// The claim a takeover of this token takes, so a release and a takeover never interleave.
|
|
1727
|
+
const claim = takeoverClaim(path, token);
|
|
1728
|
+
let claimed = false;
|
|
1729
|
+
try {
|
|
1730
|
+
linkSync(path, claim);
|
|
1731
|
+
claimed = true;
|
|
1732
|
+
}
|
|
1733
|
+
catch (e) {
|
|
1734
|
+
// Leave the lock unless the disk has no hardlinks, where no takeover can claim it either.
|
|
1735
|
+
if (!LINK_UNSUPPORTED.has(String(e?.code)))
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1726
1738
|
try {
|
|
1727
1739
|
if (readFileSync(path, 'utf8') === token)
|
|
1728
1740
|
unlinkSync(path);
|
|
1729
1741
|
}
|
|
1730
1742
|
catch { /* already gone, or taken over by someone else */ }
|
|
1743
|
+
finally {
|
|
1744
|
+
if (claimed)
|
|
1745
|
+
try {
|
|
1746
|
+
unlinkSync(claim);
|
|
1747
|
+
}
|
|
1748
|
+
catch { /* the inode keeps its other name */ }
|
|
1749
|
+
}
|
|
1731
1750
|
};
|
|
1732
1751
|
const take = () => {
|
|
1733
1752
|
try {
|
|
@@ -1859,21 +1878,22 @@ function processAlive(pid) {
|
|
|
1859
1878
|
* `path` keeps its inode throughout, so there is no moment at which the lock
|
|
1860
1879
|
* at `path` can have become somebody else's between the check and the act:
|
|
1861
1880
|
* the holder judged dead cannot release, every other breaker is behind the
|
|
1862
|
-
* claim, and a release by anyone else checks for its own token first.
|
|
1863
|
-
*
|
|
1864
|
-
*
|
|
1881
|
+
* claim, and a release by anyone else checks for its own token first.
|
|
1882
|
+
* The old holder's release takes this same claim, so it never lands inside a takeover.
|
|
1883
|
+
* A release that cannot take it leaves the lock, to be broken like an abandoned one.
|
|
1865
1884
|
*
|
|
1866
1885
|
* Residual, and deliberately not "fixed": a breaker killed between the link
|
|
1867
|
-
* and its write
|
|
1868
|
-
*
|
|
1869
|
-
*
|
|
1886
|
+
* and its write, or a release killed before its unlink, leaves the claim
|
|
1887
|
+
* behind, and that one token can then no longer be broken or released
|
|
1888
|
+
* (removing the claim by hand is the way out, and the busy message names
|
|
1889
|
+
* the lock). Every scheme for reaping an abandoned claim needs
|
|
1870
1890
|
* to decide the claim is dead and then remove it -- the same check-then-act
|
|
1871
1891
|
* this function exists to eliminate, one level up. A claim left behind AFTER
|
|
1872
1892
|
* the write is inert: the lock now carries the breaker's token, and the next
|
|
1873
1893
|
* breaker keys its claim on that. A wedged lock is recoverable and says so;
|
|
1874
1894
|
* two writers in the same section silently lose the user's config. */
|
|
1875
1895
|
function breakStaleLock(path, stale, mine) {
|
|
1876
|
-
const claim =
|
|
1896
|
+
const claim = takeoverClaim(path, stale);
|
|
1877
1897
|
try {
|
|
1878
1898
|
linkSync(path, claim);
|
|
1879
1899
|
}
|
|
@@ -1906,6 +1926,12 @@ function breakStaleLock(path, stale, mine) {
|
|
|
1906
1926
|
catch { /* the inode keeps its other name */ }
|
|
1907
1927
|
}
|
|
1908
1928
|
}
|
|
1929
|
+
/** link() errors from a disk without hardlinks, where a release may skip its claim. */
|
|
1930
|
+
const LINK_UNSUPPORTED = new Set(['EPERM', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS']);
|
|
1931
|
+
/** The hardlink that arbitrates breaking or releasing the lock `token` names. */
|
|
1932
|
+
function takeoverClaim(path, token) {
|
|
1933
|
+
return `${path}.stale.${createHash('sha256').update(token).digest('hex').slice(0, 32)}`;
|
|
1934
|
+
}
|
|
1909
1935
|
export async function computeSSH(serviceName, opts, deps = {}) {
|
|
1910
1936
|
if (opts.ensureCert !== undefined)
|
|
1911
1937
|
return ensureCertForAlias(opts.ensureCert);
|
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 { 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,17 @@ 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 an endpoint tcp service its tcp address (the
|
|
27
|
+
// platform reports no URL for one). Pure, so it's unit-tested.
|
|
28
|
+
export function deployedTarget(r, port) {
|
|
29
|
+
if (r.endpointHost)
|
|
30
|
+
return `tcp://${r.endpointHost}:${port ?? '?'}`;
|
|
31
|
+
return r.url || '(no URL: a worker)';
|
|
32
|
+
}
|
|
25
33
|
// Ask the platform which lane serves this target, so the CLI stops knowing which provider backs
|
|
26
34
|
// its service. A 404 means the platform predates the contract — and because this is a GET, it
|
|
27
35
|
// 404s the same way for a human and an agent, which a POST would not.
|
|
@@ -143,6 +151,19 @@ export async function deploy(dir, opts) {
|
|
|
143
151
|
die('pick one: a source <dir> OR --image <url>, not both');
|
|
144
152
|
if (!dir && !opts.image)
|
|
145
153
|
die('usage: insta deploy <dir> | --image <url> [--branch <b>] [--group <g>] [--port <n>]');
|
|
154
|
+
// Normalized here, once, so both lanes get the same validated value: the archive lane's body
|
|
155
|
+
// (deployArchive) otherwise silently drops anything that isn't exactly 'http'/'tcp' to
|
|
156
|
+
// undefined, which let a typo like --endpoint tpc build and deploy using the service's existing
|
|
157
|
+
// endpoint instead of failing locally. Checked before ApiClient.load()/requireProject(): those
|
|
158
|
+
// can resolve a project and persist an automatic link for an unlinked directory, so a typo must
|
|
159
|
+
// die before either runs, not after.
|
|
160
|
+
let endpoint;
|
|
161
|
+
try {
|
|
162
|
+
endpoint = opts.endpoint === undefined ? undefined : parseEndpoint(opts.endpoint);
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
die(e.message);
|
|
166
|
+
}
|
|
146
167
|
const api = await ApiClient.load();
|
|
147
168
|
const p = await requireProject();
|
|
148
169
|
const branch = opts.branch ?? p.branch;
|
|
@@ -162,7 +183,7 @@ export async function deploy(dir, opts) {
|
|
|
162
183
|
log(`using port ${exposed} (Dockerfile EXPOSE) — override with --port`);
|
|
163
184
|
}
|
|
164
185
|
}
|
|
165
|
-
const effOpts = { ...opts, port: port?.toString() };
|
|
186
|
+
const effOpts = { ...opts, port: port?.toString(), endpoint };
|
|
166
187
|
const source = dir ? await prepareSource(api, p.projectId, dir, branch, effOpts) : { image: opts.image };
|
|
167
188
|
if (!source)
|
|
168
189
|
return; // an approval is pending; the user approves and re-runs
|
|
@@ -171,8 +192,8 @@ export async function deploy(dir, opts) {
|
|
|
171
192
|
// nextActions, which the operation read does not carry.
|
|
172
193
|
const d = source.deployed;
|
|
173
194
|
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.
|
|
195
|
+
return printJson({ image: d.image, url: d.url, ...(d.endpointHost ? { endpointHost: d.endpointHost } : {}), branch: d.branch, group: d.group, machineId: d.machineId });
|
|
196
|
+
info(`deployed ${d.image} -> ${deployedTarget(d, effOpts.port)} (branch ${d.branch}, group ${d.group})`);
|
|
176
197
|
return;
|
|
177
198
|
}
|
|
178
199
|
const res = await api.rawRequest('POST', `/projects/${p.projectId}/deploy`, deployRequestBody(source, branch, effOpts))
|
|
@@ -182,7 +203,7 @@ export async function deploy(dir, opts) {
|
|
|
182
203
|
const what = source.image;
|
|
183
204
|
if (opts.json)
|
|
184
205
|
return printJson({ image: source.image, ...res.body });
|
|
185
|
-
info(`deployed ${what} -> ${res.body.
|
|
206
|
+
info(`deployed ${what} -> ${deployedTarget(res.body, effOpts.port)} (branch ${res.body.branch}, group ${res.body.group})`);
|
|
186
207
|
renderNextActions(res.body.nextActions);
|
|
187
208
|
}
|
|
188
209
|
// 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();
|
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,20 @@ export function resolveComputeServiceId(services, name) {
|
|
|
105
105
|
function defaultDatabasePort(type) {
|
|
106
106
|
return type === 'mysql' ? 3306 : type === 'mongodb' ? 27017 : 6379;
|
|
107
107
|
}
|
|
108
|
+
// How a compute service is reached: http (the default HTTPS domain) or tcp (raw TCP on its port at
|
|
109
|
+
// its own IPv6 address). Checked locally so a typo fails before any network call; which ports tcp
|
|
110
|
+
// accepts is the platform's answer, not repeated here.
|
|
111
|
+
export function parseEndpoint(raw) {
|
|
112
|
+
const v = raw.trim().toLowerCase();
|
|
113
|
+
if (v !== 'http' && v !== 'tcp')
|
|
114
|
+
throw new Error(`--endpoint must be http or tcp, got: ${raw}`);
|
|
115
|
+
return v;
|
|
116
|
+
}
|
|
117
|
+
// Where a tcp service is dialed. The host is set once a deploy has confirmed it; before that the
|
|
118
|
+
// row only knows its port.
|
|
119
|
+
export function tcpAddress(s) {
|
|
120
|
+
return s.endpoint_host ? `tcp://${s.endpoint_host}:${s.port}` : `tcp/${s.port ?? '?'} (address after the first deploy)`;
|
|
121
|
+
}
|
|
108
122
|
// Map service-add options to the platform POST body. Pure, so it's unit-tested without a network
|
|
109
123
|
// mock (mirrors deployRequestBody in deploy.ts). Validation (which options are valid for which
|
|
110
124
|
// type) stays in servicesAdd, ahead of any network/config access.
|
|
@@ -112,6 +126,7 @@ export function servicesAddRequestBody(type, name, branch, opts) {
|
|
|
112
126
|
return {
|
|
113
127
|
type, name, ...(branch ? { branch } : {}), public: !!opts.public,
|
|
114
128
|
...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}),
|
|
129
|
+
...(opts.endpoint !== undefined ? { endpoint: parseEndpoint(opts.endpoint) } : {}),
|
|
115
130
|
...(opts.region ? { region: opts.region } : {}),
|
|
116
131
|
// Sent whenever the flag was given, false included: the birth default is platform config
|
|
117
132
|
// (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on
|
|
@@ -135,6 +150,11 @@ export async function servicesAdd(type, name, opts = {}) {
|
|
|
135
150
|
throw new Error('--port is only valid for compute services');
|
|
136
151
|
parsePort(opts.port, { allowZero: true });
|
|
137
152
|
}
|
|
153
|
+
if (opts.endpoint !== undefined) {
|
|
154
|
+
if (type !== 'compute')
|
|
155
|
+
throw new Error('--endpoint is only valid for compute services');
|
|
156
|
+
parseEndpoint(opts.endpoint);
|
|
157
|
+
}
|
|
138
158
|
// Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
|
|
139
159
|
if (opts.alwaysOn !== undefined && type !== 'compute')
|
|
140
160
|
throw new Error(alwaysOnTypeError(type));
|
|
@@ -179,7 +199,8 @@ export function serviceAddedLine(type, name, branch, svc) {
|
|
|
179
199
|
const vol = svc.volume_gib ? ` vol ${svc.volume_gib}Gi at ${svc.volume_mount_path ?? "/data"}` : '';
|
|
180
200
|
// The major belongs next to the connect hint: it decides which psql/pg_dump to reach for.
|
|
181
201
|
const pg = svc.type === 'postgres' ? pgBadge(svc.pg_version) : '';
|
|
182
|
-
|
|
202
|
+
const where = svc.endpoint === 'tcp' ? ` — ${tcpAddress(svc)}` : svc.domain ? ` — ${svc.domain}` : '';
|
|
203
|
+
return `added ${type} service ${name} on ${branch ?? 'default'} (${svc.id})${access}${svc.region ? ` ${svc.region}` : ''}${img}${vol}${pg}${where}`;
|
|
183
204
|
}
|
|
184
205
|
// ` pg <major>` for a postgres row, or '' when the platform sent nothing usable. The field arrives
|
|
185
206
|
// as untyped API JSON: only a positive integer renders, so a malformed or nonsense value (true,
|
|
@@ -197,7 +218,9 @@ export function serviceListLine(s) {
|
|
|
197
218
|
// Postgres major, so the reader picks matching pg_dump/psql BEFORE connecting (a newer client
|
|
198
219
|
// dumps statements an older server cannot restore). Older platforms send no pg_version.
|
|
199
220
|
: s.type === 'postgres' ? pgBadge(s.pg_version) : '';
|
|
200
|
-
|
|
221
|
+
// A tcp compute service has no HTTP domain; its address is the tcp one.
|
|
222
|
+
const where = s.type === 'compute' && s.endpoint === 'tcp' ? ` ${tcpAddress(s)}` : s.domain ? ` ${s.domain}` : '';
|
|
223
|
+
return `${s.type}/${s.name} [${s.status}]${extra}${where} ${s.id}`;
|
|
201
224
|
}
|
|
202
225
|
export async function servicesList(opts) {
|
|
203
226
|
const api = await ApiClient.load();
|
|
@@ -65,6 +65,8 @@ function serviceText(s) {
|
|
|
65
65
|
const bits = [
|
|
66
66
|
kind,
|
|
67
67
|
s.port != null ? `port ${s.port}` : undefined,
|
|
68
|
+
// Only a web service has one, and a platform that does not serve the field says nothing.
|
|
69
|
+
s.type === 'web' && s.healthcheck !== undefined ? (s.healthcheck ? `health check ${s.healthcheck}` : 'no health check') : undefined,
|
|
68
70
|
s.image ? `image ${s.image}` : undefined,
|
|
69
71
|
// A null mount path on a volume is the default /data.
|
|
70
72
|
s.volume ? `volume at ${s.mountPath ?? '/data'}` : undefined,
|
|
@@ -32,6 +32,7 @@ export function templateListLines(templates) {
|
|
|
32
32
|
export function normalizeInfoServices(raw) {
|
|
33
33
|
const one = (name, s) => ({
|
|
34
34
|
name, type: s?.type, port: s?.port,
|
|
35
|
+
healthcheck: typeof s?.healthcheck === 'string' ? s.healthcheck : undefined,
|
|
35
36
|
volumeGib: s?.volumeGib ?? s?.volume?.size,
|
|
36
37
|
volume: s?.volume === true || s?.volumeGib != null || s?.volume?.size != null,
|
|
37
38
|
mountPath: typeof s?.mountPath === 'string' ? s.mountPath : undefined,
|
|
@@ -91,7 +92,9 @@ export function templateInfoLines(t, bold = (s) => s) {
|
|
|
91
92
|
// A size only when the registry still carries one: a manifest names no size any more, so
|
|
92
93
|
// "persistent /data" is all there is to say until the service exists.
|
|
93
94
|
const disk = s.volumeGib ? `${s.volumeGib}Gi volume` : s.volume ? `persistent ${s.mountPath ?? '/data'}` : undefined;
|
|
94
|
-
|
|
95
|
+
// Only a web service has a path to probe, and a manifest that names none has no health check.
|
|
96
|
+
const health = s.type === 'web' ? (s.healthcheck ? `health check ${s.healthcheck}` : 'no health check') : undefined;
|
|
97
|
+
const bits = [infoKind(s), s.port ? `port ${s.port}` : undefined, health, disk].filter(Boolean);
|
|
95
98
|
return `${s.name}${bits.length ? ` (${bits.join(', ')})` : ''}`;
|
|
96
99
|
});
|
|
97
100
|
lines.push(`services (${services.length}): ${summary.join(', ')}`);
|
|
@@ -126,11 +129,17 @@ export function parseSetFlags(pairs) {
|
|
|
126
129
|
for (const pair of pairs) {
|
|
127
130
|
const m = /^([A-Z][A-Z0-9_]{0,63})=([\s\S]*)$/.exec(pair);
|
|
128
131
|
if (!m)
|
|
129
|
-
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)}`);
|
|
130
133
|
values[m[1]] = m[2];
|
|
131
134
|
}
|
|
132
135
|
return values;
|
|
133
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
|
+
}
|
|
134
143
|
export function missingVariablesMessage(missing) {
|
|
135
144
|
return [
|
|
136
145
|
'missing required template variables:',
|
|
@@ -233,8 +242,13 @@ export function deploymentUrls(dep) {
|
|
|
233
242
|
return lines;
|
|
234
243
|
}
|
|
235
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.
|
|
236
248
|
export function serviceStateLines(dep) {
|
|
237
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]`;
|
|
238
252
|
const mark = s?.state === 'healthy' ? '✓' : s?.state === 'failed' ? '✗' : '•';
|
|
239
253
|
return ` ${mark} ${s?.name ?? 'service'}${s?.url ? ` — ${s.url}` : ''}${s?.state && s.state !== 'healthy' ? ` [${s.state}]` : ''}`;
|
|
240
254
|
});
|
|
@@ -244,9 +258,13 @@ export function serviceStateLines(dep) {
|
|
|
244
258
|
// deploy, or clean up), not just that something went wrong.
|
|
245
259
|
export function partialMessage(dep) {
|
|
246
260
|
const services = dep?.services ?? [];
|
|
247
|
-
|
|
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;
|
|
248
266
|
return [
|
|
249
|
-
`template deployment finished partial: ${healthy}/${
|
|
267
|
+
`template deployment finished partial: ${healthy}/${ran.length} services healthy`,
|
|
250
268
|
...serviceStateLines(dep),
|
|
251
269
|
...(dep?.error ? [` ${dep.error}`] : []),
|
|
252
270
|
...(dep?.logsTail ? ['--- log tail ---', String(dep.logsTail).trimEnd()] : []),
|
|
@@ -418,4 +436,209 @@ export async function templateDeploy(target, opts = {}, deps = {}) {
|
|
|
418
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');
|
|
419
437
|
renderNextActions(dep.nextActions);
|
|
420
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
|
+
}
|
|
421
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
|
@@ -72,6 +72,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
72
72
|
archive: ref,
|
|
73
73
|
port: opts.port ? Number(opts.port) : undefined,
|
|
74
74
|
websocket: typeof opts.websocket === 'boolean' ? opts.websocket : undefined,
|
|
75
|
+
endpoint: opts.endpoint === 'http' || opts.endpoint === 'tcp' ? opts.endpoint : undefined,
|
|
75
76
|
replaceSource: opts.replaceSource === true ? true : undefined,
|
|
76
77
|
});
|
|
77
78
|
// An approval is a 202 too, told apart by its status word; anything else here is our operation.
|
|
@@ -121,7 +122,9 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
121
122
|
if (state === 'live') {
|
|
122
123
|
const image = res.body?.imageRef;
|
|
123
124
|
const url = res.body?.url;
|
|
124
|
-
|
|
125
|
+
// An endpoint tcp service has no URL: its answer is the tcp host instead. A worker has neither.
|
|
126
|
+
const endpointHost = typeof res.body?.endpointHost === 'string' && res.body.endpointHost ? res.body.endpointHost : undefined;
|
|
127
|
+
if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0' && !endpointHost)) {
|
|
125
128
|
throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
|
|
126
129
|
}
|
|
127
130
|
const optionalString = (field, v) => {
|
|
@@ -132,7 +135,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
132
135
|
return v;
|
|
133
136
|
};
|
|
134
137
|
return {
|
|
135
|
-
image, url,
|
|
138
|
+
image, url, ...(endpointHost ? { endpointHost } : {}),
|
|
136
139
|
branch: optionalString('branch', res.body.branch) ?? branch,
|
|
137
140
|
group: optionalString('group', res.body.group) ?? opts.group ?? '',
|
|
138
141
|
machineId: optionalString('machineId', res.body.machineId),
|
package/dist/index.js
CHANGED
|
@@ -155,6 +155,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
|
|
|
155
155
|
.option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
|
|
156
156
|
.option('--image <url>', 'compute only: run this container image at creation')
|
|
157
157
|
.option('--port <n>', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)')
|
|
158
|
+
.option('--endpoint <http|tcp>', 'compute only: how the service is reached — http (default: HTTPS at its domain) or tcp (raw TCP 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')
|
|
158
159
|
.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
160
|
.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
161
|
.option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
|
|
@@ -449,7 +450,7 @@ pg.command('volume [service]').description("Show or grow a postgres service's pr
|
|
|
449
450
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
450
451
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
451
452
|
.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 =
|
|
453
|
+
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
454
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
454
455
|
.action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o)));
|
|
455
456
|
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)')
|
|
@@ -525,6 +526,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
|
|
|
525
526
|
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
527
|
.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
528
|
.option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
|
|
529
|
+
.option('--endpoint <http|tcp>', 'switch how the service is reached from this deploy on: tcp (raw TCP on --port at its own public IPv6 address; IPv6 clients only) or http (back to its HTTPS domain). Omitted: keep the current endpoint')
|
|
528
530
|
.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
531
|
.option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
|
|
530
532
|
.action(guard((dir, o) => deploy(dir, o)));
|
|
@@ -547,6 +549,18 @@ tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a
|
|
|
547
549
|
.option('-y, --yes', 'non-interactive: missing required variables fail with a --set list instead of prompting')
|
|
548
550
|
.option('--json')
|
|
549
551
|
.action(guard((target, o) => template.templateDeploy(target, o)));
|
|
552
|
+
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')
|
|
553
|
+
.option('--branch <b>', 'branch the service is on (default: current)')
|
|
554
|
+
.option('--set <NAME=value>', 'set a template variable the new version requires (repeatable)', (v, prev) => [...prev, v], [])
|
|
555
|
+
.option('-y, --yes', 'do not ask for confirmation')
|
|
556
|
+
.option('--json', 'machine-readable output: alone it prints the plan and does not upgrade; with --yes it upgrades and prints the finished deployment')
|
|
557
|
+
.action(guard((service, o) => template.templateUpgrade(service, o)));
|
|
558
|
+
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')
|
|
559
|
+
.option('--branch <b>', 'branch the service is on (default: current)')
|
|
560
|
+
.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], [])
|
|
561
|
+
.option('-y, --yes', 'do not ask for confirmation')
|
|
562
|
+
.option('--json', 'print the finished deployment as JSON and suppress progress lines; requires --yes')
|
|
563
|
+
.action(guard((service, o) => template.templateRollback(service, o)));
|
|
550
564
|
// Authoring: the org's community templates, the same drafts the console editor changes.
|
|
551
565
|
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
566
|
.option('--project <id>', 'project to generate the draft from (default: the linked project)')
|
|
@@ -556,7 +570,8 @@ tpl.command('create').description("Create a community template draft from the li
|
|
|
556
570
|
.option('--json')
|
|
557
571
|
.action(guard((o) => templateAuthor.templateCreate(o)));
|
|
558
572
|
tpl.command('drafts').description("List your org's community templates, drafts and published, with their status")
|
|
559
|
-
.option('--org <id>', "target org (default: linked project's org)")
|
|
573
|
+
.option('--org <id>', "target org (default: linked project's org)")
|
|
574
|
+
.option('--json', 'print one summary per template as the platform serves it, with no services, variables or publish requirements. insta template draft <code> --json prints one draft in full')
|
|
560
575
|
.action(guard((o) => templateAuthor.templateDrafts(o)));
|
|
561
576
|
tpl.command('draft <code>').description('Show one community template draft: its services, every variable with its choice, and the publish requirements')
|
|
562
577
|
.option('--org <id>', "target org (default: linked project's org)").option('--json')
|
|
@@ -14,6 +14,8 @@ export const MANIFEST_FILE = 'insta.template.yaml';
|
|
|
14
14
|
const CODE_RE = /^[a-z0-9][a-z0-9-]{0,38}$/;
|
|
15
15
|
export const ENV_NAME_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
16
16
|
const GENERATOR_RE = /^secret:([1-9]\d{0,2})$/;
|
|
17
|
+
// The platform's HEALTHCHECK_RE (src/provisioning/templateManifest.ts), verbatim.
|
|
18
|
+
const HEALTHCHECK_RE = /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/?]*$/;
|
|
17
19
|
// The compute vocabulary every CLI has known. Any other type is the platform's to accept or refuse.
|
|
18
20
|
const COMPUTE_TYPES = ['web', 'worker'];
|
|
19
21
|
// Datastores that never gain a url or host. Storage is left off because a public bucket may get an address.
|
|
@@ -116,10 +118,16 @@ export function validateManifest(m) {
|
|
|
116
118
|
if (svc.alwaysOn === false)
|
|
117
119
|
problems.push(`${where}.alwaysOn: a worker cannot scale to zero — nothing is routed to it, so nothing would wake it; remove alwaysOn or set it true`);
|
|
118
120
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
// Optional on a web service, and a given path must pass the platform's grammar.
|
|
122
|
+
if (svc.type !== 'worker' && svc.healthcheck !== undefined) {
|
|
123
|
+
const healthcheck = scalarString(svc.healthcheck);
|
|
124
|
+
if (healthcheck === null)
|
|
125
|
+
problems.push(`${where}.healthcheck must be a string`);
|
|
126
|
+
else if (!healthcheck.startsWith('/'))
|
|
127
|
+
problems.push(`${where}: healthcheck must be an absolute path (start with /)`);
|
|
128
|
+
else if (!HEALTHCHECK_RE.test(healthcheck))
|
|
129
|
+
problems.push(`${where}: healthcheck must be a single-slash absolute path on the service itself (no '//host', scheme, backslash or control characters), got: ${healthcheck}`);
|
|
130
|
+
}
|
|
123
131
|
// Sizing is the platform's, capped for the org's plan. Same answers the
|
|
124
132
|
// publish endpoint gives, said here so an author does not upload to find out. Read as unknown:
|
|
125
133
|
// the type above admits only what is SUPPORTED, and the document is a cast over YAML.parse, so
|
|
@@ -131,7 +139,6 @@ export function validateManifest(m) {
|
|
|
131
139
|
if (authored.spec !== undefined) {
|
|
132
140
|
problems.push(`${where}: compute size is the platform's to choose — remove spec`);
|
|
133
141
|
}
|
|
134
|
-
// Validate runtime fields: command and mountPath
|
|
135
142
|
if (svc.command !== undefined && (typeof svc.command !== 'string' || !svc.command.trim())) {
|
|
136
143
|
problems.push(`${where}.command must be a non-empty string`);
|
|
137
144
|
}
|