diff --git a/playwright/e2e/register/email.spec.ts b/playwright/e2e/register/email.spec.ts index 5b093f5036b..9184a4bea59 100644 --- a/playwright/e2e/register/email.spec.ts +++ b/playwright/e2e/register/email.spec.ts @@ -33,7 +33,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, }, }), diff --git a/playwright/plugins/docker/index.ts b/playwright/plugins/docker/index.ts index 7b6793eaed4..2b193c2fbd6 100644 --- a/playwright/plugins/docker/index.ts +++ b/playwright/plugins/docker/index.ts @@ -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((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 { - return new Promise((resolve, reject) => { - childProcess.execFile("docker", ["stop", this.id], (err) => { - if (err) reject(err); - resolve(); - }); - }); - } - - exec(params: string[]): Promise { - return new Promise((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(); - }, - ); - }); + async stop(): Promise { + try { + await exec("docker", ["stop", this.id]); + } catch (err) { + console.error(`Failed to stop docker container`, this.id, err); + } } - rm(): Promise { - return new Promise((resolve, reject) => { - childProcess.execFile("docker", ["rm", this.id], (err) => { - if (err) reject(err); - 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 { + await exec("docker", ["exec", this.id, ...params], suppressOutput); } - getContainerIp(): Promise { - return new Promise((resolve, reject) => { - childProcess.execFile( - "docker", - ["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", this.id], - (err, stdout) => { - if (err) reject(err); - else resolve(stdout.trim()); - }, - ); - }); + 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 { @@ -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 { - return new Promise((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 { + const { stdout } = await exec("docker", ["--help"], true); + return stdout.toLowerCase().includes("podman"); } } diff --git a/playwright/plugins/homeserver/dendrite/index.ts b/playwright/plugins/homeserver/dendrite/index.ts index 2ca54cc0d8f..603bd360a8c 100644 --- a/playwright/plugins/homeserver/dendrite/index.ts +++ b/playwright/plugins/homeserver/dendrite/index.ts @@ -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"], }); diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index 95165c14428..1a6d753ea4b 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -57,20 +57,9 @@ async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> | 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 { 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`, diff --git a/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml b/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml index 76cedb78f86..c5bea307b44 100644 --- a/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml +++ b/playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml @@ -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"] diff --git a/playwright/plugins/mailhog/index.ts b/playwright/plugins/mailhog/index.ts index abcc4026b82..684aaee5ed8 100644 --- a/playwright/plugins/mailhog/index.ts +++ b/playwright/plugins/mailhog/index.ts @@ -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(); diff --git a/playwright/plugins/postgres/index.ts b/playwright/plugins/postgres/index.ts new file mode 100644 index 00000000000..2b67afefa39 --- /dev/null +++ b/playwright/plugins/postgres/index.ts @@ -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 { + 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 }; + } +} diff --git a/playwright/plugins/sliding-sync-proxy/index.ts b/playwright/plugins/sliding-sync-proxy/index.ts index b8cc365ffb6..f7e07a8cb15 100644 --- a/playwright/plugins/sliding-sync-proxy/index.ts +++ b/playwright/plugins/sliding-sync-proxy/index.ts @@ -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 { - 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 { 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",