1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
//! The `boatramp migrate` command: re-key a pre-0.2.0 (layout 1) control-plane store
//! to the project-scoped (0.2.0) layout, **offline** — the server refuses to serve an
//! unmigrated store (see the serve-startup guard), so this is the explicit,
//! operator-run step that unblocks it.
//!
//! The engine lives in [`boatramp_core::migrate`]; this wires it to the same KV store
//! `serve` opens (`--kv` / `--data-dir` must match). Supports a `--dry-run` audit, a
//! `--stage` copy-only pass (leaving the old keys for a soak/rollback window), and a
//! `--finalize` pass that deletes the staged old keys.
use std::path::PathBuf;
use boatramp_core::migrate::{self, MigrateOptions, MigrationReport, Status};
use crate::config::ServerConfig;
use crate::serve::build_control_plane_kv;
use boatramp_node::backends::KvBackend;
/// Errors from the `migrate` command.
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Opening the control-plane KV store failed (delegated to the serve backend
/// builder, so the same `--kv`/feature guidance applies).
#[error(transparent)]
Store(#[from] Box<crate::serve::Error>),
/// The migration engine failed.
#[error(transparent)]
Migrate(#[from] boatramp_core::migrate::MigrateError),
/// Flushing the migrated store to durable storage failed.
#[error("flushing the migrated store failed: {0}")]
Flush(String),
}
/// `boatramp migrate` arguments.
#[derive(Debug, clap::Args)]
pub struct MigrateArgs {
/// Data directory (flag/env > `[serve].data_dir` > `./data`). Must match `serve`.
#[arg(long, env = "BOATRAMP_DATA_DIR")]
data_dir: Option<PathBuf>,
/// Metadata (KV) backend. Must match `serve`.
#[arg(long, value_enum, default_value_t = KvBackend::Slatedb)]
kv: KvBackend,
/// Report what the migration would change, writing nothing.
#[arg(long)]
dry_run: bool,
/// Copy + verify + rewrite domain values, but keep the old-layout keys for a
/// soak/rollback window (the `2-dual` state). A later `--finalize` deletes them.
/// Without this the migration is one-shot (copy then delete).
#[arg(long, conflicts_with_all = ["dry_run", "finalize"])]
stage: bool,
/// Delete the old-layout keys left by an earlier `--stage` run, completing the
/// migration to layout 2.
#[arg(long, conflicts_with = "dry_run")]
finalize: bool,
}
/// Run the `migrate` command.
pub async fn run(args: MigrateArgs, config: &ServerConfig) -> Result<(), Error> {
let data_dir = args
.data_dir
.clone()
.or_else(|| config.serve.as_ref().and_then(|s| s.data_dir.clone()))
.unwrap_or_else(|| PathBuf::from("./data"));
// The pre-0.2.0 → project-scoped re-key is a local-store migration (a fresh
// remote-state deploy has no layout-1 store), so open the local SlateDB. Never
// auto-repair here (`false`): this is a re-key migration, not a crash recovery —
// a torn tail must fail loud and be repaired via `boatramp kv repair` / `serve --repair-wal`.
let kv = build_control_plane_kv(args.kv, &data_dir, None, false)
.await
.map_err(|e| Box::new(crate::serve::Error::from(e)))?;
println!(
"control-plane store ({:?} at {}): {}",
args.kv,
data_dir.display(),
describe(migrate::status(kv.as_ref()).await?)
);
let report = if args.finalize {
migrate::finalize(kv.as_ref()).await?
} else {
migrate::migrate(
kv.as_ref(),
MigrateOptions {
dry_run: args.dry_run,
finalize: !args.stage,
},
)
.await?
};
print_report(&report, args.dry_run);
// Force the migration durable (SlateDB flushes on a timer otherwise) before we
// exit, so a `serve` started right after sees the completed layout.
if !args.dry_run {
kv.flush().await.map_err(|e| Error::Flush(e.to_string()))?;
}
Ok(())
}
/// A one-line description of a store's layout status.
fn describe(status: Status) -> &'static str {
match status {
Status::Ready => "ready (empty or already at the current schema version)",
Status::NeedsMigration => "below the current schema version — needs migration",
Status::Dual => "dual soak (migrated; old keys awaiting `--finalize`)",
}
}
/// Print a human-readable summary of a migration pass.
fn print_report(report: &MigrationReport, dry_run: bool) {
if report.already_migrated {
println!("nothing to do: the store is already fully migrated.");
return;
}
let verb = if dry_run { "would re-key" } else { "re-keyed" };
if report.rekeyed.is_empty() && report.values_rewritten.is_empty() {
println!("no layout-1 records found.");
}
for (family, n) in &report.rekeyed {
println!(" {verb} {n} key(s) under {family}");
}
for (family, n) in &report.values_rewritten {
let v = if dry_run { "would rewrite" } else { "rewrote" };
println!(" {v} {n} {family} index value(s) to the (project, site) form");
}
if report.created_default_project {
let v = if dry_run { "would create" } else { "created" };
println!(" {v} the `default` project");
}
if report.owner_entries > 0 {
println!(
" built {} owner reverse-index entries",
report.owner_entries
);
}
if dry_run {
println!("(dry run — nothing was written)");
} else if report.dual {
println!(
"staged to 2-dual — old keys kept; run `boatramp migrate --finalize` to reclaim them."
);
} else {
println!("migration complete: store is at the project-scoped layout.");
}
}