chore: add k8s grid deployments (#26359)

This commit is contained in:
Pavel Feldman 2023-08-08 18:46:32 -07:00 committed by GitHub
parent ffd6cf60eb
commit 65ac0d5256
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 296 additions and 38 deletions

22
package-lock.json generated
View file

@ -6568,6 +6568,7 @@
"dependencies": {
"commander": "^11.0.0",
"debug": "^4.3.2",
"playwright-core": "1.37.0-alpha-aug-7-2023",
"ws": "^8.1.0"
},
"bin": {
@ -6576,8 +6577,7 @@
"devDependencies": {
"@types/commander": "^2.12.2",
"@types/debug": "^4.1.8",
"@types/ws": "^8.5.5",
"playwright-core": "1.37.0-next"
"@types/ws": "^8.5.5"
},
"engines": {
"node": ">=16"
@ -6591,6 +6591,17 @@
"node": ">=16"
}
},
"packages/playwright-grid/node_modules/playwright-core": {
"version": "1.37.0-alpha-aug-7-2023",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.0-alpha-aug-7-2023.tgz",
"integrity": "sha512-heSES+oWES3ktYWiAwi0Oo+UWKCNIJtJVn8h0cgHd7qT2lZ23Iq8DPBBFHtbv+YLjhmXrXRpMIkk4o1MXguLgg==",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"packages/playwright-test": {
"name": "@playwright/test",
"version": "1.37.0-next",
@ -7571,7 +7582,7 @@
"@types/ws": "^8.5.5",
"commander": "^11.0.0",
"debug": "^4.3.2",
"playwright-core": "1.37.0-next",
"playwright-core": "1.37.0-alpha-aug-7-2023",
"ws": "^8.1.0"
},
"dependencies": {
@ -7579,6 +7590,11 @@
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz",
"integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ=="
},
"playwright-core": {
"version": "1.37.0-alpha-aug-7-2023",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.37.0-alpha-aug-7-2023.tgz",
"integrity": "sha512-heSES+oWES3ktYWiAwi0Oo+UWKCNIJtJVn8h0cgHd7qT2lZ23Iq8DPBBFHtbv+YLjhmXrXRpMIkk4o1MXguLgg=="
}
}
},

View file

@ -30,4 +30,5 @@ export { createPlaywright } from './playwright';
export type { DispatcherScope } from './dispatchers/dispatcher';
export type { Playwright } from './playwright';
export { openTraceInBrowser, openTraceViewerApp } from './trace/viewer/traceViewer';
export { serverSideCallMetadata } from './instrumentation';
export { serverSideCallMetadata } from './instrumentation';
export { SocksProxy } from '../common/socksProxy';

View file

@ -0,0 +1,8 @@
FROM mcr.microsoft.com/playwright:v1.37.0-alpha-aug-7-2023-jammy
WORKDIR /app
COPY package.json ./
COPY cli.js ./
COPY lib ./lib
RUN npm install

View file

@ -0,0 +1,42 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: grid-deployment
spec:
replicas: 1
selector:
matchLabels:
app: grid
template:
metadata:
labels:
app: grid
spec:
containers:
- name: grid
image: playwright-grid
imagePullPolicy: IfNotPresent
env:
- name: DEBUG
value: "pw:grid*"
- name: PLAYWRIGHT_GRID_ACCESS_KEY
valueFrom:
secretKeyRef:
name: access-key-secret
key: access-key
command: ["node", "./cli.js"]
args: ["grid", "--port=3000"]
---
apiVersion: v1
kind: Service
metadata:
name: grid-service
spec:
selector:
app: grid
ports:
- protocol: TCP
port: 3000
targetPort: 3000
type: LoadBalancer

View file

@ -0,0 +1,28 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: worker-deployment
spec:
replicas: 10 # or however many nodes you want
selector:
matchLabels:
app: worker
template:
metadata:
labels:
app: worker
spec:
containers:
- name: grid
image: playwright-grid
imagePullPolicy: IfNotPresent
env:
- name: DEBUG
value: "pw:grid*"
- name: PLAYWRIGHT_GRID_ACCESS_KEY
valueFrom:
secretKeyRef:
name: access-key-secret
key: access-key
command: ["node", "./cli.js"]
args: ["node", "--grid=grid-service:3000"]

View file

@ -0,0 +1,55 @@
```sh
# Create resource group
az group create --name group-grid-001 --location westus3
# Create ACR
az acr create --resource-group group-grid-001 --name acrgrid001 --sku Basic
az acr login --name acrgrid001
az acr list --resource-group group-grid-001 --query "[].{acrLoginServer:loginServer}" --output table
# Create AKS
az aks create --resource-group group-grid-001 --name aks-grid-001 --node-count 4 --enable-addons monitoring --generate-ssh-keys
az aks get-credentials --resource-group group-grid-001 --name aks-grid-001
# Grant AKS access to ACR
az aks show --resource-group group-grid-001 --name aks-grid-001 --query "servicePrincipalProfile.clientId" --output tsv
# az aks show --resource-group group-grid-001 --name aks-grid-001 --query "identityProfile.kubeletidentity.clientId" -o tsv
# az acr show --name acrgrid001 --resource-group group-grid-001 --query "id" -o tsv
# az role assignment create --assignee <GUID> --role AcrPull --scope <SCOP PATH>
# Create secrets
kubectl create secret generic access-key-secret --from-literal=access-key=$PLAYWRIGHT_GRID_ACCESS_KEY
# Create TLS
# kubectl create secret tls grid-tls-secret --cert=../../tests/config/testserver/cert.pem --key=../../tests/config/testserver/key.pem
# az network public-ip create --resource-group MC_group-grid-001_aks-grid-001_westus3 --name public-ip-grid-001 --sku Standard --allocation-method static
# az network public-ip show --resource-group MC_group-grid-001_aks-grid-001_westus3 --name public-ip-grid-001 --query ipAddress --output tsv
# # use output below
# helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
# helm install nginx-ingress ingress-nginx/ingress-nginx \
# --set controller.replicaCount=1 \
# --set controller.nodeSelector."beta\.kubernetes\.io/os"=linux \
# --set defaultBackend.nodeSelector."beta\.kubernetes\.io/os"=linux \
# --set controller.service.loadBalancerIP="20.118.130.255"
# Push Docker container
docker build -t playwright-grid:latest -f Dockerfile .
docker tag playwright-grid acrgrid001.azurecr.io/playwright-grid
docker push acrgrid001.azurecr.io/playwright-grid
# Delete deployment
kubectl delete deployment grid-deployment
kubectl delete deployment worker-deployment
kubectl delete svc grid-service
# Update deployment
kubectl apply -f deployment-grid.yaml
kubectl apply -f deployment-worker.yaml
# Debug
kubectl get pods -l app=grid
kubectl logs grid-6cbbfc866c-wh8dw
kubectl get pods -n ingress-basic
kubectl get svc grid-service
az aks show --resource-group group-grid-001 --name aks-grid-001 --query fqdn --output tsv
```

View file

@ -0,0 +1,31 @@
```sh
minikube config set memory 65536
minikube config set cpus 12
minikube start
minikube dashboard
# Point docker to minikube
minikube -p minikube docker-env
eval $(minikube docker-env)
kubectl config use-context minikube
kubectl create secret generic access-key-secret --from-literal=access-key=$PLAYWRIGHT_GRID_ACCESS_KEY
# Push Docker container
docker build -t playwright-grid:latest -f Dockerfile .
# Delete deployment
kubectl delete deployment grid-deployment
kubectl delete deployment worker-deployment
kubectl delete svc grid-service
# Update deployment
kubectl apply -f deployment-grid.yaml
kubectl apply -f deployment-worker.yaml
# Debug
minikube ip
kubectl get svc grid-service
kubectl get pods -l app=grid
kubectl logs grid-6cbbfc866c-wh8dw
```

View file

@ -10,13 +10,13 @@
"dependencies": {
"commander": "^11.0.0",
"debug": "^4.3.2",
"playwright-core": "1.37.0-alpha-aug-7-2023",
"ws": "^8.1.0"
},
"devDependencies": {
"@types/commander": "^2.12.2",
"@types/debug": "^4.1.8",
"@types/ws": "^8.5.5",
"playwright-core": "1.37.0-next"
"@types/ws": "^8.5.5"
},
"repository": "github:Microsoft/playwright",
"engines": {

View file

@ -27,7 +27,7 @@ program
.option('--access-key <key>', 'access key to the grid')
.action(async opts => {
const port = opts.port || +(process.env.PLAYWRIGHT_GRID_PORT || '3333');
const accessKey = opts.accessKey || process.env.PLAYWRIGHT_GRID_ACCESS_KEY;
const accessKey = opts.accessKey || (process.env.PLAYWRIGHT_GRID_ACCESS_KEY || '');
const { Grid } = await import('./grid/grid.js');
const grid = new Grid(port, accessKey);
grid.start();
@ -40,7 +40,9 @@ program
.option('--access-key <key>', 'access key to the grid', '')
.action(async opts => {
const { Node } = await import('./node/node.js');
new Node(opts.grid, +opts.capacity, opts.accessKey);
const accessKey = opts.accessKey || (process.env.PLAYWRIGHT_GRID_ACCESS_KEY || '');
const node = new Node(opts.grid, +opts.capacity, accessKey);
await node.connect();
});
program.parse(process.argv);

View file

@ -14,6 +14,7 @@
* limitations under the License.
*/
import debug from 'debug';
import fs from 'fs';
import http from 'http';
import path from 'path';
@ -23,11 +24,13 @@ import { Server as WebSocketServer } from 'ws';
export type ServerRouteHandler = (request: http.IncomingMessage, response: http.ServerResponse) => boolean;
export class HttpServer {
private _log: debug.Debugger;
readonly server: http.Server;
private _urlPrefix: string;
private _routes: { prefix?: string, exact?: string, handler: ServerRouteHandler }[] = [];
constructor() {
this._log = debug(`pw:grid:http`);
this._urlPrefix = '';
this.server = http.createServer(this._onRequest.bind(this));
}
@ -45,6 +48,7 @@ export class HttpServer {
}
async start(port?: number): Promise<string> {
this._log('starting server', port);
this.server.listen(port);
await new Promise(cb => this.server!.once('listening', cb));
const address = this.server.address();
@ -77,6 +81,7 @@ export class HttpServer {
}
private _onRequest(request: http.IncomingMessage, response: http.ServerResponse) {
this._log('web request', request.url);
request.on('error', () => response.end());
try {
if (!request.url) {
@ -84,6 +89,7 @@ export class HttpServer {
return;
}
const url = new URL('http://localhost' + request.url);
this._log('url pathname', url.pathname);
for (const route of this._routes) {
if (route.exact && url.pathname === route.exact && route.handler(request, response))
return;

View file

@ -243,6 +243,8 @@ export class Grid {
ws.on('error', e => this._log(e));
});
this._server.server.on('upgrade', async (request, socket, head) => {
this._log('upgrade', request.url, request.headers);
if (this._accessKey && request.headers['x-playwright-access-key'] !== this._accessKey) {
socket.destroy();
return;
@ -250,7 +252,6 @@ export class Grid {
const url = new URL('http://internal' + request.url);
const params = url.searchParams;
this._log(url.pathname);
if (url.pathname.startsWith('/registerNode')) {
const nodeRequest = new WebSocketRequest(this._wsServer, request, socket, head);

View file

@ -28,19 +28,56 @@ const caps: Capabilities = {
export class Node {
workerSeq = 0;
constructor(grid: string, capacity: number, accessKey: string) {
log('node created');
const ws = new WebSocket(grid + `/registerNode?capacity=${capacity}&caps=${JSON.stringify(caps)}`, {
headers: {
'x-playwright-access-key': accessKey,
constructor(readonly grid: string, readonly capacity: number, readonly accessKey: string) {
log('node created', accessKey);
}
async connect() {
const wsGrid = 'ws://' + this.grid;
const url = wsGrid + `/registerNode?capacity=${this.capacity}&caps=${JSON.stringify(caps)}`;
for (let i = 0; i < 5; ++i) {
const ws = await this._connect(url);
if (ws) {
this._wire(ws, wsGrid);
return;
}
await new Promise(f => setTimeout(f, 5000));
}
// eslint-disable-next-line no-restricted-properties
process.exit(0);
}
private async _connect(url: string): Promise<WebSocket | null> {
return await new Promise(resolve => {
log('connecting', url);
const ws = new WebSocket(url, {
headers: {
'x-playwright-access-key': this.accessKey,
}
});
ws.on('error', error => {
log(error);
resolve(null);
});
ws.on('open', () => {
log('connected', this.grid);
resolve(ws);
});
});
let nodeId = '';
ws.on('error', error => {
log(error);
}
private _wire(ws: WebSocket, wsGrid: string) {
ws.on('close', () => {
// eslint-disable-next-line no-restricted-properties
process.exit(0);
});
ws.on('error', () => {
// eslint-disable-next-line no-restricted-properties
process.exit(0);
});
let nodeId = '';
ws.on('message', data => {
const text = data.toString();
const message = JSON.parse(text);
@ -56,13 +93,11 @@ export class Node {
...process.env,
PLAYWRIGHT_GRID_NODE_ID: nodeId,
PLAYWRIGHT_GRID_WORKER_ID: workerId,
PLAYWRIGHT_GRID_ENDPOINT: grid,
PLAYWRIGHT_GRID_ACCESS_KEY: accessKey,
PLAYWRIGHT_GRID_ENDPOINT: wsGrid,
PLAYWRIGHT_GRID_ACCESS_KEY: this.accessKey,
},
detached: true
});
});
// eslint-disable-next-line no-restricted-properties
ws.on('close', () => process.exit(0));
}
}

View file

@ -16,11 +16,12 @@
import debug from 'debug';
import WebSocket from 'ws';
import { DispatcherConnection, RootDispatcher, PlaywrightDispatcher, createPlaywright, serverSideCallMetadata } from 'playwright-core/lib/server';
import { gracefullyProcessExitDoNotHang } from 'playwright-core/lib/utils';
import { DispatcherConnection, RootDispatcher, PlaywrightDispatcher, createPlaywright, serverSideCallMetadata, SocksProxy } from 'playwright-core/lib/server';
import { gracefullyCloseAll } from 'playwright-core/lib/utils';
import type { Playwright } from 'playwright-core/lib/server';
const workerId = process.env.PLAYWRIGHT_GRID_WORKER_ID!;
const log = debug('pw:grid:browser@' + workerId);
const log = debug('pw:grid:worker@' + workerId);
class Worker {
constructor() {
@ -28,6 +29,20 @@ class Worker {
const dispatcherConnection = new DispatcherConnection();
let browserName: 'chromium' | 'webkit' | 'firefox';
let launchOptions: any;
let proxyPattern: string | undefined;
let socksProxy: SocksProxy | undefined;
const dispose = async () => {
dispatcherConnection.onmessage = () => {};
// eslint-disable-next-line no-restricted-properties
setTimeout(() => process.exit(0), 30000);
await Promise.all([
socksProxy?.close(),
gracefullyCloseAll(),
]).catch(() => {});
// eslint-disable-next-line no-restricted-properties
process.exit(0);
};
const ws = new WebSocket(process.env.PLAYWRIGHT_GRID_ENDPOINT + `/registerWorker?nodeId=${process.env.PLAYWRIGHT_GRID_NODE_ID}&workerId=${workerId}`, {
headers: {
@ -42,29 +57,40 @@ class Worker {
browserName = headers['x-playwright-browser'] as any || 'chromium';
launchOptions = JSON.parse(headers['x-playwright-launch-options'] || '{}');
log('browserName', browserName);
log('launchOptions', launchOptions);
proxyPattern = headers['x-playwright-proxy'] || '';
log({ browserName, launchOptions, proxyPattern });
});
ws.once('open', () => {
log('worker opened');
new RootDispatcher(dispatcherConnection, async (rootScope, { sdkLanguage }) => {
const playwright = createPlaywright({ sdkLanguage });
if (proxyPattern)
socksProxy = await createOwnedSocksProxy(proxyPattern, playwright);
const browser = await playwright[browserName].launch(serverSideCallMetadata(), launchOptions);
return new PlaywrightDispatcher(rootScope, playwright, undefined, browser);
return new PlaywrightDispatcher(rootScope, playwright, socksProxy, browser);
});
});
ws.on('message', message => dispatcherConnection.dispatch(JSON.parse(message.toString())));
ws.on('error', error => {
log('socket error');
dispatcherConnection.onmessage = () => {};
gracefullyProcessExitDoNotHang(0);
dispose();
});
ws.on('close', async () => {
log('worker deleted');
dispatcherConnection.onmessage = () => {};
gracefullyProcessExitDoNotHang(0);
dispose();
});
}
}
async function createOwnedSocksProxy(proxyPattern: string, playwright: Playwright): Promise<SocksProxy | undefined> {
if (!proxyPattern)
return;
const socksProxy = new SocksProxy();
socksProxy.setPattern(proxyPattern);
playwright.options.socksProxyPort = await socksProxy.listen(0);
log(`started socks proxy on port ${playwright.options.socksProxyPort}`);
return socksProxy;
}
new Worker();

View file

@ -15,7 +15,7 @@
*/
import { config as loadEnv } from 'dotenv';
loadEnv({ path: path.join(__dirname, '..', '..', '.env') });
loadEnv({ path: path.join(__dirname, '..', '..', '.env'), override: true });
import type { Config, PlaywrightTestOptions, PlaywrightWorkerOptions, ReporterDescription } from '@playwright/test';
import * as path from 'path';
@ -78,23 +78,24 @@ if (mode === 'service2') {
if (mode === 'service-grid') {
connectOptions = {
wsEndpoint: 'ws://localhost:3333',
wsEndpoint: process.env.PLAYWRIGHT_GRID_URL || 'ws://localhost:3333',
timeout: 60 * 60 * 1000,
headers: {
'x-playwright-access-key': 'secret'
}
'x-playwright-access-key': process.env.PLAYWRIGHT_GRID_ACCESS_KEY || 'secret'
},
exposeNetwork: '<loopback>',
};
webServer = [
webServer = process.env.PLAYWRIGHT_GRID_URL ? [] : [
{
command: 'node ../../packages/playwright-grid/cli.js grid --port=3333 --access-key=secret',
stdout: 'pipe',
url: 'http://localhost:3333/secret',
reuseExistingServer: !process.env.CI,
}, {
command: 'node ../../packages/playwright-grid/cli.js node --grid=ws://localhost:3333 --access-key=secret --capacity=2',
command: 'node ../../packages/playwright-grid/cli.js node --grid=localhost:3333 --access-key=secret --capacity=2',
},
{
command: 'node ../../packages/playwright-grid/cli.js node --grid=ws://localhost:3333 --access-key=secret --capacity=2',
command: 'node ../../packages/playwright-grid/cli.js node --grid=localhost:3333 --access-key=secret --capacity=2',
}
];
}

View file

@ -33,6 +33,7 @@ class PWPackage {
this.name = descriptor.name;
this.path = descriptor.path;
this.files = descriptor.files;
this.noConsistent = descriptor.noConsistent;
this.packageJSONPath = path.join(this.path, 'package.json');
this.packageJSON = JSON.parse(fs.readFileSync(this.packageJSONPath, 'utf8'));
this.isPrivate = !!this.packageJSON.private;
@ -120,6 +121,10 @@ class Workspace {
pkg.packageJSON.author = workspacePackageJSON.author;
pkg.packageJSON.license = workspacePackageJSON.license;
}
if (pkg.noConsistent)
continue;
for (const otherPackage of this._packages) {
if (pkgLockEntry.dependencies && pkgLockEntry.dependencies[otherPackage.name])
pkgLockEntry.dependencies[otherPackage.name] = version;
@ -212,6 +217,7 @@ const workspace = new Workspace(ROOT_PATH, [
name: '@playwright/experimental-grid',
path: path.join(ROOT_PATH, 'packages', 'playwright-grid'),
files: ['LICENSE'],
noConsistent: true,
}),
]);