From 31f67b67ac5e86d3bb7d2dd01ee0ebc9c4e10942 Mon Sep 17 00:00:00 2001 From: 0xgleb Date: Wed, 1 Apr 2026 14:38:05 +0700 Subject: [PATCH 1/2] chore: tell coderabbit to hop the fuck up out of our way --- .coderabbit.yaml | 37 +++-- .github/workflows/ci.yml | 39 +++++ .github/workflows/integration-test.yml | 140 ++++++++++++++++ README.md | 10 +- ROADMAP.md | 155 +++++++++++------- flake.lock | 60 +++++++ flake.nix | 69 ++++++-- lib/bootstrap.nix | 53 +++--- lib/default.nix | 4 +- lib/deploy.nix | 88 ++++++---- lib/hooks.nix | 30 ++++ lib/remote.nix | 25 --- lib/shell.nix | 59 +++++++ lib/terraform.nix | 96 +++++------ modules/acme.nix | 30 ++++ modules/base.nix | 34 +++- modules/digitalocean.nix | 30 +++- modules/disko.nix | 13 +- modules/firewall.nix | 14 +- modules/services.nix | 149 +++++++++++------ modules/storage.nix | 9 +- templates/do-service/.github/workflows/ci.yml | 23 ++- .../.github/workflows/integration-test.yml | 98 +++++++++++ templates/do-service/config/secrets.nix | 6 +- templates/do-service/flake.nix | 76 ++++++--- templates/do-service/infra/secrets.nix | 6 +- .../do-service/infra/terraform.tfvars.example | 2 + templates/do-service/infra/variables.tf | 11 ++ templates/do-service/keys.nix | 24 ++- templates/do-service/os.nix | 23 ++- templates/do-service/services.nix | 2 +- 31 files changed, 1065 insertions(+), 350 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/integration-test.yml create mode 100644 lib/hooks.nix delete mode 100644 lib/remote.nix create mode 100644 lib/shell.nix create mode 100644 modules/acme.nix create mode 100644 templates/do-service/.github/workflows/integration-test.yml diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 091570a..886818f 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,24 +1,34 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json reviews: - auto_review: - base_branches: - - ".*" - - # Controls the "Reviews paused — this branch is under active development" - # message that CodeRabbit posts on PRs. After this many reviewed commits, - # it stops reviewing new ones. Default is 5; raised to 10. Set to 0 to - # disable entirely. - auto_pause_after_reviewed_commits: 10 - + # Review tone: "chill" (lighter feedback) or "assertive" (more nitpicky, + # flags style issues and potential problems). Default: "chill". profile: assertive - request_changes_workflow: false + + # Show metadata about the review process (ignored files, extra context used, + # suppressed comments). Default: false. review_details: true - collapse_walkthrough: false sequence_diagrams: false estimate_code_review_effort: false - auto_apply_labels: true auto_assign_reviewers: false poem: false + + # # When true, CodeRabbit submits "Request Changes" reviews (blocking) instead + # # of comments, and auto-approves once all its comments are resolved. + # request_changes_workflow: false + + auto_review: + # Review PRs targeting any branch, not just the default branch. Accepts + # regex patterns — ".*" matches all branches. + base_branches: + - ".*" + + # After this many commits are reviewed on a PR, CodeRabbit pauses automatic + # incremental reviews to reduce noise on active PRs. Default: 5, 0 disables. + auto_pause_after_reviewed_commits: 7 + + # Per-path review instructions. Each entry is a glob pattern + instructions + # string (max 20k chars) that CodeRabbit includes as extra context when + # reviewing matching files. path_instructions: - path: "*" instructions: >- @@ -30,6 +40,7 @@ reviews: technical aspects - suggest improvements if you find violations. Point out gaps in test coverage but suggest tests that are not too coupled to the implementation and actually test domain invariants and business logic + - path: "**/*.md" instructions: >- Focus on the contents of the docs and not on cosmetic things like markdown formatting. We diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..91f766c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +permissions: + contents: read + +jobs: + check: + name: Flake check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/nix-installer-action@v17 + - uses: DeterminateSystems/magic-nix-cache-action@v8 + - run: nix flake check + + examples: + name: "Example: ${{ matrix.example }}" + runs-on: ubuntu-latest + strategy: + matrix: + example: + - example-minimal + - example-single-service + - example-full + steps: + - uses: actions/checkout@v4 + - uses: DeterminateSystems/nix-installer-action@v17 + - uses: DeterminateSystems/magic-nix-cache-action@v8 + + - name: Evaluate NixOS configuration + run: nix eval .#nixosConfigurations.${{ matrix.example }}.config.system.build.toplevel.drvPath + + - name: Build NixOS configuration (dry-run) + run: nix build .#nixosConfigurations.${{ matrix.example }}.config.system.build.toplevel --dry-run diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000..dabe802 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,140 @@ +name: Integration Test + +on: + workflow_dispatch: + inputs: + droplet_size: + description: "DigitalOcean droplet size" + default: "s-1vcpu-2gb" + type: string + region: + description: "DigitalOcean region" + default: "nyc3" + type: string + +permissions: + id-token: write + contents: read + +concurrency: + group: integration-test + cancel-in-progress: false + +env: + TEST_DIR: integration-test + +jobs: + test: + name: Full lifecycle + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + TF_VAR_droplet_size: ${{ inputs.droplet_size }} + TF_VAR_region: ${{ inputs.region }} + steps: + - uses: actions/checkout@v4 + + - uses: DeterminateSystems/nix-installer-action@v17 + with: + extra-conf: | + accept-flake-config = true + fallback = true + + - uses: DeterminateSystems/magic-nix-cache-action@v8 + + - name: Setup SSH identity + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Scaffold test project from template + run: | + mkdir -p "$TEST_DIR" + cd "$TEST_DIR" + nix flake init -t ..#do-service + + # Point omnix input to the local checkout + sed -i 's|github:data-cartel/omnix|path:../|' flake.nix + + # Rename resources to avoid collisions with real infra + sed -i 's/my-service/omnix-integration-test/g' infra/main.tf + sed -i 's/my-service/omnix-integration-test/g' os.nix + sed -i 's/my-service/omnix-integration-test/g' services.nix + sed -i 's/my-service-op/omnix-integration-test/g' infra/variables.tf + + # Derive public key from the private key + PUBKEY=$(ssh-keygen -y -f ~/.ssh/id_ed25519) + + # Write keys.nix with the CI key + cat > keys.nix </dev/null) + echo "::add-mask::$HOST_IP" + echo "HOST_IP=$HOST_IP" >> "$GITHUB_ENV" + + - name: Wait for SSH + run: | + retries=0 + until ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + -i ~/.ssh/id_ed25519 "root@$HOST_IP" true 2>/dev/null; do + retries=$((retries + 1)) + if [ "$retries" -ge 30 ]; then + echo "SSH not available after 2.5 minutes" >&2 + exit 1 + fi + sleep 5 + done + + - name: Bootstrap NixOS + working-directory: ${{ env.TEST_DIR }} + run: nix run .#bootstrap -- -i ~/.ssh/id_ed25519 + + - name: Deploy system + working-directory: ${{ env.TEST_DIR }} + run: nix run .#deployNixos -- -i ~/.ssh/id_ed25519 + + - name: Verify SSH access + run: ssh -i ~/.ssh/id_ed25519 "root@$HOST_IP" uname -a + + - name: Verify NixOS + run: | + RESULT=$(ssh -i ~/.ssh/id_ed25519 "root@$HOST_IP" \ + "grep -c NixOS /etc/os-release") + if [ "$RESULT" -lt 1 ]; then + echo "Host is not running NixOS" >&2 + exit 1 + fi + + - name: Teardown + if: always() + working-directory: ${{ env.TEST_DIR }} + run: nix run .#tfDestroy -- -i ~/.ssh/id_ed25519 || true + + - name: Cleanup SSH + if: always() + run: rm -rf ~/.ssh/id_ed25519 diff --git a/README.md b/README.md index d84fd3b..f76717c 100644 --- a/README.md +++ b/README.md @@ -30,16 +30,16 @@ Each module is independently usable under the `omnix.*` namespace: | `services` | `omnix.services.*` | Systemd service generation with marker files | | `firewall` | `omnix.firewall.*` | TCP port allowlist (SSH always included) | -Use `omnix.nixosModules.default` to import all modules at once. +Use `omnix.nixosModules.default` to import all omnix modules plus upstream disko and ragenix modules at once. ## Library Functions | Function | Purpose | | ----------------- | ------------------------------------------------------------------------- | -| `lib.mkTerraform` | Terraform wrapper scripts (init, plan, apply, rekey, etc.) | +| `lib.mkTerraform` | Terraform wrapper scripts (init, plan, apply, rekey, remote SSH, etc.) | | `lib.mkDeploy` | deploy-rs config + shell wrappers (deployNixos, deployService, deployAll) | | `lib.mkBootstrap` | nixos-anywhere provisioning + host key update | -| `lib.mkRemote` | SSH helper that resolves host IP from terraform state | +| `lib.mkGitHooks` | Pre-commit hooks (nixfmt, deadnix, taplo, optional rustfmt) | ## Using as a Flake Input @@ -54,9 +54,7 @@ Use `omnix.nixosModules.default` to import all modules at once. nixosConfigurations.myservice = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ - omnix.inputs.disko.nixosModules.disko - omnix.inputs.ragenix.nixosModules.default - omnix.nixosModules.default # all omnix modules + omnix.nixosModules.default # all omnix + upstream disko/ragenix modules ./os.nix ]; }; diff --git a/ROADMAP.md b/ROADMAP.md index b31a93d..e89e6e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,75 +1,89 @@ # Roadmap -## Migrate consumers to omnix +
PR review findings (all fixed) -Extract the library, prove it works by migrating moneymentum, then migrate the -st0x repos. Each migration replaces ~500 lines of duplicated Nix with module -imports and lib calls. +- [x] nixosModules.default should include upstream disko and ragenix modules +- [x] packages.disko: replace silent null fallback with explicit error +- [x] modules/services.nix: replace nested conditionals with lib.optionalAttrs +- [x] modules/services.nix: create fallback group when cfg.group is null +- [x] modules/base.nix: remove stateVersion default, require explicit setting +- [x] modules/digitalocean.nix: add comment about unconditional imports +- [x] modules/disko.nix: guard for upstream disko module presence +- [x] lib/shell.nix: add cleanup trap in resolveIp +- [x] README.md: clarify nixosModules.default includes upstream modules +- [x] templates CI: fix branch gate, use pinned host key instead of ssh-keyscan +- [x] templates flake.nix: add deploy target comment, simplify module imports +- [x] templates variables.tf: add input validation +- [x] templates terraform.tfvars.example: document encryption workflow +- [x] templates os.nix: remove unused pkgs argument +- [x] templates services.nix: use neutral dataDir default -- [ ] Wire moneymentum's flake.nix to use `path:./omnix` as input -- [ ] Migrate st0x.rest.api to use omnix -- simpler case, no service secrets -- [ ] Migrate st0x.liquidity to use omnix -- has ragenix service secrets, needs - deploy activation changes -- [ ] Move omnix to its own repo (`data-cartel/omnix`) +
## Refactor to idiomatic Nix -Clean up the extracted code to be more idiomatic. The initial extraction is a -mechanical copy-paste; this pass makes it proper library-quality Nix. +The initial extraction from moneymentum was mechanical copy-paste. This pass +makes it proper library-quality Nix before more consumers adopt. +- [x] Consolidate duplicate `resolveIp` / `parseIdentity` shell fragments -- + extracted to lib/shell.nix, removed redundant lib/remote.nix +- [ ] Organize lib/ more logically -- group related helpers, review module + boundaries - [ ] Use NixOS module options consistently -- current lib functions use raw attrset args, some could be module options instead for better composition and type checking -- [ ] Replace string-interpolated shell scripts with structured - writeShellApplication patterns -- avoid ad-hoc `${}` Nix-to-bash - boundaries where possible -- [ ] Use `lib.mkMerge` / `lib.mkIf` patterns instead of `//` attrset merging - for conditional config -- [ ] Add proper option descriptions and types to all module options -- some are - missing descriptions -- [ ] Consolidate duplicate `resolveIp` / `parseIdentity` shell fragments -- - currently duplicated between terraform.nix, bootstrap.nix, and remote.nix - [ ] Add `_module.args` passthrough for omnix-specific config so consumers don't need to wire specialArgs manually - [ ] Break down flake.nix -- separate concerns into importable files (outputs, - dev shell config, package definitions) so the main flake.nix stays small - and scannable -- [ ] Organize lib/ more logically -- group related shell fragments, avoid - duplication between terraform/bootstrap/remote helpers - -## secretspec age provider - -Write a custom secretspec provider (Rust crate at `crates/secretspec-age/`) that -uses the `age` crate (the library backing `rage`) and reads `keys.nix` for -recipient public keys. This replaces ragenix for on-host secret decryption -- -repos like st0x.liquidity use ragenix to decrypt `.toml.age` secrets during -deploy-rs activation, and this provider must support the same workflow: deploy -copies encrypted secrets to the host, the provider decrypts them using the -host's SSH key, and services read plaintext from `/run/agenix/` (or equivalent). - -- [ ] Scaffold `crates/secretspec-age/` Rust crate with `age` dependency -- [ ] Implement secretspec `Provider` trait for age encryption/decryption + package definitions) so the main flake.nix stays small and scannable + +## Age-based secret management + +Build a Rust CLI using the `age` crate that handles the secret lifecycle omnix +consumers need: encrypt secrets to role-based recipients defined in `keys.nix`, +decrypt on-host using the SSH host key, and integrate with deploy-rs activation. +This replaces the ragenix dependency with an omnix-owned tool. + +The current pattern in existing deployments: `.toml.age` files committed to git, +deploy-rs activation calls `rage` to decrypt to `/run/agenix/` using the host's +`/etc/ssh/ssh_host_ed25519_key`, services read plaintext from tmpfs. The new +tool must support this exact workflow. + +```mermaid +graph LR + A[Scaffold Rust crate] --> B[age encrypt/decrypt core] + B --> C[keys.nix role parsing] + B --> D[Host key decryption] + B --> F[NixOS module] + B --> G[deploy-rs activation] + C --> E[CLI: encrypt/decrypt/rekey] + D --> E + E --> H[Integrate into omnix flake] + F --> H + G --> H +``` + +- [ ] Scaffold `crates/omnix-age/` Rust crate with `age` dependency +- [ ] Implement encrypt/decrypt using age -- encrypt to multiple recipients, + decrypt with SSH identity file - [ ] Support `keys.nix` role-based recipient resolution -- parse the Nix - attrset to extract public keys per role, so the provider knows which keys - to encrypt to -- [ ] Support on-host decryption using SSH host key -- the host's - `/etc/ssh/ssh_host_ed25519_key` is the identity for decryption, same as - ragenix uses today -- [ ] Integrate provider into omnix flake as a package -- [ ] Add secretspec NixOS module to omnix -- declares secrets in - `secretspec.toml`, replaces ragenix module import -- [ ] Add deploy-rs activation that uses secretspec instead of raw rage commands - -- deploy profile activation calls the provider CLI to decrypt -- [ ] Migrate existing ragenix secrets in moneymentum -- [ ] Migrate existing ragenix secrets in st0x.liquidity -- [ ] Migrate existing ragenix secrets in st0x.rest.api + attrset to extract public keys per role +- [ ] Support on-host decryption using SSH host key -- + `/etc/ssh/ssh_host_ed25519_key` as identity, same as ragenix today +- [ ] CLI: `omnix-age encrypt`, `omnix-age decrypt`, `omnix-age rekey` +- [ ] Integrate into omnix flake as a package +- [ ] Add NixOS module to omnix -- declares secrets with encryption rules, + replaces ragenix module import +- [ ] Add deploy-rs activation that uses omnix-age instead of raw rage commands +- [ ] Optional: implement secretspec `Provider` trait to emit `secretspec.toml` + -- if the secretspec SDK is available, wire the `Provider` trait so the + CLI can produce structured secret declarations alongside age encryption ## CI generation -The template includes a basic GitHub Actions workflow, but projects need -customized CI (backend tests, frontend lint, clippy, deploy gates). A lib -function or module that generates workflow YAML from Nix config would keep CI in -sync with the build system -- when packages change, CI updates automatically. +A lib function that generates GitHub Actions workflow YAML from Nix config, +keeping CI in sync with the build system -- when packages change, CI updates +automatically. - [ ] Design CI generation API -- take a list of checks/builds and produce `.github/workflows/ci.yml` content @@ -78,6 +92,26 @@ sync with the build system -- when packages change, CI updates automatically. - [ ] Generate deploy job that uses omnix deploy wrappers - [ ] Support matrix strategies for multi-target builds +## Integration test flow + +Validate the full omnix lifecycle end-to-end: provision infrastructure via +terraform, bootstrap with nixos-anywhere, deploy sample services, verify access +via remote, then tear everything down. Runs on GitHub Actions (ubuntu-latest) +since the NixOS closure can't build on macOS without remote-build. + +Manual-only trigger via `workflow_dispatch` to control cloud spend (~$0.01-0.05 +per run, a few minutes of a $12/mo droplet). + +- [x] GitHub Actions workflow for full lifecycle (manual trigger) +- [x] Scaffold test project from template, provision, bootstrap, deploy, verify +- [x] Teardown via terraform destroy (always runs, even on failure) +- [ ] Add service-level verification -- deploy a sample service, hit its HTTP + endpoint, verify response +- [ ] Redeploy test -- deploy a different service profile, verify switchover +- [ ] `mkIntegrationTest` lib function -- let consumers define their own + lifecycle test flows using the same harness, parameterized by their + project-specific config (services, keys, node name) + ## Not epic - [ ] Add Hetzner cloud modules -- alternative to DigitalOcean for when we need @@ -86,4 +120,15 @@ sync with the build system -- when packages change, CI updates automatically. nginx on port 80 only - [ ] Add logrotate module -- rest.api has it, others don't rotate logs at all -## Completed +## Completed: Extract and publish omnix + +Extracted from moneymentum, published as standalone library at +`data-cartel/omnix`. Moneymentum migrated as first consumer. + +- [x] Extract omnix from moneymentum into standalone flake +- [x] Move omnix to its own repo (`data-cartel/omnix`) +- [x] Wire moneymentum as first consumer +- [x] All 6 NixOS modules implemented with typed option interfaces +- [x] All 4 lib functions (mkTerraform, mkDeploy, mkBootstrap, mkGitHooks) + implemented +- [x] Flake template (`do-service`) scaffolds complete projects diff --git a/flake.lock b/flake.lock index 58492f7..335db94 100644 --- a/flake.lock +++ b/flake.lock @@ -142,6 +142,22 @@ "type": "github" } }, + "flake-compat_2": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, "flake-parts": { "inputs": { "nixpkgs-lib": [ @@ -199,6 +215,49 @@ "type": "github" } }, + "git-hooks": { + "inputs": { + "flake-compat": "flake-compat_2", + "gitignore": "gitignore", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772893680, + "narHash": "sha256-JDqZMgxUTCq85ObSaFw0HhE+lvdOre1lx9iI6vYyOEs=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "8baab586afc9c9b57645a734c820e4ac0a604af9", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "git-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1709087332, + "narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -355,6 +414,7 @@ "deploy-rs": "deploy-rs", "disko": "disko", "flake-utils": "flake-utils", + "git-hooks": "git-hooks", "nixos-anywhere": "nixos-anywhere", "nixpkgs": "nixpkgs", "ragenix": "ragenix" diff --git a/flake.nix b/flake.nix index e6d595f..b41d4e6 100644 --- a/flake.nix +++ b/flake.nix @@ -16,10 +16,23 @@ nixos-anywhere.url = "github:nix-community/nixos-anywhere"; nixos-anywhere.inputs.nixpkgs.follows = "nixpkgs"; + + git-hooks.url = "github:cachix/git-hooks.nix"; + git-hooks.inputs.nixpkgs.follows = "nixpkgs"; }; - outputs = { self, nixpkgs, flake-utils, ragenix, deploy-rs, disko - , nixos-anywhere, ... }: + outputs = + { + self, + nixpkgs, + flake-utils, + ragenix, + deploy-rs, + disko, + nixos-anywhere, + git-hooks, + ... + }: { nixosModules = { disko = import ./modules/disko.nix; @@ -28,35 +41,67 @@ storage = import ./modules/storage.nix; services = import ./modules/services.nix; firewall = import ./modules/firewall.nix; + acme = import ./modules/acme.nix; - # Convenience: all modules at once + # Convenience: all modules at once (includes upstream disko + ragenix) default = { imports = [ + disko.nixosModules.disko + ragenix.nixosModules.default self.nixosModules.disko self.nixosModules.digitalocean self.nixosModules.base self.nixosModules.storage self.nixosModules.services self.nixosModules.firewall + self.nixosModules.acme ]; }; }; - lib = import ./lib { inherit nixpkgs deploy-rs nixos-anywhere; }; + lib = import ./lib { + inherit + nixpkgs + deploy-rs + nixos-anywhere + git-hooks + ; + }; templates = { do-service = { path = ./templates/do-service; - description = - "DigitalOcean service with deploy-rs, terraform, and age secrets"; + description = "DigitalOcean service with deploy-rs, terraform, and age secrets"; }; default = self.templates.do-service; }; - } // flake-utils.lib.eachDefaultSystem (system: - let pkgs = import nixpkgs { inherit system; }; - in { - # Expose disko and ragenix modules for consumer nixosSystem calls - packages.disko = disko.packages.${system}.default or null; + } + // flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + hooks = self.lib.mkGitHooks { }; + in + { + packages.disko = + disko.packages.${system}.default or (throw "disko package not available for ${system}"); packages.ragenix = ragenix.packages.${system}.default; - }); + + checks.git-hooks = git-hooks.lib.${system}.run { + inherit hooks; + src = self; + }; + + formatter = pkgs.nixfmt; + + devShells.default = pkgs.mkShell { + inherit (self.checks.${system}.git-hooks) shellHook; + packages = [ + pkgs.nixfmt + pkgs.deadnix + pkgs.taplo + ]; + }; + } + ); } diff --git a/lib/bootstrap.nix b/lib/bootstrap.nix index e02d1c5..fb230ac 100644 --- a/lib/bootstrap.nix +++ b/lib/bootstrap.nix @@ -1,29 +1,30 @@ { nixos-anywhere }: -{ pkgs, keysFile, configName, system, ragenixPkg ? null, secretsRules ? null }: +{ + pkgs, + keysFile, + configName, + system, + ragenixPkg ? null, + secretsRules ? null, +}: let - resolveIp = '' - identity=~/.ssh/id_ed25519 - if [ "''${1:-}" = "-i" ]; then - identity="$2" - shift 2 - fi - - if [ -f infra/terraform.tfstate.age ]; then - rage -d -i "$identity" infra/terraform.tfstate.age > infra/terraform.tfstate - fi - host_ip=$(jq -r '.outputs.droplet_ipv4.value' infra/terraform.tfstate) - rm -f infra/terraform.tfstate - ''; + shell = import ./shell.nix { inherit keysFile; }; -in pkgs.writeShellApplication { +in +pkgs.writeShellApplication { name = "bootstrap-nixos"; - runtimeInputs = - [ pkgs.rage pkgs.jq pkgs.gnused nixos-anywhere.packages.${system}.default ] - ++ (if ragenixPkg != null then [ ragenixPkg ] else [ ]); + runtimeInputs = [ + pkgs.rage + pkgs.jq + pkgs.gnused + pkgs.openssh + nixos-anywhere.packages.${system}.default + ] + ++ (if ragenixPkg != null then [ ragenixPkg ] else [ ]); text = '' - ${resolveIp} + ${shell.resolveIp} ssh_opts=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$identity") nixos-anywhere --flake ".#${configName}" \ @@ -58,10 +59,14 @@ in pkgs.writeShellApplication { fi echo "Updated host key in keys.nix" - ${if ragenixPkg != null && secretsRules != null then '' - echo "Rekeying secrets..." - ragenix --rules ${secretsRules} -i "$identity" -r - '' else - ""} + ${ + if ragenixPkg != null && secretsRules != null then + '' + echo "Rekeying secrets..." + ragenix --rules ${secretsRules} -i "$identity" -r + '' + else + "" + } ''; } diff --git a/lib/default.nix b/lib/default.nix index 6cdeec2..c067cfb 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -1,8 +1,8 @@ -{ nixpkgs, deploy-rs, nixos-anywhere }: +{ deploy-rs, nixos-anywhere, ... }: { mkTerraform = import ./terraform.nix; mkDeploy = import ./deploy.nix { inherit deploy-rs; }; mkBootstrap = import ./bootstrap.nix { inherit nixos-anywhere; }; - mkRemote = import ./remote.nix; + mkGitHooks = import ./hooks.nix; } diff --git a/lib/deploy.nix b/lib/deploy.nix index c645840..31485ac 100644 --- a/lib/deploy.nix +++ b/lib/deploy.nix @@ -1,31 +1,43 @@ { deploy-rs }: -{ self, nodeName, services, package, nixosConfig ? null }: +{ + self, + nodeName, + services, + package, + nixosConfig ? null, + targetSystem ? "x86_64-linux", +}: let - system = "x86_64-linux"; + system = targetSystem; inherit (deploy-rs.lib.${system}) activate; profileBase = "/nix/var/nix/profiles/per-service"; - enabledServices = builtins.filter (name: services.${name}.enabled) - (builtins.attrNames services); + enabledServices = builtins.filter (name: services.${name}.enabled) (builtins.attrNames services); - mkServiceProfile = name: - let markerFile = "/run/${nodeName}/${name}.ready"; - in activate.custom package (builtins.concatStringsSep " && " [ - "systemctl stop ${name} || true" - "rm -f ${markerFile}" - "mkdir -p /run/${nodeName}" - "touch ${markerFile}" - "systemctl restart ${name}" - ]); + mkServiceProfile = + name: + let + markerFile = "/run/${nodeName}/${name}.ready"; + in + activate.custom package ( + builtins.concatStringsSep " && " [ + "systemctl stop ${name} || true" + "rm -f ${markerFile}" + "mkdir -p /run/${nodeName}" + "touch ${markerFile}" + "systemctl restart ${name}" + ] + ); mkProfile = name: { path = mkServiceProfile name; profilePath = "${profileBase}/${name}"; }; -in { +in +{ config = { nodes.${nodeName} = { hostname = "MUST_OVERRIDE_HOSTNAME"; @@ -35,21 +47,33 @@ in { profilesOrder = [ "system" ] ++ enabledServices; profiles = { - system.path = if nixosConfig != null then - activate.nixos nixosConfig - else - activate.nixos self.nixosConfigurations.${nodeName}; - } // builtins.listToAttrs (map (name: { - inherit name; - value = mkProfile name; - }) enabledServices); + system.path = + if nixosConfig != null then + activate.nixos nixosConfig + else + activate.nixos self.nixosConfigurations.${nodeName}; + } + // builtins.listToAttrs ( + map (name: { + inherit name; + value = mkProfile name; + }) enabledServices + ); }; }; - wrappers = { pkgs, infraPkgs, localSystem }: + wrappers = + { + pkgs, + infraPkgs, + localSystem, + }: let - deployInputs = - [ pkgs.rage pkgs.jq deploy-rs.packages.${localSystem}.deploy-rs ]; + deployInputs = [ + pkgs.rage + pkgs.jq + deploy-rs.packages.${localSystem}.deploy-rs + ]; deployPreamble = '' ${infraPkgs.resolveIp} @@ -66,15 +90,15 @@ in { fi ''; - deployFlags = if localSystem == "x86_64-linux" then - "--skip-checks" - else - "--remote-build --skip-checks"; + deployFlags = + if localSystem == "x86_64-linux" then "--skip-checks" else "--remote-build --skip-checks"; - serviceCleanup = builtins.concatStringsSep "; " - (map (name: "systemctl reset-failed ${name} || true") enabledServices); + serviceCleanup = builtins.concatStringsSep "; " ( + map (name: "systemctl reset-failed ${name} || true") enabledServices + ); - in { + in + { deployNixos = pkgs.writeShellApplication { name = "deploy-nixos"; runtimeInputs = deployInputs; diff --git a/lib/hooks.nix b/lib/hooks.nix new file mode 100644 index 0000000..611b820 --- /dev/null +++ b/lib/hooks.nix @@ -0,0 +1,30 @@ +{ + extraHooks ? { }, + rustToolchain ? null, +}: + +let + defaultHooks = { + nixfmt.enable = true; + deadnix = { + enable = true; + excludes = [ "^templates/" ]; + }; + taplo.enable = true; + } + // ( + if rustToolchain != null then + { + rustfmt = { + enable = true; + entry = "${rustToolchain}/bin/cargo fmt --"; + files = "\\.rs$"; + pass_filenames = true; + }; + } + else + { } + ); + +in +defaultHooks // extraHooks diff --git a/lib/remote.nix b/lib/remote.nix deleted file mode 100644 index 4ad8dae..0000000 --- a/lib/remote.nix +++ /dev/null @@ -1,25 +0,0 @@ -{ pkgs, keysFile }: - -let - resolveIp = '' - identity=~/.ssh/id_ed25519 - if [ "''${1:-}" = "-i" ]; then - identity="$2" - shift 2 - fi - - if [ -f infra/terraform.tfstate.age ]; then - rage -d -i "$identity" infra/terraform.tfstate.age > infra/terraform.tfstate - fi - host_ip=$(jq -r '.outputs.droplet_ipv4.value' infra/terraform.tfstate) - rm -f infra/terraform.tfstate - ''; - -in pkgs.writeShellApplication { - name = "remote"; - runtimeInputs = [ pkgs.rage pkgs.jq pkgs.openssh ]; - text = '' - ${resolveIp} - exec ssh -i "$identity" "root@$host_ip" "$@" - ''; -} diff --git a/lib/shell.nix b/lib/shell.nix new file mode 100644 index 0000000..6467736 --- /dev/null +++ b/lib/shell.nix @@ -0,0 +1,59 @@ +{ keysFile }: + +let + tfState = "infra/terraform.tfstate"; + tfVars = "infra/terraform.tfvars"; + + parseIdentity = '' + identity=~/.ssh/id_ed25519 + if [ "''${1:-}" = "-i" ]; then + identity="$2" + shift 2 + fi + ''; + + decryptState = '' + if [ -f ${tfState}.age ]; then + rage -d -i "$identity" ${tfState}.age > ${tfState} + chmod 600 ${tfState} + fi + ''; + + encryptState = '' + if [ -f ${tfState} ]; then + nix eval --raw --file ${keysFile} roles.infra --apply 'builtins.concatStringsSep "\n"' \ + | rage -e -R /dev/stdin -o ${tfState}.age ${tfState} + fi + ''; + + decryptVars = '' + rage -d -i "$identity" ${tfVars}.age > ${tfVars} + chmod 600 ${tfVars} + ''; + + encryptVars = '' + nix eval --raw --file ${keysFile} roles.infra --apply 'builtins.concatStringsSep "\n"' \ + | rage -e -R /dev/stdin -o ${tfVars}.age ${tfVars} + ''; + + resolveIp = '' + ${parseIdentity} + trap 'rm -f ${tfState}' EXIT + ${decryptState} + host_ip=$(jq -e -r '.outputs.droplet_ipv4.value' ${tfState}) + rm -f ${tfState} + ''; + +in +{ + inherit + tfState + tfVars + parseIdentity + decryptState + encryptState + decryptVars + encryptVars + resolveIp + ; +} diff --git a/lib/terraform.nix b/lib/terraform.nix index eb3689e..b624e2c 100644 --- a/lib/terraform.nix +++ b/lib/terraform.nix @@ -1,45 +1,33 @@ -{ pkgs, keysFile, system, ragenixPkg ? null, secretsRules ? null }: +{ + pkgs, + keysFile, + ragenixPkg ? null, + secretsRules ? null, + ... +}: let - buildInputs = [ pkgs.terraform pkgs.rage pkgs.jq ] - ++ (if ragenixPkg != null then [ ragenixPkg ] else [ ]); + shell = import ./shell.nix { inherit keysFile; }; + inherit (shell) + tfState + tfVars + parseIdentity + decryptState + encryptState + decryptVars + encryptVars + resolveIp + ; + + buildInputs = [ + pkgs.terraform + pkgs.rage + pkgs.jq + ] + ++ (if ragenixPkg != null then [ ragenixPkg ] else [ ]); - tfState = "infra/terraform.tfstate"; - tfVars = "infra/terraform.tfvars"; tfPlanFile = "infra/tfplan"; - parseIdentity = '' - set -eo pipefail - - identity=~/.ssh/id_ed25519 - if [ "''${1:-}" = "-i" ]; then - identity="$2" - shift 2 - fi - ''; - - decryptState = '' - if [ -f ${tfState}.age ]; then - rage -d -i "$identity" ${tfState}.age > ${tfState} - fi - ''; - - encryptState = '' - if [ -f ${tfState} ]; then - nix eval --raw --file ${keysFile} roles.infra --apply 'builtins.concatStringsSep "\n"' \ - | rage -e -R /dev/stdin -o ${tfState}.age ${tfState} - fi - ''; - - decryptVars = '' - rage -d -i "$identity" ${tfVars}.age > ${tfVars} - ''; - - encryptVars = '' - nix eval --raw --file ${keysFile} roles.infra --apply 'builtins.concatStringsSep "\n"' \ - | rage -e -R /dev/stdin -o ${tfVars}.age ${tfVars} - ''; - cleanup = "rm -f ${tfState} ${tfState}.backup ${tfVars}"; cleanupWithPlan = "${cleanup} ${tfPlanFile}"; @@ -73,21 +61,16 @@ let ${encryptVars} ''; - resolveIp = '' - ${parseIdentity} - ${decryptState} - host_ip=$(jq -r '.outputs.droplet_ipv4.value' ${tfState}) - rm -f ${tfState} - ''; - - mkTask = name: body: + mkTask = + name: body: pkgs.writeShellApplication { inherit name; runtimeInputs = buildInputs; text = body; }; -in { +in +{ inherit buildInputs parseIdentity resolveIp; tfInit = mkTask "tf-init" '' @@ -137,17 +120,22 @@ in { ${rekeyPreamble} ''; - rekey = if ragenixPkg != null && secretsRules != null then - mkTask "rekey" '' - ${rekeyPreamble} - ragenix --rules ${secretsRules} -i "$identity" -r - '' - else - null; + rekey = + if ragenixPkg != null && secretsRules != null then + mkTask "rekey" '' + ${rekeyPreamble} + ragenix --rules ${secretsRules} -i "$identity" -r + '' + else + null; remote = pkgs.writeShellApplication { name = "remote"; - runtimeInputs = [ pkgs.rage pkgs.jq pkgs.openssh ]; + runtimeInputs = [ + pkgs.rage + pkgs.jq + pkgs.openssh + ]; text = '' ${resolveIp} exec ssh -i "$identity" "root@$host_ip" "$@" diff --git a/modules/acme.nix b/modules/acme.nix new file mode 100644 index 0000000..1dca6f6 --- /dev/null +++ b/modules/acme.nix @@ -0,0 +1,30 @@ +{ lib, config, ... }: + +let + cfg = config.omnix.acme; +in +{ + options.omnix.acme = { + enable = lib.mkEnableOption "ACME/Let's Encrypt certificate management"; + + email = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Email address for ACME renewal notifications"; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.email != null; + message = "omnix.acme.email must be set when omnix.acme.enable is true"; + } + ]; + + security.acme = { + acceptTerms = true; + defaults.email = cfg.email; + }; + }; +} diff --git a/modules/base.nix b/modules/base.nix index e8fae91..f5a57df 100644 --- a/modules/base.nix +++ b/modules/base.nix @@ -1,7 +1,14 @@ -{ lib, config, pkgs, ... }: +{ + lib, + config, + pkgs, + ... +}: -let cfg = config.omnix.base; -in { +let + cfg = config.omnix.base; +in +{ options.omnix.base = { enable = lib.mkEnableOption "omnix base NixOS settings"; @@ -18,7 +25,6 @@ in { stateVersion = lib.mkOption { type = lib.types.str; - default = "24.11"; description = "NixOS state version"; }; }; @@ -36,7 +42,10 @@ in { nix = { settings = { - experimental-features = [ "nix-command" "flakes" ]; + experimental-features = [ + "nix-command" + "flakes" + ]; auto-optimise-store = true; download-buffer-size = 268435456; }; @@ -50,11 +59,18 @@ in { programs.bash.interactiveShellInit = "set -o vi"; - system.activationScripts.per-service-profiles.text = - "mkdir -p /nix/var/nix/profiles/per-service"; + system.activationScripts.per-service-profiles.text = "mkdir -p /nix/var/nix/profiles/per-service"; - environment.systemPackages = with pkgs; - [ bat curl htop rage zellij ] ++ cfg.extraPackages; + environment.systemPackages = + with pkgs; + [ + bat + curl + htop + rage + zellij + ] + ++ cfg.extraPackages; system.stateVersion = cfg.stateVersion; }; diff --git a/modules/digitalocean.nix b/modules/digitalocean.nix index 9101068..63a2d42 100644 --- a/modules/digitalocean.nix +++ b/modules/digitalocean.nix @@ -1,7 +1,18 @@ -{ lib, config, modulesPath, ... }: +{ + lib, + config, + modulesPath, + ... +}: -let cfg = config.omnix.digitalocean; -in { +let + cfg = config.omnix.digitalocean; +in +{ + # Unconditional: NixOS module imports are always evaluated. + # Runtime configuration is gated by lib.mkIf cfg.enable below, + # so importing this module with enable = false has no side effects + # beyond making the DO/QEMU options available. imports = [ (modulesPath + "/virtualisation/digital-ocean-config.nix") (modulesPath + "/profiles/qemu-guest.nix") @@ -23,7 +34,10 @@ in { enable = true; network.enable = true; settings = { - datasource_list = [ "ConfigDrive" "Digitalocean" ]; + datasource_list = [ + "ConfigDrive" + "Digitalocean" + ]; datasource.ConfigDrive = { }; datasource.Digitalocean = { }; cloud_init_modules = [ @@ -36,8 +50,12 @@ in { "update_hostname" "set_password" ]; - cloud_config_modules = - [ "ssh-import-id" "keyboard" "runcmd" "disable_ec2_metadata" ]; + cloud_config_modules = [ + "ssh-import-id" + "keyboard" + "runcmd" + "disable_ec2_metadata" + ]; cloud_final_modules = [ "write_files_deferred" "scripts_per_once" diff --git a/modules/disko.nix b/modules/disko.nix index ca82e37..01c8b94 100644 --- a/modules/disko.nix +++ b/modules/disko.nix @@ -1,7 +1,9 @@ { lib, config, ... }: -let cfg = config.omnix.disko; -in { +let + cfg = config.omnix.disko; +in +{ options.omnix.disko = { enable = lib.mkEnableOption "omnix GPT disk layout"; device = lib.mkOption { @@ -12,6 +14,13 @@ in { }; config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = builtins.hasAttr "disko" config; + message = "omnix.disko requires the upstream disko NixOS module. Import disko.nixosModules.disko or use omnix.nixosModules.default which includes it."; + } + ]; + disko.devices.disk.primary = { device = lib.mkDefault cfg.device; type = "disk"; diff --git a/modules/firewall.nix b/modules/firewall.nix index 8a191a9..71f92ea 100644 --- a/modules/firewall.nix +++ b/modules/firewall.nix @@ -1,10 +1,18 @@ { lib, config, ... }: -let cfg = config.omnix.firewall; -in { +let + cfg = config.omnix.firewall; + + httpPorts = lib.optional cfg.enableHTTP 80; + httpsPorts = lib.optional cfg.enableHTTPS 443; +in +{ options.omnix.firewall = { enable = lib.mkEnableOption "omnix firewall (SSH always allowed)"; + enableHTTP = lib.mkEnableOption "port 80 (HTTP, needed for ACME challenges)"; + enableHTTPS = lib.mkEnableOption "port 443 (HTTPS)"; + allowedTCPPorts = lib.mkOption { type = lib.types.listOf lib.types.port; default = [ ]; @@ -15,7 +23,7 @@ in { config = lib.mkIf cfg.enable { networking.firewall = { enable = true; - allowedTCPPorts = [ 22 ] ++ cfg.allowedTCPPorts; + allowedTCPPorts = [ 22 ] ++ httpPorts ++ httpsPorts ++ cfg.allowedTCPPorts; }; }; } diff --git a/modules/services.nix b/modules/services.nix index de229a9..6b596f9 100644 --- a/modules/services.nix +++ b/modules/services.nix @@ -5,17 +5,26 @@ let enabledServices = lib.filterAttrs (_: v: v.enabled) cfg.definitions; - mkService = name: svcCfg: + mkService = + name: svcCfg: let path = "/nix/var/nix/profiles/per-service/${name}/bin/${svcCfg.bin}"; markerFile = "/run/${cfg.project}/${name}.ready"; configFile = cfg.configDir + "/${name}.toml"; - execStart = if svcCfg.extraArgs == [ ] then - "${path} --config ${configFile}" - else - builtins.concatStringsSep " " - ([ path "--config" "${configFile}" ] ++ svcCfg.extraArgs); - in { + execStart = + if svcCfg.extraArgs == [ ] then + "${path} --config ${configFile}" + else + builtins.concatStringsSep " " ( + [ + path + "--config" + "${configFile}" + ] + ++ svcCfg.extraArgs + ); + in + { description = "${cfg.project} ${svcCfg.bin} (${name})"; wantedBy = [ ]; @@ -33,32 +42,35 @@ let Restart = "always"; RestartSec = 5; ExecStart = execStart; - } // (if cfg.group != null then { - User = cfg.user; - Group = cfg.group; - } else - { }) // (if cfg.dynamicUser && cfg.group != null then { - SupplementaryGroups = [ cfg.group ]; - } else - { }) // (if svcCfg.dataDir != null then { - ReadWritePaths = [ svcCfg.dataDir ]; - } else - { }); + } + // lib.optionalAttrs (cfg.user != null) { User = cfg.user; } + // lib.optionalAttrs (cfg.group != null) { Group = cfg.group; } + // lib.optionalAttrs (cfg.dynamicUser && cfg.group != null) { + SupplementaryGroups = [ cfg.group ]; + } + // ( + let + rwPaths = lib.filter (p: p != null) [ + svcCfg.dataDir + svcCfg.logDir + ]; + in + lib.optionalAttrs (rwPaths != [ ]) { ReadWritePaths = rwPaths; } + ); }; -in { +in +{ options.omnix.services = { project = lib.mkOption { type = lib.types.str; - description = - "Project name (used for marker dir /run// and service descriptions)"; + description = "Project name (used for marker dir /run// and service descriptions)"; }; user = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; - description = - "System user to run services as (null uses DynamicUser naming)"; + description = "System user to run services as (null uses DynamicUser naming)"; }; group = lib.mkOption { @@ -79,40 +91,76 @@ in { }; definitions = lib.mkOption { - type = lib.types.attrsOf (lib.types.submodule { - options = { - enabled = lib.mkOption { - type = lib.types.bool; - default = true; + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + enabled = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether this service is deployed and managed"; + }; + bin = lib.mkOption { + type = lib.types.str; + description = "Binary name inside the deploy-rs profile (looked up at /nix/var/nix/profiles/per-service//bin/)"; + }; + dataDir = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Persistent data directory (created via tmpfiles, added to ReadWritePaths)"; + }; + extraArgs = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Additional CLI arguments appended after --config "; + }; + logDir = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Log directory (created via tmpfiles, logrotate rules auto-generated for *.log)"; + }; }; - bin = lib.mkOption { type = lib.types.str; }; - dataDir = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - }; - extraArgs = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - }; - }; - }); + } + ); default = { }; description = "Service definitions"; }; }; config = lib.mkIf (cfg.definitions != { }) { - system.activationScripts."${cfg.project}-init".text = - "mkdir -p /run/${cfg.project}"; + system.activationScripts."${cfg.project}-init".text = "mkdir -p /run/${cfg.project}"; systemd.services = lib.mapAttrs mkService enabledServices; - systemd.tmpfiles.rules = let - dataDirs = lib.mapAttrsToList (_: svcCfg: svcCfg.dataDir) - (lib.filterAttrs (_: svcCfg: svcCfg.dataDir != null) enabledServices); - owner = if cfg.user != null then cfg.user else "root"; - group = if cfg.group != null then cfg.group else "root"; - in map (dir: "d ${dir} 0770 ${owner} ${group} -") dataDirs; + systemd.tmpfiles.rules = + let + dataDirs = lib.mapAttrsToList (_: svcCfg: svcCfg.dataDir) ( + lib.filterAttrs (_: svcCfg: svcCfg.dataDir != null) enabledServices + ); + logDirs = lib.mapAttrsToList (_: svcCfg: svcCfg.logDir) ( + lib.filterAttrs (_: svcCfg: svcCfg.logDir != null) enabledServices + ); + owner = if cfg.user != null then cfg.user else "root"; + group = if cfg.group != null then cfg.group else "root"; + in + map (dir: "d ${dir} 0770 ${owner} ${group} -") (dataDirs ++ logDirs); + + services.logrotate.settings = lib.mkMerge ( + lib.mapAttrsToList ( + _: svcCfg: + lib.optionalAttrs (svcCfg.logDir != null) { + "${svcCfg.logDir}/*.log" = { + su = "${if cfg.user != null then cfg.user else "root"} ${ + if cfg.group != null then cfg.group else "root" + }"; + rotate = 14; + weekly = true; + compress = true; + missingok = true; + notifempty = true; + }; + } + ) enabledServices + ); users.users = lib.mkIf (cfg.user != null && !cfg.dynamicUser) { ${cfg.user} = { @@ -121,6 +169,11 @@ in { }; }; - users.groups = lib.mkIf (cfg.group != null) { ${cfg.group} = { }; }; + users.groups = lib.mkIf (cfg.user != null && !cfg.dynamicUser || cfg.group != null) ( + lib.optionalAttrs (cfg.group != null) { ${cfg.group} = { }; } + // lib.optionalAttrs (cfg.group == null && cfg.user != null && !cfg.dynamicUser) { + ${cfg.user} = { }; + } + ); }; } diff --git a/modules/storage.nix b/modules/storage.nix index ffdd8dd..e382ad9 100644 --- a/modules/storage.nix +++ b/modules/storage.nix @@ -1,14 +1,15 @@ { lib, config, ... }: -let cfg = config.omnix.storage; -in { +let + cfg = config.omnix.storage; +in +{ options.omnix.storage = { enable = lib.mkEnableOption "DigitalOcean block storage volume mount"; volumeName = lib.mkOption { type = lib.types.str; - description = - "DigitalOcean volume name (used in /dev/disk/by-id/scsi-0DO_Volume_)"; + description = "DigitalOcean volume name (used in /dev/disk/by-id/scsi-0DO_Volume_)"; }; mountPoint = lib.mkOption { diff --git a/templates/do-service/.github/workflows/ci.yml b/templates/do-service/.github/workflows/ci.yml index 63205c5..26b6cb9 100644 --- a/templates/do-service/.github/workflows/ci.yml +++ b/templates/do-service/.github/workflows/ci.yml @@ -6,7 +6,6 @@ on: pull_request: permissions: - id-token: write contents: read jobs: @@ -15,8 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main + - uses: DeterminateSystems/nix-installer-action@v17 + - uses: DeterminateSystems/magic-nix-cache-action@v8 - run: nix flake check build: @@ -24,22 +23,25 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main + - uses: DeterminateSystems/nix-installer-action@v17 + - uses: DeterminateSystems/magic-nix-cache-action@v8 - run: nix build deploy: name: Deploy needs: [check, build] - if: github.event_name == 'push' && github.ref == 'refs/heads/master' + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') runs-on: ubuntu-latest + permissions: + id-token: write + contents: read concurrency: group: deploy cancel-in-progress: false steps: - uses: actions/checkout@v4 - - uses: DeterminateSystems/nix-installer-action@main - - uses: DeterminateSystems/magic-nix-cache-action@main + - uses: DeterminateSystems/nix-installer-action@v17 + - uses: DeterminateSystems/magic-nix-cache-action@v8 - name: Setup SSH run: | @@ -49,7 +51,10 @@ jobs: HOST_IP=$(nix run .#resolveIp 2>/dev/null || echo "") if [ -n "$HOST_IP" ]; then - ssh-keyscan -t ed25519 "$HOST_IP" >> ~/.ssh/known_hosts 2>/dev/null || true + HOST_KEY=$(nix eval --raw --file keys.nix keys.host 2>/dev/null || echo "") + if [ -n "$HOST_KEY" ]; then + echo "$HOST_IP $HOST_KEY" >> ~/.ssh/known_hosts + fi fi - name: Deploy diff --git a/templates/do-service/.github/workflows/integration-test.yml b/templates/do-service/.github/workflows/integration-test.yml new file mode 100644 index 0000000..32dcaa2 --- /dev/null +++ b/templates/do-service/.github/workflows/integration-test.yml @@ -0,0 +1,98 @@ +name: Integration Test + +on: + workflow_dispatch: + inputs: + droplet_size: + description: "DigitalOcean droplet size" + default: "s-1vcpu-2gb" + type: string + region: + description: "DigitalOcean region" + default: "nyc3" + type: string + +permissions: + id-token: write + contents: read + +concurrency: + group: integration-test + cancel-in-progress: false + +jobs: + test: + name: Full lifecycle + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + TF_VAR_droplet_size: ${{ inputs.droplet_size }} + TF_VAR_region: ${{ inputs.region }} + steps: + - uses: actions/checkout@v4 + + - uses: DeterminateSystems/nix-installer-action@v17 + with: + extra-conf: | + accept-flake-config = true + fallback = true + + - uses: DeterminateSystems/magic-nix-cache-action@v8 + + - name: Setup SSH identity + run: | + mkdir -p ~/.ssh + chmod 700 ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Terraform init and apply + run: | + nix run .#tfInit -- -i ~/.ssh/id_ed25519 + nix run .#tfPlan -- -i ~/.ssh/id_ed25519 + nix run .#tfApply -- -i ~/.ssh/id_ed25519 + + - name: Resolve host IP + run: | + HOST_IP=$(nix run .#resolveIp -- -i ~/.ssh/id_ed25519 2>/dev/null) + echo "::add-mask::$HOST_IP" + echo "HOST_IP=$HOST_IP" >> "$GITHUB_ENV" + + - name: Wait for SSH + run: | + retries=0 + until ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \ + -i ~/.ssh/id_ed25519 "root@$HOST_IP" true 2>/dev/null; do + retries=$((retries + 1)) + if [ "$retries" -ge 30 ]; then + echo "SSH not available after 2.5 minutes" >&2 + exit 1 + fi + sleep 5 + done + + - name: Bootstrap NixOS + run: nix run .#bootstrap -- -i ~/.ssh/id_ed25519 + + - name: Deploy all + run: nix run .#deployAll -- -i ~/.ssh/id_ed25519 + + - name: Verify SSH access + run: ssh -i ~/.ssh/id_ed25519 "root@$HOST_IP" uname -a + + - name: Verify NixOS + run: | + RESULT=$(ssh -i ~/.ssh/id_ed25519 "root@$HOST_IP" \ + "grep -c NixOS /etc/os-release") + if [ "$RESULT" -lt 1 ]; then + echo "Host is not running NixOS" >&2 + exit 1 + fi + + - name: Teardown + if: always() + run: nix run .#tfDestroy -- -i ~/.ssh/id_ed25519 || true + + - name: Cleanup SSH + if: always() + run: rm -rf ~/.ssh/id_ed25519 diff --git a/templates/do-service/config/secrets.nix b/templates/do-service/config/secrets.nix index 9001386..de7ed73 100644 --- a/templates/do-service/config/secrets.nix +++ b/templates/do-service/config/secrets.nix @@ -1,4 +1,6 @@ -let inherit (import ../keys.nix) roles; -in { +let + inherit (import ../keys.nix) roles; +in +{ # "my-service.toml.age".publicKeys = roles.service; } diff --git a/templates/do-service/flake.nix b/templates/do-service/flake.nix index 7f69710..9cb9e06 100644 --- a/templates/do-service/flake.nix +++ b/templates/do-service/flake.nix @@ -7,45 +7,51 @@ flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, omnix, nixpkgs, flake-utils, ... }: + outputs = + { + self, + omnix, + nixpkgs, + flake-utils, + ... + }: let projectName = "my-service"; services = import ./services.nix; - in { + in + { nixosConfigurations.${projectName} = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; specialArgs = { }; modules = [ - omnix.inputs.disko.nixosModules.disko - omnix.inputs.ragenix.nixosModules.default - omnix.nixosModules.disko - omnix.nixosModules.digitalocean - omnix.nixosModules.base - omnix.nixosModules.storage - omnix.nixosModules.services - omnix.nixosModules.firewall + omnix.nixosModules.default ./os.nix ]; }; - deploy = let - deployConfig = omnix.lib.mkDeploy { - inherit self services; - nodeName = projectName; - package = self.packages.x86_64-linux.default; - }; - in deployConfig.config; - } // flake-utils.lib.eachDefaultSystem (system: + # Hardcoded to x86_64-linux: deploy-rs targets the remote NixOS host, + # which is always x86_64-linux regardless of the local dev machine. + deploy = + let + deployConfig = omnix.lib.mkDeploy { + inherit self services; + nodeName = projectName; + package = self.packages.x86_64-linux.default; + }; + in + deployConfig.config; + } + // flake-utils.lib.eachDefaultSystem ( + system: let pkgs = import nixpkgs { inherit system; - config.allowUnfreePredicate = pkg: - builtins.elem (pkgs.lib.getName pkg) [ "terraform" ]; + config.allowUnfreePredicate = pkg: builtins.elem (pkgs.lib.getName pkg) [ "terraform" ]; }; infraPkgs = omnix.lib.mkTerraform { - inherit pkgs system; + inherit pkgs; keysFile = ./keys.nix; ragenixPkg = omnix.inputs.ragenix.packages.${system}.default; secretsRules = ./config/secrets.nix; @@ -62,12 +68,21 @@ localSystem = system; }; - in { + in + { packages = { default = pkgs.hello; # replace with your actual package inherit (infraPkgs) - tfInit tfPlan tfApply tfDestroy tfEditVars tfRekey rekey remote; + tfInit + tfPlan + tfApply + tfDestroy + tfEditVars + tfRekey + rekey + remote + ; bootstrap = omnix.lib.mkBootstrap { inherit pkgs system; @@ -77,6 +92,18 @@ secretsRules = ./config/secrets.nix; }; + resolveIp = pkgs.writeShellApplication { + name = "resolve-ip"; + runtimeInputs = [ + pkgs.rage + pkgs.jq + ]; + text = '' + ${infraPkgs.resolveIp} + echo "$host_ip" + ''; + }; + inherit (deployPkgs) deployNixos deployService deployAll; }; @@ -88,5 +115,6 @@ deployPkgs.deployAll ]; }; - }); + } + ); } diff --git a/templates/do-service/infra/secrets.nix b/templates/do-service/infra/secrets.nix index 48a0e0f..a0563d9 100644 --- a/templates/do-service/infra/secrets.nix +++ b/templates/do-service/infra/secrets.nix @@ -1,5 +1,7 @@ -let inherit (import ../keys.nix) roles; -in { +let + inherit (import ../keys.nix) roles; +in +{ "terraform.tfstate.age".publicKeys = roles.infra; "terraform.tfvars.age".publicKeys = roles.infra; } diff --git a/templates/do-service/infra/terraform.tfvars.example b/templates/do-service/infra/terraform.tfvars.example index f3e96fc..d025cce 100644 --- a/templates/do-service/infra/terraform.tfvars.example +++ b/templates/do-service/infra/terraform.tfvars.example @@ -1 +1,3 @@ +# Do not create this file manually. Run `nix run .#tfEditVars` to create +# an encrypted terraform.tfvars.age instead. This example shows the format. do_token = "your_digitalocean_api_token_here" diff --git a/templates/do-service/infra/variables.tf b/templates/do-service/infra/variables.tf index ab5461a..1f1b328 100644 --- a/templates/do-service/infra/variables.tf +++ b/templates/do-service/infra/variables.tf @@ -2,6 +2,12 @@ variable "do_token" { description = "DigitalOcean API token" type = string sensitive = true + nullable = false + + validation { + condition = length(trimspace(var.do_token)) > 0 + error_message = "do_token must not be empty or whitespace-only." + } } variable "ssh_key_name" { @@ -26,4 +32,9 @@ variable "volume_size_gb" { description = "Data volume size in GB" type = number default = 5 + + validation { + condition = var.volume_size_gb >= 1 && var.volume_size_gb == floor(var.volume_size_gb) + error_message = "volume_size_gb must be an integer >= 1." + } } diff --git a/templates/do-service/keys.nix b/templates/do-service/keys.nix index 2fa5b1c..48d183f 100644 --- a/templates/do-service/keys.nix +++ b/templates/do-service/keys.nix @@ -1,16 +1,22 @@ rec { keys = { - operator = - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYREPLACEME000000000000000000000"; - ci = - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYREPLACEME111111111111111111111"; - host = - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYREPLACEME222222222222222222222"; + operator = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYREPLACEME000000000000000000000"; + ci = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYREPLACEME111111111111111111111"; + host = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYREPLACEME222222222222222222222"; }; roles = with keys; { - infra = [ operator ci ]; - service = [ host operator ]; - ssh = [ operator ci ]; + infra = [ + operator + ci + ]; + service = [ + host + operator + ]; + ssh = [ + operator + ci + ]; }; } diff --git a/templates/do-service/os.nix b/templates/do-service/os.nix index 1c09967..459949d 100644 --- a/templates/do-service/os.nix +++ b/templates/do-service/os.nix @@ -1,11 +1,14 @@ -{ pkgs, lib, ... }: +{ lib, ... }: -let inherit (import ./keys.nix) roles; -in { +let + inherit (import ./keys.nix) roles; +in +{ omnix.disko.enable = true; omnix.digitalocean.enable = true; omnix.base = { enable = true; + stateVersion = "24.11"; sshKeys = roles.ssh; }; omnix.storage = { @@ -26,11 +29,15 @@ in { enable = true; virtualHosts.default = { default = true; - listen = [{ - addr = "0.0.0.0"; - port = 80; - }]; - locations."/api/" = { proxyPass = "http://127.0.0.1:8000/"; }; + listen = [ + { + addr = "0.0.0.0"; + port = 80; + } + ]; + locations."/api/" = { + proxyPass = "http://127.0.0.1:8000/"; + }; }; }; } diff --git a/templates/do-service/services.nix b/templates/do-service/services.nix index a8fb7ee..61d0556 100644 --- a/templates/do-service/services.nix +++ b/templates/do-service/services.nix @@ -1,5 +1,5 @@ { my-service.enabled = true; my-service.bin = "my-service"; - my-service.dataDir = "/mnt/data/prod"; + my-service.dataDir = "/mnt/data/my-service"; } From efd40933c1435687b22474f248a984688f42a63f Mon Sep 17 00:00:00 2001 From: 0xgleb Date: Sat, 21 Mar 2026 13:01:51 +0700 Subject: [PATCH 2/2] feat: provide more functionality --- README.md | 151 +++++++++++++++++++++++++++++++++--- flake.nix | 2 + lib/deploy.nix | 25 +++++- modules/static-sites.nix | 69 ++++++++++++++++ templates/do-service/os.nix | 2 +- 5 files changed, 235 insertions(+), 14 deletions(-) create mode 100644 modules/static-sites.nix diff --git a/README.md b/README.md index f76717c..b1f4f34 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ secrets. ```bash mkdir my-service && cd my-service nix flake init -t github:data-cartel/omnix#do-service -# Edit TODOs in flake.nix, os.nix, services.nix, keys.nix +# Fill in keys.nix with your SSH public keys # Set up terraform: nix run .#tfEditVars # Provision: nix run .#tfInit && nix run .#tfPlan && nix run .#tfApply # Bootstrap NixOS: nix run .#bootstrap @@ -21,16 +21,91 @@ nix flake init -t github:data-cartel/omnix#do-service Each module is independently usable under the `omnix.*` namespace: -| Module | Option prefix | Purpose | -| -------------- | ---------------------- | -------------------------------------------- | -| `disko` | `omnix.disko.*` | GPT disk layout (boot + EFI + root) | -| `digitalocean` | `omnix.digitalocean.*` | Cloud-init, QEMU guest, GRUB EFI | -| `base` | `omnix.base.*` | SSH hardening, nix GC, flakes, base packages | -| `storage` | `omnix.storage.*` | DO block storage volume mount | -| `services` | `omnix.services.*` | Systemd service generation with marker files | -| `firewall` | `omnix.firewall.*` | TCP port allowlist (SSH always included) | +| Module | Option prefix | Purpose | +| -------------- | ----------------------- | --------------------------------------------- | +| `disko` | `omnix.disko.*` | GPT disk layout (boot + EFI + root) | +| `digitalocean` | `omnix.digitalocean.*` | Cloud-init, QEMU guest, GRUB EFI | +| `base` | `omnix.base.*` | SSH hardening, nix GC, flakes, base packages | +| `storage` | `omnix.storage.*` | DO block storage volume mount | +| `services` | `omnix.services.*` | Systemd service generation with marker files | +| `staticSites` | `omnix.staticSites.*` | Nginx vhosts via symlink swap (no rebuild) | +| `firewall` | `omnix.firewall.*` | TCP port allowlist (SSH always included) | +| `acme` | `omnix.acme.*` | Let's Encrypt TLS certificates | -Use `omnix.nixosModules.default` to import all omnix modules plus upstream disko and ragenix modules at once. +`omnix.nixosModules.default` imports all omnix modules plus upstream disko and +ragenix modules. For most projects this is all you need. + +### Static Sites + +Static sites (frontends, docs) are deployed independently from the NixOS system +config. Nginx points at stable symlink paths (`/var/lib/sites/`), and +deploy-rs profiles swap the symlink to the new build then reload nginx. This +means: + +- System deploys never rebuild nginx config for frontend changes +- Prod and staging can serve different frontend builds +- Frontend deploys are fast (symlink swap + nginx reload, no system activation) + +```nix +# os.nix -- NixOS config (sets up nginx, never changes for frontend updates) +omnix.staticSites.definitions = { + prod = { + port = 80; + isDefault = true; + extraLocations = { + "/api/" = { proxyPass = "http://127.0.0.1:8000/"; }; + }; + }; + staging = { + port = 8080; + extraLocations = { + "/api/" = { proxyPass = "http://127.0.0.1:8001/"; }; + }; + }; +}; + +# flake.nix -- deploy config (tells deploy-rs which package to deploy) +deployConfig = omnix.lib.mkDeploy { + inherit self services; + nodeName = "my-service"; + package = self.packages.x86_64-linux.my-service; + staticSites = { + prod = { enabled = true; package = self.packages.x86_64-linux.frontend; }; + staging = { enabled = true; package = self.packages.x86_64-linux.frontend; }; + }; +}; +``` + +Deploy a specific frontend: `nix run .#deployService -- prod` + +### Services + +Backend services use the same deploy-rs profile pattern but with systemd +services instead of symlinks. Each service gets: + +- A systemd unit that only starts via deploy-rs (marker file gate) +- A per-service nix profile at `/nix/var/nix/profiles/per-service/` +- Automatic tmpfiles rules for data and log directories +- Optional logrotate configuration + +```nix +# os.nix +omnix.services = { + project = "my-service"; + user = "my-service"; + group = "my-group"; + dynamicUser = false; + configDir = ./config; + definitions = { + my-service = { + enabled = true; + bin = "my-service"; + dataDir = "/mnt/data/prod"; + logDir = "/mnt/data/prod/logs"; + }; + }; +}; +``` ## Library Functions @@ -41,20 +116,69 @@ Use `omnix.nixosModules.default` to import all omnix modules plus upstream disko | `lib.mkBootstrap` | nixos-anywhere provisioning + host key update | | `lib.mkGitHooks` | Pre-commit hooks (nixfmt, deadnix, taplo, optional rustfmt) | +### mkDeploy + +Generates deploy-rs node config and CLI wrappers. Supports both backend +services and static sites. + +```nix +deployConfig = omnix.lib.mkDeploy { + inherit self; + nodeName = "my-service"; # deploy-rs node name + services = import ./services.nix; # backend service definitions + package = self.packages.x86_64-linux.my-service; # backend binary package + + # Optional: static sites deployed via symlink swap + staticSites = { + prod = { enabled = true; package = self.packages.x86_64-linux.frontend; }; + }; + + # Optional: override target architecture (default: x86_64-linux) + targetSystem = "x86_64-linux"; +}; + +# Use the config +deploy = deployConfig.config; + +# Get CLI wrappers +deployPkgs = deployConfig.wrappers { + inherit pkgs infraPkgs; + localSystem = system; +}; +# deployPkgs.deployNixos, deployPkgs.deployService, deployPkgs.deployAll +``` + +### mkTerraform + +Generates terraform wrapper scripts with age-encrypted state and variables. +All scripts accept `-i ` for the SSH key used to decrypt. + +```nix +infraPkgs = omnix.lib.mkTerraform { + inherit pkgs system; + keysFile = ./keys.nix; # keys + roles for encryption + ragenixPkg = omnix.inputs.ragenix.packages.${system}.default; # optional + secretsRules = ./config/secrets.nix; # optional, for ragenix rekey +}; +# infraPkgs.tfInit, tfPlan, tfApply, tfDestroy, tfImport, tfEditVars, tfRekey +# infraPkgs.rekey, remote, resolveIp +``` + ## Using as a Flake Input ```nix { inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; omnix.url = "github:data-cartel/omnix"; - nixpkgs.follows = "omnix/nixpkgs"; + omnix.inputs.nixpkgs.follows = "nixpkgs"; }; outputs = { self, omnix, nixpkgs, ... }: { nixosConfigurations.myservice = nixpkgs.lib.nixosSystem { system = "x86_64-linux"; modules = [ - omnix.nixosModules.default # all omnix + upstream disko/ragenix modules + omnix.nixosModules.default ./os.nix ]; }; @@ -62,5 +186,8 @@ Use `omnix.nixosModules.default` to import all omnix modules plus upstream disko } ``` +The consumer owns its nixpkgs pin and makes omnix follow it -- not the other +way around. + See [SPEC.md](./SPEC.md) for design details and [ROADMAP.md](./ROADMAP.md) for planned work. diff --git a/flake.nix b/flake.nix index b41d4e6..c49fd91 100644 --- a/flake.nix +++ b/flake.nix @@ -41,6 +41,7 @@ storage = import ./modules/storage.nix; services = import ./modules/services.nix; firewall = import ./modules/firewall.nix; + staticSites = import ./modules/static-sites.nix; acme = import ./modules/acme.nix; # Convenience: all modules at once (includes upstream disko + ragenix) @@ -53,6 +54,7 @@ self.nixosModules.base self.nixosModules.storage self.nixosModules.services + self.nixosModules.staticSites self.nixosModules.firewall self.nixosModules.acme ]; diff --git a/lib/deploy.nix b/lib/deploy.nix index 31485ac..a9f6507 100644 --- a/lib/deploy.nix +++ b/lib/deploy.nix @@ -5,6 +5,7 @@ nodeName, services, package, + staticSites ? { }, nixosConfig ? null, targetSystem ? "x86_64-linux", }: @@ -14,7 +15,10 @@ let inherit (deploy-rs.lib.${system}) activate; profileBase = "/nix/var/nix/profiles/per-service"; + siteBase = "/var/lib/sites"; + enabledServices = builtins.filter (name: services.${name}.enabled) (builtins.attrNames services); + enabledSites = builtins.filter (name: staticSites.${name}.enabled) (builtins.attrNames staticSites); mkServiceProfile = name: @@ -36,6 +40,16 @@ let profilePath = "${profileBase}/${name}"; }; + mkSiteProfile = + name: sitePackage: + activate.custom sitePackage ( + builtins.concatStringsSep " && " [ + "mkdir -p ${siteBase}" + "ln -sfn ${sitePackage} ${siteBase}/${name}" + "systemctl reload nginx || systemctl restart nginx" + ] + ); + in { config = { @@ -44,7 +58,7 @@ in sshUser = "root"; user = "root"; - profilesOrder = [ "system" ] ++ enabledServices; + profilesOrder = [ "system" ] ++ enabledServices ++ enabledSites; profiles = { system.path = @@ -58,6 +72,15 @@ in inherit name; value = mkProfile name; }) enabledServices + ) + // builtins.listToAttrs ( + map (name: { + inherit name; + value = { + path = mkSiteProfile name staticSites.${name}.package; + profilePath = "${profileBase}/${name}"; + }; + }) enabledSites ); }; }; diff --git a/modules/static-sites.nix b/modules/static-sites.nix new file mode 100644 index 0000000..dedf271 --- /dev/null +++ b/modules/static-sites.nix @@ -0,0 +1,69 @@ +{ + lib, + config, + ... +}: + +let + cfg = config.omnix.staticSites; + + enabledSites = lib.filterAttrs (_: v: v.enabled) cfg.definitions; + + siteBase = "/var/lib/sites"; +in +{ + options.omnix.staticSites = { + definitions = lib.mkOption { + type = lib.types.attrsOf ( + lib.types.submodule { + options = { + enabled = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether this static site is deployed and managed"; + }; + port = lib.mkOption { + type = lib.types.port; + description = "Port nginx listens on for this site"; + }; + isDefault = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether this is the default nginx vhost"; + }; + extraLocations = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + description = "Additional nginx location blocks (e.g. /api/ proxy)"; + }; + }; + } + ); + default = { }; + description = "Static site definitions deployed via symlink swap"; + }; + }; + + config = lib.mkIf (enabledSites != { }) { + services.nginx.enable = true; + + services.nginx.virtualHosts = lib.mapAttrs (name: siteCfg: { + default = siteCfg.isDefault; + listen = [ + { + addr = "0.0.0.0"; + port = siteCfg.port; + } + ]; + root = "${siteBase}/${name}"; + locations = { + "/".tryFiles = "$uri $uri/ /index.html"; + } + // siteCfg.extraLocations; + }) enabledSites; + + systemd.tmpfiles.rules = map (name: "d ${siteBase}/${name} 0755 root root -") ( + builtins.attrNames enabledSites + ); + }; +} diff --git a/templates/do-service/os.nix b/templates/do-service/os.nix index 459949d..80a49e5 100644 --- a/templates/do-service/os.nix +++ b/templates/do-service/os.nix @@ -8,7 +8,7 @@ in omnix.digitalocean.enable = true; omnix.base = { enable = true; - stateVersion = "24.11"; + stateVersion = "25.11"; sshKeys = roles.ssh; }; omnix.storage = {