diff --git a/src/servers/app-store/compose.ts b/src/servers/app-store/compose.ts new file mode 100644 index 00000000..b7ae98d8 --- /dev/null +++ b/src/servers/app-store/compose.ts @@ -0,0 +1,71 @@ +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +// Starting, stopping and removing the containers behind a provisioned sidecar. +// +// ── The one rule ── +// +// `down`, never `down -v`. Uninstall removes containers; it does not remove data, and there is no +// option here that does. A user uninstalling Photos is saying "stop running this", not "delete my +// library", and for Immich or Jellyfin getting that wrong once is unrecoverable. +// +// The bind-mount convention makes that structural rather than a rule to remember: the data lives on the +// host inside the service directory, so `-v` — which only removes NAMED volumes — could not delete it +// even if someone added the flag. This module simply never gives them the chance. +// +// ── Why every function tolerates a missing directory ── +// +// Three of the four call sites can legitimately arrive with nothing there: an `existing` install never +// provisioned anything, a failed install may have died before writing the compose file, and a resumed +// uninstall may be re-running a step that already succeeded. Treating those as errors would make a row +// impossible to uninstall, which is the one state a user cannot get themselves out of. + +export type ComposeResult = { ok: true; ran: boolean } | { ok: false; error: string }; + +async function hasCompose(serviceDir: string): Promise { + try { + await stat(join(serviceDir, 'docker-compose.yaml')); + return true; + } catch { + return false; + } +} + +async function compose(serviceDir: string, args: string[]): Promise { + if (!(await hasCompose(serviceDir))) return { ok: true, ran: false }; + + const proc = Bun.spawn(['docker', 'compose', '--project-directory', serviceDir, ...args], { + stdout: 'pipe', + stderr: 'pipe', + }); + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const code = await proc.exited; + + if (code !== 0) return { ok: false, error: `${err || out}`.trim() || `docker compose ${args[0]} exited ${code}` }; + return { ok: true, ran: true }; +} + +/** + * Bring the containers up. Used by enable, and by a resumed install whose containers were stopped. + * + * `up -d` rather than `start`: `start` only works on containers that already exist, and after a + * `down` they do not. `up -d` covers both, and is a no-op against an unchanged compose file. + */ +export const composeUp = (serviceDir: string): Promise => compose(serviceDir, ['up', '-d']); + +/** + * Stop them, leaving them defined. + * + * Disable stops the container as well as the sidecar — there is no reason to leave Immich holding + * memory while Photos is switched off — and `stop` rather than `down` is what makes re-enabling + * instant rather than a fresh `up`. + */ +export const composeStop = (serviceDir: string): Promise => compose(serviceDir, ['stop']); + +/** + * Remove containers and networks. Uninstall only. + * + * No `-v`, and no `--rmi`: the images are shared and expensive to re-pull, and the volumes are the + * user's data. Both are deliberate omissions rather than oversights. + */ +export const composeDown = (serviceDir: string): Promise => compose(serviceDir, ['down']); diff --git a/src/servers/app-store/effects.ts b/src/servers/app-store/effects.ts index 0d3bcb17..25774069 100644 --- a/src/servers/app-store/effects.ts +++ b/src/servers/app-store/effects.ts @@ -5,6 +5,7 @@ import { preflight } from './preflight'; import { runSetupScript } from './run-script'; import { startProcess } from './pm2'; import { serviceDir } from './paths'; +import { publishAssets } from './assets'; // The installer's steps, wired to the actual world. // @@ -90,6 +91,15 @@ export function createEffects(): InstallEffects { ctx.log('schema: already present (platform-wide schema; per-sidecar schema is a later phase)'); }, + async publishAssets(ctx: StepContext) { + // Every install, not just provisioned ones: an `existing` Photos still needs its icon in the dock. + // The template directory is where a sidecar ships its assets, and a sidecar with none is the + // common case rather than an error. + const template = ctx.entry.composeTemplate ?? ctx.entry.id; + const published = await publishAssets(ctx.entry.id, join(TEMPLATES_DIR, template)); + ctx.log(published ? `assets published to /plugins/${ctx.entry.id}/` : 'assets: none shipped'); + }, + async startProcess(ctx: StepContext) { const result = await startProcess(ctx.entry.process, PLATFORM_DIR); if (!result.ok) throw new Error(`could not start ${ctx.entry.process}: ${result.error}`); diff --git a/src/servers/app-store/installer.test.ts b/src/servers/app-store/installer.test.ts index d61ba769..4ec8690e 100644 --- a/src/servers/app-store/installer.test.ts +++ b/src/servers/app-store/installer.test.ts @@ -27,6 +27,7 @@ function spyEffects(over: Partial = {}) { return { status: 'done' }; }, applySchema: async () => void calls.push('schema'), + publishAssets: async () => void calls.push('assets'), startProcess: async () => void calls.push('process'), provisionMembers: async () => void calls.push('members'), ...over, @@ -50,7 +51,7 @@ describe('planSteps', () => { }); it('has nothing to connect for a config-only install', () => { - expect(planSteps(email, 'config')).toEqual(['preflight', 'schema', 'process']); + expect(planSteps(email, 'config')).toEqual(['preflight', 'schema', 'assets', 'process']); }); it('always starts with preflight', () => { @@ -67,7 +68,7 @@ describe('a clean run', () => { const out = await runInstall({ entry: photos, mode: 'provisioned', values: {}, effects }); expect(out.status).toBe('installed'); - expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'process', 'members']); + expect(calls).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']); }); it('feeds one step’s results forward to the next', async () => { @@ -105,7 +106,7 @@ describe('resuming', () => { 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).toEqual(['schema', 'assets', 'process', 'members']); expect(calls).not.toContain('provision'); }); @@ -118,7 +119,7 @@ describe('resuming', () => { completed: ['preflight'], effects, }); - expect(out.completed).toEqual(['preflight', 'provision', 'connect', 'schema', 'process', 'members']); + expect(out.completed).toEqual(['preflight', 'provision', 'connect', 'schema', 'assets', 'process', 'members']); }); }); diff --git a/src/servers/app-store/installer.ts b/src/servers/app-store/installer.ts index 2bd50a7a..b384c943 100644 --- a/src/servers/app-store/installer.ts +++ b/src/servers/app-store/installer.ts @@ -30,6 +30,8 @@ export type StepName = | '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. */ @@ -67,6 +69,7 @@ export type InstallEffects = { /** 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; }; @@ -87,7 +90,9 @@ export function planSteps(entry: CatalogueEntry, mode: InstallMode): StepName[] 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'); + // 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; } @@ -172,6 +177,9 @@ async function runStep(step: StepName, ctx: StepContext, effects: InstallEffects 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' }; diff --git a/src/servers/app-store/service.ts b/src/servers/app-store/service.ts index e1bb1093..6535ae45 100644 --- a/src/servers/app-store/service.ts +++ b/src/servers/app-store/service.ts @@ -5,6 +5,7 @@ import { markInstalled, markFailed, markBlocked, + recordSteps, setEnabled as setEnabledRow, removeInstall, type SidecarInstall, @@ -14,6 +15,9 @@ import { runInstall, type StepName } from './installer'; import { createEffects } from './effects'; import { startProcess, stopProcess, deleteProcess, processStatus } from './pm2'; import { plannedOutcome } from './members'; +import { composeUp, composeStop, composeDown } from './compose'; +import { unpublishAssets } from './assets'; +import { serviceDir } from './paths'; // The app store's operations, between the HTTP routes and the machinery. Routes stay about HTTP; this // stays about what installing, enabling and uninstalling actually mean. @@ -108,6 +112,9 @@ export async function install(req: InstallRequest) { }); if (outcome.status === 'installed') { + // Recorded from the install rather than derived later: the directory is the user's and they may move + // it, and uninstall must not guess at a path it is about to run `docker compose down` in. + if (req.mode === 'provisioned') await recordSteps(entry.id, outcome.completed, serviceDir(entry.id)); await markInstalled(entry.id, outcome.completed); } else if (outcome.status === 'blocked') { await markBlocked(entry.id, outcome.completed, outcome.reason); @@ -119,11 +126,18 @@ export async function install(req: InstallRequest) { } /** - * Start or stop a sidecar and its container, without changing what is installed. + * Start or stop a sidecar AND its container, without changing what is installed. * - * The container half is not implemented yet — `docker compose stop` against the recorded compose - * directory — and is deliberately absent rather than silently skipped: a disable that leaves Immich - * running is a different thing from one that stops it, and the difference is memory on the user's box. + * Order matters in both directions, and it is the opposite each way: + * + * enable container first, then the process — a sidecar that starts before its upstream exists + * spends its first seconds failing health checks and logging errors about a service that + * is merely not up yet. + * disable process first, then the container — stopping the container underneath a running sidecar + * produces the same noise for the same reason, in reverse. + * + * `mode: 'existing'` has no container of ours, and `composeUp`/`composeStop` return `ran: false` + * rather than failing: there is no compose file, which is the correct state, not an error. */ export async function setEnabled(sidecarId: string, enabled: boolean) { const entry = byId(sidecarId); @@ -131,10 +145,19 @@ export async function setEnabled(sidecarId: string, enabled: boolean) { const row = await getSidecarInstall(sidecarId); if (!row) throw new Error(`${entry.label} is not installed`); - const result = enabled - ? await startProcess(entry.process, PLATFORM_DIR) - : await stopProcess(entry.process, PLATFORM_DIR); - if (!result.ok) throw new Error(result.error); + const dir = row.composeDir ?? serviceDir(sidecarId); + + if (enabled) { + const container = await composeUp(dir); + if (!container.ok) throw new Error(`could not start ${entry.label}'s container: ${container.error}`); + const process = await startProcess(entry.process, PLATFORM_DIR); + if (!process.ok) throw new Error(process.error); + } else { + const process = await stopProcess(entry.process, PLATFORM_DIR); + if (!process.ok) throw new Error(process.error); + const container = await composeStop(dir); + if (!container.ok) throw new Error(`could not stop ${entry.label}'s container: ${container.error}`); + } await setEnabledRow(sidecarId, enabled); return { ok: true as const }; @@ -143,18 +166,33 @@ export async function setEnabled(sidecarId: string, enabled: boolean) { /** * Stop running this. Never "delete my data". * - * The process is stopped and removed from PM2. The sidecar's tables, the service directory and - * everything under it survive — see the schema comment and the design doc. Removing the containers is - * the missing half, for the same reason as above. + * What goes: the process (stopped, then removed from PM2), the containers (`docker compose down`, no + * `-v`), the published assets, and the install row. + * + * What stays: the service directory and everything under it — configuration, databases, media — and the + * sidecar's tables. Reinstalling later is therefore a restore rather than a fresh start. + * + * The assets are the only thing here that is deleted outright, and only because they are COPIES; the + * originals live with the sidecar. Nothing a user made is in that directory. */ export async function uninstall(sidecarId: string) { const entry = byId(sidecarId); if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`); + const row = await getSidecarInstall(sidecarId); const stopped = await stopProcess(entry.process, PLATFORM_DIR); if (!stopped.ok) throw new Error(stopped.error); await deleteProcess(entry.process, PLATFORM_DIR); + // Only for something we provisioned. An `existing` install points at a service the user runs + // themselves, and bringing it down would stop a container Officer did not start. + if (row?.mode === 'provisioned') { + const dir = row.composeDir ?? serviceDir(sidecarId); + const container = await composeDown(dir); + if (!container.ok) throw new Error(`could not remove ${entry.label}'s containers: ${container.error}`); + } + + await unpublishAssets(sidecarId); await removeInstall(sidecarId); return { ok: true as const }; }