From af4606c40bc0b2541c5a96391b4eb0ab7d156fa0 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 19 Jan 2026 20:19:27 -0800 Subject: [PATCH] feat: add NixOS module and proper Nix packaging - nix/package.nix: two-phase build with fixed-output derivation for deps - nix/nixos-module.nix: systemd service with systant.enable and systant.configFile - flake.nix: expose nixosModules.default and overlays.default Usage in NixOS config: systant.enable = true; systant.configFile = ./systant.toml; When deps change, update hash: nix build .#systant 2>&1 | grep 'got:' Co-Authored-By: Claude Opus 4.5 --- .claude/skills/build.md | 12 ++++ flake.nix | 47 +++++++-------- nix/nixos-module.nix | 123 ++++++++++++++++++++++++++++++++++++++++ nix/package.nix | 79 ++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 23 deletions(-) create mode 100644 nix/nixos-module.nix create mode 100644 nix/package.nix diff --git a/.claude/skills/build.md b/.claude/skills/build.md index ead50d0..3dd2903 100644 --- a/.claude/skills/build.md +++ b/.claude/skills/build.md @@ -9,6 +9,18 @@ Build the systant CLI binary. 3. Report the binary size and location 4. If there are errors, show them clearly and suggest fixes +## Nix Build + +For NixOS deployment, the binary is built by Nix using: +```bash +nix build .#systant +``` + +If you update dependencies (bun.lock), update the hash in `nix/package.nix`: +```bash +nix build .#systant 2>&1 | grep 'got:' +``` + ## Success Criteria - No TypeScript errors diff --git a/flake.nix b/flake.nix index a94e822..b18a276 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Systant"; + description = "Systant - System monitoring agent with MQTT and Home Assistant integration"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; inputs.flake-utils.url = "github:numtide/flake-utils"; @@ -11,48 +11,49 @@ flake-utils, ... }: + { + # NixOS module (system-independent) + nixosModules.default = import ./nix/nixos-module.nix; + + # Overlay to add systant to pkgs + overlays.default = final: prev: { + systant = final.callPackage ./nix/package.nix { }; + }; + } + // flake-utils.lib.eachDefaultSystem ( system: let pkgs = import nixpkgs { inherit system; config.allowUnfree = true; + overlays = [ self.overlays.default ]; }; - systant-cli = pkgs.writeShellScriptBin "systant" '' - cd "$PROJECT_ROOT" && ./dist/systant "$@" - ''; in - with pkgs; { - devShell = pkgs.mkShell { - buildInputs = [ + packages = { + systant = pkgs.systant; + default = pkgs.systant; + }; + + apps.default = { + type = "app"; + program = "${pkgs.systant}/bin/systant"; + }; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ bashInteractive glibcLocales git bun inotify-tools - claude-code - systant-cli ]; shellHook = '' export PROJECT_ROOT=$PWD ''; }; - packages = { - systant-server = pkgs.callPackage ./nix/package.nix { - src = ./server; - }; - systant-cli = pkgs.systant-cli; - default = systant-cli; - }; - - apps = { - default = { - type = "app"; - program = "${self.packages.${system}.default}/bin/systant"; - }; - }; } ); } diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix new file mode 100644 index 0000000..dbfc177 --- /dev/null +++ b/nix/nixos-module.nix @@ -0,0 +1,123 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.systant; + settingsFormat = pkgs.formats.toml { }; +in +{ + options.systant = { + enable = lib.mkEnableOption "systant system monitoring agent"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.systant; + defaultText = lib.literalExpression "pkgs.systant"; + description = "The systant package to use."; + }; + + configFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to the systant configuration file (TOML). + If set, this takes precedence over the settings option. + ''; + }; + + settings = lib.mkOption { + type = settingsFormat.type; + default = { }; + description = '' + Configuration for systant in Nix attribute set form. + Will be converted to TOML. Ignored if configFile is set. + ''; + example = lib.literalExpression '' + { + mqtt = { + broker = "mqtt://localhost:1883"; + topicPrefix = "systant"; + }; + entities = { + cpu_usage = { + type = "sensor"; + state_command = "awk '/^cpu / {u=$2+$4; t=$2+$4+$5; print int(u*100/t)}' /proc/stat"; + unit = "%"; + icon = "mdi:cpu-64-bit"; + name = "CPU Usage"; + }; + }; + homeassistant = { + discovery = true; + discoveryPrefix = "homeassistant"; + }; + } + ''; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "systant"; + description = "User account under which systant runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "systant"; + description = "Group under which systant runs."; + }; + }; + + config = lib.mkIf cfg.enable { + # Create systant user/group if using defaults + users.users.${cfg.user} = lib.mkIf (cfg.user == "systant") { + isSystemUser = true; + group = cfg.group; + description = "Systant service user"; + }; + + users.groups.${cfg.group} = lib.mkIf (cfg.group == "systant") { }; + + # Generate config file from settings if configFile not provided + environment.etc."systant/config.toml" = lib.mkIf (cfg.configFile == null && cfg.settings != { }) { + source = settingsFormat.generate "systant-config.toml" cfg.settings; + }; + + systemd.services.systant = { + description = "Systant system monitoring agent"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + ExecStart = + let + configPath = + if cfg.configFile != null + then cfg.configFile + else "/etc/systant/config.toml"; + in + "${cfg.package}/bin/systant run --config ${configPath}"; + Restart = "on-failure"; + RestartSec = "5s"; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + + # Allow reading system metrics + ReadOnlyPaths = [ + "/proc" + "/sys" + ]; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..15eda2f --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,79 @@ +{ + lib, + stdenvNoCC, + stdenv, + bun, + cacert, +}: + +let + # Fixed-output derivation to fetch npm dependencies + # Update the hash when bun.lock changes by running: + # nix build .#systant 2>&1 | grep 'got:' + deps = stdenvNoCC.mkDerivation { + pname = "systant-deps"; + version = "0.1.0"; + + src = lib.fileset.toSource { + root = ./..; + fileset = lib.fileset.unions [ + ./../package.json + ./../bun.lock + ]; + }; + + nativeBuildInputs = [ bun cacert ]; + + buildPhase = '' + export HOME=$TMPDIR + bun install --frozen-lockfile + ''; + + installPhase = '' + cp -r node_modules $out + ''; + + outputHashMode = "recursive"; + outputHashAlgo = "sha256"; + # To update: nix build .#systant 2>&1 | grep 'got:' + outputHash = "sha256-hQ1ZzOFOHHeaAtyfCXxX6jpqB7poFLwavgMW8yMwaHw="; + }; +in +stdenv.mkDerivation { + pname = "systant"; + version = "0.1.0"; + + src = lib.fileset.toSource { + root = ./..; + fileset = lib.fileset.unions [ + ./../index.ts + ./../src + ./../package.json + ./../tsconfig.json + ]; + }; + + nativeBuildInputs = [ bun ]; + + buildPhase = '' + export HOME=$TMPDIR + cp -r ${deps} node_modules + chmod -R u+w node_modules + bun build index.ts --compile --outfile systant + ''; + + installPhase = '' + mkdir -p $out/bin + cp systant $out/bin/systant + ''; + + # Bun's compiled binaries don't like being stripped + dontStrip = true; + + meta = with lib; { + description = "System monitoring agent with MQTT and Home Assistant integration"; + homepage = "https://git.ryanpandya.com/ryan/systant"; + license = licenses.mit; + platforms = platforms.linux; + }; +}