ReviewOS

also looking at this

pantry-pm/pantry

fix(services): launch postgres from a script, and find libs where they are

#258
Merged glennmichael123 wants to merge fix/243-systemd-user-unit into main
3 files +189 -29
packages/zig/src/services/definitions.zigmodified+139-24
Changes to packages/zig/src/services/definitions.zig
@@ -208,8 +208,7 @@ fn ensureDir(path: []const u8) void {
208208/// install root (`/opt/pantry/pantry`) — the path up to and including the LAST
209209/// `/pantry/` segment. Used to glob sibling packages' lib dirs. Returns null if
210210/// the binary isn't under a pantry tree (slice into `bin`, no allocation).
211fn pantryRootOf(bin: []const u8) ?[]const u8 {
212 const marker = "/pantry/";
211fn lastSegmentRoot(bin: []const u8, marker: []const u8) ?[]const u8 {
213212 var last: ?usize = null;
214213 var from: usize = 0;
215214 while (std.mem.indexOfPos(u8, bin, from, marker)) |i| {
@@ -217,7 +216,33 @@ fn pantryRootOf(bin: []const u8) ?[]const u8 {
217216 from = i + marker.len;
218217 }
219218 const idx = last orelse return null;
220 return bin[0 .. idx + marker.len - 1]; // include "/pantry"
219 return bin[0 .. idx + marker.len - 1]; // include the marker, without its trailing slash
220}
221
222fn pantryRootOf(bin: []const u8) ?[]const u8 {
223 return lastSegmentRoot(bin, "/pantry/");
224}
225
226/// The directory to glob for installed packages' `lib/` dirs, derived from a
227/// resolved service binary.
228///
229/// A global install lays packages out under `<prefix>/packages/<domain>/v<ver>`
230/// and symlinks binaries into `<prefix>/bin`, so a resolved `/usr/local/bin/
231/// initdb` contains no `/pantry/` segment at all. That used to fall back to a
232/// hardcoded `/opt/pantry/pantry`, which does not exist on such a host: the
233/// `find` matched nothing, the library path came out empty, and postgres then
234/// failed to start for want of libpq (#243).
235///
236/// Order matters. `/packages/` is checked first because the user-local global
237/// root (`~/.local/share/pantry/global/packages`) contains BOTH markers, and
238/// the deeper one is the tighter search. Neither present means a binary that
239/// is not inside a pantry layout — its own `<bin>/..` is then the only root we
240/// can name that is certain to exist.
241fn libraryGlobRootOf(bin: []const u8) []const u8 {
242 if (lastSegmentRoot(bin, "/packages/")) |root| return root;
243 if (pantryRootOf(bin)) |root| return root;
244 const bin_dir = std.fs.path.dirname(bin) orelse return "/usr/local";
245 return std.fs.path.dirname(bin_dir) orelse "/usr/local";
221246}
222247
223248fn serviceDataDir(allocator: std.mem.Allocator, home: ?[]const u8, name: []const u8) ![]const u8 {
@@ -497,7 +522,7 @@ pub const Services = struct {
497522 // so the command can compute the platform library path itself by globbing every
498523 // installed package's lib dir — self-contained, independent of the unit
499524 // Environment. postgres links libpq/readline/icu/... from sibling deps.
500 const pantry_root = pantryRootOf(postgres_bin) orelse "/opt/pantry/pantry";
525 const pantry_root = libraryGlobRootOf(postgres_bin);
501526
502527 // Self-initializing launch command: on first start (no PG_VERSION yet)
503528 // run initdb, then exec postgres. postgres/initdb refuse to run as root,
@@ -515,24 +540,58 @@ pub const Services = struct {
515540 // backup) so the `test -f PG_VERSION || initdb` below re-initializes a
516541 // fresh, compatible cluster instead of the service silently failing.
517542 const library_var = Paths.libraryPathVar();
518 const start_cmd = try std.fmt.allocPrint(
519 allocator,
520 "/bin/sh -c 'D=\"{s}\"; " ++
521 "L=\"$(find {s} -maxdepth 6 -type d -path \"*/v*/lib\" 2>/dev/null | tr \"\\n\" \":\")\"; " ++
522 "if [ -f \"$D/PG_VERSION\" ]; then " ++
523 "DV=$(cat \"$D/PG_VERSION\" 2>/dev/null); " ++
524 "SV=$(env {s}=$L {s} -V 2>/dev/null | sed -E \"s/[^0-9]*([0-9]+).*/\\1/\"); " ++
525 "if [ -n \"$DV\" ] && [ -n \"$SV\" ] && [ \"$DV\" != \"$SV\" ]; then " ++
526 "echo \"pantry: PostgreSQL data dir $D is v$DV but the server is v$SV; backing it up and re-initializing.\" >&2; " ++
527 "mv \"$D\" \"$D.bak.v$DV.$(date +%s)\"; fi; fi; " ++
528 "if [ \"$(id -u)\" = 0 ]; then " ++
529 "id -u pantry >/dev/null 2>&1 || useradd --system --home-dir /var/lib/pantry --shell /usr/sbin/nologin pantry; " ++
530 "mkdir -p \"$D\"; chown -R pantry \"$D\"; R=\"runuser -u pantry -- env {s}=$L\"; " ++
531 "else R=\"env {s}=$L\"; fi; " ++
532 "test -f \"$D/PG_VERSION\" || $R {s} -D \"$D\" --no-locale --encoding=UTF8 --username=postgres --auth-local=trust --auth-host=trust; " ++
533 "exec $R {s} -D \"$D\" -p {d}'",
534 .{ pgdata, pantry_root, library_var, postgres_bin, library_var, library_var, initdb_bin, postgres_bin, port },
535 );
543
544 // Written to a file rather than inlined into `ExecStart=`. systemd
545 // parses that line itself: it applies its own quote removal and warns
546 // "Ignoring unknown escape sequences" on the `\n` this command needs
547 // for `tr`, so what the shell received was not what was written and
548 // the unit died with status=127 (#243). A script file is read by
549 // /bin/sh alone, and it is the same shape mysql and mariadb already
550 // use here.
551 const script = try std.fmt.allocPrint(allocator,
552 \\#!/bin/sh
553 \\D="{s}"
554 \\L="$(find {s} -maxdepth 6 -type d -path "*/v*/lib" 2>/dev/null | tr "\n" ":")"
555 \\# An existing cluster whose PG_VERSION major differs from the
556 \\# server's is incompatible — postgres fails instantly with
557 \\# "database files are incompatible with server" (common after a
558 \\# major upgrade, v17 -> v18). Move it aside so the initdb below
559 \\# makes a fresh, compatible one instead of the service flapping.
560 \\if [ -f "$D/PG_VERSION" ]; then
561 \\ DV=$(cat "$D/PG_VERSION" 2>/dev/null)
562 \\ SV=$(env {s}="$L" {s} -V 2>/dev/null | sed -E "s/[^0-9]*([0-9]+).*/\1/")
563 \\ if [ -n "$DV" ] && [ -n "$SV" ] && [ "$DV" != "$SV" ]; then
564 \\ echo "pantry: PostgreSQL data dir $D is v$DV but the server is v$SV; backing it up and re-initializing." >&2
565 \\ mv "$D" "$D.bak.v$DV.$(date +%s)"
566 \\ fi
567 \\fi
568 \\# postgres and initdb both refuse to run as root, so in system
569 \\# scope provision an unprivileged user and run through runuser,
570 \\# which drops LD_LIBRARY_PATH on the uid change — hence `env`.
571 \\# The run prefix lives in the positional parameters, not a
572 \\# string: unquoted, it would re-split on every space.
573 \\if [ "$(id -u)" = 0 ]; then
574 \\ id -u pantry >/dev/null 2>&1 || useradd --system --home-dir /var/lib/pantry --shell /usr/sbin/nologin pantry
575 \\ mkdir -p "$D"; chown -R pantry "$D"
576 \\ set -- runuser -u pantry -- env "{s}=$L"
577 \\else set -- env "{s}=$L"; fi
578 \\test -f "$D/PG_VERSION" || "$@" {s} -D "$D" --no-locale --encoding=UTF8 --username=postgres --auth-local=trust --auth-host=trust
579 \\# exec keeps the server as the unit's tracked PID for Restart=.
580 \\exec "$@" {s} -D "$D" -p {d}
581 \\
582 , .{ pgdata, pantry_root, library_var, postgres_bin, library_var, library_var, initdb_bin, postgres_bin, port });
583 defer allocator.free(script);
584
585 // Scoped per project, like mysql's: a shared script would let merely
586 // inspecting another project rewrite a running unit's next startup
587 // port and data directory.
588 const script_scope = if (project_root) |root| try projectScopeId(allocator, root) else null;
589 defer if (script_scope) |id| allocator.free(id);
590 const script_dir = try serviceDataDirScoped(allocator, home, "postgres-run", script_scope);
591 defer allocator.free(script_dir);
592 const script_path = try writeServiceFile(allocator, script_dir, "start.sh", script);
593 defer allocator.free(script_path);
594 const start_cmd = try std.fmt.allocPrint(allocator, "/bin/sh {s}", .{script_path});
536595
537596 return ServiceConfig{
538597 .name = try allocator.dupe(u8, "postgres"),
@@ -630,7 +689,7 @@ pub const Services = struct {
630689
631690 // pantry root for self-contained LD_LIBRARY_PATH; mysqld refuses root, so
632691 // run as the unprivileged `pantry` user via runuser in system scope.
633 const pantry_root = pantryRootOf(mysqld) orelse "/opt/pantry/pantry";
692 const pantry_root = libraryGlobRootOf(mysqld);
634693 // Locate the REAL mysqld robustly: resolveServiceBinary can fall back to
635694 // bare "mysqld" (not symlinked into .bin), and `env mysqld` then fails
636695 // with 127. Glob the installed mysql.com package for bin/mysqld (only
@@ -1613,7 +1672,7 @@ pub const Services = struct {
16131672 // from sibling deps). mariadbd refuses to run as root, so in system scope
16141673 // (root) provision an unprivileged `pantry` user, chown the datadir, and
16151674 // run via runuser (which drops LD_LIBRARY_PATH, so pass it via `env`).
1616 const pantry_root = pantryRootOf(mariadbd) orelse "/opt/pantry/pantry";
1675 const pantry_root = libraryGlobRootOf(mariadbd);
16171676 const script = try std.fmt.allocPrint(allocator,
16181677 \\#!/bin/sh
16191678 \\DATADIR="{s}"
@@ -2944,3 +3003,59 @@ test "project service scopes are stable and isolated" {
29443003 try std.testing.expectEqualStrings(first, repeated);
29453004 try std.testing.expect(!std.mem.eql(u8, first, second));
29463005}
3006
3007test "the library glob root is derived from the binary, never a fixed path" {
3008 // Global install: binaries symlinked into <prefix>/bin, packages beside
3009 // them under <prefix>/packages. No "/pantry/" segment anywhere, which is
3010 // what used to fall back to a nonexistent /opt/pantry/pantry (#243).
3011 try std.testing.expectEqualStrings(
3012 "/usr/local/packages",
3013 libraryGlobRootOf("/usr/local/packages/postgresql.org/v17.10/bin/initdb"),
3014 );
3015 // The same install reached through its bin symlink: no marker at all, so
3016 // the root is the binary's own parent — a directory that exists by
3017 // construction, and whose packages/ subtree the find still reaches.
3018 try std.testing.expectEqualStrings("/usr/local", libraryGlobRootOf("/usr/local/bin/initdb"));
3019
3020 // Project-local layout keeps its "/pantry" root.
3021 try std.testing.expectEqualStrings(
3022 "/srv/app/pantry",
3023 libraryGlobRootOf("/srv/app/pantry/postgresql.org/v17.10/bin/postgres"),
3024 );
3025
3026 // User-local global root carries BOTH markers; the deeper one wins
3027 // because it is the tighter search.
3028 try std.testing.expectEqualStrings(
3029 "/home/dev/.local/share/pantry/global/packages",
3030 libraryGlobRootOf("/home/dev/.local/share/pantry/global/packages/postgresql.org/v17.10/bin/postgres"),
3031 );
3032}
3033
3034test "postgres launches through a script file, not an inline shell command" {
3035 const allocator = std.testing.allocator;
3036 var pg = try Services.postgresql(allocator, 5432);
3037 defer pg.deinit(allocator);
3038
3039 // systemd parses ExecStart= itself — it strips quotes and warns on the
3040 // `\n` that `tr` needs, which is what made the unit exit 127 (#243). The
3041 // command must therefore be a plain `/bin/sh <path>` with no quoting,
3042 // escapes or shell metacharacters left for systemd to misread.
3043 try std.testing.expect(std.mem.startsWith(u8, pg.start_command, "/bin/sh /"));
3044 try std.testing.expect(std.mem.endsWith(u8, pg.start_command, "/start.sh"));
3045 try std.testing.expect(std.mem.indexOfAny(u8, pg.start_command, "\"'$\\\n;") == null);
3046 try std.testing.expect(std.mem.indexOf(u8, pg.start_command, "-c ") == null);
3047
3048 // ...and the script it points at is the thing that actually carries the
3049 // launch, so the port, the data dir and the exec are pinned here rather
3050 // than only in the unit's health check.
3051 const io_helper = ;
3052 const script_path = pg.start_command["/bin/sh ".len..];
3053 const written = try io_helper.readFileAlloc(std.testing.allocator, script_path, 64 * 1024);
3054 defer std.testing.allocator.free(written);
3055 try std.testing.expect(std.mem.startsWith(u8, written, "#!/bin/sh\n"));
3056 try std.testing.expect(std.mem.indexOf(u8, written, "-p 5432") != null);
3057 try std.testing.expect(std.mem.indexOf(u8, written, "PG_VERSION") != null);
3058 try std.testing.expect(std.mem.indexOf(u8, written, "\nexec ") != null);
3059 const pgdata = pg.env_vars.get("PGDATA").?;
3060 try std.testing.expect(std.mem.indexOf(u8, written, pgdata) != null);
3061}
packages/zig/src/services/platform.zigmodified+38-0
Changes to packages/zig/src/services/platform.zig
@@ -3,6 +3,7 @@ const builtin = ;
33const definitions = ;
44const lib = ;
55const io_helper = lib.io_helper;
6const style = ;
67
78/// Whether services are managed in the per-user systemd instance (`systemctl
89/// --user`, units under `~/.config/systemd/user`, `WantedBy=default.target`) or
@@ -375,9 +376,46 @@ pub const ServiceController = struct {
375376 } catch return;
376377 }
377378
379 /// Is there a systemd user manager for this user to talk to?
380 ///
381 /// `systemctl --user` needs a per-user bus, which exists only while the
382 /// user has a login session or lingering is enabled. Without it every
383 /// call fails with "Failed to connect to bus: No medium found", which
384 /// names neither the cause nor the fix (#243). Checked by looking for the
385 /// runtime dir the bus socket lives in, so no process is spawned.
386 fn userBusAvailable() bool {
387 if (io_helper.getenv("XDG_RUNTIME_DIR")) |dir| {
388 var buf: [std.fs.max_path_bytes]u8 = undefined;
389 const socket = std.fmt.bufPrint(&buf, "{s}/systemd/private", .{dir}) catch return true;
390 io_helper.accessAbsolute(socket, .{}) catch return false;
391 return true;
392 }
393 return false;
394 }
395
396 /// What to do about a missing user bus, in the operator's own terms.
397 pub fn userBusHint(allocator: std.mem.Allocator) ![]const u8 {
398 const user = io_helper.getenv("USER") orelse io_helper.getenv("LOGNAME") orelse "$USER";
399 return std.fmt.allocPrint(
400 allocator,
401 "No systemd user manager is running for this session, so `systemctl --user` cannot be reached.\n" ++
402 " Enable it for this user: loginctl enable-linger {s}\n" ++
403 " Or run the service in system scope instead (as root).",
404 .{user},
405 );
406 }
407
378408 fn systemdStart(self: *ServiceController, service_name: []const u8, project_id: ?[]const u8) !void {
379409 const service_unit = try self.getSystemdUnit(service_name, project_id);
380410 defer self.allocator.free(service_unit);
411 // Reported before the attempt: the raw failure is a bus error that
412 // says nothing about lingering, and it is the same for every verb.
413 if (self.scope == .user and !userBusAvailable()) {
414 const hint = userBusHint(self.allocator) catch return error.ServiceStartFailed;
415 defer self.allocator.free(hint);
416 style.printWarn("{s}\n", .{hint});
417 return error.ServiceStartFailed;
418 }
381419 if (!try self.runSystemctl("start", service_unit)) return error.ServiceStartFailed;
382420 }
383421
packages/zig/test/services_test.zigmodified+12-5
Changes to packages/zig/test/services_test.zig
@@ -87,9 +87,13 @@ test "ServiceConfig creation - PostgreSQL" {
8787 try std.testing.expect(pg.keep_alive == true);
8888 try std.testing.expect(pg.auto_start == false);
8989
90 // Start command must contain the port and PGDATA path
91 try std.testing.expect(std.mem.indexOf(u8, pg.start_command, "5432") != null);
90 // Postgres self-initializes, so it launches via a generated start script
91 // (systemd mangles an inline shell command in ExecStart — #243). The port
92 // therefore lives in that script and in health_check, as it does for
93 // mysql and mariadb; the script's own contents are pinned in
94 // definitions.zig.
9295 try std.testing.expect(std.mem.indexOf(u8, pg.start_command, "postgres") != null);
96 try std.testing.expect(std.mem.indexOf(u8, pg.health_check.?, "5432") != null);
9397
9498 // Env vars: PGPORT and PGDATA must be set
9599 try std.testing.expect(pg.env_vars.get("PGPORT") != null);
@@ -107,7 +111,8 @@ test "ServiceConfig creation - PostgreSQL with custom port" {
107111 defer pg.deinit(allocator);
108112
109113 try std.testing.expect(pg.port.? == 9999);
110 try std.testing.expect(std.mem.indexOf(u8, pg.start_command, "9999") != null);
114 // In the generated start script and the health check, not in ExecStart.
115 try std.testing.expect(std.mem.indexOf(u8, pg.health_check.?, "9999") != null);
111116
112117 const pgport = pg.env_vars.get("PGPORT").?;
113118 try std.testing.expectEqualStrings("9999", pgport);
@@ -1641,7 +1646,8 @@ test "PostgreSQL WithContext - null project_root falls back gracefully" {
16411646 try std.testing.expect(pg.port.? == 5432);
16421647 try std.testing.expect(pg.start_command.len > 0);
16431648 const expected_library_var = if (builtin.os.tag == .macos) "DYLD_LIBRARY_PATH" else "LD_LIBRARY_PATH";
1644 try std.testing.expect(std.mem.indexOf(u8, pg.start_command, expected_library_var) != null);
1649 // The library path is set inside the generated start script now, so this
1650 // asserts it where it is still observable from the config.
16451651 try std.testing.expect(std.mem.indexOf(u8, pg.health_check.?, expected_library_var) != null);
16461652 try std.testing.expect(std.mem.indexOf(u8, pg.health_check.?, "pg_isready") != null);
16471653}
@@ -2208,7 +2214,8 @@ test "getServiceConfigWithPort - PostgreSQL with custom port" {
22082214
22092215 try std.testing.expectEqualStrings("postgres", config.name);
22102216 try std.testing.expect(config.port.? == 5433);
2211 try std.testing.expect(std.mem.indexOf(u8, config.start_command, "5433") != null);
2217 // Generated start script, so the port is in the health check (#243).
2218 try std.testing.expect(std.mem.indexOf(u8, config.health_check.?, "5433") != null);
22122219}
22132220
22142221test "getServiceConfigWithPort - MySQL with custom port" {