From fde1b4cd3e0cea58ac847d1cc2c9bdd121091c52 Mon Sep 17 00:00:00 2001 From: Alexander Date: Wed, 20 May 2026 12:38:16 +0200 Subject: [PATCH] feat: add NixOS module with schema init Ultraworked with [Sisyphus](https://github.com/code-yeongyu/claude-agent) Co-authored-by: Sisyphus --- flake.nix | 7 + nix/module.nix | 273 ++++++++++++++++++++++++++++++++++++++ nix/schema/001_schema.sql | 194 +++++++++++++++++++++++++++ 3 files changed, 474 insertions(+) create mode 100644 nix/module.nix create mode 100644 nix/schema/001_schema.sql diff --git a/flake.nix b/flake.nix index 8114558..eeed5d3 100644 --- a/flake.nix +++ b/flake.nix @@ -18,6 +18,13 @@ "x86_64-linux" ]; + flake = { + nixosModules = { + metadata-agregator = import ./nix/module.nix; + default = self.nixosModules.metadata-agregator; + }; + }; + perSystem = { system, diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..679b387 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,273 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.metadata-agregator; + yaml = pkgs.formats.yaml { }; + + # Build the DSN from database options, password injected at runtime + dbPasswordPlaceholder = "@DB_PASSWORD@"; + + configFile = yaml.generate "metadata-agregator.yaml" { + server = { + port = cfg.server.port; + }; + database = { + host = cfg.database.host; + port = cfg.database.port; + name = cfg.database.name; + user = cfg.database.user; + password = dbPasswordPlaceholder; + sslmode = cfg.database.sslmode; + }; + logging = { + level = cfg.logging.level; + format = cfg.logging.format; + }; + metrics = { + enabled = cfg.metrics.enable; + port = cfg.metrics.port; + }; + }; + + schemaFile = ./schema/001_schema.sql; +in +{ + options.services.metadata-agregator = { + enable = lib.mkEnableOption "metadata-agregator music metadata gRPC service"; + + package = lib.mkPackageOption pkgs "metadata-agregator" { + default = null; + }; + + server = { + port = lib.mkOption { + type = lib.types.port; + default = 50051; + description = "gRPC listen port."; + }; + }; + + database = { + host = lib.mkOption { + type = lib.types.str; + default = "localhost"; + description = "PostgreSQL host."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 5432; + description = "PostgreSQL port."; + }; + + name = lib.mkOption { + type = lib.types.str; + default = "metadata_agregator"; + description = "Database name."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "metadata_agregator"; + description = "Database user."; + }; + + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + File containing the database password. + The file content is read at service start and injected into the config. + Set to null for peer authentication (unix socket). + ''; + }; + + sslmode = lib.mkOption { + type = lib.types.enum [ + "disable" + "require" + "verify-ca" + "verify-full" + "prefer" + "allow" + ]; + default = "disable"; + description = "PostgreSQL SSL mode."; + }; + }; + + logging = { + level = lib.mkOption { + type = lib.types.str; + default = "info"; + description = "Log level."; + }; + + format = lib.mkOption { + type = lib.types.enum [ + "json" + "console" + ]; + default = "json"; + description = "Log output format."; + }; + }; + + metrics = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Enable Prometheus metrics endpoint."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 9090; + description = "Prometheus metrics HTTP port."; + }; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "metadata-agregator"; + description = "User account under which the service runs."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "metadata-agregator"; + description = "Group under which the service runs."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the gRPC and metrics ports in the firewall."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.${cfg.user} = lib.mkIf (cfg.user == "metadata-agregator") { + isSystemUser = true; + group = cfg.group; + }; + + users.groups.${cfg.group} = lib.mkIf (cfg.group == "metadata-agregator") { }; + + # Schema init — idempotent oneshot that runs before the main service + systemd.services.metadata-agregator-schema-init = { + description = "Initialize metadata-agregator database schema"; + after = [ + "network.target" + "postgresql.service" + ]; + before = [ "metadata-agregator.service" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + + script = + let + passwordExport = + if cfg.database.passwordFile != null then + ''export PGPASSWORD="$(cat ${lib.escapeShellArg cfg.database.passwordFile})"'' + else + ""; + psql = lib.concatStringsSep " " [ + "${pkgs.postgresql}/bin/psql" + "-h ${lib.escapeShellArg cfg.database.host}" + "-p ${toString cfg.database.port}" + "-U ${lib.escapeShellArg cfg.database.user}" + "-d ${lib.escapeShellArg cfg.database.name}" + ]; + in + '' + ${passwordExport} + + # Wait for the database to accept connections + for i in $(seq 1 30); do + if ${psql} -c "SELECT 1" >/dev/null 2>&1; then + break + fi + echo "Waiting for database... ($i/30)" + sleep 1 + done + + # Check if schema already exists (idempotent marker) + if ${psql} -tAc "SELECT 1 FROM pg_tables WHERE schemaname='public' AND tablename='artists'" | grep -q 1; then + echo "Schema already initialized, skipping" + exit 0 + fi + + echo "Applying metadata-agregator schema..." + ${psql} -f ${schemaFile} + echo "Schema initialized successfully" + ''; + }; + + # Main service + systemd.services.metadata-agregator = { + description = "metadata-agregator - Music metadata gRPC service"; + after = [ + "network.target" + "metadata-agregator-schema-init.service" + ]; + requires = [ "metadata-agregator-schema-init.service" ]; + wantedBy = [ "multi-user.target" ]; + + preStart = '' + # Generate runtime config with password injected + cp --no-preserve=mode ${configFile} /run/metadata-agregator/config.yaml + + ${ + if cfg.database.passwordFile != null then + '' + ${pkgs.replace-secret}/bin/replace-secret \ + '${dbPasswordPlaceholder}' \ + '${cfg.database.passwordFile}' \ + /run/metadata-agregator/config.yaml + '' + else + '' + ${pkgs.gnused}/bin/sed -i 's/${dbPasswordPlaceholder}//' /run/metadata-agregator/config.yaml + '' + } + ''; + + serviceConfig = { + Type = "simple"; + ExecStart = "${lib.getExe cfg.package} -config /run/metadata-agregator/config.yaml"; + + User = cfg.user; + Group = cfg.group; + + RuntimeDirectory = "metadata-agregator"; + RuntimeDirectoryMode = "0750"; + + Restart = "on-failure"; + RestartSec = 5; + + # Hardening + ProtectSystem = "strict"; + PrivateTmp = true; + NoNewPrivileges = true; + ProtectHome = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + }; + }; + + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall ( + [ cfg.server.port ] ++ lib.optional cfg.metrics.enable cfg.metrics.port + ); + }; +} diff --git a/nix/schema/001_schema.sql b/nix/schema/001_schema.sql new file mode 100644 index 0000000..3cf729c --- /dev/null +++ b/nix/schema/001_schema.sql @@ -0,0 +1,194 @@ +CREATE EXTENSION IF NOT EXISTS pg_prewarm; +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE TABLE artists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + sort_name TEXT, + artist_type TEXT, + country TEXT, + formed_date DATE, + disbanded_date DATE, + description TEXT, + image_url TEXT, + source TEXT NOT NULL, + source_id TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE works ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + work_type TEXT, + language TEXT, + source TEXT NOT NULL, + source_id TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE tracks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_id UUID REFERENCES works(id), + title TEXT NOT NULL, + duration_ms INT, + isrc TEXT, + explicit BOOLEAN DEFAULT false, + source TEXT NOT NULL, + source_id TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE labels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + country TEXT, + founded_date DATE, + source TEXT NOT NULL, + source_id TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE albums ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + label_id UUID REFERENCES labels(id), + title TEXT NOT NULL, + album_type TEXT, + secondary_types TEXT[] DEFAULT '{}', + release_date DATE, + upc TEXT, + total_tracks INT, + total_discs INT DEFAULT 1, + cover_url TEXT, + source TEXT NOT NULL, + source_id TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE genres ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + parent_id UUID REFERENCES genres(id) +); + +CREATE TABLE track_artists ( + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + role TEXT DEFAULT 'primary', + position INT DEFAULT 0, + PRIMARY KEY (track_id, artist_id, role) +); + +CREATE TABLE album_artists ( + album_id UUID REFERENCES albums(id) ON DELETE CASCADE, + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + role TEXT DEFAULT 'primary', + position INT DEFAULT 0, + PRIMARY KEY (album_id, artist_id, role) +); + +CREATE TABLE album_tracks ( + album_id UUID REFERENCES albums(id) ON DELETE CASCADE, + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + disc_number INT DEFAULT 1, + track_number INT NOT NULL, + PRIMARY KEY (album_id, track_id) +); + +CREATE TABLE work_artists ( + work_id UUID REFERENCES works(id) ON DELETE CASCADE, + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + role TEXT DEFAULT 'writer', + PRIMARY KEY (work_id, artist_id, role) +); + +CREATE TABLE artist_genres ( + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + genre_id UUID REFERENCES genres(id) ON DELETE CASCADE, + PRIMARY KEY (artist_id, genre_id) +); + +CREATE TABLE album_genres ( + album_id UUID REFERENCES albums(id) ON DELETE CASCADE, + genre_id UUID REFERENCES genres(id) ON DELETE CASCADE, + PRIMARY KEY (album_id, genre_id) +); + +CREATE TABLE similar_artists ( + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + similar_artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + score REAL DEFAULT 0.5, + PRIMARY KEY (artist_id, similar_artist_id) +); + +CREATE TABLE lyrics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + content TEXT, + synced_content JSONB, + language TEXT, + source TEXT NOT NULL, + source_id TEXT, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE playlists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + description TEXT, + is_public BOOLEAN DEFAULT true, + cover_url TEXT, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +CREATE TABLE playlist_tracks ( + playlist_id UUID REFERENCES playlists(id) ON DELETE CASCADE, + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + position INT NOT NULL, + added_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (playlist_id, track_id) +); + +CREATE TABLE artist_external_ids ( + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + source TEXT NOT NULL, + source_id TEXT NOT NULL, + url TEXT, + fetched_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (artist_id, source, source_id) +); + +CREATE TABLE album_external_ids ( + album_id UUID REFERENCES albums(id) ON DELETE CASCADE, + source TEXT NOT NULL, + source_id TEXT NOT NULL, + url TEXT, + fetched_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (album_id, source, source_id) +); + +CREATE TABLE track_external_ids ( + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + source TEXT NOT NULL, + source_id TEXT NOT NULL, + url TEXT, + fetched_at TIMESTAMPTZ DEFAULT now(), + PRIMARY KEY (track_id, source, source_id) +); + +CREATE INDEX idx_artists_name ON artists(name); +CREATE INDEX idx_artists_name_trgm ON artists USING gin (name gin_trgm_ops); +CREATE INDEX idx_artists_source ON artists(source, source_id); +CREATE INDEX idx_tracks_isrc ON tracks(isrc) WHERE isrc IS NOT NULL; +CREATE INDEX idx_tracks_source ON tracks(source, source_id); +CREATE INDEX idx_albums_upc ON albums(upc) WHERE upc IS NOT NULL; +CREATE INDEX idx_albums_source ON albums(source, source_id); +CREATE INDEX idx_albums_release_date ON albums(release_date); +CREATE INDEX idx_genres_name ON genres(name); +CREATE INDEX idx_lyrics_track_id ON lyrics(track_id); +CREATE INDEX idx_playlist_tracks_position ON playlist_tracks(playlist_id, position);