diff --git a/src/servers/app-store/installer.test.ts b/src/servers/app-store/installer.test.ts new file mode 100644 index 00000000..d61ba769 --- /dev/null +++ b/src/servers/app-store/installer.test.ts @@ -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 = {}) { + 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 step’s 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 = {}; + 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 caller’s 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'); + }); +}); diff --git a/src/servers/app-store/installer.ts b/src/servers/app-store/installer.ts new file mode 100644 index 00000000..2bd50a7a --- /dev/null +++ b/src/servers/app-store/installer.ts @@ -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 } + /** + * 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; + /** 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; + /** Run the sidecar's setup.sh. Returns whatever it printed as `OFFICER_RESULT_=value`. */ + provision(ctx: StepContext): Promise<{ results: Record; composeDir: string }>; + /** Write the owner's connection row. `blocked` when the service can only be connected by a human. */ + connect(ctx: StepContext): Promise; + applySchema(ctx: StepContext): Promise; + startProcess(ctx: StepContext): Promise; + provisionMembers(ctx: StepContext): Promise; +}; + +/** + * 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; + /** 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 { + 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 { + 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' }; + } +}