mirror of
https://github.com/oblien/openship.git
synced 2026-10-02 07:44:35 +08:00
Merge PR #829: select and preserve the mail database port safely
This commit is contained in:
@@ -241,4 +241,25 @@ describeDockerE2E("mail engine DB bootstrap (real postgres)", () => {
|
||||
const r = await docker(["exec", RUNNER, "bash", "-c", "exit 3"], { allowFail: true });
|
||||
expect(r.code).toBe(3);
|
||||
}, 60_000);
|
||||
|
||||
it("bootstraps a database on a configured non-default SQL port", async () => {
|
||||
const custom = `${DB}-custom-port`;
|
||||
try {
|
||||
await docker([
|
||||
"run", "-d", "--name", custom, "--network", NET,
|
||||
"--env", `POSTGRES_PASSWORD=${PG_PASSWORD}`, "--env", "POSTGRES_DB=vmail",
|
||||
DB_IMAGE, "postgres", "-p", "5544",
|
||||
]);
|
||||
const result = await runBootstrap({ OPENSHIP_MAIL_DB_HOST: custom, OPENSHIP_MAIL_DB_PORT: "5544" });
|
||||
expect(result.code, result.log).toBe(0);
|
||||
const state = await docker([
|
||||
"exec", "--env", `PGPASSWORD=${PG_PASSWORD}`, RUNNER,
|
||||
"psql", "-h", custom, "-p", "5544", "-U", "postgres", "-d", "vmail", "-tAc",
|
||||
"SELECT current_setting('port') || ':' || count(*) FROM mailbox WHERE password <> ''",
|
||||
]);
|
||||
expect(state.stdout.trim()).toBe("5544:1");
|
||||
} finally {
|
||||
await docker(["rm", "-fv", custom], { allowFail: true });
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
const email = resolve(import.meta.dirname, "../../../email");
|
||||
const script = join(email, "docker/reconcile-db-port.py");
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function root() {
|
||||
const directory = await mkdtemp(join(tmpdir(), "openship-mail-port-test-"));
|
||||
roots.push(directory);
|
||||
return directory;
|
||||
}
|
||||
async function file(root: string, path: string, content: string) {
|
||||
const destination = join(root, path);
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content, { mode: 0o640 });
|
||||
return destination;
|
||||
}
|
||||
const run = (root: string, port: string) =>
|
||||
execFileSync("python3", [script, port, root], { encoding: "utf8", stdio: "pipe" });
|
||||
const settings =
|
||||
["vmail", "amavisd", "iredapd", "iredadmin"]
|
||||
.map(
|
||||
(prefix) =>
|
||||
`${prefix}_db_server = "127.0.0.1"\n${prefix}_db_port = "5432"\n${prefix}_db_password = "literal 5432 port=995"`,
|
||||
)
|
||||
.join("\n") + "\nlisten_port = 7777\n";
|
||||
|
||||
async function fixture() {
|
||||
const directory = await root();
|
||||
const paths: string[] = [];
|
||||
const sample = async (name: string, destination: string) => {
|
||||
const text = (await readFile(join(email, "engine/samples", name), "utf8"))
|
||||
.replaceAll("PH_SQL_SERVER_ADDRESS", "127.0.0.1")
|
||||
.replaceAll("PH_SQL_SERVER_PORT", "5432")
|
||||
.replaceAll("PH_AMAVISD_PERL_SQL_DBI", "Pg");
|
||||
paths.push(await file(directory, destination, text));
|
||||
};
|
||||
for (const name of await readdir(join(email, "engine/samples/postfix/pgsql"))) {
|
||||
await sample(`postfix/pgsql/${name}`, `etc/postfix/pgsql/${name}`);
|
||||
}
|
||||
for (const name of [
|
||||
"dovecot-sql.conf",
|
||||
"dovecot-used-quota.conf",
|
||||
"dovecot-last-login.conf",
|
||||
"dovecot-share-folder.conf",
|
||||
]) {
|
||||
await sample(`dovecot/${name}`, `etc/dovecot/${name}`);
|
||||
}
|
||||
await sample("amavisd/amavisd.conf", "etc/amavis/conf.d/50-user");
|
||||
paths.push(await file(directory, "opt/iredapd/settings.py", settings));
|
||||
return { directory, paths };
|
||||
}
|
||||
|
||||
describe("mail database port reconciliation", () => {
|
||||
it("updates actual Postfix, Dovecot, Amavis and iRedAPD configs and can return to the default", async () => {
|
||||
const { directory, paths } = await fixture();
|
||||
const originals = await Promise.all(paths.map((path) => readFile(path, "utf8")));
|
||||
for (const port of ["5433", "5435", "5432"]) {
|
||||
run(directory, port);
|
||||
for (const [index, path] of paths.entries()) {
|
||||
const expected = originals[index]!.replaceAll("5432", port).replaceAll(
|
||||
`literal ${port} port=995`,
|
||||
"literal 5432 port=995",
|
||||
);
|
||||
expect(await readFile(path, "utf8"), path).toBe(expected);
|
||||
expect((await stat(path)).mode & 0o777).toBe(0o640);
|
||||
}
|
||||
const before = await Promise.all(paths.map((path) => stat(path)));
|
||||
run(directory, port);
|
||||
expect(
|
||||
(await Promise.all(paths.map((path) => stat(path)))).map((s) => [s.ino, s.mtimeMs]),
|
||||
).toEqual(before.map((s) => [s.ino, s.mtimeMs]));
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves listeners, SQL credentials, foreign database endpoints, and fail2ban actions", async () => {
|
||||
const directory = await root();
|
||||
const untouched = {
|
||||
"etc/postfix/main.cf": "relayhost = [relay.example]:5432\n",
|
||||
"etc/postfix/pgsql/remote.cf": "hosts = db.example:5432\npassword = 5432\n",
|
||||
"etc/dovecot/listeners.conf":
|
||||
"service imap-login {\n inet_listener imap {\n port = 143\n }\n}\n",
|
||||
"etc/dovecot/remote.conf":
|
||||
"connect = host=db.example port=5432 dbname=vmail password=' port=5432'\n",
|
||||
"etc/amavis/conf.d/60-listeners": "$inet_socket_port = [10024,10026];\n$password = '5432';\n",
|
||||
"etc/fail2ban/jail.local": 'action = banned_db[name=sshd, port="5432", protocol=tcp]\n',
|
||||
"opt/iredapd/settings.py":
|
||||
'vmail_db_server = "db.example"\nvmail_db_port = "5432"\nlisten_port = 7777\n',
|
||||
};
|
||||
for (const [path, content] of Object.entries(untouched)) await file(directory, path, content);
|
||||
run(directory, "5433");
|
||||
for (const [path, content] of Object.entries(untouched))
|
||||
expect(await readFile(join(directory, path), "utf8")).toBe(content);
|
||||
});
|
||||
|
||||
it.each(["0", "65536", "5432.5", "5e3", "invalid", "5432;exit 0"])(
|
||||
"refuses invalid port %s before touching configuration",
|
||||
async (port) => {
|
||||
const directory = await root();
|
||||
const path = await file(directory, "opt/iredapd/settings.py", settings);
|
||||
expect(() => run(directory, port)).toThrow(/decimal port between 1 and 65535/);
|
||||
expect(await readFile(path, "utf8")).toBe(settings);
|
||||
},
|
||||
);
|
||||
|
||||
it("removes stale settings bytecode while preserving permissions and skipping config symlinks", async () => {
|
||||
const { directory } = await fixture();
|
||||
const settingsPath = join(directory, "opt/iredapd/settings.py");
|
||||
await chmod(settingsPath, 0o600);
|
||||
const before = await stat(settingsPath);
|
||||
const bytecode = await file(
|
||||
directory,
|
||||
"opt/iredapd/__pycache__/settings.cpython-311.pyc",
|
||||
"old-bytecode",
|
||||
);
|
||||
const outside = await file(await root(), "outside.conf", "hosts = 127.0.0.1:5432\n");
|
||||
await symlink(outside, join(directory, "etc/postfix/pgsql/symlink.cf"));
|
||||
run(directory, "5433");
|
||||
expect((await stat(settingsPath)).mode & 0o777).toBe(0o600);
|
||||
expect((await stat(settingsPath)).uid).toBe(before.uid);
|
||||
expect((await stat(settingsPath)).gid).toBe(before.gid);
|
||||
expect(await readFile(outside, "utf8")).toBe("hosts = 127.0.0.1:5432\n");
|
||||
await expect(stat(bytecode)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readdir(join(directory, "opt/iredapd"))).toEqual(["__pycache__", "settings.py"]);
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,18 @@ themselves separately.
|
||||
|
||||
## Database topology - one host, four DBs
|
||||
|
||||
Container installs publish the mail database only on host loopback. New setups try
|
||||
port 5432, then the first free port in 5433–5460 if 5432 is occupied. To select a
|
||||
specific port, set `OPENSHIP_MAIL_DB_PORT` in the API process environment before
|
||||
running mail setup. The value must be a decimal port from 1 to 65535; an occupied
|
||||
explicit port stops setup with an error.
|
||||
|
||||
The selected port is saved with the mail engine configuration and reused when
|
||||
recreating a missing engine. Existing installations keep their retained port;
|
||||
changing the API setting does not move a running database. Start-only repairs read
|
||||
the existing container binding. Inside the PostgreSQL container the port remains
|
||||
5432, and the mail daemons use the selected host port.
|
||||
|
||||
```
|
||||
openship Postgres ($DATABASE_URL) ← unrelated to mail
|
||||
└── schema "public"
|
||||
|
||||
@@ -35,9 +35,8 @@ set -euo pipefail
|
||||
|
||||
log() { echo "[openship-mail] $*"; }
|
||||
|
||||
# No DB_HOST/DB_PORT here on purpose: db-bootstrap.sh reads the same two env vars and
|
||||
# owns every conversation with the sidecar, so duplicating them invites the two files
|
||||
# to disagree about where the database is.
|
||||
# db-bootstrap.sh reads OPENSHIP_MAIL_DB_HOST and OPENSHIP_MAIL_DB_PORT to wait for
|
||||
# and bootstrap the schema; step 3d reconciles that same port into daemon configs.
|
||||
FIRST_DOMAIN="${FIRST_DOMAIN:-}"
|
||||
SEED_DIR="/opt/openship-mail/seed"
|
||||
|
||||
@@ -147,6 +146,11 @@ fi
|
||||
# on every boot so recreating the container retains the mounted TLS identity.
|
||||
bash /opt/openship-mail/reconcile-ssl.sh "$FIRST_DOMAIN"
|
||||
|
||||
# 3d. Keep daemon SQL connections on the sidecar's selected host port. Only
|
||||
# database connection fields are rewritten; mail listener ports stay intact.
|
||||
python3 /opt/openship-mail/reconcile-db-port.py "${OPENSHIP_MAIL_DB_PORT:-5432}"
|
||||
|
||||
|
||||
# 4. bootstrap the mail databases (idempotent; skips if the vmail schema exists).
|
||||
#
|
||||
# The wait for the sidecar lives INSIDE db-bootstrap.sh, which polls `SELECT 1` until
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reconcile SQL connection fields in the Debian mail image's daemon configs."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def replace_port(text, pattern, port):
|
||||
return re.sub(pattern, lambda match: match[1] + port, text, flags=re.MULTILINE)
|
||||
|
||||
|
||||
def postfix(text, port):
|
||||
return replace_port(
|
||||
text, r"^([ \t]*hosts[ \t]*=[ \t]*(?:127\.0\.0\.1|localhost):)[0-9]+\b", port
|
||||
)
|
||||
|
||||
|
||||
def dovecot(text, port):
|
||||
# Dovecot 2.3 (Debian 12) libpq connection strings. Match the connection
|
||||
# prefix, not arbitrary 'port=' text in passwords or listener blocks.
|
||||
return replace_port(
|
||||
text,
|
||||
r"^([ \t]*connect[ \t]*=[ \t]*host=(?:127\.0\.0\.1|localhost)[ \t]+port=)[0-9]+\b",
|
||||
port,
|
||||
)
|
||||
|
||||
|
||||
def amavis(text, port):
|
||||
return replace_port(
|
||||
text,
|
||||
r"^([ \t]*@(?:storage|lookup)_sql_dsn[ \t]*=[ \t]*\([ \t]*\[[ \t]*['\"]DBI:Pg:database=[^;'\"\r\n]+;host=(?:127\.0\.0\.1|localhost);port=)[0-9]+\b",
|
||||
port,
|
||||
)
|
||||
|
||||
|
||||
def iredapd(text, port):
|
||||
local = set(re.findall(
|
||||
r"^[ \t]*(vmail|amavisd|iredapd|iredadmin)_db_server[ \t]*=[ \t]*['\"](?:127\.0\.0\.1|localhost)['\"]",
|
||||
text, flags=re.MULTILINE,
|
||||
))
|
||||
return re.sub(
|
||||
r"^([ \t]*(vmail|amavisd|iredapd|iredadmin)_db_port[ \t]*=[ \t]*)(['\"]?)[0-9]+\3(?=[ \t]*(?:#.*)?$)",
|
||||
lambda match: match[1] + match[3] + port + match[3] if match[2] in local else match[0],
|
||||
text, flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def reconcile(path, transform, port, root):
|
||||
if path.is_symlink() or not path.is_file() or not path.resolve().is_relative_to(root):
|
||||
return False
|
||||
before = path.read_text()
|
||||
after = transform(before, port)
|
||||
if before == after:
|
||||
return False
|
||||
metadata = path.stat()
|
||||
fd, temporary = tempfile.mkstemp(prefix=".openship-db-port-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as output:
|
||||
owned = os.fstat(output.fileno())
|
||||
if (owned.st_uid, owned.st_gid) != (metadata.st_uid, metadata.st_gid):
|
||||
os.fchown(output.fileno(), metadata.st_uid, metadata.st_gid)
|
||||
os.fchmod(output.fileno(), stat.S_IMODE(metadata.st_mode))
|
||||
output.write(after)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
raw = sys.argv[1] if len(sys.argv) > 1 else os.environ.get("OPENSHIP_MAIL_DB_PORT", "5432")
|
||||
if not re.fullmatch(r"[0-9]+", raw.strip()) or not 1 <= int(raw) <= 65535:
|
||||
raise ValueError("OPENSHIP_MAIL_DB_PORT must be a decimal port between 1 and 65535")
|
||||
port = str(int(raw))
|
||||
# The optional root lets tests run the same script against actual iRedMail
|
||||
# sample configs without touching the developer's system configuration.
|
||||
root = Path(sys.argv[2] if len(sys.argv) > 2 else "/").resolve()
|
||||
for path in (root / "etc/postfix/pgsql").glob("*.cf"):
|
||||
reconcile(path, postfix, port, root)
|
||||
for path in (root / "etc/dovecot").rglob("*.conf"):
|
||||
reconcile(path, dovecot, port, root)
|
||||
for path in (root / "etc/amavis/conf.d").glob("*"):
|
||||
reconcile(path, amavis, port, root)
|
||||
settings = root / "opt/iredapd/settings.py"
|
||||
if reconcile(settings, iredapd, port, root):
|
||||
# Python's timestamp cache can otherwise survive a same-second change
|
||||
# between two ports with equal length. Never rewrite compiled bytecode.
|
||||
for cached in [settings.with_suffix(".pyc"), *(settings.parent / "__pycache__").glob("settings.*.pyc")]:
|
||||
if cached.resolve().is_relative_to(root):
|
||||
cached.unlink(missing_ok=True)
|
||||
print(f"[openship-mail] reconciled daemon SQL connections to port {port}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError) as error:
|
||||
print(f"[openship-mail] FATAL: database port reconciliation failed: {error}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -214,6 +214,11 @@ export {
|
||||
MAIL_DB_USER,
|
||||
MAIL_DB_HOST_BIND,
|
||||
MAIL_DB_PORT,
|
||||
MAIL_DB_DEFAULT_PORT,
|
||||
MAIL_DB_FALLBACK_PORT,
|
||||
MAIL_DB_PORT_RANGE_MAX,
|
||||
MAIL_DB_INTERNAL_PORT,
|
||||
resolveMailDbPort,
|
||||
type MailMount,
|
||||
} from "./infra/mail-container";
|
||||
|
||||
@@ -284,6 +289,9 @@ export {
|
||||
detectMailContainer,
|
||||
verifyMailEngine,
|
||||
buildMailRunCommand,
|
||||
buildDbRunCommand,
|
||||
retainedDbPort,
|
||||
findAvailableMailDbPort,
|
||||
MAIL_DB_IMAGE,
|
||||
type ContainerMailOptions,
|
||||
type ContainerMailResult,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
MAIL_DB_DEFAULT_PORT,
|
||||
MAIL_DB_FALLBACK_PORT,
|
||||
MAIL_DB_PORT_RANGE_MAX,
|
||||
MAIL_DB_INTERNAL_PORT,
|
||||
resolveMailDbPort,
|
||||
} from "./mail-container";
|
||||
|
||||
const originalEnv = process.env.OPENSHIP_MAIL_DB_PORT;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.OPENSHIP_MAIL_DB_PORT;
|
||||
} else {
|
||||
process.env.OPENSHIP_MAIL_DB_PORT = originalEnv;
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveMailDbPort", () => {
|
||||
it("defaults to 5432 when no argument or env var is present", () => {
|
||||
delete process.env.OPENSHIP_MAIL_DB_PORT;
|
||||
expect(resolveMailDbPort()).toBe(5432);
|
||||
expect(resolveMailDbPort(undefined)).toBe(5432);
|
||||
expect(resolveMailDbPort("")).toBe(5432);
|
||||
});
|
||||
|
||||
it("reads from OPENSHIP_MAIL_DB_PORT environment variable", () => {
|
||||
process.env.OPENSHIP_MAIL_DB_PORT = "5433";
|
||||
expect(resolveMailDbPort()).toBe(5433);
|
||||
});
|
||||
|
||||
it("parses valid port numbers and strings", () => {
|
||||
expect(resolveMailDbPort("5433")).toBe(5433);
|
||||
expect(resolveMailDbPort(" 5434 ")).toBe(5434);
|
||||
expect(resolveMailDbPort(5435)).toBe(5435);
|
||||
expect(resolveMailDbPort(1)).toBe(1);
|
||||
expect(resolveMailDbPort(65535)).toBe(65535);
|
||||
});
|
||||
|
||||
it.each(["0", "-1", "65536", "not-a-port", "5432.5", "0x1538", "5e3", " "])(
|
||||
"refuses an invalid explicit port %s",
|
||||
(value) => expect(() => resolveMailDbPort(value)).toThrow(/between 1 and 65535/),
|
||||
);
|
||||
|
||||
it("exports matching internal and default port constants", () => {
|
||||
expect(MAIL_DB_DEFAULT_PORT).toBe(5432);
|
||||
expect(MAIL_DB_INTERNAL_PORT).toBe(5432);
|
||||
expect(MAIL_DB_FALLBACK_PORT).toBe(5433);
|
||||
expect(MAIL_DB_PORT_RANGE_MAX).toBe(5460);
|
||||
});
|
||||
});
|
||||
@@ -91,9 +91,32 @@ export const MAIL_DB_CONTAINER_DATA_DIR = "/var/lib/postgresql/data";
|
||||
export const MAIL_DB_PGDATA = `${MAIL_DB_CONTAINER_DATA_DIR}/pgdata`;
|
||||
export const MAIL_DB_NAME = "vmail";
|
||||
export const MAIL_DB_USER = "vmail";
|
||||
/** Loopback only — the host-networked engine reaches it at 127.0.0.1:5432. */
|
||||
/** Loopback only — the host-networked engine reaches it at 127.0.0.1. */
|
||||
export const MAIL_DB_HOST_BIND = "127.0.0.1";
|
||||
export const MAIL_DB_PORT = 5432;
|
||||
export const MAIL_DB_DEFAULT_PORT = 5432;
|
||||
export const MAIL_DB_FALLBACK_PORT = 5433;
|
||||
export const MAIL_DB_PORT_RANGE_MAX = 5460;
|
||||
export const MAIL_DB_INTERNAL_PORT = 5432;
|
||||
|
||||
/**
|
||||
* Resolve the host port the mail database listens on.
|
||||
* Reads `OPENSHIP_MAIL_DB_PORT` at setup time. An invalid explicit setting must
|
||||
* fail instead of silently connecting the mail engine to a different database.
|
||||
*/
|
||||
export function resolveMailDbPort(
|
||||
raw: string | number | undefined = process.env.OPENSHIP_MAIL_DB_PORT,
|
||||
): number {
|
||||
if (raw === undefined || raw === "") return MAIL_DB_DEFAULT_PORT;
|
||||
const value = String(raw).trim();
|
||||
const n = Number(value);
|
||||
if (!/^[0-9]+$/.test(value) || !Number.isInteger(n) || n < 1 || n > 65535) {
|
||||
throw new Error("OPENSHIP_MAIL_DB_PORT must be a decimal port between 1 and 65535.");
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Legacy default constant; runtime host bindings use resolveMailDbPort(). */
|
||||
export const MAIL_DB_PORT = MAIL_DB_DEFAULT_PORT;
|
||||
|
||||
/**
|
||||
* Host-side paths for the files the admin layer writes with `exec.writeFile`
|
||||
|
||||
@@ -2,12 +2,17 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildMailRunCommand,
|
||||
buildDbRunCommand,
|
||||
findAvailableMailDbPort,
|
||||
retainedDbPort,
|
||||
startContainerMail,
|
||||
ensureContainerMail,
|
||||
resolveMailImage,
|
||||
setDefaultMailImage,
|
||||
} from "./ensure-container-mail";
|
||||
import { setManagedImagesFromSource } from "../managed-image";
|
||||
import { MAIL_HOST_STATE_DIR } from "../../infra/mail-container";
|
||||
import type { CommandExecutor } from "../../types";
|
||||
|
||||
afterEach(() => {
|
||||
setDefaultMailImage(undefined);
|
||||
@@ -39,6 +44,132 @@ describe("buildMailRunCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDbRunCommand", () => {
|
||||
it("defaults to binding host loopback port 5432 to container port 5432", () => {
|
||||
const cmd = buildDbRunCommand("openship-mail-db");
|
||||
expect(cmd).toContain("-p '127.0.0.1:5432:5432'");
|
||||
expect(cmd).toContain("--name 'openship-mail-db'");
|
||||
expect(cmd).toContain("--restart unless-stopped");
|
||||
});
|
||||
|
||||
it("binds a custom host port mapped to internal 5432 container port", () => {
|
||||
const cmd = buildDbRunCommand("openship-mail-db", 5433);
|
||||
expect(cmd).toContain("-p '127.0.0.1:5433:5432'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAvailableMailDbPort", () => {
|
||||
it("returns preferred port when it is not listening", async () => {
|
||||
const exec = vi.fn(async () => "");
|
||||
const port = await findAvailableMailDbPort({ exec } as never, 5432, false, () => {});
|
||||
expect(port).toBe(5432);
|
||||
});
|
||||
|
||||
it("does not auto-switch when the user explicitly configured the port", async () => {
|
||||
// Return tcp table showing 5432 listening (0x1538)
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("/proc/net/tcp"))
|
||||
return " sl local_address ...\n 0: 00000000:1538 00000000:0000 0A";
|
||||
return "";
|
||||
});
|
||||
await expect(findAvailableMailDbPort({ exec } as never, 5432, true, () => {})).rejects.toThrow(
|
||||
/already in use/,
|
||||
);
|
||||
expect(exec.mock.calls.every(([cmd]) => !cmd.includes("docker"))).toBe(true);
|
||||
});
|
||||
|
||||
it("auto-discovers next available port (e.g. 5433) when default 5432 is occupied", async () => {
|
||||
const logs: string[] = [];
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
// 5432 (0x1538) is listening, 5433 (0x1539) is free
|
||||
if (cmd.includes("/proc/net/tcp"))
|
||||
return " sl local_address ...\n 0: 00000000:1538 00000000:0000 0A";
|
||||
return "";
|
||||
});
|
||||
const port = await findAvailableMailDbPort({ exec } as never, 5432, false, (l) =>
|
||||
logs.push(l.message),
|
||||
);
|
||||
expect(port).toBe(5433);
|
||||
expect(logs.some((m) => m.includes("Automatically selected available port 5433"))).toBe(true);
|
||||
expect(exec.mock.calls.every(([cmd]) => !cmd.includes("docker"))).toBe(true);
|
||||
});
|
||||
|
||||
it("skips multiple occupied ports until a free one is found", async () => {
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
// 5432 (0x1538) and 5433 (0x1539) are both listening; 5434 (0x153A) is free
|
||||
if (cmd.includes("/proc/net/tcp")) {
|
||||
return (
|
||||
" sl local_address ...\n" +
|
||||
" 0: 00000000:1538 00000000:0000 0A\n" +
|
||||
" 1: 00000000:1539 00000000:0000 0A"
|
||||
);
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const port = await findAvailableMailDbPort({ exec } as never, 5432, false, () => {});
|
||||
expect(port).toBe(5434);
|
||||
});
|
||||
|
||||
it("reports exhaustion without removing containers or choosing an occupied port", async () => {
|
||||
const table = Array.from(
|
||||
{ length: 29 },
|
||||
(_, index) => `${index}: 00000000:${(5432 + index).toString(16)} 00000000:0000 0A`,
|
||||
).join("\n");
|
||||
const exec = vi.fn(async () => table);
|
||||
await expect(findAvailableMailDbPort({ exec } as never, 5432, false, () => {})).rejects.toThrow(
|
||||
/No free mail database port/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("retainedDbPort", () => {
|
||||
it("returns null when the database cluster is not initialised on disk", async () => {
|
||||
const exec = vi.fn(async () => "");
|
||||
const port = await retainedDbPort({ exec } as never);
|
||||
expect(port).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the retained port from engine.env when cluster is initialised", async () => {
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("PG_VERSION")) return "yes\n";
|
||||
return "";
|
||||
});
|
||||
const readFile = vi.fn(async () => "OPENSHIP_MAIL_DB_PORT=5435\n");
|
||||
const port = await retainedDbPort({ exec, readFile } as never);
|
||||
expect(port).toBe(5435);
|
||||
});
|
||||
});
|
||||
|
||||
describe("startContainerMail database port", () => {
|
||||
it("restarts custom-named containers using their binding without reading or rewriting credentials", async () => {
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes(STATE_PROBE)) return stateLine("mail-image:1", false);
|
||||
if (cmd.includes("HostConfig.PortBindings")) {
|
||||
expect(cmd).toContain("'custom-mail-db'");
|
||||
return JSON.stringify({ "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "5435" }] });
|
||||
}
|
||||
if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING.replace("1538", "153B");
|
||||
return "";
|
||||
});
|
||||
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
|
||||
const readFile = vi.fn(),
|
||||
writeFile = vi.fn();
|
||||
const result = await startContainerMail({ exec, streamExec, readFile, writeFile } as never, {
|
||||
container: "custom-mail",
|
||||
dbContainer: "custom-mail-db",
|
||||
onLog: () => {},
|
||||
});
|
||||
expect(result).toEqual({ started: true });
|
||||
expect(streamExec.mock.calls.map((call) => call[0])).toEqual([
|
||||
"docker start 'custom-mail-db'",
|
||||
"docker start 'custom-mail'",
|
||||
]);
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
expect(exec.mock.calls.every(([cmd]) => !/docker (rm|run)|PG_VERSION/.test(cmd))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMailImage", () => {
|
||||
it("prefers an explicit ref, else the injected default", () => {
|
||||
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
|
||||
@@ -86,7 +217,11 @@ const PROC_LISTENING = [
|
||||
* `streamExec` and the writes succeed so the bring-up runs end to end.
|
||||
*/
|
||||
function firstBootExecutor(opts: { imagePresent: boolean }) {
|
||||
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
|
||||
let dbStarted = false;
|
||||
const streamExec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("docker run") && cmd.includes("postgres")) dbStarted = true;
|
||||
return { code: 0, output: "" };
|
||||
});
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
// No engine on the box yet — the state probe finds nothing.
|
||||
if (cmd.includes(STATE_PROBE)) return "";
|
||||
@@ -95,7 +230,7 @@ function firstBootExecutor(opts: { imagePresent: boolean }) {
|
||||
// Image presence probe (docker image inspect -f '{{.Id}}').
|
||||
if (cmd.includes("docker image inspect")) return opts.imagePresent ? "sha256:abc\n" : "";
|
||||
// Port-listening probe reads /proc/net/tcp.
|
||||
if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING;
|
||||
if (cmd.includes("/proc/net/tcp")) return dbStarted ? PROC_LISTENING : "";
|
||||
return "";
|
||||
});
|
||||
const writeFile = vi.fn(async () => {});
|
||||
@@ -108,6 +243,35 @@ function firstBootExecutor(opts: { imagePresent: boolean }) {
|
||||
// skips the pull (an unpublished tag — a pull would 404). In prod the tag is absent,
|
||||
// so it pulls `:APP_VERSION`.
|
||||
describe("ensureContainerMail image acquisition gate", () => {
|
||||
it("reuses an existing sidecar binding when the engine is missing", async () => {
|
||||
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
|
||||
const box = firstBootExecutor({ imagePresent: true });
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("HostConfig.PortBindings"))
|
||||
return JSON.stringify({ "5432/tcp": [{ HostIp: "127.0.0.1", HostPort: "5436" }] });
|
||||
return (await box.exec(cmd)).replace("1538", "153C");
|
||||
});
|
||||
const writeFile = vi.fn(async (_path: string, _content: string) => {});
|
||||
await ensureContainerMail({ exec, writeFile, streamExec: box.streamExec } as never, {
|
||||
domain: "example.com",
|
||||
dbContainer: "custom-mail-db",
|
||||
secrets: {},
|
||||
onLog: () => {},
|
||||
});
|
||||
expect(
|
||||
box.streamExec.mock.calls.find(([cmd]) => cmd.includes("postgres:16-alpine"))?.[0],
|
||||
).toContain("-p '127.0.0.1:5436:5432'");
|
||||
expect(writeFile.mock.calls.find(([path]) => path.endsWith("engine.env"))?.[1]).toContain(
|
||||
"OPENSHIP_MAIL_DB_PORT=5436\n",
|
||||
);
|
||||
expect(
|
||||
exec.mock.calls.filter(([cmd]) => cmd.includes("docker rm")).map(([cmd]) => cmd),
|
||||
).toEqual([
|
||||
"docker rm -f 'custom-mail-db' 2>/dev/null || true",
|
||||
"docker rm -f 'openship-mail' 2>/dev/null || true",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the registry pull when the image is already present locally (delivered dev tag)", async () => {
|
||||
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
|
||||
const { executor, streamExec } = firstBootExecutor({ imagePresent: true });
|
||||
@@ -210,12 +374,16 @@ describe("ensureContainerMail swap", () => {
|
||||
* host (null = the file is gone, the unrecoverable case).
|
||||
*/
|
||||
function retainedDbExecutor(opts: { initialised: boolean; retainedEnv: string | null }) {
|
||||
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
|
||||
let dbStarted = false;
|
||||
const streamExec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("docker run") && cmd.includes("postgres")) dbStarted = true;
|
||||
return { code: 0, output: "" };
|
||||
});
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes(STATE_PROBE)) return "";
|
||||
if (cmd.includes("docker version")) return "27.0.0\n";
|
||||
if (cmd.includes("docker image inspect")) return "sha256:abc\n";
|
||||
if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING;
|
||||
if (cmd.includes("/proc/net/tcp")) return dbStarted ? PROC_LISTENING : "";
|
||||
if (cmd.includes("PG_VERSION")) return opts.initialised ? "yes\n" : "";
|
||||
return "";
|
||||
});
|
||||
@@ -228,12 +396,16 @@ function retainedDbExecutor(opts: { initialised: boolean; retainedEnv: string |
|
||||
}
|
||||
|
||||
function envWriteExecutor() {
|
||||
const streamExec = vi.fn(async (_cmd: string) => ({ code: 0, output: "" }));
|
||||
let dbStarted = false;
|
||||
const streamExec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("docker run") && cmd.includes("postgres")) dbStarted = true;
|
||||
return { code: 0, output: "" };
|
||||
});
|
||||
const exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes(STATE_PROBE)) return "";
|
||||
if (cmd.includes("docker version")) return "27.0.0\n";
|
||||
if (cmd.includes("docker image inspect")) return "sha256:abc\n";
|
||||
if (cmd.includes("/proc/net/tcp")) return PROC_LISTENING;
|
||||
if (cmd.includes("/proc/net/tcp")) return dbStarted ? PROC_LISTENING : "";
|
||||
return "";
|
||||
});
|
||||
const writeFile = vi.fn(async (_path: string, _content: string) => {});
|
||||
@@ -249,8 +421,7 @@ describe("engine env-file cannot be injected with extra records", () => {
|
||||
ensureContainerMail(executor, {
|
||||
domain: "example.com",
|
||||
secrets: {
|
||||
DOMAIN_ADMIN_PASSWD_PLAIN:
|
||||
"aaaaaaaaaaaa\nBASH_FUNC_psql%%=() { echo pwned; }",
|
||||
DOMAIN_ADMIN_PASSWD_PLAIN: "aaaaaaaaaaaa\nBASH_FUNC_psql%%=() { echo pwned; }",
|
||||
},
|
||||
onLog: () => {},
|
||||
}),
|
||||
@@ -261,9 +432,7 @@ describe("engine env-file cannot be injected with extra records", () => {
|
||||
for (const [, content] of writeFile.mock.calls) {
|
||||
expect(String(content)).not.toContain("BASH_FUNC");
|
||||
}
|
||||
expect(
|
||||
streamExec.mock.calls.some(([c]) => String(c).includes("docker run")),
|
||||
).toBe(false);
|
||||
expect(streamExec.mock.calls.some(([c]) => String(c).includes("docker run"))).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a carriage return too (CR alone still ends a record)", async () => {
|
||||
@@ -316,6 +485,28 @@ describe("engine env-file cannot be injected with extra records", () => {
|
||||
describe("mail database credential over a retained pgdata (GH-564)", () => {
|
||||
const RETAINED = "POSTGRES_USER=postgres\nPOSTGRES_DB=vmail\nPOSTGRES_PASSWORD=old-cluster-pw\n";
|
||||
|
||||
it.each(['"quoted-password"', " leading and trailing "])(
|
||||
"preserves the literal retained env-file password %s",
|
||||
async (password) => {
|
||||
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
|
||||
const { executor, writeFile } = retainedDbExecutor({
|
||||
initialised: true,
|
||||
retainedEnv: `POSTGRES_PASSWORD=${password}\n`,
|
||||
});
|
||||
await ensureContainerMail(executor, {
|
||||
domain: "example.com",
|
||||
secrets: { PGSQL_ROOT_PASSWD: "new" },
|
||||
onLog: () => {},
|
||||
});
|
||||
expect(writeFile.mock.calls.find(([path]) => path.endsWith("db.env"))?.[1]).toContain(
|
||||
`POSTGRES_PASSWORD=${password}\n`,
|
||||
);
|
||||
expect(writeFile.mock.calls.find(([path]) => path.endsWith("engine.env"))?.[1]).toContain(
|
||||
`PGSQL_ROOT_PASSWD=${password}\n`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("reuses the password the existing cluster was initialised with", async () => {
|
||||
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
|
||||
const { executor, writeFile } = retainedDbExecutor({
|
||||
@@ -356,8 +547,9 @@ describe("mail database credential over a retained pgdata (GH-564)", () => {
|
||||
}).catch(() => {});
|
||||
|
||||
const dbEnv =
|
||||
writeFile.mock.calls.map(([p, c]) => [String(p), String(c)] as const).find(([p]) => p.endsWith("db.env"))?.[1] ??
|
||||
"";
|
||||
writeFile.mock.calls
|
||||
.map(([p, c]) => [String(p), String(c)] as const)
|
||||
.find(([p]) => p.endsWith("db.env"))?.[1] ?? "";
|
||||
expect(dbEnv).toContain("POSTGRES_PASSWORD=newly-generated-pw");
|
||||
// No cluster on disk, so no reason to read the old file at all.
|
||||
expect(readFile).not.toHaveBeenCalled();
|
||||
@@ -489,7 +681,14 @@ function nonRootSudoBox(
|
||||
}
|
||||
if (command.includes("docker version")) return "27.0.0\n";
|
||||
if (command.includes("docker image inspect")) return opts.imagePresent ? "sha256:abc\n" : "";
|
||||
if (command.includes("/proc/net/tcp")) return PROC_LISTENING;
|
||||
if (command.includes("/proc/net/tcp")) {
|
||||
const dbStarted =
|
||||
Boolean(opts.runningImage) ||
|
||||
[...streamExec.mock.calls, ...exec.mock.calls].some(([c]) =>
|
||||
String(c).includes("postgres:16-alpine"),
|
||||
);
|
||||
return dbStarted ? PROC_LISTENING : "";
|
||||
}
|
||||
// `elevatedExecutor.writeFile` publishes by staging unelevated, then `chown 0:0` + `mv`
|
||||
// as root — which is where a file becomes root-owned and unreadable to the launcher.
|
||||
const mv = /mv -f '([^']+)' '([^']+)'/.exec(command);
|
||||
@@ -518,7 +717,7 @@ function nonRootSudoBox(
|
||||
});
|
||||
|
||||
return {
|
||||
executor: { exec, streamExec, writeFile } as never,
|
||||
executor: { exec, streamExec, writeFile } as unknown as CommandExecutor,
|
||||
exec,
|
||||
streamExec,
|
||||
published,
|
||||
@@ -620,7 +819,9 @@ describe("mail bring-up on a non-root sudo box (GH-630)", () => {
|
||||
// Search on both components, granted without read so the directory stays unlistable.
|
||||
expect(box.traversable.has(MAIL_HOST_STATE_DIR)).toBe(true);
|
||||
expect(box.traversable.has("/var/lib/openship")).toBe(true);
|
||||
const grants = box.exec.mock.calls.map(([c]) => String(c)).filter((c) => c.includes("chmod a+x"));
|
||||
const grants = box.exec.mock.calls
|
||||
.map(([c]) => String(c))
|
||||
.filter((c) => c.includes("chmod a+x"));
|
||||
expect(grants.length).toBeGreaterThan(0);
|
||||
expect(grants.every((c) => !/chmod a\+rx|chmod 0?755/.test(c))).toBe(true);
|
||||
});
|
||||
@@ -668,4 +869,108 @@ describe("mail bring-up on a non-root sudo box (GH-630)", () => {
|
||||
.filter((c) => /chown|chmod a\+x|chmod 400/.test(c));
|
||||
expect(touched).toEqual([]);
|
||||
});
|
||||
|
||||
it("publishes the configured dbPort for the postgres sidecar and in engine env", async () => {
|
||||
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
|
||||
const box = nonRootSudoBox({ imagePresent: true });
|
||||
|
||||
const originalExec = box.exec;
|
||||
box.executor.exec = box.exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("/proc/net/tcp")) {
|
||||
return (
|
||||
" sl local_address rem_address st ...\n" +
|
||||
(box
|
||||
.dockerCommands()
|
||||
.some((c) => c.includes("docker run") && c.includes("postgres:16-alpine"))
|
||||
? " 0: 00000000:1539 00000000:0000 0A 00000000:00000000\n"
|
||||
: "") +
|
||||
" 1: 00000000:0019 00000000:0000 0A 00000000:00000000\n" +
|
||||
" 2: 00000000:03E1 00000000:0000 0A 00000000:00000000"
|
||||
);
|
||||
}
|
||||
return originalExec(cmd);
|
||||
});
|
||||
|
||||
await ensureContainerMail(box.executor, {
|
||||
domain: "example.com",
|
||||
secrets: { PGSQL_ROOT_PASSWD: "pw" },
|
||||
dbPort: 5433,
|
||||
onLog: () => {},
|
||||
});
|
||||
|
||||
const dbRun = box
|
||||
.dockerCommands()
|
||||
.find((c) => c.includes("docker run") && c.includes("postgres:16-alpine"));
|
||||
expect(dbRun).toBeDefined();
|
||||
expect(dbRun).toContain("-p '127.0.0.1:5433:5432'");
|
||||
});
|
||||
|
||||
it("auto-discovers next available port when default 5432 is occupied during ensureContainerMail", async () => {
|
||||
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
|
||||
const box = nonRootSudoBox({ imagePresent: true });
|
||||
|
||||
const originalExec = box.exec;
|
||||
box.executor.exec = box.exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("/proc/net/tcp")) {
|
||||
const dbStarted = box.dockerCommands().some((c) => c.includes("127.0.0.1:5433:5432"));
|
||||
// Before sidecar start: 5432 is occupied (1538), 5433 is free
|
||||
// After sidecar start: 5433 is listening (1539)
|
||||
const portHex = dbStarted ? "1539" : "1538";
|
||||
return (
|
||||
" sl local_address rem_address st ...\n" +
|
||||
` 0: 00000000:${portHex} 00000000:0000 0A 00000000:00000000\n` +
|
||||
" 1: 00000000:0019 00000000:0000 0A 00000000:00000000\n" +
|
||||
" 2: 00000000:03E1 00000000:0000 0A 00000000:00000000"
|
||||
);
|
||||
}
|
||||
return originalExec(cmd);
|
||||
});
|
||||
|
||||
await ensureContainerMail(box.executor, {
|
||||
domain: "example.com",
|
||||
secrets: { PGSQL_ROOT_PASSWD: "pw" },
|
||||
onLog: () => {},
|
||||
});
|
||||
|
||||
const dbRun = box
|
||||
.dockerCommands()
|
||||
.find((c) => c.includes("docker run") && c.includes("postgres:16-alpine"));
|
||||
expect(dbRun).toBeDefined();
|
||||
// Automatically selects 5433
|
||||
expect(dbRun).toContain("-p '127.0.0.1:5433:5432'");
|
||||
});
|
||||
|
||||
it("retains the previously assigned dbPort from engine.env on an existing cluster", async () => {
|
||||
setDefaultMailImage("ghcr.io/x/openship-mail:pinned");
|
||||
const box = nonRootSudoBox({ imagePresent: true });
|
||||
|
||||
const originalExec = box.exec;
|
||||
box.executor.exec = box.exec = vi.fn(async (cmd: string) => {
|
||||
if (cmd.includes("PG_VERSION")) return "yes\n";
|
||||
if (cmd.includes("cat ") && cmd.includes("engine.env")) return "OPENSHIP_MAIL_DB_PORT=5436\n";
|
||||
if (cmd.includes("cat ") && cmd.includes("db.env")) return "POSTGRES_PASSWORD=pw\n";
|
||||
if (cmd.includes("/proc/net/tcp")) {
|
||||
return (
|
||||
" sl local_address rem_address st ...\n" +
|
||||
" 0: 00000000:153C 00000000:0000 0A 00000000:00000000\n" +
|
||||
" 1: 00000000:0019 00000000:0000 0A 00000000:00000000\n" +
|
||||
" 2: 00000000:03E1 00000000:0000 0A 00000000:00000000"
|
||||
);
|
||||
}
|
||||
return originalExec(cmd);
|
||||
});
|
||||
|
||||
await ensureContainerMail(box.executor, {
|
||||
domain: "example.com",
|
||||
secrets: { PGSQL_ROOT_PASSWD: "pw" },
|
||||
onLog: () => {},
|
||||
});
|
||||
|
||||
const dbRun = box
|
||||
.dockerCommands()
|
||||
.find((c) => c.includes("docker run") && c.includes("postgres:16-alpine"));
|
||||
expect(dbRun).toBeDefined();
|
||||
// Retains 5436 from engine.env
|
||||
expect(dbRun).toContain("-p '127.0.0.1:5436:5432'");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
} from "../managed-image";
|
||||
import { dirOf, elevatedExecutor } from "../elevated-executor";
|
||||
import { resolveEnvironment } from "../environment";
|
||||
import { waitForPortListening } from "../port-listen";
|
||||
import { waitForPortListening, probePortListeningOnce } from "../port-listen";
|
||||
import { rootOrDegrade } from "../privilege";
|
||||
import {
|
||||
MAIL_CONTAINER,
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
MAIL_DB_NAME,
|
||||
MAIL_DB_USER,
|
||||
MAIL_DB_HOST_BIND,
|
||||
MAIL_DB_PORT,
|
||||
MAIL_DB_FALLBACK_PORT,
|
||||
MAIL_DB_PORT_RANGE_MAX,
|
||||
MAIL_DB_INTERNAL_PORT,
|
||||
resolveMailDbPort,
|
||||
type MailMount,
|
||||
} from "../../infra/mail-container";
|
||||
|
||||
@@ -99,15 +102,17 @@ export interface ContainerMailOptions {
|
||||
image?: string;
|
||||
container?: string;
|
||||
dbContainer?: string;
|
||||
/**
|
||||
* Host port for the PostgreSQL sidecar. Defaults to `OPENSHIP_MAIL_DB_PORT`
|
||||
* if set in the environment, otherwise 5432.
|
||||
*/
|
||||
dbPort?: number;
|
||||
/** How long to wait for the mail ports before calling the start a failure. */
|
||||
verifyTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Is a container present (running or stopped)? */
|
||||
async function containerExists(
|
||||
executor: CommandExecutor,
|
||||
container: string,
|
||||
): Promise<boolean> {
|
||||
async function containerExists(executor: CommandExecutor, container: string): Promise<boolean> {
|
||||
return (await containerState(executor, container)) !== null;
|
||||
}
|
||||
|
||||
@@ -130,7 +135,11 @@ function pullFailureMessage(image: string, output: string): string {
|
||||
.filter(Boolean)
|
||||
.pop() ?? "";
|
||||
|
||||
if (/manifest unknown|manifest for .* not found|not found: manifest|repository .* not found/.test(text)) {
|
||||
if (
|
||||
/manifest unknown|manifest for .* not found|not found: manifest|repository .* not found/.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
return (
|
||||
`The mail engine image ${image} isn't in the registry. ` +
|
||||
"The engine image isn't published yet, so a server can only run it from a local " +
|
||||
@@ -143,7 +152,11 @@ function pullFailureMessage(image: string, output: string): string {
|
||||
"Log this server's Docker into that registry, or set OPENSHIP_MAIL_IMAGE to one it can read."
|
||||
);
|
||||
}
|
||||
if (/timeout|timed out|no such host|temporary failure|network is unreachable|connection refused|i\/o timeout|tls|certificate/.test(text)) {
|
||||
if (
|
||||
/timeout|timed out|no such host|temporary failure|network is unreachable|connection refused|i\/o timeout|tls|certificate/.test(
|
||||
text,
|
||||
)
|
||||
) {
|
||||
return (
|
||||
`This server couldn't reach the registry to pull ${image} (${lastLine || "network error"}). ` +
|
||||
"Check its outbound network/DNS and proxy settings, then retry."
|
||||
@@ -374,15 +387,120 @@ async function retainedDbPassword(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* For an existing initialised cluster, read the retained database port from ENGINE_ENV_FILE.
|
||||
* Preserving the previously assigned port prevents repairs/restarts from drifting ports.
|
||||
*/
|
||||
export async function retainedDbPort(
|
||||
executor: CommandExecutor,
|
||||
onLog?: SystemLogCallback,
|
||||
): Promise<number | null> {
|
||||
const initialised = await executor
|
||||
.exec(`test -s ${sq(`${MAIL_DB_HOST_DATA_DIR}/pgdata/PG_VERSION`)} && echo yes || true`)
|
||||
.then((out) => out.trim() === "yes")
|
||||
.catch(() => false);
|
||||
if (!initialised) return null;
|
||||
|
||||
const retained = await readEnvFileValue(executor, ENGINE_ENV_FILE, "OPENSHIP_MAIL_DB_PORT");
|
||||
if (!retained) return null;
|
||||
const n = resolveMailDbPort(retained);
|
||||
|
||||
onLog?.(
|
||||
log(
|
||||
`Reusing existing mail database port ${n} — ${MAIL_DB_HOST_DATA_DIR} already holds ` +
|
||||
`an initialised cluster.`,
|
||||
),
|
||||
);
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an available host loopback port for the mail database sidecar.
|
||||
* If the preferred port is free, returns it. If the default 5432 is occupied and
|
||||
* the port was not explicitly specified, scans up to MAIL_DB_PORT_RANGE_MAX (5460)
|
||||
* for the first available port.
|
||||
*/
|
||||
export async function findAvailableMailDbPort(
|
||||
executor: CommandExecutor,
|
||||
preferredPort: number,
|
||||
isExplicit: boolean,
|
||||
onLog: SystemLogCallback,
|
||||
): Promise<number> {
|
||||
resolveMailDbPort(preferredPort);
|
||||
const probe = await probePortListeningOnce(executor, preferredPort);
|
||||
if (probe !== true) {
|
||||
if (probe === null)
|
||||
onLog(
|
||||
log(
|
||||
`Could not probe mail database port ${preferredPort}; Docker will validate the binding.`,
|
||||
"warn",
|
||||
),
|
||||
);
|
||||
return preferredPort;
|
||||
}
|
||||
|
||||
// If the user explicitly configured this port, do not auto-switch ports
|
||||
if (isExplicit) {
|
||||
throw new Error(
|
||||
`Configured mail database port ${preferredPort} is already in use. Choose a free OPENSHIP_MAIL_DB_PORT.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-discovery: default port is occupied; scan candidate range 5433..5460
|
||||
for (let port = MAIL_DB_FALLBACK_PORT; port <= MAIL_DB_PORT_RANGE_MAX; port++) {
|
||||
const candidate = await probePortListeningOnce(executor, port);
|
||||
if (candidate === false) {
|
||||
onLog(
|
||||
log(
|
||||
`Default PostgreSQL port ${preferredPort} is in use on this host. ` +
|
||||
`Automatically selected available port ${port} for the mail database ` +
|
||||
`(can be overridden via OPENSHIP_MAIL_DB_PORT).`,
|
||||
"warn",
|
||||
),
|
||||
);
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No free mail database port was found in ${preferredPort}, ${MAIL_DB_FALLBACK_PORT}-${MAIL_DB_PORT_RANGE_MAX}. Set OPENSHIP_MAIL_DB_PORT to a free port.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Inspect the existing sidecar without changing it or reading root-only files. */
|
||||
async function containerDbPort(
|
||||
executor: CommandExecutor,
|
||||
container: string,
|
||||
): Promise<number | null> {
|
||||
const raw = await executor
|
||||
.exec(`docker inspect -f '{{json .HostConfig.PortBindings}}' ${sq(container)} 2>/dev/null`)
|
||||
.catch(() => "");
|
||||
if (!raw.trim()) return null;
|
||||
let bindings: Record<string, Array<{ HostIp?: string; HostPort?: string }>>;
|
||||
try {
|
||||
bindings = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Could not read the mail database container's port bindings.");
|
||||
}
|
||||
const binding = bindings?.[`${MAIL_DB_INTERNAL_PORT}/tcp`];
|
||||
if (binding?.length !== 1 || binding[0].HostIp !== MAIL_DB_HOST_BIND || !binding[0].HostPort) {
|
||||
throw new Error(
|
||||
"The mail database container must publish one PostgreSQL port on host loopback.",
|
||||
);
|
||||
}
|
||||
return resolveMailDbPort(binding[0].HostPort);
|
||||
}
|
||||
|
||||
/** `docker run` argv for the Postgres sidecar (loopback-published, bind-mounted data). */
|
||||
function buildDbRunCommand(container: string): string {
|
||||
export function buildDbRunCommand(container: string, dbPort: number = resolveMailDbPort()): string {
|
||||
resolveMailDbPort(dbPort);
|
||||
return [
|
||||
"docker run -d",
|
||||
`--name ${sq(container)}`,
|
||||
"--restart unless-stopped",
|
||||
`--env-file ${sq(DB_ENV_FILE)}`,
|
||||
`-e ${sq(`PGDATA=${MAIL_DB_PGDATA}`)}`,
|
||||
`-p ${sq(`${MAIL_DB_HOST_BIND}:${MAIL_DB_PORT}:${MAIL_DB_PORT}`)}`,
|
||||
`-p ${sq(`${MAIL_DB_HOST_BIND}:${dbPort}:${MAIL_DB_INTERNAL_PORT}`)}`,
|
||||
`-v ${sq(`${MAIL_DB_HOST_DATA_DIR}:${MAIL_DB_CONTAINER_DATA_DIR}:z`)}`,
|
||||
sq(MAIL_DB_IMAGE),
|
||||
].join(" ");
|
||||
@@ -416,11 +534,15 @@ async function startDb(
|
||||
executor: CommandExecutor,
|
||||
container: string,
|
||||
onLog: SystemLogCallback,
|
||||
dbPort: number = resolveMailDbPort(),
|
||||
): Promise<boolean> {
|
||||
await executor.exec(`docker rm -f ${sq(container)} 2>/dev/null || true`).catch(() => {});
|
||||
const run = await executor.streamExec(buildDbRunCommand(container), onLog as (l: LogEntry) => void);
|
||||
const run = await executor.streamExec(
|
||||
buildDbRunCommand(container, dbPort),
|
||||
onLog as (l: LogEntry) => void,
|
||||
);
|
||||
if (run.code !== 0) return false;
|
||||
const listening = await waitForPortListening(executor, MAIL_DB_PORT, { timeoutMs: 60_000 });
|
||||
const listening = await waitForPortListening(executor, dbPort, { timeoutMs: 60_000 });
|
||||
// checked:false = inconclusive probe; don't fail the DB on a missing /proc read.
|
||||
return !(listening.checked && !listening.listening);
|
||||
}
|
||||
@@ -614,7 +736,13 @@ export async function ensureContainerMail(
|
||||
const retainedRoot = await retainedDbPassword(hostState, onLog);
|
||||
const dbRootPassword =
|
||||
retainedRoot ?? opts.secrets.PGSQL_ROOT_PASSWD ?? opts.secrets.VMAIL_DB_ADMIN_PASSWD ?? "";
|
||||
|
||||
const isExplicitPort =
|
||||
opts.dbPort !== undefined || Boolean(process.env.OPENSHIP_MAIL_DB_PORT?.trim());
|
||||
const preferredPort = resolveMailDbPort(opts.dbPort);
|
||||
const retainedPort =
|
||||
(await retainedDbPort(hostState, onLog)) ?? (await containerDbPort(executor, dbContainer));
|
||||
const dbPort =
|
||||
retainedPort ?? (await findAvailableMailDbPort(executor, preferredPort, isExplicitPort, onLog));
|
||||
await writeEnvFile(hostState, DB_ENV_FILE, {
|
||||
POSTGRES_USER: "postgres",
|
||||
POSTGRES_DB: MAIL_DB_NAME,
|
||||
@@ -622,12 +750,12 @@ export async function ensureContainerMail(
|
||||
});
|
||||
await handOverEnvFile(executor, DB_ENV_FILE, onLog);
|
||||
await writeEnvFile(hostState, ENGINE_ENV_FILE, {
|
||||
...opts.secrets,
|
||||
FIRST_DOMAIN: opts.domain,
|
||||
OPENSHIP_MAIL_DB_HOST: MAIL_DB_HOST_BIND,
|
||||
OPENSHIP_MAIL_DB_PORT: String(MAIL_DB_PORT),
|
||||
OPENSHIP_MAIL_DB_PORT: String(dbPort),
|
||||
OPENSHIP_MAIL_DB_NAME: MAIL_DB_NAME,
|
||||
OPENSHIP_MAIL_DB_USER: MAIL_DB_USER,
|
||||
...opts.secrets,
|
||||
// Spread LAST so the retained value wins: the engine's first-boot bootstrap connects
|
||||
// as the superuser, and it has to use the password the cluster actually has, not the
|
||||
// one this deploy generated.
|
||||
@@ -638,7 +766,7 @@ export async function ensureContainerMail(
|
||||
try {
|
||||
// 4. DB sidecar first — the engine's entrypoint blocks on it.
|
||||
onLog(log("Starting the mail database (postgres sidecar)..."));
|
||||
if (!(await startDb(executor, dbContainer, onLog))) {
|
||||
if (!(await startDb(executor, dbContainer, onLog, dbPort))) {
|
||||
throw new Error("the mail database container failed to become ready");
|
||||
}
|
||||
|
||||
@@ -711,6 +839,10 @@ export async function startContainerMail(
|
||||
};
|
||||
}
|
||||
|
||||
const dbPort = await containerDbPort(executor, dbContainer);
|
||||
if (dbPort === null)
|
||||
return { started: false, reason: "Could not determine the existing mail database port." };
|
||||
|
||||
// DB sidecar first: the engine's entrypoint blocks on it.
|
||||
onLog(log("Starting the mail database (postgres sidecar)..."));
|
||||
const db = await executor.streamExec(
|
||||
@@ -720,10 +852,10 @@ export async function startContainerMail(
|
||||
if (db.code !== 0) {
|
||||
return { started: false, reason: "the mail database container did not start" };
|
||||
}
|
||||
const dbListening = await waitForPortListening(executor, MAIL_DB_PORT, { timeoutMs: 60_000 });
|
||||
const dbListening = await waitForPortListening(executor, dbPort, { timeoutMs: 60_000 });
|
||||
// checked:false = inconclusive probe; don't fail on a missing /proc read.
|
||||
if (dbListening.checked && !dbListening.listening) {
|
||||
return { started: false, reason: `the mail database is not listening on :${MAIL_DB_PORT}` };
|
||||
return { started: false, reason: `the mail database is not listening on :${dbPort}` };
|
||||
}
|
||||
|
||||
onLog(log("Starting the mail engine container..."));
|
||||
|
||||
Reference in New Issue
Block a user