Improvements around docker in Playwright (#12261)

* Extract Postgres Docker to its own class

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Don't specify docker `--rm` in CI as it makes debugging harder

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve docker commands and introspection

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove `HOST_DOCKER_INTERNAL` magic in favour of `host.containers.internal`

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Always pipe

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Re-add pipe flag to silence pg_isready and podman checks

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
pull/28217/head
Michael Telatynski 2024-02-20 14:21:10 +00:00 committed by GitHub
parent dd5b7417be
commit a9add4504f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 152 additions and 135 deletions

View File

@ -25,7 +25,7 @@ test.describe("Email Registration", async () => {
use({ use({
template: "email", template: "email",
variables: { variables: {
SMTP_HOST: "{{HOST_DOCKER_INTERNAL}}", // This will get replaced in synapseStart SMTP_HOST: "host.containers.internal",
SMTP_PORT: mailhog.instance.smtpPort, SMTP_PORT: mailhog.instance.smtpPort,
}, },
}), }),

View File

@ -19,6 +19,37 @@ import * as crypto from "crypto";
import * as childProcess from "child_process"; import * as childProcess from "child_process";
import * as fse from "fs-extra"; 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 { export class Docker {
public id: string; public id: string;
@ -26,9 +57,10 @@ export class Docker {
const userInfo = os.userInfo(); const userInfo = os.userInfo();
const params = opts.params ?? []; const params = opts.params ?? [];
if (params?.includes("-v") && userInfo.uid >= 0) { 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. // Run the docker container as our uid:gid to prevent problems with permissions.
if (await Docker.isPodman()) { if (isPodman) {
// Note: this setup is for podman rootless containers. // Note: this setup is for podman rootless containers.
// In podman, run as root in the container, which maps to the current // In podman, run as root in the container, which maps to the current
@ -45,75 +77,57 @@ export class Docker {
} }
} }
// 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 = [ const args = [
"run", "run",
"--name", "--name",
`${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`, `${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`,
"-d", "-d",
"--rm",
...params, ...params,
opts.image, opts.image,
]; ];
if (opts.cmd) args.push(...opts.cmd); if (opts.cmd) args.push(...opts.cmd);
this.id = await new Promise<string>((resolve, reject) => { const { stdout } = await exec("docker", args);
childProcess.execFile("docker", args, (err, stdout) => { this.id = stdout.trim();
if (err) reject(err);
resolve(stdout.trim());
});
});
return this.id; return this.id;
} }
stop(): Promise<void> { async stop(): Promise<void> {
return new Promise<void>((resolve, reject) => { try {
childProcess.execFile("docker", ["stop", this.id], (err) => { await exec("docker", ["stop", this.id]);
if (err) reject(err); } catch (err) {
resolve(); console.error(`Failed to stop docker container`, this.id, err);
}); }
});
} }
exec(params: string[]): Promise<void> { /**
return new Promise<void>((resolve, reject) => { * @param params - list of parameters to pass to `docker exec`
childProcess.execFile( * @param suppressOutput - whether to suppress the stdout and stderr resulting from this command.
"docker", */
["exec", this.id, ...params], async exec(params: string[], suppressOutput = true): Promise<void> {
{ encoding: "utf8" }, await exec("docker", ["exec", this.id, ...params], suppressOutput);
(err, stdout, stderr) => {
if (err) {
console.log(stdout);
console.log(stderr);
reject(err);
return;
}
resolve();
},
);
});
} }
rm(): Promise<void> { async getContainerIp(): Promise<string> {
return new Promise<void>((resolve, reject) => { const { stdout } = await exec("docker", ["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", this.id]);
childProcess.execFile("docker", ["rm", this.id], (err) => { return stdout.trim();
if (err) reject(err);
resolve();
});
});
}
getContainerIp(): Promise<string> {
return new Promise<string>((resolve, reject) => {
childProcess.execFile(
"docker",
["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", this.id],
(err, stdout) => {
if (err) reject(err);
else resolve(stdout.trim());
},
);
});
} }
async persistLogsToFile(args: { stdoutFile?: string; stderrFile?: string }): Promise<void> { async persistLogsToFile(args: { stdoutFile?: string; stderrFile?: string }): Promise<void> {
@ -134,20 +148,8 @@ export class Docker {
* Detects whether the docker command is actually podman. * Detects whether the docker command is actually podman.
* To do this, it looks for "podman" in the output of "docker --help". * To do this, it looks for "podman" in the output of "docker --help".
*/ */
static isPodman(): Promise<boolean> { static async isPodman(): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => { const { stdout } = await exec("docker", ["--help"], true);
childProcess.execFile("docker", ["--help"], (err, stdout) => { return stdout.toLowerCase().includes("podman");
if (err) reject(err);
else resolve(stdout.toLowerCase().includes("podman"));
});
});
}
/**
* Supply the right hostname to use to talk to the host machine. On Docker this
* is "host.docker.internal" and on Podman this is "host.containers.internal".
*/
static async hostnameOfHost(): Promise<"host.containers.internal" | "host.docker.internal"> {
return (await Docker.isPodman()) ? "host.containers.internal" : "host.docker.internal";
} }
} }

View File

@ -46,7 +46,6 @@ export class Dendrite extends Synapse implements Homeserver, HomeserverInstance
const dendriteId = await this.docker.run({ const dendriteId = await this.docker.run({
image: this.image, image: this.image,
params: [ params: [
"--rm",
"-v", "-v",
`${denCfg.configDir}:` + dockerConfigDir, `${denCfg.configDir}:` + dockerConfigDir,
"-p", "-p",
@ -140,7 +139,7 @@ async function cfgDirFromTemplate(
const docker = new Docker(); const docker = new Docker();
await docker.run({ await docker.run({
image: dendriteImage, image: dendriteImage,
params: ["--rm", "--entrypoint=", "-v", `${tempDir}:/mnt`], params: ["--entrypoint=", "-v", `${tempDir}:/mnt`],
containerName: `react-sdk-playwright-dendrite-keygen`, containerName: `react-sdk-playwright-dendrite-keygen`,
cmd: ["/usr/bin/generate-keys", "-private-key", "/mnt/matrix_key.pem"], cmd: ["/usr/bin/generate-keys", "-private-key", "/mnt/matrix_key.pem"],
}); });

View File

@ -57,20 +57,9 @@ async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<Homes
if (opts.oAuthServerPort) { if (opts.oAuthServerPort) {
hsYaml = hsYaml.replace(/{{OAUTH_SERVER_PORT}}/g, opts.oAuthServerPort.toString()); hsYaml = hsYaml.replace(/{{OAUTH_SERVER_PORT}}/g, opts.oAuthServerPort.toString());
} }
hsYaml = hsYaml.replace(/{{HOST_DOCKER_INTERNAL}}/g, await Docker.hostnameOfHost());
if (opts.variables) { if (opts.variables) {
let fetchedHostContainer: Awaited<ReturnType<typeof Docker.hostnameOfHost>> | null = null;
for (const key in opts.variables) { for (const key in opts.variables) {
let value = String(opts.variables[key]); hsYaml = hsYaml.replace(new RegExp("%" + key + "%", "g"), String(opts.variables[key]));
if (value === "{{HOST_DOCKER_INTERNAL}}") {
if (!fetchedHostContainer) {
fetchedHostContainer = await Docker.hostnameOfHost();
}
value = fetchedHostContainer;
}
hsYaml = hsYaml.replace(new RegExp("%" + key + "%", "g"), value);
} }
} }
@ -106,26 +95,13 @@ export class Synapse implements Homeserver, HomeserverInstance {
* Start a synapse instance: the template must be the name of * Start a synapse instance: the template must be the name of
* one of the templates in the playwright/plugins/synapsedocker/templates * one of the templates in the playwright/plugins/synapsedocker/templates
* directory. * directory.
*
* Any value in `opts.variables` that is set to `{{HOST_DOCKER_INTERNAL}}'
* will be replaced with 'host.docker.internal' (if we are on Docker) or
* 'host.containers.internal' if we are on Podman.
*/ */
public async start(opts: StartHomeserverOpts): Promise<HomeserverInstance> { public async start(opts: StartHomeserverOpts): Promise<HomeserverInstance> {
if (this.config) await this.stop(); if (this.config) await this.stop();
const synCfg = await cfgDirFromTemplate(opts); const synCfg = await cfgDirFromTemplate(opts);
console.log(`Starting synapse with config dir ${synCfg.configDir}...`); console.log(`Starting synapse with config dir ${synCfg.configDir}...`);
const dockerSynapseParams = ["--rm", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`]; const dockerSynapseParams = ["-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`];
if (await Docker.isPodman()) {
// Make host.containers.internal work to allow Synapse to talk to the test OIDC server.
dockerSynapseParams.push("--network");
dockerSynapseParams.push("slirp4netns:allow_host_loopback=true");
} else {
// Make host.docker.internal work to allow Synapse to talk to the test OIDC server.
dockerSynapseParams.push("--add-host");
dockerSynapseParams.push("host.docker.internal:host-gateway");
}
const synapseId = await this.docker.run({ const synapseId = await this.docker.run({
image: "matrixdotorg/synapse:develop", image: "matrixdotorg/synapse:develop",
containerName: `react-sdk-playwright-synapse`, containerName: `react-sdk-playwright-synapse`,

View File

@ -81,10 +81,8 @@ oidc_providers:
issuer: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth" issuer: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth"
authorization_endpoint: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth/auth.html" authorization_endpoint: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth/auth.html"
# the token endpoint receives requests from synapse, rather than the webapp, so needs to escape the docker container. # the token endpoint receives requests from synapse, rather than the webapp, so needs to escape the docker container.
# Hence, HOST_DOCKER_INTERNAL rather than localhost. This is set to token_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/token"
# host.docker.internal on Docker and host.containers.internal on Podman. userinfo_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/userinfo"
token_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/token"
userinfo_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/userinfo"
client_id: "synapse" client_id: "synapse"
discover: false discover: false
scopes: ["profile"] scopes: ["profile"]

View File

@ -38,7 +38,7 @@ export class MailHogServer {
const containerId = await this.docker.run({ const containerId = await this.docker.run({
image: "mailhog/mailhog:latest", image: "mailhog/mailhog:latest",
containerName: `react-sdk-playwright-mailhog`, containerName: `react-sdk-playwright-mailhog`,
params: ["--rm", "-p", `${smtpPort}:1025/tcp`, "-p", `${httpPort}:8025/tcp`], params: ["-p", `${smtpPort}:1025/tcp`, "-p", `${httpPort}:8025/tcp`],
}); });
console.log(`Started mailhog on ports smtp=${smtpPort} http=${httpPort}.`); console.log(`Started mailhog on ports smtp=${smtpPort} http=${httpPort}.`);
const host = await this.docker.getContainerIp(); const host = await this.docker.getContainerIp();

View File

@ -0,0 +1,72 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Docker } from "../docker";
export const PG_PASSWORD = "p4S5w0rD";
/**
* Class to manage a postgres database in docker
*/
export class PostgresDocker extends Docker {
/**
* @param key an opaque string to use when naming the docker containers instantiated by this class
*/
public constructor(private key: string) {
super();
}
private async waitForPostgresReady(): Promise<void> {
const waitTimeMillis = 30000;
const startTime = new Date().getTime();
let lastErr: Error | null = null;
while (new Date().getTime() - startTime < waitTimeMillis) {
try {
await this.exec(["pg_isready", "-U", "postgres"], true);
lastErr = null;
break;
} catch (err) {
console.log("pg_isready: failed");
lastErr = err;
}
}
if (lastErr) {
console.log("rethrowing");
throw lastErr;
}
}
public async start(): Promise<{
ipAddress: string;
containerId: string;
}> {
console.log(new Date(), "starting postgres container");
const containerId = await this.run({
image: "postgres",
containerName: `react-sdk-playwright-postgres-${this.key}`,
params: ["--tmpfs=/pgtmpfs", "-e", "PGDATA=/pgtmpfs", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`],
// Optimise for testing - https://www.postgresql.org/docs/current/non-durability.html
cmd: ["-c", `fsync=off`, "-c", `synchronous_commit=off`, "-c", `full_page_writes=off`],
});
const ipAddress = await this.getContainerIp();
console.log(new Date(), "postgres container up");
await this.waitForPostgresReady();
console.log(new Date(), "postgres container ready");
return { ipAddress, containerId };
}
}

View File

@ -16,10 +16,10 @@ limitations under the License.
import { getFreePort } from "../utils/port"; import { getFreePort } from "../utils/port";
import { Docker } from "../docker"; import { Docker } from "../docker";
import { PG_PASSWORD, PostgresDocker } from "../postgres";
// Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image. // Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image.
const SLIDING_SYNC_PROXY_TAG = "v0.99.3"; const SLIDING_SYNC_PROXY_TAG = "v0.99.3";
const PG_PASSWORD = "p4S5w0rD";
export interface ProxyInstance { export interface ProxyInstance {
containerId: string; containerId: string;
@ -28,45 +28,16 @@ export interface ProxyInstance {
} }
export class SlidingSyncProxy { export class SlidingSyncProxy {
private readonly postgresDocker = new Docker();
private readonly proxyDocker = new Docker(); private readonly proxyDocker = new Docker();
private readonly postgresDocker = new PostgresDocker("sliding-sync");
private instance: ProxyInstance; private instance: ProxyInstance;
constructor(private synapseIp: string) {} constructor(private synapseIp: string) {}
private async waitForPostgresReady(): Promise<void> {
const waitTimeMillis = 30000;
const startTime = new Date().getTime();
let lastErr: Error | null = null;
while (new Date().getTime() - startTime < waitTimeMillis) {
try {
await this.postgresDocker.exec(["pg_isready", "-U", "postgres"]);
lastErr = null;
break;
} catch (err) {
console.log("pg_isready: failed");
lastErr = err;
}
}
if (lastErr) {
console.log("rethrowing");
throw lastErr;
}
}
async start(): Promise<ProxyInstance> { async start(): Promise<ProxyInstance> {
console.log(new Date(), "Starting sliding sync proxy..."); console.log(new Date(), "Starting sliding sync proxy...");
const postgresId = await this.postgresDocker.run({ const { ipAddress: postgresIp, containerId: postgresId } = await this.postgresDocker.start();
image: "postgres",
containerName: "react-sdk-playwright-sliding-sync-postgres",
params: ["--rm", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`],
});
const postgresIp = await this.postgresDocker.getContainerIp();
console.log(new Date(), "postgres container up");
await this.waitForPostgresReady();
const port = await getFreePort(); const port = await getFreePort();
console.log(new Date(), "starting proxy container...", SLIDING_SYNC_PROXY_TAG); console.log(new Date(), "starting proxy container...", SLIDING_SYNC_PROXY_TAG);
@ -74,7 +45,6 @@ export class SlidingSyncProxy {
image: "ghcr.io/matrix-org/sliding-sync:" + SLIDING_SYNC_PROXY_TAG, image: "ghcr.io/matrix-org/sliding-sync:" + SLIDING_SYNC_PROXY_TAG,
containerName: "react-sdk-playwright-sliding-sync-proxy", containerName: "react-sdk-playwright-sliding-sync-proxy",
params: [ params: [
"--rm",
"-p", "-p",
`${port}:8008/tcp`, `${port}:8008/tcp`,
"-e", "-e",