diff options
| -rw-r--r-- | pkgs/populate/default.nix | 67 | ||||
| -rw-r--r-- | pkgs/populate/docs/design.md | 140 | ||||
| -rw-r--r-- | pkgs/populate/src/check.rs | 64 | ||||
| -rw-r--r-- | pkgs/populate/src/cli.rs | 212 | ||||
| -rw-r--r-- | pkgs/populate/src/main.rs | 65 | ||||
| -rw-r--r-- | pkgs/populate/src/run.rs | 89 | ||||
| -rw-r--r-- | pkgs/populate/src/spec.rs | 162 |
7 files changed, 619 insertions, 180 deletions
diff --git a/pkgs/populate/default.nix b/pkgs/populate/default.nix index c846826..bd8e772 100644 --- a/pkgs/populate/default.nix +++ b/pkgs/populate/default.nix @@ -1,6 +1,6 @@ # Populate, which delivers sources to a target directory. Given populate's # arguments, it checks them, and returns a script running the program in -# this directory with them as JSON. rustProgram is that program, e.g. one +# this directory with them, the sources as JSON. rustProgram is that program, e.g. one # built with cargo during development. { coreutils, findutils, git, nix, openssh, pass, passage, rsync, writers , rustPlatform ? null @@ -21,40 +21,57 @@ let "writers" "rustPlatform" "rustProgram" ] ++ unused)); + # The parts of a target string, like mkTarget parses it, without its + # defaults, which populate fills in when it runs. + parseTarget = s: let + parts = match "(([^@]*)@)?(([^:/]+))?(:([^/]+))?(/.*)?" s; + in + if parts == null then throw "krops: invalid target ${toJSON s}" + else { host = elemAt parts 3; path = elemAt parts 6; }; + populate = { backup ? false, force ? false, prune ? false, source, target }: - assert checks.checkTargetPath target.path; - assert all checks.checkSourceName (attrNames source); - assert checks.checkInputs source; - assert checks.checkCopyingWithSudo target source; let - # Store paths in the arguments stay dependencies of the script, so - # building it builds them. - spec = toJSON { - version = 1; - inherit backup force prune source; - target = { - inherit (target) host path; - user = target.user or null; - port = if target.port or null == null then null else toString target.port; - sudo = target.sudo or false; - extraOptions = target.extraOptions or []; - # Decided during evaluation, like the other functions of krops do. - local = isLocalTarget target; - }; - }; + # A target string goes to populate as it is, so populate decides what + # it leaves out when it runs. An attribute set, as mkTarget returns + # it, goes part by part, with whether it's local decided now, like the + # other functions of krops do. + parsed = parseTarget target; + path = if isString target then (if parsed.path != null then parsed.path else "/var/src") else target.path; + host = if isString target then (if parsed.host != null then parsed.host else "localhost") else target.host; + targetArgs = + if isString target then [ target ] + else [ + "--user" (if target.user or null == null then "" else target.user) + "--host" target.host + "--path" target.path + ] ++ optionals (target.port or null != null) [ + "--port" (toString target.port) + ] ++ optional (target.sudo or false) "--sudo" + ++ concatMap (arg: [ "--ssh-arg" arg ]) (flatten (target.extraOptions or [])) + ++ [ (if isLocalTarget target then "--local" else "--remote") ]; in - writers.writeDash "populate.${target.host}" /* sh */ '' + assert checks.checkTargetPath path; + assert all checks.checkSourceName (attrNames source); + assert checks.checkInputs source; + assert isString target || checks.checkCopyingWithSudo target source; + writers.writeDash "populate.${host}" /* sh */ '' set -efu echo + "$0" >&2 PATH=${makeBinPath [ coreutils findutils git nix openssh pass passage rsync ]}''${PATH:+:$PATH} export PATH - # On a file descriptor of its own, so the commands populate runs keep - # standard input. The JSON is a single line, which can't be the delimiter. - exec ${rustProgram}/bin/populate /dev/fd/3 3<<'EOF' - ${spec} + # The sources on a file descriptor of their own, so the commands + # populate runs keep standard input. Store paths in them stay + # dependencies of this script, so building it builds them. The JSON is + # a single line, which can't be the delimiter. + exec ${rustProgram}/bin/populate ${escapeShellArgs ( + optional backup "--backup" ++ + optional force "--force" ++ + optional prune "--prune" + )} /dev/fd/3 ${escapeShellArgs targetArgs} 3<<'EOF' + ${toJSON source} EOF ''; diff --git a/pkgs/populate/docs/design.md b/pkgs/populate/docs/design.md index 639d15b..cf9d356 100644 --- a/pkgs/populate/docs/design.md +++ b/pkgs/populate/docs/design.md @@ -2,27 +2,33 @@ Populate delivers sources to a target directory. It consists of two parts: -- Nix code, which turns populate's arguments into a spec: the sources as - `evalSource` returns them, the target, and the options, as JSON. It - checks the spec during evaluation, so mistakes show early. -- A program, which reads a spec and delivers the sources. It knows - nothing about Nix expressions: whatever produces a spec can drive it. +- Nix code, which turns populate's arguments into a command line: the + sources as `evalSource` returns them, as JSON, the target, and the + options. It checks the arguments during evaluation, so mistakes show + early. +- A program, which reads the sources and delivers them to the target its + command line names. It knows nothing about Nix expressions: whatever + produces sources as JSON can drive it. So there is one program for each version of populate, instead of a script for each set of arguments, and the program can be written in any language. This directory holds the program in Rust. Another implementation, e.g. in -Haskell, reads the same spec, prints the same messages, and passes the same -tests. +Haskell, takes the same command line, prints the same messages, and passes +the same tests. ## Nix side `default.nix` is `pkgs.populate`. Given populate's arguments, `{ backup ? false, force ? false, prune ? false, source, target }`, it checks them with -`checks.nix`, turns them into a spec, and returns a script running the -program with the spec on file descriptor 3, so the commands populate runs -keep standard input. The script puts the tools populate runs in front of -`PATH`, and depends on the store paths in the spec, so building it builds -them. `rustProgram` is the program, by default built by `program.nix`. +`checks.nix`, and returns a script running the program with the sources as +JSON on file descriptor 3, so the commands populate runs keep standard +input. A target string goes to the program as it is. A target attribute +set, as `mkTarget` returns it, goes part by part, as options, with +`--local` or `--remote` as krops' lib's `isLocalTarget` decides during +evaluation, like the other functions of krops. The script puts the tools +populate runs in front of `PATH`, and depends on the store paths in the +sources, so building it builds them. `rustProgram` is the program, by +default built by `program.nix`. `writeDeploy` and the other functions of `pkgs.krops` use `pkgs.populate`, so their arguments stay as they are. @@ -30,73 +36,80 @@ so their arguments stay as they are. ## Invocation ``` -populate SPEC +populate [OPTION]... SOURCES [TARGET] ``` -`SPEC` is the path of a file containing the spec, or `-` for standard -input. Exit status: +`SOURCES` is the path of a file containing the sources, or `-` for +standard input. `TARGET` is `[USER@][HOST][:PORT][PATH]`, parsed like +krops' lib's `mkTarget` parses it: the user up to the first `@`, the host +up to a `:` or a `/`, the port up to a `/`, and the path from there. What +it leaves out gets filled in when the program runs, as `mkTarget` would: +the user from `LOGNAME`, the host with the machine's name, and the path +with `/var/src`. An empty user means ssh's default. + +| Option | Meaning | +|-----------------|---------------------------------------------------------| +| `--backup` | Copy the target directory to a backup first. | +| `--force` | Create the target directory's sentinel if it's missing. | +| `--prune` | Remove what isn't a source from the target directory. | +| `--sudo` | Deliver as root, via sudo on the target. | +| `--ssh-arg ARG` | Pass `ARG` to ssh; repeatable. | +| `--user USER` | The target's user, instead of `TARGET`'s. | +| `--host HOST` | The target's host, e.g. one `TARGET` can't express. | +| `--port PORT` | The port ssh connects to. | +| `--path PATH` | The target directory. | +| `--local` | The target is the machine populate runs on. | +| `--remote` | The target isn't, even if it looks so. | +| `-h`, `--help` | Print the usage and exit. | +| `-V`, `--version` | Print populate's version and exit. | + +Options take their argument as the next argument or after `=`, and may +come before, between, or after `SOURCES` and `TARGET`, up to `--`. Exit +status: - 0: populate delivered all sources. -- 1: a check of the spec failed, or delivering did, with a message of its - own. +- 1: a check of the sources or the target failed, or delivering did, with + a message of its own. - The exit status of the command that failed, if delivering failed with that, like the shell implementation does: e.g. 255 if ssh can't reach the target, or 128 if git fetch fails. -- 2: the arguments are wrong, or the spec can't be read, isn't JSON, is of - another version, or doesn't have the spec's shape. +- 2: the command line is wrong, or the sources can't be read, aren't JSON, + or aren't sources. - 129, 130, 143: populate got SIGHUP, SIGINT, or SIGTERM, and stopped. -## Spec, version 1 - -The spec is a JSON object: - -| Field | Type | Default | Meaning | -|-----------|----------------------|---------|-----------------------------------------------| -| `version` | number | | The version of the spec: 1. | -| `backup` | boolean | false | Copy the sources to backup first. | -| `force` | boolean | false | Create the sentinel if it's missing. | -| `prune` | boolean | false | Remove what isn't a source. | -| `source` | object of sources | | The sources, by name. | -| `target` | object | | Where to deliver them. | - -`target` has these fields: - -| Field | Type | Default | Meaning | -|----------------|------------------|---------|------------------------------------------| -| `user` | string or null | null | The user to log in as. | -| `host` | string | | The host to deliver to. | -| `port` | string or null | null | The port ssh connects to. | -| `path` | string | | The target directory. | -| `sudo` | boolean | false | Deliver as root, via sudo. | -| `extraOptions` | array of strings | [] | More arguments for ssh. | -| `local` | boolean or null | null | Whether the target is local. | +## Sources -Each source is an object with the field `type`, the source's type, and a -field named after the type, holding the source's settings: +The sources are a JSON object, of sources by name. Each source is an +object with the field `type`, the source's type, and a field named after +the type, holding the source's settings: ```json -{ "type": "git", "git": { "url": "https://...", "ref": "main", ... } } +{ "nixpkgs": { "type": "git", "git": { "url": "https://...", "ref": "main" } } } ``` The types and their settings are those of `lib/types/populate.nix`, as `evalSource` returns them: with shorthands expanded, e.g. `file = "/etc/x"` as `{ "path": "/etc/x" }`, and without settings that are null. -The source types are `derivation`, `file`, `flake`, `flake-input`, `git`, -`pass`, `passage`, `pipe`, and `symlink`. +Settings with defaults may be left out. The source types are +`derivation`, `file`, `flake`, `flake-input`, `git`, `pass`, `passage`, +`pipe`, and `symlink`. -Fields the spec doesn't define are errors, so a misspelt option doesn't go -unnoticed, and a spec of another version gets refused before anything -else. A new version of the spec adds fields or changes their meaning; the -program reads the versions it knows. +Fields and settings populate doesn't know are errors, so a misspelt option +doesn't go unnoticed. The sources carry no version: this is version 0, and +a later version of populate that reads them differently will mark them. -Store paths in the spec, e.g. of a `file` source that is a derivation, must -exist when the program runs. The script `default.nix` writes depends on them. -Something producing a spec at run time has to build them first. +Store paths in the sources, e.g. of a `file` source that is a derivation, +must exist when the program runs. The script `default.nix` writes depends +on them. Something producing sources at run time has to build them first. ## Checks -The program checks the spec as `checks.nix` does, with the same messages: +The program checks the sources and the target as `checks.nix` does, with +the same messages: +- The target's host isn't empty, and neither it nor its user starts with + `-`, so ssh can't take them for options. `checks.nix` doesn't check + this, since krops' targets come from the configuration. - Source names are portable file names: letters, digits, and `._-`, not starting with `-`, and neither `.`, `..`, nor `.populate`. - The target path is absolute, not the root directory, and contains @@ -134,7 +147,7 @@ units of 1024. ## Safety Populate never changes or removes anything outside the target directory, -whatever the spec and whatever it finds on the target: +whatever the sources and whatever it finds on the target: - The checks above keep source names and the target path inside it. - A symlink at the target path of a source gets replaced, never written @@ -152,13 +165,12 @@ places populate must not touch, check this after every phase. ## Execution -Whether a target is local is what the target's `local` says, or, if -that's null, gets decided when the program runs, like krops' lib's -`isLocalTarget` does: it is, if its user is `LOGNAME`, or null if that's -empty or unset, and its host is `localhost` or the host's name, from -`HOSTNAME`, or, if that's empty or unset, the only line of `/etc/hostname` -that is a label. `default.nix` decides during evaluation, like the other -functions of krops. For a local target, populate runs commands directly. +Whether a target is local is what `--local` or `--remote` says, or else +gets decided when the program runs, like krops' lib's `isLocalTarget` +does: it is, if its user is `LOGNAME`, or null if that's empty or unset, +and its host is `localhost` or the machine's name, from `HOSTNAME`, or, if +that's empty or unset, the only line of `/etc/hostname` that is a label. +For a local target, populate runs commands directly. For a remote target, populate runs commands over ssh, sharing one master connection per target. The commands are fixed POSIX shell snippets, with diff --git a/pkgs/populate/src/check.rs b/pkgs/populate/src/check.rs index cb67865..ebe5dbf 100644 --- a/pkgs/populate/src/check.rs +++ b/pkgs/populate/src/check.rs @@ -1,13 +1,16 @@ -//! Checks of the spec, the same as checks.nix makes during evaluation, so a -//! spec from anywhere else gets checked as well. Messages are the same. +//! Checks of the sources and the target, the same as checks.nix makes during +//! evaluation, so sources from anywhere else get checked as well. Messages +//! are the same. use crate::spec::{Source, Spec}; use serde_json::{Map, Value}; use std::collections::BTreeMap; -/// The spec's checks in the order checks.nix makes them, the first failing -/// one's message. +/// The checks in the order checks.nix makes them, the first failing one's +/// message. The target's host and user, which a command line gives, come +/// first. pub fn spec(spec: &Spec) -> Result<(), String> { + target_host(&spec.target.host, spec.target.user.as_deref())?; target_path(&spec.target.path)?; for (name, source) in &spec.source { source_name(name)?; @@ -16,6 +19,24 @@ pub fn spec(spec: &Spec) -> Result<(), String> { inputs(&spec.source) } +/// The target's host and user, which ssh gets as arguments, mustn't be +/// taken for its options, and the host can't be empty. +fn target_host(host: &str, user: Option<&str>) -> Result<(), String> { + if host.is_empty() || host.starts_with('-') { + return Err(format!( + "krops: target host {} can't be used: it must not be empty or start with -", + quote(host) + )); + } + if user.is_some_and(|u| u.starts_with('-')) { + return Err(format!( + "krops: target user {} can't be used: it must not start with -", + quote(user.unwrap_or_default()) + )); + } + Ok(()) +} + /// What a setting holds. #[derive(Clone, Copy)] enum Kind { @@ -365,14 +386,37 @@ mod tests { } #[test] + fn target_hosts() { + assert!(target_host("host", Some("root")).is_ok()); + assert!(target_host("2001:db8::1", None).is_ok()); + assert_eq!( + target_host("-oProxyCommand=x", None).unwrap_err(), + r#"krops: target host "-oProxyCommand=x" can't be used: it must not be empty or start with -"# + ); + assert!(target_host("", None).is_err()); + assert!(target_host("host", Some("-x")).is_err()); + } + + #[test] fn settings() { let spec = |file: &str| -> Spec { - serde_json::from_str(&format!( - r#"{{"version": 1, "target": {{"host": "h", "path": "/a"}}, - "source": {{"x": {{"type": "file", "file": {}}}}}}}"#, - file - )) - .unwrap() + Spec { + backup: false, + force: false, + prune: false, + source: serde_json::from_str(&format!( + r#"{{"x": {{"type": "file", "file": {file}}}}}"# + )) + .unwrap(), + target: crate::spec::Target { + user: None, + host: "h".to_string(), + port: None, + path: "/a".to_string(), + sudo: false, + extra_options: vec![], + }, + } }; assert!(super::spec(&spec(r#"{"path": "/src"}"#)).is_ok()); assert!(super::spec(&spec(r#"{"path": "--rsync-path=x"}"#)).is_err()); diff --git a/pkgs/populate/src/cli.rs b/pkgs/populate/src/cli.rs new file mode 100644 index 0000000..c059c0f --- /dev/null +++ b/pkgs/populate/src/cli.rs @@ -0,0 +1,212 @@ +//! populate's command line. + +use crate::spec::TargetParts; + +/// What the command line asks for. +#[derive(Debug, PartialEq)] +pub enum Command { + Help, + Version, + Populate(Options), +} + +/// The arguments of populating. +#[derive(Debug, Default, PartialEq)] +pub struct Options { + pub backup: bool, + pub force: bool, + pub prune: bool, + /// The file the sources are in, or - for standard input. + pub sources: String, + /// The target, as given, with the options changing its parts applied. + pub target: TargetParts, + pub sudo: bool, + pub ssh_args: Vec<String>, + /// Whether the target is the machine populate runs on, if --local or + /// --remote says so. + pub local: Option<bool>, +} + +pub const USAGE: &str = "\ +usage: populate [OPTION]... SOURCES [TARGET] + +Delivers the sources in the file SOURCES, a JSON object, or standard input +if SOURCES is -, to the target directory TARGET. + +TARGET is [USER@][HOST][:PORT][PATH]. USER defaults to $LOGNAME, HOST to +the name of the machine, and PATH to /var/src. A target is local if its +user is $LOGNAME, and its host is localhost or the name of the machine; +populate reaches other targets via ssh. + +Options: + --backup copy the target directory to a backup first + --force create the target directory's sentinel if it's missing + --prune remove what isn't a source from the target directory + --sudo deliver as root, via sudo on the target + --ssh-arg ARG pass ARG to ssh; repeatable + --user USER the target's user; empty for ssh's default + --host HOST the target's host + --port PORT the port ssh connects to + --path PATH the target directory + --local the target is the machine populate runs on + --remote the target isn't, even if it looks so + -h, --help print this and exit + -V, --version print populate's version and exit +"; + +/// The command line, without the program's name. Options and the other +/// arguments may come in any order, up to --. +pub fn parse(args: &[String]) -> Result<Command, String> { + let mut options = Options::default(); + let mut positional: Vec<String> = Vec::new(); + let mut overrides: Vec<(&'static str, String)> = Vec::new(); + let mut args = args.iter(); + while let Some(arg) = args.next() { + if arg == "--" { + positional.extend(args.by_ref().cloned()); + break; + } + if arg == "-" || !arg.starts_with('-') { + positional.push(arg.clone()); + continue; + } + let (name, inline) = match arg.split_once('=') { + Some((name, value)) if name.starts_with("--") => (name, Some(value.to_string())), + _ => (arg.as_str(), None), + }; + let mut value = |name: &str| -> Result<String, String> { + match &inline { + Some(value) => Ok(value.clone()), + None => args + .next() + .cloned() + .ok_or_else(|| format!("option {name} needs an argument")), + } + }; + match name { + "-h" | "--help" => return Ok(Command::Help), + "-V" | "--version" => return Ok(Command::Version), + "--backup" => options.backup = true, + "--force" => options.force = true, + "--prune" => options.prune = true, + "--sudo" => options.sudo = true, + "--local" => options.local = Some(true), + "--remote" => options.local = Some(false), + "--ssh-arg" => options.ssh_args.push(value(name)?), + "--user" => overrides.push(("user", value(name)?)), + "--host" => overrides.push(("host", value(name)?)), + "--port" => overrides.push(("port", value(name)?)), + "--path" => overrides.push(("path", value(name)?)), + _ => return Err(format!("unknown option {name}")), + } + if inline.is_some() + && matches!( + name, + "--backup" | "--force" | "--prune" | "--sudo" | "--local" | "--remote" + ) + { + return Err(format!("option {name} takes no argument")); + } + } + let (sources, target) = match positional.as_slice() { + [sources] => (sources.clone(), String::new()), + [sources, target] => (sources.clone(), target.clone()), + [] => return Err("SOURCES is missing".to_string()), + _ => return Err("too many arguments".to_string()), + }; + options.sources = sources; + options.target = TargetParts::parse(&target)?; + for (part, value) in overrides { + let target = &mut options.target; + match part { + "user" => target.user = Some(value), + "host" => target.host = Some(value), + "port" => target.port = Some(value), + _ => target.path = Some(value), + } + } + if let Some(path) = &options.target.path { + if path.is_empty() { + return Err("the target's path is empty".to_string()); + } + } + Ok(Command::Populate(options)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result<Command, String> { + super::parse(&args.iter().map(|a| a.to_string()).collect::<Vec<_>>()) + } + + fn options(args: &[&str]) -> Options { + match parse(args) { + Ok(Command::Populate(options)) => options, + other => panic!("{other:?}"), + } + } + + #[test] + fn targets() { + let o = options(&["s.json", "root@host:2222/var/src"]); + assert_eq!(o.sources, "s.json"); + assert_eq!( + o.target, + TargetParts { + user: Some("root".to_string()), + host: Some("host".to_string()), + port: Some("2222".to_string()), + path: Some("/var/src".to_string()), + } + ); + assert_eq!(options(&["-"]).target, TargetParts::default()); + let o = options(&["--host", "2001:db8::1", "-", "root@x", "--port=22"]); + assert_eq!(o.target.user.as_deref(), Some("root")); + assert_eq!(o.target.host.as_deref(), Some("2001:db8::1")); + assert_eq!(o.target.port.as_deref(), Some("22")); + } + + #[test] + fn flags() { + let o = options(&[ + "--backup", + "--prune", + "--sudo", + "--ssh-arg", + "-o", + "--ssh-arg=X=y", + "--remote", + "s", + ]); + assert!(o.backup && o.prune && o.sudo && !o.force); + assert_eq!(o.ssh_args, ["-o", "X=y"]); + assert_eq!(o.local, Some(false)); + // After --, arguments are no options. + assert_eq!(options(&["--", "--sources"]).sources, "--sources"); + } + + #[test] + fn errors() { + assert_eq!(parse(&["--help", "--bogus"]), Ok(Command::Help)); + assert_eq!( + parse(&["--bogus"]), + Err("unknown option --bogus".to_string()) + ); + assert_eq!(parse(&[]), Err("SOURCES is missing".to_string())); + assert_eq!( + parse(&["a", "b", "c"]), + Err("too many arguments".to_string()) + ); + assert_eq!( + parse(&["s", "--user"]), + Err("option --user needs an argument".to_string()) + ); + assert_eq!( + parse(&["--prune=yes", "s"]), + Err("option --prune takes no argument".to_string()) + ); + assert!(parse(&["s", "host:"]).is_err()); + } +} diff --git a/pkgs/populate/src/main.rs b/pkgs/populate/src/main.rs index 2884fd0..eeed1ef 100644 --- a/pkgs/populate/src/main.rs +++ b/pkgs/populate/src/main.rs @@ -1,7 +1,9 @@ -//! populate SPEC: delivers sources to a target directory, as the spec, a -//! JSON file or - for standard input, says. See docs/design.md. +//! populate [OPTION]... SOURCES [TARGET]: delivers the sources in the JSON +//! file SOURCES, or standard input for -, to the target directory TARGET. +//! See README.md. mod check; +mod cli; mod flake; mod flake_input; mod populate; @@ -13,15 +15,36 @@ mod spec; use std::io::Read; use std::process::ExitCode; -/// Exit status when the spec can't be read, isn't JSON, or isn't a spec. +/// Exit status when the command line is wrong, or the sources can't be read +/// or aren't sources. const USAGE: u8 = 2; fn main() -> ExitCode { let args: Vec<String> = std::env::args().skip(1).collect(); - let [path] = args.as_slice() else { - eprintln!("usage: populate SPEC"); - return ExitCode::from(USAGE); + let options = match cli::parse(&args) { + Ok(cli::Command::Help) => { + print!("{}", cli::USAGE); + return ExitCode::SUCCESS; + } + Ok(cli::Command::Version) => { + println!("populate {}", env!("CARGO_PKG_VERSION")); + return ExitCode::SUCCESS; + } + Ok(cli::Command::Populate(options)) => options, + Err(message) => { + eprintln!("populate: {message}"); + eprint!( + "{}", + cli::USAGE + .lines() + .next() + .map(|l| format!("{l}\n")) + .unwrap_or_default() + ); + return ExitCode::from(USAGE); + } }; + let path = &options.sources; let text = if path == "-" { let mut text = String::new(); std::io::stdin().read_to_string(&mut text).map(|_| text) @@ -35,29 +58,31 @@ fn main() -> ExitCode { return ExitCode::from(USAGE); } }; - let version = serde_json::from_str::<serde_json::Value>(&text) - .ok() - .and_then(|value| value.get("version").and_then(|v| v.as_u64())); - if let Some(version) = version.filter(|v| *v != spec::VERSION) { - eprintln!( - "populate: {path} is a spec of version {version}, this populate reads version {}", - spec::VERSION - ); - return ExitCode::from(USAGE); - } - let spec: spec::Spec = match serde_json::from_str(&text) { - Ok(spec) => spec, + let source: spec::Sources = match serde_json::from_str(&text) { + Ok(source) => source, Err(e) => { - eprintln!("populate: {path} isn't a spec: {e}"); + eprintln!("populate: {path} doesn't contain sources: {e}"); return ExitCode::from(USAGE); } }; + let origin = run::Origin::from_env(); + let target = options + .target + .complete(&origin, options.sudo, options.ssh_args); + let local = options.local.unwrap_or_else(|| origin.is_local(&target)); + let spec = spec::Spec { + backup: options.backup, + force: options.force, + prune: options.prune, + source, + target, + }; if let Err(message) = check::spec(&spec) { eprintln!("{message}"); return ExitCode::FAILURE; } run::trap_signals(); - let context = match run::Context::new(spec.target.clone(), spec.target.local) { + let context = match run::Context::new(spec.target.clone(), local) { Ok(context) => context, Err(failure) => return exit(failure), }; diff --git a/pkgs/populate/src/run.rs b/pkgs/populate/src/run.rs index a16ffe4..c9bd9fb 100644 --- a/pkgs/populate/src/run.rs +++ b/pkgs/populate/src/run.rs @@ -111,8 +111,7 @@ pub struct Context { impl Context { /// Creates the temporary directory, and the ssh script in it. - pub fn new(target: Target, local: Option<bool>) -> Result<Context> { - let local = local.unwrap_or_else(|| is_local(&target)); + pub fn new(target: Target, local: bool) -> Result<Context> { let output = Command::new("mktemp") .args(["-dt", "populate.XXXXXXXX"]) .stderr(Stdio::inherit()) @@ -358,37 +357,50 @@ pub fn output(command: &mut Command) -> Result<String> { } } -/// Whether the target is the machine populate runs on, as the user running -/// it, like krops' lib's isLocalTarget decides during evaluation. -fn is_local(target: &Target) -> bool { - let etc_hostname = fs::read_to_string("/etc/hostname").ok(); - is_local_with( - target, - std::env::var("LOGNAME").ok().as_deref(), - std::env::var("HOSTNAME").ok().as_deref(), - etc_hostname.as_deref(), - ) +/// The user running populate, and the name of the machine it runs on, like +/// krops' lib's mkTarget and isLocalTarget take them. +#[derive(Clone, Debug)] +pub struct Origin { + pub user: Option<String>, + pub host: String, } -/// is_local, given LOGNAME, HOSTNAME, and the contents of /etc/hostname. -/// Like lib.maybeEnv, an empty variable counts as unset. The user running -/// populate is LOGNAME, if set, and the machine's name is HOSTNAME, or else -/// the only line of /etc/hostname that is a label, or else localhost. The -/// target is local if its user is that user, and its host is that name or -/// localhost. -fn is_local_with( - target: &Target, - logname: Option<&str>, - hostname: Option<&str>, - etc_hostname: Option<&str>, -) -> bool { - let user = logname.filter(|v| !v.is_empty()); - let host = hostname - .filter(|v| !v.is_empty()) - .map(str::to_string) - .or_else(|| etc_hostname.and_then(host_name)) - .unwrap_or_else(|| "localhost".to_string()); - target.user.as_deref() == user && (target.host == "localhost" || target.host == host) +impl Origin { + /// From LOGNAME, HOSTNAME, and /etc/hostname. + pub fn from_env() -> Origin { + let etc_hostname = fs::read_to_string("/etc/hostname").ok(); + Origin::from( + std::env::var("LOGNAME").ok().as_deref(), + std::env::var("HOSTNAME").ok().as_deref(), + etc_hostname.as_deref(), + ) + } + + /// Given LOGNAME, HOSTNAME, and the contents of /etc/hostname. Like + /// lib.maybeEnv, an empty variable counts as unset. The user is + /// LOGNAME, if set, and the machine's name is HOSTNAME, or else the only + /// line of /etc/hostname that is a label, or else localhost. + pub fn from( + logname: Option<&str>, + hostname: Option<&str>, + etc_hostname: Option<&str>, + ) -> Origin { + Origin { + user: logname.filter(|v| !v.is_empty()).map(str::to_string), + host: hostname + .filter(|v| !v.is_empty()) + .map(str::to_string) + .or_else(|| etc_hostname.and_then(host_name)) + .unwrap_or_else(|| "localhost".to_string()), + } + } + + /// Whether target is the machine populate runs on, as the user running + /// it: its user is that user, and its host is the machine's name or + /// localhost. + pub fn is_local(&self, target: &Target) -> bool { + target.user == self.user && (target.host == "localhost" || target.host == self.host) + } } /// The machine's name in the contents of /etc/hostname: its only line that @@ -425,15 +437,20 @@ mod tests { } fn target(user: Option<&str>, host: &str) -> Target { - serde_json::from_value(serde_json::json!({ - "user": user, "host": host, "path": "/var/src" - })) - .unwrap() + Target { + user: user.map(str::to_string), + host: host.to_string(), + port: None, + path: "/var/src".to_string(), + sudo: false, + extra_options: vec![], + } } #[test] fn local_targets() { - let local = |t: &Target, logname, hostname, etc| is_local_with(t, logname, hostname, etc); + let local = + |t: &Target, logname, hostname, etc| Origin::from(logname, hostname, etc).is_local(t); // An empty LOGNAME counts as unset, i.e. the user null. assert!(local(&target(None, "localhost"), Some(""), None, None)); assert!(!local(&target(None, "localhost"), Some("tv"), None, None)); diff --git a/pkgs/populate/src/spec.rs b/pkgs/populate/src/spec.rs index 8fc74fc..cde5e29 100644 --- a/pkgs/populate/src/spec.rs +++ b/pkgs/populate/src/spec.rs @@ -1,12 +1,11 @@ -//! The spec: populate's arguments as JSON, as docs/design.md describes them. +//! What populate delivers where: the sources, as JSON, and the target, as +//! the command line gives it. See README.md. +use crate::run::Origin; use serde::Deserialize; use serde_json::{Map, Value}; use std::collections::BTreeMap; -/// The version of the spec this populate reads. -pub const VERSION: u64 = 1; - /// The source types populate knows. pub const SOURCE_TYPES: &[&str] = &[ "derivation", @@ -20,39 +19,86 @@ pub const SOURCE_TYPES: &[&str] = &[ "symlink", ]; -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] +/// The sources, by name, as a JSON object. +pub type Sources = BTreeMap<String, Source>; + +#[derive(Debug)] pub struct Spec { - /// Checked before the rest, so it's only part of the shape here. - #[allow(dead_code)] - pub version: u64, - #[serde(default)] pub backup: bool, - #[serde(default)] pub force: bool, - #[serde(default)] pub prune: bool, - pub source: BTreeMap<String, Source>, + pub source: Sources, pub target: Target, } -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[derive(Clone, Debug)] pub struct Target { - #[serde(default)] pub user: Option<String>, pub host: String, - #[serde(default)] pub port: Option<String>, pub path: String, - #[serde(default)] pub sudo: bool, - #[serde(default)] pub extra_options: Vec<String>, - /// Whether the target is the machine populate runs on, or null to - /// decide that when it runs. - #[serde(default)] - pub local: Option<bool>, +} + +/// The parts of a target that [USER@][HOST][:PORT][PATH] gives, like krops' +/// lib's mkTarget parses it. +#[derive(Debug, Default, PartialEq)] +pub struct TargetParts { + pub user: Option<String>, + pub host: Option<String>, + pub port: Option<String>, + pub path: Option<String>, +} + +impl TargetParts { + /// Parses text like the regular expression + /// (([^@]*)@)?(([^:/]+))?(:([^/]+))?(/.*)? of mkTarget: the user up to + /// the first @, the host up to a : or a /, the port up to a /, and the + /// path from there. + pub fn parse(text: &str) -> Result<TargetParts, String> { + let invalid = || format!("invalid target {text:?}: not [USER@][HOST][:PORT][PATH]"); + let (user, rest) = match text.split_once('@') { + Some((user, rest)) => (Some(user.to_string()), rest), + None => (None, text), + }; + let end = rest.find([':', '/']).unwrap_or(rest.len()); + let (host, rest) = rest.split_at(end); + let (port, path) = match rest.strip_prefix(':') { + Some(rest) => { + let end = rest.find('/').unwrap_or(rest.len()); + let (port, path) = rest.split_at(end); + if port.is_empty() { + return Err(invalid()); + } + (Some(port.to_string()), path) + } + None => (None, rest), + }; + Ok(TargetParts { + user, + host: Some(host.to_string()).filter(|h| !h.is_empty()), + port, + path: Some(path.to_string()).filter(|p| !p.is_empty()), + }) + } + + /// The target, with what's missing as mkTarget defaults it: the user + /// running populate, the machine's name, and /var/src. An empty user + /// means ssh's default. + pub fn complete(self, origin: &Origin, sudo: bool, extra_options: Vec<String>) -> Target { + Target { + user: match self.user { + Some(user) => Some(user).filter(|u| !u.is_empty()), + None => origin.user.clone(), + }, + host: self.host.unwrap_or_else(|| origin.host.clone()), + port: self.port, + path: self.path.unwrap_or_else(|| "/var/src".to_string()), + sudo, + extra_options, + } + } } /// A source: its type, and the settings of that type, under the type's name. @@ -97,8 +143,8 @@ pub struct Follows { } impl Source { - /// The inputs this source draws from other sources, like krops' lib's - /// flakeFollows. + /// The inputs this source draws from other sources, like flakeFollows + /// of populate's Nix library. pub fn follows(&self) -> Vec<Follows> { fn walk(prefix: &[String], inputs: &Map<String, Value>, out: &mut Vec<Follows>) { for (name, input) in inputs { @@ -122,3 +168,69 @@ impl Source { out } } + +#[cfg(test)] +mod tests { + use super::*; + + fn parts(text: &str) -> TargetParts { + TargetParts::parse(text).unwrap() + } + + fn some(s: &str) -> Option<String> { + Some(s.to_string()) + } + + #[test] + fn target_syntax() { + assert_eq!(parts(""), TargetParts::default()); + assert_eq!( + parts("/srv/src"), + TargetParts { + path: some("/srv/src"), + ..Default::default() + } + ); + assert_eq!( + parts("host"), + TargetParts { + host: some("host"), + ..Default::default() + } + ); + assert_eq!( + parts("tv@host:22/x"), + TargetParts { + user: some("tv"), + host: some("host"), + port: some("22"), + path: some("/x") + } + ); + // The user ends at the first @, the port at the first /. + assert_eq!(parts("a@b@c").host, some("b@c")); + assert_eq!(parts("h:p:q/r").port, some("p:q")); + assert_eq!(parts(":22").host, None); + assert!(TargetParts::parse("host:").is_err()); + assert!(TargetParts::parse("host:/x").is_err()); + } + + #[test] + fn target_defaults() { + let origin = Origin { + user: some("tv"), + host: "ni".to_string(), + }; + let target = parts("").complete(&origin, false, vec![]); + assert_eq!( + ( + target.user.as_deref(), + target.host.as_str(), + target.path.as_str() + ), + (Some("tv"), "ni", "/var/src") + ); + // An empty user means ssh's default. + assert_eq!(parts("@h").complete(&origin, true, vec![]).user, None); + } +} |
