crates/monetize-admin/src/solo.rs
//! **The solo monetize** (G6): what PID 1 keeps for its two children — the
//! loopback address of each, the words each is started with, the console's
//! settings, the operator's door feed over the installer's, and the judge
//! every feed passes before monetize takes it.
use std::collections::BTreeMap;
use std::net::{Ipv4Addr, SocketAddrV4};
use std::path::{Path, PathBuf};
use monetize_front::intake;
/// monetize-server's own address: the intake writes it as `grpc_addr`.
pub const SERVER: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 50061);
/// The console's own address.
pub const UI: SocketAddrV4 = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 8091);
/// The console's settings, under the data root (0600).
pub const UI_SETTINGS_REL: &str = "monetize-ui/settings.toml";
/// The passkey and session store, under the data root.
pub const UI_STATE_REL: &str = "monetize-ui/state.redb";
/// The marker the operator's door leaves when a passkey reset is asked.
pub const RESET_REL: &str = "monetize-ui/passkeys.reset";
/// The feed the operator PUT on the door after the install (0600, JSON).
pub const DOOR_FEED_REL: &str = "solo/door-feed.json";
/// Where a feed is judged, under the data root (removed after each judging).
const JUDGE_REL: &str = "solo/judging";
/// `monetize-admin server …`'s words.
pub fn server_args(root: &Path) -> Vec<String> {
vec!["server".into(), "--config".into(), root.join(intake::CONFIG_REL).display().to_string()]
}
/// `monetize-admin ui …`'s words.
pub fn ui_args(root: &Path) -> Vec<String> {
vec!["ui".into(), "--bind".into(), UI.to_string(), "--server".into(), SERVER.to_string(), "--root".into(), root.display().to_string()]
}
/// **What the console runs with**: the passkeys' relying party and the
/// break-glass bearer for the first passkey.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct UiSettings {
/// The first fed `web_hosts` entry, else the estate's name.
pub rp_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub admin_token: Option<String>,
}
/// Why `token` is too weak to be the break-glass bearer, if it is.
pub fn weak_token(token: &str) -> Option<&'static str> {
let distinct: std::collections::BTreeSet<char> = token.chars().collect();
if token.len() < 32 {
Some("is shorter than 32 characters")
} else if distinct.len() < 8 {
Some("repeats too few characters to be random")
} else {
None
}
}
impl UiSettings {
/// From the feed — pure; the refusal lines beside it.
pub fn of(feed: &BTreeMap<String, String>, estate: &str) -> (UiSettings, Vec<String>) {
let mut refused = Vec::new();
let web = estate_front::web::WebConfig::from(estate, feed);
let admin_token = feed.get(crate::FEED_ADMIN_TOKEN).map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).and_then(|t| match weak_token(&t) {
Some(why) => {
refused.push(format!("REFUSED [console-admin-token-weak] the fed `{}` {why}; the console starts without a break-glass bearer", crate::FEED_ADMIN_TOKEN));
None
}
None => Some(t),
});
(UiSettings { rp_id: web.name().to_string(), admin_token }, refused)
}
/// Write them (0600), only when they differ: `true` when they changed.
pub fn write(&self, root: &Path) -> std::io::Result<bool> {
if UiSettings::read(root).as_ref() == Some(self) {
return Ok(false);
}
let text = toml::to_string(self).map_err(std::io::Error::other)?;
monetize_front::write_secret(root, UI_SETTINGS_REL, &text)?;
Ok(true)
}
/// What was written, if anything readable was.
pub fn read(root: &Path) -> Option<UiSettings> {
toml::from_str(&std::fs::read_to_string(root.join(UI_SETTINGS_REL)).ok()?).ok()
}
}
/// The feed the operator put on the door (empty when none).
pub fn read_door_feed(root: &Path) -> BTreeMap<String, String> {
std::fs::read_to_string(root.join(DOOR_FEED_REL)).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
}
/// Keep `feed` as the door's (0600, atomically).
pub fn write_door_feed(root: &Path, feed: &BTreeMap<String, String>) -> std::io::Result<()> {
let text = serde_json::to_string(feed).map_err(std::io::Error::other)?;
monetize_front::write_secret(root, DOOR_FEED_REL, &text)
}
/// **The next release's feed, staged before the update** (M-FOLLOW 7: the product gap made
/// zero): `PUT /feed/next` keeps it here, for the app pin the box's own pointer names. The app
/// that runs never applies it; that next app takes it at its first start ([`feed_now_for`]),
/// and once it runs confirmed it becomes the door feed ([`promote_next_feed`]).
pub const NEXT_FEED_REL: &str = "solo/door-feed.next.json";
/// A staged next feed: the app it is for, the whole feed (secrets: kept 0600, never logged).
#[derive(serde::Serialize, serde::Deserialize, Clone, PartialEq, Eq)]
pub struct StagedFeed {
pub for_app: String,
pub feed: BTreeMap<String, String>,
}
impl std::fmt::Debug for StagedFeed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "StagedFeed {{ for_app: {}, {} key(s) }}", self.for_app, self.feed.len())
}
}
/// Keep `feed` staged for the app `for_app` (0600, atomically; a later stage replaces it).
pub fn stage_next_feed(root: &Path, for_app: &str, feed: &BTreeMap<String, String>) -> std::io::Result<()> {
let text = serde_json::to_string(&StagedFeed { for_app: for_app.trim().to_string(), feed: feed.clone() }).map_err(std::io::Error::other)?;
monetize_front::write_secret(root, NEXT_FEED_REL, &text)
}
/// The staged next feed, if any.
pub fn read_next_feed(root: &Path) -> Option<StagedFeed> {
serde_json::from_str(&std::fs::read_to_string(root.join(NEXT_FEED_REL)).ok()?).ok()
}
/// **The door's feed for the app `app`**: the staged next feed when it is for `app` (that app's
/// first start, its trial), else the door feed.
fn door_feed_for(root: &Path, app: &str) -> BTreeMap<String, String> {
match read_next_feed(root) {
Some(s) if !app.trim().is_empty() && s.for_app == app.trim() => s.feed,
_ => read_door_feed(root),
}
}
/// **The staged feed becomes the door feed once its app runs CONFIRMED** (never on trial: a
/// korp-start rollback must find the old app's own feed untouched). Answers the line to log.
pub fn promote_next_feed(root: &Path, app: &str, on_trial: bool) -> Option<String> {
let s = read_next_feed(root)?;
if on_trial || app.trim().is_empty() || s.for_app != app.trim() {
return None;
}
if let Err(e) = write_door_feed(root, &s.feed) {
return Some(format!("the staged feed for app {} could not become the door feed: {e}; it stays staged", &s.for_app[..s.for_app.len().min(12)]));
}
let _ = std::fs::remove_file(root.join(NEXT_FEED_REL));
Some(format!("the feed staged for app {} is the door feed now ({} key(s)): this app runs confirmed", &s.for_app[..s.for_app.len().min(12)], s.feed.len()))
}
/// [`feed_now`] for the app pin `app` (the one that runs): its staged next feed over the
/// installer's when one is staged for it ([`door_feed_for`]).
pub fn feed_now_for(root: &Path, fed: &BTreeMap<String, String>, app: &str) -> BTreeMap<String, String> {
let mut f = fed.clone();
f.extend(door_feed_for(root, app));
over_with_app(root, f)
}
/// **The feed in force**: the installer's, with the door's lying over it key by key, and the
/// sites and accounts the operator added or edited in the application over both
/// ([`crate::app_config::overlay`]: the app wins, by name).
pub fn feed_now(root: &Path, fed: &BTreeMap<String, String>) -> BTreeMap<String, String> {
feed_now_for(root, fed, &crate::self_update::running_app())
}
/// The application's sites and accounts over the feed `f` ([`feed_now`]).
fn over_with_app(root: &Path, f: BTreeMap<String, String>) -> BTreeMap<String, String> {
let (book, secrets) = (crate::app_config::read_book(root), crate::app_config::read_secrets(root));
if book.sites.is_empty() && book.vendors.is_empty() && secrets.is_empty() {
return f;
}
match crate::app_config::overlay(&f, &book, &secrets) {
Ok(with_app) => with_app,
Err(e) => {
// Never reached through the door (it refuses a feed the app's sites cannot lie
// over); said loudly, and the bootstrap's feed serves alone until it holds again.
tracing::error!("solo: the application's sites are NOT in force: {e}");
f
}
}
}
/// **Judge `feed` before it is taken**: the intake writes it into a scratch
/// root and monetize-server's own loader reads the config back — a feed it
/// would not start on is refused whole, by name. The products it serves, else
/// why not. Nothing is written where monetize reads.
pub fn judge(root: &Path, feed: &BTreeMap<String, String>) -> Result<Vec<String>, String> {
judge_linked(root, feed, &crate::linked().product_names())
}
/// [`judge`] for a server that links the product plugins `linked`: such a product may be
/// fed with no module (`intake::plugins_linked`).
pub fn judge_linked(root: &Path, feed: &BTreeMap<String, String>, linked: &[&str]) -> Result<Vec<String>, String> {
judge_in(root, JUDGE_REL, feed, linked)
}
/// [`judge`] in the scratch directory `scratch_rel` (the site book judges in its own: it runs
/// in the server's process, PID 1 in its).
pub fn judge_in(root: &Path, scratch_rel: &str, feed: &BTreeMap<String, String>, linked: &[&str]) -> Result<Vec<String>, String> {
static ONE: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _one = ONE.lock().unwrap_or_else(|e| e.into_inner());
let scratch = root.join(scratch_rel);
let _ = std::fs::remove_dir_all(&scratch);
let judged = (|| {
let refused = intake::apply_pull_linked(&scratch, feed, linked).map_err(|e| format!("{e:#}"))?;
if !refused.is_empty() {
return Err(refused.join("\n"));
}
let cfg = monetize_server::config::Config::load(&scratch.join(intake::CONFIG_REL)).map_err(|e| format!("REFUSED [solo-config] {e:#}"))?;
Ok(cfg.products.keys().cloned().collect())
})();
let _ = std::fs::remove_dir_all(&scratch);
judged
}
/// **Where PID 1 says which door feed it took** (R34-5): the sha256 (hex) of
/// [`DOOR_FEED_REL`] as it read it, or `<sha256> NOT taken: <why>` — the door answers a put
/// feed only once this names it (and the server applied the config written for it).
pub const DOOR_TAKEN_REL: &str = "solo/door-feed.taken";
/// How long the door waits for the box to apply a feed before it answers "accepted, applying".
pub const APPLY_WAIT: std::time::Duration = std::time::Duration::from_secs(15);
/// The door feed file's sha256 as it is now (hex; an absent file is the empty text's).
pub fn door_feed_digest(root: &Path) -> String {
monetize_server::guests::config_digest(&std::fs::read(root.join(DOOR_FEED_REL)).unwrap_or_default())
}
/// PID 1's word on the door feed, written only when it moved.
fn say_taken(root: &Path, line: &str) {
let p = root.join(DOOR_TAKEN_REL);
if std::fs::read_to_string(&p).map(|t| t.trim() == line).unwrap_or(false) {
return;
}
if let Err(e) = monetize_front::write_secret(root, DOOR_TAKEN_REL, line) {
tracing::warn!("solo: {DOOR_TAKEN_REL}: {e}");
}
}
/// Where a put feed stands ([`applied`]).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Applied {
/// PID 1 took it and the server applied the config written for it.
Applied,
/// PID 1 did NOT take it (monetize serves what it had): why.
Refused(String),
/// Not applied yet: what is still awaited.
Applying(String),
}
/// **Where the door feed whose sha256 is `door` stands now** (R34-5): taken by PID 1
/// ([`DOOR_TAKEN_REL`]) and the config it wrote applied by the server
/// (`monetize_server::guests::applied_path`), refused, or still applying.
pub fn applied(root: &Path, door: &str) -> Applied {
let taken = std::fs::read_to_string(root.join(DOOR_TAKEN_REL)).unwrap_or_default();
let taken = taken.trim();
if let Some(why) = taken.strip_prefix(door).and_then(|r| r.strip_prefix(" NOT taken: ")) {
return Applied::Refused(why.to_string());
}
if taken != door {
return Applied::Applying("PID 1 has not taken it yet (it looks every two seconds)".into());
}
let config = root.join(intake::CONFIG_REL);
let now = monetize_server::guests::config_digest(&std::fs::read(&config).unwrap_or_default());
let done = std::fs::read_to_string(monetize_server::guests::applied_path(&config)).unwrap_or_default();
if done.trim() == now {
Applied::Applied
} else {
Applied::Applying("taken; monetize-server has not applied its config yet (it looks every two seconds)".into())
}
}
/// [`applied`], waited for up to `within`: the answer once it is applied or refused, else
/// the last "applying". A box whose PID 1 says nothing of the feeds it takes (no
/// [`DOOR_TAKEN_REL`] at all) is not waited for.
pub async fn wait_applied(root: &Path, door: &str, within: std::time::Duration) -> Applied {
if !root.join(DOOR_TAKEN_REL).exists() {
return Applied::Applying("this box's PID 1 says nothing yet of the feeds it takes".into());
}
let until = std::time::Instant::now() + within;
loop {
let now = applied(root, door);
if !matches!(now, Applied::Applying(_)) || std::time::Instant::now() >= until {
return now;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
}
/// How often PID 1 looks at the feed again.
pub const LOOK_EVERY: std::time::Duration = std::time::Duration::from_secs(2);
/// The keys whose change monetize-server applies only at its next start (a
/// product's prices; the guest blocks, vendors and clouds it applies live).
pub fn needs_restart(before: &BTreeMap<String, String>, after: &BTreeMap<String, String>) -> bool {
let prices = |f: &BTreeMap<String, String>| f.iter().filter(|(k, _)| k.starts_with(intake::FEED_PLUGIN_PREFIX) && k.ends_with(".prices")).map(|(k, v)| (k.clone(), v.clone())).collect::<BTreeMap<_, _>>();
prices(before) != prices(after)
}
/// **PID 1's loop for the life of the box**: every [`LOOK_EVERY`], the feed in
/// force (`feed()`, `None` until the installer fed the box) is judged when it
/// moved and taken when monetize would start on it ([`judge`], then
/// `intake::apply_pull`); the console's settings are written from it; the
/// server is started once its config is there, the console once its settings
/// are, each kept running ([`crate::supervise`]). A changed console setting
/// restarts the console; a changed price restarts the server.
pub async fn keep(root: PathBuf, estate: String, feed: std::sync::Arc<dyn Fn() -> Option<BTreeMap<String, String>> + Send + Sync>) {
use crate::supervise::Supervisor;
let mut taken: Option<BTreeMap<String, String>> = None;
let mut said: Vec<String> = Vec::new();
let mut say = |l: String| {
if !said.contains(&l) {
tracing::error!("solo: {l}");
said.push(l);
}
};
let (mut server, mut ui): (Option<Supervisor>, Option<Supervisor>) = (None, None);
// The product plugins the server child links (the same binary): fed with no module.
let linked = crate::linked().product_names();
loop {
// M-FOLLOW 7: a feed staged for this app becomes the door feed once it runs confirmed.
if let Some((app, on_trial)) = crate::self_update::running_trial() {
if let Some(line) = promote_next_feed(&root, &app, on_trial) {
tracing::warn!("solo: {line}");
}
}
// R34-5: the door feed as read before the feed in force is: never newer than what is taken.
let door = door_feed_digest(&root);
if let Some(f) = feed() {
if taken.as_ref() == Some(&f) {
say_taken(&root, &door);
} else {
let (r, g, l) = (root.clone(), f.clone(), linked.clone());
let judged = tokio::task::spawn_blocking(move || judge_linked(&r, &g, &l).and_then(|ps| intake::apply_pull_linked(&r, &g, &l).map_err(|e| format!("{e:#}")).map(|_| ps))).await;
match judged {
Ok(Ok(products)) => {
tracing::info!(products = ?products, keys = f.len(), "solo: the feed is taken");
if let (Some(s), Some(before)) = (&server, &taken) {
if needs_restart(before, &f) {
tracing::info!("solo: a price moved — monetize-server starts again to serve it");
s.restart();
}
}
taken = Some(f.clone());
say_taken(&root, &door);
}
Ok(Err(e)) => {
say_taken(&root, &format!("{door} NOT taken: {}", e.lines().next().unwrap_or_default()));
say(format!("the feed is NOT taken (monetize serves what it had): {e}"))
}
Err(e) => say(format!("the judge did not finish: {e}")),
}
}
let (settings, refused) = UiSettings::of(&f, &estate);
for l in refused {
say(l);
}
match settings.write(&root) {
Ok(true) => {
if let Some(u) = &ui {
tracing::info!(rp_id = %settings.rp_id, "solo: the console's settings moved — it starts again");
u.restart();
}
}
Ok(false) => {}
Err(e) => say(format!("{UI_SETTINGS_REL}: {e}")),
}
}
if server.is_none() && root.join(intake::CONFIG_REL).exists() {
// A32-14, stays a task: PID 1's supervisor of its own child; its handle is kept and stopped with PID 1.
server = Some(Supervisor::spawn("server", me(), server_args(&root)));
}
if ui.is_none() && server.is_some() && UiSettings::read(&root).is_some() {
// A32-14, stays a task: PID 1's supervisor of its own child; its handle is kept and stopped with PID 1.
ui = Some(Supervisor::spawn("ui", me(), ui_args(&root)));
}
tokio::time::sleep(LOOK_EVERY).await;
}
}
/// This binary, to start the children with.
pub fn me() -> PathBuf {
exe_for_children(std::env::current_exe().ok())
}
/// Pure: the path the children are started from. A binary run from RAM (a
/// memfd, as korp-installer starts the appliance) has no path on disk;
/// `/proc/self/exe` execs the same image.
pub fn exe_for_children(current: Option<PathBuf>) -> PathBuf {
current.filter(|p| p.is_file()).unwrap_or_else(|| PathBuf::from("/proc/self/exe"))
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ R34-5: a door feed stands applied only when PID 1 named it taken AND the server named
/// the config written for it applied; PID 1's refusal is said as one; else it is applying.
#[test]
fn a_door_feed_is_applied_only_when_taken_and_its_config_applied() {
let d = tempfile::tempdir().unwrap();
let root = d.path();
write_door_feed(root, &BTreeMap::from([("a".to_string(), "b".to_string())])).unwrap();
let door = door_feed_digest(root);
assert!(matches!(applied(root, &door), Applied::Applying(_)), "nothing taken yet");
say_taken(root, &format!("{door} NOT taken: REFUSED [x] no"));
assert_eq!(applied(root, &door), Applied::Refused("REFUSED [x] no".into()));
say_taken(root, &door);
let config = root.join(intake::CONFIG_REL);
std::fs::create_dir_all(config.parent().unwrap()).unwrap();
std::fs::write(&config, "# new\n").unwrap();
assert!(matches!(applied(root, &door), Applied::Applying(w) if w.contains("monetize-server")), "taken, its config not applied yet");
std::fs::write(monetize_server::guests::applied_path(&config), monetize_server::guests::config_digest(b"# new\n")).unwrap();
assert_eq!(applied(root, &door), Applied::Applied);
assert!(matches!(applied(root, &"0".repeat(64)), Applied::Applying(_)), "another feed than the one taken is not applied");
}
/// **On the appliance monetize-admin runs from RAM** (korp-installer
/// `fexecve`s a memfd): `current_exe()` answers `/memfd:monetize-admin
/// (deleted)`, a path that exists nowhere. MEASURED 2026-10-10 on the
/// bench: `supervise: cannot start /memfd:monetize-admin (deleted)
/// child="server" … No such file or directory`, every second, and the box
/// served nothing. `/proc/self/exe` still execs the same image.
#[test]
fn the_children_start_from_proc_self_exe_when_this_binary_is_in_ram() {
assert_eq!(exe_for_children(Some(PathBuf::from("/memfd:monetize-admin (deleted)"))), PathBuf::from("/proc/self/exe"));
assert_eq!(exe_for_children(None), PathBuf::from("/proc/self/exe"));
let here = std::env::current_exe().unwrap();
assert_eq!(exe_for_children(Some(here.clone())), here, "an executable on disk is started by its path");
}
#[test]
fn a_weak_token_is_named_and_a_strong_one_is_not() {
assert!(weak_token("short").is_some());
assert!(weak_token(&"ab".repeat(20)).is_some());
assert!(weak_token("Zq3v8pL0xN6wT2yR9kB4mH7sJ1cF5dGa").is_none());
}
#[test]
fn only_a_moved_price_starts_the_server_again() {
let f = |pairs: &[(&str, &str)]| pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect::<BTreeMap<_, _>>();
let a = f(&[("plugin.gunnar.prices", "[SE]\ndisk_gib_month = 1\n"), ("vendor.stripe.gunnar.toml", "enabled = true\n")]);
assert!(!needs_restart(&a, &f(&[("plugin.gunnar.prices", "[SE]\ndisk_gib_month = 1\n"), ("vendor.stripe.gunnar.toml", "enabled = false\n")])), "a vendor is applied live");
assert!(needs_restart(&a, &f(&[("plugin.gunnar.prices", "[SE]\ndisk_gib_month = 2\n")])));
}
#[test]
fn with_no_web_host_the_relying_party_is_the_estates_name() {
let (s, refused) = UiSettings::of(&BTreeMap::new(), "monetize");
assert!(refused.is_empty());
assert_eq!(s, UiSettings { rp_id: "monetize".into(), admin_token: None });
}
/// ★ **M-FOLLOW 7: the product gap made zero — the next release's feed is staged BEFORE the
/// update** (prod 2026-10-26: the new app on the old feed refused both product guests for
/// 72 s, until the new feed came). Staged for the next app (`PUT /feed/next`), it is never
/// applied by the app that runs; that next app takes it at its FIRST start, before anything
/// serves (no gap); the door feed file stays as it was through the trial, so a korp-start
/// rollback finds the old app's own feed untouched; once that app runs confirmed the staged
/// feed becomes the door feed. A feed staged for another app is never taken.
#[test]
fn the_next_feed_is_staged_before_the_update_and_taken_by_that_app_at_its_first_start() {
let d = tempfile::tempdir().unwrap();
let root = d.path();
let (old, new) = ("0".repeat(64), "1".repeat(64));
let fed = BTreeMap::from([("web_hosts".to_string(), "box.example".to_string())]);
let old_feed = BTreeMap::from([("plugin.gunnar.toml".to_string(), "grants = [\"grpc\"]".to_string())]);
let new_feed = BTreeMap::from([("plugin.gunnar.toml".to_string(), "grants = []".to_string())]);
write_door_feed(root, &old_feed).unwrap();
stage_next_feed(root, &new, &new_feed).unwrap();
assert_eq!(read_next_feed(root), Some(StagedFeed { for_app: new.clone(), feed: new_feed.clone() }));
// The app that runs keeps serving its own feed.
assert_eq!(feed_now_for(root, &fed, &old)["plugin.gunnar.toml"], old_feed["plugin.gunnar.toml"]);
// The next app, at its first start (on trial): the staged feed, over the installer's.
let at_start = feed_now_for(root, &fed, &new);
assert_eq!((at_start["plugin.gunnar.toml"].as_str(), at_start["web_hosts"].as_str()), (new_feed["plugin.gunnar.toml"].as_str(), "box.example"));
// On trial nothing is promoted: a rollback finds the old feed untouched.
assert_eq!(promote_next_feed(root, &new, true), None);
assert_eq!(read_door_feed(root), old_feed, "the door feed through the trial is the old app's");
// korp-start rolled back: the old app runs again and never takes the other app's feed.
assert_eq!(promote_next_feed(root, &old, false), None);
assert_eq!(feed_now_for(root, &fed, &old)["plugin.gunnar.toml"], old_feed["plugin.gunnar.toml"]);
// Confirmed on the next app: the staged feed becomes the door feed, once.
let line = promote_next_feed(root, &new, false).expect("promoted at the confirm");
assert!(line.contains(&new[..12]), "{line}");
assert_eq!(read_door_feed(root), new_feed);
assert_eq!(read_next_feed(root), None, "taken once");
assert_eq!(promote_next_feed(root, &new, false), None);
assert_eq!(feed_now_for(root, &fed, &new)["plugin.gunnar.toml"], new_feed["plugin.gunnar.toml"]);
}
}