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>
This commit is contained in:
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({
template: "email",
variables: {
SMTP_HOST: "{{HOST_DOCKER_INTERNAL}}", // This will get replaced in synapseStart
SMTP_HOST: "host.containers.internal",
SMTP_PORT: mailhog.instance.smtpPort,
},
}),

View file

@ -19,6 +19,37 @@ 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;
@ -26,9 +57,10 @@ export class Docker {
const userInfo = os.userInfo();
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.
if (await Docker.isPodman()) {
if (isPodman) {
// Note: this setup is for podman rootless containers.
// 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 = [
"run",
"--name",
`${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`,
"-d",
"--rm",
...params,
opts.image,
];
if (opts.cmd) args.push(...opts.cmd);
this.id = await new Promise<string>((resolve, reject) => {
childProcess.execFile("docker", args, (err, stdout) => {
if (err) reject(err);
resolve(stdout.trim());
});
});
const { stdout } = await exec("docker", args);
this.id = stdout.trim();
return this.id;
}
stop(): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile("docker", ["stop", this.id], (err) => {
if (err) reject(err);
resolve();
});
});
async stop(): Promise<void> {
try {
await exec("docker", ["stop", this.id]);
} catch (err) {
console.error(`Failed to stop docker container`, this.id, err);
}
}
exec(params: string[]): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile(
"docker",
["exec", this.id, ...params],
{ encoding: "utf8" },
(err, stdout, stderr) => {
if (err) {
console.log(stdout);
console.log(stderr);
reject(err);
return;
}
resolve();
},
);
});
/**
* @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<void> {
await exec("docker", ["exec", this.id, ...params], suppressOutput);
}
rm(): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile("docker", ["rm", this.id], (err) => {
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 getContainerIp(): Promise<string> {
const { stdout } = await exec("docker", ["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", this.id]);
return stdout.trim();
}
async persistLogsToFile(args: { stdoutFile?: string; stderrFile?: string }): Promise<void> {
@ -134,20 +148,8 @@ export class Docker {
* Detects whether the docker command is actually podman.
* To do this, it looks for "podman" in the output of "docker --help".
*/
static isPodman(): Promise<boolean> {
return new Promise<boolean>((resolve, reject) => {
childProcess.execFile("docker", ["--help"], (err, stdout) => {
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";
static async isPodman(): Promise<boolean> {
const { stdout } = await exec("docker", ["--help"], true);
return stdout.toLowerCase().includes("podman");
}
}

View file

@ -46,7 +46,6 @@ export class Dendrite extends Synapse implements Homeserver, HomeserverInstance
const dendriteId = await this.docker.run({
image: this.image,
params: [
"--rm",
"-v",
`${denCfg.configDir}:` + dockerConfigDir,
"-p",
@ -140,7 +139,7 @@ async function cfgDirFromTemplate(
const docker = new Docker();
await docker.run({
image: dendriteImage,
params: ["--rm", "--entrypoint=", "-v", `${tempDir}:/mnt`],
params: ["--entrypoint=", "-v", `${tempDir}:/mnt`],
containerName: `react-sdk-playwright-dendrite-keygen`,
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) {
hsYaml = hsYaml.replace(/{{OAUTH_SERVER_PORT}}/g, opts.oAuthServerPort.toString());
}
hsYaml = hsYaml.replace(/{{HOST_DOCKER_INTERNAL}}/g, await Docker.hostnameOfHost());
if (opts.variables) {
let fetchedHostContainer: Awaited<ReturnType<typeof Docker.hostnameOfHost>> | null = null;
for (const key in opts.variables) {
let value = 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);
hsYaml = hsYaml.replace(new RegExp("%" + key + "%", "g"), String(opts.variables[key]));
}
}
@ -106,26 +95,13 @@ export class Synapse implements Homeserver, HomeserverInstance {
* Start a synapse instance: the template must be the name of
* one of the templates in the playwright/plugins/synapsedocker/templates
* 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> {
if (this.config) await this.stop();
const synCfg = await cfgDirFromTemplate(opts);
console.log(`Starting synapse with config dir ${synCfg.configDir}...`);
const dockerSynapseParams = ["--rm", "-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 dockerSynapseParams = ["-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`];
const synapseId = await this.docker.run({
image: "matrixdotorg/synapse:develop",
containerName: `react-sdk-playwright-synapse`,

View file

@ -81,10 +81,8 @@ oidc_providers:
issuer: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth"
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.
# Hence, HOST_DOCKER_INTERNAL rather than localhost. This is set to
# host.docker.internal on Docker and host.containers.internal on Podman.
token_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/token"
userinfo_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/userinfo"
token_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/token"
userinfo_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/userinfo"
client_id: "synapse"
discover: false
scopes: ["profile"]

View file

@ -38,7 +38,7 @@ export class MailHogServer {
const containerId = await this.docker.run({
image: "mailhog/mailhog:latest",
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}.`);
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 { Docker } from "../docker";
import { PG_PASSWORD, PostgresDocker } from "../postgres";
// Docker tag to use for `ghcr.io/matrix-org/sliding-sync` image.
const SLIDING_SYNC_PROXY_TAG = "v0.99.3";
const PG_PASSWORD = "p4S5w0rD";
export interface ProxyInstance {
containerId: string;
@ -28,45 +28,16 @@ export interface ProxyInstance {
}
export class SlidingSyncProxy {
private readonly postgresDocker = new Docker();
private readonly proxyDocker = new Docker();
private readonly postgresDocker = new PostgresDocker("sliding-sync");
private instance: ProxyInstance;
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> {
console.log(new Date(), "Starting sliding sync proxy...");
const postgresId = await this.postgresDocker.run({
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 { ipAddress: postgresIp, containerId: postgresId } = await this.postgresDocker.start();
const port = await getFreePort();
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,
containerName: "react-sdk-playwright-sliding-sync-proxy",
params: [
"--rm",
"-p",
`${port}:8008/tcp`,
"-e",