# dropsonde2 analysis machine (dixie) # # dixie is the fleet's analysis tier: Postgres/Timescale for the ADS-B/AIS buckets # + labels + the eventual signal feed, DuckDB-over-Parquet for the 100-300 GB # historical scans, and a sentiment encoder. The collectors stay on hyperborea # (Pi 5, /srv/dropsonde2); this box just does the heavy lifting. # # The 5.5 TB SATA HDD (sda, WDC WD6003FZBX, serial V7G47PKH) is mounted by label # as /srv/cold — cold Parquet + a local restore copy. /srv/analysis is a bind # mount off the NVMe root (hot Parquet). Both are plain fileSystems, not disko: # the disko root layout on the PM9A1 is untouched. # # The HDD wipe is the only destructive step and is authorized for sda only. It is # NOT declarative — it runs once as an imperative step during deploy (see below). # # HDD wipe (authorized for sda = WDC WD6003FZBX-00K5WB0, serial V7G47PKH only): # wipefs -a /dev/sda2 /dev/sda1 /dev/sda # partitions first, then the disk # printf 'label: gpt\n,,L\n' | sfdisk /dev/sda # GPT + one Linux span # udevadm settle # mkfs.xfs -L ds2-cold /dev/sda1 # One GPT partition, XFS, label ds2-cold. sdb (stick) and nvme0n1 (root) untouched. # # Password: generated on dixie as root, never committed: # umask 077; openssl rand -base64 32 > /root/dropsonde-pg # The dropsonde-pg-setup service reads it via a systemd LoadCredential. Local # analysis uses the unix socket as the dropsonde user (peer auth, no password). # The only remote client is the future ADS-B collector on hyperborea; when that # is built, copy /root/dropsonde-pg over ssh to # /srv/dropsonde2/.env.pg (mode 600). Nobody needs to know the value. { inputs, pkgs, lib, config, ... }: let tailnet = "100.114.166.41"; # dixie's tailscale0 dropsondeRange = "100.64.0.0/10"; # tailnet CGNAT range in { config = { # --- dropsonde system user ------------------------------------------------ users.users.dropsonde = { isSystemUser = true; group = "dropsonde"; home = "/home/dropsonde"; createHome = true; shell = pkgs.bash; }; users.groups.dropsonde = {}; # --- /srv/cold (5.5 TB HDD, by label) ------------------------------------- # The wipe+format of sda happens once during deploy; this entry just mounts # by label with nofail so a missing disk never blocks boot. # Label is ds2-cold (8 chars; XFS caps labels at 12). Not "dropsonde-cold": # hyperborea's NVMe is already labelled "dropsonde", and duplicate labels # across hosts is a footgun. fileSystems."/srv/cold" = { device = "/dev/disk/by-label/ds2-cold"; fsType = "xfs"; options = [ "noatime" "nofail" "x-systemd.device-timeout=30s" ]; }; # --- /srv/analysis (hot Parquet on the NVMe root) ------------------------- # Bind mount off the NVMe so hot Parquet lives on the fast disk, not the # spinning HDD. tmpfiles provisions the bind source directory. fileSystems."/srv/analysis" = { device = "/var/lib/dropsonde-analysis"; fsType = "none"; options = [ "bind" "nofail" ]; }; systemd.tmpfiles.rules = [ "d /var/lib/dropsonde-analysis 0755 dropsonde dropsonde" ]; # --- analysis.slice: bounded, best-effort, below llama-server ------------- # CPUQuota 600% = 6 of 16 threads; MemoryMax 40G leaves ~24G for llama. # Launch dropsonde analysis under this slice with: # systemd-run --slice=analysis.slice --property=Slice=analysis.slice systemd.slices.analysis = { sliceConfig = { CPUQuota = "600%"; MemoryMax = "40G"; Nice = 10; IOWeight = 50; }; }; # --- llama.slice: keep the helper tier above default CPUWeight ------------ # The router's three resident models (5.6 + 2.5 + 0.7 GB) share the 12 GB # card; analysis must not starve them. Default CPUWeight is 1000; 2000 gives # it roughly double the fair-share. Wired into the llama-server service below. systemd.slices.llama = { sliceConfig = { CPUWeight = 2000; }; }; # --- analysis packages ---------------------------------------------------- # DuckDB CLI only. The analysis layer is DuckDB + Parquet; Python (pyarrow, # polars) drags in arrow-cpp + llvm-dev (~1 GB of downloads) and is avoided # unless a specific job forces it. Add it back per-job when needed. environment.systemPackages = with pkgs; [ duckdb ]; # --- Postgres 17 + Timescale + PostGIS + h3-pg ---------------------------- services.postgresql = { enable = true; package = pkgs.postgresql_17; # Listen on all interfaces; the firewall opens 5432 on tailscale0 only, so # nothing else is exposed. Binding to the tailnet IP directly would race # tailscale0 coming up after postgres at boot and leave the service down # until a manual restart. enableTCPIP = true; settings = { listen_addresses = "*"; shared_preload_libraries = "timescaledb"; shared_buffers = "8GB"; work_mem = "256MB"; maintenance_work_mem = "2GB"; max_wal_size = "4GB"; }; # Extensions installed into the pg17 package. All three exist for 17.11 in # the pinned nixpkgs (verified: timescaledb 2.27.1, postgis 3.6.4, h3-pg # 4.2.3). extensions = [ pkgs.postgresql17Packages.timescaledb pkgs.postgresql17Packages.postgis pkgs.postgresql17Packages."h3-pg" ]; ensureDatabases = [ "dropsonde" ]; ensureUsers = [ { name = "dropsonde"; ensureDBOwnership = true; # owns the dropsonde db, so it can CREATE EXTENSION } ]; # Allow the dropsonde role to connect over TCP from the tailnet with scram. # The default localhost md5 lines stay for local admin access. authentication = lib.mkAfter '' host dropsonde dropsonde ${dropsondeRange} scram-sha-256 ''; }; # Create the extensions in the dropsonde db once postgres is up and the db + # role exist. Idempotent (IF NOT EXISTS) so redeploys are safe. The password # is generated on dixie (see note) and read out of git via a systemd # LoadCredential; psql interpolates it as a bound variable so it never shows # in the process list or journal. systemd.services.dropsonde-pg-setup = { description = "dropsonde2: set dropsonde role password + create extensions"; wants = [ "postgresql-setup.service" ]; after = [ "postgresql-setup.service" "postgresql.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; User = "postgres"; Group = "postgres"; Restart = "on-failure"; RestartSec = "10s"; LoadCredential = [ "dropsonde-pg-password:/root/dropsonde-pg" ]; ExecStart = ''${pkgs.coreutils}/bin/bash <<'BASH' set -euo pipefail pw=$(cat "$CREDENTIALS_DIRECTORY/dropsonde-pg-password") psql -v ON_ERROR_STOP=1 -v pw="$pw" -d postgres <<'SQL' ALTER ROLE dropsonde WITH LOGIN PASSWORD :'pw'; SQL psql -v ON_ERROR_STOP=1 -d dropsonde <<'SQL' CREATE EXTENSION IF NOT EXISTS timescaledb; CREATE EXTENSION IF NOT EXISTS postgis; CREATE EXTENSION IF NOT EXISTS h3; CREATE EXTENSION IF NOT EXISTS h3_postgis CASCADE; SQL BASH ''; }; wantedBy = [ "multi-user.target" ]; }; }; }