/* Copyright 2024 New Vector Ltd. Copyright 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import * as os from "os"; import * as crypto from "crypto"; import * as childProcess from "child_process"; import * as fse from "fs-extra"; /** * @param cmd - command to execute * @param args - arguments to pass to executed command * @param suppressOutput - whether to suppress the stdout and stderr resulting from this command. * @return Promise which resolves to an object containing the string value of what was * written to stdout and stderr by the executed command. */ const exec = (cmd: string, args: string[], suppressOutput = false): Promise<{ stdout: string; stderr: string }> => { return new Promise((resolve, reject) => { if (!suppressOutput) { const log = ["Running command:", cmd, ...args, "\n"].join(" "); // When in CI mode we combine reports from multiple runners into a single HTML report // which has separate files for stdout and stderr, so we print the executed command to both process.stdout.write(log); if (process.env.CI) process.stderr.write(log); } const { stdout, stderr } = childProcess.execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => { if (err) reject(err); resolve({ stdout, stderr }); if (!suppressOutput) { process.stdout.write("\n"); if (process.env.CI) process.stderr.write("\n"); } }); if (!suppressOutput) { stdout.pipe(process.stdout); stderr.pipe(process.stderr); } }); }; export class Docker { public id: string; async run(opts: { image: string; containerName: string; params?: string[]; cmd?: string[] }): Promise { const userInfo = os.userInfo(); const params = opts.params ?? []; const isPodman = await Docker.isPodman(); if (params.includes("-v") && userInfo.uid >= 0) { // Run the docker container as our uid:gid to prevent problems with permissions. if (isPodman) { // Note: this setup is for podman rootless containers. // In podman, run as root in the container, which maps to the current // user on the host. This is probably the default since Synapse's // Dockerfile doesn't specify, but we're being explicit here // because it's important for the permissions to work. params.push("-u", "0:0"); // Tell Synapse not to switch UID params.push("-e", "UID=0"); params.push("-e", "GID=0"); } else { params.push("-u", `${userInfo.uid}:${userInfo.gid}`); } } // Make host.containers.internal work to allow the container to talk to other services via host ports. if (isPodman) { params.push("--network"); params.push("slirp4netns:allow_host_loopback=true"); } else { // Docker for Desktop includes a host-gateway mapping on host.docker.internal but to simplify the config // we use the Podman variant host.containers.internal in all environments. params.push("--add-host"); params.push("host.containers.internal:host-gateway"); } // Provided we are not running in CI, add a `--rm` parameter. // There is no need to remove containers in CI (since they are automatically removed anyway), and // `--rm` means that if a container crashes this means its logs are wiped out. if (!process.env.CI) params.unshift("--rm"); const args = [ "run", "--name", `${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`, "-d", ...params, opts.image, ]; if (opts.cmd) args.push(...opts.cmd); const { stdout } = await exec("docker", args); this.id = stdout.trim(); return this.id; } async stop(): Promise { try { await exec("docker", ["stop", this.id]); } catch (err) { console.error(`Failed to stop docker container`, this.id, err); } } /** * @param params - list of parameters to pass to `docker exec` * @param suppressOutput - whether to suppress the stdout and stderr resulting from this command. */ async exec(params: string[], suppressOutput = true): Promise { await exec("docker", ["exec", this.id, ...params], suppressOutput); } async getContainerIp(): Promise { const { stdout } = await exec("docker", ["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", this.id]); return stdout.trim(); } async persistLogsToFile(args: { stdoutFile?: string; stderrFile?: string }): Promise { const stdoutFile = args.stdoutFile ? await fse.open(args.stdoutFile, "w") : "ignore"; const stderrFile = args.stderrFile ? await fse.open(args.stderrFile, "w") : "ignore"; await new Promise((resolve) => { childProcess .spawn("docker", ["logs", this.id], { stdio: ["ignore", stdoutFile, stderrFile], }) .once("close", resolve); }); if (args.stdoutFile) await fse.close(stdoutFile); if (args.stderrFile) await fse.close(stderrFile); } /** * Detects whether the docker command is actually podman. * To do this, it looks for "podman" in the output of "docker --help". */ static async isPodman(): Promise { const { stdout } = await exec("docker", ["--help"], true); return stdout.toLowerCase().includes("podman"); } }