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

+2 -233
View File
@@ -186,66 +186,6 @@ pub struct SessionConfig {
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
/// first run.
///
@@ -305,26 +245,8 @@ impl Config {
pub fn load(path: &Path) -> Result<Self> {
match std::fs::read_to_string(path) {
Ok(text) => {
// `Config` defaults unknown fields away, so a file from
// 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()))
}
Ok(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
// token is generated and saved on that first start.
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.
///
/// 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]
/// What a fresh install can do before anybody configures anything:
/// echo to prove the pipe, and the Claude CLI to be useful. Both are