From e288b3a63b90030aad92fa4f47dbac18e9f9fec7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Thu, 2 Jul 2026 01:31:58 +0300 Subject: [PATCH] export nix module --- flake.nix | 32 +++- nix/checks/module-eval.nix | 37 ++++ nix/nixos-module.nix | 349 +++++++++++++++++++++++++++++++++++++ nix/packages/backend.nix | 55 ++++++ nix/packages/default.nix | 7 + nix/packages/frontend.nix | 46 +++++ 6 files changed, 522 insertions(+), 4 deletions(-) create mode 100644 nix/checks/module-eval.nix create mode 100644 nix/nixos-module.nix create mode 100644 nix/packages/backend.nix create mode 100644 nix/packages/default.nix create mode 100644 nix/packages/frontend.nix diff --git a/flake.nix b/flake.nix index 30e9749..ab7aca0 100644 --- a/flake.nix +++ b/flake.nix @@ -4,11 +4,32 @@ utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, utils }: - utils.lib.eachDefaultSystem (system: + outputs = + { + self, + nixpkgs, + utils, + }: + let + nixosModule = import ./nix/nixos-module.nix; + in + utils.lib.eachDefaultSystem ( + system: let pkgs = nixpkgs.legacyPackages.${system}; - in { + packages = import ./nix/packages { inherit pkgs; }; + in + { + inherit packages; + + checks = { + inherit (packages) backend frontend; + module-eval = pkgs.callPackage ./nix/checks/module-eval.nix { + inherit nixpkgs system; + module = nixosModule; + }; + }; + devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ onefetch @@ -44,5 +65,8 @@ ''; }; } - ); + ) + // { + nixosModules.default = nixosModule; + }; } diff --git a/nix/checks/module-eval.nix b/nix/checks/module-eval.nix new file mode 100644 index 0000000..9da8eea --- /dev/null +++ b/nix/checks/module-eval.nix @@ -0,0 +1,37 @@ +{ + lib, + nixpkgs, + module, + pkgs, + system, +}: + +let + evaluatedHostName = + (nixpkgs.lib.nixosSystem { + inherit system; + modules = [ + module + { + system.stateVersion = "26.05"; + + services.rabbi-gerzi = { + enable = true; + frontend.hostName = "www.example.test"; + backend = { + hostName = "api.example.test"; + environmentFile = "/run/secrets/rabbi-gerzi.env"; + }; + }; + } + ]; + }).config.services.rabbi-gerzi.backend.hostName; +in +pkgs.runCommand "rabbi-gerzi-module-eval" + { + value = evaluatedHostName; + preferLocalBuild = true; + } + '' + echo "$value" > $out + '' diff --git a/nix/nixos-module.nix b/nix/nixos-module.nix new file mode 100644 index 0000000..3e7c507 --- /dev/null +++ b/nix/nixos-module.nix @@ -0,0 +1,349 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.rabbi-gerzi; + + backendPackage = cfg.backend.package; + frontendPackage = cfg.frontend.package; + appDir = "${backendPackage}/share/php/rabbi-gerzi-backend"; + phpPackage = backendPackage.passthru.php; + poolName = "rabbi-gerzi"; + storagePath = "${cfg.stateDir}/storage"; + cachePath = "${cfg.cacheDir}/bootstrap-cache"; + setupService = "rabbi-gerzi-setup"; + phpfpmService = "phpfpm-${poolName}"; + + appEnvironment = { + APP_ENV = "production"; + APP_DEBUG = "false"; + APP_URL = cfg.backend.publicUrl; + APP_CONFIG_CACHE = "${cachePath}/config.php"; + APP_EVENTS_CACHE = "${cachePath}/events.php"; + APP_PACKAGES_CACHE = "${cachePath}/packages.php"; + APP_ROUTES_CACHE = "${cachePath}/routes.php"; + APP_SERVICES_CACHE = "${cachePath}/services.php"; + CACHE_STORE = "file"; + CORS_ALLOWED_ORIGINS = lib.concatStringsSep "," cfg.backend.corsAllowedOrigins; + DB_CONNECTION = "pgsql"; + DB_DATABASE = cfg.database.name; + DB_HOST = "/run/postgresql"; + DB_PASSWORD = ""; + DB_PORT = "5432"; + DB_USERNAME = cfg.database.user; + FILESYSTEM_DISK = "public"; + LARAVEL_STORAGE_PATH = storagePath; + LOG_CHANNEL = "single"; + MAIL_MAILER = "log"; + QUEUE_CONNECTION = "sync"; + SESSION_DRIVER = "database"; + SESSION_DOMAIN = cfg.backend.cookieDomain; + SESSION_SAME_SITE = cfg.backend.cookieSameSite; + SESSION_SECURE_COOKIE = lib.boolToString cfg.backend.cookieSecure; + }; + + fpmEnvironment = appEnvironment // { + APP_KEY = "$APP_KEY"; + }; + + artisan = "${phpPackage}/bin/php ${appDir}/artisan"; + + makeDirectoryRule = path: "d ${path} 0750 ${cfg.user} ${cfg.group} - -"; +in +{ + options.services.rabbi-gerzi = { + enable = lib.mkEnableOption "the Rabbi Gerzi application"; + + user = lib.mkOption { + type = lib.types.str; + default = "rabbi-gerzi"; + description = "User used to run the Rabbi Gerzi backend."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "rabbi-gerzi"; + description = "Group used to run the Rabbi Gerzi backend."; + }; + + stateDir = lib.mkOption { + type = lib.types.str; + default = "/var/lib/rabbi-gerzi"; + description = "Writable state directory for uploads and storage."; + }; + + cacheDir = lib.mkOption { + type = lib.types.str; + default = "/var/cache/rabbi-gerzi"; + description = "Writable cache directory for Laravel cache files."; + }; + + database = { + name = lib.mkOption { + type = lib.types.str; + default = cfg.user; + description = "PostgreSQL database name."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = cfg.user; + description = "PostgreSQL database user."; + }; + }; + + frontend = { + hostName = lib.mkOption { + type = lib.types.str; + description = "Frontend nginx virtual host name."; + }; + + publicUrl = lib.mkOption { + type = lib.types.str; + default = "https://${cfg.frontend.hostName}"; + description = "Public frontend origin."; + }; + + apiBaseUrl = lib.mkOption { + type = lib.types.str; + default = cfg.backend.publicUrl; + description = "API origin baked into the frontend build."; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./packages/frontend.nix { + inherit (cfg.frontend) apiBaseUrl; + }; + description = "Frontend package to serve."; + }; + + nginx = lib.mkOption { + type = lib.types.attrs; + default = { }; + description = "Extra nginx virtual host options for the frontend."; + }; + }; + + backend = { + hostName = lib.mkOption { + type = lib.types.str; + description = "Backend nginx virtual host name."; + }; + + publicUrl = lib.mkOption { + type = lib.types.str; + default = "https://${cfg.backend.hostName}"; + description = "Public backend origin."; + }; + + environmentFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Runtime dotenv file containing APP_KEY and admin secrets."; + }; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./packages/backend.nix { }; + description = "Backend package to run."; + }; + + corsAllowedOrigins = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ cfg.frontend.publicUrl ]; + description = "Allowed CORS origins for the backend API."; + }; + + cookieSecure = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether auth cookies require HTTPS."; + }; + + cookieDomain = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Auth cookie domain. Empty means host-only cookies."; + }; + + cookieSameSite = lib.mkOption { + type = lib.types.enum [ + "lax" + "none" + "strict" + ]; + default = "none"; + description = "SameSite value for auth cookies."; + }; + + nginx = lib.mkOption { + type = lib.types.attrs; + default = { }; + description = "Extra nginx virtual host options for the backend."; + }; + }; + }; + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.backend.environmentFile != null; + message = "services.rabbi-gerzi.backend.environmentFile is required."; + } + { + assertion = cfg.backend.cookieSameSite != "none" || cfg.backend.cookieSecure; + message = "SameSite=None auth cookies require cookieSecure = true."; + } + { + assertion = cfg.database.name == cfg.database.user; + message = "Managed PostgreSQL database and user names must match."; + } + ]; + + users.groups.${cfg.group} = { }; + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.stateDir; + }; + + services.postgresql = { + enable = true; + ensureDatabases = [ cfg.database.name ]; + ensureUsers = [ + { + name = cfg.database.user; + ensureDBOwnership = true; + } + ]; + }; + + systemd.tmpfiles.rules = map makeDirectoryRule [ + cfg.stateDir + cfg.cacheDir + storagePath + "${storagePath}/app" + "${storagePath}/app/private" + "${storagePath}/app/public" + "${storagePath}/framework" + "${storagePath}/framework/cache" + "${storagePath}/framework/cache/data" + "${storagePath}/framework/sessions" + "${storagePath}/framework/views" + "${storagePath}/logs" + cachePath + ]; + + services.phpfpm.pools.${poolName} = { + inherit (cfg) user group; + inherit phpPackage; + phpEnv = fpmEnvironment; + settings = { + "catch_workers_output" = true; + "clear_env" = "no"; + "listen.group" = config.services.nginx.group; + "listen.mode" = "0660"; + "listen.owner" = cfg.user; + "pm" = "dynamic"; + "pm.max_children" = 16; + "pm.max_requests" = 500; + "pm.max_spare_servers" = 4; + "pm.min_spare_servers" = 1; + "pm.start_servers" = 2; + }; + }; + + systemd.services.${setupService} = { + description = "Prepare Rabbi Gerzi backend"; + wantedBy = [ "multi-user.target" ]; + before = [ "${phpfpmService}.service" ]; + after = [ "postgresql.service" ]; + requires = [ "postgresql.service" ]; + path = [ phpPackage ]; + environment = appEnvironment; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = cfg.user; + Group = cfg.group; + UMask = "0077"; + WorkingDirectory = appDir; + EnvironmentFile = cfg.backend.environmentFile; + ReadWritePaths = [ + cfg.stateDir + cfg.cacheDir + ]; + }; + script = '' + ${artisan} migrate --force + ${artisan} rabbi-gerzi:ensure-initial-admin + ''; + }; + + systemd.services.${phpfpmService} = { + after = [ "${setupService}.service" ]; + requires = [ "${setupService}.service" ]; + serviceConfig = { + EnvironmentFile = cfg.backend.environmentFile; + ReadWritePaths = [ + cfg.stateDir + cfg.cacheDir + ]; + }; + }; + + services.nginx = { + enable = lib.mkDefault true; + recommendedGzipSettings = lib.mkDefault true; + recommendedOptimisation = lib.mkDefault true; + recommendedProxySettings = lib.mkDefault true; + recommendedTlsSettings = lib.mkDefault true; + + virtualHosts = { + ${cfg.frontend.hostName} = lib.mkMerge [ + cfg.frontend.nginx + { + root = "${frontendPackage}"; + locations."/" = { + tryFiles = "$uri $uri/ /index.html"; + }; + } + ]; + + ${cfg.backend.hostName} = lib.mkMerge [ + cfg.backend.nginx + { + root = "${appDir}/public"; + locations = { + "/" = { + index = "index.php"; + tryFiles = "$uri $uri/ /index.php?$query_string"; + }; + + "/storage/" = { + alias = "${storagePath}/app/public/"; + extraConfig = '' + expires 30d; + access_log off; + ''; + }; + + "~ \\.php$" = { + extraConfig = '' + include ${pkgs.nginx}/conf/fastcgi_params; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_pass unix:${config.services.phpfpm.pools.${poolName}.socket}; + ''; + }; + }; + } + ]; + }; + }; + }; +} diff --git a/nix/packages/backend.nix b/nix/packages/backend.nix new file mode 100644 index 0000000..e9eac6f --- /dev/null +++ b/nix/packages/backend.nix @@ -0,0 +1,55 @@ +{ + lib, + makeWrapper, + php, +}: + +let + phpPackage = php.withExtensions ( + { enabled, all }: + enabled + ++ [ + all.pdo_pgsql + ] + ); +in +phpPackage.buildComposerProject2 (finalAttrs: { + pname = "rabbi-gerzi-backend"; + version = "0.0.0"; + + src = lib.cleanSourceWith { + src = ../../backend; + filter = + path: type: + let + relativePath = lib.removePrefix (toString ../../backend + "/") (toString path); + in + !( + lib.hasPrefix ".env" relativePath + || lib.hasPrefix "vendor/" relativePath + || lib.hasPrefix ".phpunit" relativePath + || lib.hasPrefix "storage/logs/" relativePath + || lib.hasPrefix "storage/framework/views/" relativePath + || lib.hasSuffix "~" relativePath + ); + }; + + php = phpPackage; + nativeBuildInputs = [ makeWrapper ]; + vendorHash = "sha256-EhBxKo3qjEuVTlxK/F+vx6ZBGbApBjcgOK8+3u8UiNk="; + + postInstall = '' + mkdir -p $out/bin + makeWrapper ${phpPackage}/bin/php $out/bin/rabbi-gerzi-artisan \ + --add-flags "$out/share/php/${finalAttrs.pname}/artisan" + ''; + + passthru = { + php = phpPackage; + }; + + meta = { + description = "Rabbi Gerzi Laravel backend"; + mainProgram = "rabbi-gerzi-artisan"; + }; +}) diff --git a/nix/packages/default.nix b/nix/packages/default.nix new file mode 100644 index 0000000..45b1d04 --- /dev/null +++ b/nix/packages/default.nix @@ -0,0 +1,7 @@ +{ pkgs }: + +rec { + backend = pkgs.callPackage ./backend.nix { }; + frontend = pkgs.callPackage ./frontend.nix { }; + default = frontend; +} diff --git a/nix/packages/frontend.nix b/nix/packages/frontend.nix new file mode 100644 index 0000000..137bb6d --- /dev/null +++ b/nix/packages/frontend.nix @@ -0,0 +1,46 @@ +{ + buildNpmPackage, + lib, + apiBaseUrl ? "", +}: + +buildNpmPackage { + pname = "rabbi-gerzi-frontend"; + version = "0.0.0"; + + src = lib.cleanSourceWith { + src = ../../frontend/rabbi_gerzi; + filter = + path: type: + let + relativePath = lib.removePrefix (toString ../../frontend/rabbi_gerzi + "/") (toString path); + in + !( + lib.hasPrefix ".env" relativePath + || lib.hasPrefix "node_modules/" relativePath + || lib.hasPrefix "dist/" relativePath + || lib.hasPrefix "coverage/" relativePath + || lib.hasSuffix ".tsbuildinfo" relativePath + || lib.hasSuffix ".timestamp-" relativePath + ); + }; + + npmDepsHash = "sha256-SF6YRzLySEkYAnwr5YYsftyvwbohtHQo0UpelHmJ7CY="; + npmFlags = [ "--legacy-peer-deps" ]; + + CYPRESS_INSTALL_BINARY = "0"; + VITE_API_BASE_URL = apiBaseUrl; + + installPhase = '' + runHook preInstall + + mkdir -p $out + cp -R dist/* $out/ + + runHook postInstall + ''; + + meta = { + description = "Rabbi Gerzi Vue frontend"; + }; +}