{ config, lib, ... }: let serviceName = "semaphore"; servicePort = config.m3ta.ports.get serviceName; in { virtualisation.oci-containers.containers."${serviceName}" = { # Image contains the Semaphore server plus ansible, terraform, # opentofu and terragrunt runners. image = "docker.io/semaphoreui/semaphore:latest"; environment = { SEMAPHORE_DB_DIALECT = "postgres"; SEMAPHORE_DB_HOST = "postgres"; SEMAPHORE_DB_PORT = "5432"; SEMAPHORE_PLAYBOOK_PATH = "/tmp/semaphore/"; }; # SEMAPHORE_DB (name), SEMAPHORE_DB_USER, SEMAPHORE_DB_PASS and the # admin bootstrap vars (SEMAPHORE_ADMIN*) come from the agenix secret # (podman env-file). environmentFiles = [config.age.secrets.semaphore-env.path]; ports = ["127.0.0.1:${toString servicePort}:3000"]; volumes = [ "semaphore_data:/var/lib/semaphore" ]; # Public DNS: the corporate DNS behind aardvark answers AAAA queries for # galaxy.ansible.com but not A queries — with no IPv6 route in the web # network ansible-galaxy downloads failed (EAI_AGAIN / empty responses). # Bypass with public resolvers; git.az-gruppe.com is publicly resolvable, # targets connect by IP (same pattern as librechat/homarr on AZ-CLD-1). extraOptions = ["--network=web" "--ip=10.89.0.17" "--add-host=postgres:10.89.0.1" "--dns=8.8.8.8" "--dns=8.8.4.4"]; }; # Idempotent provisioning of the semaphore role/database on the host # PostgreSQL 17 (same pattern as atrocore-db-init: initialScript cannot # be used, the cluster is already initialized and the password must # never land in the Nix store). systemd.services.semaphore-db-init = { description = "Provision semaphore role/database from agenix secret"; after = ["postgresql.service"]; before = ["podman-${serviceName}.service"]; wants = ["postgresql.service"]; wantedBy = ["multi-user.target"]; serviceConfig = { Type = "oneshot"; User = "postgres"; Group = "postgres"; RemainAfterExit = true; }; path = [config.services.postgresql.package]; # Env-file format: plain KEY=value lines (no quotes), same file the # container consumes. script = '' set -euo pipefail ENV_FILE="${config.age.secrets.semaphore-env.path}" get_val() { grep -m1 "^$1=" "$ENV_FILE" | head -n1 | cut -d= -f2- | tr -d '\r' } db_name="$(get_val SEMAPHORE_DB)" db_user="$(get_val SEMAPHORE_DB_USER)" db_pass="$(get_val SEMAPHORE_DB_PASS)" if [ -z "$db_name" ] || [ -z "$db_user" ] || [ -z "$db_pass" ]; then echo "semaphore-db-init: SEMAPHORE_DB/SEMAPHORE_DB_USER/SEMAPHORE_DB_PASS missing in $ENV_FILE" >&2 exit 1 fi psql -v ON_ERROR_STOP=1 -d postgres \ -v db_name="$db_name" -v db_user="$db_user" -v db_pass="$db_pass" <<'SQL' SELECT format('CREATE ROLE %I LOGIN', :'db_user') WHERE NOT EXISTS (SELECT FROM pg_roles WHERE rolname = :'db_user') \gexec ALTER ROLE :"db_user" WITH LOGIN PASSWORD :'db_pass'; SELECT format('CREATE DATABASE %I OWNER %I', :'db_name', :'db_user') WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = :'db_name') \gexec ALTER DATABASE :"db_name" OWNER TO :"db_user"; SQL echo "semaphore-db-init: role/database '$db_name' ready" ''; }; # Traefik configuration specific to semaphore services.traefik.dynamicConfigOptions.http = { services.${serviceName}.loadBalancer.servers = [{url = "http://localhost:${toString servicePort}/";}]; routers.${serviceName} = { rule = "Host(`sem.l.az-gruppe.com`)"; tls = {certResolver = "ionos";}; service = serviceName; entrypoints = "websecure"; }; }; }