aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--pkgs/populate/default.nix67
-rw-r--r--pkgs/populate/docs/design.md140
-rw-r--r--pkgs/populate/src/check.rs64
-rw-r--r--pkgs/populate/src/cli.rs212
-rw-r--r--pkgs/populate/src/main.rs65
-rw-r--r--pkgs/populate/src/run.rs89
-rw-r--r--pkgs/populate/src/spec.rs162
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);
+ }
+}