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 <noreply@anthropic.com>
This commit is contained in:
ryan 2026-01-19 20:19:27 -08:00
parent ae778ebdab
commit af4606c40b
4 changed files with 238 additions and 23 deletions

View File

@ -9,6 +9,18 @@ Build the systant CLI binary.
3. Report the binary size and location 3. Report the binary size and location
4. If there are errors, show them clearly and suggest fixes 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 ## Success Criteria
- No TypeScript errors - No TypeScript errors

View File

@ -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.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils"; inputs.flake-utils.url = "github:numtide/flake-utils";
@ -11,48 +11,49 @@
flake-utils, 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 ( flake-utils.lib.eachDefaultSystem (
system: system:
let let
pkgs = import nixpkgs { pkgs = import nixpkgs {
inherit system; inherit system;
config.allowUnfree = true; config.allowUnfree = true;
overlays = [ self.overlays.default ];
}; };
systant-cli = pkgs.writeShellScriptBin "systant" ''
cd "$PROJECT_ROOT" && ./dist/systant "$@"
'';
in in
with pkgs;
{ {
devShell = pkgs.mkShell { packages = {
buildInputs = [ systant = pkgs.systant;
default = pkgs.systant;
};
apps.default = {
type = "app";
program = "${pkgs.systant}/bin/systant";
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
bashInteractive bashInteractive
glibcLocales glibcLocales
git git
bun bun
inotify-tools inotify-tools
claude-code
systant-cli
]; ];
shellHook = '' shellHook = ''
export PROJECT_ROOT=$PWD 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";
};
};
} }
); );
} }

123
nix/nixos-module.nix Normal file
View File

@ -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"
];
};
};
};
}

79
nix/package.nix Normal file
View File

@ -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;
};
}