├── .gitignore ├── LICENSE.md ├── README.md ├── examples └── flake.nix ├── flake.lock ├── flake.nix └── tests ├── default.nix └── vmTest ├── common.nix ├── default.nix ├── nixinateeAdditional.nix └── nixinateeBase.nix /.gitignore: -------------------------------------------------------------------------------- 1 | # Prevents Nix results from `nix build`, etc, from being checked in 2 | # accidentally. 3 | *result* 4 | 5 | # Github Workflows are not what we use to perform CI, we use Hercules-CI 6 | # instead. 7 | .github/workflows 8 | 9 | # Dockerfiles, or docker-compose files are not how we build or deploy software. 10 | # Only Nix expressions are allowed. 11 | *Dockerfile* 12 | *docker-compose* 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Matthew Croughan 4 | Copyright (c) 2022 Platonic Systems Limited 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nixinate 🕶️ 2 | 3 | Nixinate is a proof of concept that generates a deployment script for each 4 | `nixosConfiguration` you already have in your flake, which can be ran via `nix 5 | run`, thanks to the `apps` attribute of the [flake 6 | schema](https://nixos.wiki/wiki/Flakes#Flake_schema). 7 | 8 | ## Usage 9 | 10 | To add and configure `nixinate` in your own flake, you need to: 11 | 12 | 1. Add the result of `nixinate self` to the `apps` attribute of your flake. 13 | 2. Add and configure `_module.args.nixinate` to the `nixosConfigurations` you want to deploy 14 | 15 | Below is a minimal example: 16 | 17 | ```nix 18 | { 19 | inputs = { 20 | nixpkgs.url = "github:nixos/nixpkgs/nixos-23.05"; 21 | nixinate.url = "github:matthewcroughan/nixinate"; 22 | }; 23 | 24 | outputs = { self, nixpkgs, nixinate }: { 25 | apps = nixinate.nixinate.x86_64-linux self; 26 | nixosConfigurations = { 27 | myMachine = nixpkgs.lib.nixosSystem { 28 | system = "x86_64-linux"; 29 | modules = [ 30 | (import ./my-configuration.nix) 31 | { 32 | _module.args.nixinate = { 33 | host = "itchy.scratchy.com"; 34 | sshUser = "matthew"; 35 | buildOn = "remote"; # valid args are "local" or "remote" 36 | substituteOnTarget = true; # if buildOn is "local" then it will substitute on the target, "-s" 37 | hermetic = false; 38 | }; 39 | } 40 | # ... other configuration ... 41 | ]; 42 | }; 43 | }; 44 | }; 45 | } 46 | ``` 47 | 48 | Each `nixosConfiguration` you have configured should have a deployment script in 49 | `apps.nixinate`, visible in `nix flake show` like this: 50 | 51 | ``` 52 | $ nix flake show 53 | git+file:///etc/nixos 54 | ├───apps 55 | │ └───nixinate 56 | │ └───myMachine: app 57 | └───nixosConfigurations 58 | └───myMachine: NixOS configuration 59 | ``` 60 | 61 | To finally execute the deployment script, use `nix run .#apps.nixinate.myMachine` 62 | 63 | #### Example Run 64 | 65 | ``` 66 | [root@myMachine:/etc/nixos]# nix run .#apps.nixinate.myMachine 67 | 🚀 Deploying nixosConfigurations.myMachine from /nix/store/279p8aaclmng8kc3mdmrmi6q3n76r1i7-source 68 | 👤 SSH User: matthew 69 | 🌐 SSH Host: itchy.scratchy.com 70 | 🚀 Sending flake to myMachine via nix copy: 71 | (matthew@itchy.scratchy.com) Password: 72 | 🤞 Activating configuration on myMachine via ssh: 73 | (matthew@itchy.scratchy.com) Password: 74 | [sudo] password for matthew: 75 | building the system configuration... 76 | activating the configuration... 77 | setting up /etc... 78 | reloading user units for matthew... 79 | setting up tmpfiles 80 | Connection to itchy.scratchy.com closed. 81 | ``` 82 | 83 | # Available arguments via `_module.args.nixinate` 84 | 85 | - `host` *`string`* 86 | 87 | A string representing the hostname or IP address of a machine to connect to 88 | via ssh. 89 | 90 | - `sshUser` *`string`* 91 | 92 | A string representing the username a machine to connect to via ssh. 93 | 94 | - `buildOn` *`"remote"`* or *`"local"`* 95 | 96 | - `"remote"` 97 | 98 | Push the flake to the remote, build and activate entirely remotely, 99 | returning logs via SSH. 100 | 101 | - `"local"` 102 | 103 | Build the system closure locally, copy to the remote and activate. 104 | 105 | - `hermetic` *`bool`* 106 | 107 | Whether to copy Nix to the remote for usage when building and activating, 108 | instead of using the Nix which is already installed on the remote. 109 | 110 | - `substituteOnTarget` *`bool`* 111 | 112 | Whether to fetch closures and paths from the remote, even when building 113 | locally. This makes sense in most cases, because the remote will have already 114 | built a lot of the paths from the previous deployment. However, if the remote 115 | has a slow upload bandwidth, this would not be a good idea to enable. 116 | 117 | # Project Principles 118 | 119 | * No Premature Optimization: Make it work, then optimize it later if the 120 | optimization is taking a lot of time to figure out now. 121 | * KISS: Keep it simple, stupid. Unnecesary complexity should be avoided. 122 | -------------------------------------------------------------------------------- /examples/flake.nix: -------------------------------------------------------------------------------- 1 | # TODO: use a relative path to nixinate, so that everything is contained within this repo. 2 | # This would rely upon https://github.com/NixOS/nix/pull/5437 being merged. 3 | { 4 | inputs = { 5 | nixpkgs.url = "github:nixos/nixpkgs/nixos-21.11"; 6 | nixinate.url = "github:matthewcroughan/nixinate"; 7 | }; 8 | 9 | outputs = { self, nixpkgs, nixinate }: { 10 | apps = nixinate.nixinate.x86_64-linux self; 11 | nixosConfigurations = { 12 | myMachine = nixpkgs.lib.nixosSystem { 13 | system = "x86_64-linux"; 14 | modules = [ 15 | { 16 | _module.args.nixinate = { 17 | host = "itchy.scratchy.com"; 18 | sshUser = "matthew"; 19 | buildOn = "local"; # valid args are "local" or "remote" 20 | }; 21 | } 22 | # ... other configuration ... 23 | ]; 24 | }; 25 | }; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "nixpkgs": { 4 | "locked": { 5 | "lastModified": 1653060744, 6 | "narHash": "sha256-kfRusllRumpt33J1hPV+CeCCylCXEU7e0gn2/cIM7cY=", 7 | "owner": "nixos", 8 | "repo": "nixpkgs", 9 | "rev": "dfd82985c273aac6eced03625f454b334daae2e8", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "nixos", 14 | "ref": "nixos-unstable", 15 | "repo": "nixpkgs", 16 | "type": "github" 17 | } 18 | }, 19 | "root": { 20 | "inputs": { 21 | "nixpkgs": "nixpkgs" 22 | } 23 | } 24 | }, 25 | "root": "root", 26 | "version": 7 27 | } 28 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Nixinate your systems 🕶️"; 3 | inputs = { 4 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 5 | }; 6 | outputs = { self, nixpkgs, ... }@inputs: 7 | let 8 | version = builtins.substring 0 8 self.lastModifiedDate; 9 | supportedSystems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; 10 | forSystems = systems: f: 11 | nixpkgs.lib.genAttrs systems 12 | (system: f system nixpkgs.legacyPackages.${system}); 13 | forAllSystems = forSystems supportedSystems; 14 | nixpkgsFor = forAllSystems (system: pkgs: import nixpkgs { inherit system; overlays = [ self.overlay ]; }); 15 | in rec 16 | { 17 | herculesCI.ciSystems = [ "x86_64-linux" ]; 18 | overlay = final: prev: { 19 | nixinate = { 20 | nix = prev.pkgs.writeShellScriptBin "nix" 21 | ''${final.nixVersions.unstable}/bin/nix --experimental-features "nix-command flakes" "$@"''; 22 | nixos-rebuild = prev.nixos-rebuild.override { inherit (final) nix; }; 23 | }; 24 | generateApps = flake: 25 | let 26 | machines = builtins.attrNames flake.nixosConfigurations; 27 | validMachines = final.lib.remove "" (final.lib.forEach machines (x: final.lib.optionalString (flake.nixosConfigurations."${x}"._module.args ? nixinate) "${x}" )); 28 | mkDeployScript = { machine, dryRun }: let 29 | inherit (builtins) abort; 30 | inherit (final.lib) getExe optionalString concatStringsSep; 31 | nix = "${getExe final.nix}"; 32 | nixos-rebuild = "${getExe final.nixos-rebuild}"; 33 | openssh = "${getExe final.openssh}"; 34 | flock = "${getExe final.flock}"; 35 | 36 | n = flake.nixosConfigurations.${machine}._module.args.nixinate; 37 | hermetic = n.hermetic or true; 38 | user = n.sshUser or "root"; 39 | host = n.host; 40 | where = n.buildOn or "remote"; 41 | remote = if where == "remote" then true else if where == "local" then false else abort "_module.args.nixinate.buildOn is not set to a valid value of 'local' or 'remote'"; 42 | substituteOnTarget = n.substituteOnTarget or false; 43 | switch = if dryRun then "dry-activate" else "switch"; 44 | nixOptions = concatStringsSep " " (n.nixOptions or []); 45 | 46 | script = 47 | '' 48 | set -e 49 | echo "🚀 Deploying nixosConfigurations.${machine} from ${flake}" 50 | echo "👤 SSH User: ${user}" 51 | echo "🌐 SSH Host: ${host}" 52 | '' + (if remote then '' 53 | echo "🚀 Sending flake to ${machine} via nix copy:" 54 | ( set -x; ${nix} ${nixOptions} copy ${flake} --to ssh://${user}@${host} ) 55 | '' + (if hermetic then '' 56 | echo "🤞 Activating configuration hermetically on ${machine} via ssh:" 57 | ( set -x; ${nix} ${nixOptions} copy --derivation ${nixos-rebuild} ${flock} --to ssh://${user}@${host} ) 58 | ( set -x; ${openssh} $NIX_SSHOPTS -t ${user}@${host} "sudo nix-store --realise ${nixos-rebuild} ${flock} && sudo ${flock} -w 60 /dev/shm/nixinate-${machine} ${nixos-rebuild} ${nixOptions} ${switch} --flake ${flake}#${machine}" ) 59 | '' else '' 60 | echo "🤞 Activating configuration non-hermetically on ${machine} via ssh:" 61 | ( set -x; ${openssh} $NIX_SSHOPTS -t ${user}@${host} "sudo flock -w 60 /dev/shm/nixinate-${machine} nixos-rebuild ${switch} --flake ${flake}#${machine}" ) 62 | '') 63 | else '' 64 | echo "🔨 Building system closure locally, copying it to remote store and activating it:" 65 | ( set -x; NIX_SSHOPTS="-t" ${flock} -w 60 /dev/shm/nixinate-${machine} ${nixos-rebuild} ${nixOptions} ${switch} --flake ${flake}#${machine} --target-host ${user}@${host} --use-remote-sudo ${optionalString substituteOnTarget "-s"} ) 66 | 67 | ''); 68 | in final.writeShellScript "deploy-${machine}.sh" script; 69 | in 70 | { 71 | nixinate = 72 | ( 73 | nixpkgs.lib.genAttrs 74 | validMachines 75 | (x: 76 | { 77 | type = "app"; 78 | program = toString (mkDeployScript { 79 | machine = x; 80 | dryRun = false; 81 | }); 82 | } 83 | ) 84 | // nixpkgs.lib.genAttrs 85 | (map (a: a + "-dry-run") validMachines) 86 | (x: 87 | { 88 | type = "app"; 89 | program = toString (mkDeployScript { 90 | machine = nixpkgs.lib.removeSuffix "-dry-run" x; 91 | dryRun = true; 92 | }); 93 | } 94 | ) 95 | ); 96 | }; 97 | }; 98 | nixinate = forAllSystems (system: pkgs: nixpkgsFor.${system}.generateApps); 99 | checks = forAllSystems (system: pkgs: 100 | let 101 | vmTests = import ./tests { 102 | makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { inherit system; }).makeTest; 103 | inherit inputs; pkgs = nixpkgsFor.${system}; 104 | }; 105 | in 106 | pkgs.lib.optionalAttrs pkgs.stdenv.isLinux vmTests # vmTests can only be ran on Linux, so append them only if on Linux. 107 | // 108 | { 109 | # Other checks here... 110 | } 111 | ); 112 | }; 113 | } 114 | -------------------------------------------------------------------------------- /tests/default.nix: -------------------------------------------------------------------------------- 1 | { pkgs, makeTest, inputs }: 2 | { 3 | vmTestLocal = (import ./vmTest { inherit pkgs makeTest inputs; }).local; 4 | vmTestRemote = (import ./vmTest { inherit pkgs makeTest inputs; }).remote; 5 | vmTestLocalHermetic = (import ./vmTest { inherit pkgs makeTest inputs; }).localHermetic; 6 | vmTestRemoteHermetic = (import ./vmTest { inherit pkgs makeTest inputs; }).remoteHermetic; 7 | } 8 | -------------------------------------------------------------------------------- /tests/vmTest/common.nix: -------------------------------------------------------------------------------- 1 | # Configuration that will be added to both the nixinatee node and the nixinator 2 | # node. 3 | { inputs }: 4 | { 5 | nix = { 6 | extraOptions = 7 | let empty_registry = builtins.toFile "empty-flake-registry.json" ''{"flakes":[],"version":2}''; in 8 | '' 9 | experimental-features = nix-command flakes 10 | flake-registry = ${empty_registry} 11 | ''; 12 | registry.nixpkgs.flake = inputs.nixpkgs; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /tests/vmTest/default.nix: -------------------------------------------------------------------------------- 1 | { pkgs, makeTest, inputs }: 2 | let 3 | inherit (pkgs) lib; 4 | # Return a store path with a closure containing everything including 5 | # derivations and all build dependency outputs, all the way down. 6 | allDrvOutputs = pkg: 7 | let name = "allDrvOutputs-${pkg.pname or pkg.name or "unknown"}"; 8 | in 9 | pkgs.runCommand name { refs = pkgs.writeReferencesToFile pkg.drvPath; } '' 10 | touch $out 11 | while read ref; do 12 | case $ref in 13 | *.drv) 14 | cat $ref >>$out 15 | ;; 16 | esac 17 | done <$refs 18 | ''; 19 | # Imports a flake with inputs passed in by hand, rather than 20 | # builtins.getFlake, which cannot be used in this way. 21 | callLocklessFlake = path: inputs: let 22 | r = {outPath = path;} // 23 | ((import (path + "/flake.nix")).outputs (inputs // {self = r;})); 24 | in 25 | r; 26 | mkNixinateTest = { buildOn, hermetic ? false, ... }: 27 | let 28 | exampleFlake = pkgs.writeTextFile { 29 | name = "nixinate-example-flake"; 30 | destination = "/flake.nix"; 31 | text = '' 32 | { 33 | outputs = { self, nixpkgs }: 34 | let 35 | makeTest = (import (nixpkgs + "/nixos/lib/testing-python.nix") { system = "${pkgs.hostPlatform.system}"; }).makeTest; 36 | baseConfig = ((makeTest { nodes.baseConfig = { ... }: {}; testScript = "";}).nodes).baseConfig.extendModules { 37 | modules = [ 38 | ${builtins.readFile ./nixinateeBase.nix} 39 | ${builtins.readFile ./nixinateeAdditional.nix} 40 | { 41 | _module.args.nixinate = { 42 | host = "nixinatee"; 43 | sshUser = "nixinator"; 44 | buildOn = "${buildOn}"; # valid args are "local" or "remote" 45 | hermetic = ${lib.boolToString hermetic}; # valid args are true or false 46 | }; 47 | } 48 | ]; 49 | }; 50 | in 51 | { 52 | nixosConfigurations = { 53 | nixinatee = baseConfig; 54 | }; 55 | }; 56 | } 57 | ''; 58 | }; 59 | deployScript = inputs.self.nixinate.${pkgs.hostPlatform.system} (callLocklessFlake "${exampleFlake}" { nixpkgs = inputs.nixpkgs; }); 60 | exampleSystem = (callLocklessFlake "${exampleFlake}" { nixpkgs = inputs.nixpkgs; }).nixosConfigurations.nixinatee.config.system.build.toplevel; 61 | in 62 | makeTest { 63 | nodes = { 64 | nixinatee = { ... }: { 65 | imports = [ 66 | ./nixinateeBase.nix 67 | (import ./common.nix { inherit inputs; }) 68 | ]; 69 | virtualisation = { 70 | writableStore = true; 71 | additionalPaths = [] 72 | ++ lib.optional (buildOn == "remote") (allDrvOutputs exampleSystem) 73 | ++ lib.optional (hermetic == true) (pkgs.nixinate.nixos-rebuild.drvPath) 74 | ++ lib.optional (hermetic == true) (pkgs.flock.drvPath); 75 | }; 76 | }; 77 | nixinator = { ... }: { 78 | imports = [ 79 | (import ./common.nix { inherit inputs; }) 80 | ]; 81 | virtualisation = { 82 | additionalPaths = [ 83 | (allDrvOutputs exampleSystem) 84 | ] 85 | ++ lib.optional (buildOn == "remote") exampleFlake 86 | ++ lib.optional (hermetic == true) pkgs.flock.drvPath; 87 | }; 88 | }; 89 | }; 90 | testScript = 91 | '' 92 | start_all() 93 | nixinatee.wait_for_unit("sshd.service") 94 | nixinator.wait_for_unit("multi-user.target") 95 | nixinator.succeed("mkdir ~/.ssh/") 96 | nixinator.succeed("ssh-keyscan -H nixinatee >> ~/.ssh/known_hosts") 97 | nixinator.succeed("exec ${deployScript.nixinate.nixinatee.program} >&2") 98 | nixinatee.wait_for_unit("nginx.service") 99 | nixinatee.wait_for_open_port("80") 100 | with subtest("Check that Nginx webserver can be reached by deployer after deployment"): 101 | assert "Welcome to nginx!" in nixinator.succeed( 102 | "curl -sSf http:/nixinatee/ | grep title" 103 | ) 104 | with subtest("Check that Nginx webserver can be reached by deployee after deployment"): 105 | assert "Welcome to nginx!" in nixinatee.succeed( 106 | "curl -sSf http:/127.0.0.1/ | grep title" 107 | ) 108 | ''; 109 | }; 110 | in 111 | { 112 | local = (mkNixinateTest { buildOn = "local"; }); 113 | remote = (mkNixinateTest { buildOn = "remote"; }); 114 | localHermetic = (mkNixinateTest { buildOn = "local"; hermetic = true; }); 115 | remoteHermetic = (mkNixinateTest { buildOn = "remote"; hermetic = true; }); 116 | } 117 | -------------------------------------------------------------------------------- /tests/vmTest/nixinateeAdditional.nix: -------------------------------------------------------------------------------- 1 | # Configuration that will be added to the nixinatee node. Nixinate will deploy 2 | # the combination of nixinateBase.nix + nixinateAdditional.nix 3 | { 4 | config = { 5 | services.nginx.enable = true; 6 | networking.firewall.allowedTCPPorts = [ 80 ]; 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /tests/vmTest/nixinateeBase.nix: -------------------------------------------------------------------------------- 1 | # Common configuration of nixinatee node in the vmTest. This is the base 2 | # configuration which is required to perform the test. 3 | { 4 | config = { 5 | nix.trustedUsers = [ "nixinator" ]; 6 | security.sudo.extraRules = [{ 7 | users = [ "nixinator" ]; 8 | commands = [{ 9 | command = "ALL"; 10 | options = [ "NOPASSWD" ]; 11 | }]; 12 | }]; 13 | users = { 14 | mutableUsers = false; 15 | users = { 16 | nixinator = { 17 | extraGroups = [ 18 | "wheel" 19 | ]; 20 | password = ""; 21 | isNormalUser = true; 22 | }; 23 | }; 24 | }; 25 | services.openssh = { 26 | enable = true; 27 | extraConfig = "PermitEmptyPasswords yes"; 28 | }; 29 | documentation.enable = false; 30 | boot.loader.grub.enable = false; 31 | }; 32 | } 33 | --------------------------------------------------------------------------------