1# Sidecar mode (CLAUDE.md *Deployment*): a local Postgres that runs 2# postjevsql and reaches the real database through postgres_fdw, so the 3# target installs nothing. 4# 5# The module keeps only what nix owns: `services.postgresql`, this 6# extension, postgres_fdw (contrib) and the unit. Everything inside the 7# database is the sidecar CLI's (`crates/postjevsql-sidecar`, the single 8# engine every launcher calls): after every start of postgresql, 9# `postjevsql-sidecar.service` writes one TOML config per server and runs 10# `converge` then `sync --apply` on it. 11# 12# - `converge` sets a server's options to exactly the declared ones, so 13# a hand-added `extensions` is dropped (the sidecar invariant; the scan 14# refuses it at plan time as well), and declares the user mappings. 15# - `sync --apply` brings the foreign tables in line with the target's 16# catalog change by change, never by drop and re-import, and refuses, 17# changing nothing, any change a view, function or grant depends on. 18# - A mapping's password file is handed to the unit with systemd's 19# `LoadCredential`, and the config names the credential's path, so the 20# secret is read by the CLI when the unit runs and never reaches the 21# store, the environment or a command line. The source may be an opnix 22# path readable only by root. With no file, PG18's 23# `use_scram_passthrough` is used instead. 24{ 25 config, 26 lib, 27 pkgs, 28 ... 29}: 30let 31 cfg = config.services.postjevsql-sidecar; 32 pg = config.services.postgresql; 33 inherit (lib) mkOption types; 34 toml = pkgs.formats.toml { }; 35 36 unit = "postjevsql-sidecar"; 37 # A credential's name: stable per (server, local role), and a valid 38 # systemd credential name whatever the role is called. 39 credential = server: local: "pw-" + builtins.hashString "sha256" "${server}/${local}"; 40 41 mappingType = types.submodule { 42 options = { 43 remoteUser = mkOption { 44 type = types.str; 45 description = "Role on the target this local role connects as."; 46 }; 47 passwordFile = mkOption { 48 type = types.nullOr types.path; 49 default = null; 50 description = '' 51 File holding the remote role's password (an opnix or 52 systemd-creds path; never a store path), loaded into the unit 53 as a credential. Null uses `use_scram_passthrough`: clients 54 must log into the sidecar with SCRAM, and both servers must 55 hold the same SCRAM secret for the role. 56 ''; 57 }; 58 }; 59 }; 60 61 serverType = types.submodule { 62 options = { 63 host = mkOption { 64 type = types.str; 65 description = "The target's host name; verified against its certificate."; 66 }; 67 port = mkOption { 68 type = types.port; 69 default = 5432; 70 }; 71 dbname = mkOption { 72 type = types.str; 73 description = "The target database."; 74 }; 75 sslRootCert = mkOption { 76 type = types.either (types.enum [ "system" ]) types.path; 77 default = "system"; 78 description = "CA the target's certificate is verified against (libpq `sslrootcert`)."; 79 }; 80 fetchSize = mkOption { 81 type = types.ints.between 100 1000000; 82 default = 100; 83 description = '' 84 Rows per postgres_fdw fetch. At least the scan's in-flight 85 window (100 rows), so each window costs one round trip. 86 ''; 87 }; 88 mappings = mkOption { 89 type = types.attrsOf mappingType; 90 default = { }; 91 description = "User mappings, keyed by local role."; 92 }; 93 schemas = mkOption { 94 type = types.nonEmptyListOf types.str; 95 example = [ "public" ]; 96 description = '' 97 Remote schemas whose tables become foreign tables in the 98 same-named local schema. 99 ''; 100 }; 101 }; 102 }; 103 104 # The CLI's config (crates/postjevsql-sidecar/src/config.rs). Only 105 # paths: no secret value is ever in it. 106 configFile = 107 name: s: 108 toml.generate "postjevsql-sidecar-${name}.toml" { 109 target = { 110 server = name; 111 inherit (s) host port dbname schemas; 112 sslmode = "verify-full"; 113 sslrootcert = toString s.sslRootCert; 114 use_remote_estimate = true; 115 fetch_size = s.fetchSize; 116 }; 117 mapping = lib.mapAttrsToList ( 118 local: m: 119 { 120 local_user = local; 121 remote_user = m.remoteUser; 122 } 123 // lib.optionalAttrs (m.passwordFile != null) { 124 password_file = "/run/credentials/${unit}.service/${credential name local}"; 125 } 126 ) s.mappings; 127 jev.api_key_file = toString cfg.apiKeyFile; 128 }; 129 130 cli = lib.getExe cfg.package; 131 sidecarConn = "host=/run/postgresql user=postgres dbname=${cfg.database}"; 132in 133{ 134 options.services.postjevsql-sidecar = { 135 enable = lib.mkEnableOption "a postjevsql sidecar over postgres_fdw"; 136 137 extension = mkOption { 138 type = types.functionTo types.package; 139 default = ps: ps.callPackage ./package.nix { }; 140 defaultText = lib.literalExpression "ps: ps.callPackage ./nix/package.nix { }"; 141 description = '' 142 The postjevsql package, given the postgresql package set, so it 143 is built for `services.postgresql.package`'s major. 144 ''; 145 }; 146 147 package = mkOption { 148 type = types.package; 149 default = pkgs.callPackage ./sidecar-cli.nix { postjevsql = cfg.extension pg.package.pkgs; }; 150 defaultText = lib.literalExpression "the sidecar CLI, built with `extension`"; 151 description = "The `postjevsql-sidecar` CLI that converges and syncs each server."; 152 }; 153 154 database = mkOption { 155 type = types.str; 156 default = "postjevsql"; 157 description = "Local database holding the extension, its cache and the foreign tables."; 158 }; 159 160 apiKeyFile = mkOption { 161 type = types.path; 162 description = '' 163 File holding the TypeSafe API key (`jev.api_key_file`), readable 164 by the postgres user. The server's environment must not also set 165 TYPESAFE_API_KEY: two sources are refused, not ranked. 166 ''; 167 }; 168 169 settings = mkOption { 170 type = types.attrsOf ( 171 types.oneOf [ 172 types.str 173 types.int 174 types.float 175 types.bool 176 ] 177 ); 178 default = { }; 179 example = { 180 model = "jev-1.13.0"; 181 endpoint = "https://api.typesafe.ai/v1/evaluate"; 182 }; 183 description = "`jev.*` settings, without the prefix, written to postgresql.conf."; 184 }; 185 186 servers = mkOption { 187 type = types.attrsOf serverType; 188 default = { }; 189 description = "postgres_fdw foreign servers, keyed by server name."; 190 }; 191 }; 192 193 config = lib.mkIf cfg.enable { 194 assertions = [ 195 { 196 assertion = 197 lib.versionAtLeast pg.package.version "18" 198 || lib.all (s: lib.all (m: m.passwordFile != null) (lib.attrValues s.mappings)) ( 199 lib.attrValues cfg.servers 200 ); 201 message = "postjevsql-sidecar: a mapping without passwordFile needs use_scram_passthrough, which is PostgreSQL 18 or later."; 202 } 203 ]; 204 205 services.postgresql = { 206 enable = true; 207 # postgres_fdw is contrib, shipped with the server itself. 208 extensions = ps: [ (cfg.extension ps) ]; 209 ensureDatabases = [ cfg.database ]; 210 settings = lib.mapAttrs' (k: v: lib.nameValuePair "jev.${k}" v) cfg.settings // { 211 "jev.api_key_file" = toString cfg.apiKeyFile; 212 }; 213 }; 214 215 environment.systemPackages = [ cfg.package ]; 216 217 systemd.services.${unit} = { 218 description = "Converge postjevsql's foreign servers and sync their foreign tables"; 219 after = [ 220 "postgresql.service" 221 "postgresql-setup.service" 222 ]; 223 requires = [ "postgresql.service" ]; 224 wants = [ "postgresql-setup.service" ]; 225 partOf = [ "postgresql.service" ]; 226 wantedBy = [ "multi-user.target" ]; 227 serviceConfig = { 228 Type = "oneshot"; 229 RemainAfterExit = true; 230 User = "postgres"; 231 Group = "postgres"; 232 LoadCredential = lib.concatLists ( 233 lib.mapAttrsToList ( 234 name: s: 235 lib.mapAttrsToList (local: m: "${credential name local}:${toString m.passwordFile}") ( 236 lib.filterAttrs (_: m: m.passwordFile != null) s.mappings 237 ) 238 ) cfg.servers 239 ); 240 }; 241 script = lib.concatStrings ( 242 lib.mapAttrsToList (name: s: '' 243 ${cli} --sidecar ${lib.escapeShellArg sidecarConn} ${configFile name s} converge 244 ${cli} --sidecar ${lib.escapeShellArg sidecarConn} ${configFile name s} sync --apply 245 '') cfg.servers 246 ); 247 }; 248 }; 249}