feat(service): scope-explicit install/uninstall/start/stop (#526) - #20
feat(service): scope-explicit install/uninstall/start/stop (#526)#20MichaelTaylor3d wants to merge 3 commits into
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors dig-node's scope-explicit service registration: install/uninstall/start/stop accept --scope <auto|system|user> (default auto, unchanged behaviour), resolved by a pure resolve_scope(choice, os_supports_user, is_root) so an elevated dig-installer install can register a reboot-surviving system-scope service instead of a user-level one with no session under sudo. install cross-scope-migrates (best-effort deregisters the other scope first); uninstall --scope auto as root removes both scopes. Co-Authored-By: Claude <noreply@anthropic.com>
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CHANGES-REQUIRED - reviewed head bada0bb4bc390d8a3e9f72eea8547a50467902b5
Gates are genuinely green for THIS sha (fmt/clippy/build-test/coverage/deny/CodeQL x3/commitlint/version-increment; runs exist, none skipped), zero prior review threads, no vendored wire file or GPL header touched, SERVICE_LABEL scoping correct, and the resolve_scope 12-row table is real, host-independent and load-bearing. Reboot survival also checks out as a MECHANISM: autostart: true + manager_at(System) on service-manager 0.7.1 reaches systemctl enable (the multi-user.target.wants symlink) / the launchd system domain - not merely a written unit file.
The blocker is the one thing this PR could not check when it was written: dig-node's #526 PR now exists (DIG-Network/dig-node#123, head 7c93cd9b) and the two surfaces DO NOT match.
Side-by-side
| Surface | dig-relay (this PR) | dig-node #123 | Match |
|---|---|---|---|
| Enum + variant names | ScopeChoice{Auto,System,User}, ServiceScope{System,User} |
identical | YES |
| Flag + value spelling | --scope <auto / system / user>, default auto, clap ValueEnum |
identical | YES |
| Flag placement | global arg | per-subcommand ScopeArg flatten |
install --scope system parses on BOTH, installer-safe; note only |
| Verbs carrying it | install/uninstall/start/stop | same | YES |
resolve_scope(Auto, true, root) |
System | System | YES |
resolve_scope(Auto, true, !root) |
User | User | YES |
resolve_scope(User, false, *) (Windows) |
User, then manager_at errors Unsupported |
System - platform short-circuits BEFORE the explicit choice | NO |
explicit system, unelevated, unix |
proceeds; fails deep inside systemctl | ensure_privilege_for_scope -> Err(PermissionDenied) with actionable text |
NO |
uninstall_scopes(Auto, true, !root) |
[User] | [User, System] - auto always sweeps both |
NO |
| uninstall reporting | Ok + exit 0 even when NOTHING was removed |
Err(PermissionDenied) on anything unresolved, Err(NotFound) when nothing removed |
NO |
| other-scope migration | unconditional; every error silently swallowed | PROBE-GATED and REPORTED as a ScopeRemoval |
NO |
scope value in JSON |
"system" / "user" |
same | YES |
Vocabulary matches; semantics do not. Because dig-installer emits ONE argument form to both components, every NO row is a runtime-only behaviour split - precisely the failure class this mirror exists to prevent.
Six blocking findings posted inline, most-severe first, plus one non-gating note which I resolve myself.
| is_root: bool, | ||
| ) -> Vec<ServiceScope> { | ||
| if choice == ScopeChoice::Auto && is_root { | ||
| vec![ServiceScope::System, ServiceScope::User] |
There was a problem hiding this comment.
BLOCKER 1 - uninstall_scopes diverges from dig-node in the DEFAULT unelevated case.
Here Auto + unelevated returns [User]. dig-node #123's uninstall_scopes returns [User, System]: auto sweeps BOTH scopes regardless of privilege, and its test asserts uninstall_scopes(Auto, true, false) == vec![User, System] with the comment "auto sweeps BOTH scopes so an uninstall can never leave a registration behind".
Concrete failure: dig-installer (elevated) registered dig-relay at system scope. Later a plain dig-relay uninstall - no flag, unelevated, which is what an uninstall script or an operator actually runs - resolves to [User] only, finds nothing there, and exits 0 reporting success. The system unit survives, keeps auto-starting at every boot, and keeps binding 9450/9451. dig-node given the identical command sweeps the system scope and fails loudly if it cannot remove it.
The test at line 754 hard-codes the divergent row (Auto, true, false -> vec![User]), so the suite currently DEFENDS the divergence instead of catching it.
BLOCKS merge.
|
|
||
| let summary = if failed.is_empty() { | ||
| format!( | ||
| "dig-relay: uninstalled service \"{SERVICE_LABEL}\" ({} scope(s): {})", |
There was a problem hiding this comment.
BLOCKER 2 - uninstall reports SUCCESS having removed nothing.
Every per-scope failure is collected into failed and the function still returns Ok(Outcome). src/main.rs:326 emit maps Ok to ExitCode::SUCCESS and prints an ok:true envelope. So an uninstall where the registration was found but could not be deleted (unelevated, against a system unit), or where nothing existed at all, is indistinguishable from a clean removal for any caller gating on the exit code or on ok.
Three distinct problems:
- Contract divergence - dig-node's
uninstall_outcomereturnsErr(PermissionDenied)for anything found-but-not-removed or indeterminate, andErr(NotFound)when nothing was removed: "an uninstall that leaves a registration behind - or cannot tell whether it did - must never report success". - Regression vs v0.15.1, which did
mgr.uninstall(...)?and propagated the error as a non-zero exit. This PR silently converts a previously-failing uninstall into a success; the PR body's backwards-compatibility claim ("no existing behaviour changes for a caller that never passes --scope") does not hold foruninstall. - SPEC.md self-contradiction (CLAUDE.md 4.2) - the new SPEC section 9 bullet states that a registration left behind "is a defect ... not an acceptable partial removal": a MUST this code cannot satisfy, because it returns success in exactly that state.
Note also that uninstall's reporting has NO test at all, which is why the defect is green.
BLOCKS merge.
| summary, | ||
| json!({ | ||
| "installed": false, | ||
| "registered": !removed.is_empty(), |
There was a problem hiding this comment.
BLOCKER 3 - the uninstall JSON registered field has inverted polarity.
"registered": !removed.is_empty() means that after a SUCCESSFUL uninstall (removed non-empty) the envelope reports registered: true alongside installed: false; when nothing was removed it reports registered: false. At v0.15.1 the field was unconditionally "registered": false. Any consumer of the --json envelope (the stable machine contract, CLAUDE.md 6.2) now reads "still registered" precisely when removal WORKED.
The value actually being computed is "something was removed", which removed_scopes already carries.
BLOCKS merge - a one-line polarity bug on a machine-readable contract.
|
|
||
| let (mgr, user_level) = manager()?; | ||
| let resolved = resolve_scope(scope, PREFERS_USER_LEVEL, is_root()); | ||
| deregister_at(other_scope(resolved)); |
There was a problem hiding this comment.
BLOCKER 4 - the migration destroys a WORKING registration before an install that can still fail.
deregister_at(other_scope(resolved)) runs unconditionally BEFORE manager_at(resolved)? and before mgr.install(...)?, and dig-relay has no equivalent of dig-node's ensure_privilege_for_scope. So on Linux/macOS an unelevated dig-relay install --scope system - an operator following the new runbook text, or any wrapper that passes the flag - will (1) stop and uninstall the user-scope unit that was working, then (2) fail inside service-manager writing /etc/systemd/system with a raw EACCES. The host ends with NO registration at all, from a command that should have refused before any side effect. dig-node returns Err(PermissionDenied) with actionable text and leaves everything intact.
This is a denial primitive introduced by a migration feature - side-effect ordering, not a papercut. Fix shape: a pure ensure_privilege_for_scope(resolved, PREFERS_USER_LEVEL, is_root()) evaluated BEFORE any side effect (mirroring dig-node, table-testable), and perform the other-scope removal only once the new registration is provably creatable.
BLOCKS merge.
| label: label().expect("SERVICE_LABEL is a constant, always parses"), | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
BLOCKER 5 - deregister_at is neither probe-gated nor reported, and is a silent no-op in the one scenario it exists for.
-
Not reported. Every error is discarded (
if let Ok(mgr),let _ = ...). dig-node's SPEC mandates the opposite - "any removal at a scope the caller did not name MUST be probe-gated, so a scope nobody asked about is never written to" - and it returns aScopeRemovalso the migration outcome reaches the caller. Here an elevated install that could NOT clear the old registration reports unqualified success and leaves two registrations racing for 9450/9451: exactly the state the migration exists to prevent. The bar was "non-fatal but REPORTED"; this is non-fatal and invisible. -
Root cannot see the user scope. Under
sudo,manager_at(ServiceScope::User)callsset_level(User), so service-manager issuessystemctl --user/launchctl ... gui/<uid>- which addresses ROOT'S OWN user domain, never the desktop user's session (root has nosystemd --user/ D-Bus session). For the headline scenario - an elevated installer migrating a host that previously had a user-level unit - the migration therefore removes NOTHING, and the stale user-level registration can still win resolution or double-bind the ports. Relocating closes nothing if the old location still wins.
Doc consequence: the new SPEC section 9 claim that install "best-effort deregisters the service at the OTHER scope ... so a host upgrading from a prior install at the other scope never ends up with two registrations both binding the relay's ports" is unsatisfiable as written (CLAUDE.md 4.2). Either implement a scope probe that works from root (inspect /etc/systemd/system versus the real user's ~/.config/systemd/user, or enumerate sessions via loginctl), or weaken the SPEC to what the code can prove - and surface the migration result in the Outcome either way.
BLOCKS merge.
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
BLOCKER 6 - the resolve_scope truth table differs from dig-node on the two (User, os_supports_user=false) rows.
dig-node checks the platform FIRST (an early return ServiceScope::System when os_supports_user is false), so an explicit --scope user on Windows resolves to System, documented there as "there is exactly one scope, so even an explicit --scope user cannot be honoured" and asserted in its own table. dig-relay honours the explicit User, and manager_at (line 123) then converts it into Err(ErrorKind::Unsupported).
Result: dig-node install --scope user on Windows installs at system scope; dig-relay install --scope user on Windows hard-fails. One installer argument form, two outcomes, detectable only at runtime. The relay test at line 749 ("explicit User wins even where the OS has no user manager") asserts the divergent row, locking it in.
Adopt dig-node's ordering (it is the mirrored contract, and it is the non-failing one for an installer), fix that table row, and update SPEC.md section 9, which currently documents the relay semantics. Any rename/reorder in this module must be call-graph-aware, not find-and-replace.
BLOCKS merge.
…n partial uninstall Resolves the six blocking review findings on #20 by adopting dig-node #123's scope contract verbatim where the two diverged, and by making the operations able to express their own failure. - resolve_scope checks the platform FIRST, so an explicit --scope user on Windows resolves to system instead of hard-failing (one installer argument form, one outcome on every OS). - uninstall_scopes sweeps BOTH scopes under auto at either privilege level: an unelevated `dig-relay uninstall` on a host registered at system scope must still address that system unit. - install refuses a scope the caller cannot register at (ensure_privilege_for_scope, pure) BEFORE any side effect, so an unprivileged --scope system no longer destroys a working registration ahead of a create that cannot succeed. - The cross-scope sweep is probe-gated and its result is REPORTED in result.migration; the named scope is still removed unconditionally, since the OS delete is authoritative and the probe can false-negative from a root session. - uninstall returns PermissionDenied for found-but-not-removed or indeterminate and NotFound when nothing was removed; on success the JSON reports registered: false (the inverted polarity is gone). Closes #526 Co-Authored-By: Claude <noreply@anthropic.com>
What
Mirrors dig-node's scope-explicit service registration (dig_ecosystem#526) into dig-relay's
src/service.rs:install/uninstall/start/stopall accept--scope <auto|system|user>(defaultauto, clapValueEnum). Root cause: an elevateddig-installerinstall (--with-relay) previously always registered a user-level systemd unit / launchd agent, which has no session undersudo/root and therefore does not survive a reboot.--scope system(orautoas root) registers/etc/systemd/system/+multi-user.target.wants/(Linux) or/Library/LaunchDaemons+RunAtLoad(macOS) instead.Contract
resolve_scope(choice: ScopeChoice, os_supports_user: bool, is_root: bool) -> ServiceScope— pure, nocfg!/geteuid()/filesystem inside it.Auto→Systemifis_rootor!os_supports_user(Windows), elseUser. ExplicitSystem/Useralways win.installbest-effort deregisters the OTHER scope before registering at the resolved one (cross-scope migration), so a host upgrading from a prior user-level install doesn't end up with two registrations binding the relay's ports.uninstall --scope autoas root removes the service at BOTH scopes, reporting per-scope success/failure (theuninstall_scopespure helper decides this, same shape asresolve_scope).auto== today's unelevated-user-level behaviour, unchanged.Cross-check against dig-node's #526 PR
Could not cross-check — as of this PR, no dig-node PR for #526 exists yet (
gh pr list --repo DIG-Network/dig-node --search "526"returns none; the only trace is a staledn-526worktree at dig-node'sorigin/maintip with zero diff). Implemented to the naming given in the task spec verbatim:ScopeChoice(Auto/System/User),ServiceScope(System/User),resolve_scope,--scope <auto|system|user>. This needs re-verification against dig-node's actual PR once it lands, sincedig-installeremits one argument form for both components.Blast radius checked (gitnexus disabled per CLAUDE.md §2.0 override — done via ripgrep + direct read)
src/service.rs:install/uninstall/start/stopsignatures changed (added aScopeChoiceparam) — the ONLY caller issrc/main.rs'sCommandmatch arms, updated in this PR.grep -rn "service::install\|service::uninstall\|service::start\|service::stop"across the whole repo (excludingtarget/) confirmssrc/main.rsis the only call site.manager()helper removed (superseded bymanager_at(ServiceScope)); no other caller.src/wire.rs,src/pex.rs, GPL-2.0-vendored types untouched).Constraints honored
resolve_scope_covers_every_combinationtest: all 12(ScopeChoice x os_supports_user x is_root)rows, runs with zero privileges on any host (no#[cfg(unix)]-only guard).uninstall_scopes_removes_both_only_for_auto_as_rootisolates the ONE combination (Auto+ root) that must widen to both scopes from every other combination that must not — varying onlyis_root.src/bin(n/a here — this crate has nosrc/bin).Evidence
cargo fmt --check— clean.cargo clippy --all-targets -- -D warnings— clean, zero warnings.cargo test— 254 lib tests + all integration suites green (22 inservice::tests, up from 17; the 5 new:resolve_scope_covers_every_combination,uninstall_scopes_removes_both_only_for_auto_as_root,other_scope_is_the_opposite,scope_label_matches_dig_node_wording, plus the table itself).resolve_scope, then reverted ONLY its body to a stub (ServiceScope::Userunconditionally) via a plain file copy (notgit checkout, per the destructive-revert HARD RULE) — bothresolve_scope_covers_every_combinationanduninstall_scopes_removes_both_only_for_auto_as_rootfailed for the right reason (Auto, os_supports_user=true, is_root=true) = User, expected System), then restored the real implementation and confirmed a byte-identical, zero-diff working tree.Docs
SPEC.md§9 gains the normative scope-resolution contract;runbooks/configuration.mddocuments--scope+ the elevated-install example.Version
Cargo.toml0.15.1→0.16.0(minor — backwards-compatible new capability; no existing behaviour changes for a caller that never passes--scope).Not merging — parent lane (dig_ecosystem#526) orchestrator gates + merges.
Review round 2 — the 6 blocking findings (5407e2f)
Fixed by adopting dig-node #123's contract verbatim wherever the two diverged, and by giving the
operations a way to express their own failure.
uninstall_scopesdiverged unelevatedAutosweeps BOTH scopes at either privilege, resolved one firstuninstall_scopes_sweeps_both_for_auto_at_either_privilegeuninstall_outcome→PermissionDenied(found-but-not-removed / indeterminate),NotFound(nothing removed)uninstall_outcome_fails_on_anything_less_than_a_complete_removalregisteredpolarity invertedfalseon success; detail inremoved_scopes+scopes[]uninstall_outcome_success_reports_not_registeredensure_privilege_for_scopeevaluated BEFORE any side effectensure_privilege_for_scope_refuses_only_unprivileged_system+install_deregisters_the_other_scope_before_creatingderegister_atneither probe-gated nor reportedRemovalMode::Sweptprobe-gating over a real per-OSquery_installed, result returned inresult.migration+ the summary; SPEC weakened to what a root-session probe can actually proveinstall_sweep_does_not_touch_a_scope_holding_nothing,install_reports_a_sweep_it_could_not_complete,a_swept_scope_with_an_unanswerable_probe_is_indeterminateresolve_scopetruth table diverged on Windows--scope usersystem, not a hard errorresolve_scope_covers_every_combinationBlast radius checked
src/service.rsonly, plus its SPEC §9 contract text. Callers traced with ripgrep across the repo:install/uninstall/start/stop/statusare called fromsrc/main.rsalone (theemitenvelope), and no consumer read the removed
failed_scopesfield or the privatederegister_at.The signature-visible surface (
ScopeChoice,resolve_scope,Outcome) is unchanged; everythingadded (
ServiceBackend,InstallPlan,RemovalMode,remove_registration,install_at_scope,uninstall_outcome,RelayServiceBackend) is new and private exceptScopeRemoval.Behaviour change a caller can see (intentional, and the point of findings 2/3):
dig-relay uninstallnow exits NON-ZERO when there was nothing to remove or when removal could not becompleted, where before it always exited 0. This matches dig-node #123. Any wrapper that runs an
unconditional
dig-relay uninstallas a pre-step must tolerate aNotFoundexit.Verification
service::unit tests, 264 lib tests, every integration suite green locally.RED — 7/7 mutations RED (including "sweep moved after create", which pins the ORDER rather than
the outcome).
30664544796 / 30664542276 / 30664544763 / 30664544817).
#[cfg(unix)]-gated scope tests: every decision is a pure function or runs against a mockbackend, so the whole table executes on a Windows dev host and on the Linux runner alike.
Version stays 0.16.0 — already the
feat-minor bump overmain's 0.15.1; this round ispre-merge review remediation inside the same unreleased feature.