app store: containers follow the sidecar through enable, disable and uninstall
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>
This commit is contained in:
@@ -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<boolean> {
|
||||
try {
|
||||
await stat(join(serviceDir, 'docker-compose.yaml'));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function compose(serviceDir: string, args: string[]): Promise<ComposeResult> {
|
||||
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<ComposeResult> => 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<ComposeResult> => 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<ComposeResult> => compose(serviceDir, ['down']);
|
||||
@@ -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}`);
|
||||
|
||||
@@ -27,6 +27,7 @@ function spyEffects(over: Partial<InstallEffects> = {}) {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<StepResult>;
|
||||
applySchema(ctx: StepContext): Promise<void>;
|
||||
publishAssets(ctx: StepContext): Promise<void>;
|
||||
startProcess(ctx: StepContext): Promise<void>;
|
||||
provisionMembers(ctx: StepContext): Promise<void>;
|
||||
};
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user