This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-migrate / src / main.rs
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}