postjevsql.git / nix / sidecar.nix
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}