This repository has no description
17 kB
492 lines
1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4
5use base64::Engine;
6use knot_migrate::adopt::{self, AdoptError, SourcePolicy};
7use knot_migrate::casbin::{self, CasbinError};
8use knot_migrate::emit::{self, ConfigValues, EmitError, MasterKeyEnv};
9use knot_migrate::envfile::{EnvFile, EnvFileError};
10use knot_migrate::mapping::{self, Mapping, MappingError};
11use knot_migrate::report::Report;
12use knot_migrate::source::{SourceDb, SourceError, SourceRepoDid, SourceRkey, SourceSchema};
13use knot_runtime::OsEntropy;
14use knot_secrets::{MasterKey, SealedStore, SecretsError};
15use knot_types::{AccountDid, KnotHostname, ObjectFormat};
16use url::Url;
17
18// TODO: I wanted to see how well I could work without clap. I shoulda just used clap.
19const USAGE: &str = "\
20knot-migrate: offline conversion of a tangled-knot deployment into a knot deployment
21
22usage:
23 knot-migrate --source-db <knotserver.db> --host-key <ssh_host_key> --target <dir> [options]
24
25options:
26 --source-db <path> tangled-knot SQLite database, opened read-only
27 --source-repos <dir> tangled-knot scan path holding <repo_did> directories
28 defaults to KNOT_REPO_SCAN_PATH from the env file
29 --env-file <path> tangled-knot environment file
30 --host-key <path> system sshd host key to import
31 --target <dir> knot data directory to create
32 --hostname <host> knot hostname, defaults to KNOT_SERVER_HOSTNAME
33 --plc-url <url> PLC directory, defaults to KNOT_SERVER_PLC_URL
34 --object-format <fmt> sha1 or sha256 for repos knot creates, default sha1
35 --master-key-env <name> env var holding the base64 master key, default KNOT_MASTER_KEY
36 --consume-source move the source repos into place instead of copying them,
37 which empties the source tree and needs one filesystem
38 --dry-run print the mapping and reconciliation report, write nothing
39";
40
41#[derive(Debug, thiserror::Error)]
42enum MigrateError {
43 #[error(transparent)]
44 Source(#[from] SourceError),
45 #[error(transparent)]
46 Casbin(#[from] CasbinError),
47 #[error(transparent)]
48 Mapping(#[from] MappingError),
49 #[error(transparent)]
50 Adopt(#[from] AdoptError),
51 #[error(transparent)]
52 Emit(#[from] EmitError),
53 #[error(transparent)]
54 EnvFile(#[from] EnvFileError),
55 #[error(transparent)]
56 Git(#[from] knot_git::GitError),
57 #[error(transparent)]
58 Secrets(#[from] SecretsError),
59 #[error("{0}")]
60 Usage(String),
61 #[error("env file specifies knot owner {env} while the acl specifies {acl}")]
62 OwnerMismatch { env: String, acl: String },
63 #[error("--hostname {flag} doesn't match the env file's KNOT_SERVER_HOSTNAME {env}")]
64 HostnameMismatch { flag: String, env: String },
65 #[error("master key env var {name} isn't set")]
66 MissingMasterKey { name: MasterKeyEnv },
67 #[error("master key env var {name} isn't base64")]
68 MalformedMasterKey { name: MasterKeyEnv },
69 #[error("{context}: {source}")]
70 Io {
71 context: String,
72 source: std::io::Error,
73 },
74}
75
76struct Args {
77 source_db: PathBuf,
78 source_repos: Option<PathBuf>,
79 env_file: Option<PathBuf>,
80 host_key: Option<PathBuf>,
81 target: PathBuf,
82 hostname: Option<String>,
83 plc_url: Option<String>,
84 object_format: ObjectFormat,
85 master_key_env: MasterKeyEnv,
86 source_policy: SourcePolicy,
87 dry_run: bool,
88}
89
90#[derive(Debug, Default, Clone, Copy)]
91struct Switches {
92 dry_run: bool,
93 consume_source: bool,
94}
95
96const KNOWN_FLAGS: [&str; 9] = [
97 "source-db",
98 "source-repos",
99 "env-file",
100 "host-key",
101 "target",
102 "hostname",
103 "plc-url",
104 "object-format",
105 "master-key-env",
106];
107
108fn parse_args(args: &[String]) -> Result<Args, MigrateError> {
109 let (mut flags, switches, pending) = args.iter().try_fold(
110 (
111 BTreeMap::<String, String>::new(),
112 Switches::default(),
113 None::<String>,
114 ),
115 |(mut flags, switches, pending), arg| match (pending, arg.as_str()) {
116 (Some(key), value) if value.starts_with("--") => {
117 Err(MigrateError::Usage(format!("--{key} needs a value")))
118 }
119 (Some(key), value) => match flags.insert(key.clone(), value.to_string()) {
120 None => Ok((flags, switches, None)),
121 Some(_) => Err(MigrateError::Usage(format!("--{key} given twice"))),
122 },
123 (None, "--dry-run") => Ok((
124 flags,
125 Switches {
126 dry_run: true,
127 ..switches
128 },
129 None,
130 )),
131 (None, "--consume-source") => Ok((
132 flags,
133 Switches {
134 consume_source: true,
135 ..switches
136 },
137 None,
138 )),
139 (None, flag) => match flag.strip_prefix("--").map(|rest| {
140 rest.split_once('=')
141 .map_or((rest, None), |(key, value)| (key, Some(value)))
142 }) {
143 Some((key, None)) if KNOWN_FLAGS.contains(&key) => {
144 Ok((flags, switches, Some(key.to_string())))
145 }
146 Some((key, Some(value))) if KNOWN_FLAGS.contains(&key) => {
147 match flags.insert(key.to_string(), value.to_string()) {
148 None => Ok((flags, switches, None)),
149 Some(_) => Err(MigrateError::Usage(format!("--{key} given twice"))),
150 }
151 }
152 _ => Err(MigrateError::Usage(format!("unexpected argument {flag}"))),
153 },
154 },
155 )?;
156 pending.map_or(Ok(()), |key| {
157 Err(MigrateError::Usage(format!("--{key} needs a value")))
158 })?;
159 let mut take = |key: &str| flags.remove(key);
160 let required = |key: &str, value: Option<String>| {
161 value.ok_or_else(|| MigrateError::Usage(format!("--{key} is required")))
162 };
163 let object_format = take("object-format").map_or(Ok(ObjectFormat::SHA1), |value| {
164 ObjectFormat::from_capability(&value).ok_or_else(|| {
165 MigrateError::Usage(format!(
166 "--object-format must be sha1 or sha256, not {value}"
167 ))
168 })
169 })?;
170 Ok(Args {
171 source_db: required("source-db", take("source-db"))?.into(),
172 source_repos: take("source-repos").map(PathBuf::from),
173 env_file: take("env-file").map(PathBuf::from),
174 host_key: take("host-key").map(PathBuf::from),
175 target: required("target", take("target"))?.into(),
176 hostname: take("hostname"),
177 plc_url: take("plc-url"),
178 object_format,
179 master_key_env: take("master-key-env")
180 .map_or_else(|| MasterKeyEnv::new("KNOT_MASTER_KEY"), MasterKeyEnv::new)
181 .map_err(|value| {
182 MigrateError::Usage(format!(
183 "--master-key-env must be an uppercase env var name, not {value}"
184 ))
185 })?,
186 source_policy: match switches.consume_source {
187 true => SourcePolicy::Consume,
188 false => SourcePolicy::Preserve,
189 },
190 dry_run: switches.dry_run,
191 })
192}
193
194fn main() -> ExitCode {
195 let args: Vec<String> = std::env::args().skip(1).collect();
196 if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
197 print!("{USAGE}");
198 return ExitCode::SUCCESS;
199 }
200 match run(&args) {
201 Ok(()) => ExitCode::SUCCESS,
202 Err(error) => {
203 eprintln!("error: {error}");
204 ExitCode::FAILURE
205 }
206 }
207}
208
209fn run(args: &[String]) -> Result<(), MigrateError> {
210 let args = parse_args(args)?;
211 let env = args
212 .env_file
213 .as_deref()
214 .map(EnvFile::read)
215 .transpose()?
216 .unwrap_or_default();
217
218 args.hostname
219 .as_deref()
220 .zip(env.get("KNOT_SERVER_HOSTNAME"))
221 .filter(|(flag, env_value)| flag != env_value)
222 .map_or(Ok(()), |(flag, env_value)| {
223 Err(MigrateError::HostnameMismatch {
224 flag: flag.to_string(),
225 env: env_value.to_string(),
226 })
227 })?;
228 let hostname = args
229 .hostname
230 .clone()
231 .or_else(|| env.get("KNOT_SERVER_HOSTNAME").map(str::to_string))
232 .ok_or_else(|| {
233 MigrateError::Usage(
234 "--hostname is required when the env file specifies none".to_string(),
235 )
236 })?;
237 let hostname = KnotHostname::new(hostname.as_str()).map_err(|error| {
238 MigrateError::Usage(format!("hostname {hostname} isn't valid: {error}"))
239 })?;
240 let plc_url = args
241 .plc_url
242 .clone()
243 .or_else(|| env.get("KNOT_SERVER_PLC_URL").map(str::to_string))
244 .ok_or_else(|| {
245 MigrateError::Usage(
246 "--plc-url is required when the env file specifies none".to_string(),
247 )
248 })?;
249 let plc_directory = Url::parse(&plc_url)
250 .ok()
251 .filter(|url| url.scheme() == "https" && url.host().is_some())
252 .ok_or_else(|| {
253 MigrateError::Usage(format!("PLC directory {plc_url} isn't an https URL"))
254 })?;
255 let source_repos = args
256 .source_repos
257 .clone()
258 .or_else(|| env.get("KNOT_REPO_SCAN_PATH").map(PathBuf::from))
259 .ok_or_else(|| {
260 MigrateError::Usage(
261 "--source-repos is required when the env file specifies no scan path".to_string(),
262 )
263 })?;
264 match source_repos.is_dir() {
265 true => Ok(()),
266 false => Err(MigrateError::Usage(format!(
267 "source repos path {} isn't a directory",
268 source_repos.display()
269 ))),
270 }?;
271
272 let db = SourceDb::open(&args.source_db)?;
273 let schema = db.schema()?;
274 let repos = db.repos()?;
275 let rkeys = repos
276 .iter()
277 .filter_map(|repo| {
278 db.current_rkey(&repo.repo_did)
279 .map(|rkey| rkey.map(|rkey| (repo.repo_did.clone(), rkey)))
280 .transpose()
281 })
282 .collect::<Result<BTreeMap<SourceRepoDid, SourceRkey>, SourceError>>()?;
283 let resolver = casbin::resolver(repos.iter().map(|repo| {
284 (
285 repo.owner_did.clone(),
286 repo.repo_name.clone(),
287 repo.repo_did.clone(),
288 )
289 }));
290 let acl = casbin::decode(&db.acl()?, &resolver)?;
291 let exists = |repo_did: &SourceRepoDid| adopt::source_is_repo(&source_repos, repo_did);
292 let mapping = match schema {
293 SourceSchema::Tables => mapping::map_tables(
294 &repos,
295 &rkeys,
296 &db.members()?,
297 &db.collaborators()?,
298 &acl,
299 exists,
300 )?,
301 SourceSchema::PreFlip => {
302 mapping::map_preflip(&repos, &rkeys, &db.members()?, &acl, exists)?
303 }
304 };
305 env.get("KNOT_SERVER_OWNER")
306 .filter(|owner| AccountDid::new(*owner).ok().as_ref() != Some(&mapping.knot_owner))
307 .map_or(Ok(()), |owner| {
308 Err(MigrateError::OwnerMismatch {
309 env: owner.to_string(),
310 acl: mapping.knot_owner.to_string(),
311 })
312 })?;
313 let orphan_alias_count = db.orphan_alias_count()?;
314
315 let written = match args.dry_run {
316 true => None,
317 false => Some(materialize(
318 &args,
319 &hostname,
320 &plc_directory,
321 &source_repos,
322 &mapping,
323 )?),
324 };
325 print!(
326 "{}",
327 Report {
328 mapping: &mapping,
329 orphan_alias_count,
330 adoption: written.as_ref().map(|written| &written.adoption),
331 cobs: written.as_ref().map(|written| &written.cobs),
332 }
333 );
334 written.map_or(Ok(()), |written| {
335 println!();
336 println!("knot key identity: {}", written.knot_did);
337 println!("host key algorithm: {}", written.host_key_algorithm);
338 println!("config: {}", written.config_file.display());
339 println!("key archive: {}", written.archive_file.display());
340 Ok(())
341 })
342}
343
344fn timed<T, E>(phase: &str, work: impl FnOnce() -> Result<T, E>) -> Result<T, E> {
345 let started = std::time::Instant::now();
346 let outcome = work();
347 eprintln!("{phase}: {:.1}s", started.elapsed().as_secs_f64());
348 outcome
349}
350
351struct Written {
352 adoption: adopt::AdoptOutcome,
353 cobs: emit::CobSummary,
354 knot_did: knot_types::KnotId,
355 host_key_algorithm: ssh_key::Algorithm,
356 config_file: PathBuf,
357 archive_file: PathBuf,
358}
359
360fn materialize(
361 args: &Args,
362 hostname: &KnotHostname,
363 plc_directory: &Url,
364 source_repos: &Path,
365 mapping: &Mapping,
366) -> Result<Written, MigrateError> {
367 let host_key_source = args
368 .host_key
369 .as_deref()
370 .ok_or_else(|| MigrateError::Usage("--host-key is required for a real run".to_string()))?;
371 let host_key = emit::load_host_key(host_key_source)?;
372 std::fs::create_dir_all(&args.target).map_err(|source| MigrateError::Io {
373 context: format!("create {}", args.target.display()),
374 source,
375 })?;
376 let target = args
377 .target
378 .canonicalize()
379 .map_err(|source| MigrateError::Io {
380 context: format!("canonicalize {}", args.target.display()),
381 source,
382 })?;
383 let scan_path = target.join("repos");
384 let sealed_key_file = target.join("sealed-keys");
385 let host_key_file = target.join("ssh_host_key");
386 let archive_file = target.join("repo-signing-keys.json");
387 let config_file = target.join("config.toml");
388
389 let knot_did = hostname.knot_did();
390
391 let master_key_value =
392 zeroize::Zeroizing::new(std::env::var(args.master_key_env.as_str()).map_err(|_| {
393 MigrateError::MissingMasterKey {
394 name: args.master_key_env.clone(),
395 }
396 })?);
397 let master_key = MasterKey::new(
398 base64::engine::general_purpose::STANDARD
399 .decode(master_key_value.trim())
400 .map_err(|_| MigrateError::MalformedMasterKey {
401 name: args.master_key_env.clone(),
402 })?,
403 )?;
404 let secrets = SealedStore::open(sealed_key_file.clone(), &master_key, Box::new(OsEntropy))?;
405 secrets.ensure(&knot_did)?;
406 let signer = secrets.signer(&knot_did)?;
407
408 let layout = knot_git::Layout::new(&scan_path)
409 .with_object_format(args.object_format)
410 .reserving_meta(&knot_did)?;
411 let adoption = timed("adoption", || {
412 adopt::adopt_all(&layout, source_repos, &mapping.repos, args.source_policy)
413 })?;
414 let cobs = timed("cobs", || {
415 emit::write_cobs(&layout, &knot_did, mapping, &signer)
416 })?;
417 emit::write_key_archive(&archive_file, &mapping.repos)?;
418 host_key.write_to(&host_key_file)?;
419
420 let config = emit::render_config(&ConfigValues {
421 hostname: hostname.clone(),
422 admins: vec![mapping.knot_owner.clone()],
423 scan_path,
424 ssh_host_key_file: host_key_file,
425 sealed_key_file,
426 master_key_env: args.master_key_env.clone(),
427 object_format: args.object_format,
428 plc_directory: plc_directory.clone(),
429 })?;
430 std::fs::write(&config_file, config).map_err(|source| MigrateError::Io {
431 context: format!("write {}", config_file.display()),
432 source,
433 })?;
434
435 Ok(Written {
436 adoption,
437 cobs,
438 knot_did,
439 host_key_algorithm: host_key.algorithm,
440 config_file,
441 archive_file,
442 })
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 fn parse(list: &[&str]) -> Result<Args, MigrateError> {
450 let owned: Vec<String> = list.iter().map(|arg| arg.to_string()).collect();
451 parse_args(&owned)
452 }
453
454 #[test]
455 fn accepts_space_and_equals_forms() {
456 let args = parse(&[
457 "--source-db=/data/knotserver.db",
458 "--target",
459 "/data/knot",
460 "--object-format=sha256",
461 "--dry-run",
462 ])
463 .unwrap();
464 assert_eq!(args.source_db, PathBuf::from("/data/knotserver.db"));
465 assert_eq!(args.target, PathBuf::from("/data/knot"));
466 assert_eq!(args.object_format, ObjectFormat::SHA256);
467 assert!(args.dry_run);
468 }
469
470 #[test]
471 fn rejects_duplicates_missing_values_and_unknown_flags() {
472 [
473 &[
474 "--source-db=/data/knotserver.db",
475 "--target=/data/knot",
476 "--target",
477 "/data/other",
478 ][..],
479 &["--source-db"],
480 &["--source-db", "--target"],
481 &["--mystery=1", "--source-db=/data/knotserver.db"],
482 &["--object-format=blake3", "--source-db=/db", "--target=/t"],
483 ]
484 .into_iter()
485 .for_each(|args| {
486 assert!(
487 matches!(parse(args), Err(MigrateError::Usage(_))),
488 "{args:?} mustn't parse"
489 );
490 });
491 }
492}