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:
2026-08-10 19:02:40 +00:00
co-authored by Claude Opus 5
parent 82e9fdacc2
commit e83585dd42
5 changed files with 144 additions and 16 deletions
+49 -11
View File
@@ -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 };
}