Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

.nornir/uninstall-guide.md

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.

uninstall ALWAYS 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

  1. 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 ✓.
  2. 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.