M2 plan: engine and safe modules (v0.2.0)
M2 adds plan, apply, rollback and history, the engine behind them, and two modules that cannot lock anyone out. It is the subset of the architecture that those two modules need; the rest waits for the milestone that needs it (see Deferred).
Decisions
Section titled “Decisions”Modules: kernel.sysctl and updates.auto
Section titled “Modules: kernel.sysctl and updates.auto”| Module | Changes | Fixes | Why first |
|---|---|---|---|
kernel.sysctl |
/etc/sysctl.d/90-vps-ready.conf and the running values under /proc/sys |
the 14 KRN-* and NET-* controls |
Files and /proc/sys only: no commands, no network, no packages, so it exercises the whole engine with the smallest surface. Not applicable in a container (facts.sysctl is None) |
updates.auto |
installs unattended-upgrades if missing; writes /etc/apt/apt.conf.d/52vps-ready |
UPD-001 |
Adds the one command-based resource (a package) and a validate step. Debian and Ubuntu only. Testable in the existing containers |
Not in M2:
base.time(switching timesyncd to chrony is a package swap; changing the time zone changes cron and log times users see).base.swap(allocates disk and edits/etc/fstab, where a bad line can stop the next boot; Btrfs and containers need special cases).- journald settings (needs a restart of
systemd-journaldand has no control yet).
They can follow in a v0.2.x once the engine is proven.
kernel.sysctl details:
- It manages exactly the keys of the controls marked
fix = "kernel.sysctl", with these values:kptr_restrict=1,dmesg_restrict=1,unprivileged_bpf_disabled=2,yama.ptrace_scope=1,randomize_va_space=2,perf_event_paranoid=2;fs.protected_{symlinks,hardlinks,fifos,regular}=1,fs.suid_dumpable=0;tcp_syncookies=1,conf.all.accept_source_route=0,icmp_echo_ignore_broadcasts=1.
kptr_restrict=1, not the baseline’s2:2hides/proc/kallsymsfrom root too and breaksperfandbpftrace. Ubuntu ships1.- A key goes into the drop-in only if its running value fails the control or the existing drop-in already lists it. Without this, a distro’s stricter running value (Ubuntu’s
perf_event_paranoid=4) would be lowered at the next boot. Keeping keys the drop-in already lists keeps a second run a no-op. - Skipped keys:
- a key the kernel does not have (e.g. no Yama) is skipped, and dropped from the drop-in if it is listed there;
- a key that could not be read (non-root
plan) is skipped with a note in the plan. It is never added to the drop-in, but stays there if it is already listed, so a non-rootplandoes not show a removal.
- With no key to write, the module plans nothing, even if the drop-in exists.
- Values the kernel will not take back at runtime are not used:
unprivileged_bpf_disabled=1andptrace_scope=3cannot be lowered until a reboot, so rollback could not restore them. A unit test lists these values and fails if the module emits one. - It never writes
ip_forward,accept_raorrp_filter(see the baseline’s “Never set”). A unit test enforces this. - Known limits:
- A file that sorts after
90-insysctl.d(or Debian 12’s/etc/sysctl.confvia99-sysctl.conf) can override the drop-in at boot. M2 checks the running values only, so such a key shows up again in the next plan. - A runtime value written for a key that is not in the drop-in reverts at reboot and shows up again in the next plan.
- A key lowered at runtime below the kernel’s compiled-in default (Ubuntu’s
perf_event_paranoid=4) is persisted at the module’s value, which passes its control; rollback removes the line and the next boot returns to the default. The compiled-in default cannot be read. - Another tool can change a key after apply in the same boot.
rollbackthen refuses that key (see Rollback). A key set at a later boot (apport setsfs.suid_dumpable=2) is left as the boot set it.
- A file that sorts after
updates.auto details:
- The drop-in sets
APT::Periodic::Enable "1",APT::Periodic::Update-Package-Lists "1"andAPT::Periodic::Unattended-Upgrade "1".Enabledefaults to on; the drop-in sets it so that an earlier file cannot turn every job off.- It does not set
Unattended-Upgrade::Automatic-Reboot:falseis already the default, and setting it would override an admin’s own choice.UPD-001does not check it. - apt reads
apt.conf.din name order, so52overrides the package’s20auto-upgrades.
- Installing the package:
- It runs
apt-get update, thenapt-get install -y --no-install-recommends, the way Ansible’supdate_cachedoes. A fresh image’s package lists can be missing or stale. - Both commands run under
DEBIAN_FRONTEND=noninteractivewith-o DPkg::Lock::Timeout=300(cloud-init may still hold the dpkg lock on first boot) and a 10-minute command timeout.
- It runs
- If systemd has apt’s timers disabled or masked, the module does not touch them: the plan says so, and
UPD-001keeps failing. Turning timers back on needs aServiceresource, which comes in M3.
Resources: a closed enum, not trait objects
Section titled “Resources: a closed enum, not trait objects”pub enum Resource { File(File), // whole-file content we own: path, bytes, mode, uid, gid, validate: Option<Validate> Sysctl(Sysctl), // key, value; reads and writes /proc/sys/<key with dots as slashes> Package(Package), // apt package name (Debian family only in M2)}
impl Resource { pub fn id(&self) -> String; // "file:/etc/...", "sysctl:kernel.kptr_restrict", "package:unattended-upgrades" pub fn reversibility(&self) -> Reversibility; pub fn diff(&self, sys: Sys) -> Result<Option<Change>, ResourceError>; // None = already in the desired state pub fn snapshot(&self, sys: Sys) -> Result<Backup, ResourceError>; pub fn apply(&self, sys: Sys) -> Result<(), ResourceError>;}
pub enum Backup { // serde: written to the journal File { path: PathBuf, n: u32, original: Option<FileCopy> }, // bytes in files/<n>.before|after; None = did not exist Sysctl { key: String, value: String, applied: String, boot_id: Option<String> }, // boot_id: /proc/sys/kernel/random/boot_id at snapshot Package { name: String, was_installed: bool },}pub struct FileCopy { mode: u32, uid: u32, gid: u32 }pub fn restore(sys: Sys, txn_dir: &Path, backup: &Backup) -> Result<(), ResourceError>;Why:
- Restore needs only the journal.
rollback --lastruns in a later process that may be a newer version, so restoring must not rebuild modules or depend on what a module would plan today. Restore is a function ofBackup. - The set of kinds is closed (the architecture already lists them), so an enum gives exhaustive
match, freeserdefor the journal andplan --json, and notypetag-style dependency. DropInis aFile: whole-file content at a path we own.LineInFileandCommandare not added (nothing in M2 needs them).Reversibilitystays:FileandSysctlareReversible.PackageisPartial, because rollback leaves an installed package installed (removing it could take dependencies with it) and the plan says so.Irreversiblehas no user untilbase.update.
File:
File::difflstats the target. At plan time it refuses:- a symlink or any other non-regular file;
- a missing parent directory.
- M2
Fileresources only target paths that vps-ready owns, and creates: its two drop-ins. That is why snapshots skip xattrs, ACLs and SELinux labels; a file vps-ready created has none of its own to lose. - Restoring a file goes through
write_atomicwith the journaled mode, uid and gid. Restoring “did not exist” isremovefollowed by an fsync of the directory.
Change is what the plan shows: { id, summary: String, diff: Option<String>, reversibility }. diff is a unified diff (similar) for files; everything else is the one-line summary.
Sys is #[derive(Clone, Copy)] pub struct Sys<'a> { pub fs: &'a dyn Fs, pub runner: &'a dyn Runner }.
Modules
Section titled “Modules”pub trait Module { fn id(&self) -> &'static str; // "kernel.sysctl" fn title(&self) -> &'static str; fn why(&self) -> &'static str; fn applicable(&self, facts: &Facts) -> Applicability; // Applicable | NotApplicable { reason } fn plan(&self, facts: &Facts, sys: Sys) -> Result<Vec<Resource>, ModuleError>; // may read, never write}A registry modules::all() -> &[&dyn Module] lists them in a fixed order. No params, requires, conflicts, validate or verify methods in M2:
- Parameters arrive with profiles in M3.
- Ordering and conflicts are not needed with two independent modules.
- Validation is per file (
File::validate). - Verification is generic (below).
Controls gain an optional fix = "<module id>" field in the catalog TOML. audit prints fix: sudo vps-ready apply --module <id> under a failed control that has one.
Engine flow (M2)
Section titled “Engine flow (M2)”apply:
- Take the lock.
- Refuse if any transaction is unfinished (see State layout).
- Collect facts and plan. An empty plan prints “nothing to do”, creates no transaction directory and exits 0.
- Print the plan and ask
Apply N changes? [y/N]. A non-TTY without--yesis an error. - Re-run
diff()on every resource. If the set of changes differs from what was shown, abort with exit 2. - Create the transaction and run it:
create txn/<id>/ and journal.jsonl; fsync journal.jsonl, txn/<id>/ and txn/journal "begin"for each resource with a change, in plan order: backup = snapshot() File: write files/<n>.before (if the file existed) and files/<n>.after, fsync them, then run validate on files/<n>.after if set journal "backup" (fsync) apply() journal "applied" (fsync)verify: diff() of every resource is None againon any error or a failed verify: rollback (below) with reason apply-failed or verify-failedelse: journal "state applied"then: re-run the audit for controls whose fix is in the plan and print it (informational, never triggers rollback)The backup line is fsynced before the resource is touched, and .before before that line. After a crash, every resource that may have changed has a backup on disk.
There is no reload step: neither module needs one (/proc/sys is live, and apt reads its config on every run). Reload arrives with sshd in M3.
Atomic write (Fs::write_atomic):
- Refuse if the target is a symlink.
- Create
.<name>.vps-ready-tmpin the same directory (O_EXCL). - Write;
fchmod/fchownto the requested mode and owner;fsync. renameover the target;fsyncthe directory.
All with std (File::sync_all, std::os::unix::fs::fchown). A crash can leave the temp file; the next write_atomic of that path removes it first.
Lock:
/var/lib/vps-ready/lock, taken withFile::try_lock()(std since Rust 1.89; the MSRV is 1.98).- It does not wait: a second
applyorrollbackfails at once with “another vps-ready is running”. - The kernel drops the lock when the process exits, including on a crash. Rust opens files with
O_CLOEXEC, so a child process such asapt-getcannot keep holding it. plan,historyandauditdo not lock.
State layout (M2)
Section titled “State layout (M2)”/var/lib/vps-ready/ 0700 root:root lock txn/<id>/ journal.jsonl append-only, one JSON object per line, fsync per line files/<n>.before original bytes of the n-th backed-up file (absent if it did not exist) files/<n>.after bytes vps-ready wrote (also what validate runs on)- The state directory is checked with
lstat. vps-ready refuses to run if it is a symlink, not owned by root, or writable by group or others. <id>is the UTC start time,20261023T114246Z. The lock rules out two at once; if the directory exists,-2,-3is appended. Sorting by name is sorting by time. This needs a small days-to-date helper (with a unit test), not theulidcrate.files/<n>is numbered, not an escaped path: the journal mapsnto the path, so no escaping scheme is needed.
Journal records are a #[serde(tag = "event")] enum, and every line has at in Unix seconds. Line order is the sequence:
{"at":1792745000,"event":"begin","version":"0.2.0","modules":["kernel.sysctl"]}{"at":1792745000,"event":"backup","resource":"file:/etc/sysctl.d/90-vps-ready.conf","backup":{"kind":"file","path":"/etc/sysctl.d/90-vps-ready.conf","n":1,"original":null}}{"at":1792745000,"event":"applied","resource":"file:/etc/sysctl.d/90-vps-ready.conf"}{"at":1792745000,"event":"backup","resource":"sysctl:kernel.kptr_restrict","backup":{"kind":"sysctl","key":"kernel.kptr_restrict","value":"0","applied":"1"}}{"at":1792745000,"event":"applied","resource":"sysctl:kernel.kptr_restrict"}{"at":1792745001,"event":"state","state":"applied"}A rollback of that transaction appends:
{"at":1792748600,"event":"rollback","reason":"manual"}{"at":1792748600,"event":"restored","resource":"sysctl:kernel.kptr_restrict"}{"at":1792748600,"event":"restored","resource":"file:/etc/sysctl.d/90-vps-ready.conf"}{"at":1792748600,"event":"state","state":"rolled-back","reason":"manual"}Reading the journal:
- Unknown fields are ignored (an older binary can read a newer journal). An unknown event kind is an error.
- A final line without a newline is a torn write from a crash: it is ignored, and removed from the file before a resumed transaction appends to it. Any other unparseable line is an error.
Transaction states:
- State is the last
staterecord, orplannedif there is none. - M2 uses the architecture’s
TxnStatewithout the dead-man variants:Planned,Applied,RolledBack { reason: ApplyFailed | VerifyFailed | Manual }. Transitions go through one(state, event)match. M3 addsPendingConfirm,ConfirmedandDeadmanTimeout. - Unfinished means the last journal line is not a
staterecord. That covers a crash during apply and a crash during a rollback. While any transaction is unfinished:applyrefuses and says to runvps-ready rollback --txn <id>. It never rolls back on its own.rollback --lastpicks the unfinished transaction and resumes it.
Rollback
Section titled “Rollback”rollback --lastpicks the unfinished transaction if there is one, otherwise the newest in stateapplied.rollback --txn <id>picks that one. It is an error if the transaction is already rolled back.- Order: a
rollbackline with the reason (fsynced) comes before the first restore, so the transaction is unfinished from then on. Backups are then restored in reverse journal order, with arestoredline (fsynced) after each, thenstate rolled-back. A rerun after a crash skips backups that already have arestoredline, and ends with the reason from therollbackline (manualif there is none). - A plan that changes the same resource twice is refused before the transaction starts, since
restoredlines name resources by ID. - Safety check first, on every backup not yet restored:
- File: the current content must equal
.afteror.before. If the file did not exist before, it must be absent or equal.after. Mode and owner are not compared; restore sets them back from the journal. - Sysctl: within the boot the backup was taken in, the current value must equal the applied or the original value. In a later boot the runtime value is neither checked nor restored (it already comes from the boot configuration); the key is reported and the next boot settles it after the drop-in is removed.
- Both values are allowed because a crash may have happened just before or after
apply().
- File: the current content must equal
- If the check fails: nothing is restored. The command lists each resource that changed, the transaction’s
files/paths, and the by-hand step for each, e.g. “copyfiles/1.beforeto/etc/...” or “sysctl -w fs.suid_dumpable=0”. There is no--forcein M2. This also stops an older transaction from undoing a newer one on the same file. Packagerestore is a no-op; the plan already labeled itpartial.- An
apt-gettimeout killsapt-get, notdpkg, so the dpkg database may then needdpkg --configure -a, which rollback does not run.
CLI and plan rendering
Section titled “CLI and plan rendering”vps-ready plan [--module ID]... [--json]vps-ready apply [--module ID]... [--yes]vps-ready rollback (--last | --txn ID)vps-ready history [--json]- No
--modulemeans every applicable module.plan --module Xfor a module that does not apply shows it as not applicable.apply --module Xfor such a module is an error that gives the reason. apply,rollbackandhistoryrequire root (the existing/proc/self/statuscheck); the state directory is0700 root:root.historyon a host without a state directory prints “No transactions.” and does not create it.plandoes not; as non-root it skips the sysctl keys it cannot read and says so.historyprints one line per transaction (ID, state, modules, number of changes), newest first.
Exit codes:
| Code | plan |
apply |
rollback |
|---|---|---|---|
| 0 | the plan was printed | applied, or nothing to do | rolled back |
| 1 | failed and rolled back cleanly | ||
| 2 | error | error: usage, lock busy, not root, unfinished transaction, module not applicable, plan changed after the prompt | error, including refused by the safety check (nothing restored) |
| 3 | failed, and the rollback did not complete | did not complete |
Declining the prompt changes nothing and exits 0.
When exit code 3 is returned, the message names the transaction directory and the files left to restore by hand.
Text plan (an insta snapshot pins it); + create, ~ change, colors only on a TTY:
kernel.sysctl: Harden kernel settings (fixes KRN-001, KRN-003, NET-001) + /etc/sysctl.d/90-vps-ready.conf (0644 0:0) reversible @@ -0,0 +1,3 @@ +kernel.kptr_restrict = 1 +kernel.unprivileged_bpf_disabled = 2 +net.ipv4.tcp_syncookies = 1 ~ kernel.kptr_restrict 0 -> 1 reversible ~ kernel.unprivileged_bpf_disabled 0 -> 2 reversible ~ net.ipv4.tcp_syncookies 0 -> 1 reversibleupdates.auto: not applicable (apt not found)
4 changes in 1 module. Run `sudo vps-ready apply` to make them.The reversibility column is an exhaustive match, with no _ => arm. plan --json serializes the same Plan struct.
Idempotency and rollback tests
Section titled “Idempotency and rollback tests”Fixtures. Today’s testdata/hosts are container captures: no /proc/sys values (facts.sysctl is None) and no unattended-upgrades. So module tests do not use them as hosts:
- They build
Factsby hand, asroot_hint_covers_root_only_kernel_settingsinclialready does. - They write to
RealFson a tempdir, which exercises the real write path, including/proc/sys/...as plain files. - Package state comes from a
FakeRunneransweringdpkg-query. - Captures from real VMs come with M3’s VM tests.
Tests:
- Per module: an
instasnapshot of the first plan; apply → the next plan is empty; apply → rollback → the tempdir tree (contents and modes) equals the original. - Audit and fix agree: after apply, re-collect the sysctl facts from the tempdir and audit. Every control whose
fixis the module passes. A catalog test checks that everyfixnames a registered module and that each sysctl value is in its control’sone_of. - Property test (
kernel.sysctl): random starting values for the 14 keys and a random pre-existing drop-in; apply → the next plan is empty, and apply → rollback restores. - Failure injection (engine, on
FakeFs):FakeFscan fail the n-th write, rename, remove or journal append. For every n:- if the failure is in apply, the engine ends
rolled-back(apply-failed)with the tree restored; - if it is in a rollback, the exit code is 3 and a rerun of
rollbackfinishes the job.
- if the failure is in apply, the engine ends
- Crash simulation: truncate the journal after every line and in the middle of every line, then check that
applyrefuses androllback --lastrestores the original tree. - Safety check: rollback refuses a changed file and a changed sysctl. Rolling back transaction 1 after transaction 2 changed the same file is refused.
- Containers (layer 2,
updates.autoonly):apply --yes→plan --jsonhas no changes →UPD-001passes →rollback --last→ the managed files match a copy taken before. The package stays installed by design, so this compares the managed files, not a full/etcdiff. kernel.sysctlcannot run in a container. Until the M3 VM tests, its PR records a manual run on a throwaway VM (apply, reboot, audit, rollback).
PR sequence
Section titled “PR sequence”Each PR builds on the previous one and leaves main releasable. Every user-visible command arrives in PR 7, so earlier PRs change no behavior.
-
docs: plan M2(this document).- Out: code.
-
feat(sys): writes through Fs- Goal:
Fsgainswrite_atomic(path, bytes, owner: FileOwner{mode,uid,gid}),write(truncate in place, for/proc/sysonly),append_sync(for the journal),remove,create_dir_all(path, mode), andsync_dir. Add theSysstruct. FakeFs: becomes mutable behind aMutex, keeps per-file metadata, and can fail the n-th write, rename, remove or append. This is for the engine’s failure tests; module tests useRealFson a tempdir.- Files:
sys/mod.rs,sys/fake.rs. - Tests:
RealFson a tempdir (content, mode, overwrite leaves no temp file, symlink target refused, a failed write leaves the original); the same assertions againstFakeFs. - Out: lock, SELinux contexts, xattrs, owner changes in tests (non-root CI cannot
chown; the call is covered onFakeFsand in containers).
- Goal:
-
feat(resource): File and Sysctl resources- Goal:
src/resource/withResource,Change,Reversibility,Backup,restore,ResourceError; theFileandSysctlkinds; the plan-time refusals forFile; unified diffs viasimilar(new dependency). - Tests: diff none/some; symlink, non-regular file and missing parent refused; snapshot → apply → restore round trip (including a file that did not exist);
Backupserde round trip. - Out:
Package, validate, engine.
- Goal:
-
feat(engine): state directory, lock and journal- Goal: state dir creation and
lstatcheck; the lock; transaction IDs; the journal writer and reader (fsync per line, torn last line, unknown fields and events);TxnStatetransitions; “unfinished” detection. - Tests: the permission check refuses each bad case; lock contention on a tempdir (two
try_locks); the date helper; journal round trip, a torn final line ignored and a bad middle line rejected; illegal state transitions. - Out: apply, rollback.
- Goal: state dir creation and
-
feat(engine): apply, rollback and history- Goal:
planover&[&dyn Module](a test-only module is enough here);applywith the durability ordering above, verify and automatic rollback;rollbackwith the safety check and resume;history. - Tests: failure injection, crash simulation and safety-check tests from above.
- Out: real modules, CLI.
- Goal:
-
feat(modules): kernel.sysctl- Goal: the
Moduletrait, the registry,kernel.sysctl,fix = "kernel.sysctl"on the 14 controls,fixinauditoutput. - Tests: idempotency and rollback on a tempdir; the
instaplan snapshot; audit and fix agree; neverip_forward/accept_ra/rp_filter; no runtime-sticky values; absent and unreadable keys skipped; the property test. - Out: the CLI to run it (next PR).
- Goal: the
-
feat(cli): plan, apply, rollback and history- Goal: the four commands, the text and JSON plan, the prompt and the re-check after it, the root check, the lock, exit codes.
- Tests:
instasnapshots of the rendered plan and ofhistory;assert_cmd:applywithout root exits 2 and changes nothing;plan --jsonparses;rollbackwithout--lastor--txnis a usage error.
- Manual: a throwaway VM run of
kernel.sysctlnoted in the PR. - Out:
updates.auto.
-
feat(resource): Package and file validation- Goal: an apt-only
Packageresource (dpkg-queryfor state;apt-get updatethenapt-get install);File::validate, which runs a command onfiles/<n>.afterbefore the real file is written. - Tests:
FakeRunnersees exactly the expected argv and environment; a validate failure aborts before any write;Packagerestore is a no-op. - Out: other package managers, the platform layer.
- Goal: an apt-only
-
feat(modules): updates.auto- Goal: the module (validate with
apt-config -c <files/n.after> dump),fix = "updates.auto"onUPD-001, and the container cycle in thecontainersCI job. - Tests: plans with the package missing and present; the drop-in contents pinned by
insta; the container cycle. - Out: timers.
- Goal: the module (validate with
-
chore: release v0.2.0- Goal: README command table and “today” notes,
testing.md(what layer 2 now covers),CONTRIBUTING.md(adding a module), version bump. - Out: anything new.
- Goal: README command table and “today” notes,
Deferred from the architecture
Section titled “Deferred from the architecture”The architecture describes v1. For M2 these parts are not needed or are simpler:
| Architecture | M2 | When |
|---|---|---|
Platform layer (PkgManager, ServiceManager, Firewall, Sshd) |
Package calls apt directly; modules check facts.os |
When a second package manager or init system is supported (M6). Sshd and Firewall in M3 |
Profiles (TOML, extends, schemars JSON Schema), /etc/vps-ready/config.toml |
--module selects modules; no parameters |
M3, when user.admin needs keys; JSON Schema when an editor or the wizard needs it |
Module params, requires/conflicts + topological sort, validate, verify, needs_deadman |
Fixed registry order; per-file validate; generic verify | M3 (user.admin before ssh) |
plan --out plan.json → apply plan.json with hash checks |
apply plans under the lock, shows the plan, and re-checks it after the prompt |
When cloud-init export needs it (M5), if ever |
askama templates |
Two small generated files, built with format! and pinned by insta |
M3, with the sshd drop-in |
meta.json, plan.json, diff.patch, state.json, /var/log/vps-ready/ |
The journal is the record; logs go to stderr | state.json with drift (M6) |
ULID transaction IDs, rustix for fsync/flock |
UTC timestamp IDs; std has sync_all, fchown and File::try_lock |
Not needed |
| SELinux context in snapshots | Mode, owner, bytes, on files vps-ready owns | With Rocky/Alma (M6) |
Service, User, AuthorizedKeys, FirewallRuleset resources; reload step |
Not needed by M2 modules | M3 |
Decided with the owner
Section titled “Decided with the owner”- Two modules in M2 (
kernel.sysctl,updates.auto).base.time,base.swapand journald come after v0.2.0. kptr_restrict=1, not2.- Rollback leaves a package that vps-ready installed in place, and the plan labels it
partial. - No profile file in M2;
--moduleselects modules. - Exit code 3 when a rollback is incomplete.
applyrefuses while any transaction is unfinished, including a half-rolled-back one.updates.autoalways runsapt-get updatebefore installing, and its drop-in does not setAutomatic-Reboot.
Feedback
Questions, ideas or problems with vps-ready.