{ config, lib, pkgs, ... }: with lib; let cfg = config.services.yami-dev-stack; in { options.services.yami-dev-stack = { enable = mkEnableOption "Yami development stack (gitea, postgres, docker, runner)"; user = mkOption { type = types.str; description = "User that will own runner token and be added to docker group"; }; dataDir = mkOption { type = types.nullOr types.path; default = null; example = "/mnt/ssd_110/yami"; description = '' If set, bind-mounts /var/lib/{gitea,postgresql,gitea-runner} under this path so all stack data lives on a separate disk. Directories are created automatically; existing data must be rsync'd manually before enabling. ''; }; }; config = mkIf cfg.enable (mkMerge [ { # ── Systemd target ───────────────────────────────────────────────────────── # Manual-only: nothing in wantedBy, so it never starts on boot. systemd.targets.yami-dev = { description = "Yami development stack (gitea, postgres, docker, runner)"; wantedBy = [ ]; }; # ── PostgreSQL 16 ────────────────────────────────────────────────────────── services.postgresql = { enable = true; package = pkgs.postgresql_16; ensureDatabases = [ "gitea" ]; ensureUsers = [ { name = "gitea"; ensureDBOwnership = true; } ]; }; # Pull postgres under yami-dev.target; stop it when target stops. systemd.services.postgresql = { wantedBy = mkForce [ "yami-dev.target" ]; partOf = [ "yami-dev.target" ]; }; # ── Gitea ────────────────────────────────────────────────────────────────── services.gitea = { enable = true; appName = "Yami Git"; # LFS enabled via the NixOS helper so the module manages LFS_JWT_SECRET # (writing it to app.ini, which is read-only by design). lfs.enable = true; database = { type = "postgres"; socket = "/run/postgresql"; name = "gitea"; user = "gitea"; }; settings = { server = { DOMAIN = "localhost"; ROOT_URL = "http://localhost:3000/"; HTTP_ADDR = "0.0.0.0"; HTTP_PORT = 3000; SSH_PORT = 2222; START_SSH_SERVER = true; }; service = { DISABLE_REGISTRATION = true; DEFAULT_KEEP_EMAIL_PRIVATE = true; }; repository = { DEFAULT_BRANCH = "main"; ENABLE_PUSH_CREATE_USER = true; ENABLE_PUSH_CREATE_ORG = true; }; actions = { ENABLED = true; DEFAULT_ACTIONS_URL = "github"; LOG_RETENTION_DAYS = 14; ARTIFACT_RETENTION_DAYS = 30; }; packages = { ENABLED = true; }; session = { COOKIE_SECURE = false; }; "cron.git_gc_repos" = { ENABLED = true; SCHEDULE = "@every 72h"; ARGS = "--aggressive --prune=now"; }; }; }; systemd.services.gitea = { wantedBy = mkForce [ "yami-dev.target" ]; partOf = [ "yami-dev.target" ]; after = [ "postgresql.service" ]; requires = [ "postgresql.service" ]; }; # ── Docker ───────────────────────────────────────────────────────────────── virtualisation.docker = { enable = true; enableOnBoot = false; }; systemd.services.docker = { wantedBy = mkForce [ "yami-dev.target" ]; partOf = [ "yami-dev.target" ]; }; users.users.${cfg.user}.extraGroups = [ "docker" ]; # ── Gitea Actions Runner ─────────────────────────────────────────────────── services.gitea-actions-runner.instances.default = { enable = true; name = "home-runner"; url = "http://localhost:3000"; # NOT under /var/lib/gitea-runner — that path is systemd-managed for the # service (DynamicUser + StateDirectory means it becomes a symlink to # /var/lib/private/gitea-runner). Pre-creating it ourselves breaks the # service with "Failed to set up special execution directory: File exists". tokenFile = "/var/lib/gitea-runner-secret/token"; labels = [ "ubuntu-latest:docker://node:20" "native:host" ]; }; systemd.services."gitea-runner-default" = { wantedBy = mkForce [ "yami-dev.target" ]; partOf = [ "yami-dev.target" ]; after = [ "gitea.service" "docker.service" ]; requires = [ "gitea.service" "docker.service" ]; }; # Runner uses DynamicUser, so there's no static gitea-runner user/group. # Token lives in a sibling dir (not /var/lib/gitea-runner — that's systemd's # StateDirectory and pre-creating it breaks DynamicUser bind setup). # The file MUST be in env-file format, not a raw token: # TOKEN= # Get the token from Site Administration → Actions → Runners → Create new Runner. systemd.tmpfiles.rules = [ "d /var/lib/gitea-runner-secret 0700 root root -" ]; # ── Shell aliases ────────────────────────────────────────────────────────── environment.shellAliases = { yami-up = "sudo systemctl start yami-dev.target && echo 'Gitea → http://localhost:3000'"; yami-down = "sudo systemctl stop yami-dev.target && echo 'Yami dev stack stopped'"; yami-status = "systemctl status yami-dev.target gitea postgresql docker gitea-runner-default --no-pager"; yami-logs = "journalctl -u gitea -u gitea-runner-default -f"; }; # ── Firewall ─────────────────────────────────────────────────────────────── # Merged with existing rules (Steam etc.); does not overwrite them. networking.firewall.allowedTCPPorts = [ 3000 2222 ]; } # ── Optional: move all stack data to a separate disk via bind-mounts ───────── (mkIf (cfg.dataDir != null) ( let # gitea-runner intentionally NOT bind-mounted: its service uses # DynamicUser + StateDirectory, which refuses to adopt an existing # mount point ("Failed to set up special execution directory: File exists"). # Its data is tiny (token + .runner state) so it stays on the system disk. subs = [ "gitea" "postgresql" ]; mkBind = name: { name = "/var/lib/${name}"; value = { device = "${cfg.dataDir}/${name}"; fsType = "none"; options = [ "bind" ]; }; }; in { # Ensure target dirs exist on the data disk before mounts happen. systemd.tmpfiles.rules = map (n: "d ${cfg.dataDir}/${n} 0755 root root -") subs ++ [ "d ${cfg.dataDir} 0755 root root -" ]; fileSystems = listToAttrs (map mkBind subs); } )) ]); }