insta 0.1.11 → 0.1.13
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/billing.js +58 -1
- package/dist/commands/build.js +3 -3
- package/dist/commands/postgres.js +214 -12
- package/dist/commands/services.js +13 -0
- package/dist/index.js +20 -7
- 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/billing.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApiClient, requireProject } from '../api.js';
|
|
1
|
+
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
2
2
|
import { die, info, openUrl, printJson } from '../util.js';
|
|
3
3
|
import { cycleLine, dimensionLines } from './metrics.js';
|
|
4
4
|
// Resolve the target org: explicit --org, else the linked project's org.
|
|
@@ -100,6 +100,63 @@ export async function billingUpgrade(tier, opts) {
|
|
|
100
100
|
return printJson({ url });
|
|
101
101
|
presentUrl(url, `Subscribe to ${tier} — complete checkout in your browser:`, opts.open);
|
|
102
102
|
}
|
|
103
|
+
// The platform's contract (RedeemError#reason in billing/service.ts) — four literals, never
|
|
104
|
+
// collapsed into one message, because the right next action differs per reason: `not_found` is
|
|
105
|
+
// the only one worth retyping; `expired`/`revoked` both mean waiting never helps, but only
|
|
106
|
+
// `revoked` has someone to go back to.
|
|
107
|
+
//
|
|
108
|
+
// `already_redeemed` deliberately does NOT say WHO redeemed it, because this CLI cannot know.
|
|
109
|
+
// A sequential retry replays the receipt and never reaches here — but two requests from the same
|
|
110
|
+
// org in flight at once both miss the replay read (it sees committed rows only), one wins the
|
|
111
|
+
// claim and the other lands exactly here. That is the case a client resending a request it never
|
|
112
|
+
// saw an answer to is IN. Telling that person "a different org took it" would be false about
|
|
113
|
+
// their own money: the grant is on their wallet. So the message points them at their balance
|
|
114
|
+
// instead of at a conclusion this side cannot support.
|
|
115
|
+
const REDEEM_ERROR_MESSAGE = {
|
|
116
|
+
not_found: 'that code was not recognized — check it for typos and try again',
|
|
117
|
+
expired: 'that code has expired — ask whoever sent it for a new one',
|
|
118
|
+
already_redeemed: 'that code has already been redeemed — if it was your org, the credits are already there (check `insta billing`)',
|
|
119
|
+
revoked: 'that code was revoked — contact whoever sent it to you',
|
|
120
|
+
};
|
|
121
|
+
// Pure — the human line for a redeem failure. `reason` is the platform's literal (RedeemError's
|
|
122
|
+
// four values); anything else (a platform version ahead of this CLI) falls back to relaying it
|
|
123
|
+
// as-is rather than a message that would be wrong about which of the four cases this is.
|
|
124
|
+
export function redeemErrorMessage(reason, fallback) {
|
|
125
|
+
return (reason && REDEEM_ERROR_MESSAGE[reason]) ?? fallback;
|
|
126
|
+
}
|
|
127
|
+
// Pure — the three facts the spec names, in the order it names them: what landed, when it stops
|
|
128
|
+
// being spendable, and which org — by NAME — so someone who belongs to several orgs isn't left
|
|
129
|
+
// reading a uuid to find out which one just got the money.
|
|
130
|
+
export function redeemLines(r) {
|
|
131
|
+
return [
|
|
132
|
+
`credited: $${(r.amountMicros / 1_000_000).toFixed(2)}`,
|
|
133
|
+
`expires: ${r.creditsExpireAt ? r.creditsExpireAt.slice(0, 10) : 'never'}`,
|
|
134
|
+
`org: ${r.orgName}`,
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
// insta billing redeem <code> — apply a one-time credit code to the org's wallet.
|
|
138
|
+
export async function billingRedeem(code, opts) {
|
|
139
|
+
const api = await ApiClient.load();
|
|
140
|
+
const orgId = await resolveOrgId(opts);
|
|
141
|
+
let res;
|
|
142
|
+
try {
|
|
143
|
+
res = await api.request('POST', `/orgs/${orgId}/billing/redeem`, { code });
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
if (!(e instanceof ApiError) || e.status !== 400)
|
|
147
|
+
throw e;
|
|
148
|
+
// Under --json, stdout stays the one parseable document a script branches on (the platform's
|
|
149
|
+
// own `error` literal); the human line below is the diagnostic and belongs on stderr.
|
|
150
|
+
const reason = e.body?.error;
|
|
151
|
+
if (opts.json)
|
|
152
|
+
printJson({ error: reason ?? e.message });
|
|
153
|
+
die(redeemErrorMessage(reason, e.message));
|
|
154
|
+
}
|
|
155
|
+
if (opts.json)
|
|
156
|
+
return printJson(res);
|
|
157
|
+
for (const l of redeemLines(res))
|
|
158
|
+
info(l);
|
|
159
|
+
}
|
|
103
160
|
// insta billing portal — open the Stripe Customer Portal (change plan / card / cancel).
|
|
104
161
|
export async function billingPortal(opts) {
|
|
105
162
|
const api = await ApiClient.load();
|
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}`);
|
|
@@ -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')
|
|
@@ -42,10 +43,34 @@ export async function dbAlwaysOn(first, second, opts) {
|
|
|
42
43
|
const res = await api.rawRequest('PATCH', `/projects/${p.projectId}/database/settings${suffix}`, { scaleToZero: mode !== 'on' });
|
|
43
44
|
if (handleApproval(res, opts.json))
|
|
44
45
|
return;
|
|
46
|
+
const body = await settleScaleToZero(res.body, mode === 'off', async () => {
|
|
47
|
+
try {
|
|
48
|
+
return (await api.rawRequest('GET', `/projects/${p.projectId}/database/instance${suffix}`, undefined, { signal: AbortSignal.timeout(5000) })).body;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
45
54
|
if (opts.json)
|
|
46
|
-
return printJson(
|
|
47
|
-
|
|
48
|
-
|
|
55
|
+
return printJson(body);
|
|
56
|
+
info(alwaysOnSetLine(service, opts.branch, mode, body?.scaleToZero));
|
|
57
|
+
}
|
|
58
|
+
// insta-db applies the change asynchronously and answers with the value from before it, so re-read until they agree.
|
|
59
|
+
export async function settleScaleToZero(body, want, read, wait = (ms) => new Promise((r) => setTimeout(r, ms))) {
|
|
60
|
+
for (let i = 0; i < 15 && typeof body?.scaleToZero === 'boolean' && body.scaleToZero !== want; i++) {
|
|
61
|
+
await wait(2000);
|
|
62
|
+
const next = await read();
|
|
63
|
+
if (next === undefined)
|
|
64
|
+
break;
|
|
65
|
+
body = next;
|
|
66
|
+
}
|
|
67
|
+
return body;
|
|
68
|
+
}
|
|
69
|
+
export function alwaysOnSetLine(service, branch, mode, scaleToZero) {
|
|
70
|
+
const group = service ?? 'default';
|
|
71
|
+
if (scaleToZero !== (mode === 'off'))
|
|
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 (default; first connection after idle cold-starts)'}`;
|
|
49
74
|
}
|
|
50
75
|
// Validated pass-throughs for the provider's quantity strings. The insta-db resize API takes
|
|
51
76
|
// k8s-style quantities (cpu: "2", "2500m"; memory: "4Gi", "2048Mi"), so unlike the compute path
|
|
@@ -257,14 +282,6 @@ export async function dbVolume(service, opts) {
|
|
|
257
282
|
const vg = res.body?.volumeGib;
|
|
258
283
|
info(`postgres ${service ?? 'default'}: volume ${typeof vg === 'number' ? `grown to ${vg}Gi` : `set to ${sizeGib}Gi`}`);
|
|
259
284
|
}
|
|
260
|
-
// Resolve the postgres service (sole, or the named one) and its connection string. Two reads: the
|
|
261
|
-
// branch's services list names the service; GET /services/:id/credentials (gated secrets.read)
|
|
262
|
-
// carries the value. Provider-minted credentials are canonical within their source service
|
|
263
|
-
// (DATABASE_URL) and deliberately absent from the general `insta secrets` bundle, so this is the
|
|
264
|
-
// read that yields the DSN. The credentials call carries no branch param — the service id is
|
|
265
|
-
// already branch-scoped by the list. Returns null when the read parked on an approval
|
|
266
|
-
// (handleApproval already spoke). Takes the client as an argument so tests drive it with a stub,
|
|
267
|
-
// per this repo's pure-seam convention.
|
|
268
285
|
export async function resolveDbUrl(api, projectId, branch, service, json) {
|
|
269
286
|
const { services } = await api.request('GET', `/projects/${projectId}/services${q(branch)}`);
|
|
270
287
|
const svc = resolveSoleService(services, 'postgres', service);
|
|
@@ -350,4 +367,189 @@ export async function dbConnect(service, opts) {
|
|
|
350
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`);
|
|
351
368
|
relayExitCode(await connectWithPsql(r.url));
|
|
352
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
|
+
}
|
|
353
555
|
//# sourceMappingURL=postgres.js.map
|
|
@@ -70,6 +70,13 @@ export function parseVolumeGib(raw) {
|
|
|
70
70
|
throw new Error(`invalid volume size: ${raw} (whole Gi — try 1 or 10)`);
|
|
71
71
|
return n;
|
|
72
72
|
}
|
|
73
|
+
// Decimal digits only, as parsePort; which majors exist is the platform's answer (a 400 naming them).
|
|
74
|
+
export function parsePgVersion(raw) {
|
|
75
|
+
const m = /^\s*(\d+)\s*$/.exec(raw);
|
|
76
|
+
if (!m)
|
|
77
|
+
throw new Error(`invalid Postgres major: ${raw} (a whole major version, e.g. 17)`);
|
|
78
|
+
return Number(m[1]);
|
|
79
|
+
}
|
|
73
80
|
// Resolve a service id from a `services list` result by (type, name).
|
|
74
81
|
export function resolveServiceId(services, type, name) {
|
|
75
82
|
const svc = services.find((s) => s.type === type && s.name === name);
|
|
@@ -112,6 +119,7 @@ export function servicesAddRequestBody(type, name, branch, opts) {
|
|
|
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 = {}) {
|
|
@@ -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/index.js
CHANGED
|
@@ -36,7 +36,7 @@ import * as template from './commands/template.js';
|
|
|
36
36
|
import * as govern from './commands/govern.js';
|
|
37
37
|
import * as observe from './commands/observe.js';
|
|
38
38
|
import * as obs from './commands/metrics.js';
|
|
39
|
-
import { billing, billingUpgrade, billingPortal } from './commands/billing.js';
|
|
39
|
+
import { billing, billingUpgrade, billingPortal, billingRedeem } from './commands/billing.js';
|
|
40
40
|
import * as domainCmd from './commands/domain.js';
|
|
41
41
|
import * as selfUpdate from './commands/upgrade.js';
|
|
42
42
|
import * as feedbackCmd from './commands/feedback.js';
|
|
@@ -158,6 +158,7 @@ svc.command('add [type] [name]').description('Provision a service on demand (com
|
|
|
158
158
|
.option('--no-always-on', 'compute only: create as scale-to-zero — idle machines suspend and wake on the next request')
|
|
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)')
|
|
@@ -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'
|
|
@@ -530,7 +540,7 @@ tpl.command('deploy <code-or-dir-or-url>').description('Deploy a template onto a
|
|
|
530
540
|
.option('--json')
|
|
531
541
|
.action(guard((target, o) => template.templateDeploy(target, o)));
|
|
532
542
|
// ---- billing ----
|
|
533
|
-
const bill = program.command('billing').description('Billing: current cycle overview (bare), subscribe to a tier, Stripe portal, usage by dimension')
|
|
543
|
+
const bill = program.command('billing').description('Billing: current cycle overview (bare), subscribe to a tier, redeem a credit code, Stripe portal, usage by dimension')
|
|
534
544
|
.option('--org <id>', 'target org (default: linked project\'s org)').option('--json')
|
|
535
545
|
.action(guard((o) => billing(o)));
|
|
536
546
|
// commander 12 defaults allowExcessArguments to true, so a mistyped/retired subcommand (e.g.
|
|
@@ -539,6 +549,9 @@ bill.allowExcessArguments(false);
|
|
|
539
549
|
bill.command('subscribe <tier>').description('Subscribe the org to a paid tier (pro|team) via Stripe Checkout')
|
|
540
550
|
.option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
|
|
541
551
|
.action(guard((tier, o) => billingUpgrade(tier, o)));
|
|
552
|
+
bill.command('redeem <code>').description("Redeem a one-time credit code into the org's wallet")
|
|
553
|
+
.option('--org <id>').option('--json')
|
|
554
|
+
.action(guard((code, o) => billingRedeem(code, o)));
|
|
542
555
|
bill.command('portal').description('Open the Stripe Customer Portal (change plan / card / cancel)')
|
|
543
556
|
.option('--org <id>').option('--no-open', 'print the URL instead of opening a browser').option('--json')
|
|
544
557
|
.action(guard((o) => billingPortal(o)));
|
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 },
|