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:
parent
dd5b7417be
commit
a9add4504f
8 changed files with 152 additions and 135 deletions
|
@ -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,
|
||||
},
|
||||
}),
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"],
|
||||
});
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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();
|
||||
|
|
72
playwright/plugins/postgres/index.ts
Normal file
72
playwright/plugins/postgres/index.ts
Normal 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 };
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue