insta 0.0.28 → 0.0.30
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 +16 -8
- package/dist/commands/db.js +59 -0
- package/dist/commands/metrics.js +54 -4
- package/dist/index.js +5 -2
- package/package.json +1 -1
package/dist/commands/compute.js
CHANGED
|
@@ -116,23 +116,32 @@ export function parseCpu(raw) {
|
|
|
116
116
|
throw new Error(`invalid cpu: ${raw} (provider sizes: ${CPU_SIZES.join(', ')})`);
|
|
117
117
|
return n;
|
|
118
118
|
}
|
|
119
|
-
// ---- volume (the persistent /data disk; attach
|
|
119
|
+
// ---- volume (the persistent /data disk; attach any time, grow-only, never detach) ----
|
|
120
120
|
// Render the volume read. Pure, exported for tests (mirrors serviceListLine). Every plan may view;
|
|
121
121
|
// only growth is paid — that gate is the backend's to enforce, so nothing here pre-blocks.
|
|
122
122
|
export function volumeLines(name, volume, cap) {
|
|
123
123
|
if (!volume)
|
|
124
124
|
return [
|
|
125
|
-
`compute ${name}: no volume attached (attach
|
|
125
|
+
`compute ${name}: no volume attached (attach one: \`insta compute volume ${name} --size <gi>\` — it mounts at /data on the next deploy)`,
|
|
126
126
|
];
|
|
127
127
|
return [
|
|
128
128
|
`compute ${name}: volume ${volume.sizeGib}Gi at ${volume.mountPath} (plan max ${cap.volumeGib}Gi)`,
|
|
129
129
|
' billing is actual data stored — the size is a cap, not a price; grow with --size (grow-only)',
|
|
130
130
|
];
|
|
131
131
|
}
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
|
|
132
|
+
// Render the PUT result. Pure, exported for tests. `attached` comes from the backend and is what
|
|
133
|
+
// tells a FIRST attach (no disk yet — it mounts on the next deploy) apart from a grow (the live
|
|
134
|
+
// disk was already extended); the wire size is authoritative in both cases.
|
|
135
|
+
export function volumeWriteLine(name, body) {
|
|
136
|
+
if (body.attached) {
|
|
137
|
+
return `compute ${name}: volume ${body.volume.sizeGib}Gi attached — mounts at ${body.volume.mountPath} on the next deploy (plan max ${body.cap.volumeGib}Gi)`;
|
|
138
|
+
}
|
|
139
|
+
return `compute ${name}: volume grown to ${body.volume.sizeGib}Gi at ${body.volume.mountPath} (plan max ${body.cap.volumeGib}Gi)`;
|
|
140
|
+
}
|
|
141
|
+
// Show, attach, or grow a compute service's /data volume. No --size: a safe read (size + mount
|
|
142
|
+
// path + the plan cap). --size: PUT .../volume — attaches when no volume exists, grows otherwise.
|
|
143
|
+
// The paid/cap/machine-count gates all belong to the backend, whose 403/400 messages carry the
|
|
144
|
+
// upgrade hints and must reach the user verbatim (the guard prints ApiError messages as-is).
|
|
136
145
|
export async function computeVolume(serviceName, opts) {
|
|
137
146
|
const api = await ApiClient.load();
|
|
138
147
|
const p = await requireProject();
|
|
@@ -153,8 +162,7 @@ export async function computeVolume(serviceName, opts) {
|
|
|
153
162
|
return;
|
|
154
163
|
if (opts.json)
|
|
155
164
|
return printJson(res.body);
|
|
156
|
-
|
|
157
|
-
info(`compute ${res.body.service?.name ?? serviceName ?? id}: volume grown to ${v.sizeGib}Gi at ${v.mountPath} (plan max ${res.body.cap.volumeGib}Gi)`);
|
|
165
|
+
info(volumeWriteLine(res.body.service?.name ?? serviceName ?? id, res.body));
|
|
158
166
|
}
|
|
159
167
|
// Show or set a compute service's ceiling. With no --memory it PRINTS the current limits and the
|
|
160
168
|
// plan cap (so `insta compute limits` is a safe read), which is also what a UI renders as a slider
|
package/dist/commands/db.js
CHANGED
|
@@ -120,6 +120,65 @@ export async function dbLimits(opts) {
|
|
|
120
120
|
// Render the instance's volume from a database/instance read. Pure, exported for tests. Reads the
|
|
121
121
|
// CANONICAL volume* names only — storageSize/storageGiB are deprecated aliases the platform drops
|
|
122
122
|
// next release, so depending on them here would be a scheduled breakage.
|
|
123
|
+
// Bytes → human units, one decimal above KiB. Local because the metrics payload is the only
|
|
124
|
+
// bytes-denominated read in this file (fmtMib serves the MiB-denominated resize path).
|
|
125
|
+
export function fmtBytes(n) {
|
|
126
|
+
if (n < 1024)
|
|
127
|
+
return `${n} B`;
|
|
128
|
+
const units = ['KiB', 'MiB', 'GiB', 'TiB'];
|
|
129
|
+
let v = n / 1024;
|
|
130
|
+
let i = 0;
|
|
131
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
132
|
+
v /= 1024;
|
|
133
|
+
i++;
|
|
134
|
+
}
|
|
135
|
+
return `${v.toFixed(1)} ${units[i]}`;
|
|
136
|
+
}
|
|
137
|
+
// Human-readable stats lines from GET /database/metrics. Pure seam for tests. "—" for anything
|
|
138
|
+
// unmeasured (old platform, suspended instance, no cache traffic yet) — never a fake 0: the
|
|
139
|
+
// platform omits cacheHitRatio and sends max 0 in exactly those cases.
|
|
140
|
+
export function dbStatsLines(group, body) {
|
|
141
|
+
const c = body?.connections ?? {};
|
|
142
|
+
const max = typeof c.max === 'number' && c.max > 0 ? c.max : null;
|
|
143
|
+
const total = typeof c.total === 'number' ? c.total : null;
|
|
144
|
+
const conn = total === null ? '—'
|
|
145
|
+
: (max === null ? String(total) : `${total} / ${max}`)
|
|
146
|
+
+ (typeof c.active === 'number' && max !== null ? ` (${c.active} active)` : '');
|
|
147
|
+
const ratio = body?.cacheHitRatio;
|
|
148
|
+
const cache = typeof ratio === 'number' ? `${(ratio * 100).toFixed(1)}%` : '—';
|
|
149
|
+
const size = typeof body?.dbSizeBytes === 'number' ? fmtBytes(body.dbSizeBytes) : '—';
|
|
150
|
+
const bits = [
|
|
151
|
+
typeof body?.state === 'string' ? body.state : null,
|
|
152
|
+
typeof body?.serverVersion === 'string' && body.serverVersion ? `PG ${body.serverVersion}` : null,
|
|
153
|
+
].filter(Boolean);
|
|
154
|
+
const state = bits.length ? ` (${bits.join(' · ')})` : '';
|
|
155
|
+
return [
|
|
156
|
+
`postgres ${group}${state}`,
|
|
157
|
+
` connections ${conn}`,
|
|
158
|
+
` cache hit ${cache}`,
|
|
159
|
+
` size ${size}`,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
// Point-in-time stats snapshot for a postgres service: connections vs the server's ceiling, cache
|
|
163
|
+
// hit rate, database size. Read-only. insta-db-backed: a suspended instance answers from the
|
|
164
|
+
// provider's control plane (shown as "(suspended)" with structural zeros), never dialed.
|
|
165
|
+
// Neon-backed: the platform reads over a direct SQL connection, so a one-shot call may wake a
|
|
166
|
+
// suspended endpoint — acceptable for an explicit command, which is why nothing here polls.
|
|
167
|
+
export async function dbStats(opts) {
|
|
168
|
+
const api = await ApiClient.load();
|
|
169
|
+
const p = await requireProject();
|
|
170
|
+
const qs = new URLSearchParams();
|
|
171
|
+
const branch = opts.branch ?? p.branch;
|
|
172
|
+
if (branch)
|
|
173
|
+
qs.set('branch', branch);
|
|
174
|
+
if (opts.group)
|
|
175
|
+
qs.set('group', opts.group);
|
|
176
|
+
const res = await api.rawRequest('GET', `/projects/${p.projectId}/database/metrics${qs.toString() ? `?${qs}` : ''}`);
|
|
177
|
+
if (opts.json)
|
|
178
|
+
return printJson(res.body);
|
|
179
|
+
for (const line of dbStatsLines(opts.group ?? 'default', res.body))
|
|
180
|
+
info(line);
|
|
181
|
+
}
|
|
123
182
|
export function dbVolumeLines(group, body) {
|
|
124
183
|
const gib = typeof body?.volumeGib === 'number' ? `${body.volumeGib}Gi` : (typeof body?.volumeSize === 'string' ? body.volumeSize : undefined);
|
|
125
184
|
if (gib === undefined)
|
package/dist/commands/metrics.js
CHANGED
|
@@ -8,6 +8,58 @@ function qs(params) {
|
|
|
8
8
|
const s = u.toString();
|
|
9
9
|
return s ? `?${s}` : '';
|
|
10
10
|
}
|
|
11
|
+
// One printed series line. Pure seam so the formatting is testable without a backend.
|
|
12
|
+
//
|
|
13
|
+
// Byte-rate series (compute's egress/ingress) arrive as raw bytes per second, which is unreadable at
|
|
14
|
+
// real traffic volumes — 20480031 bytes/s is 20 MB/s. Percent and vCPU units are already
|
|
15
|
+
// human-sized, so only bytes and byte rates get scaled.
|
|
16
|
+
export function metricLine(s) {
|
|
17
|
+
const last = s.points?.[s.points.length - 1];
|
|
18
|
+
const value = last ? formatMetricValue(last[1], s.unit) : 'n/a';
|
|
19
|
+
const unit = s.unit && !isScaled(s.unit) ? ` (${s.unit})` : '';
|
|
20
|
+
return `${s.name}${unit}: ${value} [${s.points?.length ?? 0} points]`;
|
|
21
|
+
}
|
|
22
|
+
// The platform's unit strings are the contract here (`bytes` for memory/storage, `bytes/s` for
|
|
23
|
+
// egress/ingress — src/adapters/fly.ts and insta-db.ts). Matching them loosely is deliberate: an
|
|
24
|
+
// unrecognised unit fails SILENTLY back to the raw 8-digit number this scaling exists to fix, so a
|
|
25
|
+
// casing or spacing change on the platform side must not be enough to regress it.
|
|
26
|
+
function scaleOf(unit) {
|
|
27
|
+
const u = unit?.trim().toLowerCase();
|
|
28
|
+
if (u === 'bytes' || u === 'byte' || u === 'b')
|
|
29
|
+
return 'bytes';
|
|
30
|
+
if (u === 'bytes/s' || u === 'byte/s' || u === 'b/s' || u === 'bytes/sec')
|
|
31
|
+
return 'bytes/s';
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
function isScaled(unit) {
|
|
35
|
+
return scaleOf(unit) !== undefined;
|
|
36
|
+
}
|
|
37
|
+
function formatMetricValue(v, unit) {
|
|
38
|
+
if (!Number.isFinite(v))
|
|
39
|
+
return 'n/a';
|
|
40
|
+
const scale = scaleOf(unit);
|
|
41
|
+
// Traffic scales by 1000 because egress is BILLED per decimal GB (`bytes / 1e9`, platform
|
|
42
|
+
// src/adapters/fly.ts) — a 1024-based "GB/s" would sit ~7% off the invoice. Memory and storage
|
|
43
|
+
// stay binary: those ceilings are provisioned in GiB. Same split as the console.
|
|
44
|
+
if (scale === 'bytes')
|
|
45
|
+
return humanBytes(v, 1024);
|
|
46
|
+
if (scale === 'bytes/s')
|
|
47
|
+
return `${humanBytes(v, 1000)}/s`;
|
|
48
|
+
return String(v);
|
|
49
|
+
}
|
|
50
|
+
function humanBytes(v, base) {
|
|
51
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
52
|
+
let value = v;
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (Math.abs(value) >= base && i < units.length - 1) {
|
|
55
|
+
value /= base;
|
|
56
|
+
i += 1;
|
|
57
|
+
}
|
|
58
|
+
// Sub-KB values keep their integer form (`512 B`, not `512.0 B`), but a RATE is fractional —
|
|
59
|
+
// PromQL rate() of a byte counter yields things like 342.857142, which must not print in full.
|
|
60
|
+
const shown = i === 0 && Number.isInteger(value) ? String(value) : value.toFixed(1);
|
|
61
|
+
return `${shown} ${units[i]}`;
|
|
62
|
+
}
|
|
11
63
|
// insta metrics <db|compute> [group]
|
|
12
64
|
export async function metrics(component, group, opts) {
|
|
13
65
|
const api = await ApiClient.load();
|
|
@@ -19,10 +71,8 @@ export async function metrics(component, group, opts) {
|
|
|
19
71
|
info(`note: ${res.note}`);
|
|
20
72
|
if (!res.series?.length)
|
|
21
73
|
return info('(no series)');
|
|
22
|
-
for (const s of res.series)
|
|
23
|
-
|
|
24
|
-
info(`${s.name}${s.unit ? ` (${s.unit})` : ''}: ${last ? last[1] : 'n/a'} [${s.points?.length ?? 0} points]`);
|
|
25
|
-
}
|
|
74
|
+
for (const s of res.series)
|
|
75
|
+
info(metricLine(s));
|
|
26
76
|
}
|
|
27
77
|
// Customer-facing name for each internal billing dimension (the platform stores RAM as `ram`).
|
|
28
78
|
const DIMENSION_LABEL = { ram: 'memory' };
|
package/dist/index.js
CHANGED
|
@@ -115,7 +115,7 @@ svc.command('add <type> <name>').description('Provision a service on demand (ass
|
|
|
115
115
|
.option('--image <url>', 'compute only: run this container image at creation')
|
|
116
116
|
.option('--port <n>', 'compute only: port the image listens on (default 8080)')
|
|
117
117
|
.option('--always-on', 'compute only: create as always-on — never scales to zero (all plans; billing is actual usage either way)')
|
|
118
|
-
.option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (
|
|
118
|
+
.option('--volume <gi>', 'compute only: attach a persistent /data volume of this many whole Gi (also attachable later: `insta compute volume <name> --size <gi>`; any plan may attach at the default 1; larger sizes are paid and plan-capped). Volume services keep 1 machine and stop (cold wake) instead of suspend when idle')
|
|
119
119
|
.action(guard((type, name, o) => services.servicesAdd(type, name, o)));
|
|
120
120
|
svc.command('list').option('--json').option('--branch <branch>', 'branch (default: current)')
|
|
121
121
|
.action(guard((o) => services.servicesList(o)));
|
|
@@ -171,7 +171,7 @@ compute.command('limits [service]').description("Show or set a compute service's
|
|
|
171
171
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeLimits(service, o)));
|
|
172
172
|
compute.command('always-on <mode> [service]').description('Set a compute service always-on (mode: on|off). on = machines never scale to zero; off = default scale-to-zero. All plans; billing is actual usage either way')
|
|
173
173
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((mode, service, o) => computeCmd.computeAlwaysOn(mode, service, o)));
|
|
174
|
-
compute.command('volume [service]').description("Show or grow a compute service's persistent /data volume. No --size: print size, mount path, and the plan cap (any plan). --size
|
|
174
|
+
compute.command('volume [service]').description("Show, attach, or grow a compute service's persistent /data volume. No --size: print size, mount path, and the plan cap (any plan). --size on a volumeless service ATTACHES one (any plan at the default 1Gi; larger is paid and plan-capped; the disk mounts at /data on the next deploy); on a volume-bearing one it grows (paid plans; grow-only — a provisioned disk cannot shrink). Billing is actual data stored — the size is a cap, not a price")
|
|
175
175
|
.option('--size <gi>', 'new size in whole Gi, e.g. 10 (must be ≥ the current size)')
|
|
176
176
|
.option('--json').option('--branch <branch>', 'branch (default: current)').action(guard((service, o) => computeCmd.computeVolume(service, o)));
|
|
177
177
|
// ---- db (postgres service controls) ----
|
|
@@ -180,6 +180,9 @@ db.command('limits').description("Show or set a postgres service's resource ceil
|
|
|
180
180
|
.option('--cpu <n>', "vCPU ceiling, e.g. 2 or 2500m").option('--memory <size>', "memory ceiling, e.g. 4Gi")
|
|
181
181
|
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
|
|
182
182
|
.action(guard((o) => dbCmd.dbLimits(o)));
|
|
183
|
+
db.command('stats').description("Postgres stats snapshot: connections vs the server's max (active count), cache hit rate, database size. insta-db-backed services answer without waking a suspended instance")
|
|
184
|
+
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
|
|
185
|
+
.action(guard((o) => dbCmd.dbStats(o)));
|
|
183
186
|
db.command('always-on <mode>').description('Set a postgres service always-on (mode: on|off). on = instance stays warm, no cold starts; off = default scale-to-zero (idle instance suspends; first connection cold-starts). insta-db-backed services only')
|
|
184
187
|
.option('--json').option('--branch <branch>', 'branch (default: current)').option('--group <g>', 'postgres service name (default: the sole/default one)')
|
|
185
188
|
.action(guard((mode, o) => dbCmd.dbAlwaysOn(mode, o)));
|