flake.nix

{
  description = "Noisebell - door monitor system";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    nixos-hardware.url = "github:NixOS/nixos-hardware/master";
    agenix = {
      url = "github:ryantm/agenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    crane.url = "github:ipetkov/crane";
    rust-overlay = {
      url = "github:oxalica/rust-overlay";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    nixos-raspberrypi = {
      url = "github:nvmd/nixos-raspberrypi/main";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

  outputs =
    {
      self,
      nixpkgs,
      nixos-hardware,
      nixos-raspberrypi,
      agenix,
      crane,
      rust-overlay,
    }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        overlays = [ rust-overlay.overlays.default ];
      };

      lib = pkgs.lib;

      # Threads the rustc frontend may use inside a single crate. Nightly-only
      # (-Z threads). Cargo already builds independent crates in parallel; this
      # picks up the slack at the ends of the dependency graph, where a workspace
      # this narrow otherwise runs one crate at a time on one core.
      rustcThreads = 8;

      # Nightly is required for both the parallel frontend and the Cranelift
      # backend. selectLatestNightlyWith walks back from the newest nightly until
      # it finds one that ships every component below, so a partially published
      # nightly can't break the build.
      # Built up from `minimal` rather than `default` purely to exclude rust-docs,
      # which is 786MB unpacked that a CI runner will never read.
      rustToolchain = pkgs.rust-bin.selectLatestNightlyWith (
        toolchain:
        toolchain.minimal.override {
          extensions = [
            "clippy"
            "rustfmt"
            "rust-src"
            "rustc-codegen-cranelift-preview"
          ];
          targets = [
            "aarch64-unknown-linux-gnu"
            "aarch64-unknown-linux-musl"
          ];
        }
      );

      craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;

      src = pkgs.lib.cleanSourceWith {
        src = ./.;
        filter =
          path: type: (builtins.match ".*\.png$" path != null) || (craneLib.filterCargoSources path type);
      };

      rustFlags = "-Z threads=${toString rustcThreads}";

      # Cranelift emits object code far faster than LLVM but the code it emits is
      # slower, so it is scoped to the dev profile: clippy and the test run get
      # the speedup, shipped release binaries keep LLVM.
      craneliftEnv = {
        CARGO_UNSTABLE_CODEGEN_BACKEND = "true";
        CARGO_PROFILE_DEV_CODEGEN_BACKEND = "cranelift";
      };

      commonArgs = {
        inherit src;
        version = "0.1.0";
        strictDeps = true;
      };

      remoteArgs = commonArgs // {
        pname = "noisebell";
        doCheck = false;
        RUSTFLAGS = rustFlags;
      };

      remoteArtifacts = craneLib.buildDepsOnly remoteArgs;

      buildRemoteMember =
        name:
        craneLib.buildPackage (
          remoteArgs
          // {
            pname = name;
            cargoArtifacts = remoteArtifacts;
            cargoExtraArgs = "-p ${name}";
          }
        );

      noisebell-cache = buildRemoteMember "noisebell-cache";
      noisebell-rss = buildRemoteMember "noisebell-rss";
      noisebell-discord = buildRemoteMember "noisebell-discord";
      noisebell-zulip = buildRemoteMember "noisebell-zulip";

      crossPkgs = import nixpkgs {
        inherit system;
        crossSystem.config = "aarch64-unknown-linux-gnu";
        overlays = [ rust-overlay.overlays.default ];
      };

      muslPkgs = import nixpkgs {
        inherit system;
        crossSystem.config = "aarch64-unknown-linux-musl";
        overlays = [ rust-overlay.overlays.default ];
      };

      # Both Pi targets are cross-compiled from x86_64 with a single toolchain
      # that already carries the aarch64 std, so only the C toolchain and the
      # cargo target env differ between them.
      mkPiArgs =
        targetTriple: targetPkgs:
        let
          underscored = builtins.replaceStrings [ "-" ] [ "_" ] targetTriple;
          cc = "${targetPkgs.stdenv.cc.targetPrefix}cc";
        in
        commonArgs
        // {
          doCheck = false;
          RUSTFLAGS = rustFlags;

          CARGO_BUILD_TARGET = targetTriple;
          TARGET_CC = cc;
          HOST_CC = "${pkgs.stdenv.cc.nativePrefix}cc";

          depsBuildBuild = [ targetPkgs.stdenv.cc ];

          # Only the two Pi binaries are ever cross-built. Scoping the dependency
          # build to them keeps host-only crates (rusqlite and friends) out of the
          # cross graph entirely.
          cargoExtraArgs = "-p noisebell -p noisebell-relay";
        }
        // {
          "CARGO_TARGET_${lib.toUpper underscored}_LINKER" = cc;
          "CC_${underscored}" = cc;
        };

      piArgs = mkPiArgs "aarch64-unknown-linux-gnu" crossPkgs;
      piStaticArgs = mkPiArgs "aarch64-unknown-linux-musl" muslPkgs;

      # One dependency build per target, shared by both binaries on that target.
      piArtifacts = craneLib.buildDepsOnly (piArgs // { pname = "noisebell-pi-deps"; });
      piStaticArtifacts = craneLib.buildDepsOnly (
        piStaticArgs // { pname = "noisebell-pi-static-deps"; }
      );

      buildPiMember =
        {
          args,
          cargoArtifacts,
          member,
          pname,
        }:
        craneLib.buildPackage (
          args
          // {
            inherit cargoArtifacts pname;
            cargoExtraArgs = "-p ${member}";
          }
        );

      noisebell-pi = buildPiMember {
        args = piArgs;
        cargoArtifacts = piArtifacts;
        member = "noisebell";
        pname = "noisebell-pi";
      };

      noisebell-pi-relay = buildPiMember {
        args = piArgs;
        cargoArtifacts = piArtifacts;
        member = "noisebell-relay";
        pname = "noisebell-pi-relay";
      };

      noisebell-pi-static = buildPiMember {
        args = piStaticArgs;
        cargoArtifacts = piStaticArtifacts;
        member = "noisebell";
        pname = "noisebell-pi-static";
      };

      noisebell-pi-relay-static = buildPiMember {
        args = piStaticArgs;
        cargoArtifacts = piStaticArtifacts;
        member = "noisebell-relay";
        pname = "noisebell-pi-relay-static";
      };

      # Everything CI gates on. Dev profile plus Cranelift, with --all-targets so
      # the test harnesses are compiled once into the shared dependency layer
      # instead of separately by clippy and by nextest.
      checkArgs =
        commonArgs
        // craneliftEnv
        // {
          pname = "noisebell-checks";
          CARGO_PROFILE = "dev";
          RUSTFLAGS = rustFlags;
          cargoExtraArgs = "--locked --all-targets";
        };

      checkArtifacts = craneLib.buildDepsOnly (
        checkArgs
        // {
          pname = "noisebell-check-deps";
          doCheck = false;
        }
      );

      checks = {
        fmt = craneLib.cargoFmt {
          inherit src;
          pname = "noisebell-fmt";
          version = "0.1.0";
        };

        clippy = craneLib.cargoClippy (
          checkArgs
          // {
            pname = "noisebell-clippy";
            cargoArtifacts = checkArtifacts;
            cargoClippyExtraArgs = "-- --deny warnings";
          }
        );

        nextest = craneLib.cargoNextest (
          checkArgs
          // {
            pname = "noisebell-nextest";
            cargoArtifacts = checkArtifacts;
          }
        );

        inherit
          noisebell-cache
          noisebell-rss
          noisebell-discord
          noisebell-zulip
          noisebell-pi
          noisebell-pi-relay
          noisebell-pi-static
          noisebell-pi-relay-static
          ;
      };

      # A single derivation that pulls in every check, so CI is one `nix build`
      # and Nix schedules the independent branches across all available cores.
      ci = pkgs.linkFarm "noisebell-ci" (lib.mapAttrsToList (name: path: { inherit name path; }) checks);

      piImageBaseModule =
        {
          lib,
          nixos-raspberrypi,
          pkgs,
          ...
        }:
        {
          imports = with nixos-raspberrypi.nixosModules; [
            default
            usb-gadget-ethernet
          ];

          system.stateVersion = "24.11";

          boot.loader.raspberry-pi = {
            variant = "02";
            firmwarePackage = nixos-raspberrypi.packages.${pkgs.stdenv.hostPlatform.system}.raspberrypifw;
            bootloader = "kernel";
          };
          boot.supportedFilesystems = lib.mkForce [
            "ext4"
            "vfat"
          ];
          boot.kernelParams = lib.mkAfter [ "cfg80211.ieee80211_regdom=US" ];

          networking.networkmanager.enable = lib.mkForce false;
        };

      flash-pi-sd = pkgs.writeShellApplication {
        name = "flash-pi-sd";
        runtimeInputs = [
          agenix.packages.${system}.default
          pkgs.coreutils
          pkgs.nix
          pkgs.parted
          pkgs.systemd
          pkgs.util-linux
          pkgs.xz
          pkgs.zstd
        ];
        text = ''
          set -euo pipefail

          PARTPROBE=${pkgs.parted}/bin/partprobe
          MOUNT=${pkgs.util-linux}/bin/mount
          UMOUNT=${pkgs.util-linux}/bin/umount
          MOUNTPOINT=${pkgs.util-linux}/bin/mountpoint
          FINDMNT=${pkgs.util-linux}/bin/findmnt
          UDEVADM=${pkgs.systemd}/bin/udevadm

          if [ "$#" -ne 1 ]; then
            echo "usage: flash-pi-sd /dev/sdX" >&2
            exit 1
          fi

          device="$1"
          flake_path=${builtins.toString ./.}
          zstd_bin=${pkgs.zstd}/bin/zstd
          secrets_dir=${builtins.toString ./secrets}
          rules_file=${builtins.toString ./secrets/secrets.nix}
          key_name="bootstrap-identity.age"
          boot_mount_dir="$(mktemp -d)"

          cleanup() {
            if "$MOUNTPOINT" -q "$boot_mount_dir"; then
              sudo "$UMOUNT" "$boot_mount_dir"
            fi
            rm -rf "$boot_mount_dir"
          }
          trap cleanup EXIT

          if [ ! -b "$device" ]; then
            echo "not a block device: $device" >&2
            exit 1
          fi

          boot_part="''${device}1"
          case "$device" in
            *[0-9]) boot_part="''${device}p1" ;;
          esac

          echo "Requesting sudo access before build and flash..."
          sudo -v
          echo "Sudo authentication successful."

          echo "Building full Pi NixOS image..."
          image_out="$(nix build \
            --print-out-paths \
            --cores 0 \
            --max-jobs auto \
            "$flake_path#nixosConfigurations.pi.config.system.build.sdImage")"

          image="$(echo "$image_out"/sd-image/*.img*)"
          if [ ! -f "$image" ]; then
            echo "failed to locate SD image under $image_out/sd-image" >&2
            exit 1
          fi

          echo "Flashing $image to $device..."
          if [ "''${image##*.}" = "zst" ]; then
            "$zstd_bin" -d --stdout "$image" | sudo dd of="$device" bs=16M conv=fsync status=progress
          elif [ "''${image##*.}" = "xz" ]; then
            xz -d -c "$image" | sudo dd of="$device" bs=16M conv=fsync status=progress
          else
            sudo dd if="$image" of="$device" bs=16M conv=fsync status=progress
          fi
          sync

          sudo "$PARTPROBE" "$device"
          sudo "$UDEVADM" settle

          if "$FINDMNT" -rn "$boot_part" >/dev/null 2>&1; then
            sudo "$UMOUNT" "$boot_part"
          fi

          echo "Installing bootstrap age identity onto $boot_part..."
          sudo "$MOUNT" "$boot_part" "$boot_mount_dir"
          (
            cd "$secrets_dir"
            RULES="$rules_file" agenix -d "$key_name"
          ) | sudo tee "$boot_mount_dir/noisebell-bootstrap.agekey" >/dev/null
          sudo chmod 600 "$boot_mount_dir/noisebell-bootstrap.agekey"
          sync

          echo "Done. This is the full Pi NixOS image."
        '';
      };

      pi-serial = pkgs.writeShellApplication {
        name = "pi-serial";
        runtimeInputs = [
          pkgs.coreutils
          pkgs.procps
          pkgs.tio
          pkgs.util-linux
        ];
        text = ''
          set -euo pipefail

          baud_rate=115200
          data_bits=8
          stop_bits=1
          parity=none
          flow_control=none
          serial_tools="screen tio minicom picocom"

          port=""

          if [ "$#" -gt 1 ]; then
            echo "usage: pi-serial [device]" >&2
            exit 1
          fi

          if [ "$#" -eq 1 ]; then
            port="$1"
          else
            for candidate in /dev/serial/by-id/* /dev/ttyUSB* /dev/ttyACM*; do
              if [ -e "$candidate" ]; then
                port="$candidate"
                break
              fi
            done
          fi

          if [ -z "$port" ]; then
            echo "No serial device found." >&2
            echo "Check the adapter and run: ls -l /dev/serial/by-id /dev/ttyUSB* /dev/ttyACM* 2>/dev/null" >&2
            exit 1
          fi

          log_file="pi-serial-$(date +%Y%m%d-%H%M%S).log"

          echo "Stopping old serial sessions for this user"
          for tool in $serial_tools; do
            pkill -x -u "$USER" "$tool" 2>/dev/null || true
          done
          sleep 1

          echo "Waiting for port to become free: $port"
          while fuser "$port" >/dev/null 2>&1; do
            sleep 1
          done

          echo "Using serial port: $port"
          echo "Logging to: $log_file"
          echo "Start this before powering the Pi."

          exec sudo tio \
            -b "$baud_rate" \
            -d "$data_bits" \
            -s "$stop_bits" \
            -p "$parity" \
            -f "$flow_control" \
            -t \
            --log \
            --log-file "$log_file" \
            "$port"
        '';
      };
    in
    {
      checks.${system} = checks;

      packages.${system} = {
        inherit
          ci
          noisebell-cache
          noisebell-rss
          noisebell-discord
          noisebell-zulip
          flash-pi-sd
          pi-serial
          ;
        default = noisebell-cache;
      };

      packages.aarch64-linux = {
        noisebell = noisebell-pi;
        noisebell-relay = noisebell-pi-relay;
        noisebell-static = noisebell-pi-static;
        noisebell-relay-static = noisebell-pi-relay-static;
        default = noisebell-pi;
      };

      nixosModules = {
        cache = import ./remote/cache-service/module.nix noisebell-cache;
        rss = import ./remote/rss-service/module.nix noisebell-rss;
        discord = import ./remote/discord-bot/module.nix noisebell-discord;
        zulip = import ./remote/zulip-bot/module.nix noisebell-zulip;
        default = {
          imports = [
            (import ./remote/cache-service/module.nix noisebell-cache)
            (import ./remote/rss-service/module.nix noisebell-rss)
            (import ./remote/discord-bot/module.nix noisebell-discord)
            (import ./remote/zulip-bot/module.nix noisebell-zulip)
            (import ./remote/hosted-module.nix {
              inherit self agenix;
            })
          ];
        };
      };

      nixosConfigurations = {
        pi = nixos-raspberrypi.lib.nixosSystem {
          specialArgs = {
            inherit nixos-raspberrypi;
          };
          modules = [
            nixos-raspberrypi.nixosModules.sd-image
            agenix.nixosModules.default
            piImageBaseModule
            (import ./pi/module.nix {
              pkg = noisebell-pi;
              rev = self.shortRev or "dirty";
            })
            ./pi/configuration.nix
          ];
        };

        noisebell-do = nixpkgs.lib.nixosSystem {
          inherit system;
          modules = [
            self.nixosModules.default
            ./hosts/noisebell-do/configuration.nix
          ];
        };
      };

      devShells.${system} = {
        default = craneLib.devShell (
          craneliftEnv
          // {
            packages = [
              agenix.packages.${system}.default
              pkgs.cargo-nextest
              pkgs.curl
              pkgs.doctl
              flash-pi-sd
              pkgs.jq
              pi-serial
              pkgs.nix
              pkgs.parted
              pkgs.rust-analyzer
              pkgs.openssh
              pkgs.tio
              pkgs.wrangler
              pkgs.zstd
            ];
          }
        );

        # Deliberately lean: the default shell drags in wrangler, doctl and the
        # flashing tools, none of which a CI runner should have to build.
        ci = craneLib.devShell (
          craneliftEnv
          // {
            packages = [ pkgs.cargo-nextest ];
          }
        );
      };

      apps.${system} = {
        flash-pi-sd = {
          type = "app";
          program = "${flash-pi-sd}/bin/flash-pi-sd";
        };

        pi-serial = {
          type = "app";
          program = "${pi-serial}/bin/pi-serial";
        };
      };
    };
}