* fix: use native DNS resolution by default for Docker services * test: verify Docker service DNS through production Compose * test: isolate Docker DNS smoke resources per invocation [skip ci]
233 lines
9.1 KiB
TypeScript
233 lines
9.1 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { execFile } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { resolve } from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
const execAsync = promisify(execFile);
|
|
const composeFile = resolve(import.meta.dirname, "docker-network.compose.yml");
|
|
const project = `homarr-dns-${randomUUID()}`;
|
|
const temporaryDirectory = await mkdtemp(`${tmpdir()}/homarr-dns-proof-`);
|
|
const overrideFile = `${temporaryDirectory}/cache.json`;
|
|
let override = false;
|
|
let baseUrl = "";
|
|
let apiKey = "";
|
|
let ownsProject = false;
|
|
|
|
async function assertUnusedProject() {
|
|
const label = `label=com.docker.compose.project=${project}`;
|
|
for (const resource of ["container", "network", "volume"]) {
|
|
const args = [resource, "ls", "--quiet", "--filter", label];
|
|
if (resource === "container") args.push("--all");
|
|
const result = await execAsync("docker", args);
|
|
assert.equal(result.stdout.trim(), "", "Disposable proof project already has resources; no mutation allowed");
|
|
}
|
|
}
|
|
|
|
async function compose(args: string[], environment: Record<string, string> = {}) {
|
|
const files = ["-f", composeFile];
|
|
if (override) files.push("-f", overrideFile);
|
|
const result = await execAsync("docker", ["compose", "-p", project, ...files, ...args], {
|
|
env: { ...process.env, ...environment },
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
});
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
async function refreshUrl() {
|
|
const address = await compose(["port", "homarr", "7575"]);
|
|
baseUrl = `http://${address}`;
|
|
}
|
|
|
|
async function nextProcessIdentity() {
|
|
const identity = await compose([
|
|
"exec",
|
|
"-T",
|
|
"homarr",
|
|
"node",
|
|
"-e",
|
|
`
|
|
const fs = require('node:fs');
|
|
for (const pid of fs.readdirSync('/proc').filter(value => /^\\d+$/.test(value))) {
|
|
try {
|
|
if (fs.readFileSync('/proc/'+pid+'/cmdline','utf8').startsWith('next-server')) {
|
|
const stat = fs.readFileSync('/proc/'+pid+'/stat','utf8');
|
|
console.log(pid, stat.slice(stat.lastIndexOf(')')+2).split(' ')[19]);
|
|
}
|
|
} catch {}
|
|
}
|
|
`,
|
|
]);
|
|
assert.ok(identity, "Next.js process was not found");
|
|
return identity;
|
|
}
|
|
|
|
async function api(path: string, input: unknown) {
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ApiKey: apiKey },
|
|
body: JSON.stringify(input),
|
|
signal: AbortSignal.timeout(25000),
|
|
});
|
|
const result = await response.json();
|
|
assert.equal(response.status, 200, `Homarr ${path} returned HTTP ${response.status}`);
|
|
return result;
|
|
}
|
|
|
|
async function createIntegration(name: string, url: string, kind: string, token: string) {
|
|
const result = await api("/api/trpc/integration.create", {
|
|
json: { name, url, kind, secrets: [{ kind: "apiKey", value: token }], attemptSearchEngineCreation: false },
|
|
});
|
|
const data = result.result?.data?.json ?? result.result?.data;
|
|
const integration = data?.integration;
|
|
assert.ok(
|
|
integration?.id,
|
|
`Connection test/create failed for ${name}: ${JSON.stringify(data?.error?.type ?? result.error?.message ?? Object.keys(result))}`,
|
|
);
|
|
console.log(`PASS Next.js connection test: ${name} at ${url}`);
|
|
return integration.id as string;
|
|
}
|
|
|
|
async function request(integrationId: string, path: string) {
|
|
const result = await api("/api/integrations/request", { integrationId, path, method: "GET" });
|
|
assert.equal(result.ok, true);
|
|
assert.equal(result.status, 200);
|
|
return result.data;
|
|
}
|
|
|
|
async function upstreamKey(service: string) {
|
|
const config = await compose(["exec", "-T", service, "cat", "/config/config.xml"]);
|
|
const token = config.match(/<ApiKey>([^<]+)<\/ApiKey>/)?.[1];
|
|
assert.ok(token, `Missing API key for disposable ${service}`);
|
|
return token;
|
|
}
|
|
|
|
async function recreateFixture(address: string, instance: string) {
|
|
await compose(["up", "-d", "--no-deps", "--force-recreate", "fixture"], {
|
|
PROOF_SERVICE_IP: address,
|
|
PROOF_INSTANCE: instance,
|
|
});
|
|
const deadline = Date.now() + 10000;
|
|
while (true) {
|
|
try {
|
|
await compose([
|
|
"exec",
|
|
"-T",
|
|
"fixture",
|
|
"node",
|
|
"-e",
|
|
"fetch('http://127.0.0.1:6767/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
|
]);
|
|
return;
|
|
} catch {
|
|
assert.ok(Date.now() < deadline, "Recreated fixture never became ready");
|
|
await new Promise((done) => setTimeout(done, 100));
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
await assertUnusedProject();
|
|
// The unpredictable, empty namespace belongs only to this invocation, including partial startup.
|
|
ownsProject = true;
|
|
await compose(["up", "-d", "--wait", "--wait-timeout", "180"]);
|
|
await refreshUrl();
|
|
for (const path of ["/api/health/ready", "/api/health/live"]) {
|
|
const response = await fetch(`${baseUrl}${path}`);
|
|
assert.equal(response.status, 200);
|
|
console.log(`PASS production Homarr ${path}: HTTP 200`);
|
|
}
|
|
|
|
// Provision only the disposable database. Keep the generated API key out of logs.
|
|
apiKey = await compose([
|
|
"exec",
|
|
"-T",
|
|
"homarr",
|
|
"node",
|
|
"-e",
|
|
`
|
|
const Database = require('better-sqlite3');
|
|
const db = new Database(process.env.DB_URL, {nativeBinding:'/app/build/better_sqlite3.node'});
|
|
const token = require('node:crypto').randomBytes(24).toString('hex');
|
|
const hash = require('bcrypt').hashSync(token, 10);
|
|
db.transaction(() => {
|
|
db.prepare('INSERT OR IGNORE INTO user(id,name) VALUES (?,?)').run('dns-proof-user','DNS proof');
|
|
db.prepare('INSERT OR IGNORE INTO "group"(id,name,position) VALUES (?,?,?)').run('dns-proof-group','DNS proof',999);
|
|
db.prepare('INSERT OR IGNORE INTO groupMember(group_id,user_id) VALUES (?,?)').run('dns-proof-group','dns-proof-user');
|
|
db.prepare('DELETE FROM groupPermission WHERE group_id=?').run('dns-proof-group');
|
|
db.prepare('INSERT INTO groupPermission(group_id,permission) VALUES (?,?)').run('dns-proof-group','admin');
|
|
db.prepare('INSERT OR REPLACE INTO apiKey(id,api_key,user_id) VALUES (?,?,?)').run('dns-proof-key',hash,'dns-proof-user');
|
|
})();
|
|
db.close();
|
|
process.stdout.write('dns-proof-key.'+token);
|
|
`,
|
|
]);
|
|
|
|
for (const [service, port] of [
|
|
["sonarr", 8989],
|
|
["radarr", 7878],
|
|
] as const) {
|
|
const id = await createIntegration(service, `http://${service}:${port}`, service, await upstreamKey(service));
|
|
const health = await request(id, "/ping");
|
|
assert.equal(health.status, "OK");
|
|
const status = await request(id, "/api/v3/system/status");
|
|
assert.ok(status.version);
|
|
console.log(
|
|
`PASS Next.js -> ${service}:${port}/ping and authenticated system/status: HTTP 200, version ${status.version}`,
|
|
);
|
|
}
|
|
|
|
const fixture = await createIntegration("DNS fixture", "http://fixture:6767", "sonarr", "fixture-token");
|
|
const hosts = await createIntegration("Hosts override", "http://overridden-service:6767", "sonarr", "fixture-token");
|
|
assert.equal((await request(hosts, "/health")).instance, "hosts-override");
|
|
console.log("PASS extra_hosts takes precedence over the conflicting Docker DNS alias");
|
|
assert.equal((await request(fixture, "/health")).instance, "original");
|
|
const originalContainer = await compose(["ps", "-q", "homarr"]);
|
|
const originalNextProcess = await nextProcessIdentity();
|
|
await recreateFixture("10.203.199.12", "replacement");
|
|
assert.equal((await request(fixture, "/health")).instance, "replacement");
|
|
assert.equal(await compose(["ps", "-q", "homarr"]), originalContainer);
|
|
assert.equal(await nextProcessIdentity(), originalNextProcess);
|
|
console.log("PASS fixture IP 10.203.199.10 -> 10.203.199.12: HTTP 200 without restarting Homarr");
|
|
|
|
// Re-enable the old default as a control, using the same production image and API routes.
|
|
await writeFile(
|
|
overrideFile,
|
|
JSON.stringify({ services: { homarr: { environment: { ENABLE_DNS_CACHING: "true" } } } }),
|
|
);
|
|
override = true;
|
|
await compose(["up", "-d", "--no-deps", "--force-recreate", "--wait", "--wait-timeout", "90", "homarr"]);
|
|
await refreshUrl();
|
|
assert.equal((await request(fixture, "/health")).instance, "replacement");
|
|
assert.equal((await request(hosts, "/health")).instance, "replacement");
|
|
console.log("PASS control ENABLE_DNS_CACHING=true reproduces DNS overriding extra_hosts");
|
|
await recreateFixture("10.203.199.13", "control-replacement");
|
|
const native = JSON.parse(
|
|
await compose([
|
|
"exec",
|
|
"-T",
|
|
"homarr",
|
|
"node",
|
|
"-e",
|
|
"fetch('http://fixture:6767/health').then(async r=>console.log(JSON.stringify(await r.json()))).catch(()=>process.exit(1))",
|
|
]),
|
|
);
|
|
assert.equal(native.instance, "control-replacement");
|
|
await assert.rejects(
|
|
request(fixture, "/health"),
|
|
(error: unknown) => error instanceof assert.AssertionError && [502, 504].includes(error.actual as number),
|
|
);
|
|
console.log("PASS control ENABLE_DNS_CACHING=true reproduces failure after fixture IP change");
|
|
} finally {
|
|
try {
|
|
if (ownsProject) {
|
|
await compose(["down", "--volumes"]);
|
|
console.log("Removed this invocation's disposable Compose stack and volumes");
|
|
}
|
|
} finally {
|
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|