├── .gitignore ├── README.md ├── default.nix ├── example ├── digitalocean.nix ├── example.nix └── terranix.nix ├── flake.lock ├── flake.nix ├── lib ├── dag.nix ├── default.nix ├── lib.nix ├── nix-run.sh ├── run.nix └── wrapper.nix └── modules ├── acme.nix ├── continue.nix ├── default.nix ├── deploy.nix ├── deps.nix ├── dns.nix ├── home ├── default.nix └── secrets.nix ├── lustrate.nix ├── nixos ├── compat.nix ├── default.nix ├── digitalocean.nix ├── headless.nix ├── oracle-linux.nix ├── oracle.nix ├── secrets-users.nix ├── secrets.nix ├── ubuntu-linux.nix └── vm.nix ├── run.nix ├── secrets.nix ├── state.nix └── terraform.nix /.gitignore: -------------------------------------------------------------------------------- 1 | /terraform/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tf-nix 2 | 3 | [Terraform](https://www.terraform.io) and [Nix{,OS}](https://nixos.org/) all mashed together. 4 | 5 | ## Features and Goals 6 | 7 | - [ ] Health checks and maintenance commands 8 | - [x] NixOS deployment 9 | - [x] Secret and key deployment 10 | - [x] Pure nix configuration 11 | 12 | ## Example 13 | 14 | Try out the [example](./example/example.nix): 15 | 16 | ```bash 17 | export TF_VAR_do_token=XXX 18 | nix run -f. run.apply --arg config ./example/digitalocean.nix 19 | # or with flakes: nix run --impure github:arcnmx/tf-nix#example.digitalocean.run.apply 20 | 21 | # Now log into the server that was just deployed: 22 | nix run -f. run.system-ssh --arg config ./example/digitalocean.nix 23 | 24 | # To undo the above: 25 | nix shell -f run.terraform --arg config ./example/digitalocean.nix -c terraform destroy 26 | ``` 27 | 28 | ## See Also 29 | 30 | - [terranix](https://github.com/mrVanDalo/terranix) 31 | - [terraform-nixos](https://github.com/tweag/terraform-nixos) 32 | - [NixOps](https://nixos.org/nixops/) 33 | - [NixOSes](https://github.com/Infinisil/nixoses) 34 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { }, config }: with pkgs.lib; let 2 | pkgsModule = { ... }: { 3 | config._module.args = { 4 | pkgs = mkDefault pkgs; 5 | }; 6 | }; 7 | tfModules = import ./modules; 8 | nixosModulesPath = toString (pkgs.path + "/nixos/modules"); 9 | configPath = config; 10 | configModule = { pkgs, config, ... }: { 11 | options = { 12 | paths = { 13 | cwd = mkOption { 14 | type = types.path; 15 | }; 16 | config = mkOption { 17 | type = types.path; 18 | default = configPath; 19 | }; 20 | dataDir = mkOption { 21 | type = types.path; 22 | default = config.paths.cwd + "/terraform"; 23 | }; 24 | tf = mkOption { 25 | type = types.path; 26 | default = ./.; 27 | }; 28 | }; 29 | shell = mkOption { 30 | type = types.package; 31 | }; 32 | }; 33 | 34 | config = { 35 | deps = { 36 | enable = true; 37 | }; 38 | runners.lazy = { 39 | file = ./.; 40 | args = [ "--show-trace" "--arg" "config" (toString config.paths.config) ]; 41 | }; 42 | state = { 43 | file = config.paths.dataDir + "/terraform.tfstate"; 44 | }; 45 | terraform = { 46 | dataDir = config.paths.dataDir + "/tfdata"; 47 | logPath = config.paths.dataDir + "/terraform.log"; 48 | #environment = { 49 | # #TF_INPUT = "0"; 50 | #}; 51 | }; 52 | paths = let 53 | pwd = builtins.getEnv "PWD"; 54 | in { 55 | cwd = mkIf (pwd != "") (mkDefault pwd); 56 | }; 57 | shell = shell' config; 58 | }; 59 | }; 60 | tfEval = config: (evalModules { 61 | modules = [ 62 | pkgsModule 63 | configModule 64 | tfModules 65 | ] ++ toList config; 66 | 67 | specialArgs = { 68 | inherit nixosModulesPath; 69 | pkgsPath = toString pkgs.path; 70 | lib = pkgs.lib.extend (_: _: { 71 | inherit nixosModule nixosType; 72 | }); 73 | }; 74 | }).config; 75 | nixosModule = { config, ... }: { 76 | nixpkgs = { 77 | system = mkDefault pkgs.system; 78 | }; 79 | 80 | _module.args.pkgs = mkDefault (import pkgs.path { 81 | inherit (config.nixpkgs) config overlays localSystem crossSystem; 82 | }); 83 | 84 | # signal to the module that they will be externally managed 85 | secrets.external = true; 86 | }; 87 | nixosType = modules: let 88 | baseModules = import (pkgs.path + "/nixos/modules/module-list.nix"); 89 | in types.submoduleWith { 90 | modules = baseModules ++ [ 91 | nixosModule 92 | tfModules.nixos 93 | tfModules.nixos.headless 94 | ] ++ toList modules; 95 | 96 | specialArgs = { 97 | inherit baseModules tfModules; 98 | modulesPath = nixosModulesPath; 99 | }; 100 | }; 101 | shell' = config: let 102 | shell = pkgs.mkShell { 103 | nativeBuildInputs = with config.runners.run; [ terraform.package apply.package ]; 104 | 105 | inherit (config.terraform.environment) TF_DATA_DIR; 106 | TF_DIR = toString config.paths.dataDir; 107 | 108 | shellHook = '' 109 | mkdir -p $TF_DATA_DIR 110 | HISTFILE=$TF_DIR/history 111 | unset SSH_AUTH_SOCK 112 | ''; 113 | }; 114 | in shell; 115 | in tfEval config 116 | -------------------------------------------------------------------------------- /example/digitalocean.nix: -------------------------------------------------------------------------------- 1 | { lib, config, ... }: with lib; let 2 | inherit (config.lib.tf) terraformSelf; 3 | res = config.resources; 4 | lustrate = config.deploy.systems.system.lustrate.enable; 5 | in { 6 | imports = [ 7 | # common example system 8 | ./example.nix 9 | ]; 10 | 11 | config = { 12 | # NOTE: if not using NIXOS_LUSTRATE, images must be uploaded manually, and can be built with: 13 | # nix-build '' -A baseImage.system.build.digitalOceanImage --arg config ./example/digitalocean.nix 14 | # ... then upload it via web interface as "nixos-image-example" as used below. 15 | deploy.systems.system.lustrate = { 16 | enable = true; 17 | }; 18 | 19 | resources = { 20 | do_access = { 21 | provider = "digitalocean"; 22 | type = "ssh_key"; 23 | inputs = { 24 | name = "terraform/${config.nixos.networking.hostName} access key"; 25 | public_key = res.access_key.refAttr "public_key_openssh"; 26 | }; 27 | }; 28 | 29 | nixos_image = mkIf (!lustrate) { 30 | provider = "digitalocean"; 31 | type = "image"; 32 | dataSource = true; 33 | inputs.name = "nixos-image-example"; 34 | }; 35 | 36 | server = { 37 | provider = "digitalocean"; 38 | type = "droplet"; 39 | inputs = { 40 | image = if lustrate 41 | then "ubuntu-20-10-x64" 42 | else res.nixos_image.refAttr "id"; 43 | name = "server"; 44 | region = "tor1"; 45 | size = "s-1vcpu-1gb"; 46 | ssh_keys = singleton (res.do_access.refAttr "id"); 47 | }; 48 | connection = { 49 | host = terraformSelf "ipv4_address"; 50 | ssh = { 51 | privateKey = res.access_key.refAttr "private_key_pem"; 52 | privateKeyFile = res.access_file.refAttr "filename"; 53 | }; 54 | }; 55 | }; 56 | }; 57 | 58 | variables.do_token = { 59 | type = "string"; 60 | sensitive = true; 61 | # populate variable using https://www.passwordstore.org/ 62 | #value.shellCommand = "pass show tokens/digitalocean"; 63 | }; 64 | 65 | providers.digitalocean = { 66 | inputs.token = config.variables.do_token.ref; 67 | }; 68 | 69 | # configure the nixos image for use with DO's monitoring/networking/etc 70 | nixos = { tfModules, ... }: { 71 | imports = [ 72 | tfModules.nixos.digitalocean 73 | ] ++ optional lustrate tfModules.nixos.ubuntu-linux; 74 | 75 | config = { 76 | virtualisation.digitalOcean = { 77 | rebuildFromUserData = false; 78 | metadataNetworking = lustrate; 79 | }; 80 | }; 81 | }; 82 | baseImage = { tfModules, modulesPath, ... }: { 83 | imports = [ 84 | (modulesPath + "/virtualisation/digital-ocean-image.nix") 85 | tfModules.nixos.digitalocean 86 | ]; 87 | config = { 88 | virtualisation.digitalOceanImage.compressionMethod = "bzip2"; 89 | }; 90 | }; 91 | }; 92 | options = { 93 | baseImage = mkOption { 94 | type = nixosType [ ]; 95 | }; 96 | }; 97 | } 98 | -------------------------------------------------------------------------------- /example/example.nix: -------------------------------------------------------------------------------- 1 | { config, lib, pkgs, tfModulesPath, ... }: with lib; let 2 | tf = config; 3 | res = config.resources; 4 | in { 5 | config = { 6 | resources = { 7 | access_key = { 8 | provider = "tls"; 9 | type = "private_key"; 10 | inputs = { 11 | algorithm = "ECDSA"; 12 | ecdsa_curve = "P384"; 13 | }; 14 | }; 15 | 16 | access_file = { 17 | # shorthand to avoid specifying the provider: 18 | #type = "local.sensitive_file"; 19 | provider = "local"; 20 | type = "sensitive_file"; 21 | inputs = { 22 | content = res.access_key.refAttr "private_key_pem"; 23 | filename = "${toString config.paths.dataDir}/access.private.pem"; 24 | }; 25 | }; 26 | 27 | secret = { 28 | provider = "random"; 29 | type = "pet"; 30 | }; 31 | }; 32 | 33 | outputs = { 34 | secret = { 35 | value = res.secret.refAttr "id"; 36 | sensitive = true; 37 | }; 38 | }; 39 | 40 | nixos = { config, ... }: { 41 | config = { 42 | secrets = { 43 | # provided by 44 | files.pet = { 45 | text = res.secret.refAttr "id"; 46 | }; 47 | }; 48 | 49 | # terraform -> nix references 50 | users.users.root.openssh.authorizedKeys.keys = singleton ( 51 | res.access_key.getAttr "public_key_openssh" 52 | ); 53 | users.motd = '' 54 | welcome to ${res.server.getAttr tf.example.serverAddr} 55 | please don't look at ${config.secrets.files.pet.path}, it's private. 56 | ''; 57 | security.pam.services.sshd.showMotd = true; 58 | services.getty.autologinUser = "root"; # XXX: REMOVE ME, for testing only 59 | }; 60 | }; 61 | deploy.systems.system = with config.resources; { 62 | nixosConfig = config.nixos; 63 | connection = server.connection.set; 64 | # if server gets replaced, make sure the deployment starts over 65 | triggers.common.server = server.refAttr "id"; 66 | }; 67 | }; 68 | 69 | options = { 70 | nixos = mkOption { 71 | type = nixosType [ ]; 72 | }; 73 | example.serverAddr = mkOption { 74 | type = types.str; 75 | default = "ipv4_address"; 76 | }; 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /example/terranix.nix: -------------------------------------------------------------------------------- 1 | { ... }: { 2 | resources = { 3 | nginx = { 4 | provider = "hcloud"; 5 | type = "server"; 6 | inputs = { 7 | image = "debian-10"; 8 | server_type = "cx11"; 9 | backups = false; 10 | }; 11 | }; 12 | test = { 13 | provider = "hcloud"; 14 | type = "server"; 15 | inputs = { 16 | image = "debian-9"; 17 | server_type = "cx11"; 18 | backups = true; 19 | }; 20 | }; 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "config": { 4 | "locked": { 5 | "lastModified": 1630400035, 6 | "narHash": "sha256-MWaVOCzuFwp09wZIW9iHq5wWen5C69I940N1swZLEQ0=", 7 | "owner": "input-output-hk", 8 | "repo": "empty-flake", 9 | "rev": "2040a05b67bf9a669ce17eca56beb14b4206a99a", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "input-output-hk", 14 | "repo": "empty-flake", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1680725427, 21 | "narHash": "sha256-fx/Tc+7VEuO5VckHg65t+Alp9hh1JubnkNDpV2qyTiY=", 22 | "owner": "NixOS", 23 | "repo": "nixpkgs", 24 | "rev": "da7761cacab07eeb08eb69e94063397e8887404e", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "id": "nixpkgs", 29 | "type": "indirect" 30 | } 31 | }, 32 | "root": { 33 | "inputs": { 34 | "config": "config", 35 | "nixpkgs": "nixpkgs" 36 | } 37 | } 38 | }, 39 | "root": "root", 40 | "version": 7 41 | } 42 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "terraform meets nix"; 3 | inputs = { 4 | nixpkgs = { }; 5 | config = { 6 | url = "github:input-output-hk/empty-flake"; 7 | }; 8 | }; 9 | outputs = { self, nixpkgs, config, ... }: let 10 | nixlib = nixpkgs.lib; 11 | forAllSystems = nixlib.genAttrs nixlib.systems.flakeExposed; 12 | config' = config.lib.tfConfig.path or config.outPath; 13 | wrapModules = modules: let 14 | named = builtins.removeAttrs modules [ "__functor" ]; 15 | in builtins.mapAttrs (_: module: { ... }: { imports = [ module ]; }) named // { 16 | default = modules.__functor modules; 17 | }; 18 | in { 19 | legacyPackages = forAllSystems (system: let 20 | pkgs = nixpkgs.legacyPackages.${system}; 21 | legacyPackages = self.legacyPackages.${system}; 22 | in { 23 | eval = { config ? config' }: import ./. { 24 | inherit pkgs config; 25 | }; 26 | config = nixlib.makeOverridable legacyPackages.eval { }; 27 | example = nixlib.genAttrs self.lib.example.names (example: nixlib.makeOverridable legacyPackages.eval { 28 | config = ./example + "/${example}.nix"; 29 | }); 30 | run = legacyPackages.config.run; 31 | lib = import ./lib { 32 | inherit pkgs; 33 | lib = nixlib; 34 | }; 35 | }); 36 | nixosModules = wrapModules self.lib.modules.nixos; 37 | homeModules = wrapModules self.lib.modules.home; 38 | metaModules = wrapModules self.lib.modules.tf; 39 | lib = { 40 | modules = import ./modules; 41 | tf = import ./lib/lib.nix { 42 | lib = nixlib; 43 | }; 44 | example.names = [ "digitalocean" ]; 45 | }; 46 | }; 47 | } 48 | -------------------------------------------------------------------------------- /lib/dag.nix: -------------------------------------------------------------------------------- 1 | # A generalization of Nixpkgs's `strings-with-deps.nix`. 2 | # 3 | # The main differences from the Nixpkgs version are 4 | # 5 | # - not specific to strings, i.e., any payload is OK, 6 | # 7 | # - the addition of the function `dagEntryBefore` indicating a 8 | # "wanted by" relationship. 9 | 10 | { lib }: 11 | 12 | with lib; 13 | 14 | rec { 15 | 16 | emptyDag = {}; 17 | 18 | isDag = dag: 19 | let 20 | isEntry = e: (e ? data) && (e ? after) && (e ? before); 21 | in 22 | builtins.isAttrs dag && all (x: x) (mapAttrsToList (n: isEntry) dag); 23 | 24 | # Takes an attribute set containing entries built by 25 | # dagEntryAnywhere, dagEntryAfter, and dagEntryBefore to a 26 | # topologically sorted list of entries. 27 | # 28 | # Internally this function uses the `toposort` function in 29 | # `` and its value is accordingly. 30 | # 31 | # Specifically, the result on success is 32 | # 33 | # { result = [{name = ?; data = ?;} …] } 34 | # 35 | # For example 36 | # 37 | # nix-repl> dagTopoSort { 38 | # a = dagEntryAnywhere "1"; 39 | # b = dagEntryAfter ["a" "c"] "2"; 40 | # c = dagEntryBefore ["d"] "3"; 41 | # d = dagEntryBefore ["e"] "4"; 42 | # e = dagEntryAnywhere "5"; 43 | # } == { 44 | # result = [ 45 | # { data = "1"; name = "a"; } 46 | # { data = "3"; name = "c"; } 47 | # { data = "2"; name = "b"; } 48 | # { data = "4"; name = "d"; } 49 | # { data = "5"; name = "e"; } 50 | # ]; 51 | # } 52 | # true 53 | # 54 | # And the result on error is 55 | # 56 | # { 57 | # cycle = [ {after = ?; name = ?; data = ?} … ]; 58 | # loops = [ {after = ?; name = ?; data = ?} … ]; 59 | # } 60 | # 61 | # For example 62 | # 63 | # nix-repl> dagTopoSort { 64 | # a = dagEntryAnywhere "1"; 65 | # b = dagEntryAfter ["a" "c"] "2"; 66 | # c = dagEntryAfter ["d"] "3"; 67 | # d = dagEntryAfter ["b"] "4"; 68 | # e = dagEntryAnywhere "5"; 69 | # } == { 70 | # cycle = [ 71 | # { after = ["a" "c"]; data = "2"; name = "b"; } 72 | # { after = ["d"]; data = "3"; name = "c"; } 73 | # { after = ["b"]; data = "4"; name = "d"; } 74 | # ]; 75 | # loops = [ 76 | # { after = ["a" "c"]; data = "2"; name = "b"; } 77 | # ]; 78 | # } == {} 79 | # true 80 | dagTopoSort = dag: 81 | let 82 | dagBefore = dag: name: 83 | mapAttrsToList (n: v: n) ( 84 | filterAttrs (n: v: any (a: a == name) v.before) dag 85 | ); 86 | normalizedDag = 87 | mapAttrs (n: v: { 88 | name = n; 89 | data = v.data; 90 | after = v.after ++ dagBefore dag n; 91 | }) dag; 92 | before = a: b: any (c: a.name == c) b.after; 93 | sorted = toposort before (mapAttrsToList (n: v: v) normalizedDag); 94 | in 95 | if sorted ? result then 96 | { result = map (v: { inherit (v) name data; }) sorted.result; } 97 | else 98 | sorted; 99 | 100 | # Applies a function to each element of the given DAG. 101 | dagMap = f: dag: mapAttrs (n: v: v // { data = f n v.data; }) dag; 102 | 103 | # Create a DAG entry with no particular dependency information. 104 | dagEntryAnywhere = data: { 105 | inherit data; 106 | before = []; 107 | after = []; 108 | }; 109 | 110 | dagEntryBetween = before: after: data: { 111 | inherit data before after; 112 | }; 113 | 114 | dagEntryAfter = after: data: { 115 | inherit data after; 116 | before = []; 117 | }; 118 | 119 | dagEntryBefore = before: data: { 120 | inherit data before; 121 | after = []; 122 | }; 123 | 124 | } 125 | -------------------------------------------------------------------------------- /lib/default.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { }, lib ? pkgs.lib }: let 2 | tf = import ./lib.nix { 3 | inherit lib; 4 | }; 5 | in tf // { 6 | terraformContext = tf.terraformContext pkgs; 7 | hclDir = args: tf.hclDir ({ 8 | inherit pkgs; 9 | } // args); 10 | run = lib.mapAttrs (_: lib.flip pkgs.callPackage { }) tf.run; 11 | } 12 | -------------------------------------------------------------------------------- /lib/lib.nix: -------------------------------------------------------------------------------- 1 | { lib }: with builtins; with lib; let 2 | terraformExpr = expr: "\${${expr}}"; 3 | terraformSelf = attr: terraformExpr "self.${attr}"; 4 | terraformIdent = id: replaceStrings [ "." ":" "/" ] [ "_" "_" "_" ] id; # https://www.terraform.io/docs/configuration/syntax.html#identifiers 5 | # get all input context/dependencies for a derivation 6 | # https://github.com/NixOS/nix/issues/1245#issuecomment-401642781 7 | storeDirRe = replaceStrings [ "." ] [ "\\." ] builtins.storeDir; 8 | storeBaseRe = "[0-9a-df-np-sv-z]{32}-[+_?=a-zA-Z0-9-][+_?=.a-zA-Z0-9-]*"; 9 | re = "(${storeDirRe}/${storeBaseRe}\\.drv)"; 10 | # not a real parser (yet?) 11 | readDrv = pkg: let 12 | drv = readFile pkg; 13 | inputDrvs = concatLists (filter isList (split re drv)); 14 | in { 15 | inherit inputDrvs; 16 | }; 17 | inputDrvs' = list: drvs: 18 | foldl (list: drv: if elem drv list then list else inputDrvs' (list ++ singleton drv) (readDrv drv).inputDrvs) list drvs; 19 | inputDrvs = drv: inputDrvs' [] [ drv ]; 20 | 21 | # marker derivation for tracking (unresolved?) terraform resource dependencies, attaching context to json, etc. 22 | terraformContext = pkgs: resolved: path: attr: let 23 | contextDrv = derivation { 24 | inherit (pkgs) system; 25 | name = "tf-${if resolved then "2" else "1"}terraformReference-${path}"; 26 | builder = if resolved 27 | then "${pkgs.coreutils}/bin/touch" 28 | else "unresolved terraform reference: ${path}" + optionalString (attr != null) ".${attr}"; 29 | args = optionals resolved [ (placeholder "out") ]; 30 | #__terraformPath = path; 31 | }; 32 | in addContextFrom "${contextDrv}" ""; 33 | terraformContextFromDrv = drvPath: let 34 | tfMatch = match ".*-tf-([12])terraformReference-(.*)\\.drv"; 35 | matches = tfMatch drvPath; 36 | in mapNullable (match: { 37 | inherit drvPath; 38 | key = elemAt match 1; 39 | resolved = elemAt match 0 == "2"; 40 | }) matches; 41 | 42 | # extract marker references 43 | terraformContextFor = target: 44 | if isString target && hasSuffix ".drv" target then terraformContextForDrv target 45 | else if target ? drvPath then terraformContextForDrv target.drvPath 46 | else terraformContextForString target; 47 | terraformContextForString = str: let 48 | drvs = attrNames (getContext str); 49 | in unique (concatMap (s: let 50 | context = terraformContextFromDrv s; 51 | in if context == null then [] else [ context.key ]) drvs); 52 | terraformContextForDrv = drv: let 53 | closure = inputDrvs drv; 54 | in unique (concatMap (s: let 55 | context = terraformContextFromDrv s; 56 | in if context == null then [] else [ context.key ]) closure); 57 | 58 | # ugh 59 | fromHclPath = config: p: let 60 | path = if isString p then splitString "." p else p; 61 | name = last path; 62 | kind = head path; 63 | type = { 64 | resource = "resources"; 65 | data = "resources"; 66 | provider = "providers"; 67 | output = "outputs"; 68 | variable = "variables"; 69 | }.${kind}; 70 | cfg = config.${type}; 71 | named = cfg.${name}; 72 | nameOf = r: let 73 | alias = if r.alias != null then r.alias else r.type; 74 | in r.name or alias; 75 | find = findFirst (r: nameOf r == name) (throw "${toString p} not found") (attrValues cfg); 76 | in (if cfg ? ${name} && nameOf named == name then named else find) // { 77 | inherit kind; 78 | }; 79 | 80 | combineHcl = a: b: recursiveUpdate a b // optionalAttrs (a ? provider || b ? provider) { 81 | provider = let 82 | plist = p: if isList p then p else singleton p; 83 | in plist a.provider or [] ++ plist b.provider or []; 84 | }; 85 | 86 | scrubHclAll = hcl: let 87 | json = toJSON hcl; 88 | json' = unsafeDiscardStringContext json; 89 | in fromJSON json'; 90 | scrubHcl = hcl: let 91 | json = removeTerraformContext (toJSON hcl); 92 | context = getContext json; 93 | json' = unsafeDiscardStringContext json; 94 | #in setContext context (fromJSON json'); 95 | in mapAttrsRecursive (_: v: 96 | # HACK: just apply context to any string we can find in the attrset 97 | if isString v then setContext (context // getContext v) v else v 98 | ) (fromJSON json'); 99 | 100 | providerPrefix = if versionAtLeast (versions.majorMinor version) "22.05" 101 | # this was changed in https://github.com/NixOS/nixpkgs/pull/155477 102 | then "libexec/terraform-providers" 103 | else "plugins"; 104 | hclDir = { 105 | name ? "terraform" 106 | , hcl 107 | , pkgs ? import { } 108 | , terraform ? pkgs.buildPackages.terraform 109 | , jq ? pkgs.buildPackages.jq 110 | , generateLockfile ? versionAtLeast terraform.version or (builtins.parseDrvName terraform.name).version "0.14" 111 | , prettyJson ? false 112 | }: pkgs.stdenvNoCC.mkDerivation { 113 | name = "${name}.tf.json"; 114 | allowSubstitutes = false; 115 | preferLocalBuild = true; 116 | 117 | nativeBuildInputs = optional generateLockfile terraform ++ optional prettyJson jq; 118 | passAsFile = [ "hcl" "script" "buildCommand" ]; 119 | hcl = toJSON hcl; 120 | 121 | buildCommand = optionalString prettyJson '' 122 | jq -M . $hclPath > pretty.json 123 | hclPath=pretty.json 124 | '' + '' 125 | mkdir -p $out 126 | install -Dm0644 $hclPath $out/$name 127 | '' + optionalString generateLockfile '' 128 | terraform -chdir=$out providers lock \ 129 | -fs-mirror=${terraform /* TODO resolve this properly if spliced */}/${providerPrefix} \ 130 | -platform=${terraform.stdenv.hostPlatform.parsed.kernel.name + "_" + { 131 | x86_64 = "amd64"; 132 | aarch64 = "arm64"; 133 | }.${terraform.stdenv.hostPlatform.parsed.cpu.name} or (throw "unknown tf arch")} 134 | ''; 135 | }; 136 | 137 | # strip a string of all marker references 138 | removeTerraformContext = str: let 139 | context = filterAttrs (k: value: terraformContextFromDrv k == null) (getContext str); 140 | in setContext context str; 141 | 142 | dag = import ./dag.nix { inherit lib; }; 143 | run = import ./run.nix { inherit lib; }; 144 | 145 | readState = statefile: let 146 | state = fromJSON (readFile statefile); 147 | in assert state.version == "4"; { 148 | inherit (state) outputs; 149 | # TODO: resource instances and state 150 | }; 151 | 152 | # applies data from `builtins.getContext` back to a string. 153 | setContext = let 154 | appendContext' = str: context: let 155 | contextToString = key: cx: let 156 | drv = import key; 157 | in if cx.path or false == true then "${/. + key}" 158 | else if cx ? outputs then concatMapStrings (flip getAttr drv) cx.outputs 159 | else throw "unknown context type for ${key}: ${toString (attrNames cx)}"; 160 | context' = mapAttrsToList contextToString context; 161 | in foldl (flip addContextFrom) str context'; 162 | appendContext = builtins.appendContext or appendContext'; 163 | in context: str: appendContext (builtins.unsafeDiscardStringContext str) context; 164 | 165 | # attrsFromPath [ "a" "b" "c" ] x = { a.b.c = x } 166 | attrsFromPath = path: value: foldr (key: attrs: { ${key} = attrs; }) value path; 167 | in rec { 168 | inherit readDrv inputDrvs; 169 | inherit setContext attrsFromPath; 170 | 171 | inherit readState; 172 | 173 | inherit terraformContext terraformContextFor terraformContextForString terraformContextForDrv terraformContextFromDrv removeTerraformContext; 174 | inherit fromHclPath combineHcl scrubHcl scrubHclAll hclDir; 175 | 176 | inherit terraformExpr terraformSelf terraformIdent; 177 | 178 | inherit (dag) dagTopoSort dagEntryAfter dagEntryBefore dagEntryAnywhere; 179 | inherit run; 180 | 181 | genUrl = { 182 | protocol 183 | , host 184 | , port ? null 185 | , user ? null 186 | , password ? null 187 | , path ? "" 188 | , queryString ? if query != { } then concatStringsSep "&" (mapAttrsToList (k: v: "${k}=${v}") query) else null 189 | , query ? { } 190 | , isUrl ? true 191 | }: let 192 | portDefaults = { 193 | ssh = 22; 194 | http = 80; 195 | https = 443; 196 | }; 197 | explicitPort = port != null && (portDefaults.${protocol} or 0) != port; 198 | portStr = optionalString explicitPort ":${toString port}"; 199 | queryStr = optionalString (queryString != null) "?${queryString}"; 200 | passwordStr = optionalString (password != null) ":${password}"; 201 | protocolStr = protocol + ":" + optionalString isUrl "//"; 202 | creds = optionalString (user != null || password != null) "${toString user}${passwordStr}@"; 203 | in "${protocolStr}${creds}${host}${portStr}${path}${queryStr}"; 204 | 205 | # TODO: secrets from env or elsewhere 206 | } 207 | -------------------------------------------------------------------------------- /lib/nix-run.sh: -------------------------------------------------------------------------------- 1 | if nix --experimental-features "" shell --version 2>&1 >/dev/null; then 2 | exec nix --extra-experimental-features nix-command \ 3 | shell "$@" 4 | else 5 | exec nix \ 6 | run "$@" 7 | fi 8 | -------------------------------------------------------------------------------- /lib/run.nix: -------------------------------------------------------------------------------- 1 | { lib }: rec { 2 | nixRunner = { stdenvNoCC, runtimeShell}: binName: stdenvNoCC.mkDerivation { 3 | preferLocalBuild = true; 4 | allowSubstitutes = false; 5 | name = "nix-run-wrapper-${binName}"; 6 | defaultCommand = "bash"; # `nix run` execvp's bash by default 7 | inherit binName; 8 | inherit runtimeShell; 9 | passAsFile = [ "buildCommand" "script" ]; 10 | buildCommand = '' 11 | mkdir -p $out/bin 12 | substituteAll $scriptPath $out/bin/$defaultCommand 13 | chmod +x $out/bin/$defaultCommand 14 | ln -s $out/bin/$defaultCommand $out/bin/run 15 | ''; 16 | script = '' 17 | #!@runtimeShell@ 18 | set -eu 19 | 20 | if [[ -n ''${NIX_NO_RUN-} ]]; then 21 | # escape hatch 22 | exec bash "$@" 23 | fi 24 | 25 | # also bail out if we're not called via `nix run` 26 | #PPID=($(@ps@/bin/ps -o ppid= $$)) 27 | #if [[ $(readlink /proc/$PPID/exe) = */nix ]]; then 28 | # exec bash "$@" 29 | #fi 30 | 31 | IFS=: PATHS=($PATH) 32 | join_path() { 33 | local IFS=: 34 | echo "$*" 35 | } 36 | 37 | # remove us from PATH 38 | OPATH=() 39 | for p in "''${PATHS[@]}"; do 40 | if [[ $p != @out@/bin ]]; then 41 | OPATH+=("$p") 42 | fi 43 | done 44 | export PATH=$(join_path "''${OPATH[@]}") 45 | 46 | exec @binName@ "$@" ''${NIX_RUN_ARGS-} 47 | ''; 48 | }; 49 | nixRunWrapper' = { stdenvNoCC }: binName: package: stdenvNoCC.mkDerivation { 50 | name = "nix-run-${binName}"; 51 | preferLocalBuild = true; 52 | allowSubstitutes = false; 53 | wrapper = nixRunner binName; 54 | inherit package; 55 | passAsFile = [ "buildCommand" ]; 56 | buildCommand = '' 57 | mkdir -p $out/nix-support 58 | echo $package $wrapper > $out/nix-support/propagated-user-env-packages 59 | if [[ -e $package/bin ]]; then 60 | ln -s $package/bin $out/bin 61 | fi 62 | ''; 63 | meta = package.meta or {} // { 64 | mainProgram = package.meta.mainProgram or binName; 65 | }; 66 | passthru = package.passthru or {}; 67 | }; 68 | nixRunWrapper = { stdenvNoCC }@args: binName: package: if lib.versionOlder builtins.nixVersion "2.4.0" 69 | then nixRunWrapper' args binName package 70 | else package // { 71 | meta = package.meta or { } // { 72 | mainProgram = binName; 73 | }; 74 | }; 75 | nix-run = { }: "${./nix-run.sh}"; 76 | } 77 | -------------------------------------------------------------------------------- /lib/wrapper.nix: -------------------------------------------------------------------------------- 1 | # terraform wrapper supports extra environment variables: 2 | # - TF_CONFIG_DIR: path to terraform configuration 3 | # - TF_STATE_FILE: path to terraform state file (this *must not* be the same as $TF_DATA_DIR/terraform.tfstate) 4 | # - TF_TARGETS: space-separated list of targets to select (terraform recommends not using this option) 5 | { lib 6 | , writeShellScriptBin 7 | , terraform 8 | , terraformVersion ? terraform.version or (builtins.parseDrvName terraform.name).version 9 | }: with lib; writeShellScriptBin "terraform" '' 10 | set -eu 11 | 12 | TF_COMMAND=''${1-} 13 | if [[ -n ''${TF_CONFIG_DIR-} ]]; then 14 | case $TF_COMMAND in 15 | init|plan|apply|destroy|providers|graph|refresh|show|console) 16 | ${if versionAtLeast terraformVersion "0.14" then '' 17 | set -- -chdir="$TF_CONFIG_DIR" "$@" 18 | '' else '' 19 | set -- "$@" "$TF_CONFIG_DIR" 20 | ''} 21 | ;; 22 | import|state) 23 | ${optionalString (versionAtLeast terraformVersion "0.14") '' 24 | set -- -chdir="$TF_CONFIG_DIR" "$@" 25 | ''} 26 | ;; 27 | esac 28 | export TF_CLI_ARGS_import="''${TF_CLI_ARGS_import-} -config=$TF_CONFIG_DIR" 29 | fi 30 | if [[ -n ''${TF_DATA_DIR-} ]]; then 31 | mkdir -p "$TF_DATA_DIR" 32 | fi 33 | if [[ -n ''${TF_TARGETS-} ]]; then 34 | for target in $TF_TARGETS; do 35 | ${concatMapStringsSep "\n" (k: "export TF_CLI_ARGS_${k}=\"\${TF_CLI_ARGS_${k}-} -target=\$target\"") [ "plan" "apply" "destroy" ]} 36 | done 37 | fi 38 | if [[ -n ''${TF_STATE_FILE-} ]]; then 39 | ${concatMapStringsSep "\n" (k: 40 | "export TF_CLI_ARGS_${k}=\"\${TF_CLI_ARGS_${k}-} -state=$TF_STATE_FILE\"" 41 | ) ([ "plan" "apply" "output" "destroy" "refresh" "taint" "import" "console" ] ++ map (a: "state_${a}") [ "list" "rm" "mv" "push" "pull" "show" "replace_provider" ])} 42 | if [[ $TF_COMMAND = show ]]; then 43 | set -- "$@" "$TF_STATE_FILE" 44 | fi 45 | fi 46 | exec ${terraform}/bin/terraform "$@" 47 | '' 48 | -------------------------------------------------------------------------------- /modules/acme.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: with lib; let 2 | config' = config; 3 | cfg = config.acme; 4 | in { 5 | options.acme = { 6 | enable = mkOption { 7 | type = types.bool; 8 | }; 9 | account = { 10 | accountKeyPem = mkOption { 11 | type = types.str; 12 | }; 13 | emailAddress = mkOption { 14 | type = types.str; 15 | }; 16 | register = mkOption { 17 | type = types.bool; 18 | default = false; 19 | }; 20 | provider = mkOption { 21 | type = config.lib.tf.tfTypes.providerReferenceType; 22 | default = "acme"; 23 | }; 24 | resourceName = mkOption { 25 | type = types.str; 26 | default = "acme_account"; 27 | }; 28 | }; 29 | challenge = { 30 | defaultProvider = mkOption { 31 | type = types.nullOr types.str; 32 | default = null; 33 | }; 34 | configs = mkOption { 35 | type = types.attrsOf (types.attrsOf types.unspecified); 36 | default = { }; 37 | }; 38 | }; 39 | certs = mkOption { 40 | type = types.attrsOf (types.submodule ({ name, config, ... }: { 41 | options = { 42 | name = mkOption { 43 | type = types.str; 44 | default = name; 45 | }; 46 | dnsNames = mkOption { 47 | type = types.listOf types.str; 48 | }; 49 | keyType = mkOption { 50 | type = types.enum [ "2048" "4096" "8192" "P256" "P384" ]; 51 | default = "2048"; 52 | }; 53 | mustStaple = mkOption { 54 | type = types.bool; 55 | default = false; 56 | }; 57 | minDaysRemaining = mkOption { 58 | type = types.int; 59 | default = 30; 60 | }; 61 | challenge = { 62 | provider = mkOption { 63 | type = types.str; 64 | }; 65 | config = mkOption { 66 | type = types.attrsOf types.unspecified; 67 | default = cfg.challenge.configs.${config.challenge.provider} or { }; 68 | }; 69 | }; 70 | out = { 71 | commonName = mkOption { 72 | type = types.unspecified; 73 | internal = true; 74 | readOnly = true; 75 | }; 76 | subjectAlternateNames = mkOption { 77 | type = types.unspecified; 78 | internal = true; 79 | readOnly = true; 80 | }; 81 | resource = mkOption { 82 | type = types.unspecified; 83 | internal = true; 84 | readOnly = true; 85 | }; 86 | resourceName = mkOption { 87 | type = types.str; 88 | internal = true; 89 | readOnly = true; 90 | }; 91 | importFullchainPem = mkOption { 92 | type = types.unspecified; 93 | readOnly = true; 94 | }; 95 | getFullchainPem = mkOption { 96 | type = types.unspecified; 97 | readOnly = true; 98 | }; 99 | refFullchainPem = mkOption { 100 | type = types.unspecified; 101 | readOnly = true; 102 | }; 103 | importPrivateKeyPem = mkOption { 104 | type = types.unspecified; 105 | readOnly = true; 106 | }; 107 | getPrivateKeyPem = mkOption { 108 | type = types.unspecified; 109 | readOnly = true; 110 | }; 111 | refPrivateKeyPem = mkOption { 112 | type = types.unspecified; 113 | readOnly = true; 114 | }; 115 | set = mkOption { 116 | type = types.attrsOf types.unspecified; 117 | internal = true; 118 | readOnly = true; 119 | }; 120 | }; 121 | }; 122 | config = { 123 | challenge.provider = mkIf (cfg.challenge.defaultProvider != null) cfg.challenge.defaultProvider; 124 | out = { 125 | commonName = head config.dnsNames; 126 | subjectAlternateNames = tail config.dnsNames; 127 | resourceName = let 128 | name = config'.lib.tf.terraformIdent config.name; 129 | in mkOptionDefault "${cfg.account.provider.type}_${name}"; 130 | resource = config'.resources.${config.out.resourceName}; 131 | importFullchainPem = config.out.resource.importAttr "certificate_pem" + config.out.resource.importAttr "issuer_pem"; 132 | getFullchainPem = config.out.resource.getAttr "certificate_pem" + config.out.resource.getAttr "issuer_pem"; 133 | refFullchainPem = config.out.resource.refAttr "certificate_pem" + config.out.resource.refAttr "issuer_pem"; 134 | importPrivateKeyPem = config.out.resource.importAttr "private_key_pem"; # TODO: if certificate_request_pem used, get this from the request key instead 135 | getPrivateKeyPem = config.out.resource.getAttr "private_key_pem"; # TODO: if certificate_request_pem used, get this from the request key instead 136 | refPrivateKeyPem = config.out.resource.refAttr "private_key_pem"; # TODO: if certificate_request_pem used, get this from the request key instead 137 | set = { 138 | provider = "acme"; 139 | type = "certificate"; 140 | inputs = { 141 | account_key_pem = cfg.account.accountKeyPem; 142 | key_type = config.keyType; 143 | common_name = config.out.commonName; 144 | subject_alternative_names = config.out.subjectAlternateNames; 145 | must_staple = config.mustStaple; 146 | min_days_remaining = config.minDaysRemaining; 147 | dns_challenge = { 148 | inherit (config.challenge) provider config; 149 | }; 150 | }; 151 | dependsOn = mkIf cfg.account.register [ config'.resources.${cfg.account.resourceName}.namedRef ]; 152 | }; 153 | }; 154 | }; 155 | })); 156 | default = { }; 157 | }; 158 | }; 159 | config = { 160 | acme = { 161 | enable = mkOptionDefault (cfg.certs != { } || cfg.account.register); 162 | account = mkIf cfg.enable { 163 | accountKeyPem = mkIf cfg.account.register (mkOptionDefault 164 | (config.resources.${cfg.account.resourceName}.refAttr "account_key_pem") 165 | ); 166 | }; 167 | }; 168 | providers = mkIf cfg.enable { 169 | ${cfg.account.provider.out.name} = { 170 | type = mkDefault cfg.account.provider.type; 171 | inputs.server_url = mkDefault "https://acme-v02.api.letsencrypt.org/directory"; 172 | }; 173 | }; 174 | resources = mkIf cfg.enable (mkMerge [ 175 | (mapAttrs' (_: value: nameValuePair value.out.resourceName value.out.set) cfg.certs) 176 | (mkIf cfg.account.register { 177 | ${cfg.account.resourceName} = { 178 | provider = cfg.account.provider.set; 179 | type = "registration"; 180 | inputs = { 181 | email_address = cfg.account.emailAddress; 182 | account_key_pem = cfg.account.accountKeyPem; 183 | }; 184 | }; 185 | }) 186 | ]); 187 | }; 188 | # TODO: all the rest 189 | } 190 | -------------------------------------------------------------------------------- /modules/continue.nix: -------------------------------------------------------------------------------- 1 | { pkgs, config, lib, ... }: with lib; let 2 | cfg = config.continue; 3 | envStateStr = mapNullable builtins.getEnv cfg.envVar; 4 | envState = 5 | if envStateStr == null || envStateStr == "" then { } 6 | else builtins.fromJSON envStateStr; 7 | in { 8 | options.continue = { 9 | present = mkOption { 10 | type = types.bool; 11 | default = cfg.input.depth > 0; 12 | }; 13 | envVar = mkOption { 14 | type = types.nullOr types.str; 15 | default = "TF_NIX_CONTINUE"; 16 | }; 17 | input = { 18 | depth = mkOption { 19 | type = types.int; 20 | default = 0; 21 | }; 22 | populatedTargets = mkOption { 23 | type = types.nullOr (types.listOf types.str); 24 | default = null; 25 | }; 26 | }; 27 | output = { 28 | populatedTargets = mkOption { 29 | type = types.nullOr (types.listOf types.str); 30 | }; 31 | json = mkOption { 32 | type = types.attrsOf types.unspecified; 33 | readOnly = true; 34 | }; 35 | }; 36 | }; 37 | config.continue = { 38 | input = { 39 | depth = mkIf (envState ? depth) envState.depth; 40 | populatedTargets = mkIf (envState ? populatedTargets) envState.populatedTargets; 41 | }; 42 | output.json = { 43 | depth = cfg.input.depth + 1; 44 | populatedTargets = cfg.output.populatedTargets; 45 | }; 46 | }; 47 | config.state.filteredReferences = mkIf cfg.present (mkDefault cfg.input.populatedTargets); 48 | } 49 | -------------------------------------------------------------------------------- /modules/default.nix: -------------------------------------------------------------------------------- 1 | let 2 | tf = { 3 | acme = ./acme.nix; 4 | dns = ./dns.nix; 5 | terraform = ./terraform.nix; 6 | state = ./state.nix; 7 | deploy = ./deploy.nix; 8 | lustrate = ./lustrate.nix; 9 | run = ./run.nix; 10 | deps = ./deps.nix; 11 | continue = ./continue.nix; 12 | 13 | __functor = self: { ... }: { 14 | imports = with self; [ 15 | acme 16 | dns 17 | terraform 18 | state 19 | deploy 20 | lustrate 21 | run 22 | deps 23 | continue 24 | ]; 25 | }; 26 | }; 27 | nixos = import ./nixos; 28 | home = import ./home; 29 | in { 30 | inherit tf home nixos; 31 | } // tf 32 | -------------------------------------------------------------------------------- /modules/deploy.nix: -------------------------------------------------------------------------------- 1 | { pkgs, config, lib, ... }: with lib; let 2 | tf = config; 3 | cfg = config.deploy; 4 | interpreter = mkIf (!cfg.isRoot) [ "sudo" "bash" "-c" ]; 5 | deploySubmodule = { name, config, ... }: { 6 | options = { 7 | enable = mkEnableOption "deploy" // { 8 | default = true; 9 | }; 10 | nixosConfig = mkOption { 11 | type = types.nullOr types.unspecified; 12 | default = null; 13 | }; 14 | name = mkOption { 15 | type = types.str; 16 | default = name; 17 | }; 18 | system = mkOption { 19 | type = types.package; 20 | default = config.nixosConfig.system.build.toplevel; 21 | }; 22 | isRemote = mkOption { 23 | type = types.bool; 24 | default = true; 25 | }; 26 | connection = mkOption { 27 | type = tf.lib.tf.tfTypes.connectionType null; 28 | }; 29 | gcroot = { 30 | enable = mkEnableOption "gcroot" // { 31 | default = cfg.gcroot.enable; 32 | }; 33 | name = mkOption { 34 | type = types.str; 35 | default = config.name; 36 | }; 37 | path = mkOption { 38 | type = types.path; 39 | default = let 40 | root = if cfg.gcroot.useProfiles then cfg.gcroot.profilePath else cfg.gcroot.gcrootPath; 41 | in "${root}-${config.gcroot.name}"; 42 | }; 43 | }; 44 | secrets = { 45 | files = mkOption { 46 | type = types.attrsOf types.unspecified; 47 | default = { }; 48 | }; 49 | refIds = mkOption { 50 | type = types.separatedString ""; 51 | default = ""; 52 | }; 53 | cacheDir = mkOption { 54 | type = types.path; 55 | default = cfg.secrets.cacheDir; 56 | }; 57 | }; 58 | triggers = { 59 | common = mkOption { 60 | type = types.attrsOf types.str; 61 | default = { }; 62 | }; 63 | copy = mkOption { 64 | type = types.attrsOf types.str; 65 | default = { }; 66 | }; 67 | secrets = mkOption { 68 | type = types.attrsOf types.str; 69 | default = { }; 70 | }; 71 | switch = mkOption { 72 | type = types.attrsOf types.str; 73 | default = { }; 74 | }; 75 | gcroot = mkOption { 76 | type = types.attrsOf types.str; 77 | default = { }; 78 | }; 79 | }; 80 | out = { 81 | resourceName = mkOption { 82 | type = types.str; 83 | default = "${config.name}_system"; 84 | }; 85 | resource = { 86 | copy = mkOption { 87 | type = types.unspecified; 88 | default = tf.resources."${config.out.resourceName}_copy"; 89 | }; 90 | switch = mkOption { 91 | type = types.unspecified; 92 | default = tf.resources."${confg.out.resourceName}_switch"; 93 | }; 94 | }; 95 | setResources = mkOption { 96 | type = types.attrsOf types.unspecified; 97 | default = { }; 98 | }; 99 | }; 100 | }; 101 | config = mkIf config.enable { 102 | secrets = { 103 | files = mkIf (config.nixosConfig ? secrets.files) (mkDefault config.nixosConfig.secrets.files); 104 | refIds = mkMerge (mapAttrsToList (key: _: tf.resources."${config.name}_${tf.lib.tf.terraformIdent key}".refAttr "id") config.secrets.files); 105 | }; 106 | triggers = { 107 | copy = mapAttrs (_: mkOptionDefault) config.triggers.common // { 108 | system = "${config.system}"; 109 | }; 110 | switch = { 111 | copy = config.out.resource.copy.refAttr "id"; 112 | secrets = config.secrets.refIds; 113 | }; 114 | secrets = mapAttrs (_: mkOptionDefault) config.triggers.common; 115 | gcroot = { 116 | system = mkOptionDefault "${config.system}"; 117 | }; 118 | }; 119 | out.setResources = 120 | listToAttrs (concatLists (mapAttrsToList (key: file: let 121 | name = "${config.name}_${tf.lib.tf.terraformIdent key}"; 122 | source = if file.source != null then toString file.source else tf.resources."${name}_file".refAttr "filename"; 123 | in [ (nameValuePair name { 124 | provider = "terraform"; 125 | type = "data"; 126 | connection = mkIf config.isRemote config.connection.set; 127 | inputs.triggers_replace = { 128 | inherit (file) sha256 owner group mode; 129 | path = toString file.path; 130 | } // optionalAttrs (file.source == null) { 131 | file = tf.resources."${name}_file".refAttr "id"; 132 | } // optionalAttrs (file.source != null) { 133 | inherit source; 134 | } // config.triggers.secrets; 135 | provisioners = if config.isRemote then [ { 136 | type = "remote-exec"; 137 | remote-exec.inline = [ 138 | "install -dm0755 -o ${file.out.rootOwner} -g ${file.out.rootGroup} ${toString file.out.root}" 139 | "install -dm7755 -o ${file.out.rootOwner} -g ${file.out.rootGroup} ${toString file.out.dir}" 140 | ]; 141 | } { 142 | type = "file"; 143 | file = { 144 | inherit source; 145 | destination = toString file.path; 146 | }; 147 | } { 148 | type = "remote-exec"; 149 | remote-exec.inline = [ 150 | "chown ${file.out.rootOwner}:${file.out.rootGroup} ${toString file.path}" 151 | "chown ${file.owner}:${file.group} ${toString file.path}" 152 | "chmod ${file.mode} ${toString file.path}" 153 | ]; 154 | } ] else [ { 155 | type = "local-exec"; 156 | local-exec = { 157 | inherit interpreter; 158 | command = concatStringsSep " && " [ 159 | "install -dm0755 -o ${file.out.rootOwner} -g ${file.out.rootGroup} ${toString file.out.root}" 160 | "install -dm7755 -o ${file.out.rootOwner} -g ${file.out.rootGroup} ${toString file.out.dir}" 161 | "install -o ${file.out.rootOwner} -g ${file.out.rootGroup} -m ${file.mode} ${source} ${toString file.path}" 162 | ]; 163 | }; 164 | } { 165 | type = "local-exec"; 166 | local-exec = { 167 | inherit interpreter; 168 | command = concatStringsSep "; " [ 169 | "chown ${file.owner}:${file.group} ${toString file.path}" 170 | "true" 171 | ]; 172 | }; 173 | } ]; 174 | }) (nameValuePair "${name}_file" { 175 | enable = file.source == null; 176 | provider = "local"; 177 | type = "sensitive_file"; 178 | inputs = { 179 | filename = "${toString config.secrets.cacheDir}/${name}.secret"; 180 | content = file.text; 181 | file_permission = "0600"; 182 | }; 183 | }) ]) config.secrets.files)) // { 184 | "${config.out.resourceName}_copy" = { 185 | provider = "terraform"; 186 | type = "data"; 187 | connection = mkIf config.isRemote config.connection.set; 188 | inputs.triggers_replace = { 189 | inherit (config.triggers) copy; 190 | }; 191 | provisioners = mkIf config.isRemote [ { 192 | # wait for remote host to come online 193 | type = "remote-exec"; 194 | remote-exec.inline = [ "true" ]; 195 | } { 196 | type = "local-exec"; 197 | local-exec = { 198 | environment.NIX_SSHOPTS = config.connection.out.ssh.nixStoreOpts; 199 | command = "nix copy --substitute-on-destination --to ${config.connection.nixStoreUrl} ${config.system}"; 200 | }; 201 | } ]; 202 | }; 203 | "${config.out.resourceName}_switch" = { 204 | provider = "terraform"; 205 | type = "data"; 206 | connection = mkIf config.isRemote config.connection.set; 207 | inputs.triggers_replace = { 208 | inherit (config.triggers) switch; 209 | }; 210 | provisioners = let 211 | commands = [ 212 | "nix-env -p /nix/var/nix/profiles/system --set ${config.system}" 213 | "${config.system}/bin/switch-to-configuration switch" 214 | ]; 215 | remote = { 216 | type = "remote-exec"; 217 | remote-exec.inline = commands; 218 | }; 219 | local = { 220 | type = "local-exec"; 221 | local-exec = { 222 | inherit interpreter; 223 | command = concatStringsSep " && " commands; 224 | }; 225 | }; 226 | in if config.isRemote then [ remote ] else [ local ]; 227 | }; 228 | "${config.out.resourceName}_gcroot" = { 229 | enable = config.gcroot.enable && config.isRemote; 230 | provider = "terraform"; 231 | type = "data"; 232 | inputs.triggers_replace = { 233 | inherit (config.triggers) gcroot; 234 | }; 235 | provisioners = let 236 | indirectGcroot = tf.terraform.dataDir != null; 237 | gcrootTarget = if indirectGcroot 238 | then tf.terraform.dataDir + "/gcroot-${config.gcroot.name}" 239 | else config.gcroot.path; 240 | setProfile = [ 241 | ''nix-env -p "${config.gcroot.path}" --set ${config.system}'' 242 | ] ++ optional indirectGcroot ''ln -sfn "${config.gcroot.path}" "${toString gcrootTarget}"''; 243 | setGcroot = [ 244 | ''ln -sfn ${config.system} "${toString gcrootTarget}"'' 245 | ] ++ optional indirectGcroot ''ln -sfn "${toString gcrootTarget}" "${config.gcroot.path}"''; 246 | commands = if cfg.gcroot.useProfiles then setProfile else setGcroot; 247 | in singleton { 248 | local-exec.command = concatStringsSep " && " commands; 249 | }; 250 | }; 251 | }; 252 | }; 253 | }; 254 | in { 255 | options.deploy = { 256 | systems = mkOption { 257 | type = types.attrsOf (types.submodule deploySubmodule); 258 | default = { }; 259 | }; 260 | isRoot = mkOption { 261 | type = types.bool; 262 | default = false; 263 | }; 264 | secrets = { 265 | cacheDir = mkOption { 266 | type = types.path; 267 | default = tf.terraform.dataDir + "/secrets"; 268 | }; 269 | }; 270 | gcroot = { 271 | enable = mkEnableOption "gcroots by default"; 272 | useProfiles = mkOption { 273 | type = types.bool; 274 | default = false; 275 | }; 276 | user = mkOption { 277 | type = types.nullOr types.str; 278 | default = null; 279 | }; 280 | name = mkOption { 281 | type = types.str; 282 | default = "tf"; 283 | }; 284 | profilePath = mkOption { 285 | type = types.path; 286 | default = "/nix/var/nix/profiles" 287 | + optionalString (cfg.gcroot.user != null) "/per-user/${cfg.gcroot.user}" 288 | + "/${cfg.gcroot.name}"; 289 | }; 290 | gcrootPath = mkOption { 291 | type = types.path; 292 | default = "/nix/var/nix/gcroots" 293 | + optionalString (cfg.gcroot.user != null) "/per-user/${cfg.gcroot.user}" 294 | + "/${cfg.gcroot.name}"; 295 | }; 296 | }; 297 | }; 298 | 299 | config = { 300 | resources = mkMerge (mapAttrsToList (_: system: system.out.setResources) cfg.systems); 301 | outputs = mkMerge (mapAttrsToList (_: system: mkIf system.enable { 302 | "${system.out.resourceName}_ssh" = { 303 | value = { 304 | inherit (system.connection.out.ssh) destination cliArgs opts; 305 | inherit (system.connection) nixStoreUrl host port; 306 | }; 307 | sensitive = true; 308 | }; 309 | }) cfg.systems); 310 | runners.run = mkMerge (mapAttrsToList (_: system: mkIf system.enable { 311 | "${system.name}-ssh" = { 312 | command = let 313 | ssh = tf.outputs."${system.out.resourceName}_ssh".import; 314 | in '' 315 | exec ${config.runners.pkgs.openssh}/bin/ssh ${escapeShellArgs ssh.cliArgs} ${escapeShellArg ssh.destination} "$@" 316 | ''; 317 | }; 318 | }) cfg.systems); 319 | }; 320 | } 321 | -------------------------------------------------------------------------------- /modules/deps.nix: -------------------------------------------------------------------------------- 1 | { pkgs, lib, config, ... }: with config.lib.tf; with builtins; with lib; let 2 | cfg = config.deps; 3 | dagEntryType = types.attrs; 4 | dagType = types.submodule ({ config, ... }: { 5 | options = { 6 | type = mkOption { 7 | type = types.enum [ "drv" "path" "tf" ]; 8 | }; 9 | 10 | key = mkOption { 11 | type = types.str; 12 | default = null; 13 | }; 14 | }; 15 | }); 16 | dagFromString = str: let 17 | tfDrv = terraformContextFromDrv str; 18 | in if tfDrv != null then { 19 | type = "tf"; 20 | key = tfDrv.key; 21 | } else if hasSuffix ".drv" str then { 22 | type = "drv"; 23 | key = str; 24 | } else if hasPrefix "/" str then { 25 | type = "path"; 26 | key = str; 27 | } else { 28 | type = "tf"; 29 | key = str; 30 | }; 31 | 32 | # Sort a mixture of terraform resources and nix derivations by their interdependencies 33 | dagsFor' = paths: foldl (a: b: a // dagFor b) {} paths; 34 | dagFor = entry': let 35 | entry = dagFromString entry'; 36 | target = fromHclPath entry.key; 37 | json = toJSON target.hcl; 38 | references = if entry.type == "tf" 39 | then mapAttrsToList (k: _: (dagFromString k).key) (getContext json) 40 | else if entry.type == "drv" then terraformContextForDrv entry.key 41 | else [ ]; # TODO: consider including the entry.key path in dag? paths can't depend on anything though so are mostly useless? 42 | in { 43 | ${entry.key} = dagEntryAfter references { 44 | inherit references; 45 | inherit entry; 46 | }; 47 | }; 48 | dagsFor = attrs: let 49 | paths = mapAttrsToList (k: v: v.data.references) attrs; 50 | paths' = concatLists paths; 51 | paths'' = filter (k: ! attrs ? ${k}) paths'; 52 | next = dagsFor (attrs // dagsFor' paths''); 53 | in if paths'' == [] then attrs else next; 54 | in { 55 | options.deps = { 56 | enable = mkEnableOption "terraform/nix DAG"; 57 | continue.enable = mkEnableOption "partial targeted apply" // { 58 | default = true; 59 | }; 60 | 61 | select = { 62 | hclPaths = mkOption { 63 | type = types.nullOr (types.listOf types.str); 64 | default = null; 65 | example = ''[ tfconfig.some_resource.out.hclPath ]''; 66 | }; 67 | allProviders = mkOption { 68 | type = types.bool; 69 | default = false; 70 | description = "Deleted resources may require unused providers to be present in the config."; 71 | }; 72 | providers = mkOption { 73 | type = types.listOf config.lib.tf.tfTypes.providerReferenceType; 74 | default = [ ]; 75 | }; 76 | allOutputs = mkOption { 77 | type = types.bool; 78 | default = true; 79 | description = "Export all outputs even if they're unused"; 80 | }; 81 | outputs = mkOption { 82 | type = types.listOf types.str; 83 | default = [ ]; 84 | }; 85 | }; 86 | entries = mkOption { 87 | type = types.attrsOf dagEntryType; 88 | default = [ ]; 89 | }; 90 | sorted = mkOption { 91 | type = types.listOf dagType; 92 | readOnly = true; 93 | }; 94 | hcl = mkOption { 95 | type = types.attrs; 96 | readOnly = true; 97 | }; 98 | isComplete = mkOption { 99 | type = types.bool; 100 | readOnly = true; 101 | }; 102 | targets = mkOption { 103 | type = types.listOf types.str; 104 | readOnly = true; 105 | }; 106 | 107 | apply = { 108 | package = mkOption { 109 | type = types.package; 110 | readOnly = true; 111 | }; 112 | initCommand = mkOption { 113 | type = types.lines; 114 | defaultText = "terraform init"; 115 | }; 116 | doneCommand = mkOption { 117 | type = types.lines; 118 | default = ""; 119 | }; 120 | }; 121 | }; 122 | 123 | config = let 124 | # 1. remove any complete (or irrelevant) items from sorted 125 | filterDone = e: let 126 | hcl = fromHclPath e.key; 127 | name = hcl.out.reference; 128 | filteredNames = config.continue.input.populatedTargets; 129 | filtered = config.continue.present && (filteredNames == null || elem hcl.out.reference filteredNames); 130 | isDone = hcl.kind == "variable" || hcl.kind == "provider" || filtered; 131 | in e.type == "tf" && (!cfg.continue.enable || isDone); 132 | done' = partition filterDone cfg.sorted; 133 | done = if cfg.continue.enable 134 | then done'.right 135 | else filter filterDone cfg.sorted; 136 | remaining = done'.wrong; 137 | # 2. if sorted starts with drv entries, remove all until the first tf 138 | remaining' = (foldl (sum: e: if e.type == "drv" && !sum.fused 139 | then { fused = false; sum = [ ]; } 140 | else { fused = true; sum = sum.sum ++ [ e ]; } 141 | ) { fused = false; sum = [ ]; } remaining).sum; 142 | # 3. if sorted starts with any tf entries, apply all targets up until the first drv 143 | remaining'' = foldl (sum: e: if e.type == "tf" && sum.rest == [ ] 144 | then { rest = [ ]; tfs = sum.tfs ++ [ e ]; } 145 | else { rest = sum.rest ++ [ e ]; tfs = sum.tfs; } 146 | ) { tfs = [ ]; rest = [ ]; } remaining'; 147 | tfTargets = if cfg.continue.enable 148 | then remaining''.tfs 149 | else [ ]; 150 | tfIncomplete = if cfg.continue.enable 151 | then filter (e: e.type == "tf") remaining''.rest 152 | else [ ]; 153 | # 4. if there are no drvs at the end, you're done. (no need to specify TF_TARGETS or continue) 154 | isComplete = if cfg.continue.enable 155 | then remaining''.rest == [ ] 156 | else true; 157 | # 5. if there are drvs at the end (and no more tfs), something is messed up? 158 | broken = !isComplete && all (e: e.type == "drv") remaining''.rest; 159 | 160 | 161 | toHcl = r: hcl: let 162 | # TODO: this provider handling is hacky and meh 163 | hclPath = r.out.hclPath; 164 | path = if hasPrefix "provider." r.out.hclPathStr then [ "provider" r.type ] else hclPath; 165 | in attrsFromPath path (hcl r.hcl); 166 | hcl = foldl combineHcl { } ( 167 | map (e: toHcl (fromHclPath e.key) scrubHcl) (done ++ tfTargets) 168 | ++ map (e: toHcl (fromHclPath e.key) scrubHclAll) tfIncomplete 169 | ) // optionalAttrs (config.hcl.terraform != { }) { inherit (config.hcl) terraform; }; 170 | 171 | filterTarget = e: let 172 | item = fromHclPath e.key; 173 | in item.kind == "resource" || item.kind == "data"; 174 | targetMap = (e: (fromHclPath e.key).out.reference); 175 | targets = done ++ tfTargets; 176 | targetResources = filter filterTarget targets; 177 | 178 | select' = 179 | map (r: dagFromString r.out.provider.out.hclPathStr) cfg.select.providers 180 | ++ map (o: dagFromString config.outputs.${o}.out.hclPathStr) cfg.select.outputs 181 | ++ (if cfg.select.hclPaths == null 182 | then map (r: dagFromString r.out.hclPathStr) (filter (res: res.enable && !res.dataSource) (attrValues config.resources)) 183 | else map (res: dagFromString res) cfg.select.hclPaths 184 | ); 185 | in { 186 | deps = { 187 | entries = dagsFor (dagsFor' (map (t: t.key) select')); 188 | sorted = map ({ data, name }: data.entry) (dagTopoSort cfg.entries).result or (throw "tf-nix dependency loop detected"); 189 | 190 | select = { 191 | providers = mkIf cfg.select.allProviders (mapAttrsToList (_: r: r.out.reference) config.providers); 192 | outputs = mkIf cfg.select.allOutputs (mapAttrsToList (_: o: o.name) config.outputs); 193 | }; 194 | 195 | inherit isComplete; 196 | hcl = assert !broken; hcl; 197 | targets = map targetMap targetResources; 198 | 199 | apply = let 200 | targets = optionals (!cfg.isComplete) cfg.targets; 201 | # TODO: consider whether to include targets even on completion if not all resources are selected? 202 | applyHeader = optionalString cfg.continue.enable '' 203 | export ${config.continue.envVar}='${toJSON config.continue.output.json}' 204 | ''; 205 | applyInit = optionalString (!cfg.continue.enable || !config.continue.present) cfg.apply.initCommand; 206 | applyTargets = optionalString (cfg.continue.enable && config.continue.present) '' 207 | export TF_TARGETS="${concatStringsSep " " targets}" 208 | ''; 209 | applyFooter = if cfg.continue.enable && (!config.continue.present || !cfg.isComplete) 210 | then ''exec ${escapeShellArgs config.runners.lazy.run.apply.out.runArgs} "$@"'' 211 | else cfg.apply.doneCommand; 212 | in { 213 | package = pkgs.writeShellScriptBin "terraform-apply" '' 214 | set -eu 215 | 216 | ${applyHeader} 217 | ${applyInit} 218 | ${applyTargets} 219 | ${config.terraform.cli}/bin/terraform apply "$@" 220 | ${applyFooter} 221 | ''; 222 | initCommand = "${config.terraform.cli}/bin/terraform init"; 223 | }; 224 | }; 225 | continue.output.populatedTargets = mkIf (cfg.enable && cfg.continue.enable) ( 226 | if config.continue.present then map targetMap targets else [ ] 227 | ); 228 | terraform = { 229 | environment = mkIf cfg.enable { 230 | TF_CONFIG_DIR = mkDefault "${hclDir { 231 | inherit (cfg) hcl; 232 | inherit (config.terraform) prettyJson; 233 | terraform = config.terraform.packageWithPlugins; 234 | }}"; 235 | }; 236 | }; 237 | runners.run = mkIf cfg.enable { 238 | apply = { 239 | executable = mkDefault "terraform-apply"; 240 | package = mkDefault cfg.apply.package; 241 | }; 242 | }; 243 | }; 244 | } 245 | -------------------------------------------------------------------------------- /modules/dns.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: with lib; let 2 | cfg = config.dns; 3 | tflib = config.lib.tf; 4 | inherit (config) resources; 5 | warnings = mkOption { 6 | # mkAliasOptionModule sets these 7 | type = with types; listOf str; 8 | internal = true; 9 | default = [ ]; 10 | }; 11 | zoneType = types.submodule ({ name, config, ... }: { 12 | imports = [ 13 | (mkRenamedOptionModule [ "tld" ] [ "domain" ]) 14 | (mkAliasOptionModule [ "zone" ] [ "domain" ]) 15 | ]; 16 | options = { 17 | enable = mkEnableOption "dns zone" // { 18 | default = true; 19 | }; 20 | domain = mkOption { 21 | type = types.str; 22 | default = name; 23 | }; 24 | provider = mkOption { 25 | type = tflib.tfTypes.providerReferenceType; 26 | }; 27 | create = mkOption { 28 | type = types.bool; 29 | default = true; 30 | }; 31 | dataSource = mkOption { 32 | type = types.bool; 33 | default = false; 34 | }; 35 | inputs = mkOption { 36 | type = types.attrsOf types.unspecified; 37 | default = false; 38 | }; 39 | cloudflare.id = mkOption { 40 | type = types.nullOr types.str; 41 | default = null; 42 | }; 43 | out = { 44 | resourceName = mkOption { 45 | type = types.str; 46 | internal = true; 47 | readOnly = true; 48 | }; 49 | resource = mkOption { 50 | type = types.unspecified; 51 | internal = true; 52 | readOnly = true; 53 | }; 54 | set = { 55 | provider = mkOption { 56 | type = types.unspecified; 57 | internal = true; 58 | readOnly = true; 59 | }; 60 | type = mkOption { 61 | type = types.str; 62 | internal = true; 63 | readOnly = true; 64 | }; 65 | inputs = mkOption { 66 | type = types.attrs; 67 | internal = true; 68 | readOnly = true; 69 | }; 70 | }; 71 | }; 72 | inherit warnings; 73 | }; 74 | config = { 75 | provider = mkIf (config.cloudflare.id != null) (mkOptionDefault "cloudflare"); 76 | create = mkMerge [ 77 | (mkIf (config.cloudflare.id != null) (mkDefault false)) 78 | (mkIf (config.provider.type == "dns") (mkDefault false)) 79 | ]; 80 | out = { 81 | resourceName = let 82 | domain = tflib.terraformIdent config.domain; 83 | in mkOptionDefault "${config.provider.type}_${domain}"; 84 | resource = resources.${config.out.resourceName}; 85 | set = { 86 | provider = config.provider.set; 87 | } // { 88 | cloudflare = if config.dataSource then { 89 | type = "zones"; 90 | inputs.filter.name = config.domain; 91 | } else { 92 | type = "zone"; 93 | inputs = { 94 | zone = config.domain; 95 | } // config.inputs; 96 | }; 97 | dns = { }; 98 | }.${config.provider.type}; 99 | }; 100 | }; 101 | }); 102 | recordType = types.submodule ({ name, config, ... }: { 103 | imports = [ 104 | (mkRenamedOptionModule [ "tld" ] [ "zone" ]) 105 | ]; 106 | options = { 107 | enable = mkEnableOption "dns record" // { 108 | default = true; 109 | }; 110 | name = mkOption { 111 | type = types.str; 112 | default = name; 113 | }; 114 | zone = mkOption { 115 | type = types.str; 116 | }; 117 | domain = mkOption { 118 | type = types.nullOr types.str; 119 | default = null; 120 | }; 121 | ttl = mkOption { 122 | type = types.int; 123 | default = 3600; 124 | }; 125 | a = mkOption { 126 | type = types.nullOr (types.submodule ({ config, ... }: { 127 | options = { 128 | address = mkOption { 129 | type = types.str; 130 | }; 131 | }; 132 | })); 133 | default = null; 134 | }; 135 | aaaa = mkOption { 136 | type = types.nullOr (types.submodule ({ config, ... }: { 137 | options = { 138 | address = mkOption { 139 | type = types.str; 140 | }; 141 | }; 142 | })); 143 | default = null; 144 | }; 145 | mx = mkOption { 146 | type = types.nullOr (types.submodule ({ config, ... }: { 147 | options = { 148 | target = mkOption { 149 | type = types.str; 150 | }; 151 | priority = mkOption { 152 | type = types.int; 153 | default = 10; 154 | }; 155 | }; 156 | })); 157 | default = null; 158 | }; 159 | txt = mkOption { 160 | type = types.nullOr (types.submodule ({ config, ... }: { 161 | options = { 162 | value = mkOption { 163 | type = types.str; 164 | }; 165 | }; 166 | })); 167 | default = null; 168 | }; 169 | cname = mkOption { 170 | type = types.nullOr (types.submodule ({ config, ... }: { 171 | options = { 172 | target = mkOption { 173 | type = types.str; 174 | }; 175 | }; 176 | })); 177 | default = null; 178 | }; 179 | srv = mkOption { 180 | type = types.nullOr (types.submodule ({ config, ... }: { 181 | options = { 182 | service = mkOption { 183 | type = types.str; 184 | }; 185 | proto = mkOption { 186 | type = types.str; 187 | default = "tcp"; 188 | }; 189 | priority = mkOption { 190 | type = types.int; 191 | default = 0; 192 | }; 193 | weight = mkOption { 194 | type = types.int; 195 | default = 5; 196 | }; 197 | port = mkOption { 198 | type = types.port; 199 | }; 200 | target = mkOption { 201 | type = types.str; 202 | }; 203 | }; 204 | })); 205 | default = null; 206 | }; 207 | uri = mkOption { 208 | type = types.nullOr (types.submodule ({ config, ... }: { 209 | options = { 210 | service = mkOption { 211 | type = types.str; 212 | }; 213 | proto = mkOption { 214 | type = types.nullOr types.str; 215 | default = "tcp"; 216 | }; 217 | priority = mkOption { 218 | type = types.int; 219 | default = 0; 220 | }; 221 | weight = mkOption { 222 | type = types.int; 223 | default = 5; 224 | }; 225 | target = mkOption { 226 | type = types.str; 227 | }; 228 | }; 229 | })); 230 | default = null; 231 | }; 232 | out = { 233 | type = mkOption { 234 | type = types.str; 235 | internal = true; 236 | readOnly = true; 237 | }; 238 | resourceName = mkOption { 239 | type = types.str; 240 | internal = true; 241 | readOnly = true; 242 | }; 243 | domain = mkOption { 244 | type = types.str; 245 | internal = true; 246 | readOnly = true; 247 | }; 248 | fqdn = mkOption { 249 | type = types.str; 250 | internal = true; 251 | readOnly = true; 252 | }; 253 | resource = mkOption { 254 | type = types.unspecified; 255 | internal = true; 256 | readOnly = true; 257 | }; 258 | zone = mkOption { 259 | type = types.unspecified; 260 | internal = true; 261 | readOnly = true; 262 | }; 263 | set = { 264 | provider = mkOption { 265 | type = types.unspecified; 266 | internal = true; 267 | readOnly = true; 268 | }; 269 | type = mkOption { 270 | type = types.str; 271 | internal = true; 272 | readOnly = true; 273 | }; 274 | inputs = mkOption { 275 | type = types.attrs; 276 | internal = true; 277 | readOnly = true; 278 | }; 279 | }; 280 | }; 281 | inherit warnings; 282 | }; 283 | config = { 284 | out = { 285 | type = let 286 | types = filter (t: t != null) [ 287 | (mapNullable (_: "SRV") config.srv) 288 | (mapNullable (_: "URI") config.uri) 289 | (mapNullable (_: "A") config.a) 290 | (mapNullable (_: "AAAA") config.aaaa) 291 | (mapNullable (_: "CNAME") config.cname) 292 | (mapNullable (_: "MX") config.mx) 293 | (mapNullable (_: "TXT") config.txt) 294 | ]; 295 | in if length types == 1 then mkOptionDefault (head types) 296 | else throw "invalid DNS record type"; 297 | resourceName = let 298 | name = replaceStrings [ "-" "." ] [ "_" "_" ] config.name; 299 | in mkOptionDefault "record_${name}_${config.out.type}"; 300 | domain = if config.domain == null then "@" else config.domain; 301 | fqdn = optionalString (config.domain != null) "${config.domain}." + config.zone; 302 | resource = resources.${config.out.resourceName}; 303 | zone = cfg.zones.${config.zone}; 304 | set = { 305 | cloudflare = { 306 | provider = config.out.zone.provider.set; 307 | type = "record"; 308 | inputs = { 309 | zone_id = if config.out.zone.cloudflare.id != null 310 | then config.out.zone.cloudflare.id 311 | else if config.out.zone.dataSource 312 | then config.out.zone.out.resource.refAttr ''zones[0]["id"]'' 313 | else config.out.zone.out.resource.refAttr "id"; 314 | inherit (config.out) type; 315 | name = config.out.domain; 316 | } // (if config.out.type == "SRV" then { 317 | name = concatStringsSep "." ([ 318 | "_${config.srv.service}" 319 | "_${config.srv.proto}" 320 | ] ++ optional (config.domain != null) config.domain 321 | #++ [ config.out.fqdn ] 322 | ); 323 | data = { 324 | inherit (config.srv) priority weight port target; 325 | }; 326 | } else if config.out.type == "URI" then { 327 | data = { 328 | service = "_${config.uri.service}"; 329 | ${mapNullable (_: "proto") config.uri.proto} = "_${config.uri.proto}"; 330 | name = config.out.fqdn; 331 | inherit (config.uri) priority weight target; 332 | }; 333 | } else if config.out.type == "A" then { 334 | content = config.a.address; 335 | } else if config.out.type == "AAAA" then { 336 | content = config.aaaa.address; 337 | } else if config.out.type == "CNAME" then { 338 | content = config.cname.target; 339 | } else if config.out.type == "MX" then { 340 | inherit (config.mx) priority; 341 | content = config.mx.target; 342 | } else if config.out.type == "TXT" then { 343 | content = config.txt.value; 344 | } else throw "unknown DNS record ${config.out.type}"); 345 | }; 346 | dns = let 347 | name = config.domain; 348 | zone = config.out.zone.domain; 349 | in { 350 | provider = config.out.zone.provider.set; 351 | type = "${toLower config.out.type}_record" 352 | + optionalString (config.out.type != "CNAME" && config.out.type != "PTR") "_set"; 353 | inputs = { 354 | A = { 355 | inherit zone; 356 | inherit (config) ttl; 357 | addresses = singleton config.a.address; 358 | } // optionalAttrs (name != null) { 359 | inherit name; 360 | }; 361 | AAAA = { 362 | inherit zone; 363 | inherit (config) ttl; 364 | addresses = singleton config.aaaa.address; 365 | } // optionalAttrs (name != null) { 366 | inherit name; 367 | }; 368 | MX = { 369 | inherit zone; 370 | inherit (config) ttl; 371 | mx = singleton { 372 | preference = config.mx.priority; 373 | exchange = config.mx.target; 374 | }; 375 | }; 376 | TXT = { 377 | inherit zone; 378 | inherit (config) ttl; 379 | txt = singleton config.txt.value; 380 | } // optionalAttrs (name != null) { 381 | inherit name; 382 | }; 383 | CNAME = { 384 | inherit zone name; 385 | inherit (config) ttl; 386 | cname = config.cname.target; 387 | }; 388 | SRV = { 389 | inherit zone; 390 | name = "_${config.srv.service}._${config.srv.proto}"; 391 | inherit (config) ttl; 392 | srv = singleton { 393 | inherit (config.srv) priority weight port target; 394 | }; 395 | }; 396 | }.${config.out.type} or (throw "Unsupported record type ${config.out.type}"); 397 | }; 398 | }.${config.out.zone.provider.type} or (throw "Unknown provider ${config.out.zone.provider.type}"); 399 | }; 400 | }; 401 | }); 402 | in { 403 | options.dns = { 404 | zones = mkOption { 405 | type = types.attrsOf zoneType; 406 | default = { }; 407 | }; 408 | records = mkOption { 409 | type = types.attrsOf recordType; 410 | default = { }; 411 | }; 412 | }; 413 | config.resources = 414 | mapAttrs' (name: cfg: nameValuePair cfg.out.resourceName { 415 | inherit (cfg) enable dataSource; 416 | inherit (cfg.out.set) provider type; 417 | inputs = mkIf cfg.enable cfg.out.set.inputs; 418 | }) (filterAttrs (_: z: z.create) cfg.zones) 419 | // mapAttrs' (name: cfg: nameValuePair cfg.out.resourceName { 420 | inherit (cfg) enable; 421 | inherit (cfg.out.set) provider type; 422 | inputs = mkIf cfg.enable cfg.out.set.inputs; 423 | }) cfg.records; 424 | } 425 | -------------------------------------------------------------------------------- /modules/home/default.nix: -------------------------------------------------------------------------------- 1 | { 2 | secrets = ./secrets.nix; 3 | 4 | __functor = self: { ... }: { 5 | imports = with self; [ 6 | secrets 7 | ]; 8 | }; 9 | } 10 | -------------------------------------------------------------------------------- /modules/home/secrets.nix: -------------------------------------------------------------------------------- 1 | import ../secrets.nix false 2 | -------------------------------------------------------------------------------- /modules/lustrate.nix: -------------------------------------------------------------------------------- 1 | { pkgs, config, lib, ... }: with lib; let 2 | tf = config; 3 | cfg = config.deploy.lustrate; 4 | lustrateSubmodule = { config, ... }: { 5 | options = { 6 | lustrate = { 7 | enable = mkEnableOption "NIXOS_LUSTRATE"; 8 | 9 | connection = mkOption { 10 | type = tf.lib.tf.tfTypes.connectionType null; 11 | default = config.connection.set; 12 | }; 13 | 14 | install = mkOption { 15 | type = types.bool; 16 | default = true; 17 | description = "Install Nix"; 18 | }; 19 | 20 | whitelist = mkOption { 21 | type = with types; listOf path; 22 | default = [ ]; 23 | defaultText = ''[ "/root/.ssh/authorized_keys" ]''; 24 | }; 25 | mount = mkOption { 26 | type = with types; listOf str; 27 | default = [ ]; 28 | }; 29 | unmount = mkOption { 30 | type = with types; listOf path; 31 | default = [ ]; 32 | }; 33 | 34 | scripts = { 35 | install = mkOption { 36 | type = types.lines; 37 | }; 38 | mount = mkOption { 39 | type = types.lines; 40 | }; 41 | prepare = mkOption { 42 | type = types.lines; 43 | }; 44 | lustrate = mkOption { 45 | type = types.lines; 46 | }; 47 | }; 48 | }; 49 | triggers = { 50 | lustrate_install = mkOption { 51 | type = types.attrsOf types.str; 52 | default = { }; 53 | }; 54 | lustrate_copy = mkOption { 55 | type = types.attrsOf types.str; 56 | default = { }; 57 | }; 58 | lustrate = mkOption { 59 | type = types.attrsOf types.str; 60 | default = { }; 61 | }; 62 | }; 63 | out.resource = { 64 | lustrate_install = mkOption { 65 | type = types.unspecified; 66 | default = tf.resources."${config.out.resourceName}_lustrate_install"; 67 | }; 68 | lustrate_copy = mkOption { 69 | type = types.unspecified; 70 | default = tf.resources."${config.out.resourceName}_lustrate_copy"; 71 | }; 72 | lustrate = mkOption { 73 | type = types.unspecified; 74 | default = tf.resources."${config.out.resourceName}_lustrate"; 75 | }; 76 | }; 77 | }; 78 | config = { 79 | lustrate = { 80 | whitelist = [ 81 | "/root/.ssh/authorized_keys" 82 | ]; 83 | unmount = [ 84 | "/boot" 85 | ]; 86 | mount = let 87 | inherit (config.nixosConfig.boot.loader.efi) efiSysMountPoint; 88 | mountEsp = config.nixosConfig.boot.loader.grub.efiSupport && efiSysMountPoint != "/boot"; 89 | in mkMerge [ 90 | (mkIf (config.nixosConfig.fileSystems ? "/boot") [ "/boot" ]) 91 | (mkIf (mountEsp && config.nixosConfig.fileSystems ? ${efiSysMountPoint}) [ efiSysMountPoint ]) 92 | ]; 93 | scripts = { 94 | install = '' 95 | #!/usr/bin/env bash 96 | set -eu 97 | 98 | if command -v nix > /dev/null; then 99 | # skip install if nix already exists 100 | exit 0 101 | fi 102 | 103 | groupadd nixbld -g 30000 || true 104 | for i in {1..10}; do 105 | useradd -c "Nix build user $i" -d /var/empty -g nixbld -G nixbld -M -N -r -s "$(command -v nologin)" "nixbld$i" || true 106 | done 107 | 108 | curl -L https://nixos.org/nix/install | $SHELL 109 | sed -i -e '1s_^_source ~/.nix-profile/etc/profile.d/nix.sh\n_' ~/.bashrc # must be at beginning of file 110 | ''; # TODO: tmp mkswap and swapon because nix copy can eat rams thanks 111 | mount = '' 112 | #!/usr/bin/env bash 113 | set -eu 114 | 115 | if [[ -e /etc/NIXOS ]]; then 116 | exit 0 117 | fi 118 | 119 | find /boot -type f -delete 120 | umount -Rq ${escapeShellArgs config.lustrate.unmount} || true 121 | '' + concatMapStringsSep "\n" (mount: let 122 | fs = config.nixosConfig.fileSystems.${mount}; 123 | in '' 124 | mkdir -p ${escapeShellArg fs.mountPoint} && 125 | mount ${escapeShellArgs [ fs.device fs.mountPoint ]} 126 | '') config.lustrate.mount; 127 | prepare = '' 128 | #!/usr/bin/env bash 129 | set -eu 130 | 131 | if [[ -e /etc/NIXOS ]]; then 132 | exit 0 133 | fi 134 | 135 | touch /etc/NIXOS 136 | touch /etc/NIXOS_LUSTRATE 137 | printf '%s\n' ${escapeShellArgs config.lustrate.whitelist} >> /etc/NIXOS_LUSTRATE 138 | 139 | nix-env -p /nix/var/nix/profiles/system --set ${config.system} 140 | /nix/var/nix/profiles/system/bin/switch-to-configuration boot 141 | ''; 142 | lustrate = '' 143 | #!/usr/bin/env bash 144 | set -eu 145 | 146 | if [[ -e /etc/NIXOS_LUSTRATE ]]; then 147 | reboot 148 | fi 149 | ''; 150 | }; 151 | }; 152 | triggers = mkIf config.lustrate.enable { 153 | lustrate_install = mapAttrs (_: mkOptionDefault) config.triggers.common // { 154 | install_enable = toString config.lustrate.install; 155 | }; 156 | lustrate_copy = { 157 | install = config.out.resource.lustrate_install.refAttr "id"; 158 | }; 159 | lustrate = { 160 | copy = config.out.resource.lustrate_copy.refAttr "id"; 161 | }; 162 | copy = { 163 | lustrate = config.out.resource.lustrate.refAttr "id"; 164 | }; 165 | secrets = { 166 | lustrate = config.out.resource.lustrate.refAttr "id"; 167 | }; 168 | }; 169 | out.setResources = mkIf config.lustrate.enable { 170 | "${config.out.resourceName}_lustrate_install" = { 171 | provider = "null"; 172 | type = "resource"; 173 | connection = config.lustrate.connection.set; 174 | inputs.triggers = config.triggers.lustrate_install; 175 | provisioners = mkIf config.lustrate.install [ 176 | { file = { 177 | destination = "/tmp/lustrate-install"; 178 | content = config.lustrate.scripts.install; 179 | }; } 180 | { remote-exec.command = "bash -x /tmp/lustrate-install"; } 181 | ]; 182 | }; 183 | "${config.out.resourceName}_lustrate_copy" = { 184 | provider = "null"; 185 | type = "resource"; 186 | connection = config.lustrate.connection.set; 187 | inputs.triggers = config.triggers.lustrate_copy; 188 | provisioners = [ 189 | { # wait for remote host to come online 190 | type = "remote-exec"; 191 | remote-exec.inline = [ "true" ]; 192 | } 193 | { local-exec = { 194 | environment.NIX_SSHOPTS = config.lustrate.connection.out.ssh.nixStoreOpts; 195 | command = "nix copy --substitute-on-destination --to ${config.lustrate.connection.nixStoreUrl} ${config.system}"; 196 | }; } 197 | ]; 198 | }; 199 | "${config.out.resourceName}_lustrate" = { 200 | provider = "null"; 201 | type = "resource"; 202 | connection = config.lustrate.connection.set; 203 | inputs.triggers = config.triggers.lustrate; 204 | provisioners = [ 205 | { file = { 206 | destination = "/tmp/lustrate-mount"; 207 | content = config.lustrate.scripts.mount; 208 | }; } 209 | { file = { 210 | destination = "/tmp/lustrate-prepare"; 211 | content = config.lustrate.scripts.prepare; 212 | }; } 213 | { file = { 214 | destination = "/tmp/lustrate"; 215 | content = config.lustrate.scripts.lustrate; 216 | }; } 217 | { remote-exec.command = "bash -x /tmp/lustrate-mount"; } 218 | { remote-exec.command = "bash -x /tmp/lustrate-prepare"; } 219 | { remote-exec.command = "bash -x /tmp/lustrate"; # reboot into new system 220 | onFailure = "continue"; 221 | } 222 | ]; 223 | }; 224 | }; 225 | }; 226 | }; 227 | in { 228 | options.deploy = { 229 | systems = mkOption { 230 | type = types.attrsOf (types.submodule lustrateSubmodule); 231 | }; 232 | }; 233 | } 234 | -------------------------------------------------------------------------------- /modules/nixos/compat.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: with lib; { 2 | options = { 3 | services.openssh.sha1LegacyCompatibility = mkOption { 4 | type = types.bool; 5 | default = versionAtLeast config.programs.ssh.package.version "8.8"; 6 | description = '' 7 | Allow signing clients to use the deprecated RSA/SHA1 algorithm to authenticate, which is 8 | still required at this time by Go applications such as Terraform. 9 | ''; 10 | }; 11 | }; 12 | config = { 13 | services.openssh.extraConfig = mkIf config.services.openssh.sha1LegacyCompatibility '' 14 | # workaround for terraform (see https://github.com/golang/go/issues/39885) 15 | PubkeyAcceptedAlgorithms +ssh-rsa 16 | ''; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /modules/nixos/default.nix: -------------------------------------------------------------------------------- 1 | { 2 | compat = ./compat.nix; 3 | secrets = ./secrets.nix; 4 | secrets-users = ./secrets-users.nix; 5 | run = ../run.nix; 6 | 7 | # for installing over base images with lustrate 8 | ubuntu-linux = ./ubuntu-linux.nix; 9 | oracle-linux = ./oracle-linux.nix; 10 | 11 | # headless/vm settings 12 | vm = ./vm.nix; 13 | headless = ./headless.nix; 14 | 15 | # oci_core instances 16 | oracle = ./oracle.nix; 17 | 18 | # digitalocean droplets 19 | digitalocean = ./digitalocean.nix; 20 | 21 | __functor = self: { ... }: { 22 | imports = with self; [ 23 | compat 24 | secrets 25 | secrets-users 26 | run 27 | ]; 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /modules/nixos/digitalocean.nix: -------------------------------------------------------------------------------- 1 | { modulesPath, pkgs, config, lib, ... }: with lib; let 2 | cfg = config.virtualisation.digitalOcean; 3 | in { 4 | imports = [ 5 | ./vm.nix 6 | (modulesPath + "/virtualisation/digital-ocean-config.nix") 7 | ]; 8 | 9 | options.virtualisation.digitalOcean = { 10 | metadataNetworking = mkOption { 11 | type = types.bool; 12 | default = false; 13 | description = "Whether to instantiate networking config from Digital Ocean metadata"; 14 | }; 15 | }; 16 | 17 | config = { 18 | boot.initrd.availableKernelModules = [ 19 | "nvme" 20 | ]; 21 | networking.interfaces.eth0 = mkIf cfg.metadataNetworking { 22 | ipv4 = { 23 | addresses = [ 24 | { 25 | address = "169.254.0.1"; 26 | prefixLength = 16; 27 | } 28 | ]; 29 | routes = [ 30 | { 31 | address = "169.254.169.254"; 32 | prefixLength = 32; 33 | } 34 | ]; 35 | }; 36 | }; 37 | systemd.services.digitalocean-network = mkIf cfg.metadataNetworking { 38 | path = [ pkgs.iproute pkgs.jq ]; 39 | wantedBy = [ "network.target" ]; 40 | description = "DigitalOcean static network configuration"; 41 | script = '' 42 | set -xeo pipefail 43 | netmask() { 44 | # https://stackoverflow.com/a/50414560 45 | c=0 x=0$( printf '%o' ''${1//./ } ) 46 | while [ $x -gt 0 ]; do 47 | let c+=$((x%2)) 'x>>=1' 48 | done 49 | echo /$c 50 | } 51 | META=/run/do-metadata/v1.json 52 | IPV4=$(jq -er '.interfaces.public[0].ipv4.ip_address' $META) 53 | NETMASK=$(jq -er '.interfaces.public[0].ipv4.netmask' $META) 54 | GATEWAY=$(jq -er '.interfaces.public[0].ipv4.gateway' $META) 55 | NAMESERVERS=$(jq -er '.dns.nameservers | .[]' $META) 56 | ip addr add $IPV4$(netmask $NETMASK) dev eth0 57 | ip route add default via $GATEWAY dev eth0 58 | for ns in $NAMESERVERS; do 59 | echo "nameserver $ns" >> /etc/resolv.conf 60 | done 61 | ''; 62 | unitConfig = { 63 | Before = [ "network.target" ]; 64 | After = [ "digitalocean-metadata.service" ]; 65 | Requires = [ "digitalocean-metadata.service" ]; 66 | }; 67 | serviceConfig = { 68 | Type = "oneshot"; 69 | }; 70 | }; 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /modules/nixos/headless.nix: -------------------------------------------------------------------------------- 1 | { lib, ... }: with lib; { 2 | config = { 3 | boot = { 4 | vesa = mkDefault false; 5 | }; 6 | 7 | systemd.services."getty@tty1".enable = mkDefault false; 8 | systemd.services."autovt@".enable = mkDefault false; 9 | systemd.enableEmergencyMode = mkDefault false; 10 | 11 | services.openssh = { 12 | enable = mkDefault true; 13 | }; 14 | 15 | # slim build 16 | documentation.enable = mkDefault false; 17 | services.udisks2.enable = mkDefault false; 18 | 19 | environment.variables = { 20 | GC_INITIAL_HEAP_SIZE = mkDefault "8M"; # nix default is way too big 21 | }; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /modules/nixos/oracle-linux.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: with lib; { 2 | config = { 3 | boot = { 4 | efi.efiSysMountPoint = mkDefault "/mnt/esp"; 5 | loader.grub = mkIf (!config.boot.loader.grub.efiSupport) { 6 | device = mkDefault "/dev/sda"; 7 | }; 8 | }; 9 | 10 | fileSystems = { 11 | "/" = { 12 | device = mkDefault "/dev/sda3"; 13 | fsType = mkDefault "xfs"; 14 | }; 15 | "/mnt/esp" = mkIf config.boot.loader.grub.efiSupport { 16 | device = mkDefault "/dev/disk/by-partlabel/EFI\\x20System\\x20Partition"; 17 | fsType = mkDefault "vfat"; 18 | }; 19 | }; 20 | 21 | swapDevices = [ 22 | { 23 | device = "/dev/sda2"; 24 | } 25 | ]; 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /modules/nixos/oracle.nix: -------------------------------------------------------------------------------- 1 | { lib, ... }: with lib; { 2 | imports = [ 3 | ./vm.nix 4 | ]; 5 | 6 | config = { 7 | boot = { 8 | loader.grub.efiSupport = mkDefault true; 9 | initrd.availableKernelModules = [ 10 | "nvme" "ata_piix" "uhci_hcd" 11 | ]; 12 | }; 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /modules/nixos/secrets-users.nix: -------------------------------------------------------------------------------- 1 | { options, config, lib, ... }: with lib; let 2 | cfg = config.secrets; 3 | in { 4 | options.secrets.userConfigs = mkOption { 5 | type = types.listOf types.unspecified; 6 | default = [ ]; 7 | }; 8 | 9 | config.secrets = { 10 | userConfigs = mkIf (options ? home-manager.users) (mkDefault (attrValues config.home-manager.users)); 11 | files = mkMerge (map (user: mapAttrs' (k: file: 12 | nameValuePair "user-${user.home.username}-${k}" file.out.set 13 | ) user.secrets.files) cfg.userConfigs); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /modules/nixos/secrets.nix: -------------------------------------------------------------------------------- 1 | import ../secrets.nix true 2 | -------------------------------------------------------------------------------- /modules/nixos/ubuntu-linux.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: with lib; { 2 | config = { 3 | boot = { 4 | loader = { 5 | efi.efiSysMountPoint = mkDefault "/mnt/esp"; 6 | grub = mkIf (!config.boot.loader.grub.efiSupport) { 7 | device = mkDefault "/dev/sda"; 8 | }; 9 | }; 10 | }; 11 | 12 | fileSystems = { 13 | "/" = { 14 | device = mkDefault "/dev/disk/by-label/cloudimg-rootfs"; 15 | fsType = mkDefault "ext4"; 16 | }; 17 | "/mnt/esp" = mkIf config.boot.loader.grub.efiSupport { 18 | device = mkDefault "/dev/disk/by-label/UEFI"; 19 | fsType = mkDefault "vfat"; 20 | }; 21 | }; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /modules/nixos/vm.nix: -------------------------------------------------------------------------------- 1 | { pkgs, config, lib, modulesPath, ... }: with lib; { 2 | imports = [ 3 | (modulesPath + "/profiles/qemu-guest.nix") 4 | ./headless.nix 5 | ]; 6 | 7 | config = { 8 | boot = { 9 | kernelParams = mkMerge [ 10 | [ 11 | "panic=30" "boot.panic_on_fail" 12 | ] 13 | (mkIf pkgs.hostPlatform.isx86 [ 14 | "console=ttyS0" 15 | ]) 16 | (mkIf pkgs.hostPlatform.isAarch64 [ 17 | "console=ttyAMA0" 18 | ]) 19 | ]; 20 | growPartition = mkDefault true; 21 | loader.grub = mkIf config.boot.loader.grub.efiSupport { 22 | efiInstallAsRemovable = mkDefault true; 23 | device = mkDefault "nodev"; 24 | }; 25 | }; 26 | 27 | networking = { 28 | hostName = mkOverride 1250 ""; 29 | }; 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /modules/run.nix: -------------------------------------------------------------------------------- 1 | { config, pkgs, lib, ... }: with lib; let 2 | runlib = import ../lib/run.nix { inherit lib; }; 3 | nixRunWrapper = cfg.pkgs.callPackage runlib.nixRunWrapper { }; 4 | nix-run = runlib.nix-run { }; 5 | cfg = config.runners; 6 | runType = types.submodule ({ config, name, ... }: { 7 | options = { 8 | name = mkOption { 9 | type = types.str; 10 | default = name; 11 | }; 12 | executable = mkOption { 13 | type = types.str; 14 | default = name; 15 | }; 16 | command = mkOption { 17 | type = types.nullOr types.lines; 18 | default = null; 19 | }; 20 | package = mkOption { 21 | type = types.package; 22 | }; 23 | runner = mkOption { 24 | type = types.package; 25 | }; 26 | set = mkOption { 27 | type = types.unspecified; 28 | readOnly = true; 29 | }; 30 | }; 31 | config = { 32 | runner = mkOptionDefault (nixRunWrapper config.executable config.package); 33 | package = mkOptionDefault (cfg.pkgs.writeShellScriptBin config.executable config.command); 34 | set = { 35 | inherit (config) package executable name; 36 | }; 37 | }; 38 | }); 39 | lazyRunType = types.submodule ({ config, name, ... }: { 40 | options = { 41 | name = mkOption { 42 | type = types.str; 43 | default = name; 44 | }; 45 | scriptHeader = mkOption { 46 | type = types.nullOr types.lines; 47 | default = null; 48 | }; 49 | executable = mkOption { 50 | type = types.str; 51 | default = name; 52 | }; 53 | file = mkOption { 54 | type = types.nullOr types.path; 55 | default = null; 56 | }; 57 | attr = mkOption { 58 | type = types.str; 59 | }; 60 | args = mkOption { 61 | type = types.listOf types.str; 62 | }; 63 | set = mkOption { 64 | type = types.unspecified; 65 | readOnly = true; 66 | }; 67 | out = { 68 | runArgs = mkOption { 69 | type = types.listOf types.str; 70 | readOnly = true; 71 | }; 72 | }; 73 | }; 74 | config = { 75 | args = mkIf (config.file != null) [ "-f" (toString config.file) ]; 76 | out = { 77 | runArgs = singleton nix-run ++ config.args 78 | ++ [ config.attr "-c" config.executable ]; 79 | }; 80 | set = { 81 | inherit (config) attr file executable name; 82 | }; 83 | }; 84 | }); 85 | in { 86 | options = { 87 | runners = { 88 | pkgs = mkOption { 89 | type = types.unspecified; 90 | default = pkgs.buildPackages; 91 | defaultText = "pkgs.buildPackages"; 92 | }; 93 | lazy = { 94 | file = mkOption { 95 | type = types.nullOr types.path; 96 | default = null; 97 | }; 98 | attrPrefix = mkOption { 99 | type = types.str; 100 | default = "runners.run."; 101 | }; 102 | args = mkOption { 103 | type = types.listOf types.str; 104 | default = [ ]; 105 | }; 106 | run = mkOption { 107 | type = types.attrsOf lazyRunType; 108 | }; 109 | nativeBuildInputs = mkOption { 110 | type = types.listOf types.package; 111 | readOnly = true; 112 | }; 113 | }; 114 | run = mkOption { 115 | type = types.attrsOf runType; 116 | default = { }; 117 | }; 118 | }; 119 | run = mkOption { 120 | type = types.attrsOf types.unspecified; 121 | }; 122 | }; 123 | 124 | config = { 125 | runners.lazy = { 126 | run = mapAttrs' (k: run: nameValuePair k (mapAttrs (_: mkDefault) { 127 | attr = "${cfg.lazy.attrPrefix}${k}.package"; 128 | inherit (cfg.lazy) file; 129 | inherit (run) executable name; 130 | } // { 131 | inherit (cfg.lazy) args; 132 | })) cfg.run; 133 | nativeBuildInputs = mapAttrsToList (k: v: let 134 | exec = '' 135 | exec ${escapeShellArgs v.out.runArgs} "$@" 136 | ''; 137 | cmd = optionalString (v.scriptHeader != null) "${v.scriptHeader}" 138 | + exec; 139 | in cfg.pkgs.writeShellScriptBin v.name cmd) cfg.lazy.run; 140 | }; 141 | run = mapAttrs (_: r: r.runner) cfg.run; 142 | }; 143 | } 144 | -------------------------------------------------------------------------------- /modules/secrets.nix: -------------------------------------------------------------------------------- 1 | isNixos: { pkgs, config, lib, ... }: with lib; let 2 | cfg = config.secrets; 3 | activationScript = concatStringsSep "\n" (concatLists (mapAttrsToList (_: f: let 4 | source = if f.source != null 5 | then f.source 6 | else builtins.toFile f.fileName f.text; 7 | external = '' 8 | if [[ ! -e ${toString f.path} ]]; then 9 | echo "WARN: secret at ${toString f.path} does not exist" >&2 10 | else 11 | ${pkgs.coreutils}/bin/chown ${f.owner}:${f.group} ${toString f.path} 12 | fi 13 | ''; 14 | embedded = '' 15 | ${pkgs.coreutils}/bin/install -m${f.mode} -o ${f.owner} -g ${f.group} ${source} ${toString f.path} 16 | ''; 17 | in [ 18 | "${pkgs.coreutils}/bin/install -dm0755 -o ${f.out.rootOwner} -g ${f.out.rootGroup} ${toString f.out.root}" 19 | "${pkgs.coreutils}/bin/install -dm7755 -o ${f.out.rootOwner} -g ${f.out.rootGroup} ${toString f.out.dir}" 20 | (if f.external then external else embedded) 21 | ]) config.secrets.files)); 22 | fileType = types.submodule ({ name, config, ... }: { 23 | options = { 24 | text = mkOption { 25 | type = types.nullOr types.str; 26 | default = null; 27 | }; 28 | source = mkOption { 29 | type = types.nullOr types.path; 30 | default = null; 31 | }; 32 | sha256 = mkOption { 33 | type = types.str; 34 | }; 35 | persistent = mkOption { 36 | type = types.bool; 37 | default = cfg.persistent; 38 | }; 39 | external = mkOption { 40 | type = types.bool; 41 | default = cfg.external; 42 | }; 43 | owner = mkOption { 44 | type = types.str; 45 | default = cfg.owner; 46 | }; 47 | group = mkOption { 48 | type = types.str; 49 | default = cfg.group; 50 | }; 51 | mode = mkOption { 52 | type = types.str; 53 | default = "0400"; 54 | }; 55 | fileName = mkOption { 56 | type = types.str; 57 | default = name; 58 | }; 59 | 60 | path = mkOption { 61 | type = types.path; 62 | internal = true; 63 | }; 64 | out = { 65 | root = mkOption { 66 | type = types.path; 67 | internal = true; 68 | }; 69 | rootOwner = mkOption { 70 | type = types.str; 71 | internal = true; 72 | }; 73 | rootGroup = mkOption { 74 | type = types.str; 75 | internal = true; 76 | }; 77 | dir = mkOption { 78 | type = types.path; 79 | internal = true; 80 | }; 81 | checkHash = mkOption { 82 | type = types.bool; 83 | internal = true; 84 | }; 85 | set = mkOption { 86 | type = types.unspecified; 87 | internal = true; 88 | }; 89 | }; 90 | }; 91 | 92 | config = let 93 | textHash = builtins.hashString "sha256" config.text; 94 | fileHash = builtins.hashFile "sha256" config.source; 95 | in { 96 | sha256 = mkMerge [ 97 | (mkIf (config.text != null) 98 | (mkDefault textHash)) 99 | (mkIf (config.text == null && builtins ? hashFile) 100 | (mkDefault fileHash)) 101 | ]; 102 | path = mkOptionDefault (toString (config.out.dir + "/${config.fileName}")); 103 | out = { 104 | root = mkOptionDefault (if config.persistent then cfg.persistentRoot else cfg.root); 105 | rootOwner = mkOptionDefault cfg.owner; 106 | rootGroup = mkOptionDefault cfg.group; 107 | dir = mkOptionDefault (config.out.root + "/${builtins.unsafeDiscardStringContext config.sha256}"); 108 | checkHash = # TODO: add this to assertions 109 | if config.source != null && builtins.pathExists config.source then config.sha256 == fileHash 110 | else if config.text != null then config.sha256 == textHash 111 | else true; # TODO: null instead? 112 | set = { 113 | inherit (config) text source sha256 persistent external owner group mode fileName path; 114 | out = { 115 | inherit (config.out) root dir rootOwner rootGroup; 116 | }; 117 | }; 118 | }; 119 | }; 120 | }); 121 | in { 122 | options.secrets = { 123 | enable = mkOption { 124 | type = types.bool; 125 | default = cfg.files != { }; 126 | }; 127 | owner = mkOption { 128 | type = types.str; 129 | default = if isNixos then "root" else config.home.username; 130 | }; 131 | group = mkOption { 132 | type = types.nullOr types.str; 133 | default = if isNixos then "keys" else "users"; 134 | }; 135 | files = mkOption { 136 | type = types.loaOf fileType; 137 | default = { }; 138 | }; 139 | root = mkOption { 140 | type = types.path; 141 | default = if isNixos 142 | then /var/run/arc/secrets 143 | else /dev/shm + "/arc-${config.home.username}/secrets"; 144 | }; 145 | persistentRoot = mkOption { 146 | type = types.path; 147 | default = if isNixos 148 | then /var/lib/arc/secrets 149 | else config.xdg.cacheHome + "/arc/secrets"; 150 | }; 151 | persistent = mkOption { 152 | type = types.bool; 153 | default = true; 154 | }; 155 | external = mkOption { 156 | type = types.bool; 157 | default = false; 158 | }; 159 | }; 160 | 161 | config = mkIf cfg.enable (if isNixos then { 162 | system.activationScripts.arc_secrets = { 163 | text = activationScript; 164 | deps = [ "etc" ]; # must be done after passwd/etc are ready 165 | }; 166 | 167 | users.groups.${cfg.group}.members = [ ]; 168 | } else { 169 | home.activation.arc_secrets = config.lib.dag.entryAfter ["writeBoundary"] activationScript; 170 | }); 171 | } 172 | -------------------------------------------------------------------------------- /modules/state.nix: -------------------------------------------------------------------------------- 1 | { config, lib, ... }: with lib; let 2 | cfg = config.state; 3 | v4 = state: let 4 | mapInstance = ins: let 5 | prefix = optionalString (ins.mode == "data") "data."; 6 | key = "${prefix}${ins.type}.${ins.name}"; 7 | singular = nameValuePair key (head ins.instances).attributes; 8 | in map (ins: nameValuePair "${key}${optionalString (ins ? index_key) "[${ins.index_key}]"}" ins.attributes) ins.instances 9 | /*++ [ singular ]*/; 10 | filter = filterAttrs (k: _: cfg.filteredReferences == null || elem k cfg.filteredReferences); 11 | in { 12 | outputs = filter (mapAttrs' (k: v: nameValuePair "output.${k}" v.value) state.outputs); 13 | resources = filter (listToAttrs (concatMap mapInstance state.resources)); 14 | }; 15 | in { 16 | options.state = { 17 | enable = mkEnableOption "tfstate" // { 18 | default = builtins.pathExists cfg.file; 19 | }; 20 | file = mkOption { 21 | type = types.nullOr types.path; 22 | default = null; 23 | }; 24 | filteredReferences = mkOption { 25 | type = types.nullOr (types.listOf types.str); 26 | default = null; 27 | }; 28 | }; 29 | 30 | config.state = let 31 | state = builtins.fromJSON (builtins.readFile cfg.file); 32 | out = 33 | if state.version == 4 then v4 state 34 | else throw "unsupported tfstate version ${state.version}"; 35 | in mkIf cfg.enable { 36 | inherit (out) outputs resources; 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /modules/terraform.nix: -------------------------------------------------------------------------------- 1 | { pkgs, config, lib, ... }: with lib; let 2 | tconfig = config; 3 | # TODO: filter out all empty/unnecessary/default keys and objects 4 | inherit (config.lib) tf; 5 | tfTypes = { 6 | inherit pathType providerReferenceType providerType resourceType outputType moduleType variableType 7 | connectionType provisionerType; 8 | }; 9 | pathType = types.str; # types.path except that ${} expressions work too (and also sometimes relative paths?) 10 | providerReferenceType' = types.submodule ({ config, ... }: let 11 | split = splitString "." config.reference; 12 | in { 13 | options = { 14 | # TODO: support just "alias" since "config.providers" is an attrset so they must be unique anyway? 15 | type = mkOption { 16 | type = types.str; 17 | default = head split; 18 | }; 19 | alias = mkOption { 20 | type = types.nullOr types.str; 21 | default = if tail split == [ ] then null else elemAt split 1; 22 | }; 23 | reference = mkOption { 24 | type = types.nullOr types.str; 25 | default = "${config.type}${optionalString (config.alias != null) ".${config.alias}"}"; 26 | }; 27 | isDefault = mkOption { 28 | type = types.bool; 29 | default = config.alias == null; 30 | readOnly = true; 31 | }; 32 | out = { 33 | name = mkOption { 34 | type = types.str; 35 | readOnly = true; 36 | }; 37 | provider = mkOption { 38 | type = types.unspecified; 39 | readOnly = true; 40 | }; 41 | }; 42 | ref = mkOption { 43 | type = types.str; 44 | readOnly = true; 45 | }; 46 | set = mkOption { 47 | type = types.attrsOf types.unspecified; 48 | readOnly = true; 49 | }; 50 | }; 51 | config = { 52 | out = { 53 | name = if config.alias != null then config.alias else config.type; 54 | provider = let 55 | default = if !config.isDefault 56 | then throw "provider ${config.reference} not found" 57 | else null; 58 | in findFirst (p: p.out.reference == config.reference) default (attrValues tconfig.providers); 59 | }; 60 | ref = 61 | optionalString (config.out.provider != null) (tf.terraformContext false config.out.provider.out.hclPathStr null) 62 | + config.reference; 63 | set = { 64 | inherit (config) type alias reference; 65 | }; 66 | }; 67 | }); 68 | providerReferenceType = types.coercedTo types.str (reference: { inherit reference; }) providerReferenceType'; 69 | resourceType = types.submodule ({ config, name, ... }: { 70 | options = { 71 | name = mkOption { 72 | type = types.str; 73 | default = tf.terraformIdent name; 74 | }; 75 | enable = mkOption { 76 | type = types.bool; 77 | default = true; 78 | }; 79 | dataSource = mkOption { 80 | type = types.bool; 81 | default = false; 82 | }; 83 | provider = mkOption { 84 | type = providerReferenceType; 85 | example = "aws.alias"; 86 | # TODO: support just "alias" since "config.providers" is an attrset so they must be unique anyway? 87 | }; 88 | type = mkOption { 89 | type = types.str; 90 | example = "instance"; 91 | }; 92 | inputs = mkOption { 93 | type = types.attrsOf types.unspecified; 94 | default = { }; 95 | example = { 96 | instance_type = "t2.micro"; 97 | }; 98 | description = '' 99 | The "default" alias will be used as a fallback if no alias is provided. 100 | ''; 101 | }; 102 | dependsOn = mkOption { 103 | type = types.listOf types.str; 104 | default = [ ]; 105 | }; 106 | count = mkOption { 107 | type = types.int; 108 | default = 1; 109 | }; 110 | # TODO: for_each 111 | lifecycle = { 112 | createBeforeDestroy = mkOption { 113 | type = types.bool; 114 | default = false; 115 | }; 116 | ignoreChanges = mkOption { 117 | type = types.either (types.enum [ "all" ]) (types.listOf types.str); 118 | default = [ ]; 119 | }; 120 | preventDestroy = mkOption { 121 | type = types.bool; 122 | default = false; 123 | }; 124 | replaceTriggeredBy = mkOption { 125 | type = types.listOf types.str; 126 | default = [ ]; 127 | }; 128 | }; 129 | connection = mkOption { 130 | type = types.nullOr (connectionType config); 131 | default = null; 132 | }; 133 | provisioners = mkOption { 134 | type = types.listOf provisionerType; 135 | default = [ ]; 136 | }; 137 | timeouts = mkOption { 138 | type = timeoutsType; 139 | default = { }; 140 | }; 141 | hcl = mkOption { 142 | type = types.attrsOf types.unspecified; 143 | readOnly = true; 144 | }; 145 | out = { 146 | resourceKey = mkOption { 147 | type = types.str; 148 | internal = true; 149 | }; 150 | dataType = mkOption { 151 | type = types.str; 152 | internal = true; 153 | }; 154 | reference = mkOption { 155 | type = types.str; 156 | internal = true; 157 | }; 158 | hclPath = mkOption { 159 | type = types.listOf types.str; 160 | internal = true; 161 | }; 162 | hclPathStr = mkOption { 163 | type = types.str; 164 | internal = true; 165 | }; 166 | }; 167 | importAttr = mkOption { 168 | type = types.unspecified; 169 | }; 170 | refAttr = mkOption { 171 | type = types.unspecified; 172 | internal = true; 173 | }; 174 | getAttr = mkOption { 175 | type = types.unspecified; 176 | }; 177 | namedRef = mkOption { 178 | type = types.unspecified; 179 | internal = true; 180 | }; 181 | }; 182 | 183 | config = { 184 | out = { 185 | resourceKey = config.provider.type + optionalString (config.type != "") "_${config.type}"; 186 | dataType = if config.dataSource then "data" else "resource"; 187 | reference = optionalString config.dataSource "data." + config.out.resourceKey + ".${config.name}"; 188 | hclPath = [ config.out.dataType config.out.resourceKey config.name ]; 189 | hclPathStr = concatStringsSep "." config.out.hclPath; 190 | }; 191 | refAttr = attr: tf.terraformContext false config.out.hclPathStr attr 192 | + tf.terraformExpr "${config.out.reference}${optionalString (attr != null) ".${attr}"}"; 193 | hcl = config.inputs // optionalAttrs (config.count != 1) { 194 | inherit (config) count; 195 | } // optionalAttrs (config.provisioners != [ ]) { 196 | provisioner = map (p: p.hcl) config.provisioners; 197 | } // optionalAttrs (config.dependsOn != [ ]) { 198 | depends_on = config.dependsOn; 199 | } // optionalAttrs (config.connection != null) { 200 | connection = config.connection.hcl; 201 | } // optionalAttrs (config.timeouts.hcl != { }) { 202 | timeouts = config.timeouts.hcl; 203 | } // optionalAttrs (!config.provider.isDefault || config.provider.out.provider != null) { 204 | provider = config.provider.ref; 205 | } // optionalAttrs (config.lifecycle.createBeforeDestroy || config.lifecycle.preventDestroy || config.lifecycle.ignoreChanges != [ ]) { 206 | lifecycle = optionalAttrs (config.lifecycle.createBeforeDestroy) { 207 | create_before_destroy = true; 208 | } // optionalAttrs (config.lifecycle.preventDestroy) { 209 | prevent_destroy = true; 210 | } // optionalAttrs (config.lifecycle.ignoreChanges != [ ]) { 211 | ignore_changes = config.lifecycle.ignoreChanges; 212 | } // optionalAttrs (config.lifecycle.replaceTriggeredBy != [ ]) { 213 | replace_triggered_by = config.lifecycle.replaceTriggeredBy; 214 | }; 215 | }; 216 | getAttr = mkOptionDefault (attr: let 217 | ctx = tf.terraformContext exists config.out.hclPathStr attr; 218 | exists = tconfig.state.resources ? ${config.out.reference}; 219 | attrPath = splitString "." attr; 220 | fallback = throw "${attr} on ${config.out.reference} not found"; 221 | in (ctx + optionalString exists (attrByPath attrPath fallback tconfig.state.resources.${config.out.reference}))); 222 | importAttr = mkOptionDefault (attr: let 223 | ctx = tf.terraformContext exists config.out.hclPathStr attr; 224 | exists = tconfig.state.resources ? ${config.out.reference}; 225 | attrPath = splitString "." attr; 226 | fallback = throw "${attr} on imported resource ${config.out.reference} not found"; 227 | in if exists then attrByPath attrPath fallback tconfig.state.resources.${config.out.reference} else throw "imported resource ${config.out.reference} not found"); 228 | namedRef = tf.terraformContext false config.out.hclPathStr null 229 | + config.out.reference; 230 | }; 231 | }); 232 | provisionerType = types.submodule ({ config, ... }: { 233 | options = { 234 | type = mkOption { 235 | type = types.str; 236 | example = "local-exec"; 237 | }; 238 | when = mkOption { 239 | type = types.enum [ "create" "destroy" ]; 240 | default = "create"; 241 | }; 242 | onFailure = mkOption { 243 | type = types.enum [ "continue" "fail" ]; 244 | default = "fail"; 245 | }; 246 | inputs = mkOption { 247 | type = types.attrsOf types.unspecified; 248 | example = { 249 | command = "echo The server's IP address is \${self.private_ip}"; 250 | }; 251 | }; 252 | hcl = mkOption { 253 | type = types.attrsOf types.unspecified; 254 | readOnly = true; 255 | }; 256 | 257 | # built-in provisioners 258 | local-exec = mkOption { 259 | type = types.nullOr (types.submodule ({ config, ... }: { 260 | options = { 261 | command = mkOption { 262 | type = types.lines; 263 | }; 264 | working_dir = mkOption { 265 | type = types.nullOr types.path; 266 | default = null; 267 | }; 268 | environment = mkOption { 269 | type = types.attrsOf types.str; 270 | default = { }; 271 | }; 272 | interpreter = mkOption { 273 | type = types.listOf types.str; 274 | default = [ ]; 275 | }; 276 | hcl = mkOption { 277 | type = types.attrsOf types.unspecified; 278 | readOnly = true; 279 | }; 280 | }; 281 | 282 | config.hcl = { 283 | inherit (config) command; 284 | } // optionalAttrs (config.working_dir != null) { 285 | inherit (config) working_dir; 286 | } // optionalAttrs (config.environment != { }) { 287 | inherit (config) environment; 288 | } // optionalAttrs (config.interpreter != [ ]) { 289 | inherit (config) interpreter; 290 | }; 291 | })); 292 | default = null; 293 | }; 294 | remote-exec = mkOption { 295 | type = types.nullOr (types.submodule ({ config, ... }: { 296 | options = { 297 | inline = mkOption { 298 | type = types.nullOr (types.listOf types.str); 299 | default = null; 300 | }; 301 | scripts = mkOption { 302 | type = types.listOf pathType; 303 | default = [ ]; 304 | }; 305 | command = mkOption { 306 | type = types.lines; 307 | default = ""; 308 | description = "Alias for inline"; 309 | }; 310 | hcl = mkOption { 311 | type = types.attrsOf types.unspecified; 312 | readOnly = true; 313 | }; 314 | }; 315 | 316 | config = { 317 | inline = mkIf (config.command != "") [ config.command ]; 318 | 319 | hcl = optionalAttrs (config.inline != null) { 320 | inline = assert config.scripts == [ ]; config.inline; 321 | } // optionalAttrs (length config.scripts == 1) { 322 | script = assert config.inline == null; head config.scripts; 323 | } // optionalAttrs (length config.scripts > 1) { 324 | scripts = assert config.inline == null; config.scripts; 325 | }; 326 | }; 327 | })); 328 | default = null; 329 | }; 330 | file = mkOption { 331 | type = types.nullOr (types.submodule ({ config, ... }: { 332 | options = { 333 | destination = mkOption { 334 | type = pathType; 335 | }; 336 | source = mkOption { 337 | type = types.nullOr pathType; 338 | default = null; 339 | }; 340 | content = mkOption { 341 | type = types.nullOr types.str; 342 | default = null; 343 | }; 344 | hcl = mkOption { 345 | type = types.attrsOf types.unspecified; 346 | readOnly = true; 347 | }; 348 | }; 349 | config = { 350 | hcl = { 351 | inherit (config) destination; 352 | } // optionalAttrs (config.source != null) { 353 | source = assert config.content == null; config.source; 354 | } // optionalAttrs (config.content != null) { 355 | content = assert config.source == null; config.content; 356 | }; 357 | }; 358 | })); 359 | default = null; 360 | }; 361 | # TODO: chef, habitat, puppet, salt-masterless (https://www.terraform.io/docs/provisioners/) 362 | }; 363 | 364 | config = let 365 | attrs' = filterAttrs (_: v: v != null) { 366 | inherit (config) local-exec remote-exec file; 367 | }; 368 | attrs = mapAttrsToList nameValuePair attrs'; 369 | attr = head attrs; 370 | in { 371 | type = assert length attrs <= 1; mkIf (attrs != [ ]) attr.name; 372 | inputs = mkIf (attrs != [ ]) attr.value.hcl; 373 | hcl = { 374 | ${config.type} = optionalAttrs (config.when != "create") { 375 | inherit (config) when; 376 | } // optionalAttrs (config.onFailure != "fail") { 377 | on_failure = config.onFailure; 378 | } // config.inputs; 379 | }; 380 | }; 381 | }); 382 | connectionType = self: types.submodule ({ config, ... }: { 383 | options = { 384 | hcl = mkOption { 385 | type = types.attrsOf types.unspecified; 386 | readOnly = true; 387 | }; 388 | nixStoreUrl = mkOption { 389 | type = types.str; 390 | readOnly = true; 391 | }; 392 | type = mkOption { 393 | type = types.enum [ "ssh" "winrm" ]; 394 | default = "ssh"; 395 | }; 396 | user = mkOption { 397 | type = types.nullOr types.str; 398 | default = null; 399 | }; 400 | timeout = mkOption { 401 | type = types.nullOr timeoutType; 402 | default = null; 403 | }; 404 | scriptPath = mkOption { 405 | type = types.nullOr pathType; 406 | default = null; 407 | }; 408 | host = mkOption { 409 | type = types.str; 410 | }; 411 | port = mkOption { 412 | type = types.nullOr types.port; 413 | default = null; 414 | }; 415 | # ssh options 416 | ssh = { 417 | privateKey = mkOption { 418 | type = types.nullOr types.str; 419 | default = null; 420 | }; 421 | privateKeyFile = mkOption { 422 | type = types.nullOr types.str; 423 | default = null; 424 | # NOTE: this is not directly used by hcl 425 | }; 426 | hostKey = mkOption { 427 | type = types.nullOr types.str; 428 | default = null; 429 | }; 430 | certificate = mkOption { 431 | type = types.nullOr types.str; 432 | default = null; 433 | }; 434 | agent = { 435 | enabled = mkOption { 436 | type = types.bool; 437 | default = config.ssh.privateKey == null; 438 | }; 439 | identity = mkOption { 440 | type = types.nullOr types.str; 441 | default = null; 442 | }; 443 | }; 444 | bastion = { 445 | host = mkOption { 446 | type = types.nullOr types.str; 447 | default = null; 448 | }; 449 | hostKey = mkOption { 450 | type = types.nullOr types.str; 451 | default = null; 452 | }; 453 | port = mkOption { 454 | type = types.nullOr types.port; 455 | default = null; 456 | }; 457 | user = mkOption { 458 | type = types.nullOr types.str; 459 | default = null; 460 | }; 461 | password = mkOption { 462 | type = types.nullOr types.str; 463 | default = null; 464 | }; 465 | privateKey = mkOption { 466 | type = types.nullOr types.str; 467 | default = null; 468 | }; 469 | certificate = mkOption { 470 | type = types.nullOr types.str; 471 | default = null; 472 | }; 473 | }; 474 | cli = { 475 | extraOpts = mkOption { 476 | type = types.attrsOf types.str; 477 | }; 478 | extraArgs = mkOption { 479 | type = types.listOf types.str; 480 | }; 481 | }; 482 | }; 483 | winrm = { 484 | https = mkOption { 485 | type = types.bool; 486 | default = false; 487 | }; 488 | insecure = mkOption { 489 | type = types.bool; 490 | default = false; 491 | }; 492 | useNtlm = mkOption { 493 | type = types.bool; 494 | default = false; 495 | }; 496 | cacert = mkOption { 497 | type = types.nullOr types.str; 498 | default = null; 499 | }; 500 | }; 501 | set = mkOption { 502 | type = types.attrsOf types.unspecified; 503 | readOnly = true; 504 | }; 505 | out = { 506 | ssh = { 507 | opts = mkOption { 508 | type = types.attrsOf types.str; 509 | readOnly = true; 510 | }; 511 | nixStoreOpts = mkOption { 512 | type = types.str; 513 | readOnly = true; 514 | description = "NIX_SSHOPTS"; 515 | }; 516 | cliArgs = mkOption { 517 | type = types.listOf types.str; 518 | readOnly = true; 519 | }; 520 | destination = mkOption { 521 | type = types.str; 522 | readOnly = true; 523 | }; 524 | }; 525 | }; 526 | }; 527 | 528 | config = { 529 | hcl = filterAttrs (_: v: v != null) { 530 | inherit (config) type user timeout host port; 531 | script_path = config.scriptPath; 532 | private_key = config.ssh.privateKey; 533 | host_key = config.ssh.hostKey; 534 | inherit (config.ssh) certificate; 535 | agent = config.ssh.agent.enabled; 536 | agent_identity = config.ssh.agent.identity; 537 | 538 | bastion_host = config.ssh.bastion.host; 539 | bastion_host_key = config.ssh.bastion.hostKey; 540 | bastion_port = config.ssh.bastion.port; 541 | bastion_user = config.ssh.bastion.user; 542 | bastion_password = config.ssh.bastion.password; 543 | bastion_private_key = config.ssh.bastion.privateKey; 544 | bastion_certificate = config.ssh.bastion.certificate; 545 | 546 | inherit (config.winrm) https insecure cacert; 547 | use_ntlm = config.winrm.useNtlm; 548 | }; 549 | set = let 550 | attrs = { 551 | inherit (config) ssh winrm type user timeout scriptPath host port; 552 | }; 553 | attrs' = filterAttrs (_: v: v != null) attrs; 554 | selfRef = tf.terraformContext false self.out.hclPathStr null + "\${${self.out.reference}."; 555 | selfPrefix = "\${self."; 556 | mapSelf = v: if isString v && hasInfix selfPrefix v then replaceStrings [ selfPrefix ] [ selfRef ] v else v; 557 | in mapAttrsRecursive (_: mapSelf) attrs'; 558 | nixStoreUrl = tf.genUrl { 559 | protocol = "ssh"; 560 | host = config.out.ssh.destination; 561 | #inherit (config) port; # https://github.com/NixOS/nix/issues/1994 was never fixed, nix doesn't parse ssh urls with ports 562 | query = optionalAttrs (config.ssh.privateKeyFile != null) { 563 | ssh-key = config.ssh.privateKeyFile; 564 | }; 565 | }; 566 | ssh.cli = { 567 | extraOpts = { 568 | UpdateHostKeys = "no"; 569 | StrictHostKeyChecking = "off"; 570 | UserKnownHostsFile = "/dev/null"; 571 | IdentitiesOnly = mkIf (!config.ssh.agent.enabled) "yes"; 572 | }; 573 | extraArgs = [ 574 | "-q" "-F" "none" 575 | ] ++ concatLists (mapAttrsToList (key: value: [ "-o" "${key}=${value}" ]) config.ssh.cli.extraOpts); 576 | }; 577 | out.ssh = let 578 | bastionDestination = 579 | optionalString (config.ssh.bastion.user != null) "${config.ssh.bastion.user}@" 580 | + config.ssh.bastion.host 581 | + optionalString (config.ssh.bastion.port != null) ":${toString config.ssh.bastion.port}"; 582 | in { 583 | nixStoreOpts = concatStringsSep " " config.out.ssh.cliArgs; 584 | opts = config.ssh.cli.extraOpts 585 | // { 586 | User = if config.user == null then "root" else config.user; 587 | # TODO: cert and hostkey 588 | } // optionalAttrs (config.ssh.bastion.host != null) { 589 | ProxyJump = bastionDestination; 590 | } // optionalAttrs (config.port != null) { 591 | Port = toString config.port; 592 | } // optionalAttrs (config.ssh.privateKeyFile != null) { 593 | IdentityFile = config.ssh.privateKeyFile; 594 | IdentitiesOnly = "yes"; 595 | }; 596 | cliArgs = config.ssh.cli.extraArgs 597 | ++ optionals (config.ssh.bastion.host != null) [ "-J" bastionDestination ] 598 | ++ optionals (config.port != null) [ "-p" (toString config.port) ] 599 | ++ optionals (config.ssh.privateKeyFile != null) [ 600 | "-i" config.ssh.privateKeyFile 601 | "-o" "IdentitiesOnly=yes" 602 | ]; 603 | destination = "${if config.user == null then "root" else config.user}@${config.host}"; 604 | }; 605 | }; 606 | }); 607 | providerType = types.submodule ({ name, config, ... }: { 608 | options = { 609 | type = mkOption { 610 | type = types.str; 611 | default = name; 612 | }; 613 | alias = mkOption { 614 | type = types.nullOr types.str; 615 | default = if name == config.type then null else name; 616 | }; 617 | inputs = mkOption { 618 | type = types.attrsOf types.unspecified; 619 | default = { }; 620 | }; 621 | source = mkOption { 622 | type = types.nullOr types.str; 623 | default = null; 624 | }; 625 | version = mkOption { 626 | type = types.nullOr types.str; 627 | default = null; 628 | }; 629 | hcl = mkOption { 630 | type = types.attrsOf types.unspecified; 631 | readOnly = true; 632 | }; 633 | out = { 634 | reference = mkOption { 635 | type = types.str; 636 | readOnly = true; 637 | }; 638 | hclPath = mkOption { 639 | type = types.listOf types.str; 640 | internal = true; 641 | }; 642 | hclPathStr = mkOption { 643 | type = types.str; 644 | internal = true; 645 | }; 646 | }; 647 | }; 648 | 649 | config = { 650 | hcl = config.inputs // optionalAttrs (config.alias != null) { 651 | inherit (config) alias; 652 | }; 653 | out = { 654 | reference = "${config.type}${optionalString (config.alias != null) ".${config.alias}"}"; 655 | hclPath = [ "provider" config.type ] ++ optional (config.alias != null) config.alias; 656 | hclPathStr = concatStringsSep "." config.out.hclPath; 657 | }; 658 | }; 659 | }); 660 | requiredProviderType = types.submodule ({ name, config, ... }: { 661 | options = { 662 | type = mkOption { 663 | type = types.str; 664 | default = name; 665 | readOnly = true; 666 | }; 667 | source = mkOption { 668 | type = types.nullOr types.str; 669 | default = let 670 | plugin = tconfig.terraform.packageUnwrapped.plugins.${config.type} or null; 671 | in if versionAtLeast tconfig.terraform.version "0.13" && plugin ? provider-source-address 672 | then plugin.provider-source-address 673 | # hack around https://github.com/NixOS/nixpkgs/pull/203000 changes 674 | else if plugin.homepage or "" != "" then replaceStrings [ "https://registry" ".io/providers" ] [ "registry" ".io" ] plugin.homepage 675 | else "nixpkgs/${config.type}"; 676 | }; 677 | version = mkOption { 678 | type = types.nullOr types.str; 679 | default = 680 | if versionAtLeast tconfig.terraform.version "0.13" && tconfig.terraform.packageUnwrapped ? plugins.${config.type}.version 681 | then tconfig.terraform.packageUnwrapped.plugins.${config.type}.version 682 | else null; 683 | }; 684 | hcl = mkOption { 685 | type = types.attrsOf types.unspecified; 686 | readOnly = true; 687 | }; 688 | }; 689 | config = { 690 | hcl = { 691 | source = mkIf (config.source != null) config.source; 692 | version = mkIf (config.version != null) config.version; 693 | }; 694 | }; 695 | }); 696 | variableValidationType = types.submodule ({ config, ... }: { 697 | options = { 698 | condition = mkOption { 699 | type = types.str; 700 | }; 701 | errorMessage = mkOption { 702 | type = types.str; 703 | }; 704 | hcl = mkOption { 705 | type = types.attrsOf types.unspecified; 706 | readOnly = true; 707 | }; 708 | }; 709 | config = { 710 | hcl = { 711 | inherit (config) condition; 712 | error_message = config.errorMessage; 713 | }; 714 | }; 715 | }); 716 | variableType = types.submodule ({ name, config, ... }: { 717 | options = { 718 | type = mkOption { 719 | type = types.nullOr types.str; 720 | default = null; 721 | }; 722 | default = mkOption { 723 | type = types.nullOr types.unspecified; 724 | default = null; 725 | }; 726 | name = mkOption { 727 | type = types.str; 728 | default = name; 729 | }; 730 | validation = mkOption { 731 | # new in 0.13 732 | type = types.nullOr variableValidationType; 733 | default = null; 734 | }; 735 | sensitive = mkOption { 736 | # new in 0.14? 737 | type = types.bool; 738 | default = false; 739 | }; 740 | export = mkOption { 741 | type = types.bool; 742 | default = false; 743 | }; 744 | value = { 745 | shellCommand = mkOption { 746 | type = types.nullOr types.str; 747 | default = null; 748 | }; 749 | }; 750 | hcl = mkOption { 751 | type = types.attrsOf types.unspecified; 752 | readOnly = true; 753 | }; 754 | out = { 755 | reference = mkOption { 756 | type = types.str; 757 | readOnly = true; 758 | }; 759 | hclPath = mkOption { 760 | type = types.listOf types.str; 761 | internal = true; 762 | }; 763 | hclPathStr = mkOption { 764 | type = types.str; 765 | internal = true; 766 | }; 767 | }; 768 | ref = mkOption { 769 | type = types.str; 770 | readOnly = true; 771 | }; 772 | get = mkOption { 773 | type = types.str; 774 | readOnly = true; 775 | }; 776 | }; 777 | 778 | config = { 779 | hcl = filterAttrs (_: v: v != null) { 780 | inherit (config) type default; 781 | validation = mapNullable (v: v.hcl) config.validation; 782 | } // optionalAttrs config.sensitive { 783 | sensitive = true; 784 | }; 785 | out = { 786 | reference = "var.${config.name}"; 787 | hclPath = [ "variable" config.name ]; 788 | hclPathStr = concatStringsSep "." config.out.hclPath; 789 | }; 790 | ref = tf.terraformContext false config.out.hclPathStr null 791 | + tf.terraformExpr config.out.reference; 792 | get = if !config.export 793 | then throw "${config.name} must have `export = true`" 794 | else tconfig.resources.tfnix-exports.getAttr "result.${config.name}"; 795 | }; 796 | }); 797 | outputType = types.submodule ({ name, config, ... }: { 798 | options = { 799 | type = mkOption { 800 | type = types.nullOr types.str; 801 | default = null; 802 | }; 803 | description = mkOption { 804 | type = types.nullOr types.str; 805 | default = null; 806 | }; 807 | sensitive = mkOption { 808 | type = types.bool; 809 | default = false; 810 | }; 811 | dependsOn = mkOption { 812 | type = types.listOf types.str; 813 | default = [ ]; 814 | }; 815 | value = mkOption { 816 | type = types.unspecified; 817 | }; 818 | name = mkOption { 819 | type = types.str; 820 | default = name; 821 | }; 822 | hcl = mkOption { 823 | type = types.attrsOf types.unspecified; 824 | readOnly = true; 825 | }; 826 | out = { 827 | reference = mkOption { 828 | type = types.str; 829 | readOnly = true; 830 | }; 831 | hclPath = mkOption { 832 | type = types.listOf types.str; 833 | internal = true; 834 | }; 835 | hclPathStr = mkOption { 836 | type = types.str; 837 | internal = true; 838 | }; 839 | }; 840 | get = mkOption { 841 | type = types.unspecified; 842 | readOnly = true; 843 | }; 844 | import = mkOption { 845 | type = types.unspecified; 846 | readOnly = true; 847 | }; 848 | }; 849 | 850 | config = { 851 | hcl = { 852 | inherit (config) value; 853 | } // filterAttrs (_: v: v != null) { 854 | inherit (config) type description; 855 | } // optionalAttrs config.sensitive { 856 | sensitive = true; 857 | } // optionalAttrs (config.dependsOn != [ ]) { 858 | depends_on = config.dependsOn; 859 | }; 860 | out = { 861 | reference = "output.${config.name}"; 862 | hclPath = [ "output" config.name ]; 863 | hclPathStr = concatStringsSep "." config.out.hclPath; 864 | }; 865 | get = let 866 | ctx = tf.terraformContext exists config.out.hclPathStr null; 867 | exists = tconfig.state.outputs ? ${config.out.reference}; 868 | in mkOptionDefault (ctx + optionalString exists tconfig.state.outputs.${config.out.reference}); 869 | import = mkOptionDefault (let 870 | ctx = tf.terraformContext exists config.out.hclPathStr null; 871 | exists = tconfig.state.outputs ? ${config.out.reference}; 872 | in if exists then tconfig.state.outputs.${config.out.reference} else throw "imported output ${config.out.reference} not found"); 873 | }; 874 | }); 875 | moduleType = types.submodule ({ name, config, ... }: { 876 | options = { 877 | name = mkOption { 878 | type = types.str; 879 | default = name; 880 | }; 881 | source = mkOption { 882 | type = types.str; 883 | }; 884 | version = mkOption { 885 | type = types.nullOr types.str; 886 | default = null; 887 | }; 888 | providers = mkOption { 889 | type = types.attrsOf types.str; 890 | default = { }; 891 | }; 892 | inputs = mkOption { 893 | type = types.attrsOf types.unspecified; 894 | default = { }; 895 | }; 896 | # TODO: count, for_each, lifecycle 897 | hcl = mkOption { 898 | type = types.attrsOf types.unspecified; 899 | readOnly = true; 900 | }; 901 | out = { 902 | reference = mkOption { 903 | type = types.str; 904 | readOnly = true; 905 | }; 906 | hclPath = mkOption { 907 | type = types.listOf types.str; 908 | internal = true; 909 | }; 910 | hclPathStr = mkOption { 911 | type = types.str; 912 | internal = true; 913 | }; 914 | }; 915 | }; 916 | 917 | config = { 918 | hcl = filterAttrs (_: v: v != null) { 919 | inherit (config) source version; 920 | } // optionalAttrs (config.providers != { }) { 921 | inherit (config) providers; 922 | } // config.inputs; 923 | out = { 924 | reference = "module.${config.name}"; 925 | hclPath = [ "module" config.name ]; 926 | hclPathStr = concatStringsSep "." config.out.hclPath; 927 | }; 928 | }; 929 | }); 930 | timeoutType = types.str; # TODO: validate "2h" "60m" "10s" etc 931 | timeoutsType = types.submodule ({ config, ... }: { 932 | # NOTE: only a limited subset of resource types support this? why not just put it in inputs? 933 | options = { 934 | create = mkOption { 935 | type = types.nullOr timeoutType; 936 | default = null; 937 | }; 938 | delete = mkOption { 939 | type = types.nullOr timeoutType; 940 | default = null; 941 | }; 942 | update = mkOption { 943 | type = types.nullOr timeoutType; 944 | default = null; 945 | }; 946 | hcl = mkOption { 947 | type = types.attrsOf types.unspecified; 948 | readOnly = true; 949 | }; 950 | }; 951 | 952 | config.hcl = filterAttrs (_: v: v != null) { 953 | inherit (config) create delete update; 954 | }; 955 | }); 956 | in { 957 | options = { 958 | resources = mkOption { 959 | type = types.attrsOf resourceType; 960 | default = { }; 961 | }; 962 | providers = mkOption { 963 | type = types.attrsOf providerType; 964 | default = { }; 965 | }; 966 | variables = mkOption { 967 | type = types.attrsOf variableType; 968 | default = { }; 969 | }; 970 | outputs = mkOption { 971 | type = types.attrsOf outputType; 972 | default = { }; 973 | }; 974 | modules = mkOption { 975 | type = types.attrsOf moduleType; 976 | default = { }; 977 | }; 978 | 979 | state = { 980 | outputs = mkOption { 981 | type = types.attrsOf types.unspecified; 982 | default = { }; 983 | }; 984 | resources = mkOption { 985 | type = types.attrsOf types.unspecified; 986 | default = { }; 987 | }; 988 | }; 989 | 990 | terraform = { 991 | version = mkOption { 992 | type = types.enum [ "0.11" "0.12" "0.13" "0.14" "0.15" "1.0" ]; 993 | default = if pkgs ? terraform_1 || pkgs ? terraform_1_0 then "1.0" else "0.13"; 994 | }; 995 | package = mkOption { 996 | type = types.package; 997 | readOnly = true; 998 | }; 999 | packageUnwrapped = mkOption { 1000 | type = types.package; 1001 | readOnly = true; 1002 | }; 1003 | packageWithPlugins = mkOption { 1004 | type = types.package; 1005 | readOnly = true; 1006 | }; 1007 | cli = mkOption { 1008 | type = types.package; 1009 | readOnly = true; 1010 | }; 1011 | wrapper = mkOption { 1012 | type = types.unspecified; 1013 | default = terraform: pkgs.callPackage ../lib/wrapper.nix { inherit terraform; }; 1014 | }; 1015 | requiredProviders = mkOption { 1016 | type = types.attrsOf requiredProviderType; 1017 | default = { }; 1018 | }; 1019 | refreshOnApply = mkOption { 1020 | type = types.bool; 1021 | default = true; 1022 | }; 1023 | autoApprove = mkOption { 1024 | type = types.bool; 1025 | default = false; 1026 | }; 1027 | inputPrompt = mkOption { 1028 | type = types.bool; 1029 | default = true; 1030 | }; 1031 | prettyJson = mkOption { 1032 | type = types.bool; 1033 | default = false; 1034 | }; 1035 | logLevel = mkOption { 1036 | type = types.enum [ "TRACE" "DEBUG" "INFO" "WARN" "ERROR" "" ]; 1037 | default = ""; 1038 | }; 1039 | logPath = mkOption { 1040 | type = types.nullOr types.path; 1041 | default = null; 1042 | }; 1043 | dataDir = mkOption { 1044 | type = types.nullOr types.path; 1045 | default = null; 1046 | }; 1047 | environment = mkOption { 1048 | type = types.attrsOf (types.separatedString " "); 1049 | default = { }; 1050 | }; 1051 | }; 1052 | 1053 | hcl = mkOption { 1054 | type = types.attrsOf types.unspecified; 1055 | readOnly = true; 1056 | }; 1057 | 1058 | lib = mkOption { 1059 | type = types.attrsOf (types.attrsOf types.unspecified); 1060 | default = { }; 1061 | }; 1062 | }; 1063 | 1064 | config = { 1065 | terraform = { 1066 | packageUnwrapped = { 1067 | "0.11" = pkgs.terraform_0_11; 1068 | "0.12" = pkgs.terraform_0_12; 1069 | "0.13" = pkgs.terraform_0_13; 1070 | "0.14" = pkgs.terraform_0_14; 1071 | "0.15" = pkgs.terraform_0_15; 1072 | "1.0" = pkgs.terraform_1 or pkgs.terraform_1_0; 1073 | }.${config.terraform.version}; 1074 | package = config.terraform.wrapper config.terraform.packageWithPlugins; 1075 | packageWithPlugins = let 1076 | pluginFor = ps: p: 1077 | if ps ? ${p.type} then ps.${p.type} else throw "terraform provider plugin ${p.type} not found"; 1078 | in config.terraform.packageUnwrapped.withPlugins (ps: 1079 | mapAttrsToList (_: pluginFor ps) config.terraform.requiredProviders 1080 | ); 1081 | requiredProviders = mkMerge ( 1082 | mapAttrsToList (_: p: { 1083 | ${p.type} = mapAttrs (_: mkDefault) (filterAttrs (_: v: v != null) { 1084 | inherit (p) source version; 1085 | }); 1086 | }) config.providers 1087 | ++ mapAttrsToList (_: r: { ${r.provider.type} = { }; }) ( 1088 | filterAttrs (_: r: r.provider.type != "terraform") config.resources 1089 | ) 1090 | ); 1091 | cli = let 1092 | wrapEnv = name: environment: let 1093 | vars = if isAttrs environment 1094 | then attrList environment 1095 | else environment; 1096 | exportable = partition ({ name, ... }: isExportable name) vars; 1097 | export = optionalString (exportable.right != [ ]) 1098 | "export ${concatMapStringsSep " " mapExport exportable.right}"; 1099 | env = optionalString (exportable.wrong != [ ]) 1100 | "env ${concatMapStringsSep " " mapEnv exportable.wrong} \\\n "; 1101 | script = pkgs.writeShellScriptBin name '' 1102 | set -eu 1103 | ${concatMapStringsSep "\n" mapVar vars} 1104 | ${export} 1105 | exec ${env}"$@" 1106 | ''; 1107 | in optionalString (vars != [ ]) "${script}/bin/${name}"; 1108 | vars = partition isVar (attrList config.terraform.environment); 1109 | run = wrapEnv "tf-env" vars.wrong; 1110 | run-vars = wrapEnv "tf-env-vars" vars.right; 1111 | attrList = mapAttrsToList nameValuePair; 1112 | isVar = { name, ... }: hasPrefix "TF_VAR_" name; 1113 | isExportable = name: ! hasInfix "-" name; 1114 | sanitize = replaceStrings [ "-" ] [ "__" ]; 1115 | mapVar = { name, value }: ''${sanitize name}="${value}"''; 1116 | mapExport = getAttr "name"; 1117 | mapEnv = { name, ... }: ''"${name}=''$${sanitize name}"''; 1118 | terraform = "${config.terraform.package}/bin/terraform"; 1119 | in pkgs.writeShellScriptBin "terraform" '' 1120 | set -eu 1121 | 1122 | terraform() { 1123 | exec ${run} ${terraform} "$@" 1124 | } 1125 | 1126 | terraform-vars() { 1127 | exec ${run} ${run-vars} ${terraform} "$@" 1128 | } 1129 | 1130 | case "''${1-}" in 1131 | state) 1132 | case "''${2-}" in 1133 | push|pull) 1134 | terraform-vars "$@" 1135 | ;; 1136 | *) 1137 | terraform "$@" 1138 | ;; 1139 | esac 1140 | ;; 1141 | init|get|validate|fmt|graph|output|show|taint|workspace|providers|version|force-unlock) 1142 | terraform "$@" 1143 | ;; 1144 | *) 1145 | terraform-vars "$@" 1146 | ;; 1147 | esac 1148 | ''; 1149 | environment = 1150 | mapAttrs' (_: var: 1151 | nameValuePair "TF_VAR_${var.name}" (mkOptionDefault "$(${var.value.shellCommand})") 1152 | ) (filterAttrs (_: var: var.value.shellCommand != null) config.variables) // { 1153 | TF_CONFIG_DIR = mkOptionDefault "${tf.hclDir { 1154 | inherit (config) hcl; 1155 | inherit (config.terraform) prettyJson; 1156 | terraform = config.terraform.packageWithPlugins; 1157 | }}"; 1158 | TF_LOG_PATH = mkIf (config.terraform.logPath != null) (mkOptionDefault (toString config.terraform.logPath)); 1159 | TF_DATA_DIR = mkIf (config.terraform.dataDir != null) (mkOptionDefault (toString config.terraform.dataDir)); 1160 | TF_STATE_FILE = mkIf (config.state.file != null) (mkOptionDefault (toString config.state.file)); 1161 | TF_CLI_CONFIG_FILE = mkOptionDefault "${pkgs.writeText "terraformrc" '' 1162 | disable_checkpoint = true 1163 | ''}"; 1164 | TF_CLI_ARGS_init = mkIf (versionAtLeast config.terraform.version "0.14") "-lockfile=readonly"; 1165 | TF_CLI_ARGS_refresh = "-compact-warnings"; 1166 | TF_CLI_ARGS_state_replace_provider = "-auto-approve"; 1167 | TF_CLI_ARGS_apply = mkMerge ([ 1168 | "-compact-warnings" 1169 | "-refresh=${if config.terraform.refreshOnApply then "true" else "false"}" 1170 | ] ++ optional config.terraform.autoApprove "-auto-approve"); 1171 | TF_IN_AUTOMATION = mkOptionDefault "1"; 1172 | TF_INPUT = mkOptionDefault (if config.terraform.inputPrompt then "1" else "0"); 1173 | TF_LOG = mkOptionDefault config.terraform.logLevel; 1174 | }; 1175 | }; 1176 | resources.tfnix-exports = let 1177 | vars = filterAttrs (_: var: var.export) config.variables; 1178 | in { 1179 | enable = vars != { }; 1180 | provider = "external"; 1181 | type = ""; 1182 | dataSource = true; 1183 | inputs = { 1184 | program = [ "${pkgs.coreutils}/bin/cat" ]; 1185 | query = mapAttrs' (_: var: nameValuePair var.name var.ref) vars; 1186 | }; 1187 | }; 1188 | hcl = tf.scrubHcl { 1189 | resource = let 1190 | resources' = filter (r: !r.dataSource && r.enable) (attrValues config.resources); 1191 | resources = groupBy (r: r.out.resourceKey) resources'; 1192 | in mkIf (resources != { }) (mapAttrs (_: r: listToAttrs (map (r: nameValuePair r.name r.hcl) r)) resources); 1193 | data = let 1194 | resources' = filter (r: r.dataSource && r.enable) (attrValues config.resources); 1195 | resources = groupBy (r: r.out.resourceKey) resources'; 1196 | in mkIf (resources != { }) (mapAttrs (_: r: listToAttrs (map (r: nameValuePair r.name r.hcl) r)) resources); 1197 | provider = let 1198 | providers' = attrValues config.providers; 1199 | providers = filter (p: p.hcl != { }) providers'; 1200 | in mkIf (providers != [ ]) (map (p: { ${p.type} = p.hcl; }) providers); 1201 | output = mkIf (config.outputs != { }) (mapAttrs' (_: o: nameValuePair o.name o.hcl) config.outputs); 1202 | variable = mkIf (config.variables != { }) (mapAttrs' (_: o: nameValuePair o.name o.hcl) config.variables); 1203 | terraform = let 1204 | providers = config.terraform.requiredProviders; 1205 | v0_13 = mapAttrs' (_: p: nameValuePair p.type p.hcl) providers; 1206 | v0_12 = mapAttrs' (_: p: nameValuePair p.type p.version) providers; 1207 | required_providers' = if versionAtLeast config.terraform.version "0.13" then v0_13 else v0_12; 1208 | required_providers = filterAttrs (_: p: p != { } && p != null) required_providers'; 1209 | in optionalAttrs (required_providers != { }) { 1210 | inherit required_providers; 1211 | }; 1212 | }; 1213 | runners.run = { 1214 | terraform.package = config.terraform.cli; 1215 | }; 1216 | lib = { 1217 | tf = let 1218 | tf = import ../lib { 1219 | inherit pkgs lib; 1220 | } // { 1221 | inherit tfTypes; 1222 | }; 1223 | in tf // { 1224 | fromHclPath = tf.fromHclPath config; 1225 | }; 1226 | }; 1227 | }; 1228 | } 1229 | --------------------------------------------------------------------------------