Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit a9add45

Browse files
authored
Improvements around docker in Playwright (#12261)
* Extract Postgres Docker to its own class Signed-off-by: Michael Telatynski <[email protected]> * Don't specify docker `--rm` in CI as it makes debugging harder Signed-off-by: Michael Telatynski <[email protected]> * Improve docker commands and introspection Signed-off-by: Michael Telatynski <[email protected]> * Remove `HOST_DOCKER_INTERNAL` magic in favour of `host.containers.internal` Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Always pipe Signed-off-by: Michael Telatynski <[email protected]> * Re-add pipe flag to silence pg_isready and podman checks Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> * Iterate Signed-off-by: Michael Telatynski <[email protected]> --------- Signed-off-by: Michael Telatynski <[email protected]>
1 parent dd5b741 commit a9add45

File tree

8 files changed

+152
-135
lines changed

8 files changed

+152
-135
lines changed

playwright/e2e/register/email.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ test.describe("Email Registration", async () => {
2525
use({
2626
template: "email",
2727
variables: {
28-
SMTP_HOST: "{{HOST_DOCKER_INTERNAL}}", // This will get replaced in synapseStart
28+
SMTP_HOST: "host.containers.internal",
2929
SMTP_PORT: mailhog.instance.smtpPort,
3030
},
3131
}),

playwright/plugins/docker/index.ts

Lines changed: 70 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,48 @@ import * as crypto from "crypto";
1919
import * as childProcess from "child_process";
2020
import * as fse from "fs-extra";
2121

22+
/**
23+
* @param cmd - command to execute
24+
* @param args - arguments to pass to executed command
25+
* @param suppressOutput - whether to suppress the stdout and stderr resulting from this command.
26+
* @return Promise which resolves to an object containing the string value of what was
27+
* written to stdout and stderr by the executed command.
28+
*/
29+
const exec = (cmd: string, args: string[], suppressOutput = false): Promise<{ stdout: string; stderr: string }> => {
30+
return new Promise((resolve, reject) => {
31+
if (!suppressOutput) {
32+
const log = ["Running command:", cmd, ...args, "\n"].join(" ");
33+
// When in CI mode we combine reports from multiple runners into a single HTML report
34+
// which has separate files for stdout and stderr, so we print the executed command to both
35+
process.stdout.write(log);
36+
if (process.env.CI) process.stderr.write(log);
37+
}
38+
const { stdout, stderr } = childProcess.execFile(cmd, args, { encoding: "utf8" }, (err, stdout, stderr) => {
39+
if (err) reject(err);
40+
resolve({ stdout, stderr });
41+
if (!suppressOutput) {
42+
process.stdout.write("\n");
43+
if (process.env.CI) process.stderr.write("\n");
44+
}
45+
});
46+
if (!suppressOutput) {
47+
stdout.pipe(process.stdout);
48+
stderr.pipe(process.stderr);
49+
}
50+
});
51+
};
52+
2253
export class Docker {
2354
public id: string;
2455

2556
async run(opts: { image: string; containerName: string; params?: string[]; cmd?: string[] }): Promise<string> {
2657
const userInfo = os.userInfo();
2758
const params = opts.params ?? [];
2859

29-
if (params?.includes("-v") && userInfo.uid >= 0) {
60+
const isPodman = await Docker.isPodman();
61+
if (params.includes("-v") && userInfo.uid >= 0) {
3062
// Run the docker container as our uid:gid to prevent problems with permissions.
31-
if (await Docker.isPodman()) {
63+
if (isPodman) {
3264
// Note: this setup is for podman rootless containers.
3365

3466
// In podman, run as root in the container, which maps to the current
@@ -45,75 +77,57 @@ export class Docker {
4577
}
4678
}
4779

80+
// Make host.containers.internal work to allow the container to talk to other services via host ports.
81+
if (isPodman) {
82+
params.push("--network");
83+
params.push("slirp4netns:allow_host_loopback=true");
84+
} else {
85+
// Docker for Desktop includes a host-gateway mapping on host.docker.internal but to simplify the config
86+
// we use the Podman variant host.containers.internal in all environments.
87+
params.push("--add-host");
88+
params.push("host.containers.internal:host-gateway");
89+
}
90+
91+
// Provided we are not running in CI, add a `--rm` parameter.
92+
// There is no need to remove containers in CI (since they are automatically removed anyway), and
93+
// `--rm` means that if a container crashes this means its logs are wiped out.
94+
if (!process.env.CI) params.unshift("--rm");
95+
4896
const args = [
4997
"run",
5098
"--name",
5199
`${opts.containerName}-${crypto.randomBytes(4).toString("hex")}`,
52100
"-d",
53-
"--rm",
54101
...params,
55102
opts.image,
56103
];
57104

58105
if (opts.cmd) args.push(...opts.cmd);
59106

60-
this.id = await new Promise<string>((resolve, reject) => {
61-
childProcess.execFile("docker", args, (err, stdout) => {
62-
if (err) reject(err);
63-
resolve(stdout.trim());
64-
});
65-
});
107+
const { stdout } = await exec("docker", args);
108+
this.id = stdout.trim();
66109
return this.id;
67110
}
68111

69-
stop(): Promise<void> {
70-
return new Promise<void>((resolve, reject) => {
71-
childProcess.execFile("docker", ["stop", this.id], (err) => {
72-
if (err) reject(err);
73-
resolve();
74-
});
75-
});
76-
}
77-
78-
exec(params: string[]): Promise<void> {
79-
return new Promise<void>((resolve, reject) => {
80-
childProcess.execFile(
81-
"docker",
82-
["exec", this.id, ...params],
83-
{ encoding: "utf8" },
84-
(err, stdout, stderr) => {
85-
if (err) {
86-
console.log(stdout);
87-
console.log(stderr);
88-
reject(err);
89-
return;
90-
}
91-
resolve();
92-
},
93-
);
94-
});
112+
async stop(): Promise<void> {
113+
try {
114+
await exec("docker", ["stop", this.id]);
115+
} catch (err) {
116+
console.error(`Failed to stop docker container`, this.id, err);
117+
}
95118
}
96119

97-
rm(): Promise<void> {
98-
return new Promise<void>((resolve, reject) => {
99-
childProcess.execFile("docker", ["rm", this.id], (err) => {
100-
if (err) reject(err);
101-
resolve();
102-
});
103-
});
120+
/**
121+
* @param params - list of parameters to pass to `docker exec`
122+
* @param suppressOutput - whether to suppress the stdout and stderr resulting from this command.
123+
*/
124+
async exec(params: string[], suppressOutput = true): Promise<void> {
125+
await exec("docker", ["exec", this.id, ...params], suppressOutput);
104126
}
105127

106-
getContainerIp(): Promise<string> {
107-
return new Promise<string>((resolve, reject) => {
108-
childProcess.execFile(
109-
"docker",
110-
["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", this.id],
111-
(err, stdout) => {
112-
if (err) reject(err);
113-
else resolve(stdout.trim());
114-
},
115-
);
116-
});
128+
async getContainerIp(): Promise<string> {
129+
const { stdout } = await exec("docker", ["inspect", "-f", "{{ .NetworkSettings.IPAddress }}", this.id]);
130+
return stdout.trim();
117131
}
118132

119133
async persistLogsToFile(args: { stdoutFile?: string; stderrFile?: string }): Promise<void> {
@@ -134,20 +148,8 @@ export class Docker {
134148
* Detects whether the docker command is actually podman.
135149
* To do this, it looks for "podman" in the output of "docker --help".
136150
*/
137-
static isPodman(): Promise<boolean> {
138-
return new Promise<boolean>((resolve, reject) => {
139-
childProcess.execFile("docker", ["--help"], (err, stdout) => {
140-
if (err) reject(err);
141-
else resolve(stdout.toLowerCase().includes("podman"));
142-
});
143-
});
144-
}
145-
146-
/**
147-
* Supply the right hostname to use to talk to the host machine. On Docker this
148-
* is "host.docker.internal" and on Podman this is "host.containers.internal".
149-
*/
150-
static async hostnameOfHost(): Promise<"host.containers.internal" | "host.docker.internal"> {
151-
return (await Docker.isPodman()) ? "host.containers.internal" : "host.docker.internal";
151+
static async isPodman(): Promise<boolean> {
152+
const { stdout } = await exec("docker", ["--help"], true);
153+
return stdout.toLowerCase().includes("podman");
152154
}
153155
}

playwright/plugins/homeserver/dendrite/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ export class Dendrite extends Synapse implements Homeserver, HomeserverInstance
4646
const dendriteId = await this.docker.run({
4747
image: this.image,
4848
params: [
49-
"--rm",
5049
"-v",
5150
`${denCfg.configDir}:` + dockerConfigDir,
5251
"-p",
@@ -140,7 +139,7 @@ async function cfgDirFromTemplate(
140139
const docker = new Docker();
141140
await docker.run({
142141
image: dendriteImage,
143-
params: ["--rm", "--entrypoint=", "-v", `${tempDir}:/mnt`],
142+
params: ["--entrypoint=", "-v", `${tempDir}:/mnt`],
144143
containerName: `react-sdk-playwright-dendrite-keygen`,
145144
cmd: ["/usr/bin/generate-keys", "-private-key", "/mnt/matrix_key.pem"],
146145
});

playwright/plugins/homeserver/synapse/index.ts

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,9 @@ async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<Homes
5757
if (opts.oAuthServerPort) {
5858
hsYaml = hsYaml.replace(/{{OAUTH_SERVER_PORT}}/g, opts.oAuthServerPort.toString());
5959
}
60-
hsYaml = hsYaml.replace(/{{HOST_DOCKER_INTERNAL}}/g, await Docker.hostnameOfHost());
6160
if (opts.variables) {
62-
let fetchedHostContainer: Awaited<ReturnType<typeof Docker.hostnameOfHost>> | null = null;
6361
for (const key in opts.variables) {
64-
let value = String(opts.variables[key]);
65-
66-
if (value === "{{HOST_DOCKER_INTERNAL}}") {
67-
if (!fetchedHostContainer) {
68-
fetchedHostContainer = await Docker.hostnameOfHost();
69-
}
70-
value = fetchedHostContainer;
71-
}
72-
73-
hsYaml = hsYaml.replace(new RegExp("%" + key + "%", "g"), value);
62+
hsYaml = hsYaml.replace(new RegExp("%" + key + "%", "g"), String(opts.variables[key]));
7463
}
7564
}
7665

@@ -106,26 +95,13 @@ export class Synapse implements Homeserver, HomeserverInstance {
10695
* Start a synapse instance: the template must be the name of
10796
* one of the templates in the playwright/plugins/synapsedocker/templates
10897
* directory.
109-
*
110-
* Any value in `opts.variables` that is set to `{{HOST_DOCKER_INTERNAL}}'
111-
* will be replaced with 'host.docker.internal' (if we are on Docker) or
112-
* 'host.containers.internal' if we are on Podman.
11398
*/
11499
public async start(opts: StartHomeserverOpts): Promise<HomeserverInstance> {
115100
if (this.config) await this.stop();
116101

117102
const synCfg = await cfgDirFromTemplate(opts);
118103
console.log(`Starting synapse with config dir ${synCfg.configDir}...`);
119-
const dockerSynapseParams = ["--rm", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`];
120-
if (await Docker.isPodman()) {
121-
// Make host.containers.internal work to allow Synapse to talk to the test OIDC server.
122-
dockerSynapseParams.push("--network");
123-
dockerSynapseParams.push("slirp4netns:allow_host_loopback=true");
124-
} else {
125-
// Make host.docker.internal work to allow Synapse to talk to the test OIDC server.
126-
dockerSynapseParams.push("--add-host");
127-
dockerSynapseParams.push("host.docker.internal:host-gateway");
128-
}
104+
const dockerSynapseParams = ["-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`];
129105
const synapseId = await this.docker.run({
130106
image: "matrixdotorg/synapse:develop",
131107
containerName: `react-sdk-playwright-synapse`,

playwright/plugins/homeserver/synapse/templates/default/homeserver.yaml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,10 +81,8 @@ oidc_providers:
8181
issuer: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth"
8282
authorization_endpoint: "http://localhost:{{OAUTH_SERVER_PORT}}/oauth/auth.html"
8383
# the token endpoint receives requests from synapse, rather than the webapp, so needs to escape the docker container.
84-
# Hence, HOST_DOCKER_INTERNAL rather than localhost. This is set to
85-
# host.docker.internal on Docker and host.containers.internal on Podman.
86-
token_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/token"
87-
userinfo_endpoint: "http://{{HOST_DOCKER_INTERNAL}}:{{OAUTH_SERVER_PORT}}/oauth/userinfo"
84+
token_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/token"
85+
userinfo_endpoint: "http://host.containers.internal:{{OAUTH_SERVER_PORT}}/oauth/userinfo"
8886
client_id: "synapse"
8987
discover: false
9088
scopes: ["profile"]

playwright/plugins/mailhog/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ export class MailHogServer {
3838
const containerId = await this.docker.run({
3939
image: "mailhog/mailhog:latest",
4040
containerName: `react-sdk-playwright-mailhog`,
41-
params: ["--rm", "-p", `${smtpPort}:1025/tcp`, "-p", `${httpPort}:8025/tcp`],
41+
params: ["-p", `${smtpPort}:1025/tcp`, "-p", `${httpPort}:8025/tcp`],
4242
});
4343
console.log(`Started mailhog on ports smtp=${smtpPort} http=${httpPort}.`);
4444
const host = await this.docker.getContainerIp();
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
Copyright 2023 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { Docker } from "../docker";
18+
19+
export const PG_PASSWORD = "p4S5w0rD";
20+
21+
/**
22+
* Class to manage a postgres database in docker
23+
*/
24+
export class PostgresDocker extends Docker {
25+
/**
26+
* @param key an opaque string to use when naming the docker containers instantiated by this class
27+
*/
28+
public constructor(private key: string) {
29+
super();
30+
}
31+
32+
private async waitForPostgresReady(): Promise<void> {
33+
const waitTimeMillis = 30000;
34+
const startTime = new Date().getTime();
35+
let lastErr: Error | null = null;
36+
while (new Date().getTime() - startTime < waitTimeMillis) {
37+
try {
38+
await this.exec(["pg_isready", "-U", "postgres"], true);
39+
lastErr = null;
40+
break;
41+
} catch (err) {
42+
console.log("pg_isready: failed");
43+
lastErr = err;
44+
}
45+
}
46+
if (lastErr) {
47+
console.log("rethrowing");
48+
throw lastErr;
49+
}
50+
}
51+
52+
public async start(): Promise<{
53+
ipAddress: string;
54+
containerId: string;
55+
}> {
56+
console.log(new Date(), "starting postgres container");
57+
const containerId = await this.run({
58+
image: "postgres",
59+
containerName: `react-sdk-playwright-postgres-${this.key}`,
60+
params: ["--tmpfs=/pgtmpfs", "-e", "PGDATA=/pgtmpfs", "-e", `POSTGRES_PASSWORD=${PG_PASSWORD}`],
61+
// Optimise for testing - https://www.postgresql.org/docs/current/non-durability.html
62+
cmd: ["-c", `fsync=off`, "-c", `synchronous_commit=off`, "-c", `full_page_writes=off`],
63+
});
64+
65+
const ipAddress = await this.getContainerIp();
66+
console.log(new Date(), "postgres container up");
67+
68+
await this.waitForPostgresReady();
69+
console.log(new Date(), "postgres container ready");
70+
return { ipAddress, containerId };
71+
}
72+
}

0 commit comments

Comments
 (0)