Skip to content

Make the service YAML the single source of truth for custom services#679

Merged
geodro merged 1 commit into
mainfrom
fix/custom-service-single-source-of-truth
Jun 29, 2026
Merged

Make the service YAML the single source of truth for custom services#679
geodro merged 1 commit into
mainfrom
fix/custom-service-single-source-of-truth

Conversation

@geodro

@geodro geodro commented Jun 29, 2026

Copy link
Copy Markdown
Member

Problem

A custom service's install state was tracked in two places that could drift apart. serviceops.ServiceInstalled (and therefore the preset picker's installed flag, the install guard, and dependency checks) keyed off the quadlet file, while the services list, lerd start/stop, update, tuning, env, pause and host-proxy all read the per-service definition YAML. When a quadlet existed with no backing YAML, the preset picker reported the service as installed while it was invisible in every list, never started, and could not be managed. We found four such orphans on a real install (gotenberg, phpmyadmin, stripe-mock, and a misclassified site container). Fixes #678.

Change

The service definition YAML is now the single source of truth for custom (non-default) services. ServiceInstalled resolves install state from the YAML for them, so the preset flag, the install guard and dependency checks agree with the services list. Built-in default presets keep the quadlet as their signal because their definition lives in the binary and they have no YAML.

A reconciliation pass runs on lerd start and enforces the invariant in both directions:

  • YAML present but unit missing: regenerate the quadlet/plist from the YAML.
  • A service quadlet with no backing YAML: remove the orphan quadlet/plist, leaving the data directory intact.

Orphans are identified by a # lerd-managed-service marker stamped into managed quadlets, unioned with the known non-default preset names so orphans written before the marker existed are still reaped. Site containers and per-site workers carry neither, so they are never touched. A running orphan is left in place rather than force-stopped and destroyed, and per-service errors are collected so one bad service can't abort the rest of the pass. The install guard now blocks only a fully installed service (YAML and unit both present), so a partial remnant from an interrupted install or remove can be healed by reinstalling.

Closes #678

Custom service install state was tracked in two places that could drift:
ServiceInstalled keyed off the quadlet file while the services list, start,
update, tuning and the rest read the per-service YAML. A quadlet with no YAML
showed as installed in the preset picker yet was invisible everywhere else and
never ran (issue #678).

ServiceInstalled now resolves custom (non-default) services from their YAML, so
the preset flag, the install guard and dependency checks agree with the services
list. Built-in default presets keep the quadlet as their signal since they have
no YAML by design.

A reconcile pass on lerd start enforces the invariant both ways: regenerate a
missing quadlet/plist from its YAML, and remove an orphan quadlet with no
backing YAML (data dir left intact). Orphans are found by a marker stamped into
managed quadlets plus the non-default preset names, so legacy orphans written
before the marker are still reaped; site and worker quadlets are never touched.
A running orphan is left in place rather than destroyed, and per-service errors
no longer abort the rest of the pass. The install guard now blocks only a fully
installed service so a partial remnant can be healed by reinstalling.

Closes #678
@geodro geodro merged commit 718b5df into main Jun 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Custom service install-state has two sources of truth (quadlet vs YAML), producing invisible orphan services

1 participant