insta 0.0.80 → 0.0.81
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 +1 -0
- package/dist/build-logs.js +206 -0
- package/dist/commands/build-logs.js +23 -0
- package/dist/commands/deploy.js +10 -2
- package/dist/deploy-archive.js +3 -1
- package/dist/index.js +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -226,6 +226,7 @@ build never reaches a production installer.
|
|
|
226
226
|
| `insta db` | `url` (print the postgres DSN) · `connect` (psql session) · `limits` · `stats` · `always-on` · `volume` |
|
|
227
227
|
| `insta regions` | Regions available for postgres and compute |
|
|
228
228
|
| `insta manifest` | Agent-legible view of every branch and its URLs |
|
|
229
|
+
| `insta build-logs <id>` | Read 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 |
|
|
229
230
|
| `insta metrics` · `logs` · `events` | Service metrics; runtime logs (`--deploy` for deploy events); audit timeline |
|
|
230
231
|
| `insta usage` · `billing` | Usage by billing dimension; `billing upgrade` · `billing portal` |
|
|
231
232
|
| `insta approvals` | `list` · `approve` · `deny` |
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
3
|
+
import { ApiError } from './api.js';
|
|
4
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
5
|
+
const terminal = new Set(['succeeded', 'failed', 'canceled', 'unknown', 'live']);
|
|
6
|
+
const entryKey = (step, entry) => createHash('sha256').update(JSON.stringify([step, entry.timestamp, entry.message])).digest('hex');
|
|
7
|
+
class LogsNotReady extends Error {
|
|
8
|
+
}
|
|
9
|
+
export async function readBuildLogs(api, projectId, source, buildId, signal = AbortSignal.timeout(30_000), follow) {
|
|
10
|
+
let bytes = 0;
|
|
11
|
+
let requests = 0;
|
|
12
|
+
let pending = false;
|
|
13
|
+
async function pages(step) {
|
|
14
|
+
const result = [];
|
|
15
|
+
const seen = new Set();
|
|
16
|
+
const tail = step ? follow?.tails.get(step) ?? { counts: new Map() } : undefined;
|
|
17
|
+
let cursor = tail?.cursor;
|
|
18
|
+
do {
|
|
19
|
+
if (++requests > 200)
|
|
20
|
+
throw new Error('build logs exceed the per-read page limit');
|
|
21
|
+
const params = new URLSearchParams({ ...(step ? { step } : {}), ...(cursor ? { cursor } : {}) });
|
|
22
|
+
const { body } = await api.rawRequest('GET', `/projects/${projectId}/builds/${source}/${encodeURIComponent(buildId)}/logs?${params}`, undefined, { signal });
|
|
23
|
+
if (!body || !['ready', 'pending', 'unsupported', 'unavailable'].includes(body.state) || !Array.isArray(body.steps) || !Array.isArray(body.entries))
|
|
24
|
+
throw new Error('invalid build log response');
|
|
25
|
+
bytes += Buffer.byteLength(JSON.stringify(body));
|
|
26
|
+
if (bytes > 16 * 1024 * 1024)
|
|
27
|
+
throw new Error('build logs exceed the 16 MiB per-read limit');
|
|
28
|
+
if (body.state === 'pending')
|
|
29
|
+
pending = true;
|
|
30
|
+
if (step && follow && body.state === 'ready') {
|
|
31
|
+
const counts = new Map(tail.counts);
|
|
32
|
+
const entries = body.entries.map(entry => {
|
|
33
|
+
const key = entryKey(step, entry);
|
|
34
|
+
const occurrence = (counts.get(key) ?? 0) + 1;
|
|
35
|
+
if (counts.size >= 100_000 && !counts.has(key))
|
|
36
|
+
throw new Error('live build logs exceed the display limit');
|
|
37
|
+
counts.set(key, occurrence);
|
|
38
|
+
return { ...entry, occurrence };
|
|
39
|
+
});
|
|
40
|
+
const name = steps.find(item => item.digest === step).name;
|
|
41
|
+
follow.emit({ ...body, output: [{ step, name, entries }] });
|
|
42
|
+
if (body.nextCursor) {
|
|
43
|
+
tail.counts = counts;
|
|
44
|
+
tail.cursor = body.nextCursor;
|
|
45
|
+
}
|
|
46
|
+
follow.tails.set(step, tail);
|
|
47
|
+
result.push({ ...body, entries: [] });
|
|
48
|
+
}
|
|
49
|
+
else
|
|
50
|
+
result.push(body);
|
|
51
|
+
cursor = body.nextCursor || undefined;
|
|
52
|
+
if (cursor && seen.has(cursor))
|
|
53
|
+
throw new Error('build log pagination did not advance');
|
|
54
|
+
if (cursor)
|
|
55
|
+
seen.add(cursor);
|
|
56
|
+
} while (cursor);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
const stepPages = await pages();
|
|
60
|
+
const first = stepPages[0];
|
|
61
|
+
if (stepPages.some((page) => page.state !== first.state))
|
|
62
|
+
throw new LogsNotReady('build steps are temporarily unavailable');
|
|
63
|
+
const steps = stepPages.flatMap((page) => page.steps);
|
|
64
|
+
follow?.emit({ ...first, steps, output: [] });
|
|
65
|
+
const output = [];
|
|
66
|
+
for (const step of steps) {
|
|
67
|
+
if (!step.hasLogs)
|
|
68
|
+
continue;
|
|
69
|
+
const logs = await pages(step.digest);
|
|
70
|
+
if (logs.some((page) => page.state !== 'ready' && page.state !== 'pending'))
|
|
71
|
+
throw new LogsNotReady('step output is temporarily unavailable');
|
|
72
|
+
output.push({ step: step.digest, name: step.name, entries: logs.flatMap((page) => page.entries) });
|
|
73
|
+
}
|
|
74
|
+
return { ...first, nextCursor: undefined, state: pending ? 'pending' : first.state, steps, output };
|
|
75
|
+
}
|
|
76
|
+
const safeText = (text) => stripVTControlCharacters(text).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
|
|
77
|
+
export class BuildLogPrinter {
|
|
78
|
+
seen = new Map();
|
|
79
|
+
diagnostics = new Set();
|
|
80
|
+
lastStep;
|
|
81
|
+
lineStart = true;
|
|
82
|
+
finishLine(write) {
|
|
83
|
+
if (!this.lineStart)
|
|
84
|
+
write('\n');
|
|
85
|
+
this.lineStart = true;
|
|
86
|
+
}
|
|
87
|
+
print(snapshot, write) {
|
|
88
|
+
const errors = [
|
|
89
|
+
...(snapshot.error ? [{ id: 'build', message: snapshot.error }] : []),
|
|
90
|
+
...snapshot.steps.filter(step => step.error).map(step => ({ id: step.digest, message: `${step.name}: ${step.error}` })),
|
|
91
|
+
];
|
|
92
|
+
for (const error of errors) {
|
|
93
|
+
const key = JSON.stringify([error.id, error.message]);
|
|
94
|
+
if (this.diagnostics.has(key))
|
|
95
|
+
continue;
|
|
96
|
+
this.finishLine(write);
|
|
97
|
+
write(safeText(error.message) + '\n');
|
|
98
|
+
this.diagnostics.add(key);
|
|
99
|
+
}
|
|
100
|
+
for (const step of snapshot.output) {
|
|
101
|
+
const counts = new Map();
|
|
102
|
+
for (const entry of step.entries) {
|
|
103
|
+
const key = entryKey(step.step, entry);
|
|
104
|
+
const count = entry.occurrence ?? (counts.get(key) ?? 0) + 1;
|
|
105
|
+
counts.set(key, count);
|
|
106
|
+
if (count <= (this.seen.get(key) ?? 0))
|
|
107
|
+
continue;
|
|
108
|
+
if (this.seen.size >= 100_000)
|
|
109
|
+
throw new Error('live build logs exceed the display limit');
|
|
110
|
+
if (this.lastStep !== step.step) {
|
|
111
|
+
this.finishLine(write);
|
|
112
|
+
write(safeText(step.name) + '\n');
|
|
113
|
+
this.lastStep = step.step;
|
|
114
|
+
}
|
|
115
|
+
const text = safeText(entry.message);
|
|
116
|
+
write(text);
|
|
117
|
+
if (text)
|
|
118
|
+
this.lineStart = text.endsWith('\n');
|
|
119
|
+
}
|
|
120
|
+
for (const [key, count] of counts)
|
|
121
|
+
this.seen.set(key, Math.max(count, this.seen.get(key) ?? 0));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export function archiveLogWatcher(api, projectId, write, wait = sleep) {
|
|
126
|
+
const printer = new BuildLogPrinter();
|
|
127
|
+
const follow = { tails: new Map(), emit: snapshot => printer.print(snapshot, write) };
|
|
128
|
+
let warned = '';
|
|
129
|
+
let unavailable = false;
|
|
130
|
+
return async (buildId, finished, remainingMs = 30_000) => {
|
|
131
|
+
if (unavailable)
|
|
132
|
+
return;
|
|
133
|
+
const deadline = Date.now() + Math.min(remainingMs, finished ? 18_000 : 3000);
|
|
134
|
+
for (let attempt = 0; attempt < (finished ? 6 : 1); attempt++) {
|
|
135
|
+
if (attempt > 0)
|
|
136
|
+
await wait(Math.min(3000, Math.max(0, deadline - Date.now())));
|
|
137
|
+
const remaining = deadline - Date.now();
|
|
138
|
+
if (remaining <= 0)
|
|
139
|
+
return;
|
|
140
|
+
if (finished && (attempt === 0 || attempt === 5))
|
|
141
|
+
follow.tails.clear();
|
|
142
|
+
try {
|
|
143
|
+
const snapshot = await readBuildLogs(api, projectId, 'archive', buildId, AbortSignal.timeout(remaining), follow);
|
|
144
|
+
if (snapshot.state === 'unsupported') {
|
|
145
|
+
printer.finishLine(write);
|
|
146
|
+
write('Build logs are not supported for this build provider.\n');
|
|
147
|
+
unavailable = true;
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (snapshot.state === 'unavailable')
|
|
151
|
+
throw new Error('build logs unavailable');
|
|
152
|
+
warned = '';
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (error instanceof ApiError && error.status === 400)
|
|
156
|
+
follow.tails.clear();
|
|
157
|
+
printer.finishLine(write);
|
|
158
|
+
const message = `Could not read build logs. Retry with: insta build-logs ${buildId}\n`;
|
|
159
|
+
if (warned !== message)
|
|
160
|
+
write(message);
|
|
161
|
+
warned = message;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
export async function followBuildLogs(api, projectId, source, buildId, write, wait = sleep) {
|
|
167
|
+
const printer = new BuildLogPrinter();
|
|
168
|
+
const follow = { tails: new Map(), emit: snapshot => printer.print(snapshot, write) };
|
|
169
|
+
let finalReads = 0;
|
|
170
|
+
let failures = 0;
|
|
171
|
+
for (;;) {
|
|
172
|
+
let snapshot;
|
|
173
|
+
try {
|
|
174
|
+
snapshot = await readBuildLogs(api, projectId, source, buildId, AbortSignal.timeout(30_000), follow);
|
|
175
|
+
failures = 0;
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const retryable = error instanceof ApiError ? error.status === 400 || error.status === 429 || error.status >= 500
|
|
179
|
+
: error instanceof LogsNotReady || error instanceof TypeError || (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name));
|
|
180
|
+
if (error instanceof ApiError && error.status === 400)
|
|
181
|
+
follow.tails.clear();
|
|
182
|
+
if (!retryable || ++failures >= 5) {
|
|
183
|
+
printer.finishLine(write);
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
printer.finishLine(write);
|
|
187
|
+
write('Could not refresh build logs; retrying…\n');
|
|
188
|
+
await wait(3000);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (snapshot.state === 'unsupported' || snapshot.state === 'unavailable') {
|
|
192
|
+
printer.finishLine(write);
|
|
193
|
+
throw new Error(`Build logs ${snapshot.state}`);
|
|
194
|
+
}
|
|
195
|
+
if (snapshot.state === 'ready' && terminal.has(snapshot.buildState)) {
|
|
196
|
+
if (++finalReads >= 6) {
|
|
197
|
+
printer.finishLine(write);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (finalReads === 1 || finalReads === 5)
|
|
201
|
+
follow.tails.clear();
|
|
202
|
+
}
|
|
203
|
+
await wait(3000);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=build-logs.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ApiClient, requireProject } from '../api.js';
|
|
2
|
+
import { BuildLogPrinter, followBuildLogs, readBuildLogs } from '../build-logs.js';
|
|
3
|
+
import { info, printJson } from '../util.js';
|
|
4
|
+
export async function buildLogs(buildId, opts) {
|
|
5
|
+
if (opts.source !== 'github' && opts.source !== 'archive')
|
|
6
|
+
throw new Error('--source must be github or archive');
|
|
7
|
+
if (opts.follow && opts.json)
|
|
8
|
+
throw new Error('--follow cannot be combined with --json');
|
|
9
|
+
const api = await ApiClient.load();
|
|
10
|
+
const { projectId } = await requireProject();
|
|
11
|
+
if (opts.follow)
|
|
12
|
+
return followBuildLogs(api, projectId, opts.source, buildId, (text) => { process.stdout.write(text); });
|
|
13
|
+
const snapshot = await readBuildLogs(api, projectId, opts.source, buildId);
|
|
14
|
+
if (opts.json)
|
|
15
|
+
return printJson(snapshot);
|
|
16
|
+
const printer = new BuildLogPrinter();
|
|
17
|
+
const write = (text) => { process.stdout.write(text); };
|
|
18
|
+
printer.print(snapshot, write);
|
|
19
|
+
printer.finishLine(write);
|
|
20
|
+
if (snapshot.state !== 'ready')
|
|
21
|
+
info(`Build logs ${snapshot.state}`);
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=build-logs.js.map
|
package/dist/commands/deploy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { archiveLogWatcher } from '../build-logs.js';
|
|
1
2
|
import { resolve, join } from 'node:path';
|
|
2
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
4
|
import { ApiClient, ApiError, requireProject } from '../api.js';
|
|
@@ -74,7 +75,13 @@ export async function prepareSource(api, projectId, dir, branch, opts, run = opt
|
|
|
74
75
|
// flyctl, local-docker and legacy all end in an image, and all three need a Dockerfile.
|
|
75
76
|
return { image: await buildFromSource(api, projectId, dir, branch, opts, run) };
|
|
76
77
|
}
|
|
77
|
-
const
|
|
78
|
+
const stream = opts.json ? process.stderr : process.stdout;
|
|
79
|
+
let lineStart = true;
|
|
80
|
+
const finishLine = () => { if (!lineStart)
|
|
81
|
+
stream.write('\n'); lineStart = true; };
|
|
82
|
+
const log = (message) => { finishLine(); note(opts)(message); };
|
|
83
|
+
const writeOutput = (text) => { stream.write(text); if (text)
|
|
84
|
+
lineStart = text.endsWith('\n'); };
|
|
78
85
|
const absDir = resolve(process.cwd(), dir);
|
|
79
86
|
const caveat = windowsModeCaveat();
|
|
80
87
|
if (caveat)
|
|
@@ -89,7 +96,8 @@ export async function prepareSource(api, projectId, dir, branch, opts, run = opt
|
|
|
89
96
|
// time, because a platform request has to answer inside the ALB's 60s while a build runs minutes.
|
|
90
97
|
// A repo-connected service refuses this with a 409 the same way it refuses an image deploy, and
|
|
91
98
|
// the hint that names the FLAG rather than the API field lives here, beside the `/deploy` path.
|
|
92
|
-
const out = await deployArchive(api, projectId, ref, branch, opts, Date.now, undefined, log)
|
|
99
|
+
const out = await deployArchive(api, projectId, ref, branch, opts, Date.now, undefined, log, archiveLogWatcher(api, projectId, writeOutput))
|
|
100
|
+
.finally(finishLine)
|
|
93
101
|
.catch((e) => { throw e instanceof ApiError && e.status === 409 ? new ApiError(e.status, repoConnectedHint(e.message), e.body) : e; });
|
|
94
102
|
if (!out)
|
|
95
103
|
return null;
|
package/dist/deploy-archive.js
CHANGED
|
@@ -64,7 +64,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
64
64
|
// The operation is idempotent on (target, archive, build kind), so a re-run after approving finds
|
|
65
65
|
// the one it already started rather than building again: same image, same result, no second
|
|
66
66
|
// approval for a build that already happened.
|
|
67
|
-
export async function deployArchive(api, projectId, ref, branch, opts, now = Date.now, wait = sleep, log = () => { }) {
|
|
67
|
+
export async function deployArchive(api, projectId, ref, branch, opts, now = Date.now, wait = sleep, log = () => { }, watchLogs) {
|
|
68
68
|
const started = await api.rawRequest('POST', `/projects/${projectId}/archive-deploys`, {
|
|
69
69
|
branch,
|
|
70
70
|
group: opts.group,
|
|
@@ -79,6 +79,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
79
79
|
const operationId = started.body?.operationId;
|
|
80
80
|
if (typeof operationId !== 'string' || !operationId)
|
|
81
81
|
throw new Error('the platform accepted the deploy but returned no operation id — re-run the deploy');
|
|
82
|
+
log(`build logs: insta build-logs ${operationId}`);
|
|
82
83
|
if (started.body?.resumed === true)
|
|
83
84
|
log('resuming the deploy this archive already started');
|
|
84
85
|
const deadline = now() + DEPLOY_DEADLINE_MS;
|
|
@@ -99,6 +100,7 @@ export async function deployArchive(api, projectId, ref, branch, opts, now = Dat
|
|
|
99
100
|
throw remaining <= POLL_REQUEST_TIMEOUT_MS ? overdue() : new Error(`the platform did not answer a status poll within ${POLL_REQUEST_TIMEOUT_MS / 1000}s — check \`insta status\` or re-run`);
|
|
100
101
|
});
|
|
101
102
|
const state = res.body?.state;
|
|
103
|
+
await watchLogs?.(operationId, state === 'failed' || state === 'live', Math.max(0, deadline - now()));
|
|
102
104
|
// A failed operation is an ANSWER, not a transport error: the poll worked, and the sentence
|
|
103
105
|
// it carries (usually the gateway's own, e.g. "no Dockerfile at ./api") is the one to show.
|
|
104
106
|
if (state === 'failed') {
|
package/dist/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import * as regions from './commands/regions.js';
|
|
|
21
21
|
import * as secretsCmd from './commands/secrets.js';
|
|
22
22
|
import { deploy } from './commands/deploy.js';
|
|
23
23
|
import { build } from './commands/build.js';
|
|
24
|
+
import { buildLogs } from './commands/build-logs.js';
|
|
24
25
|
import * as computeCmd from './commands/compute.js';
|
|
25
26
|
import * as githubCmd from './commands/github.js';
|
|
26
27
|
import * as dbCmd from './commands/db.js';
|
|
@@ -347,6 +348,9 @@ program.command('manifest').description('Print an agent-legible view of the proj
|
|
|
347
348
|
// ---- regions ----
|
|
348
349
|
program.command('regions').description('List regions available for postgres/compute services').option('--json').action(guard((o) => regions.regionsList(o)));
|
|
349
350
|
// ---- observability ----
|
|
351
|
+
program.command('build-logs <build-id>').description('Read source-build output for a deploy operation or GitHub build')
|
|
352
|
+
.option('--source <source>', 'archive or github', 'archive').option('--follow', 'poll new output until the build ends').option('--json')
|
|
353
|
+
.action(guard((id, opts) => buildLogs(id, opts)));
|
|
350
354
|
program.command('metrics <target> [group]').description('Service metrics (target: db|compute|redis|mysql|mongodb)')
|
|
351
355
|
.option('--branch <b>').option('--from <unix>').option('--to <unix>').option('--step <s>').option('--json')
|
|
352
356
|
.action(guard((target, group, o) => obs.metrics(target, group, o)));
|