insta 0.1.12 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -9
- package/dist/commands/build.js +3 -3
- package/dist/commands/deploy.js +1 -4
- package/dist/commands/postgres.js +187 -9
- package/dist/commands/services.js +24 -11
- package/dist/deploy-archive.js +1 -1
- package/dist/index.js +20 -10
- package/dist/pack.js +5 -26
- package/dist/resolve-service.js +3 -3
- package/dist/telemetry.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,11 +74,12 @@ insta deploy .
|
|
|
74
74
|
|
|
75
75
|
`project create` makes an empty project and links the current directory. Services are
|
|
76
76
|
opt-in, so you add only what you need. `secrets` writes the current branch's user-defined
|
|
77
|
-
secrets
|
|
77
|
+
secrets plus the primary provider credentials of each service type to `./.env`. A service created with
|
|
78
|
+
`insta service add compute` starts with no provider credential bindings; bind the sources it needs before deploying. `deploy .`
|
|
78
79
|
builds the directory remotely and ships it to the branch's compute
|
|
79
80
|
service, with no local Docker. Whether it needs a `Dockerfile` depends on where the
|
|
80
81
|
service runs: on insta-compute it is optional, and a directory without one is built
|
|
81
|
-
by nixpacks on the build gateway; on
|
|
82
|
+
by nixpacks on the build gateway; on legacy-plane services one is still required.
|
|
82
83
|
|
|
83
84
|
## Authentication
|
|
84
85
|
|
|
@@ -116,12 +117,21 @@ the two branches diverge independently. A project is capped at 10 branches.
|
|
|
116
117
|
|
|
117
118
|
### Credentials come from the secret seam, not a file you maintain
|
|
118
119
|
|
|
119
|
-
`insta secrets`
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
`insta secrets bind` rules
|
|
124
|
-
|
|
120
|
+
`insta secrets` writes the current branch's **user-defined secrets plus primary provider
|
|
121
|
+
credentials** to `./.env`: one credential set per service type, from that type's primary service.
|
|
122
|
+
`insta run <cmd>` injects the same bundle into a local child process without touching disk.
|
|
123
|
+
Fetching this bundle does not bind credentials to compute. A compute service receives provider
|
|
124
|
+
credentials only through explicit `insta secrets bind` rules; `insta service add compute` creates
|
|
125
|
+
a service with no provider credential bindings inherited from other services. Bind each source it
|
|
126
|
+
needs, then deploy or restart to apply the bindings. For a specific postgres service's connection string, use
|
|
127
|
+
`insta postgres url <name>` (or `insta postgres connect <name>` for a psql session).
|
|
128
|
+
|
|
129
|
+
A postgres database is reachable on its public endpoint (`DATABASE_URL`) by default.
|
|
130
|
+
`insta postgres private-access on` adds `DATABASE_PRIVATE_URL` beside it, which resolves only inside
|
|
131
|
+
InstaCloud compute; bind it into a compute with `insta secrets bind DATABASE_URL postgres/db
|
|
132
|
+
--source-name DATABASE_PRIVATE_URL --to compute/api`. `insta postgres public-access off` then makes the
|
|
133
|
+
database not reachable from the internet: it prints what will break and asks first (`--yes` when
|
|
134
|
+
there is no terminal), and external clients, CI and local development can no longer connect.
|
|
125
135
|
|
|
126
136
|
Secrets can be scoped per compute service, so several services may each define the same name — and
|
|
127
137
|
a flat bundle cannot carry two values for one name. Such a name is **withheld** from the bundle and
|
|
@@ -221,7 +231,7 @@ build never reaches a production installer.
|
|
|
221
231
|
| `insta secrets` | Write `.env`, plus `list` · `set` · `unset` · `bind` · `unbind` · `bindings` · `sources` · `tree` |
|
|
222
232
|
| `insta domain` | Bought or bring-your-own: `attach` · `check` · `detach`; buy through InstaCloud: `search` · `buy` · `list` · `status` · `records …` |
|
|
223
233
|
| `insta compute` | `start` · `stop` · `suspend` · `restart` · `status` · `scale` · `limits` · `volume` · `always-on` · `exec` · `ssh` · `repo` · `connect-repo` · `watch-paths` · `disconnect-repo` · `logs` · `metrics` |
|
|
224
|
-
| `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `volume` · `always-on` · `logs` · `metrics` — every verb takes `[service]` |
|
|
234
|
+
| `insta postgres` | `url` (print the DSN) · `connect` (psql) · `stats` · `limits` · `volume` · `always-on` · `private-access` · `public-access` · `logs` · `metrics` — every verb takes `[service]` |
|
|
225
235
|
| `insta redis` · `mysql` · `mongodb` | `query` · `status` · `limits` · `volume` · `always-on` · `logs` · `metrics` |
|
|
226
236
|
| `insta storage` | `list` · `get` · `delete` · `set-access` |
|
|
227
237
|
| `insta build [dir]` · `deploy [dir]` | Verify a source dir would build; deploy a source directory (built remotely) or `--image <url>`. `insta build logs <id>` reads source-build output: `--source archive` (default) uses the deploy operation ID, `--source github` uses a GitHub build ID; `--follow` watches output, `--json` returns one snapshot |
|
package/dist/commands/build.js
CHANGED
|
@@ -123,11 +123,11 @@ export async function buildReport(dirArg, opts, deps) {
|
|
|
123
123
|
severity: 'warning',
|
|
124
124
|
status: 'fail',
|
|
125
125
|
title: 'Dockerfile',
|
|
126
|
-
detail: `${dockerfileDetail} — \`insta deploy <dir>\` builds this on the gateway with nixpacks when the service runs on insta-compute, and needs your own Dockerfile on a
|
|
126
|
+
detail: `${dockerfileDetail} — \`insta deploy <dir>\` builds this on the gateway with nixpacks when the service runs on insta-compute, and needs your own Dockerfile on a legacy-plane one`,
|
|
127
127
|
// NOT "save the generated Dockerfile here": it is not standalone (it COPYs the
|
|
128
128
|
// .nixpacks/nixpkgs-<hash>.nix support files nixpacks writes beside it, which this
|
|
129
129
|
// directory does not have). The detected commands above are the reusable part.
|
|
130
|
-
nextAction: `on insta-compute this deploys as-is (nixpacks builds it on the gateway). On a
|
|
130
|
+
nextAction: `on insta-compute this deploys as-is (nixpacks builds it on the gateway). On a legacy-plane service, write a Dockerfile at ${userDockerfilePath} — the detected install/start commands above are the starting point — or connect the GitHub repo (\`insta compute connect-repo <owner/repo>\`)`,
|
|
131
131
|
}
|
|
132
132
|
: {
|
|
133
133
|
id: 'dockerfile',
|
|
@@ -190,7 +190,7 @@ export function renderReport(r, explain) {
|
|
|
190
190
|
// The builder line is the first thing read (and the thing an agent scrapes), so it carries the
|
|
191
191
|
// lane caveat too — "builder: nixpacks" on its own reads as a promise that is only true on some
|
|
192
192
|
// targets. It has to say the SAME thing the detail below it says, or a scrape sees both claims.
|
|
193
|
-
const lane = r.plan.builder === 'nixpacks' ? ' — server-side: insta-compute builds this as-is, a
|
|
193
|
+
const lane = r.plan.builder === 'nixpacks' ? ' — server-side: insta-compute builds this as-is, a legacy-plane service needs your own Dockerfile' : '';
|
|
194
194
|
lines.push(` builder: ${r.plan.builder ?? 'none'}${r.plan.providers.length ? ` (providers: ${r.plan.providers.join(', ')})` : ''}${lane}`);
|
|
195
195
|
if (r.plan.installCommand)
|
|
196
196
|
lines.push(` install: ${r.plan.installCommand}`);
|
package/dist/commands/deploy.js
CHANGED
|
@@ -147,12 +147,9 @@ export async function deploy(dir, opts) {
|
|
|
147
147
|
const p = await requireProject();
|
|
148
148
|
const branch = opts.branch ?? p.branch;
|
|
149
149
|
const log = note(opts);
|
|
150
|
-
// Junk fails here, before a directory is packed and uploaded for a body the platform would only
|
|
151
|
-
// refuse: the same parser every other --port in this CLI runs. `Number()` alone sent NaN as
|
|
152
|
-
// `null` and let 0 or 70000 travel to the server.
|
|
153
150
|
let port;
|
|
154
151
|
try {
|
|
155
|
-
port = opts.port === undefined ? undefined : parsePort(opts.port);
|
|
152
|
+
port = opts.port === undefined ? undefined : parsePort(opts.port, { allowZero: true });
|
|
156
153
|
}
|
|
157
154
|
catch (e) {
|
|
158
155
|
die(`--${e.message}`);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { constants as osConstants } from 'node:os';
|
|
3
3
|
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
4
|
-
import
|
|
4
|
+
import * as clack from '@clack/prompts';
|
|
5
|
+
import { info, printJson, handleApproval, refuse, relayExitCode } from '../util.js';
|
|
5
6
|
import { parseVolumeGib, q, resolveSoleService } from './services.js';
|
|
6
7
|
export function alwaysOnArgs(first, second) {
|
|
7
8
|
if (first === 'on' || first === 'off')
|
|
@@ -281,14 +282,6 @@ export async function dbVolume(service, opts) {
|
|
|
281
282
|
const vg = res.body?.volumeGib;
|
|
282
283
|
info(`postgres ${service ?? 'default'}: volume ${typeof vg === 'number' ? `grown to ${vg}Gi` : `set to ${sizeGib}Gi`}`);
|
|
283
284
|
}
|
|
284
|
-
// Resolve the postgres service (sole, or the named one) and its connection string. Two reads: the
|
|
285
|
-
// branch's services list names the service; GET /services/:id/credentials (gated secrets.read)
|
|
286
|
-
// carries the value. Provider-minted credentials are canonical within their source service
|
|
287
|
-
// (DATABASE_URL) and deliberately absent from the general `insta secrets` bundle, so this is the
|
|
288
|
-
// read that yields the DSN. The credentials call carries no branch param — the service id is
|
|
289
|
-
// already branch-scoped by the list. Returns null when the read parked on an approval
|
|
290
|
-
// (handleApproval already spoke). Takes the client as an argument so tests drive it with a stub,
|
|
291
|
-
// per this repo's pure-seam convention.
|
|
292
285
|
export async function resolveDbUrl(api, projectId, branch, service, json) {
|
|
293
286
|
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
|
|
294
287
|
const svc = resolveSoleService(services, 'postgres', service);
|
|
@@ -374,4 +367,189 @@ export async function dbConnect(service, opts) {
|
|
|
374
367
|
process.stderr.write(`psql → postgres/${r.serviceName}${branch ? ` (branch ${branch})` : ''} — a suspended instance wakes on connect, so the first prompt can take a few seconds\n`);
|
|
375
368
|
relayExitCode(await connectWithPsql(r.url));
|
|
376
369
|
}
|
|
370
|
+
async function confirmOnTerminal(question) {
|
|
371
|
+
const answer = await clack.confirm({ message: question, initialValue: false });
|
|
372
|
+
if (clack.isCancel(answer))
|
|
373
|
+
return false;
|
|
374
|
+
return answer === true;
|
|
375
|
+
}
|
|
376
|
+
const onOff = (v) => (v === true ? 'on' : v === false ? 'off' : 'unknown');
|
|
377
|
+
// The access block of a database/instance read. Pure, exported for tests. privateConnString in the
|
|
378
|
+
// instance view carries NO password (the full URL is DATABASE_PRIVATE_URL in the credentials), so
|
|
379
|
+
// printing it is safe.
|
|
380
|
+
export function dbAccessLines(group, body) {
|
|
381
|
+
const pub = body?.publicAccess;
|
|
382
|
+
const lines = [
|
|
383
|
+
`postgres ${group}: public access ${onOff(pub)}${pub === false ? ' — not reachable from the internet; only the private network lane connects' : pub === true ? ' — reachable on the public endpoint (DATABASE_URL)' : ' — the provider did not report it'}`,
|
|
384
|
+
`postgres ${group}: private access ${onOff(body?.privateAccess)}${body?.privateAccess === true ? ' — DATABASE_PRIVATE_URL is in the service credentials (resolves only inside InstaCloud compute)' : ''}`,
|
|
385
|
+
];
|
|
386
|
+
if (typeof body?.privateConnString === 'string' && body.privateConnString)
|
|
387
|
+
lines.push(` private: ${body.privateConnString}`);
|
|
388
|
+
if (body?.privateLane?.enabled === false)
|
|
389
|
+
lines.push(' the private network lane is not yet available on this deployment — private access cannot be turned on, nor public access closed');
|
|
390
|
+
return lines;
|
|
391
|
+
}
|
|
392
|
+
// Human lines for what closing public access will break. The platform's warnings already name each
|
|
393
|
+
// affected compute (and always end with the external-clients one), so the services array is left
|
|
394
|
+
// to --json rather than repeated.
|
|
395
|
+
export function impactLines(impact) {
|
|
396
|
+
const warnings = (impact?.warnings ?? []).filter((w) => typeof w === 'string' && w);
|
|
397
|
+
if (!warnings.length)
|
|
398
|
+
return [];
|
|
399
|
+
return ['closing public access will break:', ...warnings.map((w) => ` ! ${w}`)];
|
|
400
|
+
}
|
|
401
|
+
// Map an access-change refusal to a CLI-shaped message. Pure, exported for tests. The platform's
|
|
402
|
+
// 400 texts name REST routes; the CLI names its own command instead, keeping the reason.
|
|
403
|
+
export function accessErrorMessage(e, ctx) {
|
|
404
|
+
const code = e.body?.code;
|
|
405
|
+
const b = ctx.branch ? ` --branch ${ctx.branch}` : '';
|
|
406
|
+
if (code === 'private_lane_disabled') {
|
|
407
|
+
return `private network access for postgres is not yet available on this deployment — postgres ${ctx.service} stays reachable on its public endpoint (DATABASE_URL)`;
|
|
408
|
+
}
|
|
409
|
+
if (code === 'private_lane_unavailable_in_region') {
|
|
410
|
+
return `the region of postgres ${ctx.service} has no private network lane yet — private access was turned back off and nothing was minted; DATABASE_URL is unchanged`;
|
|
411
|
+
}
|
|
412
|
+
if (e.status === 400 && /private network access first/i.test(e.message)) {
|
|
413
|
+
return `turn on private access first: \`insta postgres private-access on ${ctx.service}${b}\` — closing public access without it would leave the database with no way to connect`;
|
|
414
|
+
}
|
|
415
|
+
if (e.status === 400 && /re-open public access first/i.test(e.message)) {
|
|
416
|
+
return `re-open public access first: \`insta postgres public-access on ${ctx.service}${b}\` — turning private access off while public access is closed would leave the database with no way to connect`;
|
|
417
|
+
}
|
|
418
|
+
return `${ctx.what} failed (${e.status}): ${e.message}`;
|
|
419
|
+
}
|
|
420
|
+
async function resolvePostgres(api, projectId, branch, service) {
|
|
421
|
+
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
|
|
422
|
+
return resolveSoleService(services, 'postgres', service);
|
|
423
|
+
}
|
|
424
|
+
function settingsSuffix(branch, group) {
|
|
425
|
+
const qs = new URLSearchParams();
|
|
426
|
+
if (branch)
|
|
427
|
+
qs.set('branch', branch);
|
|
428
|
+
qs.set('group', group);
|
|
429
|
+
return `?${qs}`;
|
|
430
|
+
}
|
|
431
|
+
// `insta postgres public-access [on|off] [service]` — show, open, or close the public endpoint.
|
|
432
|
+
// Reads GET /database/instance; writes PUT /services/:id/access {public} (gated: service.setAccess).
|
|
433
|
+
// Closing previews GET /services/:id/access/impact?public=false first and asks before acting; a
|
|
434
|
+
// non-interactive caller (agent, CI, --json) must pass --yes, having read the preview it printed.
|
|
435
|
+
export async function dbPublicAccess(first, second, opts, deps = {}) {
|
|
436
|
+
const { mode, service } = alwaysOnArgs(first, second);
|
|
437
|
+
const api = await ApiClient.load();
|
|
438
|
+
const p = await requireProject();
|
|
439
|
+
const branch = opts.branch ?? p.branch;
|
|
440
|
+
const svc = await resolvePostgres(api, p.projectId, branch, service);
|
|
441
|
+
if (!mode)
|
|
442
|
+
return showAccess(api, p.projectId, branch, svc.name, opts);
|
|
443
|
+
const what = mode === 'off' ? 'closing public access' : 'opening public access';
|
|
444
|
+
const ctx = { service: svc.name, branch: opts.branch, what };
|
|
445
|
+
if (mode === 'off') {
|
|
446
|
+
let impact;
|
|
447
|
+
try {
|
|
448
|
+
impact = await api.request('GET', `/projects/${p.projectId}/services/${svc.id}/access/impact?public=false`);
|
|
449
|
+
}
|
|
450
|
+
catch (e) {
|
|
451
|
+
if (e instanceof ApiError)
|
|
452
|
+
throw new Error(`previewing the impact failed (${e.status}): ${e.message}`);
|
|
453
|
+
throw e;
|
|
454
|
+
}
|
|
455
|
+
const lines = impactLines(impact);
|
|
456
|
+
const tty = deps.tty ?? (!!process.stdin.isTTY && !!process.stdout.isTTY);
|
|
457
|
+
if (!opts.yes) {
|
|
458
|
+
if (opts.json || !tty) {
|
|
459
|
+
refuse([
|
|
460
|
+
...lines,
|
|
461
|
+
`postgres ${svc.name}: closing public access makes the database not reachable from the internet — re-run with --yes to confirm`,
|
|
462
|
+
]);
|
|
463
|
+
}
|
|
464
|
+
for (const l of lines)
|
|
465
|
+
info(l);
|
|
466
|
+
const ok = await (deps.confirm ?? confirmOnTerminal)(`Close public access to postgres ${svc.name}?`);
|
|
467
|
+
if (!ok) {
|
|
468
|
+
info(`postgres ${svc.name}: public access unchanged`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
else if (!opts.json) {
|
|
473
|
+
for (const l of lines)
|
|
474
|
+
info(l);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
let res;
|
|
478
|
+
try {
|
|
479
|
+
res = await api.rawRequest('PUT', `/projects/${p.projectId}/services/${svc.id}/access`, { public: mode === 'on' });
|
|
480
|
+
}
|
|
481
|
+
catch (e) {
|
|
482
|
+
if (e instanceof ApiError)
|
|
483
|
+
throw new Error(accessErrorMessage(e, ctx));
|
|
484
|
+
throw e;
|
|
485
|
+
}
|
|
486
|
+
if (handleApproval(res, opts.json))
|
|
487
|
+
return;
|
|
488
|
+
if (opts.json)
|
|
489
|
+
return printJson(res.body);
|
|
490
|
+
for (const l of publicAccessSetLines(svc.name, mode, res.body))
|
|
491
|
+
info(l);
|
|
492
|
+
}
|
|
493
|
+
export function publicAccessSetLines(group, mode, body) {
|
|
494
|
+
const notice = typeof body?.notice === 'string' && body.notice ? body.notice : undefined;
|
|
495
|
+
const head = mode === 'off'
|
|
496
|
+
? `postgres ${group}: public access CLOSED — not reachable from the internet; external clients, CI and local development can no longer connect. Compute services on InstaCloud connect through DATABASE_PRIVATE_URL`
|
|
497
|
+
: `postgres ${group}: public access open — reachable on the public endpoint (DATABASE_URL)${notice ? '' : ' (it already was; nothing changed)'}`;
|
|
498
|
+
return notice ? [head, ` ${notice}`] : [head];
|
|
499
|
+
}
|
|
500
|
+
// `insta postgres private-access [on|off] [service]` — show or toggle the private network lane.
|
|
501
|
+
// Writes PATCH /database/settings {privateAccess} (gated: service.setAccess). On mints
|
|
502
|
+
// DATABASE_PRIVATE_URL beside DATABASE_URL (unchanged); off retracts it (refused while public
|
|
503
|
+
// access is closed). No confirmation: neither direction takes a working connection away without
|
|
504
|
+
// the platform refusing or warning (off answers `warnings` naming computes still bound to it).
|
|
505
|
+
export async function dbPrivateAccess(first, second, opts) {
|
|
506
|
+
const { mode, service } = alwaysOnArgs(first, second);
|
|
507
|
+
const api = await ApiClient.load();
|
|
508
|
+
const p = await requireProject();
|
|
509
|
+
const branch = opts.branch ?? p.branch;
|
|
510
|
+
const svc = await resolvePostgres(api, p.projectId, branch, service);
|
|
511
|
+
if (!mode)
|
|
512
|
+
return showAccess(api, p.projectId, branch, svc.name, opts);
|
|
513
|
+
const ctx = { service: svc.name, branch: opts.branch, what: `turning private access ${mode}` };
|
|
514
|
+
let res;
|
|
515
|
+
try {
|
|
516
|
+
res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${settingsSuffix(branch, svc.name)}`, { privateAccess: mode === 'on' });
|
|
517
|
+
}
|
|
518
|
+
catch (e) {
|
|
519
|
+
if (e instanceof ApiError)
|
|
520
|
+
throw new Error(accessErrorMessage(e, ctx));
|
|
521
|
+
throw e;
|
|
522
|
+
}
|
|
523
|
+
if (handleApproval(res, opts.json))
|
|
524
|
+
return;
|
|
525
|
+
if (opts.json)
|
|
526
|
+
return printJson(res.body);
|
|
527
|
+
for (const l of privateAccessSetLines(svc.name, mode, res.body))
|
|
528
|
+
info(l);
|
|
529
|
+
}
|
|
530
|
+
export function privateAccessSetLines(group, mode, body) {
|
|
531
|
+
const lines = mode === 'on'
|
|
532
|
+
? [
|
|
533
|
+
`postgres ${group}: private access ON — DATABASE_PRIVATE_URL minted beside DATABASE_URL (unchanged)`,
|
|
534
|
+
' it resolves only inside InstaCloud compute (insta-compute), not from a laptop or CI — keep DATABASE_URL for those',
|
|
535
|
+
` switch a compute over: insta secrets bind DATABASE_URL postgres/${group} --source-name DATABASE_PRIVATE_URL --to compute/<name>, then \`insta compute restart <name>\``,
|
|
536
|
+
' other workloads on the compute plane can reach the private endpoint too — the password is what authenticates',
|
|
537
|
+
]
|
|
538
|
+
: [`postgres ${group}: private access off — DATABASE_PRIVATE_URL retracted; DATABASE_URL is unchanged`];
|
|
539
|
+
for (const w of Array.isArray(body?.warnings) ? body.warnings : [])
|
|
540
|
+
if (typeof w === 'string' && w)
|
|
541
|
+
lines.push(` ! ${w}`);
|
|
542
|
+
return lines;
|
|
543
|
+
}
|
|
544
|
+
async function showAccess(api, projectId, branch, group, opts) {
|
|
545
|
+
const read = await fetchDbInstance(api, projectId, settingsSuffix(branch, group));
|
|
546
|
+
if (read.kind === 'no-instance') {
|
|
547
|
+
info(`postgres ${group}: no manageable instance (network access is not configurable for this service)`);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (opts.json)
|
|
551
|
+
return printJson(read.body);
|
|
552
|
+
for (const l of dbAccessLines(group, read.body))
|
|
553
|
+
info(l);
|
|
554
|
+
}
|
|
377
555
|
//# sourceMappingURL=postgres.js.map
|
|
@@ -47,14 +47,13 @@ export function parseCount(raw) {
|
|
|
47
47
|
}
|
|
48
48
|
return n;
|
|
49
49
|
}
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
export function parsePort(raw) {
|
|
50
|
+
// Keep TCP-only callers strict; port 0 is reserved for explicitly opted-in compute workers.
|
|
51
|
+
export function parsePort(raw, opts = {}) {
|
|
52
|
+
const min = opts.allowZero ? 0 : 1;
|
|
54
53
|
const m = /^\s*(\d+)\s*$/.exec(raw);
|
|
55
54
|
const n = m ? Number(m[1]) : NaN;
|
|
56
|
-
if (!Number.isInteger(n) || n <
|
|
57
|
-
throw new Error(`port must be an integer between
|
|
55
|
+
if (!Number.isInteger(n) || n < min || n > 65535)
|
|
56
|
+
throw new Error(`port must be an integer between ${min} and 65535, got: ${raw}`);
|
|
58
57
|
return n;
|
|
59
58
|
}
|
|
60
59
|
// Parse a volume size in whole Gi: "10" or "10Gi" (suffix case-insensitive — unlike the db
|
|
@@ -70,6 +69,13 @@ export function parseVolumeGib(raw) {
|
|
|
70
69
|
throw new Error(`invalid volume size: ${raw} (whole Gi — try 1 or 10)`);
|
|
71
70
|
return n;
|
|
72
71
|
}
|
|
72
|
+
// Decimal digits only, as parsePort; which majors exist is the platform's answer (a 400 naming them).
|
|
73
|
+
export function parsePgVersion(raw) {
|
|
74
|
+
const m = /^\s*(\d+)\s*$/.exec(raw);
|
|
75
|
+
if (!m)
|
|
76
|
+
throw new Error(`invalid Postgres major: ${raw} (a whole major version, e.g. 17)`);
|
|
77
|
+
return Number(m[1]);
|
|
78
|
+
}
|
|
73
79
|
// Resolve a service id from a `services list` result by (type, name).
|
|
74
80
|
export function resolveServiceId(services, type, name) {
|
|
75
81
|
const svc = services.find((s) => s.type === type && s.name === name);
|
|
@@ -105,13 +111,15 @@ function defaultDatabasePort(type) {
|
|
|
105
111
|
export function servicesAddRequestBody(type, name, branch, opts) {
|
|
106
112
|
return {
|
|
107
113
|
type, name, ...(branch ? { branch } : {}), public: !!opts.public,
|
|
108
|
-
...(opts.image ? { image: opts.image } : {}), ...(opts.port ? { port: parsePort(opts.port) } : {}),
|
|
114
|
+
...(opts.image ? { image: opts.image } : {}), ...(opts.port !== undefined ? { port: parsePort(opts.port, { allowZero: type === 'compute' }) } : {}),
|
|
109
115
|
...(opts.region ? { region: opts.region } : {}),
|
|
110
|
-
// Sent whenever the flag was given, false included:
|
|
111
|
-
//
|
|
116
|
+
// Sent whenever the flag was given, false included: the birth default is platform config
|
|
117
|
+
// (INSTA_COMPUTE_ALWAYS_ON_DEFAULT, scale-to-zero unless set), so an explicit --no-always-on
|
|
118
|
+
// must reach the API as false to hold whatever that config says. Omitted means the platform default.
|
|
112
119
|
...(opts.alwaysOn !== undefined ? { alwaysOn: opts.alwaysOn } : {}),
|
|
113
120
|
...(opts.volume !== undefined ? { volumeGib: parseVolumeGib(opts.volume) } : {}),
|
|
114
121
|
...(opts.mountPath !== undefined ? { volumeMountPath: opts.mountPath } : {}),
|
|
122
|
+
...(opts.pgVersion !== undefined ? { pgVersion: parsePgVersion(opts.pgVersion) } : {}),
|
|
115
123
|
};
|
|
116
124
|
}
|
|
117
125
|
export async function servicesAdd(type, name, opts = {}) {
|
|
@@ -122,10 +130,10 @@ export async function servicesAdd(type, name, opts = {}) {
|
|
|
122
130
|
throw new Error('--region is not valid for storage services');
|
|
123
131
|
if (opts.image && type !== 'compute')
|
|
124
132
|
throw new Error('--image is only valid for compute services');
|
|
125
|
-
if (opts.port) {
|
|
133
|
+
if (opts.port !== undefined) {
|
|
126
134
|
if (type !== 'compute')
|
|
127
135
|
throw new Error('--port is only valid for compute services');
|
|
128
|
-
parsePort(opts.port
|
|
136
|
+
parsePort(opts.port, { allowZero: true });
|
|
129
137
|
}
|
|
130
138
|
// Presence, not truthiness: `--no-always-on` is an explicit false and is just as compute-only.
|
|
131
139
|
if (opts.alwaysOn !== undefined && type !== 'compute')
|
|
@@ -137,6 +145,11 @@ export async function servicesAdd(type, name, opts = {}) {
|
|
|
137
145
|
throw new Error(volumeTypeError(type));
|
|
138
146
|
parseVolumeGib(opts.volume); // junk fails here, before any config/network access
|
|
139
147
|
}
|
|
148
|
+
if (opts.pgVersion !== undefined) {
|
|
149
|
+
if (type !== 'postgres')
|
|
150
|
+
throw new Error('--pg-version is only valid for postgres services');
|
|
151
|
+
parsePgVersion(opts.pgVersion);
|
|
152
|
+
}
|
|
140
153
|
const api = await ApiClient.load();
|
|
141
154
|
const p = await requireProject();
|
|
142
155
|
const branch = opts.branch ?? p.branch;
|
package/dist/deploy-archive.js
CHANGED
|
@@ -121,7 +121,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
121
121
|
if (state === 'live') {
|
|
122
122
|
const image = res.body?.imageRef;
|
|
123
123
|
const url = res.body?.url;
|
|
124
|
-
if (typeof image !== 'string' || !image || typeof url !== 'string' || !url) {
|
|
124
|
+
if (typeof image !== 'string' || !image || typeof url !== 'string' || (!url && opts.port !== '0')) {
|
|
125
125
|
throw new Error('the deploy finished but the platform returned no image or URL for it — check `insta status`');
|
|
126
126
|
}
|
|
127
127
|
const optionalString = (field, v) => {
|
package/dist/index.js
CHANGED
|
@@ -153,11 +153,12 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
|
|
|
153
153
|
.option('--region <region>', 'region for postgres/compute/managed databases, e.g. us-east (see `insta config regions`)')
|
|
154
154
|
.option('--public', 'storage only: serve the bucket with anonymous public-read (default private)')
|
|
155
155
|
.option('--image <url>', 'compute only: run this container image at creation')
|
|
156
|
-
.option('--port <n>', 'compute only: port the image listens on (default 8080)')
|
|
157
|
-
.option('--always-on', 'compute only: create as always-on — never scales to zero (
|
|
158
|
-
.option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
|
|
156
|
+
.option('--port <n>', 'compute only: port the image listens on (default 8080; 0 for a worker without a routed port)')
|
|
157
|
+
.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')
|
|
158
|
+
.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)')
|
|
159
159
|
.option('--mount-path <path>', 'compute only: container mount path for a new volume (requires --volume; default /data)')
|
|
160
160
|
.option('--volume <gi>', 'compute only: attach a persistent volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`). Any plan may attach up to its own plan cap (10Gi free, 50Gi paid by default; the bare `insta compute volume <name>` read prints it as plan max); a size above the free cap is paid. Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
|
|
161
|
+
.option('--pg-version <major>', 'postgres only: the Postgres major version to create the database on — 16, 17 or 18 (default 16). Fixed for the database\'s lifetime; branches keep it')
|
|
161
162
|
.option('--json')
|
|
162
163
|
.action(guard(async (type, name, o) => {
|
|
163
164
|
const a = await resolveServiceArgs(type, name, serviceArgsDeps(o.json), o);
|
|
@@ -172,10 +173,11 @@ svc.command('rename <type> <name> <new-name>').description('Rename a service and
|
|
|
172
173
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
173
174
|
.action(guard((type, name, newName, o) => services.servicesRename(type, name, newName, o)));
|
|
174
175
|
// ---- secrets (seam) ----
|
|
175
|
-
const sec = program.command('secrets').description('
|
|
176
|
+
const sec = program.command('secrets').description('Write a local .env bundle: user secrets plus primary provider credentials; compute credentials require explicit bindings')
|
|
176
177
|
.option('--branch <branch>')
|
|
177
178
|
.option('--service <type/name>', "read one compute service's own slice of the bundle instead of the branch-wide merge, e.g. compute/api")
|
|
178
179
|
.option('-o, --output <file>', 'output file (default .env)').option('--print', 'print instead of writing').option('--json')
|
|
180
|
+
.addHelpText('after', '\nFetching this bundle does not bind credentials to compute. A service created with `insta service add compute` starts with no provider credential bindings; bind each source it needs, then deploy.\n\nExample: insta secrets bind DATABASE_URL postgres/db --to compute/app')
|
|
179
181
|
.action(guard((o) => secretsCmd.secrets(o)));
|
|
180
182
|
// commander 12 defaults allowExcessArguments to true, so a mistyped/retired subcommand (e.g.
|
|
181
183
|
// `secrets lst`) would otherwise run this group's own action instead of failing.
|
|
@@ -188,11 +190,12 @@ sec.command('unset <name>').description('Remove a user secret')
|
|
|
188
190
|
.option('--branch <branch>', 'scope to one branch')
|
|
189
191
|
.option('--service <type/name>', "remove only that service's copy, e.g. compute/api")
|
|
190
192
|
.option('--json').action(guard((n, o) => secretsCmd.secretsUnset(n, o)));
|
|
191
|
-
sec.command('bind <env-name> <source>').description('Bind
|
|
193
|
+
sec.command('bind <env-name> <source>').description('Bind one provider credential to a compute service (applied on next deploy or restart)')
|
|
192
194
|
.option('--branch <branch>', 'branch (default: current)')
|
|
193
195
|
.option('--to <compute-service>', 'target compute service, e.g. compute/api')
|
|
194
196
|
.option('--source-name <name>', 'source credential name when the source exposes more than one')
|
|
195
197
|
.option('--json')
|
|
198
|
+
.addHelpText('after', '\nThe local .env bundle includes primary provider credentials without bindings. Compute services require their own explicit bindings: `insta service add compute` creates a service with no provider credential bindings inherited from other services.\n\nExample: insta secrets bind DATABASE_URL postgres/db --to compute/app')
|
|
196
199
|
.action(guard((n, source, o) => secretsCmd.secretsBind(n, source, o)));
|
|
197
200
|
sec.command('unbind <env-name>').description('Remove a service credential binding from a compute env var')
|
|
198
201
|
.option('--branch <branch>', 'branch (default: current)')
|
|
@@ -376,7 +379,7 @@ compute.command('scale [count] [service]').description('Set a compute service sa
|
|
|
376
379
|
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")
|
|
377
380
|
.option('--memory <size>', 'memory ceiling, e.g. 512mb or 1gb').option('--cpu <n>', 'vCPU ceiling override (provider sizes: 1, 2, 4, 6, 8)')
|
|
378
381
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
|
|
379
|
-
compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero (the default for new compute services
|
|
382
|
+
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')
|
|
380
383
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
|
|
381
384
|
compute.command('ssh [service]')
|
|
382
385
|
.description("Issue a short-lived SSH certificate for a compute service and print the command that uses it -- this command does NOT open the session itself, it makes `ssh` work. `--setup` does the one-time work: it generates a dedicated key under ~/.insta/ssh (your existing keys are never touched), has the platform sign a SHORT-LIVED certificate for it, adds one @cert-authority line to ~/.ssh/known_hosts so every region is trusted without per-node fingerprint prompts, and writes an ssh_config block AT THE TOP of ~/.ssh/config giving each compute service the alias `<service>.insta`. After that it is plain `ssh api.insta`, scp and -L: the block renews that alias's certificate for you while OpenSSH parses the config. Needs an interactive login -- API keys are refused; use `insta compute exec` for one-shot commands from CI")
|
|
@@ -420,8 +423,8 @@ compute.command('volume [service]').description("Show, attach, grow, remount, or
|
|
|
420
423
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
|
|
421
424
|
addObservability(compute, 'compute', 'compute');
|
|
422
425
|
// ---- postgres ----
|
|
423
|
-
const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, volume, always-on, logs, metrics');
|
|
424
|
-
pg.command('url [service]').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta postgres url)"` (gated: secrets.read).
|
|
426
|
+
const pg = program.command('postgres').description('Postgres services: connection string, psql, stats, resource ceiling, volume, always-on, network access, logs, metrics');
|
|
427
|
+
pg.command('url [service]').description('Print the postgres connection string (DSN) — bare on stdout for piping, e.g. `psql "$(insta postgres url)"` (gated: secrets.read). `insta secrets` includes the primary postgres service\'s DATABASE_URL; use this command to select a specific postgres service')
|
|
425
428
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
426
429
|
.action(guard((service, o) => pgCmd.dbUrl(service, o)));
|
|
427
430
|
pg.command('connect [service]').description("Open an interactive psql session on the postgres service (needs psql on PATH; gated: secrets.read). A suspended instance wakes on connect — the first prompt can take a few seconds. Exits with psql's own exit code")
|
|
@@ -441,8 +444,15 @@ pg.command('volume [service]').description("Show or grow a postgres service's pr
|
|
|
441
444
|
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 = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
|
|
442
445
|
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
443
446
|
.action(guard((mode, service, o) => pgCmd.dbAlwaysOn(mode, service, o)));
|
|
447
|
+
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)')
|
|
448
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
449
|
+
.action(guard((mode, service, o) => pgCmd.dbPrivateAccess(mode, service, o)));
|
|
450
|
+
pg.command('public-access [mode] [service]').description("Show, close or re-open the public endpoint (mode: on|off; no mode = print public and private access). off = the database is not reachable from the internet: external clients, CI and local development can no longer connect, only InstaCloud compute via DATABASE_PRIVATE_URL. Needs private access on first. Prints what will break and asks first; takes effect within about 30 seconds. insta-db-backed only (gated: service.setAccess)")
|
|
451
|
+
.option('-y, --yes', 'close without asking (required when there is no terminal, or with --json) — read the printed impact first')
|
|
452
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
453
|
+
.action(guard((mode, service, o) => pgCmd.dbPublicAccess(mode, service, o)));
|
|
444
454
|
addObservability(pg, 'db', 'postgres');
|
|
445
|
-
// ---- redis / mysql / mongodb (managed
|
|
455
|
+
// ---- redis / mysql / mongodb (managed databases) ----
|
|
446
456
|
for (const type of ['redis', 'mysql', 'mongodb']) {
|
|
447
457
|
const g = program.command(type).description(`Managed ${type} services: query, status, resource ceiling, volume, always-on, logs, metrics`);
|
|
448
458
|
const query = g.command('query <service> [args...]').description(type === 'redis'
|
|
@@ -505,7 +515,7 @@ buildCmd.command('logs <build-id>').description('Read source-build output for a
|
|
|
505
515
|
.action(guard((id, opts) => buildLogs(id, opts)));
|
|
506
516
|
// ---- deploy ----
|
|
507
517
|
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')
|
|
508
|
-
.option('--image <url>', 'prebuilt container image to deploy (instead of a source dir)').option('--branch <b>').option('--group <g>').option('--port <p>')
|
|
518
|
+
.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)')
|
|
509
519
|
.option('--websocket', 'run a WebSocket app (larger guest + connection-based concurrency)')
|
|
510
520
|
.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')
|
|
511
521
|
.option('--json', 'print the deploy result as JSON (build progress goes to stderr)')
|
package/dist/pack.js
CHANGED
|
@@ -109,31 +109,8 @@ function walk(root, rel, out, links, ig, files, flavour, cap = Infinity) {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
-
//
|
|
113
|
-
|
|
114
|
-
// into an archive that promises none. Two guards, and neither is a full one on its own:
|
|
115
|
-
//
|
|
116
|
-
// O_NOFOLLOW refuses when the final component is a symlink AT OPEN TIME, closing the swap the
|
|
117
|
-
// walk cannot see. Undefined on Windows, where it degrades to the check below.
|
|
118
|
-
//
|
|
119
|
-
// fstat on the OPEN HANDLE must still describe the file the walk measured: same inode, same
|
|
120
|
-
// device, same size. Its job is the tar's own consistency -- a file rewritten to a different
|
|
121
|
-
// length mid-pack would otherwise produce a header whose count disagrees with its payload.
|
|
122
|
-
//
|
|
123
|
-
// Two things neither closes, and both are stated rather than implied away:
|
|
124
|
-
//
|
|
125
|
-
// An ANCESTOR directory swapped for a symlink. Node exposes no openat, so resolving each
|
|
126
|
-
// component against a directory handle is not available here.
|
|
127
|
-
//
|
|
128
|
-
// A same-size plain file deleted and recreated. Measured on linux rather than assumed: the
|
|
129
|
-
// inode is REUSED and mtimeNs/ctimeNs are byte-identical for a delete+create inside one
|
|
130
|
-
// timestamp tick, so no stat-based identity can see it. It is also the least interesting case
|
|
131
|
-
// -- the symlink promise still holds, the tar stays well formed because the length did not
|
|
132
|
-
// move, and the archive simply carries a slightly newer copy of a file the caller owns.
|
|
133
|
-
//
|
|
134
|
-
// The residual on both is narrow: someone able to rewrite files and directories inside the tree
|
|
135
|
-
// being packed can already put any bytes they like into it by writing them.
|
|
136
|
-
export function readEntry(abs, e) {
|
|
112
|
+
// O_NOFOLLOW closes final-component symlink swaps only on platforms that expose it.
|
|
113
|
+
export function readEntry(abs, e, platform = process.platform) {
|
|
137
114
|
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
138
115
|
let fd;
|
|
139
116
|
try {
|
|
@@ -147,8 +124,10 @@ export function readEntry(abs, e) {
|
|
|
147
124
|
}
|
|
148
125
|
try {
|
|
149
126
|
const st = fstatSync(fd);
|
|
127
|
+
// Some Windows libuv versions omit lstat's device ID; inode and size must still match.
|
|
128
|
+
const missingDevice = platform === 'win32' && (e.dev === 0 || e.dev === 0n);
|
|
150
129
|
const same = st.isFile() && st.size === e.size
|
|
151
|
-
&& (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || st.dev === e.dev);
|
|
130
|
+
&& (e.ino === undefined || st.ino === e.ino) && (e.dev === undefined || missingDevice || st.dev === e.dev);
|
|
152
131
|
if (!same)
|
|
153
132
|
throw new Error(`${e.path} changed while packing — re-run the deploy`);
|
|
154
133
|
return readFileSync(fd);
|
package/dist/resolve-service.js
CHANGED
|
@@ -70,7 +70,7 @@ export async function resolveServiceArgs(type, name, deps, given = {}) {
|
|
|
70
70
|
throw new Error(missingArgsMessage(type));
|
|
71
71
|
// A bad --port is a typo in the command, not an answer: fail before asking anything.
|
|
72
72
|
if (given.port !== undefined)
|
|
73
|
-
parsePort(given.port);
|
|
73
|
+
parsePort(given.port, { allowZero: !type || type === 'compute' });
|
|
74
74
|
const kind = type
|
|
75
75
|
? SERVICE_KINDS.find((k) => k.type === type && !k.needsImage)
|
|
76
76
|
: await deps.selectKind(SERVICE_KINDS);
|
|
@@ -133,12 +133,12 @@ export async function promptServiceName(kind, suggested) {
|
|
|
133
133
|
}
|
|
134
134
|
export async function promptPort(fallback) {
|
|
135
135
|
const answer = await clack.text({
|
|
136
|
-
message: 'Port the image listens on:',
|
|
136
|
+
message: 'Port the image listens on (0 for a worker):',
|
|
137
137
|
initialValue: fallback,
|
|
138
138
|
// The rule the command enforces, so the prompt and a --port can never disagree.
|
|
139
139
|
validate: (v) => {
|
|
140
140
|
try {
|
|
141
|
-
parsePort(v.trim());
|
|
141
|
+
parsePort(v.trim(), { allowZero: true });
|
|
142
142
|
return undefined;
|
|
143
143
|
}
|
|
144
144
|
catch (e) {
|
package/dist/telemetry.js
CHANGED
|
@@ -36,6 +36,7 @@ const SAFE_ARGS = {
|
|
|
36
36
|
'storage set-access': { 0: oneOf(['public', 'private']) },
|
|
37
37
|
'compute scale': { 0: (v) => /^\d+$/.test(v) },
|
|
38
38
|
'compute always-on': { 0: ON_OFF }, 'postgres always-on': { 0: ON_OFF },
|
|
39
|
+
'postgres private-access': { 0: ON_OFF }, 'postgres public-access': { 0: ON_OFF },
|
|
39
40
|
'redis always-on': { 0: ON_OFF }, 'mysql always-on': { 0: ON_OFF }, 'mongodb always-on': { 0: ON_OFF },
|
|
40
41
|
'template info': { 0: SLUG }, 'billing subscribe': { 0: oneOf(['pro', 'team']) },
|
|
41
42
|
'agent approvals approve': { 0: ID }, 'agent approvals deny': { 0: ID },
|