playwright/packages/playwright-core/src/containers/dockerApi.ts
Andrey Lushnikov 8538f61a72
feat(containers): implement global network tethering for playwright server (#17719)
This patch implements a new mode of network tethering for Playwright
server & its clients.
With this patch:
- playwright server could be launched with the
`--browser-proxy-mode=tether` flag to engage in the new mode
- a new type of client, "Network Tethering Client" can connect to the
server to provide network traffic to the browsers
- all clients that connect to the server with the `x-playwright-proxy:
*` header will get traffic from the "Network Tethering Client"

This patch also adds an environment variable
`PW_OWNED_BY_TETHER_CLIENT`. With this env, playwright server will
auto-close when the network tethering client disconnects. It will also
auto-close if the network client does not connect to the server in the
first 10 seconds of the server existence. This way we can ensure that
`npx playwright docker start` blocks terminal & controls the lifetime of
the started container.
2022-11-03 13:47:51 -07:00

229 lines
7.2 KiB
TypeScript

/**
* Copyright (c) Microsoft Corporation.
*
* 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 http from 'http';
// Docker engine API.
// See https://docs.docker.com/engine/api/v1.41/
const DOCKER_API_VERSION = '1.41';
export interface DockerImage {
imageId: string;
names: string[];
}
export interface PortBinding {
ip: string;
hostPort: number;
containerPort: number;
}
export interface DockerContainer {
containerId: string;
labels: Record<string, string>;
imageId: string;
state: 'created'|'restarting'|'running'|'removing'|'paused'|'exited'|'dead';
names: string[];
portBindings: PortBinding[];
}
export async function listContainers(): Promise<DockerContainer[]> {
const containers = (await getJSON('/containers/json')) ?? [];
return containers.map((container: any) => ({
containerId: container.Id,
imageId: container.ImageID,
state: container.State,
// Note: container names are usually prefixed with '/'.
// See https://github.com/moby/moby/issues/6705
names: (container.Names ?? []).map((name: string) => name.startsWith('/') ? name.substring(1) : name),
portBindings: container.Ports?.map((portInfo: any) => ({
ip: portInfo.IP,
hostPort: portInfo.PublicPort,
containerPort: portInfo.PrivatePort,
})) ?? [],
labels: container.Labels ?? {},
}));
}
interface LaunchContainerOptions {
imageId: string;
autoRemove: boolean;
command?: string[];
labels?: Record<string, string>;
ports?: { container: number, host: number }[],
name?: string;
workingDir?: string;
waitUntil?: 'not-running' | 'next-exit' | 'removed';
env?: { [key: string]: string | number | boolean | undefined };
}
export async function launchContainer(options: LaunchContainerOptions): Promise<string> {
const ExposedPorts: any = {};
const PortBindings: any = {};
for (const port of (options.ports ?? [])) {
ExposedPorts[`${port.container}/tcp`] = {};
PortBindings[`${port.container}/tcp`] = [{ HostPort: port.host + '', HostIp: '127.0.0.1' }];
}
const container = await postJSON(`/containers/create` + (options.name ? '?name=' + options.name : ''), {
Cmd: options.command,
WorkingDir: options.workingDir,
Labels: options.labels ?? {},
AttachStdout: true,
AttachStderr: true,
Image: options.imageId,
ExposedPorts,
Env: dockerProtocolEnv(options.env),
HostConfig: {
Init: true,
AutoRemove: options.autoRemove,
ShmSize: 2 * 1024 * 1024 * 1024,
PortBindings,
},
});
await postJSON(`/containers/${container.Id}/start`);
if (options.waitUntil)
await postJSON(`/containers/${container.Id}/wait?condition=${options.waitUntil}`);
return container.Id;
}
interface StopContainerOptions {
containerId: string,
waitUntil?: 'not-running' | 'next-exit' | 'removed';
}
export async function stopContainer(options: StopContainerOptions) {
await Promise.all([
// Make sure to wait for the container to be removed.
postJSON(`/containers/${options.containerId}/wait?condition=${options.waitUntil ?? 'not-running'}`),
postJSON(`/containers/${options.containerId}/kill`),
]);
}
export async function removeContainer(containerId: string) {
await Promise.all([
// Make sure to wait for the container to be removed.
postJSON(`/containers/${containerId}/wait?condition=removed`),
callDockerAPI('delete', `/containers/${containerId}`),
]);
}
export async function getContainerLogs(containerId: string): Promise<string[]> {
const rawLogs = await callDockerAPI('get', `/containers/${containerId}/logs?stdout=true&stderr=true`).catch(e => '');
if (!rawLogs)
return [];
// Docker might prefix every log line with 8 characters. Stip them out.
// See https://github.com/moby/moby/issues/7375
// This doesn't happen if the containers is launched manually with attached terminal.
return rawLogs.split('\n').map(line => {
if ([0, 1, 2].includes(line.charCodeAt(0)))
return line.substring(8);
return line;
});
}
interface CommitContainerOptions {
containerId: string,
repo: string,
tag: string,
entrypoint?: string[],
workingDir?: string,
env?: {[key: string]: string | number | boolean | undefined},
}
function dockerProtocolEnv(env?: {[key: string]: string | number | boolean | undefined}): string[] {
const result = [];
for (const [key, value] of Object.entries(env ?? {}))
result.push(`${key}=${value}`);
return result;
}
export async function commitContainer(options: CommitContainerOptions) {
await postJSON(`/commit?container=${options.containerId}&repo=${options.repo}&tag=${options.tag}`, {
Entrypoint: options.entrypoint,
WorkingDir: options.workingDir,
Env: dockerProtocolEnv(options.env),
});
}
export async function listImages(): Promise<DockerImage[]> {
const rawImages: any[] = (await getJSON('/images/json')) ?? [];
return rawImages.map((rawImage: any) => ({
imageId: rawImage.Id,
names: rawImage.RepoTags ?? [],
}));
}
export async function removeImage(imageId: string) {
await callDockerAPI('delete', `/images/${imageId}`);
}
export async function checkEngineRunning() {
try {
await callDockerAPI('get', '/info');
return true;
} catch (e) {
return false;
}
}
async function getJSON(url: string): Promise<any> {
const result = await callDockerAPI('get', url);
if (!result)
return result;
return JSON.parse(result);
}
async function postJSON(url: string, json: any = undefined) {
const result = await callDockerAPI('post', url, json ? JSON.stringify(json) : undefined);
if (!result)
return result;
return JSON.parse(result);
}
function callDockerAPI(method: 'post'|'get'|'delete', url: string, body: Buffer|string|undefined = undefined): Promise<string> {
const dockerSocket = process.platform === 'win32' ? '\\\\.\\pipe\\docker_engine' : '/var/run/docker.sock';
return new Promise((resolve, reject) => {
const request = http.request({
socketPath: dockerSocket,
path: `/v${DOCKER_API_VERSION}${url}`,
timeout: 30000,
method,
}, (response: http.IncomingMessage) => {
let body = '';
response.on('data', function(chunk){
body += chunk;
});
response.on('end', function(){
if (!response.statusCode || response.statusCode < 200 || response.statusCode >= 300)
reject(new Error(`${method} ${url} FAILED with statusCode ${response.statusCode} and body\n${body}`));
else
resolve(body);
});
});
request.on('error', function(e){
reject(e);
});
if (body) {
request.setHeader('Content-Type', 'application/json');
request.setHeader('Content-Length', body.length);
request.write(body);
} else {
request.setHeader('Content-Type', 'text/plain');
}
request.end();
});
}