Sidecar mode (CLAUDE.md Deployment): a local Postgres that runs postjevsql and reaches the real database through postgres_fdw, so the target installs nothing.
The module keeps only what nix owns: services.postgresql, this
extension, postgres_fdw (contrib) and the unit. Everything inside the
database is the sidecar CLI's (crates/postjevsql-sidecar, the single
engine every launcher calls): after every start of postgresql,
postjevsql-sidecar.service writes one TOML config per server and runs
converge then sync --apply on it.
convergesets a server's options to exactly the declared ones, so a hand-addedextensionsis dropped (the sidecar invariant; the scan refuses it at plan time as well), and declares the user mappings.sync --applybrings the foreign tables in line with the target's catalog change by change, never by drop and re-import, and refuses, changing nothing, any change a view, function or grant depends on.- A mapping's password file is handed to the unit with systemd's
LoadCredential, and the config names the credential's path, so the secret is read by the CLI when the unit runs and never reaches the store, the environment or a command line. The source may be an opnix path readable only by root. With no file, PG18'suse_scram_passthroughis 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 };
The CLI's config (crates/postjevsql-sidecar/src/config.rs). Only 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 };
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}