Skip to content

Commit 7d6ca7e

Browse files
committed
convergence kept the declared list current and let everything else rot
On this laptop, right now: 13 outdated brew formulae, ten of them casks, a stale org.gnome.Sdk runtime, and convergence running daily the whole time. None of it was a bug in the sense of something not working. Both syncs upgraded exactly what they were told to upgrade: xargs -a "$declared" "$brew" upgrade flatpak install ... --or-update <declared> Scoping the upgrade to the declared list looked like the same restraint that scopes *removal* to the declared list, and it is not the same thing at all. Membership and currency are different questions and only the first belongs to the declaration. Convergence decides what exists. Nothing decided how old it was allowed to get. That mattered more after `73771ab`. Taking back only what convergence installed is what made it safe to ship a store, and shipping a store makes "install an undeclared app" a path kuma actively encourages. The combination shipped a way to install a browser that nothing on the machine would ever update. It hadn't bitten yet only because there are no undeclared flatpaks here yet. So both upgrades lose their argument list. Bare `brew upgrade` also takes casks, which kuma cannot otherwise see at all: `inspect.rs` reads the Cellar, and every one of those ten fonts lives in the Caskroom, so capture has never listed them and never will until that is fixed separately. `flatpak update --system` covers undeclared apps and, unlike the declared install, runtimes that no declared app happens to demand. This takes no authority it did not have. Updating is not installing: membership still comes from the declaration, removal still only reaches what convergence installed, and both ecosystems keep their own hold for anyone who wants to stay put, `flatpak mask` and `brew pin`. Ordering is load-bearing and the tests pin it. Both scripts run under `set -euo pipefail`, so an upgrade placed before the state file is written would, on one flaky-network run, abort with authority tracking still describing the previous declaration and strand a removal until some later run happened to have DNS. The flatpak prune moves after the update so runtimes the update orphans go in the same pass. The test needed hardening before it was worth anything, and sabotage is what showed it: searching for the command and slicing from the hit hides everything to its left, so a re-scoped `xargs -a "$declared" flatpak update` read as unscoped, which is to say it passed exactly when the property it guards had been undone. It matches whole lines now. Three sabotages verified: re-scoping either upgrade, and pruning before the update. Docs say the rule plainly in concepts.md and both examples, since "being undeclared costs reproducibility, never survival" was true and incomplete. Also fixes four em-dashes that survived the repo-wide purge in files written after it. 135 tests, shellcheck clean on both generated scripts, fmt and clippy clean.
1 parent 912f5a1 commit 7d6ca7e

6 files changed

Lines changed: 108 additions & 9 deletions

File tree

‎docs/concepts.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,15 @@ people actually have is one file.
3333
Convergence takes back only what it installed. Boxes above was declared
3434
once and no longer is, so it is on the removal list; Bazaar you installed
3535
yourself, so it is undeclared but in no danger. Install apps from a store
36-
if you like — being undeclared costs reproducibility, never survival.
36+
if you like: being undeclared costs reproducibility, never survival.
37+
38+
Membership and currency are separate questions, and only the first belongs
39+
to the declaration. It decides what exists. Keeping software current is
40+
kuma's job regardless of who installed it, so a store-installed app, an
41+
ad-hoc `brew install`, and a flatpak runtime are updated on the same
42+
schedule as a declared app. Nothing is left to rot for the crime of not
43+
being written down. Each ecosystem's own hold still works if you want to
44+
stay on a version: `flatpak mask` and `brew pin`.
3745

3846
`kuma capture` prints the proposal and writes nothing until `--yes`; naming
3947
items captures only those. You review a diff of your *declaration*, not of

‎examples/cosmic.toml.example‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ rpm = ["fish", "git-core", "git-core-doc", "distrobox"]
2424

2525
# Converged on boot and daily, and convergence only takes back what it
2626
# installed: an app you install through a store stays yours until you
27-
# decide, and `kuma capture` offers to write it down here.
27+
# decide, and `kuma capture` offers to write it down here. Updates are
28+
# not scoped that way: everything installed is kept current.
2829
flatpak = [
2930
"org.mozilla.firefox",
3031
"org.gnome.Loupe",

‎examples/kuma.toml.example‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@
33
schema_version = 1
44

55
[system]
6-
# Unset — the default — kuma composes its own base from Fedora's package
6+
# Unset (the default) kuma composes its own base from Fedora's package
77
# repos: Fedora's minimal bootc core (kernel, systemd, bootc, dnf) plus
88
# hardware enablement, and none of the general-purpose weight
99
# fedora-bootc carries. Set it to any bootc image reference to build
10-
# FROM that instead — the escape hatch for debugging against
10+
# FROM that instead: the escape hatch for debugging against
1111
# fedora-bootc, a mirror, or a test tag.
1212
# base = "quay.io/fedora/fedora-bootc:44"
1313

@@ -56,7 +56,8 @@ rpm = ["fish", "git-core", "git-core-doc", "distrobox", "tailscale"]
5656
# Flathub apps, converged on boot and daily: additions install, removals
5757
# uninstall. Convergence only ever takes back what it installed, so an
5858
# app you install yourself stays yours, from a store or the command line,
59-
# and `kuma capture` offers to write it down here.
59+
# and `kuma capture` offers to write it down here. Updates are not scoped
60+
# that way: everything installed is kept current, declared or not.
6061
flatpak = [
6162
"org.mozilla.firefox",
6263
"org.gnome.Loupe",

‎examples/minimal.toml.example‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ schema_version = 1
99

1010
[system]
1111
# Explicit on purpose, twice over: it keeps this the fast compose-free
12-
# build the smoke tests lean on, and it demonstrates the escape hatch —
12+
# build the smoke tests lean on, and it demonstrates the escape hatch:
1313
# any bootc image can be the base when you name one. Omit the key and
1414
# kuma composes its own base instead (the default everywhere else).
1515
base = "quay.io/fedora/fedora-bootc:44"

‎src/containerfile.rs‎

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,20 @@ fi
503503
///
504504
/// The uninstall tolerates failure because the state file can name an
505505
/// app the owner already removed by hand, which is not an error.
506+
///
507+
/// Membership and currency are different questions, and only the first
508+
/// belongs to the declaration. Convergence decides what exists; the
509+
/// update decides how old it is, and it is deliberately unscoped. An app
510+
/// the owner installed through the store is theirs to keep, but leaving
511+
/// it to rot is not respect, it is an unpatched browser. The same call
512+
/// covers runtimes, which the declared install reaches only when a
513+
/// declared app happens to demand a newer one.
514+
///
515+
/// Ordering is load-bearing. The state file is written before the
516+
/// update, so a failed update (a flaky network, one broken remote)
517+
/// cannot leave authority tracking behind and silently strand a removal
518+
/// until the next successful run. Pruning comes last so the runtimes the
519+
/// update orphans go in the same pass.
506520
const FLATPAK_SYNC_SCRIPT: &str = r#"#!/usr/bin/bash
507521
set -euo pipefail
508522
declared=/usr/lib/kuma/flatpaks
@@ -514,8 +528,9 @@ while read -r app; do
514528
grep -qxF "$app" "$declared" \
515529
|| flatpak uninstall --system --assumeyes --noninteractive "$app" || true
516530
done < "$state"
517-
flatpak uninstall --system --unused --assumeyes --noninteractive
518531
cp "$declared" "$state"
532+
flatpak update --system --assumeyes --noninteractive
533+
flatpak uninstall --system --unused --assumeyes --noninteractive
519534
"#;
520535

521536
const FLATHUB_URL: &str = "https://dl.flathub.org/repo/flathub.flatpakrepo";
@@ -1108,6 +1123,15 @@ WantedBy=multi-user.target
11081123
/// and only ever-declared formulae are removal candidates. Ad-hoc `brew
11091124
/// install` is untouched. The flatpak sync learned the same trick after
11101125
/// its scope proxy turned out to be one a store could break.
1126+
///
1127+
/// The upgrade at the end is unscoped on purpose, and it is the one
1128+
/// thing here that reaches past the declaration. Ad-hoc formulae stay
1129+
/// the owner's to keep or remove; they were simply never getting
1130+
/// updated, because the upgrade used to name the declared list. Bare
1131+
/// `brew upgrade` also covers casks, which nothing else in kuma can even
1132+
/// see. It runs after the state file is written so a failure cannot
1133+
/// strand authority tracking, and needs no `brew update` first because
1134+
/// brew auto-updates its taps before upgrading.
11111135
const BREW_SYNC_SCRIPT: &str = r#"#!/usr/bin/bash
11121136
set -euo pipefail
11131137
brew=/home/linuxbrew/.linuxbrew/bin/brew
@@ -1117,14 +1141,14 @@ state=/home/linuxbrew/.linuxbrew/.kuma-brews
11171141
[ -f "$state" ] || : > "$state"
11181142
if [ -s "$declared" ]; then
11191143
xargs -a "$declared" "$brew" install
1120-
xargs -a "$declared" "$brew" upgrade
11211144
fi
11221145
while read -r formula; do
11231146
grep -qxF "$formula" "$declared" && continue
11241147
"$brew" uninstall "$formula" || true
11251148
done < "$state"
11261149
"$brew" autoremove
11271150
cp "$declared" "$state"
1151+
"$brew" upgrade
11281152
"#;
11291153

11301154
/// brew refuses to run as root; uid 1000 owns the prefix (brew's
@@ -2119,6 +2143,70 @@ mod tests {
21192143
assert!(dir.path().join("kuma-flatpak-sync").exists());
21202144
}
21212145

2146+
/// Both syncs keep software current without claiming it. Scoping the
2147+
/// upgrade to the declared list is what left 13 outdated formulae,
2148+
/// every store-installed app, and a stale runtime on a real machine
2149+
/// whose convergence had been running daily the whole time. So the
2150+
/// upgrade must name nothing: the moment it takes an argument it has
2151+
/// a scope again, and everything outside that scope rots.
2152+
///
2153+
/// The ordering assertions are the ones worth keeping. Both scripts
2154+
/// run under `set -euo pipefail`, so an upgrade placed before the
2155+
/// state file is written would, on any flaky-network run, abort the
2156+
/// script with authority tracking still describing the previous
2157+
/// declaration, stranding a removal until some later run happened to
2158+
/// have working DNS.
2159+
#[test]
2160+
fn both_syncs_update_what_they_do_not_own() {
2161+
// Match whole lines, not offsets into them. Searching for the
2162+
// command and slicing from the hit hides everything to its left,
2163+
// so a re-scoped `xargs -a "$declared" flatpak update ...` reads
2164+
// as unscoped: the assertion holds exactly when the thing it
2165+
// guards has been undone.
2166+
let line_of = |script: &'static str, needle: &str| -> (usize, &'static str) {
2167+
let mut at = 0;
2168+
for line in script.lines() {
2169+
if line.contains(needle) {
2170+
return (at, line);
2171+
}
2172+
at += line.len() + 1;
2173+
}
2174+
panic!("{needle} is gone from the sync script");
2175+
};
2176+
2177+
let (flatpak_update, line) = line_of(FLATPAK_SYNC_SCRIPT, "flatpak update");
2178+
assert!(
2179+
!line.contains("declared") && !line.contains("xargs"),
2180+
"the update must be unscoped, or undeclared apps and runtimes never move: {line}"
2181+
);
2182+
let (flatpak_state, _) = line_of(FLATPAK_SYNC_SCRIPT, r#"cp "$declared" "$state""#);
2183+
assert!(
2184+
flatpak_state < flatpak_update,
2185+
"authority must be recorded before the update, so a failed update cannot strand it"
2186+
);
2187+
// The prune runs last so runtimes the update orphans go with it.
2188+
let (flatpak_prune, _) = line_of(FLATPAK_SYNC_SCRIPT, "--unused");
2189+
assert!(
2190+
flatpak_prune > flatpak_update,
2191+
"unused runtimes are pruned after the update, not before"
2192+
);
2193+
2194+
let (brew_upgrade, line) = line_of(BREW_SYNC_SCRIPT, r#""$brew" upgrade"#);
2195+
assert_eq!(
2196+
line.trim(),
2197+
r#""$brew" upgrade"#,
2198+
"bare upgrade takes formulae and casks alike; an argument list takes neither"
2199+
);
2200+
let (brew_state, _) = line_of(BREW_SYNC_SCRIPT, r#"cp "$declared" "$state""#);
2201+
assert!(brew_state < brew_upgrade, "same ordering rule as the flatpak sync");
2202+
2203+
// Membership is still the declaration's alone. Widening the
2204+
// upgrade must not widen the install: only the declared list is
2205+
// ever installed, in both scripts.
2206+
assert!(BREW_SYNC_SCRIPT.contains(r#"xargs -a "$declared" "$brew" install"#));
2207+
assert!(FLATPAK_SYNC_SCRIPT.contains(r#"xargs -r -a "$declared" flatpak install"#));
2208+
}
2209+
21222210
#[test]
21232211
fn user_generates_boot_sync_not_build_time_useradd() {
21242212
let out = generate(&config(

‎src/main.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,8 @@ enum Cmd {
142142
#[arg(long)]
143143
json: bool,
144144
},
145-
/// Converge flatpaks and brew to the declaration now, not at next boot
145+
/// Converge flatpaks and brew to the declaration and update everything
146+
/// installed, now rather than at next boot
146147
Sync {
147148
/// Report the result as JSON (progress moves to stderr)
148149
#[arg(long)]

0 commit comments

Comments
 (0)