diff --git a/.github/actions/setup-nix/action.yaml b/.github/actions/setup-nix/action.yaml deleted file mode 100644 index 498014c46..000000000 --- a/.github/actions/setup-nix/action.yaml +++ /dev/null @@ -1,75 +0,0 @@ -name: "Setup Nix with Lix & Caches" -description: "Setup Nix with Lix & Caches" - -inputs: - root: - description: "Path to the root of the nix project (where flake.nix is)" - required: false - default: ${{ github.workspace }} - arch: - description: "What architecture to install for" - required: false - default: "x86_64-linux" - TS_OAUTH_CLIENT_ID: - description: "Tailscale OAuth Client ID" - required: false - TS_OAUTH_SECRET: - description: "Tailscale OAuth Secret" - required: false - ATTIC_ENDPOINT: - description: "Attic endpoint" - required: false - ATTIC_CACHE: - description: "Attic cache" - required: false - ATTIC_TOKEN: - description: "Attic token" - required: false - -runs: - using: composite - steps: - - uses: samueldr/lix-gha-installer-action@latest - with: - extra_nix_config: | - accept-flake-config = true - experimental-features = nix-command flakes pipe-operator - substituters = https://cache.nixos.org https://cache.racci.dev/global https://nix-community.cachix.org https://chaotic-nyx.cachix.org/ - trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= global:OKNSxDYKp8Q8Tr5/5Bc7CYVSfvdFQV0dMhpG0fOAG0k= nix-community.cachix.org-1:mB9FSh9qf2dCimDSUo8Zy7bkq5CX+/rkCWyvRCYg3Fs= chaotic-nyx.cachix.org-1:HfnXSw4pj95iI/n17rIDy40agHj12WfF+Gqk6SonIT8= - - # Setup TS so we can access the cache - - name: Tailscale - uses: tailscale/github-action@v4 - if: ${{ !env.ACT && inputs.TS_OAUTH_CLIENT_ID && inputs.TS_OAUTH_SECRET }} - with: - oauth-client-id: ${{ inputs.TS_OAUTH_CLIENT_ID }} - oauth-secret: ${{ inputs.TS_OAUTH_SECRET }} - tags: tag:ci - use-cache: true - - # Taken from https://github.com/ryanccn/attic-action/issues/28#issuecomment-2743233082 - - name: Restore attic-client binary - uses: actions/cache@v5 - id: attic-cache - with: - path: /tmp/attic-client - key: attic-client-${{runner.os}}-${{ hashFiles(format('{0}/flake.lock', inputs.root)) }} - - name: Make attic-client binary cacheable - if: steps.attic-cache.outputs.cache-hit != 'true' - shell: bash - run: nix copy --to file:///tmp/attic-client --inputs-from ${{inputs.root}} nixpkgs#attic-client - - name: Install attic-client binary - shell: bash - run: | - # https://git.lix.systems/lix-project/lix/issues/751: - find /tmp/attic-client -type f -print0 | xargs -0 touch -d '@0' - - nix copy --inputs-from ${{inputs.root}} --from file:///tmp/attic-client nixpkgs#attic-client - - name: Setup Attic cache - uses: ryanccn/attic-action@v0 - if: ${{ !env.ACT && inputs.ATTIC_ENDPOINT && inputs.ATTIC_CACHE && inputs.ATTIC_TOKEN }} - with: - inputs-from: ${{ inputs.root }} - endpoint: ${{ inputs.ATTIC_ENDPOINT }} - cache: ${{ inputs.ATTIC_CACHE }} - token: ${{ inputs.ATTIC_TOKEN }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 16e3e2fb6..000000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,120 +0,0 @@ -name: Build and test flake outputs - -on: - pull_request: - push: - branches: [master] - workflow_dispatch: - workflow_call: - -permissions: - contents: read - -jobs: - generate-matrix: - runs-on: self-hosted - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Generate Matrix - id: set-matrix - # Do not update until https://github.com/workflow/nix-shell-action/issues/364 is fixed. - uses: workflow/nix-shell-action@v4.0.0 - with: - flakes: nixpkgs#jq,nixpkgs#nushell - script: | - SCRIPT_ARGS="--json" - if [[ -n "${{ github.event.before }}" ]]; then - SCRIPT_ARGS+=" --range ${{ github.event.before }}..${{ github.sha }}" - else - SCRIPT_ARGS+=" $(find . -type f | sed 's|^\./||' | grep -v '^\.git/' | tr '\n' ' ')" - fi - - CHANGED_HOSTS=$(./flake/ci/detect-affected-outputs.nu nixosConfigurations $SCRIPT_ARGS | jq 'to_entries | map("nixosConfigurations." + .key + ".config.system.build.toplevel")' ) - CHANGED_USERS=$(./flake/ci/detect-affected-outputs.nu homeConfigurations $SCRIPT_ARGS | jq 'to_entries | map("homeConfigurations." + .key + ".activationPackage")' ) - - EXTRA_SPACE='["nixmi"]' - NO_BUILD='["nixmi"]' - { - echo 'matrix</dev/null || echo "[]" - echo EOF - } >> "$GITHUB_OUTPUT" - - build-outputs: - needs: generate-matrix - if: needs.generate-matrix.outputs.matrix != '[]' - runs-on: ubuntu-latest - concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ matrix.attr }}-${{ matrix.system }} - cancel-in-progress: true - strategy: - fail-fast: false - matrix: - include: ${{ fromJSON(needs.generate-matrix.outputs.matrix) }} - - name: Build ${{ matrix.attr }} on ${{ matrix.system }} - - steps: - - uses: actions/checkout@v5 - - - name: Free Disk Space - if: ${{ matrix.extraSpace && !env.ACT }} - uses: jlumbroso/free-disk-space@main - with: - swap-storage: true - tool-cache: true - - - name: Create Dir for Mounting more Disk Space ❄ - if: ${{ matrix.extraSpace && !env.ACT }} - run: sudo mkdir /nix - - - name: Maximize Disk Space - if: ${{ matrix.extraSpace && !env.ACT }} - uses: easimon/maximize-build-space@v10 - with: - build-mount-path: /nix - remove-dotnet: true - remove-android: true - remove-haskell: true - remove-codeql: true - remove-docker-images: true - temp-reserve-mb: 100 - swap-size-mb: 4096 - root-reserve-mb: 1024 - - - name: Ensure correct permissions for /nix - if: ${{ matrix.extraSpace && !env.ACT }} - run: sudo chown -R root:root /nix - - - uses: ./.github/actions/setup-nix - with: - arch: ${{ matrix.system }} - TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }} - TS_OAUTH_SECRET: ${{ secrets.TS_OAUTH_SECRET }} - ATTIC_ENDPOINT: ${{ secrets.ATTIC_ENDPOINT }} - ATTIC_CACHE: ${{ secrets.ATTIC_CACHE }} - ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} - - - name: Evaluate ${{ matrix.attr }} - env: - _system: ${{ matrix.system }} - run: nix eval --accept-flake-config .#${{ matrix.attr }} - - - name: Build ${{ matrix.attr }} - if: ${{ !matrix.noBuild }} - env: - _system: ${{ matrix.system }} - run: nix build --accept-flake-config .#${{ matrix.attr }} diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 7471d06d9..f5d5a003f 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,17 +1,13 @@ name: Documentation - on: push: branches: [master] - concurrency: cancel-in-progress: true group: pages - defaults: run: shell: bash - jobs: docs: runs-on: self-hosted @@ -23,18 +19,14 @@ jobs: environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} - steps: - uses: actions/checkout@v6 - - name: Build Documentation run: nix build .#docs - - name: Upload Pages Artifact uses: actions/upload-pages-artifact@v5 with: path: result - - name: Deploy Pages id: deploy uses: actions/deploy-pages@v5 diff --git a/.github/workflows/packages.yaml b/.github/workflows/packages.yaml deleted file mode 100644 index f633816e4..000000000 --- a/.github/workflows/packages.yaml +++ /dev/null @@ -1,188 +0,0 @@ -name: Build packages - -on: - pull_request: - paths: - - "pkgs/**" - - "flake.nix" - - "flake.lock" - - ".github/workflows/packages.yaml" - push: - branches: [master] - paths: - - "pkgs/**" - - "flake.nix" - - "flake.lock" - - ".github/workflows/packages.yaml" - workflow_dispatch: - -permissions: - contents: read - -jobs: - discover-packages: - name: Discover Packages - runs-on: self-hosted - outputs: - matrix: ${{ steps.filtered-packages.outputs.matrix }} - steps: - - uses: actions/checkout@v5 - - - name: Get packages from flake - id: get-packages - run: | - FLAKE_JSON=$(nix flake show --no-pure-eval --json) - ARCHS=$(echo "$FLAKE_JSON" | jq -r '.packages | keys[]') - INCLUDE_JSON='[]' - for ARCH in $ARCHS; do - PKGS=$(echo "$FLAKE_JSON" | jq -r --arg arch "$ARCH" '.packages[$arch] | keys[] | select(startswith("devenv-") | not)') - for PKG in $PKGS; do - BROKEN=$(nix eval --json --system "$ARCH" ".#${PKG}.meta.broken" 2>/dev/null) - if [ "$BROKEN" != "true" ]; then - INCLUDE_JSON=$(echo "$INCLUDE_JSON" | jq --arg pkg "$PKG" --arg arch "$ARCH" '. + [{package:$pkg, arch:$arch}]') - else - echo "Skipping broken package $PKG on $ARCH" - fi - done - done - if [ "$(echo "$INCLUDE_JSON" | jq 'length')" -eq 0 ]; then - MATRIX_JSON='[]' - else - MATRIX_JSON=$(jq -n --argjson include "$INCLUDE_JSON" '{include:$include}') - fi - echo "Calculated matrix: $MATRIX_JSON" - { - echo 'matrix<> "$GITHUB_OUTPUT" - - - name: Filter to changed packages - id: filtered-packages - uses: workflow/nix-shell-action@v4.0.0 - with: - packages: gawk - script: | - set -euo pipefail - - git fetch origin ${{ github.event.before }} --depth=1 - - if [ "${{ github.event_name }}" != "push" ]; then - echo "Not a push event (${{ github.event_name }}); keeping full matrix." - { - echo 'matrix<> "$GITHUB_OUTPUT" - exit 0 - fi - - # If flake.lock changed, check if the nixpkgs input changed; if so, keep full matrix - if git diff --name-only ${{ github.event.before }} ${{ github.sha }} -- flake.lock | grep -q 'flake.lock'; then - echo "flake.lock changed, checking nixpkgs input change..." - # Try to obtain previous flake.lock; if unavailable, assume nixpkgs changed - if git show ${{ github.event.before }}:flake.lock > /tmp/old.lock 2>/dev/null; then - : - else - echo "Previous flake.lock not available; assuming nixpkgs changed. Keeping full matrix." - { - echo 'matrix<> "$GITHUB_OUTPUT" - exit 0 - fi - - OLD_NIXPKGS=$(jq -r ' - (.root) as $r - | (.nodes[$r].inputs.nixpkgs // "nixpkgs") as $n0 - | ($n0 | if type=="string" then . else (.[0] // "nixpkgs") end) as $n - | (.nodes[$n].locked // {}) - | [(.rev // "none"), (.narHash // "none")] | join("|") - ' /tmp/old.lock 2>/dev/null || echo "none|none") - - NEW_NIXPKGS=$(jq -r ' - (.root) as $r - | (.nodes[$r].inputs.nixpkgs // "nixpkgs") as $n0 - | ($n0 | if type=="string" then . else (.[0] // "nixpkgs") end) as $n - | (.nodes[$n].locked // {}) - | [(.rev // "none"), (.narHash // "none")] | join("|") - ' flake.lock 2>/dev/null || echo "none|none") - - if [ "$OLD_NIXPKGS" != "$NEW_NIXPKGS" ]; then - echo "nixpkgs input changed ($OLD_NIXPKGS -> $NEW_NIXPKGS). Keeping full matrix." - { - echo 'matrix<> "$GITHUB_OUTPUT" - exit 0 - else - echo "nixpkgs input did not change." - fi - fi - - CHANGED_FILES=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} -- pkgs/) - if [ -z "$CHANGED_FILES" ]; then - echo "No changes in pkgs, skipping all builds." - echo "matrix=[]" >> "$GITHUB_OUTPUT" - exit 0 - fi - - CHANGED_PKGS=$( - echo "$CHANGED_FILES" \ - | tr ' ' '\n' \ - | grep -E '^pkgs/' \ - | sed 's|^pkgs/||' \ - | awk -F'/' '{ - n=split($0,a,"/"); - if (a[n]=="default.nix") { - if (n>1) print a[n-1] - } else { - fname=a[n]; sub(/\.nix$/,"",fname); print fname - } - }' \ - | sort -u - ) - echo "Changed packages:" - echo "$CHANGED_PKGS" - FILTERED_JSON='[]' - for PKG in $CHANGED_PKGS; do - MATCHING_ENTRIES=$(echo "${{ steps.get-packages.outputs.matrix }}" | jq --arg pkg "$PKG" '.include[] | select(.package == $pkg)') - if [ -n "$MATCHING_ENTRIES" ]; then - FILTERED_JSON=$(echo "$FILTERED_JSON" | jq --argjson entries "$MATCHING_ENTRIES" '. + $entries') - fi - done - if [ "$(echo "$FILTERED_JSON" | jq 'length')" -eq 0 ]; then - MATRIX_JSON='[]' - else - MATRIX_JSON=$(jq -n --argjson include "$FILTERED_JSON" '{include:$include}') - fi - echo "Filtered matrix: $MATRIX_JSON" - { - echo 'matrix<> "$GITHUB_OUTPUT" - - build-packages: - needs: discover-packages - if: ${{ needs.discover-packages.outputs.matrix != '[]' }} - strategy: - matrix: ${{ fromJson(needs.discover-packages.outputs.matrix) }} - fail-fast: false - runs-on: ${{ matrix.arch == 'aarch64-linux' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} - name: Build package ${{ matrix.package }} on ${{ matrix.arch }} - steps: - - uses: actions/checkout@v5 - - uses: ./.github/actions/setup-nix - with: - arch: ${{ matrix.arch }} - TS_OAUTH_CLIENT_ID: ${{ secrets.TS_OAUTH_CLIENT_ID }} - TS_OAUTH_SECRET: ${{ secrets.TS_OAUTH_SECRET }} - ATTIC_ENDPOINT: ${{ secrets.ATTIC_ENDPOINT }} - ATTIC_CACHE: ${{ secrets.ATTIC_CACHE }} - ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN }} - - - name: Build Package - run: nix build .#${{ matrix.package }} -L --show-trace --no-link --print-out-paths diff --git a/.github/workflows/update-packages.yaml b/.github/workflows/update-packages.yaml deleted file mode 100644 index 7c426efd1..000000000 --- a/.github/workflows/update-packages.yaml +++ /dev/null @@ -1,117 +0,0 @@ -name: Update Packages - -on: - schedule: - - cron: "0 17 * * */3" - workflow_dispatch: - inputs: - base: - description: "Base branch to open PRs against" - required: false - default: "master" - -permissions: - contents: write - pull-requests: write - -env: - BASE_BRANCH: ${{ github.event_name == 'workflow_dispatch' && inputs.base || 'master' }} - -jobs: - discover: - name: Discover packages - runs-on: self-hosted - outputs: - matrix: ${{ steps.build-matrix.outputs.matrix }} - count: ${{ steps.build-matrix.outputs.count }} - steps: - - name: Checkout base branch - uses: actions/checkout@v5 - with: - fetch-depth: 0 - ref: ${{ env.BASE_BRANCH }} - - - name: Build matrix - id: build-matrix - shell: bash - run: | - set -euo pipefail - - echo "::group::Collect package names from flake" - FLAKE_JSON=$(nix flake show --no-pure-eval --json) - UNIQUE_PKGS=$(echo "$FLAKE_JSON" \ - | jq -r '.packages | to_entries[] | .value | keys[]' \ - | grep -v '^devenv-' \ - | sort -u) - - if [ -z "$UNIQUE_PKGS" ]; then - echo "No packages found." - echo "matrix=[]" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "::endgroup::" - - INCLUDE_JSON='[]' - - echo "::group::Filter for passthru.updateScript" - for PKG in $UNIQUE_PKGS; do - UPDATE_SCRIPT=$(nix eval --json --no-pure-eval ".#${PKG}.passthru.updateScript" 2>/dev/null || echo "null") - if [ "$UPDATE_SCRIPT" != "null" ]; then - INCLUDE_JSON=$(echo "$INCLUDE_JSON" | jq --arg pkg "$PKG" '. + [{package:$pkg}]') - echo "Include: $PKG" - else - echo "Skip (no updateScript): $PKG" - fi - done - echo "::endgroup::" - - MATRIX=$(jq -c -n --argjson include "$INCLUDE_JSON" '{include:$include}') - echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" - - update: - name: Update ${{ matrix.package }} - needs: discover - if: needs.discover.outputs.matrix != '[]' - runs-on: self-hosted - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.discover.outputs.matrix) }} - concurrency: - group: pkg-update-${{ matrix.package }} - cancel-in-progress: true - env: - PACKAGE: ${{ matrix.package }} - BRANCH: chore/update-pkg-${{ matrix.package }} - steps: - - name: Checkout base branch - uses: actions/checkout@v5 - with: - fetch-depth: 0 - ref: ${{ env.BASE_BRANCH }} - - - name: Run nix-update - id: update - shell: bash - run: | - set -euo pipefail - - echo "Updating ${PACKAGE}" - export NIX_PATH=nixpkgs=channel:nixos-unstable - nix develop --no-pure-eval --accept-flake-config --command nix-update --flake "${PACKAGE}" --use-update-script --commit || { - echo "nix-update failed for ${PACKAGE}" - exit 1 - } - - PR_TITLE=$(git log -1 --pretty=%s) - echo "PR_TITLE=${PR_TITLE}" >> "$GITHUB_OUTPUT" - - - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 - with: - token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ env.BRANCH }} - base: ${{ env.BASE_BRANCH }} - labels: dependencies, automated, merge-queue - reviewers: DaRacci - title: ${{ steps.update.outputs.PR_TITLE }} - body: This PR was created automatically by the [Update Packages](./.github/workflows/update-packages.yaml) workflow. diff --git a/.gitignore b/.gitignore index a26b999c3..04a902fde 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -result +/result* .direnv .jj diff --git a/.mergify.yml b/.mergify.yml index 7c19c6c44..f89201c90 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -47,8 +47,7 @@ merge_protections: if: - base = master success_conditions: - - "title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\\(.+\\\ - ))?:" + - "title ~= ^(fix|feat|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\\(.+\\))?:" - name: Do not merge outdated PRs description: Make sure PRs are almost up to date before merging if: diff --git a/.opencode/skills/testing/SKILL.md b/.opencode/skills/testing/SKILL.md index e0b7093bd..96ce2e63f 100644 --- a/.opencode/skills/testing/SKILL.md +++ b/.opencode/skills/testing/SKILL.md @@ -11,20 +11,35 @@ After making changes you **must always** evaluate and test them. ## Finding Affected Configurations -Use module-graph script to find which hosts and homes are affected by changed files: +Use module-graph script with `--since` to find only hosts and homes affected by files changed since commit or ref. Add `--refine` to further narrow results for files under `modules/nixos/` and `modules/home-manager/` when module exposes `options..enable`: ```bash -./flake/dev/scripts/module-graph.nu +SILENT=true nix run .#module-graph -- --since --refine --report ``` -This outputs JSON showing which configs use each file: +This will output a report similar to the following, showing which hosts and homes are affected by the changes: -```json -{ - "file": "modules/nixos/services/tailscale.nix", - "hosts": ["nixdev", "nixmi", "nixcloud"], - "homes": [] -} +```text +************************************************************* + NixOS HOSTS +************************************************************* + +Priority: HIGH + - nixai [5 modules] + - nixserv [4 modules] + +Priority: MEDIUM + - nixdev [3 modules] + +Priority: LOW + - nixio [1 modules] + +************************************************************* + HOME-MANAGER CONFIGS +************************************************************* + +Priority: LOW + - racci [1 modules] ``` ## Minimum Test Requirements @@ -61,10 +76,10 @@ nix flake check --override-input devenv-root "file+file://$PWD/.devenv/root" 1. Make changes to `modules/nixos/services/tailscale.nix` -2. Run module-graph to find affected configs: +2. Run module-graph with `--since` to find affected configs from your change range. Add `--refine` when you want enable-option based narrowing: ```bash - ./flake/dev/scripts/module-graph.nu | jq '.[] | select(.file | contains("tailscale"))' + nix run .#module-graph -- --since origin/main --refine | jq '.[] | select(.file | contains("tailscale"))' ``` 3. Pick one affected host and build it: diff --git a/.woodpecker/check-upstream-todos.yaml b/.woodpecker/check-upstream-todos.yaml deleted file mode 100644 index 7a4ee66aa..000000000 --- a/.woodpecker/check-upstream-todos.yaml +++ /dev/null @@ -1,15 +0,0 @@ -labels: - platform: linux/amd64 - -when: - - event: manual - - event: [push, pull_request] - branch: master - - event: cron - cron: weekly - -steps: - - name: Check upstream TODOs - image: registry.racci.dev/lix-woodpecker:latest - pull: true - commands: nix run .#check-upstream-todos diff --git a/.woodpecker/check.yaml b/.woodpecker/check.yaml deleted file mode 100644 index a9e492e76..000000000 --- a/.woodpecker/check.yaml +++ /dev/null @@ -1,23 +0,0 @@ -labels: - platform: linux/amd64 - -when: - - event: manual - - event: [push, pull_request] - branch: master - -steps: - - name: Build flake checks - image: registry.racci.dev/lix-woodpecker:latest - pull: true - environment: - binary_cache_token: - from_secret: binary_cache_token - commands: | - nix run .#setup-attic -- --watch - nix run .#archive-flakes -- "." "flake/ci" "flake/dev" - nix run nixpkgs#nix-fast-build -- --skip-cached --no-nom --flake ".#checks.$(nix eval --raw --impure --expr builtins.currentSystem)" - - - name: Run flake checks - image: registry.racci.dev/lix-woodpecker:latest - commands: nix flake check diff --git a/.woodpecker/checks.yaml b/.woodpecker/checks.yaml new file mode 100644 index 000000000..7b08a0a2f --- /dev/null +++ b/.woodpecker/checks.yaml @@ -0,0 +1,154 @@ +variables: + - &env + BASE_BRANCH: &base_branch master + BRANCH: &branch chore/update-ci-matrix + - &when_push + event: [push] + branch: *base_branch + - &when_change + event: [push, pull_request] + branch: *base_branch + - &when_cron + event: cron + cron: weekly + - &when_manual + event: manual + - &when_all + - *when_manual + - *when_change + - *when_cron + - &when_ci + - *when_manual + - *when_change + +labels: + platform: linux/amd64 + +steps: + - name: build-flake-checks + image: registry.racci.dev/lix-woodpecker + pull: true + environment: + BINARY_CACHE_TOKEN: + from_secret: BINARY_CACHE_TOKEN + when: *when_ci + depends_on: [] # enable parallel steps + commands: | + nix run .#setup-attic -- --watch + nix run .#archive-flakes -- "." "flake/ci" "flake/dev" + nix run nixpkgs#nix-fast-build -- --skip-cached --no-nom --flake ".#checks.$(nix eval --raw --impure --expr builtins.currentSystem)" + + - name: run-flake-checks + image: registry.racci.dev/lix-woodpecker + pull: true + when: *when_ci + depends_on: ["build-flake-checks"] + commands: nix flake check --override-input devenv-root "file+file://$PWD/.devenv/root" + + - name: check-upstream-todos + image: registry.racci.dev/lix-woodpecker + pull: true + when: *when_all + commands: nix run .#check-upstream-todos + + - name: run-flake-checker + image: registry.racci.dev/lix-woodpecker + pull: true + when: *when_all + commands: nix run nixpkgs#flake-checker + + - name: run-gitguardian + image: gitguardian/ggshield + pull: true + environment: + GITGUARDIAN_API_KEY: + from_secret: GITGUARDIAN_API_KEY + when: *when_ci + commands: | + if [ -n "$${CI_PREV_COMMIT_SHA:-}" ]; then + ggshield secret scan commit-range "$${CI_PREV_COMMIT_SHA}..HEAD" + else + ggshield secret scan repo . + fi + + - name: matrix-ci-hosts.yaml + image: registry.racci.dev/lix-woodpecker + pull: true + when: *when_ci + commands: nix run ".#update-matrix" -- .woodpecker/ci-hosts.yaml HOST .#nixosConfigurations + + - name: matrix-ci-homes.yaml + image: registry.racci.dev/lix-woodpecker + pull: true + when: *when_ci + commands: nix run ".#update-matrix" -- .woodpecker/ci-homes.yaml HOME .#homeConfigurations + + - name: matrix-ci-packages.yaml + image: registry.racci.dev/lix-woodpecker + pull: true + when: *when_push + commands: | + nix run ".#update-matrix" -- \ + .woodpecker/ci-packages.yaml \ + PACKAGE \ + .#packages.x86_64-linux \ + 'pkgs: builtins.attrNames pkgs |> builtins.filter (name: (pkgs.${name}.passthru.discovery or true))' + + - name: matrix-update-packages.yaml + image: registry.racci.dev/lix-woodpecker + pull: true + when: *when_push + commands: | + nix run ".#update-matrix" -- \ + .woodpecker/update-packages.yaml \ + PACKAGE \ + .#packages.x86_64-linux \ + 'pkgs: builtins.attrNames pkgs |> builtins.filter (name: ((pkgs.${name}.passthru or {}) ? updateScript))' + + - name: push-matrix-updates + image: appleboy/drone-git-push + pull: true + when: *when_push + depends_on: + [ + "matrix-ci-hosts.yaml", + "matrix-ci-homes.yaml", + "matrix-ci-packages.yaml", + "matrix-update-packages.yaml", + ] + settings: + remote: git@github.com:DaRacci/nix-config.git + branch: *branch + rebase: true + commit: true + author_name: "Woodpecker CI" + author_email: "woodpecker@racci.dev" + commit_message: "chore(ci): update CI matrix entries [SKIP CI]" + ssh_key: + from_secret: GITHUB_AUTH_KEY + + - name: create-matrix-update-pr + image: registry.racci.dev/lix-woodpecker + pull: true + when: *when_push + depends_on: ["push-matrix-updates"] + environment: + <<: *env + GITHUB_TOKEN: + from_secret: GITHUB_TOKEN + commands: |- + # avoid creating PRs with no changes + git fetch --depth=1 origin "${BASE_BRANCH}" + if git diff --quiet "origin/${BASE_BRANCH}"...HEAD; then + echo "No changes detected, skipping PR creation." + exit 0 + fi + + TITLE=$(git log -1 --pretty=%s) + nix run .#create-pr -- \ + --title "$TITLE" \ + --branch "$BRANCH" \ + --base "${BASE_BRANCH}" \ + --labels "automated,merge-queue" \ + --reviewers "DaRacci" \ + --body "This PR was created automatically by the Woodpecker CI pipeline to update the CI matrix entries based on changes to the configuration files." diff --git a/.woodpecker/ci-homes.yaml b/.woodpecker/ci-homes.yaml new file mode 100644 index 000000000..1e0144ccb --- /dev/null +++ b/.woodpecker/ci-homes.yaml @@ -0,0 +1,84 @@ +variables: + trigger_paths: &trigger_paths + - .woodpecker/ci-homes.yaml + - flake.nix + - flake.lock + - flake/modules.nix + - flake/ci/** + - flake/home-manager/** + - home/** + - lib/** + - modules/home-manager/** + +labels: + platform: linux/amd64 + +when: + - event: manual + - event: push + branch: master + path: *trigger_paths + - event: pull_request + path: *trigger_paths + +clone: + - name: git + image: woodpeckerci/plugin-git + settings: + partial: false + depth: 0 + +matrix: + HOME: + - racci + +steps: + - name: decide-${HOME} + image: registry.racci.dev/lix-woodpecker + pull: true + commands: | + set -euo pipefail + + SHOULD_BUILD=false + if [ "$${CI_PREV_PIPELINE_STATUS}" = "failure" ]; then + echo "Previous pipeline failed for $${HOME}, Building to check if it was a transient issue." + SHOULD_BUILD=true + else + echo "Previous pipeline state was $${CI_PREV_PIPELINE_STATUS} for $${HOME}, checking if it is affected by changes." + if [ -n "$${CI_PREV_COMMIT_SHA:-}" ]; then + RANGE="$${CI_PREV_COMMIT_SHA}..$${CI_COMMIT_SHA}" + if ! AFFECTED=$(nix run .#detect-affected-outputs -- homeConfigurations --json --range "$${RANGE}" --outputs "$${HOME}" 2>/dev/null); then + echo "Failed to detect affected outputs; building $${HOME} to be safe." + SHOULD_BUILD=true + elif echo "$${AFFECTED}" | jq -e --arg home "$${HOME}" 'has($home)' > /dev/null 2>&1; then + echo "Home $${HOME} is affected by changes, proceeding with build" + SHOULD_BUILD=true + else + echo "Home $${HOME} is not affected by changes, skipping build" + fi + else + echo "No previous commit SHA, building..." + SHOULD_BUILD=true + fi + fi + + if [ "$${SHOULD_BUILD}" = "true" ]; then + touch .do-build + fi + + - name: build-${HOME} + image: registry.racci.dev/lix-woodpecker + pull: true + depends_on: ["decide-${HOME}"] + environment: + BINARY_CACHE_TOKEN: + from_secret: BINARY_CACHE_TOKEN + commands: |- + if [ ! -f .do-build ]; then + echo "Skipping build for ${HOME} - not affected by changes" + exit 0 + fi + + nix run .#setup-attic + nix run .#archive-flakes -- "." "flake/nixos" "flake/home-manager" + nix-fast-build --no-nom --no-link --attic-cache raccidev --skip-cached --flake ".#homeConfigurations.${HOME}.activationPackage" diff --git a/.woodpecker/ci-hosts.yaml b/.woodpecker/ci-hosts.yaml new file mode 100644 index 000000000..23c423a6b --- /dev/null +++ b/.woodpecker/ci-hosts.yaml @@ -0,0 +1,103 @@ +variables: + trigger_paths: &trigger_paths + - .woodpecker/ci-hosts.yaml + - flake.nix + - flake.lock + - flake/modules.nix + - flake/ci/** + - flake/nixos/** + - flake/home-manager/** + - home/** + - hosts/** + - lib/** + - modules/** + +labels: + platform: linux/amd64 + +when: + - event: manual + - event: push + branch: master + path: *trigger_paths + - event: pull_request + path: *trigger_paths + +clone: + - name: git + image: woodpeckerci/plugin-git + settings: + partial: false + depth: 0 + +matrix: + HOST: + - nixai + - nixarr + - nixcloud + - nixdev + - nixio + - nixmi + - nixmon + - nixserv + +steps: + - name: decide-${HOST} + image: registry.racci.dev/lix-woodpecker + pull: true + commands: | + set -euo pipefail + + SHOULD_BUILD=false + if [ "$${CI_PREV_PIPELINE_STATUS}" = "failure" ]; then + echo "Previous pipeline failed for $${HOST}, Building to check if it was a transient issue." + SHOULD_BUILD=true + else + echo "Previous pipeline state was $${CI_PREV_PIPELINE_STATUS} for $${HOST}, checking if it is affected by changes." + if [ -n "$${CI_PREV_COMMIT_SHA:-}" ]; then + RANGE="$${CI_PREV_COMMIT_SHA}..$${CI_COMMIT_SHA}" + AFFECTED=$(nix run .#detect-affected-outputs -- nixosConfigurations --json --range "$${RANGE}" --outputs "$${HOST}" 2>/dev/null) + if echo "$${AFFECTED}" | jq -e --arg host "$${HOST}" 'has($host)' > /dev/null 2>&1; then + echo "Host $${HOST} is affected by changes, proceeding with build" + SHOULD_BUILD=true + else + echo "Host $${HOST} is not affected by changes, skipping build" + fi + else + echo "No previous commit SHA, building..." + SHOULD_BUILD=true + fi + fi + + if [ "$${SHOULD_BUILD}" = "true" ]; then + touch .do-build + fi + + - name: build-${HOST} + image: registry.racci.dev/lix-woodpecker + pull: true + depends_on: ["decide-${HOST}"] + environment: + BINARY_CACHE_TOKEN: + from_secret: BINARY_CACHE_TOKEN + commands: |- + if [ ! -f .do-build ]; then + echo "Skipping build for ${HOST} - not affected by changes" + exit 0 + fi + + nix run .#setup-attic + nix run .#archive-flakes -- "." "flake/nixos" "flake/home-manager" + nix-fast-build --no-nom --attic-cache raccidev --skip-cached --flake ".#nixosConfigurations.${HOST}.config.system.build.toplevel" --out-link "result-${HOST}" + + - name: vulnerability-scan-${HOST} + image: registry.racci.dev/lix-woodpecker + pull: true + depends_on: ["build-${HOST}"] + commands: |- + if [ ! -e "result-${HOST}" ]; then + echo "Build output for ${HOST} not found, skipping vulnerability scan" + exit 0 + fi + + vulnix "result-${HOST}" diff --git a/.woodpecker/ci-packages.yaml b/.woodpecker/ci-packages.yaml new file mode 100644 index 000000000..dd02a25c3 --- /dev/null +++ b/.woodpecker/ci-packages.yaml @@ -0,0 +1,97 @@ +variables: + trigger_paths: &trigger_paths + - .woodpecker/ci-packages.yaml + - flake.nix + - flake.lock + - flake/packages.nix + - flake/ci/** + - lib/nu-lib/** + - pkgs/** + +labels: + platform: linux/amd64 + +when: + - event: manual + - event: push + branch: master + path: *trigger_paths + - event: pull_request + path: *trigger_paths + +clone: + - name: git + image: woodpeckerci/plugin-git + settings: + partial: false + depth: 0 + +matrix: + PACKAGE: + - alvr-bin + - drive-stats + - huntress + - io-guardian-client + - io-guardian-server + - lidarr-plugins + - lix-woodpecker + - mcp-sequential-thinking + - mcp-server-amazon + - monocoque + - orca-slicer-zink + - proton-mcp + - pyarlo + - pyuptimekuma + - ssh-terminal-manager + - take-control-viewer + - terminal-manager + +steps: + - name: decide-${PACKAGE} + image: registry.racci.dev/lix-woodpecker + pull: true + depends_on: [] # enable parallel steps + commands: | + set -euo pipefail + + SHOULD_BUILD=false + if [ "$${CI_PREV_PIPELINE_STATUS}" = "error" ]; then + echo "Previous pipeline failed for $${PACKAGE}, Building to check if it was a transient issue." + SHOULD_BUILD=true + else + echo "Previous pipeline state was $${CI_PREV_PIPELINE_STATUS} for $${PACKAGE}, checking if it is affected by changes." + if [ -n "$${CI_PREV_COMMIT_SHA:-}" ]; then + RANGE="$${CI_PREV_COMMIT_SHA}..$${CI_COMMIT_SHA}" + AFFECTED=$(nix run .#discover-packages -- --json "$${RANGE}" "$${PACKAGE}") + if echo "$${AFFECTED}" | jq -e --arg package "$${PACKAGE}" 'index($package)' > /dev/null 2>&1; then + echo "Package $${PACKAGE} is affected by changes, proceeding with build" + SHOULD_BUILD=true + else + echo "Package $${PACKAGE} is not affected by changes, skipping build" + fi + else + echo "No previous commit SHA, building..." + SHOULD_BUILD=true + fi + fi + + if [ "$${SHOULD_BUILD}" = "true" ]; then + touch .do-build + fi + + - name: build-${PACKAGE} + image: registry.racci.dev/lix-woodpecker + pull: true + depends_on: ["decide-${PACKAGE}"] + environment: + BINARY_CACHE_TOKEN: + from_secret: BINARY_CACHE_TOKEN + commands: |- + if [ ! -f .do-build ]; then + echo "Skipping build for ${PACKAGE} - not affected by changes" + exit 0 + fi + + nix run .#setup-attic + nix run .#archive-flakes -- "." + nix-fast-build --no-nom --no-link --attic-cache raccidev --skip-cached --flake ".#packages.x86_64-linux.${PACKAGE}" --out-link "result-${PACKAGE}" diff --git a/.woodpecker/flake-checker.yaml b/.woodpecker/flake-checker.yaml deleted file mode 100644 index 62485bd77..000000000 --- a/.woodpecker/flake-checker.yaml +++ /dev/null @@ -1,15 +0,0 @@ -labels: - platform: linux/amd64 - -when: - - event: [push, pull_request] - branch: master - - event: cron - cron: daily - - event: manual - -steps: - - name: Run flake checker - image: registry.racci.dev/lix-woodpecker:latest - pull: true - commands: nix run nixpkgs#flake-checker diff --git a/.woodpecker/lix.yaml b/.woodpecker/lix.yaml index 05027d50a..979a038fc 100644 --- a/.woodpecker/lix.yaml +++ b/.woodpecker/lix.yaml @@ -12,7 +12,7 @@ when: steps: - name: Check for input changes - image: registry.racci.dev/lix-woodpecker:latest + image: registry.racci.dev/lix-woodpecker pull: true commands: | # Check if pkgs/lix-woodpecker changed @@ -68,12 +68,17 @@ steps: fi - name: Publish to Registry - image: registry.racci.dev/lix-woodpecker:latest - when: - evaluate: 'BUILD_PUBLISH == "true"' + image: registry.racci.dev/lix-woodpecker environment: REGISTRY_USER: from_secret: REGISTRY_USER REGISTRY_TOKEN: from_secret: REGISTRY_TOKEN - commands: nix run ".#lix-woodpecker.copyToRegistry" -- --dest-creds "$REGISTRY_USER:$REGISTRY_TOKEN" + commands: | + . envvar + if [ "$SHOULD_PUBLISH" = "true" ]; then + echo "Publishing lix-woodpecker image to registry..." + nix run ".#lix-woodpecker.copyToRegistry" -- --dest-creds "$REGISTRY_USER:$REGISTRY_TOKEN" + else + echo "Skipping publish step." + fi diff --git a/.woodpecker/security.yaml b/.woodpecker/security.yaml deleted file mode 100644 index 756e78615..000000000 --- a/.woodpecker/security.yaml +++ /dev/null @@ -1,12 +0,0 @@ -when: - branch: master - event: [push] - -steps: - - name: GitGuardian Secret Scan - image: gitguardian/ggshield:latest - pull: true - environment: - GITGUARDIAN_API_KEY: - from_secret: GITGUARDIAN_API_KEY - commands: ggshield secret scan commit-range ${CI_PREV_COMMIT_SHA}.. diff --git a/.woodpecker/update-packages.yaml b/.woodpecker/update-packages.yaml new file mode 100644 index 000000000..f132e7a33 --- /dev/null +++ b/.woodpecker/update-packages.yaml @@ -0,0 +1,83 @@ +variables: &env + BASE_BRANCH: master + BRANCH: &branch update/package-${PACKAGE} + +when: + - event: manual + - event: cron + cron: update-packages + +clone: + - name: git + image: woodpeckerci/plugin-git + settings: + partial: false + depth: 0 + +matrix: + PACKAGE: + - alvr-bin + - lidarr-plugins + - mcp-sequential-thinking + - mcp-server-amazon + - proton-mcp + - pyarlo + - pyuptimekuma + - ssh-terminal-manager + - terminal-manager + +steps: + - name: update-package-${PACKAGE} + image: registry.racci.dev/lix-woodpecker + environment: *env + commands: | + set -euo pipefail + + nix run .#setup-git + export NIX_PATH=nixpkgs=channel:nixos-unstable + nix develop --no-pure-eval --accept-flake-config --command \ + nix-update --flake "${PACKAGE}" --use-update-script || { + echo "nix-update failed for ${PACKAGE}" + exit 1 + } + + - name: Push to Branch + image: appleboy/drone-git-push + depends_on: + - update-package-${PACKAGE} + settings: + remote: git@github.com:DaRacci/nix-config.git + branch: *branch + rebase: true + commit: true + empty_commit: false + author_name: "Woodpecker CI" + author_email: "woodpecker@racci.dev" + commit_message: "chore(pkgs): update ${PACKAGE} version" + ssh_key: + from_secret: GITHUB_AUTH_KEY + + - name: Create/Update PR + image: registry.racci.dev/lix-woodpecker + pull: true + depends_on: + - Push to Branch + environment: + <<: *env + GITHUB_TOKEN: + from_secret: GITHUB_TOKEN + commands: |- + git fetch --depth=1 origin "${BASE_BRANCH}" "$BRANCH" + if git diff --quiet "origin/${BASE_BRANCH}"..."origin/${BRANCH}"; then + echo "No changes detected for ${PACKAGE}, skipping PR creation." + exit 0 + fi + + TITLE=$(git log -1 --pretty=%s "origin/${BRANCH}") + nix run .#create-pr -- \ + --title "$TITLE" \ + --branch "$BRANCH" \ + --base "${BASE_BRANCH}" \ + --labels "dependencies,automated,merge-queue" \ + --reviewers "DaRacci" \ + --body "This PR was created automatically by the Woodpecker update-packages pipeline." diff --git a/.woodpecker/update.yaml b/.woodpecker/update.yaml index 2fba43fd2..2fdc47ef8 100644 --- a/.woodpecker/update.yaml +++ b/.woodpecker/update.yaml @@ -15,7 +15,7 @@ when: steps: - name: Update all flake.lock files - image: registry.racci.dev/lix-woodpecker:latest + image: registry.racci.dev/lix-woodpecker pull: true environment: GITHUB_TOKEN: @@ -49,7 +49,7 @@ steps: environment: GITHUB_TOKEN: from_secret: GITHUB_TOKEN - commands: | + commands: |- nix run .#create-pr -- \ --title "chore(deps): Update flake inputs" \ --body "This PR updates the flake inputs to their latest versions, see individual commits for changes." \ diff --git a/AGENTS.md b/AGENTS.md index 773ff7acd..be2231291 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,12 +66,14 @@ This repository uses `devenv`. You must provide the override for `nix flake chec ### Finding Affected Configurations -Before testing, determine what your changes affect: +Before testing, determine what your changes affect. Prefer `--since` so output only includes entries tied to files changed in your range. Add `--refine` to narrow host or home lists for files under `modules/nixos/` and `modules/home-manager/` when module exposes `options..enable`. Use `--report` to print a summary table grouping affected hosts/homes by priority: ```bash -./flake/dev/scripts/module-graph.nu +SILENT=true nix run .#module-graph -- --since --refine --report ``` +Recommend setting `SILENT=true` when sending output to LLMs or remote analysis tools to reduce logging verbosity and token usage. + ### Project Structure Overview ``` diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 94ae8f880..d696e8deb 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -30,6 +30,7 @@ - [MCPO](modules/nixos/services/mcpo.md) - [Metrics](modules/nixos/services/metrics.md) - [Tailscale](modules/nixos/services/tailscale.md) + - [Woodpecker Nix](modules/nixos/services/woodpecker_nix.md) - [Core](modules/nixos/core/default.md) - [Activation](modules/nixos/core/activation.md) - [Auto Upgrade](modules/nixos/core/auto_upgrade.md) diff --git a/docs/src/modules/nixos/services/woodpecker_nix.md b/docs/src/modules/nixos/services/woodpecker_nix.md new file mode 100644 index 000000000..214ea6019 --- /dev/null +++ b/docs/src/modules/nixos/services/woodpecker_nix.md @@ -0,0 +1,439 @@ +# Woodpecker Shared Nix Store + +The `woodpeckerNix` module provides an **isolated, shared Nix store** for +Woodpecker CI pipeline containers. Instead of every pipeline step downloading +and building its dependencies from scratch, a long-lived Nix daemon manages a +persistent store on the host. Containers bind-mount that store and connect to +the daemon so they share cached derivations across jobs. + +## Why an isolated store? + +Running CI builds directly against the host's `/nix/store` and `nix-daemon` +has two problems: + +1. **Security** – untrusted build code runs in the same store that powers your + production system. +1. **Pollution** – CI builds leave large, unrelated closures in the host store + and make GC harder to reason about. + +The module solves both by keeping a completely separate store under +`stateDir` (default `/var/lib/woodpecker-nix`). The CI daemon, its store, +and the containers that use it are sandboxed away from the host. + +## Architecture + +```text +┌──────────────────────────────────────────────────────────────────────────┐ +│ Host (NixOS) │ +│ │ + │ woodpecker-nix-init ─── hash-aware bootstrap + profile setup │ + │ woodpecker-nix-mount ─── overlay mount service │ + │ woodpecker-nix-daemon ─── serves merged store/ as /nix │ + │ woodpecker-nix-propagate ─── audits or promotes upper-layer paths │ + │ woodpecker-nix-compact ─── whiteout compaction + lower-layer promotion│ + │ woodpecker-nix-healthcheck ─ health-check + remount on ESTALE │ + │ woodpecker-nix-gc ─── size- and time-gated garbage collection │ +│ │ │ +│ │ overlayfs: lower=store-real/ upper=overlay/upper/ │ +│ └──────────────────────────────────────────────────────────────┤ │ +│ stateDir │ │ +│ Woodpecker agent ──► Docker container │ │ +│ WOODPECKER_BACKEND_DOCKER_VOLUMES = │ │ +│ stateDir/nix:/nix │ │ +│ stateDir/nix/var/nix/daemon-socket:/nix/var/nix/daemon-socket │ │ +│ stateDir/nix/var/nix/profiles:/nix/var/nix/profiles:ro │ │ +│ stateDir/cache/gitv3:/root/.cache/nix/gitv3 (when cache=git) │ │ +│ WOODPECKER_ENVIRONMENT = │ │ +│ PATH=/bin:/bin:/usr/bin │ │ +│ NIX_REMOTE=daemon │ │ +│ SSL_CERT_FILE=/etc/ssl/certs/ca-bundle.crt │ │ +└──────────────────────────────────────────────────────────────────────────┘ + + store-real/ Lower layer (read-only). Bootstrap + base packages. + overlay/upper/ Upper layer (writable). New builds from containers land here. + overlay/work/ Overlayfs internal work directory. + store/ Merged mount: store-real + overlay/upper (what nix sees). + +> On unprivileged Proxmox LXC, kernel overlayfs is unavailable. The module +> falls back to `fuse-overlayfs` (with `programs.fuse.userAllowOther = true`) +> and mounts with `index=on` plus `redirect_dir=on` so hard-link deduplication +> and atomic rename semantics stay intact. +``` + +### systemd services + +| Service | Purpose | Sandboxed? | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `woodpecker-nix-init` | Hash-aware bootstrap: creates directories, copies `runtimeEnv` + `bootstrapPackages` closures into `store-real/`, reconstructs profile symlinks, and registers GC roots. Prepares lowerdir/upperdir for overlayfs. Ordered before mount service (not local-fs.target) to avoid cycle, and reruns on each dependent start so deleted overlay dirs are recreated. | Yes – `ProtectSystem=strict`, `ReadWritePaths = [ stateDir ]` (stateDir created by tmpfiles), runs as woodpecker-nix | +| `woodpecker-nix-mount` | Mounts `${stateDir}/nix/store` as overlay — kernel overlayfs on supported hosts, `fuse-overlayfs` fallback in unprivileged Proxmox LXC. Required because overlay mount must be host-visible and reliable. Init prepares layers before mount starts. | N/A | +| `woodpecker-nix-daemon` | Runs `nix daemon` with the overlayfs-backed store at `/nix`. Depends on `woodpecker-nix-mount.service`. Polls for ready mount before starting (fuse-overlayfs delay). | Yes – `PrivateMounts`, `ProtectSystem=strict`, runs as `woodpecker-nix` | +| `woodpecker-nix-populate-cache` | Oneshot cache warmer. After daemon is ready, fetches git inputs from each flake listed in `isolatedStore.cache.populate` into host `gitv3` cache before CI containers start. | Yes – sandboxed, runs as `woodpecker-nix` | +| `woodpecker-nix-propagate` | Audits the overlayfs upper layer for new store paths. In `mode = "promote"` it also copies stable paths from `upper/` into `store-real/` and removes them from `upper/` once they have aged past the configured threshold. | Yes – sandboxed, runs as `woodpecker-nix` | +| `woodpecker-nix-propagate.timer` | Triggers the propagation audit on a configurable schedule (default: every 15 minutes). | N/A | +| `woodpecker-nix-compact` | Optional monthly compaction service. It stops the daemon, unmounts the overlay, removes whiteout-marked paths from `store-real/`, optionally promotes stable paths from `upper/`, remounts the overlay, and restarts the daemon. | Yes – root-run maintenance service with mount/admin caps | +| `woodpecker-nix-compact.timer` | Triggers compaction on the configured calendar schedule. | N/A | +| `woodpecker-nix-healthcheck` | Periodically `stat`s a known store path and remounts the overlay if the mount becomes stale or unresponsive. | Yes – root-run maintenance service with mount/admin caps | +| `woodpecker-nix-healthcheck.timer` | Triggers the health check on a short cadence. | N/A | +| `woodpecker-nix-gc` | Garbage-collects the CI store. Only runs when store size exceeds `gc.sizeThreshold` AND more than `gc.minInterval` has elapsed since the last GC. Respects `--max-freed` budget. | Yes – sandboxed, runs as `woodpecker-nix` | +| `woodpecker-nix-gc.timer` | Periodic trigger for the GC service (default: weekly). | N/A | + +## Overlay compaction, promotion, and limits + +The store is now split into two layers so GC and promotion behave correctly: + +- `stateDir/nix/store-real/` remains the reusable lower layer that is populated by bootstrap and later promotion. +- `stateDir/nix/overlay/upper/` is the writable upper layer for CI build outputs. +- `stateDir/nix/store/` is the merged overlay view that the daemon and containers see. + +The module measures `store-real/` and `upper/` separately for size-based GC decisions, so whiteouts from removed lower-layer paths do not silently hide the real disk usage. If you enable `isolatedStore.compaction.enable`, the monthly compaction service will stop the daemon, unmount the overlay, remove lower-layer paths that are hidden by whiteouts in `upper/`, optionally promote older upper-layer paths into `store-real/`, remount the overlay, and restart the daemon. + +The propagation sidecar now supports `isolatedStore.propagation.mode = "audit"` (default) or `"promote"`. In promote mode it copies stable paths from the upper layer into the reusable lower layer after they have aged past `isolatedStore.propagation.promoteStableAfter`. + +### Known limitation with nested overlays + +Docker’s `overlay2` storage driver can be layered on top of this fuse-backed store. That nesting is mostly safe for the host-visible mount, but it introduces a few caveats: + +- `overlay.*` xattrs from the inner overlay can be stripped by Docker’s own overlay layer. +- Whiteout markers may not survive the nesting path exactly as they do on a single overlay mount. +- The module therefore treats the lower/upper split as the authoritative storage model and uses the merged store only as a view layer. + +## How version-drift is handled + +### The problem + +CI Docker images built with `nix2container` (like `lix-woodpecker`) bake +their runtime packages into image layers as `/nix/store/…` paths. The module +bind-mounts the CI store over `/nix/store` inside every container — which +**replaces** the image's store entirely. If the image was built at a different +time than the host (different nixpkgs revision, different Lix version, etc.), +the store hashes diverge and the container's own packages vanish: + +```text +exec: "/bin/sh": stat /bin/sh: no such file or directory +``` + +Even when the container manages to start (via a static `/bin/sh`), the Nix +profile symlink chain inside the image breaks: + +```text +/root/.nix-profile → /nix/var/nix/profiles/default + → /nix/var/nix/profiles/default-1-link + → /nix/store/-user-environment ← MISSING from CI store +``` + +This leaves the shell with no tools besides builtins, `sh`, and `env`. + +### The solution — three complementary mechanisms + +1. **Static `/bin/sh`** — The `lix-woodpecker` image includes a layer with + a statically-linked [BusyBox](https://busybox.net/) binary at `/bin/sh` + and `/usr/bin/env`. These are real files (not symlinks into `/nix/store`), + so they survive the store overlay and let the OCI runtime exec the + container regardless of what is in the CI store. + +1. **`runtimePackages` + PATH injection** — The module builds a merged + `buildEnv` ("runtimeEnv") from the host's current packages and injects + `PATH=/bin:/bin:/usr/bin` into every pipeline container via + `WOODPECKER_ENVIRONMENT`. This completely decouples the container's + runtime tools from the image's own `/nix/store` paths: + - The tools (nix, git, jq, …) always match the **host's** store. + - The `runtimeEnv` closure is copied into the CI store during bootstrap. + - Version drift between image and host never causes missing-binary failures. + +1. **Profile symlink reconstruction** — The init service rebuilds the Nix + profile chain inside the CI store's profile directory and mounts it into + containers at `/nix/var/nix/profiles:ro`. This ensures the image's + default PATH entries (`/root/.nix-profile/bin`, + `/nix/var/nix/profiles/default/bin`) also resolve correctly: + + ```text + stateDir/nix/var/nix/profiles/default-1-link → (in CI store) + stateDir/nix/var/nix/profiles/default → default-1-link + ``` + + The profile reconstruction runs on **every** init service start (not just + when the bootstrap hash changes), so even manually-deleted symlinks are + repaired automatically. A GC root is also registered so the runtime + environment survives garbage collection. + +### Hash-aware bootstrap + +The init service computes a SHA-256 hash of the store paths of the +`runtimeEnv` and every `bootstrapPackages` entry. It writes this to +`/.bootstrap-hash`. On every service start it compares the recorded +hash against the current one: + +- **Unchanged** → skips `nix copy` (no copy overhead), still verifies profiles. +- **Changed** (e.g. after `nix flake update`) → runs `nix copy` for each + package and updates the hash file. + +You never need to clear a sentinel file manually; updates are picked up +automatically on the next host rebuild + restart. + +#### Overlay migration and state directory recovery + +When migrating from a plain store layout to overlayfs, the init service +copies top-level store entries from `${stateDir}/nix/store` into +`${stateDir}/nix/store-real/` (the overlay lowerdir) then deletes the source. +This runs before the overlay mount starts, so migration completes before +Nix sees the store. Later init invocations skip entries already present +in `store-real/` and clean up remaining source entries. + +- Paths already in `store-real/` are skipped (idempotent). +- If an interrupted run left a partial `store-real/`, the migration resumes + safely — entries copied before interruption are skipped, remaining entries + are now copied. +- After migration completes, the old `${stateDir}/nix/store` is removed; + the mount unit provides the merged overlay view at `store/`. +- `stateDir` is created at boot by tmpfiles, so init never touches parent dirs. + Sandbox remains tight: `ReadWritePaths = [ stateDir ]`. + Tmpfiles creates the full directory skeleton under `${stateDir}` as `woodpecker-nix` — including intermediates like `${stateDir}/etc`, `${stateDir}/nix`, `${stateDir}/nix/overlay`, `${stateDir}/gc-home/.local` — so all service-accessible paths are correctly owned without root-owned intermediate parents. + +**To force a full re-bootstrap**, delete `.bootstrap-hash` only (see +Troubleshooting). Deleting the entire `stateDir` triggers an unnecessary +full migration — tmpfiles recreates it on next boot. + +#### Signature enforcement during bootstrap + +The isolated daemon runs with `require-sigs = true` — unsigned paths are +rejected during normal CI operation. The bootstrap `nix copy` step is a +command-scoped exception: it imports preselected local host closures and +bypasses signature checks. If the init service fails during boot with: + +``` +error: cannot add path '/nix/store/...-woodpecker-ci-runtime' because +it lacks a signature by a trusted key +``` + +the bootstrap copy path is broken (e.g. source path missing or +permissions changed). Verify the host-side store paths exist and are +readable by the init service. + +## Basic configuration + +```nix +services.woodpeckerNix = { + enable = true; + + isolatedStore.enable = true; + + # Tools available to every CI container via PATH. + # Defaults include bash, coreutils, git, cacert, curl, etc. + # Add project-specific tools here: + isolatedStore.runtimePackages = with pkgs; [ + bashInteractive + coreutils-full + cacert + gitMinimal + gnutar + gzip + gnugrep + findutils + curl + # Project extras: + gawk + jq + gnupg + attic-client + openssh + which + less + ]; + + # Apply store + daemon socket volumes to these Woodpecker agents. + woodpecker.agents = [ "local" ]; +}; +``` + +> **Note:** `isolatedStore.package` (the Nix/Lix daemon, defaulting to +> `config.nix.package`) is always included in the runtime environment +> automatically — you do not need to list it in `runtimePackages`. + +## Cache options + +The `cache` option controls what Nix caches are shared across pipeline +containers. It is a single enum with three values: + +### `"none"` + +Don't share any caches. Each pipeline starts cold. This is the safest option +but slowest. + +### `"git"` (default) + +Mounts `/cache/gitv3` at `/root/.cache/nix/gitv3` inside every +container as read-only. Host-side `woodpecker-nix-populate-cache.service` +pre-warms that cache from flakes listed in `isolatedStore.cache.populate`, so +containers avoid concurrent write contention while still reusing fetched git +inputs. This eliminates repeated `git fetch` operations and pack unpacking on +every job. + +### `"all"` + +Mounts `/cache` at `/root/.cache/nix` inside every container, +sharing the entire Nix cache directory (including the eval cache). + +> **Warning:** the eval cache is a SQLite database. SQLite supports many +> concurrent _readers_ but only a single _writer_ at a time. Enabling this +> option with high-parallelism agents can cause lock contention and stale +> reads. Only enable it when you know builds are effectively sequential +> (e.g. `WOODPECKER_MAX_WORKFLOWS=1`) or when contention is acceptable. + +## Reference + +### Options + +{{#include ../../../../generated/services-woodpecker-nix-options.md}} + +### Injected container environment + +When `isolatedStore.enable` is true, the module automatically injects the +following into every pipeline container: + +| Variable | Value | Purpose | +| ------------------- | ------------------------------------------ | -------------------------------------------------- | +| `PATH` | `/bin:/bin:/usr/bin` | Tools from the CI store + static busybox fallback | +| `NIX_REMOTE` | `daemon` | Route all Nix operations through the shared daemon | +| `SSL_CERT_FILE` | `/etc/ssl/certs/ca-bundle.crt` | TLS certificate bundle | +| `NIX_SSL_CERT_FILE` | (same as above) | Nix-specific TLS certs | +| `GIT_SSL_CAINFO` | (same as above) | Git TLS certs | + +### Injected container volumes + +With overlayfs enabled, containers receive the full merged view of the store +(image paths + CI store + build results) at `/nix`. This eliminates the +version-drift problem that the old `:ro` bind-mount of `/nix/store` alone +could cause. + +| Host path | Container path | Mode | Purpose | +| -------------------------------------- | ---------------------------- | ---- | ----------------------------------------- | +| `/nix` | `/nix` | `rw` | Full Nix tree via overlayfs (merged view) | +| `/nix/var/nix/daemon-socket` | `/nix/var/nix/daemon-socket` | `rw` | Daemon socket | +| `/nix/var/nix/profiles` | `/nix/var/nix/profiles` | `ro` | Reconstructed profile symlinks | +| `/cache/gitv3` | `/root/.cache/nix/gitv3` | `ro` | Git cache (when `cache = "git"`) | +| `/cache` | `/root/.cache/nix` | `rw` | Full cache (when `cache = "all"`) | + +## Troubleshooting + +### Tools missing or wrong version in CI steps + +All tools come from `isolatedStore.runtimePackages` (via PATH), **not** from +the Docker image. If a tool is missing, add it to +`isolatedStore.runtimePackages` in the host config and rebuild. The init +service will detect the hash change and re-copy closures automatically. + +If tools are still missing after adding them, check the agent's +`WOODPECKER_ENVIRONMENT` is being set correctly — it should contain +comma-separated `KEY:VALUE` pairs including `PATH:...`. + +### Profile symlinks broken + +The init service reconstructs profile symlinks on every start. If profiles +appear broken: + +1. Check that `/nix/var/nix/profiles` is mounted into the container + (verify agent `WOODPECKER_BACKEND_DOCKER_VOLUMES`). + +1. Inspect the host-side symlinks: + + ```bash + ls -la /var/lib/woodpecker-nix/nix/var/nix/profiles/ + ``` + + You should see `default-1-link` pointing to a `/nix/store/...-woodpecker-ci-runtime` + path, and `default` pointing to `default-1-link`. + +1. Restart the init service to force reconstruction: + + ```bash + systemctl restart woodpecker-nix-init.service + ``` + +### Bootstrap is slow after a `nix flake update` + +This is expected: `nix copy` transfers potentially large closures into the CI +store. Subsequent starts are instant (hash unchanged → skip copy, still verify +profiles). Enable a binary cache substituter (via +`isolatedStore.substituters` / `isolatedStore.trustedPublicKeys`) to speed up +the initial copy. + +### GC service fails with `error: creating directory '//.local': Read-only file system` + +`nix-collect-garbage` may try to resolve XDG/Home paths even when talking to remote daemon. With `ProtectSystem=strict`, leaving `HOME` unset can make Nix fall back to `/.local`, which is read-only inside service sandbox. + +Module now points `HOME`, `XDG_CACHE_HOME`, `XDG_CONFIG_HOME`, and `XDG_DATA_HOME` at `${stateDir}/gc-home` for the GC service. No separate `DynamicUser` or per-service `StateDirectory` is used — both maintenance services share the parent `${stateDir}`. + +If you still see this error after deploy, verify generated unit contains those environment variables: + +```bash +systemctl cat woodpecker-nix-gc.service +``` + +### GC not running or running too often + +GC is gated by **both** size and time. If GC never runs: + +1. Check current store size against the threshold (default 20GB): + + ```bash + du -sh /var/lib/woodpecker-nix/nix/store-real + du -sh /var/lib/woodpecker-nix/nix/overlay/upper + cat /var/lib/woodpecker-nix/.store-size + ``` + +1. Check time since last GC: + + ```bash + cat /var/lib/woodpecker-nix/.last-gc # epoch timestamp + date -d @$(cat /var/lib/woodpecker-nix/.last-gc) + ``` + +1. Both conditions must be met: store > `sizeThreshold` AND more than + `minInterval` (default 7d) since last GC. + +If GC runs too aggressively, adjust `gc.maxFreed` (default null) to cap +how much is freed per run, or increase `gc.sizeThreshold`. + +### Daemon socket permission denied + +Check that the Woodpecker agent user is in the `docker` group (for the Docker +backend) and that `/nix/var/nix/daemon-socket` is owned by the +`woodpecker-nix` service user with strict permissions. The init service sets +this on every start; check its journal if permissions look wrong: + +```bash +journalctl -u woodpecker-nix-init.service +``` + +### Forcing a full re-bootstrap + +Delete the hash sentinel and restart the init service — do **not** delete the +entire `stateDir`, which would trigger an unnecessary full migration: + +```bash +rm /var/lib/woodpecker-nix/.bootstrap-hash +systemctl restart woodpecker-nix-init.service +``` + +If you already deleted `stateDir`, tmpfiles recreates the full tree on the next +boot. Run `systemd-tmpfiles --create` to trigger recreation immediately. + +### Verifying the fix end-to-end + +After deploying, run a test pipeline that exercises the full chain: + +```yaml +steps: + - name: verify + image: registry.racci.dev/lix-woodpecker:latest + commands: + - echo "Shell: $(which sh)" + - echo "PATH: $PATH" + - nix --version + - git --version + - ls -la /nix/var/nix/profiles/default + - readlink -f /nix/var/nix/profiles/default +``` + +All commands should succeed. The `readlink` should resolve to a +`/nix/store/...-woodpecker-ci-runtime` path that exists in the mounted store. diff --git a/docs/src/modules/overview.md b/docs/src/modules/overview.md index 0b8aed47e..058672187 100644 --- a/docs/src/modules/overview.md +++ b/docs/src/modules/overview.md @@ -25,6 +25,7 @@ This section provides an overview of the custom NixOS and Home-Manager modules d - [MCPO](nixos/services/mcpo.md) - [Metrics](nixos/services/metrics.md) - [Tailscale](nixos/services/tailscale.md) + - [Woodpecker Nix](nixos/services/woodpecker_nix.md) - `modules/flake/`: Flake-level modules for cross-host configuration. - [Flake Allocations](flake/allocations.md) - `modules/home-manager/`: Contains Home-Manager-specific modules. diff --git a/flake/ci/scripts/default.nix b/flake/ci/scripts/default.nix index 6ef2ac477..effca9439 100644 --- a/flake/ci/scripts/default.nix +++ b/flake/ci/scripts/default.nix @@ -5,25 +5,23 @@ }: let inherit (lib.mine.packages) writeNuApplicationWithLibs; + libSource = ../../../lib/nu-lib; + sourceRoot = ./.; in { archive-flakes = writeNuApplicationWithLibs { - inherit pkgs; - sourceRoot = ./.; + inherit pkgs libSource sourceRoot; name = "archive-flakes"; - runtimeInputs = [ pkgs.nix ]; }; create-pr = writeNuApplicationWithLibs { - inherit pkgs; - sourceRoot = ./.; + inherit pkgs libSource sourceRoot; name = "create-pr"; runtimeInputs = [ pkgs.gh ]; }; check-upstream-todos = writeNuApplicationWithLibs { - inherit pkgs; - sourceRoot = ./.; + inherit pkgs libSource sourceRoot; name = "check-upstream-todos"; runtimeInputs = [ pkgs.curl @@ -32,9 +30,31 @@ in ]; }; + detect-affected-outputs = writeNuApplicationWithLibs { + inherit pkgs libSource sourceRoot; + name = "detect-affected-outputs"; + }; + + discover-packages = writeNuApplicationWithLibs { + inherit pkgs libSource sourceRoot; + name = "discover-packages"; + runtimeInputs = [ + pkgs.fd + pkgs.jq + ]; + }; + + setup-attic = writeNuApplicationWithLibs { + inherit pkgs libSource sourceRoot; + name = "setup-attic"; + runtimeInputs = [ + pkgs.bash + pkgs.attic-client + ]; + }; + setup-git = writeNuApplicationWithLibs { - inherit pkgs; - sourceRoot = ./.; + inherit pkgs libSource sourceRoot; name = "setup-git"; runtimeInputs = [ pkgs.gitMinimal @@ -43,9 +63,12 @@ in }; update-locks = writeNuApplicationWithLibs { - inherit pkgs; - sourceRoot = ./.; + inherit pkgs libSource sourceRoot; name = "update-locks"; - runtimeInputs = [ pkgs.nix ]; + }; + + update-matrix = writeNuApplicationWithLibs { + inherit pkgs libSource sourceRoot; + name = "update-matrix"; }; } diff --git a/flake/ci/detect-affected-outputs.nu b/flake/ci/scripts/detect-affected-outputs.nu similarity index 93% rename from flake/ci/detect-affected-outputs.nu rename to flake/ci/scripts/detect-affected-outputs.nu index 6fee00531..5c4901740 100755 --- a/flake/ci/detect-affected-outputs.nu +++ b/flake/ci/scripts/detect-affected-outputs.nu @@ -1,7 +1,7 @@ #!/usr/bin/env -S nix shell nixpkgs#nushell --command nu use std/log -use ../dev/scripts/lib/flake.nu * +use lib/flake.nu * # Detect which outputs are affected based on changed files in the git repo. def main [ @@ -9,6 +9,7 @@ def main [ --verbose (-v) # Enable verbose logging --range: string # Git range to check --json (-j) # Output JSON format + --outputs: list # Specific outputs to check ...files: string # Specific files to check ] { init_logging $verbose $json @@ -18,12 +19,16 @@ def main [ let cache_key = init_cache $flake_info $cache_dir let dirty_files = collect_files $files $range - let outputs = get_outputs $type + mut outputs = $outputs if ($outputs | is-empty) { - log error $"No outputs found for identifier ($type) in flake." - exit 1 + log info "No specific outputs provided, checking all outputs for type ($type)..." + $outputs = get_outputs $type + if ($outputs | is-empty) { + log error $"No outputs found for identifier ($type) in flake." + exit 1 + } + log info $"Found outputs: ($outputs | str join ', ')" } - log info $"Found outputs: ($outputs | str join ', ')" let output_imports: record = compute_graphs $type $outputs $cache_key $cache_dir $flake_info.source_path let results = check_affected_outputs $dirty_files $output_imports diff --git a/flake/ci/scripts/discover-packages.nu b/flake/ci/scripts/discover-packages.nu new file mode 100755 index 000000000..16fc273fe --- /dev/null +++ b/flake/ci/scripts/discover-packages.nu @@ -0,0 +1,113 @@ +#!/usr/bin/env nu + +use std/log +use lib/lib.nu * +use lib/flake.nu * + +def get_packages [ + arch: string # The architecture to get packages for +] { + let packages = nix flake show --no-pure-eval --json e> /dev/null | jq $'.packages."($arch)" | keys' | from json + return $packages +} + +def filter_packages [ + arch: string + ...package_names: string +] { + mut valid_packages = [] + + for package in $package_names { + let pkg_details = nix eval --json --system $arch $'.#($package)' --apply 'pkg: { discovery = pkg.passthru.discovery or true; broken = pkg.meta.broken or false; }' | from json + log info $"Package ($package)\n\tisBroken: ($pkg_details.broken)\n\tisDiscoverable: ($pkg_details.discovery)" + + if ($pkg_details.discovery and (not $pkg_details.broken)) { + $valid_packages = $valid_packages | append $package + } + } + + return $valid_packages +} + +export def get_package_location [ + package_name: string, + arch: string +] { + let eval_nix = $env.GIT_ROOT | path join "flake/ci/scripts/eval.nix" + log debug $"Evaluating package location for ($package_name) using eval script at ($eval_nix)" + let attribute_json = ["packages", $arch, $package_name] | to json + let cmd = [ + "--eval", + "--json", + "--strict", + $eval_nix, + "--argstr", + "importPath", + $env.GIT_ROOT, + "--argstr", + "attribute", + $attribute_json + ] + + mut package_path = "" + try { + $package_path = (nix-instantiate ...$cmd) | from json | path dirname + } catch { + log warning $"Unable to evaluate location from nix positions for ($package_name)" + } + if ($package_path | is-empty) { + $package_path = ([$env.GIT_ROOT, "pkgs" $package_name] | path join) + } + + return $package_path +} + +def get_changed [ + arch: string, + git_range: string, + ...packages: string +] { + let nixpkgs_changed = has_flake_inputs_changed $git_range "nixpkgs" + if $nixpkgs_changed { + return $packages + } + + mut changed_packages = [ ]; + for package in $packages { + let pkg_folder = get_package_location $package $arch + if (not ($pkg_folder | path exists)) { + log warning $"Package folder ($pkg_folder) does not exist, maybe it isn't a normal package and should be excluded from discovery." + continue + } + + log info $"Checking package ($package) for changes under ($pkg_folder)" + let changed = check_file_changed $git_range $pkg_folder + if $changed { + log info $"Package ($package) has changed." + $changed_packages = $changed_packages | append $package + } + } + + return $changed_packages +} + +def main [ + git_range: string + --arch: string # The architecture to get packages for + --json (-j) # Output in JSON format + ...package_names: string # Only look for changes to these packages +] { + let arch = if ($arch | is-empty) { nix eval --raw --impure --expr builtins.currentSystem } else { $arch } + mut packages = $package_names + if ($packages | is-empty) { + $packages = get_packages $arch + } + let valid_packages = filter_packages $arch ...$packages + let changed_packages = get_changed $arch $git_range ...$valid_packages + + if ($json) { + return ($changed_packages | to json) + } else { + return $changed_packages + } +} diff --git a/flake/ci/scripts/eval.nix b/flake/ci/scripts/eval.nix new file mode 100644 index 000000000..931a0e1c7 --- /dev/null +++ b/flake/ci/scripts/eval.nix @@ -0,0 +1,99 @@ +# This was mostly just taken from https://github.com/Mic92/nix-update/blob/main/nix_update/eval.nix +{ + importPath, + attribute, + system ? builtins.currentSystem, +}: + +let + inherit (builtins) + getFlake + stringLength + substring + foldl' + fromJSON + ; + + # Parse the attribute path from JSON string + attributePath = fromJSON attribute; + # In case of flakes, we must pass a url with git attrs of the flake + # otherwise the entire directory is copied to nix store + flakeOrImportPath = importPath; + + # Try to navigate nested attributes, returning { success = bool; value = ...; } + tryGetAttrPath = + attrPath: root: + foldl' + ( + acc: attr: + if acc.success && builtins.isAttrs acc.value && acc.value ? ${attr} then + { + success = true; + value = acc.value.${attr}; + } + else + { + success = false; + value = null; + } + ) + { + success = true; + value = root; + } + attrPath; + + flake = getFlake flakeOrImportPath; + + pkg = + let + packages = flake.packages.${system} or { }; + # Try packages.${system} first, fall back to flake root if attribute not found + packagesResult = tryGetAttrPath attributePath packages; + flakeResult = tryGetAttrPath attributePath flake; + in + if packagesResult.success then + packagesResult.value + else if flakeResult.success then + flakeResult.value + else + throw "Package not found at attribute path: ${builtins.toJSON attributePath}"; + + sanitizePosition = + let + outPath = flake.outPath; + outPathLen = stringLength outPath; + in + { file, ... }@pos: + if substring 0 outPathLen file != outPath then + throw "${file} is not in ${outPath}" + else + pos // { file = importPath + substring outPathLen (stringLength file - outPathLen) file; }; + + positionFromMeta = + pkg: + let + parts = builtins.match "(.*):([0-9]+)" pkg.meta.position; + in + if parts == null then + throw "Unable to parse meta.position '${pkg.meta.position}': expected 'file:line' format" + else + { + file = builtins.elemAt parts 0; + line = builtins.fromJSON (builtins.elemAt parts 1); + }; + + position = + if (builtins.unsafeGetAttrPos "src" pkg) != null then + sanitizePosition (builtins.unsafeGetAttrPos "src" pkg) + else if pkg ? meta && pkg.meta ? position then + sanitizePosition (positionFromMeta pkg) + else + throw "Unable to determine position: package has neither 'src' attribute nor 'meta.position'"; + + eval = builtins.tryEval position.file; +in +if eval.success then + eval.value +else + builtins.addErrorContext "Unable to evaluate file or extract position from meta, returning null" null diff --git a/flake/ci/scripts/lib b/flake/ci/scripts/lib new file mode 120000 index 000000000..1553244de --- /dev/null +++ b/flake/ci/scripts/lib @@ -0,0 +1 @@ +../../../lib/nu-lib \ No newline at end of file diff --git a/flake/ci/scripts/setup-attic.nu b/flake/ci/scripts/setup-attic.nu new file mode 100755 index 000000000..38fa34253 --- /dev/null +++ b/flake/ci/scripts/setup-attic.nu @@ -0,0 +1,28 @@ +#!/usr/bin/env nu + +use std/log +use lib/lib.nu check_required_vars + +const REQUIRED_VARS = [ + BINARY_CACHE_TOKEN +] + +# Sets up the attic binary cache +# +# Required environment variables: +# BINARY_CACHE_TOKEN - Token for accessing the binary cache +def main [ + --watch # If a background process should be spawned to watch the nix store +] { + check_required_vars --exit ...$REQUIRED_VARS + + touch /tmp/netrc + chmod 600 /tmp/netrc + $"machine cache.racci.dev\npassword ($env.BINARY_CACHE_TOKEN)" | save -f /tmp/netrc + attic login raccidev https://cache.racci.dev/global $env.BINARY_CACHE_TOKEN + + if $watch { + # No native way to disown a background job in nu so lets use bash + bash -c "attic watch-store raccidev:global & disown" + } +} diff --git a/flake/ci/scripts/update-matrix.nu b/flake/ci/scripts/update-matrix.nu new file mode 100644 index 000000000..7c718ea49 --- /dev/null +++ b/flake/ci/scripts/update-matrix.nu @@ -0,0 +1,51 @@ +use lib/flake.nu * +use std/log + +def main [ + workflow_file: string, # The path to the woodpecker workflow file + matrix_key: string, # The key of the matrix to update (e.g., "USERS") + eval_expression: string, # The nix expression path to get the list of entries to use for the matrix (e.g., "nixosConfigurations") + eval_apply: string = "builtins.attrNames" # The nix expression to apply to the evaluated entries to get the final list (default: "builtins.attrNames") +] { + if (not ($workflow_file | path exists)) { + log error $"Workflow file ($workflow_file) does not exist" + exit 1 + } + + let yaml = open $workflow_file + if ($yaml | describe -d | get type) != "record" { + log error $"Workflow file ($workflow_file) is not a valid YAML record" + exit 1 + } + + if ( + (($yaml | get --optional matrix) == null) + or (($yaml.matrix | get --optional $matrix_key) == null) + ) { + log error $"Workflow file ($workflow_file) does not contain a matrix with key ($matrix_key)" + exit 1 + } + + let current_matrix = $yaml.matrix | get $matrix_key + log info $"Current matrix for key ($matrix_key): ($current_matrix)" + + let eval_entries = nix eval --json $eval_expression --apply $eval_apply | from json + if ($eval_entries | describe -d | get type) != "list" { + log error $"Evaluated expression ($eval_expression) with apply ($eval_apply) did not return a list" + exit 1 + } + + let new_matrix = $eval_entries | sort | uniq + log info $"New matrix for key ($matrix_key): ($new_matrix)" + + if $new_matrix == $current_matrix { + log info "Matrix is up to date, no changes needed" + exit 0 + } + + let updated_yaml = $yaml | update (["matrix", $matrix_key] | into cell-path) $new_matrix + let yaml_string = $updated_yaml | to yaml + + $yaml_string | save --force $workflow_file + log info $"Updated workflow file ($workflow_file) with new matrix for key ($matrix_key)" +} diff --git a/flake/dev/devenv.nix b/flake/dev/devenv.nix index 755832b53..9bea32128 100644 --- a/flake/dev/devenv.nix +++ b/flake/dev/devenv.nix @@ -35,6 +35,8 @@ nix-init nh nix-update + vulnix + nix-fast-build # Required Tools lix diff --git a/flake/dev/scripts/default.nix b/flake/dev/scripts/default.nix index 834bd9d0b..9db81a9f6 100644 --- a/flake/dev/scripts/default.nix +++ b/flake/dev/scripts/default.nix @@ -5,25 +5,41 @@ }: let inherit (lib.mine.packages) writeNuApplicationWithLibs; + libSource = ../../../lib/nu-lib; + sourceRoot = ./.; in { + build-changed = writeNuApplicationWithLibs { + inherit pkgs libSource sourceRoot; + name = "build-changed"; + runtimeInputs = [ pkgs.git ]; + }; + + get-symbol-value = writeNuApplicationWithLibs { + inherit pkgs libSource sourceRoot; + name = "get-symbol-value"; + }; + + module-graph = writeNuApplicationWithLibs { + inherit pkgs libSource sourceRoot; + name = "module-graph"; + runtimeInputs = [ pkgs.git ]; + }; + nix-tree-host = writeNuApplicationWithLibs { - inherit pkgs; - sourceRoot = ./.; + inherit pkgs libSource sourceRoot; name = "nix-tree-host"; runtimeInputs = [ pkgs.nix-tree ]; }; rebuild-target = writeNuApplicationWithLibs { - inherit pkgs; - sourceRoot = ./.; + inherit pkgs libSource sourceRoot; name = "rebuild-target"; runtimeInputs = [ pkgs.nh ]; }; update-redis-mappings = writeNuApplicationWithLibs { - inherit pkgs; - sourceRoot = ./.; + inherit pkgs libSource sourceRoot; name = "update-redis-mappings"; runtimeInputs = [ pkgs.lix ]; }; diff --git a/flake/dev/scripts/lib b/flake/dev/scripts/lib new file mode 120000 index 000000000..1553244de --- /dev/null +++ b/flake/dev/scripts/lib @@ -0,0 +1 @@ +../../../lib/nu-lib \ No newline at end of file diff --git a/flake/dev/scripts/lib/flake.nu b/flake/dev/scripts/lib/flake.nu deleted file mode 100644 index e3c20f958..000000000 --- a/flake/dev/scripts/lib/flake.nu +++ /dev/null @@ -1,125 +0,0 @@ -use std/log - -export-env { - $env.GIT_ROOT = ($env.PWD | git rev-parse --show-toplevel | str trim) - $env.CURRENT_HOST = (cat /etc/hostname | str trim) - $env.CURRENT_USER = (whoami | str trim) -} - -export def select_host [] { - let hosts = flake-eval r#'builtins.attrNames flake.nixosConfigurations |> builtins.concatStringsSep " "'# --raw - | split words - | where $it != $env.CURRENT_HOST - - let selected = ["current", ...($hosts)] | input list -f - if $selected == "current" { - $env.CURRENT_HOST - } else { - $selected - } -} - -export def select_user [] { - let users = flake-eval r#'builtins.attrNames flake.homeConfigurations |> builtins.concatStringsSep " "'# --raw - | split words - | where $it != $env.CURRENT_USER - - let selected = ["current", ...($users)] | input list -f - if $selected == "current" { - $env.CURRENT_USER - } else { - $selected - } -} - -export def --wrapped flake-eval [ - expr: string - ...nix_args: string, -] { - - nix eval --quiet --no-pure-eval ...$nix_args --expr $' - let - flake = builtins.getFlake "($env.GIT_ROOT)"; - in ($expr) - ' -} - -# Recursively extract all objects with file fields from nested graph structure -export def flatten_graph_recursively [] { - def extract_objects [input] { - let type = ($input | describe) - - if ($type | str starts-with "list") or ($type | str starts-with "table") { - $input | each { |item| extract_objects $item } | flatten - } else if ($type | str starts-with "record") { - let result = if ($input | get -o file | default null) != null { [$input] } else { [] } - let imports_result = if ($input | get -o imports | default null) != null { - extract_objects ($input.imports) - } else { [] } - $result ++ $imports_result - } else { - [] - } - } - - extract_objects $in -} - -export def get_output_graph_files [ - identifier: string - flake_source: string -] { - let graph_file = (mktemp -t "module-graph.XXXX") - - try { - run-external "nix" "eval" "--json" $".#($identifier).graph" o> $graph_file - } catch { |err| - log error $"Failed to evaluate graph for ($identifier): ($err)" - exit 1 - } - - let files = open $graph_file - | from json - | flatten_graph_recursively - | where ($it | get -o file | default "" | str starts-with $"($flake_source)/") - | get file - | each { |file| $file | str replace $"($flake_source)/" "" } - | each { |file_path| - if ($file_path | path type) != "dir" { - $file_path - } else { - let default_nix_path = ([$file_path, "default.nix"] | path join) - if ($default_nix_path | path exists) { - $default_nix_path - } else { - $file_path - } - } - } | sort | uniq - - rm $graph_file - $files -} - -export def get_flake_info [] { - let archive_info = try { - nix flake archive --json | from json - } catch { |err| - log error $"Failed to get flake archive info: ($err)" - exit 1 - } - - let source_path = $archive_info.path - if ($source_path | is-empty) or ($source_path == "null") { - log error "Failed to resolve flake source path." - exit 1 - } - - let hash = $source_path | path basename | str substring 0..11 - if ($hash | is-empty) { - log error "Failed to extract flake hash." - exit 1 - } - - { source_path: $source_path, hash: $hash } -} diff --git a/flake/dev/scripts/module-graph.nu b/flake/dev/scripts/module-graph.nu index 461aefba1..460d50b66 100755 --- a/flake/dev/scripts/module-graph.nu +++ b/flake/dev/scripts/module-graph.nu @@ -2,11 +2,24 @@ use std/log use lib/flake.nu +use lib/lib.nu + +def main [ + --since: string # Git commit/ref used to filter results to files changed since that point + --refine # Try narrow affected hosts/homes by checking detected enable options + --report # Print summary table of recommended actions +] { -def main [] { log info "Getting flake information..." let flake_info = get_flake_info + let changed_files = if $since != null { + log info $"Getting changed files since: ($since)" + get_changed_files_since $since + } else { + [] + } + log info "Getting host configurations..." let hosts = get_hosts $flake_info.hosts $flake_info.source @@ -16,7 +29,30 @@ def main [] { log info "Building module graph..." let all_modules = build_module_graph $hosts $homes - $all_modules | to json + let filtered_modules = if $since != null { + log info "Filtering module graph to affected files..." + $all_modules | where { |entry| $entry.file in $changed_files } + } else { + $all_modules + } + + log info $"Total modules: ($all_modules | length), Affected modules: ($filtered_modules | length)" + + let refined_modules = if $refine { + log info "Refining module graph with detected enable options..." + refine_module_graph $filtered_modules + } else { + $filtered_modules + } + + log info $"Total modules with refinement: ($refined_modules | length)" + + if $report { + log info "Generating summary report..." + generate_report $refined_modules $changed_files + } else { + $refined_modules | to json + } } def get_flake_info [] { @@ -36,16 +72,46 @@ def get_flake_info [] { [] } - if ($hosts | is-empty) { log warning "Host list came back empty" } - if ($homes | is-empty) { log warning "Home list came back empty" } + if ($hosts | is-empty) { + log warning "Host list came back empty" + } + if ($homes | is-empty) { + log warning "Home list came back empty" + } let flake_source = (flake get_flake_info).source_path let flake_info = { hosts: $hosts, homes: $homes, source: $flake_source } $flake_info } +def get_changed_files_since [since: string] { + let tracked_changes = try { + git diff --name-only $since | lines + } catch { |err| + log error $"Failed to get changed files since '($since)': ($err)" + exit 1 + } + + let untracked_changes = try { + git ls-files --others --exclude-standard | lines + } catch { |err| + log error $"Failed to get untracked files: ($err)" + exit 1 + } + + let changed_files = ($tracked_changes ++ $untracked_changes) + | where { |file| $file != "" } + | uniq + + if ($changed_files | is-empty) { + log warning $"No changed files found since '($since)'" + } + + $changed_files +} + def get_hosts [ - host_names: list, + host_names: list flake_source: string ] { let host_configs = $host_names | reduce -f {} { |host, acc| @@ -63,7 +129,7 @@ def get_hosts [ } def get_homes [ - home_names: list, + home_names: list flake_source: string ] { let home_configs = $home_names | reduce -f {} { |home, acc| @@ -99,9 +165,177 @@ def build_module_graph [hosts: record, homes: record] { } | where { $in != null } { - file: $module_file, - hosts: $hosts_using_module, + file: $module_file + hosts: $hosts_using_module homes: $homes_using_module } } } + +def refine_module_graph [module_graph: list] { + $module_graph | each { |entry| + refine_module_graph_entry $entry + } +} + +def refine_module_graph_entry [entry: record] { + if not (supports_enable_option_refinement $entry.file) { + $entry | insert refinement { + mode: "none" + reason: "unsupported-file-path" + } + } else { + let enable_options = detect_enable_options $entry.file + + if ($enable_options | is-empty) { + + $entry | insert refinement { + mode: "none" + reason: "no-enable-option-detected" + } + } else if ($enable_options | length) > 1 { + log debug $"Skipping refinement for '($entry.file)': multiple enable options detected" + $entry | insert refinement { + mode: "none" + reason: "multiple-enable-options-detected" + options: $enable_options + } + } else { + let option_path = ($enable_options | first) + let refined_hosts = refine_targets_by_option "nixosConfigurations" $entry.hosts $option_path + let refined_homes = refine_targets_by_option "homeConfigurations" $entry.homes $option_path + + let final_hosts = if ($entry.hosts | is-empty) or not ($refined_hosts | is-empty) { + $refined_hosts + } else { + $entry.hosts + } + let final_homes = if ($entry.homes | is-empty) or not ($refined_homes | is-empty) { + $refined_homes + } else { + $entry.homes + } + + $entry + | upsert hosts $final_hosts + | upsert homes $final_homes + | insert refinement { + mode: "enable-option" + option: $option_path + hosts_before: $entry.hosts + hosts_after: $final_hosts + homes_before: $entry.homes + homes_after: $final_homes + hosts_refined: ($final_hosts != $entry.hosts) + homes_refined: ($final_homes != $entry.homes) + } + } + } +} + +def supports_enable_option_refinement [file_path: string] { + ($file_path | str starts-with "modules/nixos/") or ($file_path | str starts-with "modules/home-manager/") +} + +def detect_enable_options [file_path: string] { + # only run detection for files under modules/nixos or modules/home-manager + # (extra guard; callers already check this, but keep here safe) + if not (supports_enable_option_refinement $file_path) { + return [] + } + + let file_contents = try { + open $file_path + } catch { |err| + log warning $"Failed to read '($file_path)' for refinement: ($err)" + return [] + } + + let nested_options = ($file_contents + | parse --regex '(?ms)options\.(?[A-Za-z0-9_.-]+)\s*=\s*\{.*?enable\s*=\s*(?:lib\.)?(?:mkEnableOption|mkOption)' + | get -o path + | default []) + + let direct_options = ($file_contents + | parse --regex '(?m)options\.(?[A-Za-z0-9_.-]+)\.enable\s*=\s*(?:lib\.)?(?:mkEnableOption|mkOption)' + | get -o path + | default []) + + ($nested_options ++ $direct_options) + | uniq + | sort +} + +def refine_targets_by_option [ + output_kind: string + targets: list + option_path: string +] { + $targets | where { |target| + is_option_enabled_for_target $output_kind $target $option_path + } +} + +def is_option_enabled_for_target [ + output_kind: string + target: string + option_path: string +] { + let attr_path = lib quote_nix_segments $".#($output_kind).($target).config.($option_path).enable" + + try { + let result = (nix eval --json $attr_path | from json) + $result == true + } catch { + true + } +} + +# Report generation +# - aggregate counts per host/home +# - group hosts/homes into priority buckets +def generate_report [module_graph: list, changed_files: list] { + mut host_counts = {} + mut home_counts = {} + + for entry in $module_graph { + for h in $entry.hosts { $host_counts = ($host_counts | upsert $h (($host_counts | get -o $h | default 0) + 1)) } + for m in $entry.homes { $home_counts = ($home_counts | upsert $m (($home_counts | get -o $m | default 0) + 1)) } + } + + let host_rows = $host_counts | items {|key, value| { name: $key, count: $value } } | sort-by { $in.count } -r + let home_rows = $home_counts | items {|key, value| { name: $key, count: $value } } | sort-by { $in.count } -r + + let host_table = [ + { priority: "HIGH", rows: ($host_rows | where { $in.count > 3 }) }, + { priority: "MEDIUM", rows: ($host_rows | where { ($in.count > 1) and ($in.count <= 3) }) }, + { priority: "LOW", rows: ($host_rows | where { $in.count == 1 }) } + ] + + let home_table = [ + { priority: "HIGH", rows: ($home_rows | where { $in.count > 3 }) }, + { priority: "MEDIUM", rows: ($home_rows | where { ($in.count > 1) and ($in.count <= 3) }) }, + { priority: "LOW", rows: ($home_rows | where { $in.count == 1 }) } + ] + + print_section "NixOS HOSTS" $host_table + print_section "HOME-MANAGER CONFIGS" $home_table +} + +def print_section [title: string, table: list] { + print "" + print "*************************************************************" + print $" ($title)" + print "*************************************************************" + print "" + + for section in $table { + if ($section.rows | length) > 0 { + print $"Priority: ($section.priority)" + for r in $section.rows { + print $" - ($r.name) [($r.count) modules]" + } + print "" + } + } +} diff --git a/flake/packages.nix b/flake/packages.nix index 0c1b91112..c5f9623e9 100644 --- a/flake/packages.nix +++ b/flake/packages.nix @@ -13,20 +13,9 @@ ... }: { - packages = lib.mkMerge [ - (import "${self}/pkgs" { - inherit - self - inputs - pkgs - lib - ; - }) - (import "${self}/flake/ci/scripts" { inherit inputs pkgs lib; }) - (import "${self}/flake/dev/scripts" { inherit inputs pkgs lib; }) - - ( - import "${self}/docs" { + packages = + let + docsPackages = import "${self}/docs" { inherit self system @@ -34,9 +23,26 @@ lib ; inputs = config.partitions.docs.extraInputs; + }; + in + lib.mkMerge [ + (import "${self}/pkgs" { + inherit + self + inputs + pkgs + lib + ; + }) + (import "${self}/flake/ci/scripts" { inherit inputs pkgs lib; }) + (import "${self}/flake/dev/scripts" { inherit inputs pkgs lib; }) + { + inherit (docsPackages) + docs + serve-docs + search + ; } - |> lib.filterAttrs (_: v: lib.isDerivation v) - ) - ]; + ]; }; } diff --git a/hosts/server/nixdev/default.nix b/hosts/server/nixdev/default.nix index 3c7795835..283261fbc 100644 --- a/hosts/server/nixdev/default.nix +++ b/hosts/server/nixdev/default.nix @@ -16,6 +16,7 @@ virtualisation.docker = { enable = true; autoPrune.enable = true; + daemon.settings.storage-driver = "overlayfs"; }; networking.firewall = { diff --git a/hosts/server/nixdev/woodpecker.nix b/hosts/server/nixdev/woodpecker.nix index a6d2b3c3e..4a8040468 100644 --- a/hosts/server/nixdev/woodpecker.nix +++ b/hosts/server/nixdev/woodpecker.nix @@ -58,33 +58,46 @@ }; }; - services.woodpecker-server = { - enable = true; - environmentFile = config.sops.templates.WOODPECKER_SERVER_ENV.path; - environment = { - WOODPECKER_HOST = "https://woodpecker.racci.dev"; - WOODPECKER_OPEN = "true"; - WOODPECKER_ADMIN = "DaRacci,Racci"; + services = { + woodpecker-server = { + enable = true; + environmentFile = config.sops.templates.WOODPECKER_SERVER_ENV.path; + environment = { + WOODPECKER_HOST = "https://woodpecker.racci.dev"; + WOODPECKER_OPEN = "true"; + WOODPECKER_ADMIN = "DaRacci,Racci"; - WOODPECKER_DATABASE_DRIVER = "postgres"; + WOODPECKER_DATABASE_DRIVER = "postgres"; - WOODPECKER_GITHUB = "true"; - # WOODPECKER_FORGEJO = "true"; - WOODPECKER_FORGEJO_URL = "https://codeberg.org"; + WOODPECKER_GITHUB = "true"; + # WOODPECKER_FORGEJO = "true"; + # WOODPECKER_FORGEJO_URL = "https://codeberg.org"; - WOODPECKER_SERVER_ADDR = ":8000"; - WOODPECKER_GRPC_ADDR = ":9000"; + WOODPECKER_SERVER_ADDR = ":8000"; + WOODPECKER_GRPC_ADDR = ":9000"; + }; }; - }; - services.woodpecker-agents.agents.local = { - enable = true; - environmentFile = [ config.sops.templates.WOODPECKER_AGENT_ENV.path ]; - extraGroups = [ "docker" ]; - environment = { - WOODPECKER_SERVER = "localhost:9000"; - WOODPECKER_BACKEND = "docker"; - WOODPECKER_MAX_WORKFLOWS = "8"; + woodpecker-agents.agents.local = { + enable = true; + environmentFile = [ config.sops.templates.WOODPECKER_AGENT_ENV.path ]; + extraGroups = [ "docker" ]; + environment = { + WOODPECKER_SERVER = "localhost:9000"; + WOODPECKER_BACKEND = "docker"; + WOODPECKER_MAX_WORKFLOWS = "8"; + }; + }; + + woodpeckerNix = { + enable = true; + isolatedStore.enable = true; + cachePopulate = [ "github:DaRacci/nix-config" ]; + cachePopulateInterval = "hourly"; + woodpecker = { + agents = [ "local" ]; + extraVolumes = [ "${config.services.woodpeckerNix.stateDir}/vulnix-cache:/root/.cache/vulnix" ]; + }; }; }; } diff --git a/lib/nu-lib/flake.nu b/lib/nu-lib/flake.nu new file mode 100644 index 000000000..585f8ba4f --- /dev/null +++ b/lib/nu-lib/flake.nu @@ -0,0 +1,228 @@ +use std/log + +export-env { + $env.GIT_ROOT = ($env.PWD | git rev-parse --show-toplevel | str trim) + $env.CURRENT_HOST = (cat /etc/hostname | str trim) + $env.CURRENT_USER = (whoami | str trim) +} + +export def select_host [] { + let hosts = flake-eval r#'builtins.attrNames flake.nixosConfigurations |> builtins.concatStringsSep " "'# --raw + | split words + | where $it != $env.CURRENT_HOST + + let selected = ["current", ...($hosts)] | input list -f + if $selected == "current" { + $env.CURRENT_HOST + } else { + $selected + } +} + +export def select_user [] { + let users = flake-eval r#'builtins.attrNames flake.homeConfigurations |> builtins.concatStringsSep " "'# --raw + | split words + | where $it != $env.CURRENT_USER + + let selected = ["current", ...($users)] | input list -f + if $selected == "current" { + $env.CURRENT_USER + } else { + $selected + } +} + +export def --wrapped flake-eval [ + expr: string + ...nix_args: string, +] { + + nix eval --quiet --no-pure-eval ...$nix_args --expr $' + let + flake = builtins.getFlake "($env.GIT_ROOT)"; + in ($expr) + ' +} + +# Recursively extract all objects with file fields from nested graph structure +export def flatten_graph_recursively [] { + def extract_objects [input] { + let type = ($input | describe) + + if ($type | str starts-with "list") or ($type | str starts-with "table") { + $input | each { |item| extract_objects $item } | flatten + } else if ($type | str starts-with "record") { + let result = if ($input | get -o file | default null) != null { [$input] } else { [] } + let imports_result = if ($input | get -o imports | default null) != null { + extract_objects ($input.imports) + } else { [] } + $result ++ $imports_result + } else { + [] + } + } + + extract_objects $in +} + +export def get_output_graph_files [ + identifier: string + flake_source: string +] { + let graph_file = (mktemp -t "module-graph.XXXX") + + try { + log debug $"Evaluating graph for ($identifier) with flake source ($flake_source)" + run-external "nix" "eval" "--json" $".#($identifier).graph" o> $graph_file + } catch { |err| + log error $"Failed to evaluate graph for ($identifier): ($err)" + exit 1 + } + + let files = open $graph_file + | from json + | flatten_graph_recursively + | where ($it | get -o file | default "" | str starts-with $"($flake_source)/") + | get file + | each { |file| $file | str replace $"($flake_source)/" "" } + | each { |file_path| + if ($file_path | path type) != "dir" { + $file_path + } else { + let default_nix_path = ([$file_path, "default.nix"] | path join) + if ($default_nix_path | path exists) { + $default_nix_path + } else { + $file_path + } + } + } | sort | uniq + + rm $graph_file + $files +} + +# Gets some basic flake info +# +# Returns a record like: +# { +# source_path +# hash +# } +export def get_flake_info [] { + let archive_info = try { + nix flake archive --json | from json + } catch { |err| + log error $"Failed to get flake archive info: ($err)" + exit 1 + } + + let source_path = $archive_info.path + if ($source_path | is-empty) or ($source_path == "null") { + log error "Failed to resolve flake source path." + exit 1 + } + + let hash = $source_path | path basename | str substring 0..11 + if ($hash | is-empty) { + log error "Failed to extract flake hash." + exit 1 + } + + { source_path: $source_path, hash: $hash } +} + +# Gets the revision and hash of a flake input +# +# Returns a record like: +# { +# rev +# hash +# } +export def get_flake_input [ + lock_file: string # The lock file to read from + input_name: string # The name of the input to check for changes. +] { + jq -r --arg input_name $input_name ' + (.root) as $r + | (.nodes[$r].inputs[$input_name] // $input_name) as $n0 + | ($n0 | if type=="string" then . else (.[0] // $input_name) end) as $n + | (.nodes[$n].locked // {}) + | { rev: (.rev // "none"), hash: (.narHash // "none") } + ' $lock_file e> /dev/null | from json +} + +export def has_flake_inputs_changed [ + git_range: string + input_name: string +] { + let split_range = $git_range | split row ".." + let prev_commit = $split_range | first + + let root_flake_changed = not (git diff --name-only $git_range -- flake.lock | str trim | is-empty) + if $root_flake_changed { + let prev_lock_file = mktemp -t "old-lock.XXXX" + git show $"($prev_commit):flake.lock" | save -f $prev_lock_file + let prev_input = get_flake_input $prev_lock_file $input_name + rm -f $prev_lock_file + + let curr_input = get_flake_input flake.lock $input_name + log info $"Previous ($input_name): ($prev_input), Current ($input_name): ($curr_input)" + if $prev_input.rev != $curr_input.rev or ($prev_input.hash != $curr_input.hash) { + log info $"Root flake input ($input_name) version changed from [rev=($prev_input.rev), hash=($prev_input.hash)] to [rev=($curr_input.rev), hash=($curr_input.hash)]." + return true + } + } + + return false +} + +# Check if a file has changed between git range +export def check_file_changed [ + git_range: string # The git range to check for changes. + ...files: string +] { + log info $"Checking files for changes: ($files)" + let split_range = $git_range | split row ".." + let prev_commit = $split_range | first + + let git_file_diffs = git diff --name-only $git_range -- ...$files | str trim | split row "\n" | where {|f| not ($f | is-empty) } + if ($git_file_diffs | is-empty) { + log info "No changes detected in the specified files" + return false; + } + + log info $"Git file diffs: ($git_file_diffs)" + mut changed_files = [ ]; + for file in $git_file_diffs { + let prev_blob = $"($prev_commit):($file)" + if (git cat-file -e $prev_blob | complete | get exit_code) != 0 { + log info $"File [($file)] is new or renamed; previous blob missing." + $changed_files = $changed_files | append $file + continue + } + + let old_file = mktemp -t "old-file.XXXX" + git show $prev_blob | save -f $old_file + + log info $"Checking differences between ($file) and ($old_file)" + let old_hash = nix hash path $old_file + if not ($file | path exists) { + rm -f $old_file + log info $"File [($file)] was deleted; treating as changed." + $changed_files = $changed_files | append $file + continue + } + + let cur_hash = nix hash path $file + rm -f $old_file + + if $old_hash != $cur_hash { + log info $"File [($file)] has changed." + $changed_files = $changed_files | append $file + } + } + + + return (($changed_files | length) > 0) +} diff --git a/lib/nu-lib/lib.nu b/lib/nu-lib/lib.nu new file mode 100644 index 000000000..cd6c7d832 --- /dev/null +++ b/lib/nu-lib/lib.nu @@ -0,0 +1,75 @@ +use std/log + +# Code ran at import time +export-env { + setup_logging $env.DEBUG? $env.SILENT? +} + +# Setup logging level and format +# +# Environment variables: +# DEBUG: set to "true" to enable debug logging +# SILENT: set to "true" to suppress logging +export def --env setup_logging [debug: string = "", silent: string = ""] { + log set-level ( + if $debug == "true" { 10 } + else if $silent == "true" { 40 } + else { 20 } + ) + log info $"Logging initialized. Level: ($env.NU_LOG_LEVEL)" + $env.NU_LOG_FORMAT = "%ANSI_START%%LEVEL%|%MSG%%ANSI_STOP%" +} + +# Checks if the provided variables are available from the environment. +export def check_required_vars [ + --exit # If the required vars aren't present exit the script with code 1 + ...vars: string # The variables to look for. +] { + mut missing_vars = [ ]; + for var in $vars { + let value = $env | get -o $var + log debug $"Checking required variable of [($var)] - present: \(($value != null)\)" + if $value == null { + $missing_vars = $missing_vars | append $var + } + } + + let result = (($missing_vars | length) > 0) + + if $result and $exit { + log debug $"Exiting early due to missing vars ($missing_vars | str join ",")" + exit 1 + } + + return $result +} + +# Quote segments of a nix attribute path to handle special characters +# e.g. "my-attr" becomes "\"my-attr\"". +# +# This will split the input string by dots, quote each segment, and then rejoin them with dots. +# If a segment is quoted already, it will be kept as is. +# Preserves .# flake references at the start. +export def quote_nix_segments [attribute_path: string] { + let has_flake_ref = $attribute_path | str starts-with ".#" + let path_to_process = if $has_flake_ref { $attribute_path | str substring 2.. } else { $attribute_path } + + let chars = $path_to_process | split chars + let result = $chars | reduce -f {segments: [], current: "", in_quotes: false} {|char, acc| + if $char == '"' { + {segments: $acc.segments, current: ($acc.current + $char), in_quotes: (not $acc.in_quotes)} + } else if $char == "." and not $acc.in_quotes { + {segments: ($acc.segments | append $acc.current), current: "", in_quotes: $acc.in_quotes} + } else { + {segments: $acc.segments, current: ($acc.current + $char), in_quotes: $acc.in_quotes} + } + } + + let all_segments = $result.segments | append $result.current + let quoted = $all_segments | each {|s| + if (($s | str starts-with '"') and ($s | str ends-with '"')) { $s } else { '"' + $s + '"' } + } + + let final = $quoted | str join "." + if $has_flake_ref { ".#" + $final } else { $final } +} diff --git a/lib/package.nix b/lib/package.nix index 7d2de48f4..2305ec4c9 100644 --- a/lib/package.nix +++ b/lib/package.nix @@ -6,14 +6,10 @@ let inherit (lib) getExe makeBinPath - filter - hasSuffix ; inherit (builtins) readFile - readDir - attrNames - concatStringsSep + path ; in rec { @@ -50,6 +46,8 @@ rec { + '' ${readFile "${sourceRoot}/${name}.nu"} ''; + + passthru.discovery = false; } // extraDrv ); @@ -59,8 +57,23 @@ rec { name, runtimeInputs ? [ ], sourceRoot, + libSource ? null, pkgs, }: + let + sourceStore = path { + path = sourceRoot; + name = "${name}-source"; + }; + libStore = + if libSource == null then + sourceStore + "/lib" + else + path { + path = libSource; + name = "${name}-lib"; + }; + in writeNuApplication { inherit name @@ -69,24 +82,13 @@ rec { pkgs ; - extraDrv.checkPhase = - let - libDir = "${sourceRoot}/lib"; - in - '' - mkdir -p $out/bin/lib - - ${ - if builtins.pathExists libDir then - readDir "${sourceRoot}/lib" - |> attrNames - |> filter (libFile: hasSuffix "nu" libFile) - |> map (libFile: "cp ${"${sourceRoot}/lib/${libFile}"} $out/bin/lib/${libFile}") - |> concatStringsSep "\n" - else - "" - } - ''; + extraDrv.checkPhase = '' + mkdir -p $out/bin/lib + if [ -d ${libStore} ]; then + find ${libStore} -maxdepth 1 -type f -name '*.nu' -print0 \ + | xargs -0 -r cp -t "$out/bin/lib" + fi + ''; }; } diff --git a/lib/strings.nix b/lib/strings.nix index f4920d507..80416da4b 100644 --- a/lib/strings.nix +++ b/lib/strings.nix @@ -3,7 +3,11 @@ ... }: let - inherit (builtins) elem concatStringsSep; + inherit (builtins) + elem + concatStringsSep + elemAt + ; inherit (lib) head tail @@ -15,7 +19,20 @@ let toUpper splitStringBy flatten + toInt ; + + scalar = 1024; + sizeTable = rec { + K = scalar; + Ki = K; + M = K * scalar; + Mi = M; + G = M * scalar; + Gi = G; + T = G * scalar; + Ti = T; + }; in rec { capitalise = @@ -30,10 +47,10 @@ rec { /* Splits a formatted string into its component parts based on common word boundaries. For example: - - "HelloWorld" -> [ "Hello", "World" ] - - "hello_world" -> [ "hello", "world" ] - - "hello-world" -> [ "hello", "world" ] - - "hello world" -> [ "hello", "world" ] + - "HelloWorld" -> [ "Hello", "World" ] (CamelCase) + - "hello_world" -> [ "hello", "world" ] (Snake case) + - "hello-world" -> [ "hello", "world" ] (Kebab case) + - "hello world" -> [ "hello", "world" ] (Space separated) */ splitFormattedString = str: @@ -60,4 +77,19 @@ rec { toSnakeCase = str: concatStringsSep "_" (map toLower (splitFormattedString str)); toKebabCase = str: concatStringsSep "-" (map toLower (splitFormattedString str)); + + parseSize = + s: + let + m = match "^([0-9]+)([KMGT]i?)B?$" s; + in + if m == null then + throw "Invalid size format: ${toString s}" + else + let + num = toInt (elemAt m 0); + unit = elemAt m 1; + multiplier = sizeTable.${unit} or (throw "Unknown size unit: ${unit}"); + in + num * multiplier; } diff --git a/modules/home-manager/purpose/development/editors/ai/skills/jujutsu/SKILL.md b/modules/home-manager/purpose/development/editors/ai/skills/jujutsu/SKILL.md index 380e4799a..39db7f7f8 100644 --- a/modules/home-manager/purpose/development/editors/ai/skills/jujutsu/SKILL.md +++ b/modules/home-manager/purpose/development/editors/ai/skills/jujutsu/SKILL.md @@ -90,11 +90,11 @@ jj resolve # Interactive conflict resolution **⚠️ Common mistakes:** -- ❌ `@~1` → ✅ `@-` -- ❌ `@^` → ✅ `@-` -- ❌ `@~-1` → ✅ `@+` +- ❌ `@~1` → ✅ `@-` (parent) +- ❌ `@^` → ✅ `@-` (parent) +- ❌ `@~-1` → ✅ `@+` (child) - ❌ `jj changes` → ✅ `jj log` or `jj diff` -- ❌ `a,b,c` → ✅ `a | b | c` +- ❌ `a,b,c` → ✅ `a | b | c` (union uses pipe, not comma) **Functions:** diff --git a/modules/nixos/services/default.nix b/modules/nixos/services/default.nix index 3a3bdedcb..0abcb6b49 100644 --- a/modules/nixos/services/default.nix +++ b/modules/nixos/services/default.nix @@ -5,5 +5,6 @@ _: { ./mcpo.nix ./metrics.nix ./tailscale.nix + ./woodpecker-nix.nix ]; } diff --git a/modules/nixos/services/woodpecker-nix.nix b/modules/nixos/services/woodpecker-nix.nix new file mode 100644 index 000000000..b2245c5a8 --- /dev/null +++ b/modules/nixos/services/woodpecker-nix.nix @@ -0,0 +1,1469 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) + mkEnableOption + mkOption + mkMerge + mkIf + mkDefault + types + literalExpression + concatStringsSep + optionalString + optionals + optional + ; + inherit (types) + attrsOf + str + enum + lines + listOf + nullOr + ints + package + ; + + cfg = config.services.woodpeckerNix; + stateDir = cfg.stateDir; + cacheDir = "${stateDir}/cache"; + + storeRealDir = "${stateDir}/nix/store-real"; + overlayDir = "${stateDir}/nix/overlay"; + upperDir = "${overlayDir}/upper"; + workDir = "${overlayDir}/work"; + storeMountDir = "${stateDir}/nix/store"; + + gcSizeThreshold = cfg.isolatedStore.gc.sizeThreshold; + propagationInterval = cfg.isolatedStore.propagation.interval; + + overlayRequested = cfg.isolatedStore.overlayfs.enable; + unprivilegedProxmoxLxc = config.boot.isContainer && !(config.proxmoxLXC.privileged or true); + fuseOverlayEnabled = overlayRequested && unprivilegedProxmoxLxc; + kernelOverlayEnabled = overlayRequested && !unprivilegedProxmoxLxc; + overlayEnabled = kernelOverlayEnabled || fuseOverlayEnabled; + + storeSizeBytes = lib.mine.strings.parseSize gcSizeThreshold; + + runtimeEnv = pkgs.buildEnv { + name = "woodpecker-ci-runtime"; + paths = [ cfg.isolatedStore.package ] ++ cfg.isolatedStore.runtimePackages; + pathsToLink = [ + "/bin" + "/etc" + "/share" + "/lib" + ]; + }; + runtimeStorePathName = baseNameOf runtimeEnv; + + allBootstrapPkgs = [ runtimeEnv ] ++ cfg.isolatedStore.bootstrapPackages; + + bootstrapHash = builtins.hashString "sha256" ( + concatStringsSep " " (map (p: builtins.unsafeDiscardStringContext (toString p)) allBootstrapPkgs) + ); + + nixConf = '' + allowed-users = * + substituters = ${concatStringsSep " " cfg.isolatedStore.substituters} + trusted-public-keys = ${concatStringsSep " " cfg.isolatedStore.trustedPublicKeys} + require-sigs = true + sandbox = true + keep-outputs = true + keep-derivations = true + max-jobs = ${toString cfg.isolatedStore.maxJobs} + experimental-features = nix-command flakes + ${cfg.isolatedStore.extraConfig} + ''; +in +{ + options.services.woodpeckerNix = { + enable = mkEnableOption "specialised Nix configuration for Woodpecker CI pipelines"; + + stateDir = mkOption { + type = str; + default = "/var/lib/woodpecker-nix"; + description = '' + Directory that holds the isolated Nix store, daemon socket, and config. + ''; + }; + + isolatedStore = { + enable = mkEnableOption "Whether to set up an isolated Nix store and daemon"; + + runtimePackages = mkOption { + type = listOf package; + default = with pkgs; [ + bashInteractive + coreutils-full + cacert + gitMinimal + gnutar + gzip + gnugrep + findutils + curl + ]; + defaultText = literalExpression '' + with pkgs; [ + bashInteractive coreutils-full cacert gitMinimal + gnutar gzip gnugrep findutils curl + ] + ''; + description = '' + Packages whose closures are copied into the CI store **and** whose + `bin/` directories are exposed to pipeline containers via `PATH`. + + The module creates a merged `buildEnv` from these packages plus + {option}`package` (the Nix/Lix daemon) and injects `PATH`, + `SSL_CERT_FILE`, and related variables pointing into that env. + ''; + }; + + bootstrapPackages = mkOption { + type = listOf package; + default = [ ]; + example = literalExpression "[ pkgs.hello ]"; + description = '' + Extra packages whose closures are copied into the CI store during + initialisation but are **not** added to `PATH`. + ''; + }; + + package = mkOption { + type = package; + default = config.nix.package; + defaultText = literalExpression "config.nix.package"; + description = '' + The Nix package to run inside the isolated daemon. + Always included in the runtime environment. + ''; + }; + + maxJobs = mkOption { + type = ints.unsigned; + default = 8; + description = "Maximum number of parallel builds the isolated daemon may run."; + }; + + substituters = mkOption { + type = listOf str; + default = [ ]; + description = '' + Binary-cache substituters for the isolated daemon. + When left empty the host's substituters are inherited automatically. + ''; + }; + + trustedPublicKeys = mkOption { + type = listOf str; + default = [ ]; + description = '' + Trusted public keys that correspond to the configured substituters. + When left empty the host's keys are inherited automatically. + ''; + }; + + extraConfig = mkOption { + type = lines; + default = ""; + description = "Extra lines appended verbatim to the isolated daemon's nix.conf."; + }; + + overlayfs = { + enable = mkEnableOption "Whether to use overlayfs for the isolated Nix store" // { + default = true; + }; + + upperDir = mkOption { + type = str; + default = "${stateDir}/nix/overlay/upper"; + defaultText = literalExpression "\${stateDir}/nix/overlay/upper"; + description = '' + Path for the overlayfs upper (writable) layer. + New store paths from container builds land here. + ''; + }; + + workDir = mkOption { + type = str; + default = "${stateDir}/nix/overlay/work"; + defaultText = literalExpression "\${stateDir}/nix/overlay/work"; + description = '' + Path for the overlayfs work directory. + Required by overlayfs; not used for storage. + ''; + }; + }; + + gc = { + enable = mkEnableOption "Whether to set up periodic garbage collection for the isolated store" // { + default = true; + }; + + interval = mkOption { + type = str; + default = "weekly"; + description = "systemd calendar expression for the GC timer."; + }; + + olderThan = mkOption { + type = str; + default = "14d"; + description = "Delete store paths older than this threshold."; + }; + + sizeThreshold = mkOption { + type = str; + default = "20G"; + description = '' + Minimum store size before GC is triggered. + Prevents unnecessary GC when the store is small. + Accepts units: K, Ki, M, Mi, G, Gi, T, Ti. + ''; + }; + + minInterval = mkOption { + type = str; + default = "7d"; + description = '' + Minimum time between GC runs. + Prevents GC thrashing when the store hovers near the size threshold. + ''; + }; + + maxFreed = mkOption { + type = nullOr str; + default = null; + description = '' + Maximum amount of data to free in a single GC run. + Prevents overly aggressive collection. + ''; + }; + }; + + compaction = { + enable = mkEnableOption "Whether to compact the overlay-backed store periodically" // { + default = false; + }; + + interval = mkOption { + type = str; + default = "monthly"; + description = "systemd calendar expression for the compaction timer."; + }; + + promoteStableAfter = mkOption { + type = nullOr str; + default = "7d"; + description = '' + Promote stable store paths from the overlay upper layer into the reusable + lower layer after they have remained untouched for this long. + Set to `null` to disable promotion during compaction. + ''; + }; + }; + + propagation = { + enable = mkEnableOption "Whether to run a propagation sidecar that audits new store paths" // { + default = true; + }; + + interval = mkOption { + type = str; + default = "15m"; + description = "systemd calendar expression for the propagation audit timer."; + }; + + mode = mkOption { + type = enum [ + "audit" + "promote" + ]; + default = "audit"; + description = '' + Whether propagation only audits the upper layer or also promotes stable + store paths into the reusable lower layer. + ''; + }; + + promoteStableAfter = mkOption { + type = str; + default = "7d"; + description = '' + Minimum age before a store path is promoted from the upper layer into the + lower layer when propagation runs in promote mode. + ''; + }; + }; + }; + + cache = mkOption { + type = enum [ + "none" + "git" + "all" + ]; + default = "git"; + description = '' + Level of caching to share with Woodpecker CI pipelines. + + Options: + * none: + Don't share any caches. Each pipeline starts with an empty cache directory. + This will result in a significant slowdown for pipelines while they unpack + and populate flake inputs into the git cache. + + * git: + Share the Nix git fetch cache (gitv3) across pipeline containers. + Mounted at `/root/.cache/nix/gitv3` inside each container. + + * all: + Share the Nix eval cache across pipeline containers. + + The eval cache is a SQLite database and concurrent writes from + parallel jobs can cause lock contention. + Safe to enable when `WOODPECKER_MAX_WORKFLOWS=1`. + + When enabled the entire cache directory is mounted at + `/root/.cache/nix` (which also covers the git cache). + ''; + }; + + cachePopulate = mkOption { + type = listOf str; + default = [ ]; + description = '' + Flake URLs to prefetch on host into shared gitv3 cache. + Each entry is cloned, locked at repo root, then scanned for nested flakes. + Only used with `cache = "git"`. + ''; + }; + + cachePopulateInterval = mkOption { + type = nullOr str; + default = null; + description = '' + systemd calendar expression for periodic gitv3 cache prewarming. + `null` disables timer. + ''; + }; + + woodpecker = { + agents = mkOption { + type = listOf str; + default = [ ]; + description = '' + Names of Woodpecker agents to configure with the shared Nix store. + Must match the attribute names under `services.woodpecker-agents.agents`. + ''; + }; + + extraVolumes = mkOption { + type = listOf str; + default = [ ]; + description = '' + Additional Docker volume specifications appended to + `WOODPECKER_BACKEND_DOCKER_VOLUMES`. + ''; + }; + + extraEnvironment = mkOption { + type = attrsOf str; + default = { }; + description = '' + Additional entries appended to `WOODPECKER_ENVIRONMENT`. + ''; + }; + }; + }; + + config = mkMerge [ + { + assertions = [ + { + assertion = + !( + cfg.isolatedStore.compaction.enable + && cfg.isolatedStore.gc.enable + && cfg.isolatedStore.compaction.interval == cfg.isolatedStore.gc.interval + ); + message = '' + services.woodpeckerNix.isolatedStore.compaction.interval and + services.woodpeckerNix.isolatedStore.gc.interval must differ because + compaction and GC both mutate the overlay-backed store and can race. + ''; + } + ]; + } + + (mkIf (cfg.enable && cfg.cache != "none") ( + let + inherit + ( + if cfg.cache == "git" then + { + hostPath = "${cacheDir}/nix/gitv3"; + containerPath = "/root/.cache/nix/gitv3:ro"; + } + else if cfg.cache == "all" then + { + hostPath = cacheDir; + containerPath = "/root/.cache/nix"; + } + else + { + hostPath = null; + containerPath = null; + } + ) + hostPath + containerPath + ; + in + { + services.woodpeckerNix.woodpecker.extraVolumes = [ "${hostPath}:${containerPath}" ]; + systemd.tmpfiles.settings."woodpecker-nix"."${cacheDir}".d = { + mode = "0700"; + user = "woodpecker-nix"; + group = "woodpecker-nix"; + }; + } + )) + + (mkIf (cfg.enable && cfg.isolatedStore.enable) { + services.woodpeckerNix = { + isolatedStore = { + substituters = mkDefault config.nix.settings.substituters; + trustedPublicKeys = mkDefault config.nix.settings.trusted-public-keys; + }; + + woodpecker = { + extraVolumes = [ + "${stateDir}/nix:/nix" + "${stateDir}/nix/var/nix/daemon-socket:/nix/var/nix/daemon-socket" + "${stateDir}/nix/var/nix/profiles:/nix/var/nix/profiles" + ]; + extraEnvironment = + let + sslCertPath = "${runtimeEnv}/etc/ssl/certs/ca-bundle.crt"; + in + { + HOME = "/root"; + NIX_REMOTE = "daemon"; + PATH = "${runtimeEnv}/bin:/bin:/usr/bin"; + SSL_CERT_FILE = sslCertPath; + NIX_SSL_CERT_FILE = sslCertPath; + GIT_SSL_CAINFO = sslCertPath; + }; + }; + }; + + users = { + groups.woodpecker-nix = { }; + users.woodpecker-nix = { + isSystemUser = true; + group = "woodpecker-nix"; + description = "Woodpecker CI Nix store daemon user"; + }; + }; + + systemd = { + tmpfiles.settings."woodpecker-nix" = { + "${stateDir}/etc/nix/nix.conf"."f+" = { + mode = "0644"; + argument = nixConf; + user = "woodpecker-nix"; + group = "woodpecker-nix"; + }; + } + // ( + # All intermediate parents explicitly listed so tmpfiles does + # not create them as root before subdir rules kick in. + [ + stateDir + "${stateDir}/etc" + "${stateDir}/etc/nix" + "${stateDir}/nix" + "${stateDir}/nix/store" + "${stateDir}/nix/var" + "${stateDir}/nix/var/nix" + "${stateDir}/nix/var/nix/daemon-socket" + "${stateDir}/nix/var/nix/db" + "${stateDir}/nix/var/nix/profiles" + "${stateDir}/nix/var/nix/gcroots" + ] + ++ (optionals cfg.isolatedStore.gc.enable [ + "${stateDir}/gc-home" + "${stateDir}/gc-home/.cache" + "${stateDir}/gc-home/.config" + "${stateDir}/gc-home/.local" + "${stateDir}/gc-home/.local/share" + ]) + ++ (optionals overlayEnabled [ + storeRealDir + overlayDir + upperDir + workDir + ]) + |> map ( + p: + lib.nameValuePair p { + d = { + mode = "0700"; + user = "woodpecker-nix"; + group = "woodpecker-nix"; + }; + } + ) + |> lib.listToAttrs + ); + + services = { + woodpecker-nix-init = { + description = "Bootstrap isolated Nix store for Woodpecker CI"; + requiredBy = [ "woodpecker-nix-daemon.service" ]; + before = [ + "woodpecker-nix-mount.service" + "woodpecker-nix-daemon.service" + ]; + conflicts = [ "woodpecker-nix-mount.service" ]; + + serviceConfig = { + Type = "oneshot"; + + User = "woodpecker-nix"; + + NoNewPrivileges = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + + PrivateDevices = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + RestrictNamespaces = true; + + # nix copy needs CAP_CHOWN/+FOWNER to set ownership on + # destination store paths, and CAP_DAC_OVERRIDE to read + # source paths from host /nix/store (0444, root-owned). + CapabilityBoundingSet = [ + "CAP_CHOWN" + "CAP_DAC_OVERRIDE" + "CAP_FOWNER" + ]; + AmbientCapabilities = [ + "CAP_CHOWN" + "CAP_DAC_OVERRIDE" + "CAP_FOWNER" + ]; + SystemCallFilter = [ + "@system-service" + "rename" + "renameat" + "renameat2" + ]; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + MemoryDenyWriteExecute = true; + + ReadWritePaths = [ stateDir ]; + }; + + path = [ + cfg.isolatedStore.package + pkgs.uutils-coreutils-noprefix + ] + ++ (optional overlayEnabled pkgs.util-linux); + + script = + let + bootstrapStorePaths = concatStringsSep " " (map (p: ''"${p}"'') allBootstrapPkgs); + overlaySetup = optionalString overlayEnabled '' + echo ">>> Preparing directories for overlayfs ..." + + mkdir -p "${storeRealDir}" + mkdir -p "${upperDir}" + mkdir -p "${workDir}" + + # Copy existing store content into store-real (lower layer). + # Copy-then-delete avoids rename-at syscall issues with seccomp. + if [ -d "${stateDir}/nix/store" ] && ! mountpoint -q "${stateDir}/nix/store"; then + echo " Migrating existing store entries to store-real ..." + HAD_ENTRIES=0 + for entry in "${stateDir}/nix/store"/*; do + [ -e "$entry" ] || continue + HAD_ENTRIES=1 + name="$(basename "$entry")" + if [ -e "${storeRealDir}/$name" ]; then + echo " Skipping $name — already exists in store-real" + chmod -R u+w "$entry" 2>/dev/null || true + rm -rf "$entry" + else + echo " Copying $name ..." + cp -a "$entry" "${storeRealDir}/" + # Source no longer needed once copy confirmed success + chmod -R u+w "$entry" 2>/dev/null || true + rm -rf "$entry" + fi + done + if [ "$HAD_ENTRIES" = 1 ] && [ -d "${stateDir}/nix/store" ] && [ -z "$(ls -A "${stateDir}/nix/store" 2>/dev/null)" ]; then + rmdir "${stateDir}/nix/store" + fi + fi + + mkdir -p "${stateDir}/nix/store" + + echo " Overlayfs mountpoint ready" + ''; + in + '' + set -euo pipefail + + CURRENT_HASH="${bootstrapHash}" + HASH_FILE="${stateDir}/.bootstrap-hash" + if [ -f "$HASH_FILE" ] && [ "$(cat "$HASH_FILE")" = "$CURRENT_HASH" ]; then + echo ">>> Store is up-to-date (hash: $CURRENT_HASH). Verifying profiles ..." + else + echo ">>> Bootstrapping CI Nix store at ${stateDir} ..." + + for pkg in ${bootstrapStorePaths}; do + echo " Copying closure: $pkg ..." + nix copy --no-check-sigs --to "local?root=${stateDir}" "$pkg" + done + + printf '%s' "$CURRENT_HASH" > "$HASH_FILE" + echo ">>> Store bootstrap complete (hash: $CURRENT_HASH)." + fi + + ${overlaySetup} + + echo ">>> Reconstructing profile symlinks ..." + ln -sfn ${runtimeEnv} "${stateDir}/nix/var/nix/profiles/default-1-link" + ln -sfn /nix/var/nix/profiles/default-1-link "${stateDir}/nix/var/nix/profiles/default" + + mkdir -p "${stateDir}/nix/var/nix/profiles/per-user/root" + + echo ">>> Registering GC roots ..." + ln -sfn ${runtimeEnv} "${stateDir}/nix/var/nix/gcroots/runtime-env" + + echo ">>> Init complete." + ''; + }; + + woodpecker-nix-mount = mkIf overlayEnabled { + description = "Mount overlayfs for Woodpecker CI store"; + requiredBy = [ "woodpecker-nix-daemon.service" ]; + before = [ "woodpecker-nix-daemon.service" ]; + after = [ "woodpecker-nix-init.service" ]; + requires = [ "woodpecker-nix-init.service" ]; + partOf = [ "woodpecker-nix-daemon.service" ]; + + serviceConfig = { + Type = if fuseOverlayEnabled then "simple" else "oneshot"; + RemainAfterExit = !fuseOverlayEnabled; + + User = "woodpecker-nix"; + Group = "woodpecker-nix"; + + # Mount is host-visible; cannot isolate filesystem namespace. + # ProtectKernelModules/Logs/Tunables blocked FUSE daemon. + ProtectClock = true; + ProtectHostname = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + + # Kernel overlay and FUSE daemon both need CAP_SYS_ADMIN + # (FUSE needs it for unmount cleanup). + CapabilityBoundingSet = [ "CAP_SYS_ADMIN" ]; + AmbientCapabilities = [ "CAP_SYS_ADMIN" ]; + SystemCallFilter = [ + "@system-service" + "@mount" + ]; + SystemCallArchitectures = "native"; + MemoryDenyWriteExecute = true; + NoNewPrivileges = false; + + # FUSE daemon: poll store paths until mount is responsive + # so dependent services don't race with first FS access. + ExecStartPost = optionals fuseOverlayEnabled [ + (pkgs.writeShellScript "wait-fuse-ready" '' + set -euo pipefail + for i in $(seq 1 15); do + entry="$(ls "${storeRealDir}" 2>/dev/null | head -1 || true)" + if [ -n "$entry" ] && [ -e "${stateDir}/nix/store/$entry" ]; then + exit 0 + fi + sleep 1 + done + echo "Timed out waiting for fuse overlay to become responsive" + exit 1 + '') + ]; + }; + + path = [ + pkgs.util-linux + ] + ++ optionals fuseOverlayEnabled [ + pkgs.fuse-overlayfs + pkgs.fuse3 + ] + # poll helper for fuse readiness + ++ optionals fuseOverlayEnabled [ pkgs.bashInteractive ]; + + script = + if kernelOverlayEnabled then + '' + set -euo pipefail + MOUNTPOINT="${stateDir}/nix/store" + if mountpoint -q "$MOUNTPOINT"; then + echo ">>> Overlay already mounted on $MOUNTPOINT" + exit 0 + fi + echo ">>> Mounting kernel overlayfs on $MOUNTPOINT ..." + mount -t overlay overlay \ + -o lowerdir=${storeRealDir},upperdir=${upperDir},workdir=${workDir},index=on,redirect_dir=on \ + "$MOUNTPOINT" + echo ">>> Kernel overlay mount complete." + '' + else + '' + set -euo pipefail + MOUNTPOINT="${stateDir}/nix/store" + echo ">>> Mounting fuse-overlayfs on $MOUNTPOINT ..." + exec fuse-overlayfs -f -o allow_other,lowerdir=${storeRealDir},upperdir=${upperDir},workdir=${workDir},index=on,redirect_dir=on \ + "$MOUNTPOINT" + ''; + + preStop = '' + MOUNTPOINT="${stateDir}/nix/store" + if mountpoint -q "$MOUNTPOINT"; then + umount -l "$MOUNTPOINT" + fi + ''; + }; + + woodpecker-nix-daemon = { + description = "Woodpecker CI Shared Nix Store Daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ + "woodpecker-nix-init.service" + "network-online.target" + ] + ++ (optionals overlayEnabled [ "woodpecker-nix-mount.service" ]); + wants = [ "network-online.target" ]; + requires = [ + "woodpecker-nix-init.service" + ] + ++ (optionals overlayEnabled [ "woodpecker-nix-mount.service" ]); + restartTriggers = [ (pkgs.writeText "woodpecker-nix.conf" nixConf) ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${cfg.isolatedStore.package}/bin/nix --extra-experimental-features 'nix-command flakes' daemon"; + Environment = "NIX_CONF_DIR=${stateDir}/etc/nix"; + BindPaths = [ "${stateDir}/nix:/nix" ]; + User = "woodpecker-nix"; + Group = "woodpecker-nix"; + ReadWritePaths = [ stateDir ]; + + NoNewPrivileges = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + + PrivateDevices = true; + PrivateMounts = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + # User namespaces required for nix sandbox builds; + # blocked when enabled. + RestrictNamespaces = false; + + CapabilityBoundingSet = ""; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; + SystemCallFilter = [ "@system-service" ]; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + MemoryDenyWriteExecute = true; + + LimitNOFILE = 65536; + Restart = "on-failure"; + RestartSec = "5s"; + TimeoutStartSec = "120"; + KillMode = "mixed"; + }; + }; + + woodpecker-nix-compact = mkIf (cfg.isolatedStore.compaction.enable && overlayEnabled) { + description = "Compact the overlay-backed Woodpecker CI shared Nix store"; + after = [ + "woodpecker-nix-daemon.service" + "woodpecker-nix-mount.service" + ]; + wants = [ "woodpecker-nix-mount.service" ]; + requires = [ + "woodpecker-nix-daemon.service" + "woodpecker-nix-mount.service" + ]; + conflicts = [ + "woodpecker-nix-daemon.service" + "woodpecker-nix-gc.service" + "woodpecker-nix-propagate.service" + ]; + + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + NoNewPrivileges = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + RestrictNamespaces = true; + CapabilityBoundingSet = [ + "CAP_SYS_ADMIN" + "CAP_CHOWN" + "CAP_DAC_OVERRIDE" + "CAP_FOWNER" + ]; + AmbientCapabilities = [ + "CAP_SYS_ADMIN" + "CAP_CHOWN" + "CAP_DAC_OVERRIDE" + "CAP_FOWNER" + ]; + SystemCallFilter = [ + "@system-service" + "@mount" + ]; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + MemoryDenyWriteExecute = true; + ReadWritePaths = [ stateDir ]; + }; + + path = [ + pkgs.coreutils + pkgs.util-linux + ]; + + script = '' + set -euo pipefail + echo ">>> Compaction: stopping daemon" + systemctl stop woodpecker-nix-daemon.service || true + systemctl stop woodpecker-nix-mount.service || true + sleep 2 + + echo ">>> Compaction: removing whiteout'd lower-layer paths" + for entry in "${storeRealDir}"/*; do + [ -e "$entry" ] || continue + name="$(basename "$entry")" + if [ -e "${upperDir}/.wh.$name" ]; then + echo " Removing whiteouted lower path $name" + rm -rf "$entry" + fi + done + + ${optionalString (cfg.isolatedStore.compaction.promoteStableAfter != null) '' + cutoff_epoch=$(date -d "${cfg.isolatedStore.compaction.promoteStableAfter} ago" +%s 2>/dev/null || true) + if [ -n "$cutoff_epoch" ]; then + echo ">>> Compaction: promoting stable paths from upper to lower" + for entry in "${upperDir}"/*; do + [ -e "$entry" ] || continue + [ -d "$entry" ] || continue + name="$(basename "$entry")" + case "$name" in + info|locks|temp|log|db) continue ;; + esac + if [[ "$name" =~ ^[a-z0-9]{32}-.*$ ]]; then + mtime_epoch=$(stat -c %Y "$entry") + if [ "$mtime_epoch" -le "$cutoff_epoch" ]; then + dest="${storeRealDir}/$name" + if [ -e "$dest" ]; then + rm -rf "$entry" + else + cp -a "$entry" "${storeRealDir}/" + rm -rf "$entry" + fi + fi + fi + done + fi + ''} + + chown -R woodpecker-nix:woodpecker-nix "${storeRealDir}" "${upperDir}" "${workDir}" "${storeMountDir}" 2>/dev/null || true + + echo ">>> Compaction: remounting overlay and restarting daemon" + systemctl start woodpecker-nix-mount.service || true + systemctl start woodpecker-nix-daemon.service || true + ''; + }; + + woodpecker-nix-healthcheck = mkIf (cfg.isolatedStore.enable && overlayEnabled) { + description = "Verify the overlay-backed Woodpecker CI store remains responsive"; + after = [ + "woodpecker-nix-daemon.service" + "woodpecker-nix-mount.service" + ]; + requires = [ "woodpecker-nix-daemon.service" ]; + + serviceConfig = { + Type = "oneshot"; + User = "root"; + Group = "root"; + NoNewPrivileges = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + RestrictNamespaces = true; + CapabilityBoundingSet = [ + "CAP_SYS_ADMIN" + "CAP_CHOWN" + "CAP_DAC_OVERRIDE" + "CAP_FOWNER" + ]; + AmbientCapabilities = [ + "CAP_SYS_ADMIN" + "CAP_CHOWN" + "CAP_DAC_OVERRIDE" + "CAP_FOWNER" + ]; + SystemCallFilter = [ + "@system-service" + "@mount" + ]; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + MemoryDenyWriteExecute = true; + ReadWritePaths = [ stateDir ]; + }; + + path = [ + pkgs.coreutils + pkgs.util-linux + ]; + + script = '' + set -euo pipefail + CHECK_PATH="${storeMountDir}/${runtimeStorePathName}" + if stat "$CHECK_PATH" >/dev/null 2>&1; then + exit 0 + fi + echo ">>> Healthcheck: overlay became stale; remounting store" + systemctl stop woodpecker-nix-daemon.service || true + systemctl stop woodpecker-nix-mount.service || true + sleep 2 + systemctl start woodpecker-nix-mount.service || true + systemctl start woodpecker-nix-daemon.service || true + ''; + }; + }; + + timers = { + woodpecker-nix-compact = mkIf (cfg.isolatedStore.compaction.enable && overlayEnabled) { + description = "Periodically compact the Woodpecker CI shared Nix store"; + wantedBy = [ "timers.target" ]; + after = [ "woodpecker-nix-daemon.service" ]; + requires = [ "woodpecker-nix-daemon.service" ]; + + timerConfig = { + OnCalendar = cfg.isolatedStore.compaction.interval; + Persistent = true; + RandomizedDelaySec = "30m"; + }; + }; + + woodpecker-nix-healthcheck = mkIf (cfg.isolatedStore.enable && overlayEnabled) { + description = "Periodically verify the Woodpecker CI shared Nix store overlay"; + wantedBy = [ "timers.target" ]; + after = [ "woodpecker-nix-daemon.service" ]; + requires = [ "woodpecker-nix-daemon.service" ]; + + timerConfig = { + OnCalendar = "*:0/5"; + Persistent = true; + RandomizedDelaySec = "1m"; + }; + }; + }; + }; + }) + + (mkIf (cfg.enable && cfg.isolatedStore.enable && fuseOverlayEnabled) { + warnings = [ "using fuse-overlayfs fallback in unprivileged Proxmox LXC" ]; + programs.fuse.userAllowOther = mkDefault true; + }) + + (mkIf (cfg.enable && cfg.cache == "git" && cfg.cachePopulate != [ ]) { + systemd = { + services.woodpecker-nix-populate-cache = { + description = "Populate Woodpecker CI gitv3 cache"; + after = [ "woodpecker-nix-daemon.service" ]; + requires = [ "woodpecker-nix-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "oneshot"; + User = "woodpecker-nix"; + Group = "woodpecker-nix"; + Environment = [ + "NIX_REMOTE=unix://${stateDir}/nix/var/nix/daemon-socket/socket" + "XDG_CACHE_HOME=${cacheDir}" + "HOME=${stateDir}/gc-home" + ]; + NoNewPrivileges = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + RestrictNamespaces = true; + CapabilityBoundingSet = ""; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; + SystemCallFilter = [ "@system-service" ]; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + MemoryDenyWriteExecute = true; + ReadWritePaths = [ stateDir ]; + }; + + path = [ + cfg.isolatedStore.package + pkgs.coreutils + pkgs.gitMinimal + ]; + + script = '' + set -euo pipefail + mkdir -p "${cacheDir}/nix/gitv3" + + while IFS= read -r entry; do + [ -n "$entry" ] || continue + + clone_dir="/tmp/woodpecker-cache-$$" + rm -rf "$clone_dir" + + echo ">>> Cloning $entry to $clone_dir ..." + nix flake clone "$entry" --dest "$clone_dir" + + echo ">>> Locking $entry ..." + nix flake lock --override-input devenv-root "file+file:///dev/null" "$entry" + + find "$clone_dir" -maxdepth 3 -name flake.nix | while IFS= read -r flake_file; do + flake_dir=$(dirname "$flake_file") + rel_dir=$(realpath --relative-to="$clone_dir" "$flake_dir") + [ "$rel_dir" = "." ] && continue + [ -f "$flake_dir/flake.lock" ] || continue + + echo ">>> Locking $entry?dir=$rel_dir ..." + nix flake lock --override-input devenv-root "file+file:///dev/null" "$entry?dir=$rel_dir" + done + + rm -rf "$clone_dir" + done <<'EOF' + ${concatStringsSep "\n" cfg.cachePopulate} + EOF + ''; + }; + }; + }) + + (mkIf + (cfg.enable && cfg.cache == "git" && cfg.cachePopulate != [ ] && cfg.cachePopulateInterval != null) + { + systemd.timers.woodpecker-nix-populate-cache = { + description = "Periodically populate Woodpecker CI gitv3 cache"; + wantedBy = [ "timers.target" ]; + after = [ "woodpecker-nix-daemon.service" ]; + requires = [ "woodpecker-nix-daemon.service" ]; + timerConfig = { + OnCalendar = cfg.cachePopulateInterval; + Persistent = true; + }; + }; + } + ) + + (mkIf (cfg.enable && cfg.isolatedStore.enable && cfg.isolatedStore.gc.enable) { + systemd = { + services.woodpecker-nix-gc = { + description = "Garbage-collect the Woodpecker CI shared Nix store"; + after = [ "woodpecker-nix-daemon.service" ]; + requires = [ "woodpecker-nix-daemon.service" ]; + + serviceConfig = { + Type = "oneshot"; + + User = "woodpecker-nix"; + Group = "woodpecker-nix"; + + Environment = [ + "HOME=${stateDir}/gc-home" + "XDG_CACHE_HOME=${stateDir}/gc-home/.cache" + "XDG_CONFIG_HOME=${stateDir}/gc-home/.config" + "XDG_DATA_HOME=${stateDir}/gc-home/.local/share" + "STATE_DIR=${stateDir}" + "STORE_SIZE_FILE=${stateDir}/.store-size" + "SIZE_THRESHOLD_BYTES=${toString storeSizeBytes}" + "GC_OLDER_THAN=${cfg.isolatedStore.gc.olderThan}" + "MIN_INTERVAL=${cfg.isolatedStore.gc.minInterval}" + "NIX_REMOTE=unix://${stateDir}/nix/var/nix/daemon-socket/socket" + ] + ++ (optional ( + cfg.isolatedStore.gc.maxFreed != null + ) "GC_MAX_FREED=${cfg.isolatedStore.gc.maxFreed}") + ++ (optional overlayEnabled "UPPER_DIR=${upperDir}"); + NoNewPrivileges = true; + + ProtectClock = true; + ProtectHostname = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + + PrivateDevices = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + RestrictNamespaces = true; + + CapabilityBoundingSet = ""; + RestrictAddressFamilies = [ "AF_UNIX" ]; + SystemCallFilter = [ "@system-service" ]; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + MemoryDenyWriteExecute = true; + + ReadWritePaths = [ stateDir ]; + }; + + script = '' + set -euo pipefail + + SIZE_THRESHOLD_BYTES="$SIZE_THRESHOLD_BYTES" + MIN_INTERVAL="$MIN_INTERVAL" + LAST_GC_FILE="$STATE_DIR/.last-gc" + STORE_SIZE_FILE="$STATE_DIR/.store-size" + + if [ -f "$STORE_SIZE_FILE" ]; then + read -r CURRENT_REAL_SIZE CURRENT_UPPER_SIZE < "$STORE_SIZE_FILE" + if [ -z "$CURRENT_UPPER_SIZE" ]; then + CURRENT_REAL_SIZE=$(cat "$STORE_SIZE_FILE") + CURRENT_UPPER_SIZE=0 + fi + else + CURRENT_REAL_SIZE=0 + CURRENT_UPPER_SIZE=0 + fi + + CURRENT_SIZE=$(( CURRENT_REAL_SIZE + CURRENT_UPPER_SIZE )) + SIZE_HUMAN=$(( CURRENT_SIZE / 1024 / 1024 )) + THRESHOLD_HUMAN=$(( SIZE_THRESHOLD_BYTES / 1024 / 1024 )) + + if [ "$CURRENT_SIZE" -lt "$SIZE_THRESHOLD_BYTES" ]; then + echo ">>> GC: Store size (''${SIZE_HUMAN}MB; store-real=''${CURRENT_REAL_SIZE}B, upper=''${CURRENT_UPPER_SIZE}B) below threshold (''${THRESHOLD_HUMAN}MB). Skipping." + exit 0 + fi + + # --- Time gate --- + if [ -f "$LAST_GC_FILE" ]; then + LAST_GC_EPOCH=$(cat "$LAST_GC_FILE") + NOW_EPOCH=$(date +%s) + CUTOFF_EPOCH=$(date -d "$MIN_INTERVAL ago" +%s 2>/dev/null || date -v-''${MIN_INTERVAL} +%s 2>/dev/null || echo 0) + if [ "$LAST_GC_EPOCH" -gt "$CUTOFF_EPOCH" ]; then + REMAINING=$(( LAST_GC_EPOCH - CUTOFF_EPOCH )) + ELAPSED=$(( NOW_EPOCH - LAST_GC_EPOCH )) + echo ">>> GC: Last GC was ''${ELAPSED}s ago, minimum interval not met (''${REMAINING}s remaining). Skipping." + exit 0 + fi + fi + + echo ">>> GC: Store size (''${SIZE_HUMAN}MB) exceeds threshold (''${THRESHOLD_HUMAN}MB). Running GC..." + + export NIX_REMOTE="unix://${stateDir}/nix/var/nix/daemon-socket/socket" + ${cfg.isolatedStore.package}/bin/nix-collect-garbage \ + --delete-older-than "${cfg.isolatedStore.gc.olderThan}" \ + ${optionalString ( + cfg.isolatedStore.gc.maxFreed != null + ) ''--max-freed "${cfg.isolatedStore.gc.maxFreed}"''} + + date +%s > "$LAST_GC_FILE" + + # Update store size cache after GC + NEW_REAL_SIZE=$(du -sb "$STATE_DIR/nix/store-real" 2>/dev/null | awk '{print $1}' || echo 0) + NEW_UPPER_SIZE=$(du -sb "$UPPER_DIR" 2>/dev/null | awk '{print $1}' || echo 0) + echo "$NEW_REAL_SIZE $NEW_UPPER_SIZE" > "$STORE_SIZE_FILE" + + echo ">>> GC: Complete." + ''; + }; + + timers.woodpecker-nix-gc = { + description = "Periodically garbage-collect the Woodpecker CI shared Nix store"; + wantedBy = [ "timers.target" ]; + after = [ "woodpecker-nix-daemon.service" ]; + requires = [ "woodpecker-nix-daemon.service" ]; + + timerConfig = { + OnCalendar = cfg.isolatedStore.gc.interval; + Persistent = true; + RandomizedDelaySec = "30m"; + }; + }; + }; + }) + + (mkIf + (cfg.enable && cfg.isolatedStore.enable && cfg.isolatedStore.propagation.enable && overlayEnabled) + { + systemd = { + services.woodpecker-nix-propagate = { + description = "Audit new store paths in overlayfs upper layer"; + after = [ + "woodpecker-nix-daemon.service" + ]; + requires = [ "woodpecker-nix-daemon.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ + cfg.isolatedStore.package + pkgs.gawk + ]; + + serviceConfig = { + Type = "simple"; + User = "woodpecker-nix"; + Group = "woodpecker-nix"; + Environment = [ + "STATE_DIR=${stateDir}" + "UPPER_DIR=${upperDir}" + "STORE_REAL_DIR=${storeRealDir}" + "JOURNAL=${stateDir}/propagation-journal" + "STORE_SIZE_FILE=${stateDir}/.store-size" + "NIX_REMOTE=unix://${stateDir}/nix/var/nix/daemon-socket/socket" + "PROPAGATION_MODE=${cfg.isolatedStore.propagation.mode}" + "PROMOTE_STABLE_AFTER=${cfg.isolatedStore.propagation.promoteStableAfter}" + ]; + NoNewPrivileges = true; + ProtectClock = true; + ProtectHostname = true; + ProtectKernelModules = true; + ProtectKernelLogs = true; + ProtectKernelTunables = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + LockPersonality = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectHome = true; + ProtectSystem = "strict"; + RestrictNamespaces = true; + CapabilityBoundingSet = ""; + RestrictAddressFamilies = [ "AF_UNIX" ]; + SystemCallFilter = [ "@system-service" ]; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + MemoryDenyWriteExecute = true; + ReadWritePaths = [ stateDir ]; + ExecStart = pkgs.writeShellScript "woodpecker-nix-propagate" '' + set -euo pipefail + + touch "$JOURNAL" + + log() { + echo "[$(date -Iseconds)] propagate: $*" + } + + update_store_size() { + local real_bytes upper_bytes + real_bytes=$(du -sb "$STORE_REAL_DIR" 2>/dev/null | awk '{print $1}' || echo 0) + upper_bytes=$(du -sb "$UPPER_DIR" 2>/dev/null | awk '{print $1}' || echo 0) + echo "$real_bytes $upper_bytes" > "$STORE_SIZE_FILE" + log "Store size: store-real=''${real_bytes}B upper=''${upper_bytes}B" + } + + promote_stable_path() { + local store_dir="$1" + local dir_name + dir_name=$(basename "$store_dir") + local dest="$STORE_REAL_DIR/$dir_name" + local mtime_epoch + local cutoff_epoch + mtime_epoch=$(stat -c %Y "$store_dir") + cutoff_epoch=$(date -d "$PROMOTE_STABLE_AFTER ago" +%s 2>/dev/null || true) + + if [ -z "$cutoff_epoch" ]; then + log "Promotion skipped for $dir_name: unable to parse threshold $PROMOTE_STABLE_AFTER" + return 0 + fi + + if [ "$mtime_epoch" -gt "$cutoff_epoch" ]; then + return 0 + fi + + if [ -e "$dest" ]; then + log "Promoting $dir_name: lower layer already has it; removing upper copy" + rm -rf "$store_dir" + return 0 + fi + + log "Promoting stable path $dir_name into store-real" + cp -a "$store_dir" "$STORE_REAL_DIR/" + rm -rf "$store_dir" + } + + scan_new_paths() { + local new_count=0 + + if [ ! -d "$UPPER_DIR" ]; then + log "No upperdir found at $UPPER_DIR, skipping scan" + return 0 + fi + + while IFS= read -r narinfo; do + local basename + basename=$(basename "$narinfo" .narinfo) + local store_path="store-''${basename}" + + if grep -qF "$store_path" "$JOURNAL" 2>/dev/null; then + continue + fi + + local path_size + path_size=$(grep -oP 'NarSize: \K[0-9]+' "$narinfo" 2>/dev/null || echo "0") + + log "NEW: $store_path (narinfo-size: ''${path_size} bytes)" + echo "$(date -Iseconds) $store_path $path_size" >> "$JOURNAL" + new_count=$(( new_count + 1 )) + done < <(find "$UPPER_DIR" -name '*.narinfo' -type f 2>/dev/null) + + while IFS= read -r store_dir; do + local dir_name + dir_name=$(basename "$store_dir") + + case "$dir_name" in + info|locks|temp|log|db) continue ;; + esac + + if grep -qF "$dir_name" "$JOURNAL" 2>/dev/null; then + continue + fi + + if [[ "$dir_name" =~ ^[a-z0-9]{32}-.*$ ]]; then + local dir_size + dir_size=$(du -sb "$store_dir" 2>/dev/null | awk '{print $1}' || echo "0") + log "NEW: $dir_name (dir-size: ''${dir_size} bytes)" + echo "$(date -Iseconds) $dir_name $dir_size" >> "$JOURNAL" + new_count=$(( new_count + 1 )) + if [ "$PROPAGATION_MODE" = "promote" ]; then + promote_stable_path "$store_dir" + fi + fi + done < <(find "$UPPER_DIR" -maxdepth 1 -mindepth 1 -type d 2>/dev/null) + + log "Scan complete: $new_count new paths found" + } + + log "Propagation sidecar started" + log "Upper dir: $UPPER_DIR" + log "Store real dir: $STORE_REAL_DIR" + log "Journal: $JOURNAL" + log "Mode: $PROPAGATION_MODE" + + update_store_size + scan_new_paths + + log "Propagation ''${PROPAGATION_MODE} complete. Exiting." + ''; + }; + }; + + timers.woodpecker-nix-propagate = { + description = "Periodically audit new store paths in overlayfs"; + wantedBy = [ "timers.target" ]; + after = [ "woodpecker-nix-daemon.service" ]; + requires = [ "woodpecker-nix-daemon.service" ]; + + timerConfig = { + OnCalendar = propagationInterval; + Persistent = true; + RandomizedDelaySec = "2m"; + }; + }; + }; + } + ) + + (mkIf (cfg.enable && cfg.woodpecker.agents != [ ]) { + services.woodpecker-agents.agents = + let + agentCfg = { + environment = { + WOODPECKER_BACKEND_DOCKER_VOLUMES = mkDefault (concatStringsSep "," cfg.woodpecker.extraVolumes); + WOODPECKER_ENVIRONMENT = mkDefault ( + lib.mapAttrsToList (k: v: "${k}:${v}") cfg.woodpecker.extraEnvironment |> concatStringsSep "," + ); + }; + }; + in + lib.genAttrs cfg.woodpecker.agents (_name: agentCfg); + }) + ]; +} diff --git a/pkgs/lix-woodpecker/default.nix b/pkgs/lix-woodpecker/default.nix index cb9948611..8abec253b 100644 --- a/pkgs/lix-woodpecker/default.nix +++ b/pkgs/lix-woodpecker/default.nix @@ -7,7 +7,7 @@ let inherit (pkgs.stdenv.hostPlatform) system; inherit (inputs.nix2container.packages.${system}) nix2container; - buildLixImage = import "${inputs.lix}/docker.nix"; + buildLixImage = import ./v2.nix; in buildLixImage { inherit pkgs nix2container; @@ -17,6 +17,7 @@ buildLixImage { extraPkgs = with pkgs; [ gawk jq + yq-go gnupg attic-client ]; @@ -28,6 +29,8 @@ buildLixImage { trusted-substituters = "https://cache.racci.dev/global"; extra-trusted-public-keys = "global:OKNSxDYKp8Q8Tr5/5Bc7CYVSfvdFQV0dMhpG0fOAG0k="; extra-substituters = trusted-substituters; - netrc-file = "/tmp/netrc"; # For use with the setup-attic.nu ci script. + netrc-file = "/tmp/netrc"; }; + + staticShellLayer = true; } diff --git a/pkgs/lix-woodpecker/v2.nix b/pkgs/lix-woodpecker/v2.nix new file mode 100644 index 000000000..b37c7dcf6 --- /dev/null +++ b/pkgs/lix-woodpecker/v2.nix @@ -0,0 +1,404 @@ +{ + pkgs ? import { }, + # Git commit ID, if available + lixRevision ? null, + nix2container, + lib ? pkgs.lib, + name ? "lix", + tag ? "latest", + bundleNixpkgs ? true, + channelName ? "nixpkgs", + channelURL ? "https://channels.nixos.org/nixpkgs-unstable", + extraPkgs ? [ ], + maxLayers ? 100, + nixConf ? { }, + flake-registry ? null, + staticShellLayer ? false, + +}: +let + layerContents = with pkgs; [ + # pulls in glibc and openssl, about 60MB + { contents = [ uutils-coreutils-noprefix ]; } + # some stuff that is low in the closure graph and small ish, mostly to make + # incremental lix updates cheaper + { + contents = [ + curl + libxml2 + sqlite + ]; + } + # 50MB of git + { contents = [ gitMinimal ]; } + # 144MB of nixpkgs + { + contents = [ channel ]; + inProfile = false; + } + ]; + + # These packages are left to be auto layered by nix2container, since it is + # less critical that they get layered sensibly and they tend to not be deps + # of anything in particular + autoLayered = with pkgs; [ + bashInteractive + gnutar + gzip + gnugrep + which + less + wget + man + cacert.out + findutils + iana-etc + openssh + nix + ]; + + defaultPkgs = + lib.lists.flatten ( + map (x: if !(x ? inProfile) || x.inProfile then x.contents else [ ]) layerContents + ) + ++ autoLayered + ++ extraPkgs; + + users = { + + root = { + uid = 0; + shell = if staticShellLayer then "/bin/sh" else "${pkgs.bashInteractive}/bin/bash"; + home = "/root"; + gid = 0; + groups = [ "root" ]; + description = "System administrator"; + }; + + nobody = { + uid = 65534; + shell = "${pkgs.shadow}/bin/nologin"; + home = "/var/empty"; + gid = 65534; + groups = [ "nobody" ]; + description = "Unprivileged account (don't use!)"; + }; + } + // lib.listToAttrs ( + map (n: { + name = "nixbld${toString n}"; + value = { + uid = 30000 + n; + gid = 30000; + groups = [ "nixbld" ]; + description = "Nix build user ${toString n}"; + }; + }) (lib.lists.range 1 32) + ); + + groups = { + root.gid = 0; + nixbld.gid = 30000; + nobody.gid = 65534; + }; + + userToPasswd = k: + { + uid, + gid ? 65534, + home ? "/var/empty", + description ? "", + shell ? "/bin/false", + }: + "${k}:x:${toString uid}:${toString gid}:${description}:${home}:${shell}"; + passwdContents = lib.concatStringsSep "\n" (lib.attrValues (lib.mapAttrs userToPasswd users)); + + userToShadow = k: _: "${k}:!:1::::::"; + shadowContents = lib.concatStringsSep "\n" (lib.attrValues (lib.mapAttrs userToShadow users)); + + # Map groups to members + # { + # group = [ "user1" "user2" ]; + # } + groupMemberMap = let + # Create a flat list of user/group mappings + mappings = builtins.foldl' ( + acc: user: + let + groups = users.${user}.groups or [ ]; + in + acc ++ map (group: { inherit user group; }) groups + ) [ ] (lib.attrNames users); + in + builtins.foldl' ( + acc: v: acc // { ${v.group} = acc.${v.group} or [ ] ++ [ v.user ]; } + ) { } mappings; + + groupToGroup = + k: + { gid }: + let + members = groupMemberMap.${k} or [ ]; + in + "${k}:x:${toString gid}:${lib.concatStringsSep "," members}"; + groupContents = lib.concatStringsSep "\n" (lib.attrValues (lib.mapAttrs groupToGroup groups)); + + defaultNixConf = { + sandbox = "false"; + build-users-group = "nixbld"; + trusted-public-keys = [ "cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=" ]; + }; + + nixConfContents = + (lib.concatStringsSep "\n" ( + lib.mapAttrsToList ( + n: v: + let + vStr = if builtins.isList v then lib.concatStringsSep " " v else v; + in + "${n} = ${vStr}" + ) (defaultNixConf // nixConf) + )) + + "\n"; + + nixpkgs = pkgs.path; + channel = pkgs.runCommand "channel-nixpkgs" { } '' + mkdir $out + ${lib.optionalString bundleNixpkgs '' + ln -s ${nixpkgs} $out/nixpkgs + echo "[]" > $out/manifest.nix + ''} + ''; + + baseSystem = + let + rootEnv = pkgs.buildPackages.buildEnv { + name = "root-profile-env"; + paths = defaultPkgs; + }; + manifest = pkgs.buildPackages.runCommand "manifest.nix" { } '' + cat > $out < $out/etc/passwd + echo "" >> $out/etc/passwd + + cat $groupContentsPath > $out/etc/group + echo "" >> $out/etc/group + + cat $shadowContentsPath > $out/etc/shadow + echo "" >> $out/etc/shadow + + mkdir -p $out/usr + ln -s /nix/var/nix/profiles/share $out/usr/ + + mkdir -p $out/nix/var/nix/gcroots + ln -s /nix/var/nix/profiles $out/nix/var/nix/gcroots/profiles + + mkdir $out/tmp + + mkdir -p $out/var/tmp + + mkdir -p $out/etc/nix + cat $nixConfContentsPath > $out/etc/nix/nix.conf + + mkdir -p $out/root + mkdir -p $out/nix/var/nix/profiles/per-user/root + + ln -s ${profile} $out/nix/var/nix/profiles/default-1-link + ln -s /nix/var/nix/profiles/default-1-link $out/nix/var/nix/profiles/default + ln -s /nix/var/nix/profiles/default $out/root/.nix-profile + + ln -s ${channel} $out/nix/var/nix/profiles/per-user/root/channels-1-link + ln -s /nix/var/nix/profiles/per-user/root/channels-1-link $out/nix/var/nix/profiles/per-user/root/channels + + mkdir -p $out/root/.nix-defexpr + ln -s /nix/var/nix/profiles/per-user/root/channels $out/root/.nix-defexpr/channels + echo "${channelURL} ${channelName}" > $out/root/.nix-channels + + mkdir -p $out/bin $out/usr/bin + '' + + ( + if staticShellLayer then + '' + cp ${pkgs.pkgsStatic.bashInteractive}/bin/bash $out/bin/sh + cp ${pkgs.pkgsStatic.coreutils}/bin/env $out/usr/bin/env + '' + else + '' + ln -s ${pkgs.coreutils}/bin/env $out/usr/bin/env + ln -s ${pkgs.bashInteractive}/bin/bash $out/bin/sh + '' + ) + + (lib.optionalString (flake-registry-path != null) '' + nixCacheDir="/root/.cache/nix" + mkdir -p $out$nixCacheDir + globalFlakeRegistryPath="$nixCacheDir/flake-registry.json" + ln -s ${flake-registry-path} $out$globalFlakeRegistryPath + mkdir -p $out/nix/var/nix/gcroots/auto + rootName=$(${pkgs.nix}/bin/nix --extra-experimental-features nix-command hash file --type sha1 --base32 <(echo -n $globalFlakeRegistryPath)) + ln -s $globalFlakeRegistryPath $out/nix/var/nix/gcroots/auto/$rootName + '') + ); + + layers = builtins.foldl' ( + layersList: el: + let + layer = nix2container.buildLayer { + deps = el.contents; + layers = layersList; + }; + in + layersList ++ [ layer ] + ) [ ] layerContents; + + image = nix2container.buildImage { + + inherit name tag maxLayers; + + inherit layers; + + copyToRoot = [ baseSystem ]; + + initializeNixDatabase = true; + + perms = [ + { + path = baseSystem; + regex = "(/var)?/tmp"; + mode = "1777"; + } + ]; + + config = { + Cmd = [ (if staticShellLayer then "/bin/sh" else "/root/.nix-profile/bin/bash") ]; + Env = [ + "USER=root" + "PATH=${ + lib.concatStringsSep ":" [ + "/root/.nix-profile/bin" + "/nix/var/nix/profiles/default/bin" + "/nix/var/nix/profiles/default/sbin" + ] + }" + "MANPATH=${ + lib.concatStringsSep ":" [ + "/root/.nix-profile/share/man" + "/nix/var/nix/profiles/default/share/man" + ] + }" + "SSL_CERT_FILE=/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt" + "GIT_SSL_CAINFO=/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt" + "NIX_SSL_CERT_FILE=/nix/var/nix/profiles/default/etc/ssl/certs/ca-bundle.crt" + "NIX_PATH=/nix/var/nix/profiles/per-user/root/channels:/root/.nix-defexpr/channels" + ]; + + Labels = { + "org.opencontainers.image.title" = "Lix"; + "org.opencontainers.image.source" = "https://git.lix.systems/lix-project/lix"; + "org.opencontainers.image.vendor" = "Lix project"; + "org.opencontainers.image.version" = pkgs.nix.version; + "org.opencontainers.image.description" = + "Minimal Lix container image, with some batteries included."; + } + // lib.optionalAttrs (lixRevision != null) { "org.opencontainers.image.revision" = lixRevision; }; + }; + + meta = { + description = "Docker image for Lix. This is built with nix2container; see that project's README for details"; + longDescription = '' + Docker image for Lix, built with nix2container. + To copy it to your docker daemon, nix run .#dockerImage.copyToDockerDaemon + To copy it to podman, nix run .#dockerImage.copyTo containers-storage:lix + ''; + }; + }; +in +image +// { + # We don't ship the tarball as the default output because it is a strange thing to want imo + tarball = + pkgs.buildPackages.runCommand "docker-image-tarball-${pkgs.nix.version}" + { + nativeBuildInputs = [ pkgs.buildPackages.bubblewrap ]; + meta.description = "Docker image tarball with Lix for ${pkgs.stdenv.hostPlatform.system}"; + } + '' + mkdir -p $out/nix-support + image=$out/image.tar + # bwrap for foolish temp dir selection code that forces /var/tmp: + # https://github.com/containers/skopeo.git/blob/60ee543f7f7c242f46cc3a7541d9ac8ab1c89168/vendor/github.com/containers/image/v5/internal/tmpdir/tmpdir.go#L15-L18 + mkdir -p $TMPDIR/fake-var/tmp + args=(--unshare-user --bind "$TMPDIR/fake-var" /var) + for dir in /*; do + args+=(--dev-bind "/$dir" "/$dir") + done + bwrap ''${args[@]} -- ${lib.getExe image.copyTo} docker-archive:$image + gzip $image + echo "file binary-dist $image" >> $out/nix-support/hydra-build-products + ''; +} diff --git a/result.json b/result.json deleted file mode 100644 index 95a8222a3..000000000 --- a/result.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "error_string": "Success.", - "export_time": 0, - "plate_index": 0, - "prepare_time": 0, - "return_code": 0 -}