mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: refactor docker integration code (#17297)
This patch splits out `dockerApi` namespace that is solely responsible for docker API manipulation.
This commit is contained in:
parent
b2e8cf7121
commit
ebfefefc8e
@ -15,25 +15,12 @@
|
||||
*/
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import http from 'http';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { spawnAsync } from 'playwright-core/lib/utils/spawnAsync';
|
||||
import * as utils from 'playwright-core/lib/utils';
|
||||
import { getPlaywrightVersion } from 'playwright-core/lib/common/userAgent';
|
||||
|
||||
interface DockerImage {
|
||||
Containers: number;
|
||||
Created: number;
|
||||
Id: string;
|
||||
Labels: null | Record<string, string>;
|
||||
ParentId: string;
|
||||
RepoDigests: null | string[];
|
||||
RepoTags: null | string[];
|
||||
SharedSize: number;
|
||||
Size: number;
|
||||
VirtualSize: number;
|
||||
}
|
||||
import * as dockerApi from './dockerApi';
|
||||
|
||||
const VRT_IMAGE_DISTRO = 'focal';
|
||||
const VRT_IMAGE_NAME = `playwright:local-${getPlaywrightVersion()}-${VRT_IMAGE_DISTRO}`;
|
||||
@ -46,7 +33,7 @@ export async function deleteImage() {
|
||||
|
||||
if (await containerInfo())
|
||||
await stopContainer();
|
||||
await callDockerAPI('delete', `/images/${dockerImage.Id}`);
|
||||
await dockerApi.removeImage(dockerImage.imageId);
|
||||
}
|
||||
|
||||
export async function buildImage() {
|
||||
@ -80,27 +67,26 @@ export async function buildImage() {
|
||||
// 3. Launch container and install VNC in it
|
||||
console.log(`Building ${VRT_IMAGE_NAME}...`);
|
||||
const buildScriptText = await fs.promises.readFile(path.join(__dirname, 'build_docker_image.sh'), 'utf8');
|
||||
const containerId = await launchContainer({
|
||||
image: dockerImage,
|
||||
const containerId = await dockerApi.launchContainer({
|
||||
imageId: dockerImage.imageId,
|
||||
autoRemove: false,
|
||||
command: ['/bin/bash', '-c', buildScriptText],
|
||||
waitUntil: 'not-running',
|
||||
});
|
||||
await postJSON(`/containers/${containerId}/wait`);
|
||||
|
||||
// 4. Commit a new image based on the launched container with installed VNC & noVNC.
|
||||
const [vrtRepo, vrtTag] = VRT_IMAGE_NAME.split(':');
|
||||
await postJSON(`/commit?container=${containerId}&repo=${vrtRepo}&tag=${vrtTag}`, {
|
||||
Entrypoint: ['/entrypoint.sh'],
|
||||
Env: [
|
||||
'DISPLAY_NUM=99',
|
||||
'DISPLAY=:99',
|
||||
],
|
||||
await dockerApi.commitContainer({
|
||||
containerId,
|
||||
repo: vrtRepo,
|
||||
tag: vrtTag,
|
||||
entrypoint: '/entrypoint.sh',
|
||||
env: {
|
||||
'DISPLAY_NUM': '99',
|
||||
'DISPLAY': ':99',
|
||||
},
|
||||
});
|
||||
await Promise.all([
|
||||
// Make sure to wait for the container to be removed.
|
||||
postJSON(`/containers/${containerId}/wait?condition=removed`),
|
||||
callDockerAPI('delete', `/containers/${containerId}`),
|
||||
]);
|
||||
await dockerApi.removeContainer(containerId);
|
||||
console.log(`Done!`);
|
||||
}
|
||||
|
||||
@ -113,17 +99,7 @@ export async function containerInfo(): Promise<ContainerInfo|undefined> {
|
||||
const containerId = await findRunningDockerContainerId();
|
||||
if (!containerId)
|
||||
return undefined;
|
||||
const rawLogs = await callDockerAPI('get', `/containers/${containerId}/logs?stdout=true&stderr=true`).catch(e => '');
|
||||
if (!rawLogs)
|
||||
return undefined;
|
||||
// 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.
|
||||
const logLines = rawLogs.split('\n').map(line => {
|
||||
if ([0, 1, 2].includes(line.charCodeAt(0)))
|
||||
return line.substring(8);
|
||||
return line;
|
||||
});
|
||||
const logLines = await dockerApi.getContainerLogs(containerId);
|
||||
const WS_LINE_PREFIX = 'Listening on ws://';
|
||||
const webSocketLine = logLines.find(line => line.startsWith(WS_LINE_PREFIX));
|
||||
const NOVNC_LINE_PREFIX = 'novnc is listening on ';
|
||||
@ -152,8 +128,8 @@ export async function ensureContainerOrDie(): Promise<ContainerInfo> {
|
||||
if (info)
|
||||
return info;
|
||||
|
||||
await launchContainer({
|
||||
image: pwImage,
|
||||
await dockerApi.launchContainer({
|
||||
imageId: pwImage.imageId,
|
||||
name: VRT_CONTAINER_NAME,
|
||||
autoRemove: true,
|
||||
ports: [5400, 7900],
|
||||
@ -176,128 +152,34 @@ export async function stopContainer() {
|
||||
const containerId = await findRunningDockerContainerId();
|
||||
if (!containerId)
|
||||
return;
|
||||
await Promise.all([
|
||||
// Make sure to wait for the container to be removed.
|
||||
postJSON(`/containers/${containerId}/wait?condition=removed`),
|
||||
postJSON(`/containers/${containerId}/kill`),
|
||||
]);
|
||||
await dockerApi.stopContainer({
|
||||
containerId,
|
||||
waitUntil: 'removed',
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureDockerEngineIsRunningOrDie() {
|
||||
try {
|
||||
await callDockerAPI('get', '/info');
|
||||
} catch (e) {
|
||||
console.error(utils.wrapInASCIIBox([
|
||||
`Docker is not running!`,
|
||||
`Please install and launch docker:`,
|
||||
``,
|
||||
` https://docs.docker.com/get-docker`,
|
||||
``,
|
||||
].join('\n'), 1));
|
||||
process.exit(1);
|
||||
}
|
||||
if (await dockerApi.checkEngineRunning())
|
||||
return;
|
||||
console.error(utils.wrapInASCIIBox([
|
||||
`Docker is not running!`,
|
||||
`Please install and launch docker:`,
|
||||
``,
|
||||
` https://docs.docker.com/get-docker`,
|
||||
``,
|
||||
].join('\n'), 1));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function findDockerImage(imageName: string): Promise<DockerImage|undefined> {
|
||||
const images: DockerImage[] | null = await getJSON('/images/json');
|
||||
return images ? images.find(image => image.RepoTags?.includes(imageName)) : undefined;
|
||||
}
|
||||
|
||||
interface Container {
|
||||
ImageID: string;
|
||||
State: string;
|
||||
Names: [string];
|
||||
Id: string;
|
||||
async function findDockerImage(imageName: string): Promise<dockerApi.DockerImage|undefined> {
|
||||
const images = await dockerApi.listImages();
|
||||
return images.find(image => image.names.includes(imageName));
|
||||
}
|
||||
|
||||
async function findRunningDockerContainerId(): Promise<string|undefined> {
|
||||
const containers: (Container[]|undefined) = await getJSON('/containers/json');
|
||||
if (!containers)
|
||||
return undefined;
|
||||
const containers = await dockerApi.listContainers();
|
||||
const dockerImage = await findDockerImage(VRT_IMAGE_NAME);
|
||||
const container = dockerImage ? containers.find((container: Container) => container.ImageID === dockerImage.Id) : undefined;
|
||||
return container?.State === 'running' ? container.Id : undefined;
|
||||
}
|
||||
|
||||
interface ContainerOptions {
|
||||
image: DockerImage;
|
||||
autoRemove: boolean;
|
||||
command?: string[];
|
||||
ports?: Number[];
|
||||
name?: string;
|
||||
}
|
||||
|
||||
async function launchContainer(options: ContainerOptions): Promise<string> {
|
||||
const ExposedPorts: any = {};
|
||||
const PortBindings: any = {};
|
||||
for (const port of (options.ports ?? [])) {
|
||||
ExposedPorts[`${port}/tcp`] = {};
|
||||
PortBindings[`${port}/tcp`] = [{ HostPort: port + '' }];
|
||||
}
|
||||
const container = await postJSON(`/containers/create` + (options.name ? '?name=' + options.name : ''), {
|
||||
Cmd: options.command,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Image: options.image.Id,
|
||||
ExposedPorts,
|
||||
HostConfig: {
|
||||
Init: true,
|
||||
AutoRemove: options.autoRemove,
|
||||
ShmSize: 2 * 1024 * 1024 * 1024,
|
||||
PortBindings,
|
||||
},
|
||||
});
|
||||
await postJSON(`/containers/${container.Id}/start`);
|
||||
return container.Id;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const DOCKER_API_VERSION = '1.41';
|
||||
|
||||
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();
|
||||
});
|
||||
const container = dockerImage ? containers.find(container => container.imageId === dockerImage.imageId) : undefined;
|
||||
return container?.state === 'running' ? container.containerId : undefined;
|
||||
}
|
||||
|
||||
|
||||
200
packages/playwright-test/src/docker/dockerApi.ts
Normal file
200
packages/playwright-test/src/docker/dockerApi.ts
Normal file
@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 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 DockerContainer {
|
||||
containerId: string;
|
||||
imageId: string;
|
||||
state: 'created'|'restarting'|'running'|'removing'|'paused'|'exited'|'dead';
|
||||
names: string[];
|
||||
}
|
||||
|
||||
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,
|
||||
names: container.Names
|
||||
}));
|
||||
}
|
||||
|
||||
interface LaunchContainerOptions {
|
||||
imageId: string;
|
||||
autoRemove: boolean;
|
||||
command?: string[];
|
||||
ports?: Number[];
|
||||
name?: string;
|
||||
waitUntil?: 'not-running' | 'next-exit' | 'removed';
|
||||
}
|
||||
|
||||
export async function launchContainer(options: LaunchContainerOptions): Promise<string> {
|
||||
const ExposedPorts: any = {};
|
||||
const PortBindings: any = {};
|
||||
for (const port of (options.ports ?? [])) {
|
||||
ExposedPorts[`${port}/tcp`] = {};
|
||||
PortBindings[`${port}/tcp`] = [{ HostPort: port + '' }];
|
||||
}
|
||||
const container = await postJSON(`/containers/create` + (options.name ? '?name=' + options.name : ''), {
|
||||
Cmd: options.command,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Image: options.imageId,
|
||||
ExposedPorts,
|
||||
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,
|
||||
env?: {[key: string]: string | number | boolean | undefined},
|
||||
}
|
||||
|
||||
export async function commitContainer(options: CommitContainerOptions) {
|
||||
const Env = [];
|
||||
for (const [key, value] of Object.entries(options.env ?? {}))
|
||||
Env.push(`${key}=${value}`);
|
||||
await postJSON(`/commit?container=${options.containerId}&repo=${options.repo}&tag=${options.tag}`, {
|
||||
Entrypoint: options.entrypoint ?? '',
|
||||
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();
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user