app store: run a sidecar's setup.sh, streaming it and collecting what it returns
Implements the half of the template contract that faces the platform: answers go in as environment and never as prompts, and results come back as OFFICER_RESULT_<KEY>= lines on stdout. A line protocol rather than JSON because the same stream is the user's live log — it goes to a terminal panel while the install runs. A script that must emit clean JSON cannot also narrate, and one that emits both needs a framing convention anyway. This mirrors the @@officer:progress@@ sentinel the job runner already uses, with the same rule: marker lines are plucked out, everything else passes through. parseResults is pure and tested against the realistic near-misses: a line that MENTIONS the prefix without starting with it, an empty value (Transmission with no RPC auth returns exactly that, and blank is a real answer), a value containing `=` (splitting on every one would truncate a credential), and a prefix with no assignment (a script bug — skipped rather than stored as a blank key). Verified end to end against a real script: environment reaches it, stderr is forwarded (docker compose writes its progress there, so dropping it would hide most of what a user watches), OFFICER_NONINTERACTIVE is set so a script that would block fails loudly instead of hanging behind a web form, and a non-zero exit is reported with the tail. Notes an artifact rather than hiding it: the two streams are pumped concurrently, so the error tail can interleave differently from real time. The live log is correctly ordered; only the summary can read out of order. Serialising the pumps would make a script that writes heavily to one stream block on the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { parseResults } from './run-script';
|
||||
|
||||
// The line protocol between a setup script and the installer. Pure, so it is tested without spawning
|
||||
// anything — and worth testing precisely, because a script's output is interleaved with `docker
|
||||
// compose`'s and a loose parser would pick up things that merely look like results.
|
||||
|
||||
describe('parseResults', () => {
|
||||
it('reads the values a script hands back', () => {
|
||||
const out = parseResults(
|
||||
['==> Starting', 'OFFICER_RESULT_URL=http://127.0.0.1:18091', 'OFFICER_RESULT_PATH=/transmission/rpc', '==> Done'].join('\n'),
|
||||
);
|
||||
expect(out).toEqual({ url: 'http://127.0.0.1:18091', path: '/transmission/rpc' });
|
||||
});
|
||||
|
||||
it('ignores everything that is not a result line', () => {
|
||||
// The same stream is the user's live log. Narration must never be mistaken for a value.
|
||||
const out = parseResults(
|
||||
['Container officer-transmission Started', 'note: OFFICER_RESULT_URL is printed at the end', ''].join('\n'),
|
||||
);
|
||||
// The middle line MENTIONS the prefix but does not start with it, which is the realistic near-miss.
|
||||
expect(out).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps an empty value, because blank is a real answer', () => {
|
||||
// Transmission with no RPC auth returns exactly this, and it means "no username", which is
|
||||
// different from the key being absent.
|
||||
expect(parseResults('OFFICER_RESULT_USERNAME=')).toEqual({ username: '' });
|
||||
});
|
||||
|
||||
it('keeps everything after the first `=`', () => {
|
||||
// Tokens and URLs contain `=`; splitting on every one would silently truncate a credential.
|
||||
expect(parseResults('OFFICER_RESULT_SECRET=abc=def==')).toEqual({ secret: 'abc=def==' });
|
||||
});
|
||||
|
||||
it('lets a later line win', () => {
|
||||
// A script that reports twice has changed its mind — a retry inside it that finally worked.
|
||||
expect(parseResults(['OFFICER_RESULT_URL=http://first', 'OFFICER_RESULT_URL=http://second'].join('\n'))).toEqual({
|
||||
url: 'http://second',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips a prefix with no assignment rather than storing a blank key', () => {
|
||||
// A script bug. Storing `{'': ''}` would look like a deliberate empty value downstream.
|
||||
expect(parseResults('OFFICER_RESULT_')).toEqual({});
|
||||
expect(parseResults('OFFICER_RESULT_=novalue')).toEqual({});
|
||||
});
|
||||
|
||||
it('tolerates indentation, since stderr lines arrive prefixed', () => {
|
||||
expect(parseResults(' OFFICER_RESULT_URL=http://x ')).toEqual({ url: 'http://x' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { join } from 'node:path';
|
||||
import { serviceDir } from './paths';
|
||||
|
||||
// Running a sidecar's setup.sh, streaming what it prints, and collecting what it hands back.
|
||||
//
|
||||
// The script contract lives in `templates/README.md`. Two halves of it are implemented here: answers go
|
||||
// in as environment (never as prompts, which behind a web form is a hang with no output), and results
|
||||
// come back as `OFFICER_RESULT_<KEY>=value` lines on stdout.
|
||||
//
|
||||
// ── Why results are a line protocol and not JSON on stdout ──
|
||||
//
|
||||
// The same stream is the user's live log — it goes to a terminal panel while the install runs. A script
|
||||
// that must emit clean JSON cannot also narrate, and one that emits both needs a framing convention
|
||||
// anyway. A prefixed line is that convention, it survives being interleaved with `docker compose`
|
||||
// output, and a human reading the log can see exactly what was handed back.
|
||||
//
|
||||
// This mirrors the progress sentinel the job runner already uses (`@@officer:progress@@`), for the same
|
||||
// reason and with the same rule: the marker lines are plucked out of the stream, and everything else is
|
||||
// passed through as narration.
|
||||
|
||||
const RESULT_PREFIX = 'OFFICER_RESULT_';
|
||||
|
||||
/**
|
||||
* Pull `OFFICER_RESULT_*` assignments out of a script's output.
|
||||
*
|
||||
* Pure, so the protocol is testable without spawning anything.
|
||||
*
|
||||
* Later lines win. A script that reports a value twice has changed its mind — a retry inside the script
|
||||
* that finally succeeded, say — and the last word is the one that reflects reality.
|
||||
*/
|
||||
export function parseResults(output: string): Record<string, string> {
|
||||
const results: Record<string, string> = {};
|
||||
for (const raw of output.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line.startsWith(RESULT_PREFIX)) continue;
|
||||
const eq = line.indexOf('=');
|
||||
// A prefix with no `=` is a script bug, not a value. Skipping beats storing a key with an empty
|
||||
// string, which would look like a deliberate blank later.
|
||||
if (eq <= RESULT_PREFIX.length) continue;
|
||||
const key = line.slice(RESULT_PREFIX.length, eq).toLowerCase();
|
||||
results[key] = line.slice(eq + 1);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export type RunScriptParams = {
|
||||
sidecarId: string;
|
||||
/** Directory holding the template's `setup.sh`, i.e. `app-store/templates/<template>`. */
|
||||
templateDir: string;
|
||||
/** Answers from the install form. Keys are used verbatim as environment variable names. */
|
||||
env: Record<string, string>;
|
||||
/** Called per line, as it happens — this is what the terminal panel renders. */
|
||||
log: (line: string) => void;
|
||||
/** Guard against a script that hangs rather than fails. */
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type RunScriptOutcome =
|
||||
| { ok: true; results: Record<string, string>; serviceDir: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** Ten minutes: long enough to pull a large image on a slow line, short enough to not hang a UI forever. */
|
||||
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export async function runSetupScript(params: RunScriptParams): Promise<RunScriptOutcome> {
|
||||
const dir = serviceDir(params.sidecarId);
|
||||
const script = join(params.templateDir, 'setup.sh');
|
||||
|
||||
const proc = Bun.spawn(['bash', script], {
|
||||
// The script's own working directory is its template dir; where it WRITES is OFFICER_SERVICE_DIR.
|
||||
// Keeping those separate is what stops a script from accidentally writing into the repo.
|
||||
cwd: params.templateDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...params.env,
|
||||
OFFICER_SERVICE_DIR: dir,
|
||||
// A form cannot answer a prompt, so a script that would block must fail loudly instead. This is
|
||||
// the flag that turns a hang into a diagnosable error.
|
||||
OFFICER_NONINTERACTIVE: '1',
|
||||
OFFICER_UID: String(process.getuid?.() ?? 1000),
|
||||
OFFICER_GID: String(process.getgid?.() ?? 1000),
|
||||
},
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
params.log(`✗ timed out after ${(params.timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s — killing`);
|
||||
proc.kill();
|
||||
}, params.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
|
||||
// Line-buffered so the panel updates as things happen rather than in one dump at the end — the whole
|
||||
// reason for streaming. Both streams are forwarded: `docker compose` writes its progress to stderr,
|
||||
// so dropping it would hide most of what a user wants to watch.
|
||||
const collected: string[] = [];
|
||||
const pump = async (stream: ReadableStream<Uint8Array>, prefix = '') => {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
let nl: number;
|
||||
while ((nl = buffer.indexOf('\n')) >= 0) {
|
||||
const line = buffer.slice(0, nl);
|
||||
buffer = buffer.slice(nl + 1);
|
||||
collected.push(line);
|
||||
params.log(prefix + line);
|
||||
}
|
||||
}
|
||||
if (buffer) {
|
||||
collected.push(buffer);
|
||||
params.log(prefix + buffer);
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all([pump(proc.stdout), pump(proc.stderr, ' ')]);
|
||||
const code = await proc.exited;
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (code !== 0) {
|
||||
// The last few lines are almost always the reason; the whole log is already in the panel above.
|
||||
//
|
||||
// `collected` can interleave differently from real time: the two streams are pumped concurrently and
|
||||
// append as they arrive, so a stderr line can land before a stdout line that was printed first. The
|
||||
// LIVE log is correctly ordered — each `log()` fires as its line arrives — and only this summary can
|
||||
// read out of order. Kept concurrent deliberately: serialising the pumps would make a script that
|
||||
// writes a lot to one stream block on the other.
|
||||
const tail = collected.slice(-5).join('\n').trim();
|
||||
return { ok: false, error: `setup.sh exited ${code}${tail ? `: ${tail}` : ''}` };
|
||||
}
|
||||
|
||||
return { ok: true, results: parseResults(collected.join('\n')), serviceDir: dir };
|
||||
}
|
||||
Reference in New Issue
Block a user