diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..b0428c4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,17 @@ +name: Build +on: + pull_request: + push: +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - uses: cachix/install-nix-action@v12 + - run: nix-build + tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2.3.4 + - uses: cachix/install-nix-action@v12 + - run: nix-shell dev-shell.nix --run "cargo test" diff --git a/Cargo.lock b/Cargo.lock index 2596879..e09b2f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -18,6 +18,27 @@ dependencies = [ "winapi", ] +[[package]] +name = "async-stream" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3670df70cbc01729f901f94c887814b3c68db038aad1329a418bae178bc5295c" +dependencies = [ + "async-stream-impl", + "futures-core", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3548b8efc9f8e8a5a0a2808c5bd8451a9031b9e5b879a79590304ae928b0a70" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.42" @@ -114,6 +135,7 @@ dependencies = [ "sys-info", "tempfile", "tokio", + "tokio-test", "validator", ] @@ -829,6 +851,30 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-stream" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1981ad97df782ab506a1f43bf82c967326960d278acf3bf8279809648c3ff3ea" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-test" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c7d205f6f59b03f9e824ac86eaba635a98395f287756ecc8a06464779c399bf" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "unicode-bidi" version = "0.3.4" diff --git a/Cargo.toml b/Cargo.toml index 07e716d..0e84e04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ serde_json = "1.0" sys-info = "0.7.0" snafu = "0.6.10" tempfile = "3.1.0" +tokio-test = "0.4.0" validator = { version = "0.12", features = ["derive"] } [dependencies.tokio] diff --git a/README.md b/README.md index b6aa02a..f2c9e75 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Colmena +![Build](https://github.com/zhaofengli/colmena/workflows/Build/badge.svg) + Colmena is a simple, stateless NixOS deployment tool modeled after [NixOps](https://github.com/NixOS/nixops) and [Morph](https://github.com/DBCDK/morph), written in Rust. It's a thin wrapper over Nix commands like `nix-instantiate` and `nix-copy-closure`, and supports parallel deployment. diff --git a/default.nix b/default.nix index 00ecbc9..5cde34b 100644 --- a/default.nix +++ b/default.nix @@ -14,5 +14,8 @@ in rustPlatform.buildRustPackage { src = ./.; }; }; - cargoSha256 = "1ibhn8bbcx0y9gjl42d9ba478j6a5dr928v0ds61vwn7lbm68dzr"; + cargoSha256 = "0rkpv9afkg33i1d0yjlq34zrdqy3i6ldbdag0hgsvxi3v3jfg4qv"; + + # Recursive Nix is not stable yet + doCheck = false; } diff --git a/dev-shell.nix b/dev-shell.nix index 2637bbb..975f051 100644 --- a/dev-shell.nix +++ b/dev-shell.nix @@ -4,4 +4,7 @@ in pkgs.mkShell { buildInputs = with pkgs; [ rustc cargo ]; + shellHook = '' + export NIX_PATH=nixpkgs=${pkgs.path} + ''; } diff --git a/src/nix/mod.rs b/src/nix/mod.rs index cadd36d..25c2b65 100644 --- a/src/nix/mod.rs +++ b/src/nix/mod.rs @@ -31,6 +31,9 @@ pub use profile::{Profile, ProfileMap}; pub mod deployment; pub use deployment::{Goal, Target, Deployment}; +#[cfg(test)] +mod tests; + pub const SYSTEM_PROFILE: &'static str = "/nix/var/nix/profiles/system"; pub type NixResult = Result; diff --git a/src/nix/tests.rs b/src/nix/tests.rs new file mode 100644 index 0000000..6750e5a --- /dev/null +++ b/src/nix/tests.rs @@ -0,0 +1,212 @@ +//! Integration-ish tests + +use super::*; + +use std::collections::HashSet; +use std::hash::Hash; +use std::io::Write; +use std::iter::{FromIterator, Iterator}; +use std::ops::Deref; + +use tempfile::NamedTempFile; +use tokio_test::block_on; + +fn set_eq(a: &[T], b: &[T]) -> bool +where + T: Eq + Hash, +{ + let a: HashSet<_> = HashSet::from_iter(a); + let b: HashSet<_> = HashSet::from_iter(b); + + a == b +} + +/// An ad-hoc Hive configuration. +struct TempHive { + hive: Hive, + _temp_file: NamedTempFile, +} + +impl TempHive { + pub fn new(text: &str) -> Self { + let mut temp_file = NamedTempFile::new().unwrap(); + temp_file.write_all(text.as_bytes()).unwrap(); + + let hive = Hive::new(temp_file.path()).unwrap(); + + Self { + hive, + _temp_file: temp_file, + } + } + + /// Asserts that the configuration is valid. + /// + /// Note that this _does not_ attempt to evaluate `config.toplevel`. + pub fn valid(text: &str) { + let mut hive = Self::new(text); + hive.hive.show_trace(true); + assert!(block_on(hive.deployment_info()).is_ok()); + } + + /// Asserts that the configuration is invalid. + /// + /// Note that this _does not_ attempt to evaluate `config.toplevel`. + pub fn invalid(text: &str) { + let hive = Self::new(text); + assert!(block_on(hive.deployment_info()).is_err()); + } +} + +impl Deref for TempHive { + type Target = Hive; + + fn deref(&self) -> &Hive { + &self.hive + } +} + +// eval.nix tests + +#[test] +fn test_parse_simple() { + let hive = TempHive::new(r#" + { + defaults = { pkgs, ... }: { + environment.systemPackages = with pkgs; [ + vim wget curl + ]; + boot.loader.grub.device = "/dev/sda"; + fileSystems."/" = { + device = "/dev/sda1"; + fsType = "ext4"; + }; + + deployment.tags = [ "common-tag" ]; + }; + + host-a = { name, nodes, ... }: { + networking.hostName = name; + time.timeZone = nodes.host-b.config.time.timeZone; + + deployment.tags = [ "a-tag" ]; + }; + + host-b = { + deployment = { + targetHost = "somehost.tld"; + targetPort = 1234; + targetUser = "luser"; + }; + time.timeZone = "America/Los_Angeles"; + }; + } + "#); + let nodes = block_on(hive.deployment_info()).unwrap(); + + assert!(set_eq( + &["host-a", "host-b"], + &nodes.keys().map(String::as_str).collect::>(), + )); + + // host-a + assert!(set_eq( + &["common-tag", "a-tag"], + &nodes["host-a"].tags.iter().map(String::as_str).collect::>(), + )); + assert_eq!(Some("host-a"), nodes["host-a"].target_host.as_deref()); + assert_eq!(None, nodes["host-a"].target_port); + assert_eq!("root", &nodes["host-a"].target_user); + + // host-b + assert!(set_eq( + &["common-tag"], + &nodes["host-b"].tags.iter().map(String::as_str).collect::>(), + )); + assert_eq!(Some("somehost.tld"), nodes["host-b"].target_host.as_deref()); + assert_eq!(Some(1234), nodes["host-b"].target_port); + assert_eq!("luser", &nodes["host-b"].target_user); +} + +#[test] +fn test_parse_node_references() { + TempHive::valid(r#" + with builtins; + { + host-a = { name, nodes, ... }: + assert name == "host-a"; + assert length (attrNames nodes) == 2; + { + time.timeZone = "America/Los_Angeles"; + }; + host-b = { name, nodes, ... }: + assert name == "host-b"; + assert length (attrNames nodes) == 2; + assert nodes.host-a.config.time.timeZone == "America/Los_Angeles"; + {}; + } + "#); +} + +#[test] +fn test_parse_unknown_option() { + TempHive::invalid(r#" + { + bad = { + deployment.noSuchOption = "not kidding"; + }; + } + "#); +} + +#[test] +fn test_parse_key_text() { + TempHive::valid(r#" + { + test = { + deployment.keys.topSecret = { + text = "be sure to drink your ovaltine"; + }; + }; + } + "#); +} + +#[test] +fn test_parse_key_command_good() { + TempHive::valid(r#" + { + test = { + deployment.keys.elohim = { + keyCommand = [ "eternalize" ]; + }; + }; + } + "#); +} + +#[test] +fn test_parse_key_command_bad() { + TempHive::invalid(r#" + { + test = { + deployment.keys.elohim = { + keyCommand = "transcend"; + }; + }; + } + "#); +} + +#[test] +fn test_parse_key_file() { + TempHive::valid(r#" + { + test = { + deployment.keys.l337hax0rwow = { + keyFile = "/etc/passwd"; + }; + }; + } + "#); +}