Delete the pre-setups migration, which has done its job

The rule is that migration code goes once the update carrying it has been
received, because there is one backend and one phone: once they are past
a shape, nothing anywhere is still on it, and a second parsing path that
nothing exercises only constrains later changes to the schema. The
module's own comment said to delete it "once the host has started on a
build containing it", and that has happened -- it is in the pushed commit
the host reports itself up to date with, and the server has been starting
on it.

Out: the `legacy` module, `migrate_from_pre_setups`, the branch in
`Config::load` that reached it, and the test. `Config::load` is now one
expression.

PLAN.md keeps the history rather than reverting to what it said before,
because the interesting part is not the migration but the decision it
replaced: refusing to start on an old config was the wrong trade and
proved it on Iris's host, as a crash loop that could not explain itself
because the crashing process is how the phone reaches the machine at all.

Verified by running it, since the point of this change is what happens at
startup: a server with no existing state starts, generates its CA, prints
its enrollment QR and writes a config that reads back. 34 tests, clippy
silent, rustfmt clean.
This commit is contained in:
iris committed 2026-08-28 18:40:24 -04:00
1 parent af41d86186
commit 297e85c68d
2 files changed
+17 -237

No files matched your search

+15 -4
View File
@@ -67,10 +67,21 @@ Settled while building it:
into `config.ron` on first run rather than conjured at read time — a into `config.ron` on first run rather than conjured at read time — a
provider nobody can see in the file is one nobody can edit from the provider nobody can see in the file is one nobody can edit from the
phone, which is the opposite of what this app is for. phone, which is the opposite of what this app is for.
- **No migration; a config in the old shape is refused** with instructions. - **Migrated once, then the migration was deleted** (2026-08-28). Unknown
Unknown fields default away, so `providers:`/`hosts:` would have loaded fields default away, so a `providers:`/`hosts:` file would have loaded as
as an empty config and then been seeded over, losing everything an empty config and then been seeded over, losing everything silently.
silently. The first answer to that was to *refuse* such a file, which was the wrong
trade and proved it: this process is how a phone reaches the backend at
all, so refusing to start stranded the person who would have to fix it,
as a crash loop with nothing reachable to explain it. It was replaced by
a migration that kept the token hashes, backed the old file up, and
rebuilt the rest — which is discoverable now anyway.
That migration has since run on the one host there is, so it is gone
again, per the standing rule that migration code is deleted once the
update carrying it has been received. With one backend and one phone,
nothing is left on the old shape, and a second parsing path nothing
exercises only constrains later changes to the schema. A file in the old
shape now fails to parse, which is correct because no such file exists.
- **Still to do: editing setups from the phone.** `GET /setups` exists; - **Still to do: editing setups from the phone.** `GET /setups` exists;
writing them is not built, so a new machine is still a hand edit on the writing them is not built, so a new machine is still a hand edit on the
backend. That is the remaining gap against the standing preference that backend. That is the remaining gap against the standing preference that
+2 -233
View File
@@ -186,66 +186,6 @@ pub struct SessionConfig {
pub created: f64, pub created: f64,
} }
/// The shape `config.ron` had before setups (up to 2026-08-28).
///
/// Here only so an existing install upgrades rather than refusing to
/// start, and **due for deletion as soon as it has run**. There is one
/// backend and one phone, so once they are past this shape nothing
/// anywhere is still on it, and a second parsing path that nothing
/// exercises only constrains later changes to the schema.
///
/// The reader for the kebab-case driver kind has already gone for that
/// reason. Delete the rest -- this module and the branch in
/// [`Config::load`] that reaches it -- once the host has started on a
/// build containing it.
mod legacy {
use serde::Deserialize;
use super::{ProviderConfig, TokenEntry};
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase", default)]
pub struct Config {
pub tokens: Vec<TokenEntry>,
pub providers: Vec<ProviderConfig>,
pub hosts: Vec<Host>,
pub sessions: Vec<Session>,
}
/// A machine, when the name lived on the connection rather than on a
/// setup.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Host {
pub name: String,
pub address: String,
#[serde(default)]
pub port: Option<u16>,
#[serde(default)]
pub identity_file: Option<std::path::PathBuf>,
#[serde(default)]
pub options: Vec<String>,
}
/// A session, when it named a provider and a host independently.
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub id: String,
pub provider: String,
#[serde(default)]
pub host: Option<String>,
pub title: String,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub cwd: Option<std::path::PathBuf>,
#[serde(default)]
pub permission_mode: Option<String>,
pub created: f64,
}
}
/// The name of the echo provider, and of the setup this machine gets on /// The name of the echo provider, and of the setup this machine gets on
/// first run. /// first run.
/// ///
@@ -305,26 +245,8 @@ impl Config {
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) { match std::fs::read_to_string(path) {
Ok(text) => { Ok(text) => format::parse(&text)
// `Config` defaults unknown fields away, so a file from .with_context(|| format!("{} is not valid config RON", path.display())),
// before setups existed would otherwise load as "no setups
// at all" and be re-seeded over, losing what was
// configured without a word.
//
// This used to refuse to start, which was the wrong trade
// and took the server down on the machine it was meant to
// protect: this process is how a phone reaches that
// machine at all, so refusing to run strands the person
// who would have to fix it. Migrating keeps the one thing
// that genuinely cannot be recovered -- the enrolled token
// hashes -- and rebuilds the rest, which is now
// discoverable anyway.
if text.contains("\nproviders:") || text.contains("\nhosts:") {
return Self::migrate_from_pre_setups(path, &text);
}
format::parse(&text)
.with_context(|| format!("{} is not valid config RON", path.display()))
}
// A first run has no config -- the normal starting state; a // A first run has no config -- the normal starting state; a
// token is generated and saved on that first start. // token is generated and saved on that first start.
Err(err) if err.kind() == std::io::ErrorKind::NotFound => { Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
@@ -335,96 +257,6 @@ impl Config {
} }
} }
/// Reads a config written before setups existed and rewrites it in
/// the current shape, keeping a copy of the original.
///
/// The old model had `providers` and `hosts` as independent lists and
/// a session naming one of each. The mapping is mechanical: each host
/// becomes a setup, the top-level providers belong to the machine this
/// server runs on, and a session's host becomes its setup.
///
/// Migrated hosts arrive with **no providers**, deliberately. The old
/// file never recorded which machine had which program -- that was the
/// flaw the setups model exists to fix -- so inventing an answer here
/// would recreate exactly the impossible pairings this was meant to
/// end. `Rediscover` on the card asks the machine instead.
///
/// Writes as a side effect, which a loader would not normally do: this
/// runs once, and leaving the old file in place would mean migrating
/// again on every start and never recording the result.
fn migrate_from_pre_setups(path: &Path, text: &str) -> Result<Self> {
let old: legacy::Config = format::parse(text)
.with_context(|| format!("{} is not valid config RON", path.display()))?;
let mut setups = Vec::new();
setups.push(SetupConfig {
id: LOCAL_SETUP_ID.to_string(),
name: LOCAL_SETUP.to_string(),
ssh: None,
providers: old.providers,
});
// Name and id both taken from the old host's name: it is what
// sessions referenced, so reusing it is what lets them keep
// working without a second lookup table.
for host in &old.hosts {
setups.push(SetupConfig {
id: crate::setups::id_from(&host.name),
name: host.name.clone(),
ssh: Some(SshConfig {
address: host.address.clone(),
port: host.port,
identity_file: host.identity_file.clone(),
options: host.options.clone(),
}),
providers: Vec::new(),
});
}
let sessions = old
.sessions
.into_iter()
.map(|session| SessionConfig {
id: session.id,
setup: match &session.host {
Some(name) => crate::setups::id_from(name),
None => LOCAL_SETUP_ID.to_string(),
},
provider: session.provider,
title: session.title,
model: session.model,
cwd: session.cwd,
permission_mode: session.permission_mode,
params: BTreeMap::new(),
created: session.created,
})
.collect();
let migrated = Self {
tokens: old.tokens,
setups,
sessions,
};
// The original is kept rather than overwritten in place: this is a
// one-way conversion of the only record of what was configured,
// and it costs one file to make it reversible by hand.
let backup = path.with_extension("ron.pre-setups");
std::fs::copy(path, &backup)
.with_context(|| format!("back up {} to {}", path.display(), backup.display()))?;
migrated.save(path)?;
tracing::warn!(
"{} was in the pre-setups shape and has been migrated: {} token(s) and {} session(s) \
kept, {} host(s) became setups with no providers yet -- press Rediscover on each to \
ask it what it has. The original is at {}.",
path.display(),
migrated.tokens.len(),
migrated.sessions.len(),
old.hosts.len(),
backup.display(),
);
Ok(migrated)
}
/// Writes the config, owner-readable only. /// Writes the config, owner-readable only.
/// ///
/// The token hashes here are verifiers, not secrets -- a 256-bit /// The token hashes here are verifiers, not secrets -- a 256-bit
@@ -562,69 +394,6 @@ mod tests {
); );
} }
#[test]
/// The upgrade path, and the reason it exists: refusing to start took
/// the whole server down on an install that had done nothing wrong.
/// What must survive is the token hashes -- everything else can be
/// rebuilt, and losing those means re-enrolling every device by hand
/// at the terminal, which is the position this server exists to keep
/// people out of.
fn a_config_from_before_setups_is_migrated_rather_than_refused() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.ron");
std::fs::write(
&path,
"tokens: [(name: \"phone\", sha256: \"ab\")],\n\
providers: [(name: \"claude-cli\", kind: claude_cli, models: [\"opus\"])],\n\
hosts: [(name: \"dev vm\", address: \"bob@10.0.0.2\", port: 2222)],\n\
sessions: [\n\
(id: \"a\", provider: \"claude-cli\", host: \"dev vm\", title: \"remote\", created: 1.0),\n\
(id: \"b\", provider: \"claude-cli\", title: \"local\", created: 2.0),\n\
],\n",
)
.expect("write");
let migrated = Config::load(&path).expect("migrates rather than refusing");
// The irreplaceable part.
assert_eq!(migrated.tokens.len(), 1);
assert_eq!(migrated.tokens[0].sha256, "ab");
// The old host is a setup, keeping the name sessions referenced.
let vm = migrated.setup("dev-vm").expect("host became a setup");
assert_eq!(vm.name, "dev vm");
assert_eq!(vm.ssh.as_ref().expect("ssh").port, Some(2222));
// Empty on purpose: the old file never recorded what was on which
// machine, so this asks rather than invents.
assert!(vm.providers.is_empty());
// Top-level providers belonged to the machine running the server.
let local = migrated.setup(LOCAL_SETUP_ID).expect("local setup");
assert_eq!(
local.provider("claude-cli").expect("kept").kind,
DriverKind::ClaudeCli
);
// Sessions keep working, each pointing at the right machine.
let remote = migrated
.sessions
.iter()
.find(|s| s.id == "a")
.expect("remote");
assert_eq!(remote.setup, "dev-vm");
let local_session = migrated
.sessions
.iter()
.find(|s| s.id == "b")
.expect("local");
assert_eq!(local_session.setup, LOCAL_SETUP_ID);
// The original is kept, and a second load is an ordinary one.
assert!(path.with_extension("ron.pre-setups").is_file());
let again = Config::load(&path).expect("reload");
assert_eq!(again.setups.len(), 2);
}
#[test] #[test]
/// What a fresh install can do before anybody configures anything: /// What a fresh install can do before anybody configures anything:
/// echo to prove the pipe, and the Claude CLI to be useful. Both are /// echo to prove the pipe, and the Claude CLI to be useful. Both are