UNINSTALL — guide
How to make your application's uninstall correct by declaring one thing. The law and the four incidents behind it: uninstall-design.md. Never done this before: uninstall-for-idiots.md.
uninstallALWAYS kills the DATA and ALWAYS keeps the SSH KEYS. Not a flag, not--purge. You do not implement this. You declare where your data lives and the ship does it.
skidbladnir = { version = "0.1", path = "../Skidbladnir" } # 0.1.13+
⚠ Read this first if you already ship an uninstall
Lifecycle::uninstall() now ERASES DATA. It used to remove only the unit and return (). As of 0.1.13 it removes the unit and erases the product's data (keeping the ssh key and token) and returns an UninstallReport.
expr?; still compiles, so most call sites are untouched. What changes is what happens on disk. If you were calling uninstall() to remove only a unit — stop.
install::uninstall_product's purge parameter is now ignored: the data dies either way.
Step 1 — declare ONE data root
One absolute path. The same path whoever owns the process. Not $HOME-derived, not CWD-derived, not different for a --user install.
use skidbladnir::service::ServiceSpec;
use skidbladnir::uninstall::DataRoot;
let root = DataRoot::new("/var/lib/dwarves")?; // validated right here
let spec = ServiceSpec::new("dwarves", "Dwarves symbol forge", "/usr/local/bin/dwarves")
.user("dwarves")
.working_dir("/var/lib/dwarves")
.data_root(&root);
DataRoot::new refuses — with a named reason, before your spec exists — a root that is relative, one component deep, a system directory, a real user's home, or anything that contains one:
DataRoot::new("/home/rickard")?; // Err: IsUserHome { user: "rickard" }
DataRoot::new("/")?; // Err: SystemDir
DataRoot::new("data")?; // Err: NotAbsolute
Dev-box override, if you want one — it feeds the same declaration, never a second path ladder:
let root = DataRoot::from_env("DWARVES_DATA", "/var/lib/dwarves")?;
Step 2 — declare your old roots, once
If your product has ever stored state anywhere else, say so, or those bytes become orphans no uninstall will ever find (~/.nornir was 5.3 GB on this box):
.legacy_data_root("/home/rickard/.dwarves")
.legacy_data_root("/home/dwarves/.dwarves")
They are swept by the same law — data dies, keys live — and each is named in the report. Keep them for at least one release, then drop them.
Step 3 — call uninstall
use skidbladnir::service::Lifecycle;
let report = Lifecycle::new(spec).uninstall()?;
println!("{}", report.summary());
That is the whole integration. Stop + disable + remove the unit, erase the data, keep the key and the token, report honestly.
Check what it WILL do, in your own test suite, without root
state_plan() is the resolved plan with nothing deleted:
let plan = Lifecycle::new(spec.clone()).state_plan();
assert_eq!(plan.root.as_deref(), Some(Path::new("/var/lib/dwarves")));
assert!(plan.erases_anything());
// and it must not move when the owner does:
let as_user = Lifecycle::new(spec.scope(Scope::User)).state_plan();
assert_eq!(as_user.root, plan.root);
That last assertion is the regression guard for the incident that started all of this. Write it.
Read the report — and never print ✓ over a no-op
let r = lifecycle.uninstall()?;
if r.is_noop() {
eprintln!("{}", r.summary()); // ✗ uninstall dwarves: NOTHING was removed …
std::process::exit(1);
}
println!("{}", r.summary());
| field | meaning |
erased: Vec<PathBuf> |
every path erased |
kept: Vec<PathBuf> |
every identity path deliberately kept (.ssh, token, …) |
refused: Vec<(PathBuf, String)> |
what the guard refused, and why |
legacy_swept: Vec<PathBuf> |
legacy roots that actually had something in them |
identity_wiped / account_removed |
the explicit opt-ins, if used |
notes: Vec<String> |
why a no-op is a no-op |
is_noop() |
nothing removed at all |
summary() |
the one line to print; renders ✗ … NOTHING was removed for a no-op |
What survives, exactly
uninstall::IDENTITY_NAMES:
.ssh · token · id_ed25519 · id_ed25519.pub · id_rsa · id_rsa.pub · authorized_keys · known_hosts
A directory that contains one of these is walked instead of swept — so .nornir/ loses its workspaces, its registry and its caches, and keeps exactly its token.
The opt-ins (you almost never want these)
use skidbladnir::uninstall::UninstallOptions;
lifecycle.uninstall_with(UninstallOptions::new().wipe_identity(true))?;
⚠ This destroys the ssh deploy key that is registered on your git remotes. After it, a reinstall cannot clone anything until a human generates a new key and registers it on every private repo. It is an outage, not a tidy-up.
UninstallOptions::new().remove_account(true).validate() // Err — REFUSED
UninstallOptions::new().wipe_identity(true).remove_account(true) // allowed
Removing the service account destroys the key it owns as a side effect — holger did exactly this and destroyed the key twice over. The combination without an explicit identity wipe is refused outright, and the error names the incident.
If you are not using Lifecycle
| you call | what to use |
systemd::remove_systemd(_scoped) |
already the law; now returns UninstallReport |
install::uninstall_product(.., purge) |
already the law; purge is ignored. uninstall_product_with for the opt-ins |
product::Installer::remove(spec) |
erases nothing — it has no Layout, so it has no data root. Move to remove_with_layout(spec, layout) |
systemd::purge_service_data_keep_keys(home, user) |
the low-level sweep. It takes home verbatim, so it cannot save you from passing a path your own resolver never uses. Prefer Lifecycle::uninstall() |
The two rules to remember
- One data root, declared, absolute, owner-independent. If your resolver and your eraser can disagree about where state lives, one of them is lying — and you will find out by wiping nothing and printing ✓.
- Test with the root inside a real
$HOME. Every fixture that used a bare tempdir missed the bug, because a tempdir is the one place the delete guard does not behave the way production does.