app store: the install step machine, resumable and testable without docker

Install spans a container start, a health wait, an upstream API call and a process start. Any 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 there: unwind everything, or leave a half-installed service that neither works nor
uninstalls, which is the state users cannot get out of.

So each step is named, completion is persisted, and running install again resumes. planSteps is a pure
function of (entry, mode) and the effects are injected, which makes ordering, resume, blocking and
failure testable with no Docker, Postgres, PM2 or Immich in sight. 15 tests cover exactly the behaviour
that only appears when something goes wrong.

Two rules are enforced by the plan rather than remembered at call sites: 'existing' never provisions, so
pointing at an instance the user already runs cannot start a container; and the members step is omitted
entirely for a service with no user concept, so a Transmission install does not report a step that did
nothing — which reads as a silent failure to anyone debugging a member's access.

`blocked` is a first-class outcome, not an error. For Immich the container is up and healthy and only
its own UI can mint a key; calling that a failure would make a normal install look broken and invite the
user to tear down a working container. The blocking step is deliberately NOT recorded as complete, so a
resume re-runs the step the human just answered.

Results feed forward — provision discovers the URL that connect writes down two steps later — over a
copy of the caller's values, so a failure halfway cannot rewrite what an earlier attempt achieved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:44:30 +00:00
co-authored by Claude Opus 5
parent b197c2aeba
commit 4a03b9f84e
2 changed files with 392 additions and 0 deletions
+210
View File
@@ -0,0 +1,210 @@
import { describe, expect, it } from 'bun:test';
import { planSteps, runInstall, type InstallEffects, type StepName } from './installer';
import { byId } from './catalogue';
// The whole install machine, tested without Docker, Postgres, PM2 or an Immich to talk to — which is
// the reason the effects are injected. What is being pinned here is the behaviour that only shows up
// when something goes wrong: resume, blocking, and not doing work twice.
const photos = byId('photos')!; // existing|provisioned, members: 'accounts'
const transmission = byId('transmission')!; // existing|provisioned, members: 'none'
const email = byId('email')!; // config only
/** Records what was actually called, so a test can assert on absence as well as presence. */
function spyEffects(over: Partial<InstallEffects> = {}) {
const calls: string[] = [];
const effects: InstallEffects = {
preflight: async () => {
calls.push('preflight');
return { ok: true };
},
provision: async () => {
calls.push('provision');
return { results: { url: 'http://127.0.0.1:18091' }, composeDir: '/root/dockers/x' };
},
connect: async () => {
calls.push('connect');
return { status: 'done' };
},
applySchema: async () => void calls.push('schema'),
startProcess: async () => void calls.push('process'),
provisionMembers: async () => void calls.push('members'),
...over,
};
return { effects, calls };
}
describe('planSteps', () => {
it('never provisions when pointing at an instance the user already runs', () => {
// The guarantee that matters: choosing 'existing' cannot start a container. Enforced here rather
// than remembered at each call site.
expect(planSteps(photos, 'existing')).not.toContain('provision');
expect(planSteps(photos, 'provisioned')).toContain('provision');
});
it('omits the members step for a service with no user concept', () => {
// Otherwise a Transmission install reports a members step that did nothing, which reads as a
// silent failure to anyone debugging why a member has no access.
expect(planSteps(transmission, 'provisioned')).not.toContain('members');
expect(planSteps(photos, 'provisioned')).toContain('members');
});
it('has nothing to connect for a config-only install', () => {
expect(planSteps(email, 'config')).toEqual(['preflight', 'schema', 'process']);
});
it('always starts with preflight', () => {
// Checking the host before anything is written is the whole reason a half-install is avoidable.
for (const mode of ['existing', 'provisioned', 'config'] as const) {
expect(planSteps(photos, mode)[0]).toBe('preflight');
}
});
});
describe('a clean run', () => {
it('executes the plan in order and reports installed', async () => {
const { effects, calls } = spyEffects();
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('installed');
expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'process', 'members']);
});
it('feeds one steps results forward to the next', async () => {
// `provision` discovers the URL that `connect` writes down two steps later. Without this the
// installer would have to ask the user for something it already knows.
let seen: Record<string, string> = {};
const { effects } = spyEffects({
connect: async (ctx) => {
seen = { ...ctx.values };
return { status: 'done' };
},
});
await runInstall({ entry: photos, mode: 'provisioned', values: { given: 'yes' }, effects });
expect(seen.url).toBe('http://127.0.0.1:18091');
expect(seen.composeDir).toBe('/root/dockers/x');
expect(seen.given).toBe('yes');
});
it('does not mutate the callers values', async () => {
const values = { given: 'yes' };
const { effects } = spyEffects();
await runInstall({ entry: photos, mode: 'provisioned', values, effects });
expect(values).toEqual({ given: 'yes' });
});
});
describe('resuming', () => {
it('skips what an earlier attempt already did', async () => {
// The point of persisting completedSteps: a resume must not provision a second container.
const { effects, calls } = spyEffects();
const done: StepName[] = ['preflight', 'provision', 'connect'];
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, completed: done, effects });
expect(out.status).toBe('installed');
expect(calls).toEqual(['schema', 'process', 'members']);
expect(calls).not.toContain('provision');
});
it('reports every completed step, including the ones it skipped', async () => {
const { effects } = spyEffects();
const out = await runInstall({
entry: photos,
mode: 'provisioned',
values: {},
completed: ['preflight'],
effects,
});
expect(out.completed).toEqual(['preflight', 'provision', 'connect', 'schema', 'process', 'members']);
});
});
describe('blocking on a human', () => {
it('stops without failing, and does NOT mark the blocking step done', async () => {
// Immich: the container is up and healthy, and only its own UI can mint an API key. Recording
// `connect` as complete would mean a resume skipped the very step that is waiting.
const { effects, calls } = spyEffects({
connect: async () => ({ status: 'blocked', reason: 'Needs an API key', completeAt: 'http://x/keys' }),
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('blocked');
if (out.status !== 'blocked') throw new Error('unreachable');
expect(out.at).toBe('connect');
expect(out.completeAt).toBe('http://x/keys');
expect(out.completed).toEqual(['preflight', 'provision']);
// Everything after the block is untouched — no process started against a service we cannot reach.
expect(calls).not.toContain('process');
});
it('re-runs the blocking step on resume, once the human has answered', async () => {
const { effects, calls } = spyEffects();
const out = await runInstall({
entry: photos,
mode: 'provisioned',
values: { secret: 'now-provided' },
completed: ['preflight', 'provision'],
effects,
});
expect(out.status).toBe('installed');
expect(calls).toContain('connect');
});
});
describe('failing', () => {
it('stops at the failing step and keeps what came before', async () => {
const { effects, calls } = spyEffects({
applySchema: async () => {
throw new Error('relation already exists');
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
expect(out.at).toBe('schema');
expect(out.error).toBe('relation already exists');
// Resumable: the three that worked are recorded, so a retry does not redo them.
expect(out.completed).toEqual(['preflight', 'provision', 'connect']);
expect(calls).not.toContain('process');
});
it('treats a failed preflight as a failure before anything is written', async () => {
const { effects, calls } = spyEffects({
preflight: async () => {
calls.push('preflight');
return { ok: false, reason: 'Docker is not installed.', remedy: 'Install it.' };
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
expect(out.at).toBe('preflight');
// The remedy travels with the reason: "Docker is not installed" without "install it" is a dead end.
expect(out.error).toContain('Install it.');
// Nothing provisioned, so there is nothing to unwind — the entire reason preflight goes first.
expect(calls).toEqual(['preflight']);
});
it('turns a thrown effect into a recorded failure rather than an escape', async () => {
// An effect that throws is a bug in that effect. If it escaped, the row would be stranded in
// `installing` with nothing to resume from.
const { effects } = spyEffects({
startProcess: async () => {
throw new Error('pm2 not found');
},
});
const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects });
expect(out.status).toBe('failed');
if (out.status !== 'failed') throw new Error('unreachable');
expect(out.at).toBe('process');
});
});
+182
View File
@@ -0,0 +1,182 @@
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'
/** 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>;
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');
steps.push('schema', '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 'process':
await effects.startProcess(ctx);
return { status: 'done' };
case 'members':
await effects.provisionMembers(ctx);
return { status: 'done' };
}
}