| @@ -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 | +} |