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 } /** * 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; publishAssets(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'); // 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; /** 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 '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' }; } }