also looking at this
fix(services): launch postgres from a script, and find libs where they are
#258
3 files
+189
-29
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -208,8 +208,7 @@ fn ensureDir(path: []const u8) void { | ||
| 208 | 208 | /// install root (`/opt/pantry/pantry`) — the path up to and including the LAST |
| 209 | 209 | /// `/pantry/` segment. Used to glob sibling packages' lib dirs. Returns null if |
| 210 | 210 | /// the binary isn't under a pantry tree (slice into `bin`, no allocation). |
| 211 | fn pantryRootOf(bin: []const u8) ?[]const u8 { | |
| 212 | const marker = "/pantry/"; | |
| 211 | fn lastSegmentRoot(bin: []const u8, marker: []const u8) ?[]const u8 { | |
| 213 | 212 | var last: ?usize = null; |
| 214 | 213 | var from: usize = 0; |
| 215 | 214 | while (std.mem.indexOfPos(u8, bin, from, marker)) |i| { |
| @@ -217,7 +216,33 @@ fn pantryRootOf(bin: []const u8) ?[]const u8 { | ||
| 217 | 216 | from = i + marker.len; |
| 218 | 217 | } |
| 219 | 218 | 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 | ||
| 222 | fn 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. | |
| 241 | fn 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"; | |
| 221 | 246 | } |
| 222 | 247 | |
| 223 | 248 | fn serviceDataDir(allocator: std.mem.Allocator, home: ?[]const u8, name: []const u8) ![]const u8 { |
| @@ -497,7 +522,7 @@ pub const Services = struct { | ||
| 497 | 522 | // so the command can compute the platform library path itself by globbing every |
| 498 | 523 | // installed package's lib dir — self-contained, independent of the unit |
| 499 | 524 | // 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); | |
| 501 | 526 | |
| 502 | 527 | // Self-initializing launch command: on first start (no PG_VERSION yet) |
| 503 | 528 | // run initdb, then exec postgres. postgres/initdb refuse to run as root, |
| @@ -515,24 +540,58 @@ pub const Services = struct { | ||
| 515 | 540 | // backup) so the `test -f PG_VERSION || initdb` below re-initializes a |
| 516 | 541 | // fresh, compatible cluster instead of the service silently failing. |
| 517 | 542 | 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}); | |
| 536 | 595 | |
| 537 | 596 | return ServiceConfig{ |
| 538 | 597 | .name = try allocator.dupe(u8, "postgres"), |
| @@ -630,7 +689,7 @@ pub const Services = struct { | ||
| 630 | 689 | |
| 631 | 690 | // pantry root for self-contained LD_LIBRARY_PATH; mysqld refuses root, so |
| 632 | 691 | // 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); | |
| 634 | 693 | // Locate the REAL mysqld robustly: resolveServiceBinary can fall back to |
| 635 | 694 | // bare "mysqld" (not symlinked into .bin), and `env mysqld` then fails |
| 636 | 695 | // with 127. Glob the installed mysql.com package for bin/mysqld (only |
| @@ -1613,7 +1672,7 @@ pub const Services = struct { | ||
| 1613 | 1672 | // from sibling deps). mariadbd refuses to run as root, so in system scope |
| 1614 | 1673 | // (root) provision an unprivileged `pantry` user, chown the datadir, and |
| 1615 | 1674 | // 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); | |
| 1617 | 1676 | const script = try std.fmt.allocPrint(allocator, |
| 1618 | 1677 | \\#!/bin/sh |
| 1619 | 1678 | \\DATADIR="{s}" |
| @@ -2944,3 +3003,59 @@ test "project service scopes are stable and isolated" { | ||
| 2944 | 3003 | try std.testing.expectEqualStrings(first, repeated); |
| 2945 | 3004 | try std.testing.expect(!std.mem.eql(u8, first, second)); |
| 2946 | 3005 | } |
| 3006 | ||
| 3007 | test "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 | ||
| 3034 | test "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 | } | |
| @@ -3,6 +3,7 @@ const builtin = ; | ||
| 3 | 3 | const definitions = ; |
| 4 | 4 | const lib = ; |
| 5 | 5 | const io_helper = lib.io_helper; |
| 6 | const style = ; | |
| 6 | 7 | |
| 7 | 8 | /// Whether services are managed in the per-user systemd instance (`systemctl |
| 8 | 9 | /// --user`, units under `~/.config/systemd/user`, `WantedBy=default.target`) or |
| @@ -375,9 +376,46 @@ pub const ServiceController = struct { | ||
| 375 | 376 | } catch return; |
| 376 | 377 | } |
| 377 | 378 | |
| 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 | ||
| 378 | 408 | fn systemdStart(self: *ServiceController, service_name: []const u8, project_id: ?[]const u8) !void { |
| 379 | 409 | const service_unit = try self.getSystemdUnit(service_name, project_id); |
| 380 | 410 | 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 | } | |
| 381 | 419 | if (!try self.runSystemctl("start", service_unit)) return error.ServiceStartFailed; |
| 382 | 420 | } |
| 383 | 421 | |
| @@ -87,9 +87,13 @@ test "ServiceConfig creation - PostgreSQL" { | ||
| 87 | 87 | try std.testing.expect(pg.keep_alive == true); |
| 88 | 88 | try std.testing.expect(pg.auto_start == false); |
| 89 | 89 | |
| 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. | |
| 92 | 95 | 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); | |
| 93 | 97 | |
| 94 | 98 | // Env vars: PGPORT and PGDATA must be set |
| 95 | 99 | try std.testing.expect(pg.env_vars.get("PGPORT") != null); |
| @@ -107,7 +111,8 @@ test "ServiceConfig creation - PostgreSQL with custom port" { | ||
| 107 | 111 | defer pg.deinit(allocator); |
| 108 | 112 | |
| 109 | 113 | 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); | |
| 111 | 116 | |
| 112 | 117 | const pgport = pg.env_vars.get("PGPORT").?; |
| 113 | 118 | try std.testing.expectEqualStrings("9999", pgport); |
| @@ -1641,7 +1646,8 @@ test "PostgreSQL WithContext - null project_root falls back gracefully" { | ||
| 1641 | 1646 | try std.testing.expect(pg.port.? == 5432); |
| 1642 | 1647 | try std.testing.expect(pg.start_command.len > 0); |
| 1643 | 1648 | 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. | |
| 1645 | 1651 | try std.testing.expect(std.mem.indexOf(u8, pg.health_check.?, expected_library_var) != null); |
| 1646 | 1652 | try std.testing.expect(std.mem.indexOf(u8, pg.health_check.?, "pg_isready") != null); |
| 1647 | 1653 | } |
| @@ -2208,7 +2214,8 @@ test "getServiceConfigWithPort - PostgreSQL with custom port" { | ||
| 2208 | 2214 | |
| 2209 | 2215 | try std.testing.expectEqualStrings("postgres", config.name); |
| 2210 | 2216 | 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); | |
| 2212 | 2219 | } |
| 2213 | 2220 | |
| 2214 | 2221 | test "getServiceConfigWithPort - MySQL with custom port" { |