postjevsql.git / nix / sidecar.nix

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.

  • converge sets a server's options to exactly the declared ones, so a hand-added extensions is dropped (the sidecar invariant; the scan refuses it at plan time as well), and declares the user mappings.
  • sync --apply brings 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's 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  };

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}