Tier two works end to end. Transmission installed through the API with a real container, verified on
this machine and then removed:
install preflight, provision, connect, schema, assets, process — container up, health-checked,
connection written from what the setup script printed
disable process stopped, then container Exited(0), data intact
enable container back up, then the process
uninstall container gone, install row gone, DATA UNTOUCHED — config, compose file, downloads and
watch directories all still present
Order matters in both directions and it is opposite each way. Enable brings the container up first: a
sidecar that starts before its upstream exists spends its first seconds failing health checks and
logging about a service that is merely not up yet. Disable stops the process first, for the same reason
in reverse.
`down`, never `down -v`, and no `--rmi`: the volumes are the user's data and the images are shared and
expensive to re-pull. Both are deliberate omissions, stated so nobody adds them later as a tidy-up.
Uninstall only brings down containers for `mode: 'provisioned'`. An `existing` install points at a
service the user runs themselves, and `down` there would stop a container Officer never started.
Every compose call tolerates a missing directory rather than failing. Three call sites can legitimately
arrive with nothing there — an `existing` install, a failed install that died before writing the file,
and a resumed uninstall re-running a completed step — and erroring would make a row impossible to
uninstall, which is the one state a user cannot escape.
Adds an `assets` step, before `process`: the dock reads manifests as soon as the install is recorded, so
an icon arriving a moment later shows as broken on the first render. And composeDir is recorded from the
install rather than derived later, because the directory is the user's and they may move it — uninstall
must not guess at a path it is about to run `down` in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
191 lines
8.5 KiB
TypeScript
191 lines
8.5 KiB
TypeScript
import type { CatalogueEntry, InstallMode } from './catalogue';
|
|
import type { Preflight } from './preflight';
|
|
|
|
// Installing one sidecar, as a sequence of named steps that can stop anywhere and be resumed.
|
|
//
|
|
// ── Why a step machine and not a function ──
|
|
//
|
|
// Install spans a container start, a health wait, an upstream API call and a process start. Any of them
|
|
// can fail, and one of them (a token only a human can mint) is EXPECTED to stop the run. A straight-line
|
|
// function has two bad options at that point: unwind everything, or leave the user with a half-installed
|
|
// service that neither works nor uninstalls. The second is the one people cannot get out of.
|
|
//
|
|
// So each step is named, its completion is persisted in `sidecar_installs.completed_steps`, and running
|
|
// install again resumes from where it stopped. Re-running a completed step is never necessary, but is
|
|
// also never harmful — every effect below is required to be idempotent, because the alternative is
|
|
// trusting that a crash never lands between "did the thing" and "recorded the thing".
|
|
//
|
|
// ── Why the effects are injected ──
|
|
//
|
|
// `planSteps` is pure and `runInstall` takes its side effects as an argument, so the whole machine —
|
|
// ordering, resume, blocking, failure — is testable without Docker, Postgres, PM2 or an Immich to talk
|
|
// to. The parts that genuinely touch the world stay thin enough to read.
|
|
|
|
export type StepName =
|
|
/** Host is capable of this: Docker present for a provisioned install, display present for vnc. */
|
|
| 'preflight'
|
|
/** Render the compose template and bring the containers up. Provisioned installs only. */
|
|
| 'provision'
|
|
/** Write the owner's `service_connections` row — the URL and credential this install is reachable by. */
|
|
| 'connect'
|
|
/** Apply the sidecar's own schema. */
|
|
| 'schema'
|
|
/** Copy the sidecar's icon and assets to where the browser can fetch them. */
|
|
| 'assets'
|
|
/** Start the PM2 process. */
|
|
| 'process'
|
|
/** Give every existing member their own account, where the service supports it. */
|
|
| 'members';
|
|
|
|
export type StepResult =
|
|
| { status: 'done'; results?: Record<string, string> }
|
|
/**
|
|
* Everything up to here worked and the run cannot continue without a human.
|
|
*
|
|
* A real state, not a failure: for Immich, Jellyfin and Memos the container is up and healthy and we
|
|
* are waiting for a token only their own UI can mint. Reporting this as an error would make a normal
|
|
* install look broken and invite the user to tear down a container that is working perfectly.
|
|
*/
|
|
| { status: 'blocked'; reason: string; completeAt?: string }
|
|
| { status: 'failed'; error: string };
|
|
|
|
/** What a step is given. Deliberately small — a step that needs more probably belongs in the sidecar. */
|
|
export type StepContext = {
|
|
entry: CatalogueEntry;
|
|
mode: InstallMode;
|
|
/** Answers from the install form, plus anything earlier steps returned via `OFFICER_RESULT_*`. */
|
|
values: Record<string, string>;
|
|
/** Progress for the log the UI streams into a terminal panel. */
|
|
log: (line: string) => void;
|
|
};
|
|
|
|
/**
|
|
* The world, as the installer touches it. Every one of these MUST be idempotent — see above.
|
|
*/
|
|
export type InstallEffects = {
|
|
preflight(entry: CatalogueEntry, mode: InstallMode): Promise<Preflight>;
|
|
/** Run the sidecar's setup.sh. Returns whatever it printed as `OFFICER_RESULT_<KEY>=value`. */
|
|
provision(ctx: StepContext): Promise<{ results: Record<string, string>; composeDir: string }>;
|
|
/** Write the owner's connection row. `blocked` when the service can only be connected by a human. */
|
|
connect(ctx: StepContext): Promise<StepResult>;
|
|
applySchema(ctx: StepContext): Promise<void>;
|
|
publishAssets(ctx: StepContext): Promise<void>;
|
|
startProcess(ctx: StepContext): Promise<void>;
|
|
provisionMembers(ctx: StepContext): Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* Which steps this install needs, in order — a pure function of the entry and the chosen mode.
|
|
*
|
|
* Separated from running them so the UI can show what is about to happen, and so ordering is testable
|
|
* on its own. The two rules that matter:
|
|
*
|
|
* - `provision` exists only for `provisioned`. Pointing at an instance the user already runs must
|
|
* never start a container, and this is where that is guaranteed rather than remembered.
|
|
* - `members` is omitted entirely when the service has no user concept, so a Transmission install does
|
|
* not report a members step that did nothing.
|
|
*/
|
|
export function planSteps(entry: CatalogueEntry, mode: InstallMode): StepName[] {
|
|
const steps: StepName[] = ['preflight'];
|
|
if (mode === 'provisioned') steps.push('provision');
|
|
// `config` installs have nothing to point at, so there is no connection row to write.
|
|
if (mode !== 'config') steps.push('connect');
|
|
// Assets before the process: the dock reads manifests as soon as the install is recorded, and an icon
|
|
// that arrives a moment later shows as broken on the first render.
|
|
steps.push('schema', 'assets', 'process');
|
|
if (entry.members !== 'none') steps.push('members');
|
|
return steps;
|
|
}
|
|
|
|
export type InstallOutcome =
|
|
| { status: 'installed'; completed: StepName[] }
|
|
| { status: 'blocked'; completed: StepName[]; at: StepName; reason: string; completeAt?: string }
|
|
| { status: 'failed'; completed: StepName[]; at: StepName; error: string };
|
|
|
|
export type RunInstallParams = {
|
|
entry: CatalogueEntry;
|
|
mode: InstallMode;
|
|
values: Record<string, string>;
|
|
/** Steps already done by an earlier attempt. Passing them is what makes this a resume. */
|
|
completed?: StepName[];
|
|
effects: InstallEffects;
|
|
log?: (line: string) => void;
|
|
};
|
|
|
|
/**
|
|
* Run (or resume) an install.
|
|
*
|
|
* Returns rather than throws, because every outcome here is something the caller has to record: a
|
|
* failure updates `last_error` and leaves the row resumable, and a block is a normal pause. Throwing
|
|
* would make the caller's job "catch and guess which of those happened".
|
|
*/
|
|
export async function runInstall(params: RunInstallParams): Promise<InstallOutcome> {
|
|
const { entry, mode, effects } = params;
|
|
const log = params.log ?? (() => {});
|
|
const plan = planSteps(entry, mode);
|
|
const completed = [...(params.completed ?? [])];
|
|
// Copied, not aliased: a resumed run must not mutate the caller's record of what an earlier attempt
|
|
// achieved, or a failure halfway through would silently rewrite history.
|
|
const values = { ...params.values };
|
|
|
|
for (const step of plan) {
|
|
if (completed.includes(step)) {
|
|
log(`· ${step} — already done, skipping`);
|
|
continue;
|
|
}
|
|
|
|
const ctx: StepContext = { entry, mode, values, log };
|
|
log(`▸ ${step}`);
|
|
|
|
try {
|
|
const result = await runStep(step, ctx, effects);
|
|
|
|
if (result.status === 'failed') {
|
|
return { status: 'failed', completed, at: step, error: result.error };
|
|
}
|
|
if (result.status === 'blocked') {
|
|
// Note that `completed` does NOT include this step: resuming re-runs it, which is the point —
|
|
// the human has now supplied what it was waiting for.
|
|
return { status: 'blocked', completed, at: step, reason: result.reason, completeAt: result.completeAt };
|
|
}
|
|
|
|
// Results feed forward: `provision` discovers the URL a `connect` two steps later writes down.
|
|
if (result.results) Object.assign(values, result.results);
|
|
completed.push(step);
|
|
} catch (err) {
|
|
// An effect that throws is a bug in that effect, not a different kind of failure. Recording it the
|
|
// same way keeps the row resumable instead of stranding it in `installing` forever.
|
|
return { status: 'failed', completed, at: step, error: err instanceof Error ? err.message : String(err) };
|
|
}
|
|
}
|
|
|
|
return { status: 'installed', completed };
|
|
}
|
|
|
|
async function runStep(step: StepName, ctx: StepContext, effects: InstallEffects): Promise<StepResult> {
|
|
switch (step) {
|
|
case 'preflight': {
|
|
const check = await effects.preflight(ctx.entry, ctx.mode);
|
|
return check.ok ? { status: 'done' } : { status: 'failed', error: `${check.reason} ${check.remedy}` };
|
|
}
|
|
case 'provision': {
|
|
const { results, composeDir } = await effects.provision(ctx);
|
|
return { status: 'done', results: { ...results, composeDir } };
|
|
}
|
|
case 'connect':
|
|
return effects.connect(ctx);
|
|
case 'schema':
|
|
await effects.applySchema(ctx);
|
|
return { status: 'done' };
|
|
case 'assets':
|
|
await effects.publishAssets(ctx);
|
|
return { status: 'done' };
|
|
case 'process':
|
|
await effects.startProcess(ctx);
|
|
return { status: 'done' };
|
|
case 'members':
|
|
await effects.provisionMembers(ctx);
|
|
return { status: 'done' };
|
|
}
|
|
}
|