diff options
| author | tv <tv@krebsco.de> | 2026-11-14 00:24:45 +0000 |
|---|---|---|
| committer | tv <tv@krebsco.de> | 2026-11-14 01:43:19 +0100 |
| commit | 8f08426ae3a2e5f0ecc5d010bc6469b41e3d7678 (patch) | |
| tree | f6573ff55af0b9eacf77e7d58d3668c1129a589a /pkgs | |
| parent | 57d47a36129b459f0fac519a460c581870a54bbf (diff) | |
populate: a command line instead of a spec
The program takes populate's arguments on its command line, like other
programs: populate [OPTION]... SOURCES [TARGET], with the sources as
JSON in the file SOURCES, or on standard input for -. TARGET has the
syntax of krops' lib's mkTarget, [USER@][HOST][:PORT][PATH], parsed the
same way, and what it leaves out gets filled in when populate runs, as
mkTarget would, from LOGNAME and the machine's name. Options set what
the syntax can't express, or override its parts: --sudo, --ssh-arg,
--user, --host, --port, --path, and --local or --remote for whether the
target is the machine populate runs on, which populate otherwise
decides like isLocalTarget. The sources carry no version.
pkgs.populate passes a target attribute set, as mkTarget returns it,
part by part, with --local or --remote as evaluation decides, so
nothing changes for krops. A target string goes to the program as it
is.
The program also refuses target hosts and users starting with -, which
ssh would take for options, now that a command line can give them.
Diffstat (limited to 'pkgs')
| -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); + } +} |
