{
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";
};
};
};
}