From 3520fbebb323df393c273ff2971e0e57d86cd878 Mon Sep 17 00:00:00 2001 From: Roy Razon Date: Wed, 30 Aug 2023 11:55:11 +0300 Subject: [PATCH 1/3] cta: narrow down docker API replace generic docker API proxy with specific endpoints --- packages/cli/src/commands/logs.ts | 3 +- packages/common/index.ts | 1 + packages/common/src/compose-tunnel-agent.ts | 10 ++ packages/compose-tunnel-agent/index.ts | 49 +++++---- .../src/api-server/errors.ts | 13 +++ .../http-server-helpers.ts | 35 +++++- .../docker-proxy => api-server}/index.test.ts | 68 ++++-------- .../src/api-server/index.ts | 104 ++++++++++++++++++ .../src/{http => api-server}/query-params.ts | 2 +- .../docker-proxy => api-server}/ws/handler.ts | 5 +- .../src/api-server/ws/handlers/exec.ts | 62 +++++++++++ .../ws/handlers/logs.ts | 12 +- .../docker-proxy => api-server}/ws/index.ts | 0 packages/compose-tunnel-agent/src/docker.ts | 81 -------------- .../src/docker/events-client.ts | 66 +++++++++++ .../src/docker/filtered-client.ts | 21 ++++ .../src/docker/filters.ts | 48 ++++++++ .../compose-tunnel-agent/src/docker/index.ts | 2 + .../compose-tunnel-agent/src/docker/labels.ts | 2 + .../src/http/api-server.ts | 48 -------- .../src/http/docker-proxy/index.ts | 77 ------------- .../src/http/docker-proxy/ws/handlers/exec.ts | 42 ------- .../compose-tunnel-agent/src/http/index.ts | 35 ------ packages/core/src/commands/proxy.ts | 28 +++-- packages/core/src/commands/up/index.ts | 7 +- packages/core/src/commands/urls.ts | 3 +- .../core/src/compose-tunnel-agent-client.ts | 21 +++- packages/core/src/compose/model.ts | 3 +- packages/core/src/env-metadata.ts | 8 +- packages/core/src/index.ts | 1 - 30 files changed, 473 insertions(+), 384 deletions(-) create mode 100644 packages/common/src/compose-tunnel-agent.ts create mode 100644 packages/compose-tunnel-agent/src/api-server/errors.ts rename packages/compose-tunnel-agent/src/{http => api-server}/http-server-helpers.ts (76%) rename packages/compose-tunnel-agent/src/{http/docker-proxy => api-server}/index.test.ts (80%) create mode 100644 packages/compose-tunnel-agent/src/api-server/index.ts rename packages/compose-tunnel-agent/src/{http => api-server}/query-params.ts (85%) rename packages/compose-tunnel-agent/src/{http/docker-proxy => api-server}/ws/handler.ts (81%) create mode 100644 packages/compose-tunnel-agent/src/api-server/ws/handlers/exec.ts rename packages/compose-tunnel-agent/src/{http/docker-proxy => api-server}/ws/handlers/logs.ts (75%) rename packages/compose-tunnel-agent/src/{http/docker-proxy => api-server}/ws/index.ts (100%) delete mode 100644 packages/compose-tunnel-agent/src/docker.ts create mode 100644 packages/compose-tunnel-agent/src/docker/events-client.ts create mode 100644 packages/compose-tunnel-agent/src/docker/filtered-client.ts create mode 100644 packages/compose-tunnel-agent/src/docker/filters.ts create mode 100644 packages/compose-tunnel-agent/src/docker/index.ts create mode 100644 packages/compose-tunnel-agent/src/docker/labels.ts delete mode 100644 packages/compose-tunnel-agent/src/http/api-server.ts delete mode 100644 packages/compose-tunnel-agent/src/http/docker-proxy/index.ts delete mode 100644 packages/compose-tunnel-agent/src/http/docker-proxy/ws/handlers/exec.ts delete mode 100644 packages/compose-tunnel-agent/src/http/index.ts diff --git a/packages/cli/src/commands/logs.ts b/packages/cli/src/commands/logs.ts index edf1db9e..c3f28cb9 100644 --- a/packages/cli/src/commands/logs.ts +++ b/packages/cli/src/commands/logs.ts @@ -1,9 +1,10 @@ import yaml from 'yaml' import { Args, Flags, Interfaces } from '@oclif/core' import { - COMPOSE_TUNNEL_AGENT_SERVICE_NAME, addBaseComposeTunnelAgentService, + addBaseComposeTunnelAgentService, localComposeClient, wrapWithDockerSocket, findEnvId, MachineConnection, ComposeModel, remoteUserModel, } from '@preevy/core' +import { COMPOSE_TUNNEL_AGENT_SERVICE_NAME } from '@preevy/common' import DriverCommand from '../driver-command' import { envIdFlags } from '../common-flags' diff --git a/packages/common/index.ts b/packages/common/index.ts index 82d8d808..78931963 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -22,5 +22,6 @@ export { Logger } from './src/log' export { requiredEnv, numberFromEnv } from './src/env' export { tunnelNameResolver, TunnelNameResolver } from './src/tunnel-name' export { editUrl } from './src/url' +export * from './src/compose-tunnel-agent' export { MachineStatusCommand, DockerMachineStatusCommandRecipe } from './src/machine-status-command' export { ProcessOutputBuffers, orderedOutput, OrderedOutput } from './src/process-output-buffers' diff --git a/packages/common/src/compose-tunnel-agent.ts b/packages/common/src/compose-tunnel-agent.ts new file mode 100644 index 00000000..04c880c1 --- /dev/null +++ b/packages/common/src/compose-tunnel-agent.ts @@ -0,0 +1,10 @@ +export const COMPOSE_TUNNEL_AGENT_SERVICE_LABELS = { + PROFILE_THUMBPRINT: 'preevy.profile_thumbprint', + PRIVATE_MODE: 'preevy.private_mode', + ENV_ID: 'preevy.env_id', + ACCESS: 'preevy.access', + EXPOSE: 'preevy.expose', +} + +export const COMPOSE_TUNNEL_AGENT_SERVICE_NAME = 'preevy_proxy' +export const COMPOSE_TUNNEL_AGENT_PORT = 3000 diff --git a/packages/compose-tunnel-agent/index.ts b/packages/compose-tunnel-agent/index.ts index 00fb278e..6184fa66 100644 --- a/packages/compose-tunnel-agent/index.ts +++ b/packages/compose-tunnel-agent/index.ts @@ -14,20 +14,22 @@ import { SshConnectionConfig, tunnelNameResolver, MachineStatusCommand, + COMPOSE_TUNNEL_AGENT_PORT, } from '@preevy/common' -import createDockerClient from './src/docker' -import createApiServerHandler from './src/http/api-server' +import createApiServerHandler from './src/api-server' import { sshClient as createSshClient } from './src/ssh' -import { createDockerProxyHandlers } from './src/http/docker-proxy' -import { tryHandler, tryUpgradeHandler } from './src/http/http-server-helpers' -import { httpServerHandlers } from './src/http' +import { tryHandler, tryUpgradeHandler } from './src/api-server/http-server-helpers' import { runMachineStatusCommand } from './src/machine-status' import { envMetadata } from './src/metadata' import { readAllFiles } from './src/files' +import { eventsClient as dockerEventsClient, filteredClient as dockerFilteredClient } from './src/docker' const homeDir = process.env.HOME || '/root' const dockerSocket = '/var/run/docker.sock' +const targetComposeProject = process.env.COMPOSE_PROJECT +const defaultAccess = process.env.DEFAULT_ACCESS_LEVEL === 'private' ? 'private' : 'public' + const sshConnectionConfigFromEnv = async (): Promise<{ connectionConfig: SshConnectionConfig; sshUrl: string }> => { const sshUrl = requiredEnv('SSH_URL') const parsed = parseSshUrl(sshUrl) @@ -72,7 +74,13 @@ const main = async () => { }) const docker = new Docker({ socketPath: dockerSocket }) - const dockerClient = createDockerClient({ log: log.child({ name: 'docker' }), docker, debounceWait: 500 }) + const dockerClient = dockerEventsClient({ + log: log.child({ name: 'docker' }), + docker, + debounceWait: 500, + defaultAccess, + composeProject: targetComposeProject, + }) const sshLog = log.child({ name: 'ssh' }) const sshClient = await createSshClient({ @@ -95,28 +103,21 @@ const main = async () => { }, }) - const apiListenAddress = process.env.PORT ?? 3000 + const apiListenAddress = process.env.PORT ?? COMPOSE_TUNNEL_AGENT_PORT if (typeof apiListenAddress === 'string' && Number.isNaN(Number(apiListenAddress))) { await rimraf(apiListenAddress) } - const { handler, upgradeHandler } = httpServerHandlers({ - log: log.child({ name: 'http' }), - apiHandler: createApiServerHandler({ - log: log.child({ name: 'api' }), - currentSshState: async () => (await currentTunnels), - machineStatus: machineStatusCommand - ? async () => await runMachineStatusCommand({ log, docker })(machineStatusCommand) - : undefined, - envMetadata: await envMetadata({ env: process.env, log }), - composeModelPath: '/preevy/docker-compose.yaml', - }), - dockerProxyHandlers: createDockerProxyHandlers({ - log: log.child({ name: 'docker-proxy' }), - dockerSocket, - docker, - }), - dockerProxyPrefix: '/docker/', + const { handler, upgradeHandler } = createApiServerHandler({ + log: log.child({ name: 'api' }), + currentSshState: async () => (await currentTunnels), + machineStatus: machineStatusCommand + ? async () => await runMachineStatusCommand({ log, docker })(machineStatusCommand) + : undefined, + envMetadata: await envMetadata({ env: process.env, log }), + composeModelPath: '/preevy/docker-compose.yaml', + docker, + dockerFilter: dockerFilteredClient({ docker, composeProject: targetComposeProject }), }) const httpLog = log.child({ name: 'http' }) diff --git a/packages/compose-tunnel-agent/src/api-server/errors.ts b/packages/compose-tunnel-agent/src/api-server/errors.ts new file mode 100644 index 00000000..6b9034f3 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/errors.ts @@ -0,0 +1,13 @@ +import { BadRequestError, NotFoundError } from './http-server-helpers' + +export class MissingContainerIdError extends BadRequestError { + constructor() { + super('Missing container id') + } +} + +export class ContainerNotFoundError extends NotFoundError { + constructor(containerId: string) { + super(`Container "${containerId}" does not exist or is not managed by this agent`) + } +} diff --git a/packages/compose-tunnel-agent/src/http/http-server-helpers.ts b/packages/compose-tunnel-agent/src/api-server/http-server-helpers.ts similarity index 76% rename from packages/compose-tunnel-agent/src/http/http-server-helpers.ts rename to packages/compose-tunnel-agent/src/api-server/http-server-helpers.ts index a1023de2..1fcf92b8 100644 --- a/packages/compose-tunnel-agent/src/http/http-server-helpers.ts +++ b/packages/compose-tunnel-agent/src/api-server/http-server-helpers.ts @@ -2,8 +2,9 @@ import { Logger } from '@preevy/common' import http from 'node:http' import stream from 'node:stream' import { inspect } from 'node:util' +import { WebSocket } from 'ws' -export const respond = (res: http.ServerResponse, content: string, type = 'text/plain', status = 200) => { +export const respond = (res: http.ServerResponse, content: string | Buffer, type = 'text/plain', status = 200) => { res.writeHead(status, { 'Content-Type': type }) res.end(content) } @@ -105,9 +106,11 @@ export const errorUpgradeHandler = ( log.warn('caught error: %j in upgrade %s %s', inspect(err), req.method || '', req.url || '') } +export type UpgradeHandler = (req: http.IncomingMessage, socket: stream.Duplex, head: Buffer) => Promise + export const tryUpgradeHandler = ( { log }: { log: Logger }, - f: (req: http.IncomingMessage, socket: stream.Duplex, head: Buffer) => Promise + f: UpgradeHandler, ) => async (req: http.IncomingMessage, socket: stream.Duplex, head: Buffer) => { try { await f(req, socket, head) @@ -115,3 +118,31 @@ export const tryUpgradeHandler = ( errorUpgradeHandler(log, err, req, socket) } } + +export const errorWsHandler = ( + log: Logger, + err: unknown, + ws: WebSocket, + req: http.IncomingMessage, +) => { + const [code, message]: [number, string] = err instanceof HttpError + ? [err.status, err.clientMessage] + : [500, InternalError.defaultMessage] + + const wsCode = 4000 + code // https://github.com/websockets/ws/issues/715#issuecomment-504702511 + ws.close(wsCode, message) + log.warn('caught error: %j in ws %s %s', inspect(err), req.method || '', req.url || '') +} + +export type WsHandler = (ws: WebSocket, req: http.IncomingMessage) => Promise + +export const tryWsHandler = ( + { log }: { log: Logger }, + f: WsHandler, +) => async (ws: WebSocket, req: http.IncomingMessage) => { + try { + await f(ws, req) + } catch (err) { + errorWsHandler(log, err, ws, req) + } +} diff --git a/packages/compose-tunnel-agent/src/http/docker-proxy/index.test.ts b/packages/compose-tunnel-agent/src/api-server/index.test.ts similarity index 80% rename from packages/compose-tunnel-agent/src/http/docker-proxy/index.test.ts rename to packages/compose-tunnel-agent/src/api-server/index.test.ts index 7fe330bd..8b8f0ce7 100644 --- a/packages/compose-tunnel-agent/src/http/docker-proxy/index.test.ts +++ b/packages/compose-tunnel-agent/src/api-server/index.test.ts @@ -1,6 +1,6 @@ import http from 'node:http' import net from 'node:net' -import { describe, expect, beforeAll, afterAll, test, jest, it } from '@jest/globals' +import { describe, expect, beforeAll, afterAll, jest, it } from '@jest/globals' import { ChildProcess, spawn, exec } from 'child_process' import pino from 'pino' import pinoPretty from 'pino-pretty' @@ -10,13 +10,18 @@ import { inspect, promisify } from 'node:util' import waitForExpect from 'wait-for-expect' import WebSocket from 'ws' import stripAnsi from 'strip-ansi' -import { createDockerProxyHandlers } from '.' +import createApiServerHandlers from '.' +import { filteredClient } from '../docker' +import { SshState } from '../ssh' +import { COMPOSE_PROJECT_LABEL } from '../docker/labels' + +const TEST_COMPOSE_PROJECT = 'my-project' const setupDockerContainer = () => { let dockerProcess: ChildProcess let containerName: string let output: Buffer[] - jest.setTimeout(100000) + jest.setTimeout(20000) beforeAll(() => { containerName = `test-docker-proxy-${Math.random().toString(36).substring(2, 9)}` @@ -24,7 +29,7 @@ const setupDockerContainer = () => { dockerProcess = spawn( 'docker', [ - ...`run --rm --name ${containerName} busybox sh -c`.split(' '), + ...`run --rm --name ${containerName} --label ${COMPOSE_PROJECT_LABEL}=${TEST_COMPOSE_PROJECT} busybox sh -c`.split(' '), 'while true; do echo "hello stdout"; >&2 echo "hello stderr"; sleep 0.1; done', ] ) @@ -50,7 +55,7 @@ const setupDockerContainer = () => { } } -const setupDockerProxy = () => { +const setupApiServer = () => { const log = pino({ level: 'debug', }, pinoPretty({ destination: pino.destination(process.stderr) })) @@ -60,7 +65,13 @@ const setupDockerProxy = () => { beforeAll(async () => { const docker = new Dockerode() - const handlers = createDockerProxyHandlers({ log, docker, dockerSocket: '/var/run/docker.sock' }) + const handlers = createApiServerHandlers({ + log, + docker, + dockerFilter: filteredClient({ docker, composeProject: TEST_COMPOSE_PROJECT }), + composeModelPath: '', + currentSshState: () => Promise.resolve({} as unknown as SshState), + }) server = http.createServer(handlers.handler).on('upgrade', handlers.upgradeHandler) const serverPort = await new Promise(resolve => { @@ -121,14 +132,14 @@ const openWebSocket = (url: string) => new Promise((resolve, reje }) }) -describe('docker proxy', () => { +describe('docker api', () => { const { containerName } = setupDockerContainer() - const { serverBaseUrl } = setupDockerProxy() + const { serverBaseUrl } = setupApiServer() const waitForContainerId = async () => { let containerId = '' await waitForExpect(async () => { - const containers = await fetchJson(`http://${serverBaseUrl()}/containers/json`) as { Id: string; Names: string[] }[] + const containers = await fetchJson(`http://${serverBaseUrl()}/containers`) as { Id: string; Names: string[] }[] const container = containers.find(({ Names: names }) => names.includes(`/${containerName()}`)) expect(container).toBeDefined() containerId = container?.Id as string @@ -136,30 +147,7 @@ describe('docker proxy', () => { return containerId } - test('use the docker API', async () => { - expect(await waitForContainerId()).toBeDefined() - }) - describe('exec', () => { - const createExec = async (containerId: string, tty: boolean) => { - const { Id: execId } = await fetchJson(`http://${serverBaseUrl()}/containers/${containerId}/exec`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: tty, - Cmd: ['sh'], - }), - }) - - return execId - } - - let execId: string let containerId: string beforeAll(async () => { @@ -167,12 +155,8 @@ describe('docker proxy', () => { }) describe('tty=true', () => { - beforeAll(async () => { - execId = await createExec(containerId, true) - }) - it('should communicate via websocket', async () => { - const { receivedBuffers, send, close } = await openWebSocket(`ws://${serverBaseUrl()}/exec/${execId}/start`) + const { receivedBuffers, send, close } = await openWebSocket(`ws://${serverBaseUrl()}/container/${containerId}/exec`) await waitForExpect(() => expect(receivedBuffers.length).toBeGreaterThan(0)) await send('ls \n') await waitForExpect(() => { @@ -186,12 +170,8 @@ describe('docker proxy', () => { }) describe('tty=false', () => { - beforeAll(async () => { - execId = await createExec(containerId, false) - }) - it('should communicate via websocket', async () => { - const { receivedBuffers, send, close } = await openWebSocket(`ws://${serverBaseUrl()}/exec/${execId}/start`) + const { receivedBuffers, send, close } = await openWebSocket(`ws://${serverBaseUrl()}/container/${containerId}/exec`) await waitForExpect(async () => { await send('ls\n') const received = Buffer.concat(receivedBuffers).toString('utf-8') @@ -214,7 +194,7 @@ describe('docker proxy', () => { const testStream = (...s: LogStream[]) => { describe(`${s.join(' and ')}`, () => { it(`should show the ${s.join(' and ')} logs via websocket`, async () => { - const { receivedBuffers, close } = await openWebSocket(`ws://${serverBaseUrl()}/containers/${containerId}/logs?${s.map(st => `${st}=true`).join('&')}`) + const { receivedBuffers, close } = await openWebSocket(`ws://${serverBaseUrl()}/container/${containerId}/logs?${s.map(st => `${st}=true`).join('&')}`) await waitForExpect(() => expect(receivedBuffers.length).toBeGreaterThan(0)) const length1 = receivedBuffers.length await waitForExpect(() => { @@ -240,7 +220,7 @@ describe('docker proxy', () => { describe('timestamps', () => { it('should show the logs with a timestamp', async () => { - const { receivedBuffers, close } = await openWebSocket(`ws://${serverBaseUrl()}/containers/${containerId}/logs?stdout=true×tamps=true`) + const { receivedBuffers, close } = await openWebSocket(`ws://${serverBaseUrl()}/container/${containerId}/logs?stdout=true×tamps=true`) await waitForExpect(() => expect(receivedBuffers.length).toBeGreaterThan(0)) const received = Buffer.concat(receivedBuffers).toString('utf-8') expect(received).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d*Z/) diff --git a/packages/compose-tunnel-agent/src/api-server/index.ts b/packages/compose-tunnel-agent/src/api-server/index.ts new file mode 100644 index 00000000..9d17ad52 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/index.ts @@ -0,0 +1,104 @@ +import fs from 'node:fs' +import url from 'node:url' +import { WebSocketServer } from 'ws' +import { Logger } from 'pino' +import Dockerode from 'dockerode' +import { SshState } from '../ssh' +import { NotFoundError, respond, respondAccordingToAccept, respondJson, tryHandler, tryUpgradeHandler, tryWsHandler } from './http-server-helpers' +import { DockerFilterClient } from '../docker' +import { findHandler as findWsHandler, handlers as wsHandlers } from './ws' +import { ContainerNotFoundError, MissingContainerIdError } from './errors' + +// const pathRe = /^\/(?[^/]+)(\/(?[^/]+)(?\/[^/]+)?)?$/ + +const createApiServerHandlers = ({ + log, + currentSshState, + machineStatus, + envMetadata, + composeModelPath, + dockerFilter, + docker, +}: { + log: Logger + currentSshState: () => Promise + machineStatus?: () => Promise<{ data: Buffer; contentType: string }> + envMetadata?: Record + composeModelPath: string + dockerFilter: DockerFilterClient + docker: Dockerode +}) => { + const handler = tryHandler({ log }, async (req, res) => { + const { pathname: path } = url.parse(req.url || '') + + if (req.method === 'GET' && path === '/healthz') { + respondAccordingToAccept(req, res, 'OK') + return + } + + if (req.method === 'GET' && path === '/tunnels') { + respondJson(res, await currentSshState()) + return + } + + if (req.method === 'GET' && path === '/machine-status' && machineStatus) { + const { data, contentType } = await machineStatus() + respond(res, data, contentType) + return + } + + if (req.method === 'GET' && path === '/metadata' && envMetadata) { + respondJson(res, envMetadata) + return + } + + if (req.method === 'GET' && path === '/compose-model') { + respond(res, await fs.promises.readFile(composeModelPath, { encoding: 'utf-8' }), 'application/x-yaml') + return + } + + if (req.method === 'GET' && path === '/containers') { + respondJson(res, await dockerFilter.listContainers()) + return + } + + if (req.method === 'GET' && path?.startsWith('/container/')) { + const containerId = path.substring('/container/'.length) + if (!containerId) { + throw new MissingContainerIdError() + } + const container = await dockerFilter.inspectContainer(containerId) + if (!container) { + throw new ContainerNotFoundError(containerId) + } + respondJson(res, container) + return + } + + throw new NotFoundError() + }) + + const wss = new WebSocketServer({ noServer: true }) + + wss.on('connection', tryWsHandler({ log }, async (ws, req) => { + const foundHandler = findWsHandler(wsHandlers, req) + if (!foundHandler) { + throw new NotFoundError() + } + await foundHandler.handler.handler(ws, req, foundHandler.match, { log, docker, dockerFilter }) + })) + + const upgradeHandler = tryUpgradeHandler({ log }, async (req, socket, head) => { + if (req.headers.upgrade?.toLowerCase() !== 'websocket') { + throw new NotFoundError() + } + + wss.handleUpgrade(req, socket, head, client => { + wss.emit('connection', client, req) + }) + }) + + return { handler, upgradeHandler } +} + +export default createApiServerHandlers diff --git a/packages/compose-tunnel-agent/src/http/query-params.ts b/packages/compose-tunnel-agent/src/api-server/query-params.ts similarity index 85% rename from packages/compose-tunnel-agent/src/http/query-params.ts rename to packages/compose-tunnel-agent/src/api-server/query-params.ts index 8edb39df..efe9a0c5 100644 --- a/packages/compose-tunnel-agent/src/http/query-params.ts +++ b/packages/compose-tunnel-agent/src/api-server/query-params.ts @@ -6,7 +6,7 @@ T extends Record >(requestUrl: string, defaultValues: Partial = {}) => { const { search } = url.parse(requestUrl) const queryParams = new URLSearchParams(search || '') - return defaults(Object.fromEntries(queryParams), defaultValues) + return { search: queryParams, obj: defaults(Object.fromEntries(queryParams), defaultValues) } } export const queryParamBoolean = (v: string | boolean | undefined, defaultValue = false): boolean => { diff --git a/packages/compose-tunnel-agent/src/http/docker-proxy/ws/handler.ts b/packages/compose-tunnel-agent/src/api-server/ws/handler.ts similarity index 81% rename from packages/compose-tunnel-agent/src/http/docker-proxy/ws/handler.ts rename to packages/compose-tunnel-agent/src/api-server/ws/handler.ts index d3d3a845..7f5aa9b9 100644 --- a/packages/compose-tunnel-agent/src/http/docker-proxy/ws/handler.ts +++ b/packages/compose-tunnel-agent/src/api-server/ws/handler.ts @@ -2,13 +2,14 @@ import http from 'node:http' import { Logger } from 'pino' import WebSocket from 'ws' import Dockerode from 'dockerode' +import { DockerFilterClient } from '../../docker' -type Context = { log: Logger; docker: Dockerode } +type Context = { log: Logger; docker: Dockerode; dockerFilter: DockerFilterClient } export type WsHandlerFunc = ( ws: WebSocket, req: http.IncomingMessage, match: RegExpMatchArray, - { log, docker }: Context, + context: Context, ) => Promise export type WsHandler = { diff --git a/packages/compose-tunnel-agent/src/api-server/ws/handlers/exec.ts b/packages/compose-tunnel-agent/src/api-server/ws/handlers/exec.ts new file mode 100644 index 00000000..6ab0a305 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/ws/handlers/exec.ts @@ -0,0 +1,62 @@ +import { inspect } from 'util' +import { createWebSocketStream } from 'ws' +import { parseQueryParams, queryParamBoolean } from '../../query-params' +import { wsHandler } from '../handler' +import { NotFoundError } from '../../http-server-helpers' + +const handler = wsHandler( + /^\/container\/([^/?]+)\/exec($|\?)/, + async (ws, req, match, { log, docker, dockerFilter }) => { + const containerId = match[1] + if (!await dockerFilter.inspectContainer(containerId)) { + throw new NotFoundError() + } + const { obj: { tty: ttyQueryParam }, search } = parseQueryParams(req.url ?? '', { tty: true }) + const cmdQueryParams = search.getAll('cmd') + const cmd = cmdQueryParams.length ? cmdQueryParams : ['sh'] + + const tty = queryParamBoolean(ttyQueryParam) + const abort = new AbortController() + const exec = await docker.getContainer(containerId).exec({ + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Cmd: cmd, + Tty: tty, + abortSignal: abort.signal, + }) + + const execStream = await exec.start({ + hijack: true, + stdin: true, + Tty: tty, + }) + + execStream.on('close', () => { ws.close() }) + execStream.on('error', err => { log.warn('execStream error %j', inspect(err)) }) + ws.on('close', () => { + abort.abort() + execStream.destroy() + }) + + const inspectResults = await exec.inspect() + log.debug('exec %s: %j', containerId, inspect(inspectResults)) + + const wsStream = createWebSocketStream(ws) + wsStream.on('error', err => { + const level = err.message === 'aborted' || err.message.includes('WebSocket is not open') ? 'debug' : 'warn' + log[level]('wsStream error %j', inspect(err)) + }) + + if (tty) { + execStream.pipe(wsStream, { end: false }).pipe(execStream) + } else { + docker.modem.demuxStream(execStream, wsStream, wsStream) + wsStream.pipe(execStream) + } + + return undefined + }, +) + +export default handler diff --git a/packages/compose-tunnel-agent/src/http/docker-proxy/ws/handlers/logs.ts b/packages/compose-tunnel-agent/src/api-server/ws/handlers/logs.ts similarity index 75% rename from packages/compose-tunnel-agent/src/http/docker-proxy/ws/handlers/logs.ts rename to packages/compose-tunnel-agent/src/api-server/ws/handlers/logs.ts index ba96e6ae..a17120fd 100644 --- a/packages/compose-tunnel-agent/src/http/docker-proxy/ws/handlers/logs.ts +++ b/packages/compose-tunnel-agent/src/api-server/ws/handlers/logs.ts @@ -1,13 +1,17 @@ import { inspect } from 'util' import { createWebSocketStream } from 'ws' -import { parseQueryParams, queryParamBoolean } from '../../../query-params' +import { parseQueryParams, queryParamBoolean } from '../../query-params' import { wsHandler } from '../handler' +import { NotFoundError } from '../../http-server-helpers' const handler = wsHandler( - /^\/containers\/([^/?]+)\/logs($|\?)/, - async (ws, req, match, { log, docker }) => { + /^\/container\/([^/?]+)\/logs($|\?)/, + async (ws, req, match, { log, docker, dockerFilter }) => { const id = match[1] - const { stdout, stderr, since, until, timestamps, tail } = parseQueryParams(req.url ?? '') + if (!await dockerFilter.inspectContainer(id)) { + throw new NotFoundError() + } + const { stdout, stderr, since, until, timestamps, tail } = parseQueryParams(req.url ?? '').obj const abort = new AbortController() const logStream = await docker.getContainer(id).logs({ stdout: queryParamBoolean(stdout), diff --git a/packages/compose-tunnel-agent/src/http/docker-proxy/ws/index.ts b/packages/compose-tunnel-agent/src/api-server/ws/index.ts similarity index 100% rename from packages/compose-tunnel-agent/src/http/docker-proxy/ws/index.ts rename to packages/compose-tunnel-agent/src/api-server/ws/index.ts diff --git a/packages/compose-tunnel-agent/src/docker.ts b/packages/compose-tunnel-agent/src/docker.ts deleted file mode 100644 index 0b959e1e..00000000 --- a/packages/compose-tunnel-agent/src/docker.ts +++ /dev/null @@ -1,81 +0,0 @@ -import Docker from 'dockerode' -import { tryParseJson, Logger } from '@preevy/common' -import { throttle } from 'lodash' - -const targetComposeProject = process.env.COMPOSE_PROJECT -const defaultAccess = process.env.DEFAULT_ACCESS_LEVEL === 'private' ? 'private' : 'public' - -const composeFilter = { - label: targetComposeProject ? [`com.docker.compose.project=${targetComposeProject}`] : ['com.docker.compose.project'], -} - -export type RunningService = { - project: string - name: string - networks: string[] - ports: number[] - access: 'private' | 'public' -} - -const client = ({ - log, - docker, - debounceWait, -}: { - log: Logger - docker: Pick - debounceWait: number -}) => { - const getRunningServices = async (): Promise => ( - await docker.listContainers({ - all: true, - filters: { - ...composeFilter, - }, - }) - ).map(x => { - let portFilter : (p: Docker.Port)=> boolean - if (x.Labels['preevy.expose']) { - const exposedPorts = new Set((x.Labels['preevy.expose']).split(',').map(n => parseInt(n, 10)).filter(n => !Number.isNaN(n))) - portFilter = p => exposedPorts.has(p.PrivatePort) - } else { - portFilter = p => !!p.PublicPort - } - - return ({ - project: x.Labels['com.docker.compose.project'], - name: x.Labels['com.docker.compose.service'], - access: (x.Labels['preevy.access'] || defaultAccess) as ('private' | 'public'), - networks: Object.keys(x.NetworkSettings.Networks), - // ports may have both IPv6 and IPv4 addresses, ignoring - ports: [...new Set(x.Ports.filter(p => p.Type === 'tcp' && portFilter(p)).map(p => p.PrivatePort))], - }) - }) - - return { - getRunningServices, - startListening: async ({ onChange }: { onChange: (services: RunningService[]) => void }) => { - const handler = throttle(async (data?: Buffer) => { - log.debug('event handler: %j', data && tryParseJson(data.toString())) - - const services = await getRunningServices() - onChange(services) - }, debounceWait, { leading: true, trailing: true }) - - const stream = await docker.getEvents({ - filters: { - ...composeFilter, - event: ['start', 'stop', 'pause', 'unpause', 'create', 'destroy', 'rename', 'update'], - type: ['container'], - }, - since: 0, - }) - stream.on('data', handler) - log.info('listening on docker') - void handler() - return { close: () => stream.removeAllListeners() } - }, - } -} - -export default client diff --git a/packages/compose-tunnel-agent/src/docker/events-client.ts b/packages/compose-tunnel-agent/src/docker/events-client.ts new file mode 100644 index 00000000..59face8a --- /dev/null +++ b/packages/compose-tunnel-agent/src/docker/events-client.ts @@ -0,0 +1,66 @@ +import Docker from 'dockerode' +import { tryParseJson, Logger, COMPOSE_TUNNEL_AGENT_SERVICE_LABELS } from '@preevy/common' +import { throttle } from 'lodash' +import { filters, portFilter } from './filters' +import { COMPOSE_PROJECT_LABEL, COMPOSE_SERVICE_LABEL } from './labels' + +export type RunningService = { + project: string + name: string + networks: string[] + ports: number[] + access: 'private' | 'public' +} + +export const eventsClient = ({ + log, + docker, + debounceWait, + composeProject, + defaultAccess, +}: { + log: Logger + docker: Pick + debounceWait: number + composeProject?: string + defaultAccess: 'private' | 'public' +}) => { + const { listContainers, apiFilter } = filters({ docker, composeProject }) + + const containerToService = (c: Docker.ContainerInfo) => ({ + project: c.Labels[COMPOSE_PROJECT_LABEL], + name: c.Labels[COMPOSE_SERVICE_LABEL], + access: (c.Labels[COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.ACCESS] || defaultAccess) as ('private' | 'public'), + networks: Object.keys(c.NetworkSettings.Networks), + // ports may have both IPv6 and IPv4 addresses, ignoring + ports: [...new Set(c.Ports.filter(p => p.Type === 'tcp').filter(portFilter(c)).map(p => p.PrivatePort))], + }) + + const getRunningServices = async (): Promise => (await listContainers()).map(containerToService) + + const startListening = async ({ onChange }: { onChange: (services: RunningService[]) => void }) => { + const handler = throttle(async (data?: Buffer) => { + log.debug('event handler: %j', data && tryParseJson(data.toString())) + + const services = await getRunningServices() + onChange(services) + }, debounceWait, { leading: true, trailing: true }) + + const stream = await docker.getEvents({ + filters: { + ...apiFilter, + event: ['start', 'stop', 'pause', 'unpause', 'create', 'destroy', 'rename', 'update'], + type: ['container'], + }, + since: 0, + }) + stream.on('data', handler) + log.info('listening on docker') + void handler() + return { close: () => stream.removeAllListeners() } + } + + return { getRunningServices, startListening } +} + +export type DockerEventsClient = ReturnType diff --git a/packages/compose-tunnel-agent/src/docker/filtered-client.ts b/packages/compose-tunnel-agent/src/docker/filtered-client.ts new file mode 100644 index 00000000..730f6825 --- /dev/null +++ b/packages/compose-tunnel-agent/src/docker/filtered-client.ts @@ -0,0 +1,21 @@ +import Docker from 'dockerode' +import { filters } from './filters' + +export const filteredClient = ({ + docker, + composeProject, +}: { + docker: Pick + composeProject?: string +}) => { + const { listContainers, adhocFilter } = filters({ docker, composeProject }) + + const inspectContainer = async (id: string) => { + const result = await docker.getContainer(id).inspect() + return result && adhocFilter(result.Config) ? result : undefined + } + + return { listContainers, inspectContainer } +} + +export type DockerFilterClient = ReturnType diff --git a/packages/compose-tunnel-agent/src/docker/filters.ts b/packages/compose-tunnel-agent/src/docker/filters.ts new file mode 100644 index 00000000..0c871d5a --- /dev/null +++ b/packages/compose-tunnel-agent/src/docker/filters.ts @@ -0,0 +1,48 @@ +import Docker from 'dockerode' +import { COMPOSE_TUNNEL_AGENT_SERVICE_LABELS } from '@preevy/common' +import { COMPOSE_PROJECT_LABEL } from './labels' + +export type RunningService = { + project: string + name: string + networks: string[] + ports: number[] + access: 'private' | 'public' +} + +const parseExposeLabel = (s: string) => new Set(s.split(',').map(Number).filter(n => !Number.isNaN(n))) + +export const portFilter = ( + { Labels: { [COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.EXPOSE]: exposeLabel } }: Pick, +) => { + if (exposeLabel) { + const ports = parseExposeLabel(exposeLabel) + return (p: Docker.Port) => ports.has(p.PrivatePort) + } + return (p: Docker.Port) => Boolean(p.PublicPort) +} + +export const filters = ({ + docker, + composeProject, +}: { + docker: Pick + composeProject?: string +}) => { + const apiFilter = { + label: composeProject ? [`${COMPOSE_PROJECT_LABEL}=${composeProject}`] : [COMPOSE_PROJECT_LABEL], + } + + const listContainers = async () => await docker.listContainers({ + all: true, + filters: { ...apiFilter }, + }) + + const adhocFilter = (c: Pick): boolean => ( + composeProject + ? c.Labels[COMPOSE_PROJECT_LABEL] === composeProject + : COMPOSE_PROJECT_LABEL in c.Labels + ) + + return { listContainers, adhocFilter, apiFilter } +} diff --git a/packages/compose-tunnel-agent/src/docker/index.ts b/packages/compose-tunnel-agent/src/docker/index.ts new file mode 100644 index 00000000..cedb8df3 --- /dev/null +++ b/packages/compose-tunnel-agent/src/docker/index.ts @@ -0,0 +1,2 @@ +export * from './events-client' +export * from './filtered-client' diff --git a/packages/compose-tunnel-agent/src/docker/labels.ts b/packages/compose-tunnel-agent/src/docker/labels.ts new file mode 100644 index 00000000..d5fd7a76 --- /dev/null +++ b/packages/compose-tunnel-agent/src/docker/labels.ts @@ -0,0 +1,2 @@ +export const COMPOSE_PROJECT_LABEL = 'com.docker.compose.project' +export const COMPOSE_SERVICE_LABEL = 'com.docker.compose.service' diff --git a/packages/compose-tunnel-agent/src/http/api-server.ts b/packages/compose-tunnel-agent/src/http/api-server.ts deleted file mode 100644 index 14525da0..00000000 --- a/packages/compose-tunnel-agent/src/http/api-server.ts +++ /dev/null @@ -1,48 +0,0 @@ -import fs from 'node:fs' -import url from 'node:url' -import { Logger } from '@preevy/common' -import { SshState } from '../ssh' -import { NotFoundError, respondAccordingToAccept, respondJson, tryHandler } from './http-server-helpers' - -const createApiServerHandler = ({ log, currentSshState, machineStatus, envMetadata, composeModelPath }: { - log: Logger - currentSshState: () => Promise - machineStatus?: () => Promise<{ data: Buffer; contentType: string }> - envMetadata?: Record - composeModelPath: string -}) => tryHandler({ log }, async (req, res) => { - const { pathname: path } = url.parse(req.url || '') - - if (path === '/healthz') { - respondAccordingToAccept(req, res, 'OK') - return - } - - if (path === '/tunnels') { - respondJson(res, await currentSshState()) - return - } - - if (req.method === 'GET' && path === '/machine-status' && machineStatus) { - const { data, contentType } = await machineStatus() - res.setHeader('Content-Type', contentType) - res.end(data) - return - } - - if (req.method === 'GET' && path === '/metadata' && envMetadata) { - res.setHeader('Content-Type', 'application/json') - res.end(JSON.stringify(envMetadata)) - return - } - - if (req.method === 'GET' && path === '/compose-model') { - res.setHeader('Content-Type', 'application/x-yaml') - res.end(await fs.promises.readFile(composeModelPath, { encoding: 'utf-8' })) - return - } - - throw new NotFoundError() -}) - -export default createApiServerHandler diff --git a/packages/compose-tunnel-agent/src/http/docker-proxy/index.ts b/packages/compose-tunnel-agent/src/http/docker-proxy/index.ts deleted file mode 100644 index b6e5a554..00000000 --- a/packages/compose-tunnel-agent/src/http/docker-proxy/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import net from 'node:net' -import HttpProxy from 'http-proxy' -import { Logger } from 'pino' -import { inspect } from 'node:util' -import { WebSocketServer } from 'ws' -import Dockerode from 'dockerode' -import { findHandler, handlers as wsHandlers } from './ws' -import { tryHandler, tryUpgradeHandler } from '../http-server-helpers' - -export const createDockerProxyHandlers = ( - { log, dockerSocket, docker }: { - log: Logger - dockerSocket: string - docker: Dockerode - }, -) => { - const proxy = new HttpProxy({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - target: { - socketPath: dockerSocket, - }, - }) - - const wss = new WebSocketServer({ noServer: true }) - - wss.on('connection', async (ws, req) => { - const foundHandler = findHandler(wsHandlers, req) - if (!foundHandler) { - ws.close(404, 'Not found') - return undefined - } - - await foundHandler.handler.handler(ws, req, foundHandler.match, { log, docker }) - return undefined - }) - - const handler = tryHandler({ log }, async (req, res) => { - proxy.web(req, res) - }) - - const upgradeHandler = tryUpgradeHandler({ log }, async (req, socket, head) => { - const upgrade = req.headers.upgrade?.toLowerCase() - - if (upgrade === 'websocket') { - if (findHandler(wsHandlers, req)) { - wss.handleUpgrade(req, socket, head, client => { - wss.emit('connection', client, req) - }) - return undefined - } - - proxy.ws(req, socket, head, {}, err => { - log.warn('error in ws proxy %j', inspect(err)) - }) - return undefined - } - - if (upgrade === 'tcp') { - const targetSocket = net.createConnection({ path: dockerSocket }, () => { - const reqBuf = `${req.method} ${req.url} HTTP/${req.httpVersion}\r\n${Object.entries(req.headers).map(([k, v]) => `${k}: ${v}`).join('\r\n')}\r\n\r\n` - targetSocket.write(reqBuf) - targetSocket.write(head) - socket.pipe(targetSocket).pipe(socket) - }) - return undefined - } - - log.warn('invalid upgrade %s', upgrade) - socket.end(`Invalid upgrade ${upgrade}`) - return undefined - }) - - return { handler, upgradeHandler } -} - -export type DockerProxyHandlers = ReturnType diff --git a/packages/compose-tunnel-agent/src/http/docker-proxy/ws/handlers/exec.ts b/packages/compose-tunnel-agent/src/http/docker-proxy/ws/handlers/exec.ts deleted file mode 100644 index 0aae4705..00000000 --- a/packages/compose-tunnel-agent/src/http/docker-proxy/ws/handlers/exec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { inspect } from 'util' -import { createWebSocketStream } from 'ws' -import { parseQueryParams, queryParamBoolean } from '../../../query-params' -import { wsHandler } from '../handler' - -const handler = wsHandler( - /^\/exec\/([^/?]+)\/start($|\?)/, - async (ws, req, match, { log, docker }) => { - const id = match[1] - const { tty } = parseQueryParams(req.url ?? '') - const exec = docker.getExec(id) - const execStream = await exec.start({ - hijack: true, - stdin: true, - ...(tty !== undefined ? { Tty: queryParamBoolean(tty) } : {}), - }) - - execStream.on('close', () => { ws.close() }) - execStream.on('error', err => { log.warn('execStream error %j', inspect(err)) }) - ws.on('close', () => { execStream.destroy() }) - - const inspectResults = await exec.inspect() - log.debug('exec %s: %j', id, inspect(inspectResults)) - - const wsStream = createWebSocketStream(ws) - wsStream.on('error', err => { - const level = err.message.includes('WebSocket is not open') ? 'debug' : 'warn' - log[level]('wsStream error %j', inspect(err)) - }) - - if (inspectResults.ProcessConfig.tty) { - execStream.pipe(wsStream, { end: false }).pipe(execStream) - } else { - docker.modem.demuxStream(execStream, wsStream, wsStream) - wsStream.pipe(execStream) - } - - return undefined - }, -) - -export default handler diff --git a/packages/compose-tunnel-agent/src/http/index.ts b/packages/compose-tunnel-agent/src/http/index.ts deleted file mode 100644 index a7be7d99..00000000 --- a/packages/compose-tunnel-agent/src/http/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -import http from 'node:http' -import pino from 'pino' -import { NotFoundError, tryHandler, tryUpgradeHandler } from './http-server-helpers' -import { DockerProxyHandlers } from './docker-proxy' - -export const httpServerHandlers = ( - { log, dockerProxyHandlers, apiHandler, dockerProxyPrefix }: { - log: pino.Logger - dockerProxyHandlers: DockerProxyHandlers - dockerProxyPrefix: `/${string}/` - apiHandler: http.RequestListener - } -) => { - const removeDockerPrefix = (s: string) => `/${s.substring(dockerProxyPrefix.length)}` - - const handler = tryHandler({ log }, async (req, res) => { - log.debug('request %s %s', req.method, req.url) - if (req.url?.startsWith(dockerProxyPrefix)) { - req.url = removeDockerPrefix(req.url) - return await dockerProxyHandlers.handler(req, res) - } - return apiHandler(req, res) - }) - - const upgradeHandler = tryUpgradeHandler({ log }, async (req, socket, head) => { - log.debug('upgrade %s %s', req.method, req.url) - if (req.url?.startsWith(dockerProxyPrefix)) { - req.url = removeDockerPrefix(req.url) - return await dockerProxyHandlers.upgradeHandler(req, socket, head) - } - throw new NotFoundError() - }) - - return { handler, upgradeHandler } -} diff --git a/packages/core/src/commands/proxy.ts b/packages/core/src/commands/proxy.ts index b9c2a7f0..04f46f68 100644 --- a/packages/core/src/commands/proxy.ts +++ b/packages/core/src/commands/proxy.ts @@ -1,11 +1,10 @@ -import { tunnelNameResolver } from '@preevy/common' +import { COMPOSE_TUNNEL_AGENT_PORT, COMPOSE_TUNNEL_AGENT_SERVICE_NAME, tunnelNameResolver } from '@preevy/common' import { mkdtemp, writeFile } from 'node:fs/promises' import path from 'node:path' import { tmpdir } from 'node:os' -import { set } from 'lodash' import { Connection } from '../tunneling' import { execPromiseStdout } from '../child-process' -import { COMPOSE_TUNNEL_AGENT_SERVICE_NAME, COMPOSE_TUNNEL_AGENT_PORT, addComposeTunnelAgentService } from '../compose-tunnel-agent-client' +import { addComposeTunnelAgentService } from '../compose-tunnel-agent-client' import { ComposeModel } from '../compose' import { TunnelOpts } from '../ssh' import { EnvId } from '../env-id' @@ -62,29 +61,34 @@ export function initProxyComposeModel(opts: { networks: ComposeModel['networks'] version: string }) { - const compose = { + const compose: ComposeModel = { version: '3.8', name: opts.projectName, networks: opts.networks, } + const privateMode = Boolean(opts.privateMode) + const envMetadata = { + id: opts.envId, + lastDeployTime: new Date(), + version: opts.version, + profileThumbprint: opts.tunnelingKeyThumbprint, + } + const newComposeModel = addComposeTunnelAgentService({ tunnelOpts: opts.tunnelOpts, envId: opts.envId, debug: true, composeModelPath: './docker-compose.yml', - envMetadata: { id: opts.envId, lastDeployTime: new Date(), version: opts.version }, + envMetadata, knownServerPublicKeyPath: './tunnel_server_public_key', sshPrivateKeyPath: './tunneling_key', + composeProject: opts.projectName, + profileThumbprint: opts.tunnelingKeyThumbprint, + privateMode, + defaultAccess: privateMode ? 'private' : 'public', }, compose) - set(newComposeModel, ['services', agentServiceName, 'environment', 'COMPOSE_PROJECT'], opts.projectName) - set(newComposeModel, ['services', agentServiceName, 'labels', 'preevy.profile_thumbprint'], opts.tunnelingKeyThumbprint) - if (opts.privateMode) { - set(newComposeModel, ['services', agentServiceName, 'environment', 'DEFAULT_ACCESS_LEVEL'], 'private') - set(newComposeModel, ['services', agentServiceName, 'labels', 'preevy.private_mode'], 'true') - } - return { data: newComposeModel, async write({ tunnelingKey, knownServerPublicKey } : diff --git a/packages/core/src/commands/up/index.ts b/packages/core/src/commands/up/index.ts index b0c8d56d..27ccc386 100644 --- a/packages/core/src/commands/up/index.ts +++ b/packages/core/src/commands/up/index.ts @@ -1,4 +1,4 @@ -import { formatPublicKey, readOrUndefined } from '@preevy/common' +import { COMPOSE_TUNNEL_AGENT_SERVICE_NAME, formatPublicKey, readOrUndefined } from '@preevy/common' import fs from 'fs' import path from 'path' import { rimraf } from 'rimraf' @@ -7,7 +7,7 @@ import { TunnelOpts } from '../../ssh' import { composeModelFilename, fixModelForRemote, localComposeClient, resolveComposeFiles } from '../../compose' import { ensureCustomizedMachine } from './machine' import { wrapWithDockerSocket } from '../../docker' -import { COMPOSE_TUNNEL_AGENT_SERVICE_NAME, addComposeTunnelAgentService } from '../../compose-tunnel-agent-client' +import { addComposeTunnelAgentService } from '../../compose-tunnel-agent-client' import { MachineCreationDriver, MachineDriver, MachineBase } from '../../driver' import { remoteProjectDir } from '../../remote-files' import { Logger } from '../../log' @@ -146,6 +146,9 @@ const up = async ({ machineStatusCommand: await machineDriver.machineStatusCommand(machine), envMetadata: await envMetadata({ envId, version }), composeModelPath: path.join(remoteDir, composeModelFilename), + privateMode: false, + defaultAccess: 'public', + composeProject: projectName, }, fixedModel) const modelStr = yaml.stringify(remoteModel) diff --git a/packages/core/src/commands/urls.ts b/packages/core/src/commands/urls.ts index 8499903b..f9b4c901 100644 --- a/packages/core/src/commands/urls.ts +++ b/packages/core/src/commands/urls.ts @@ -1,6 +1,7 @@ import retry from 'p-retry' +import { COMPOSE_TUNNEL_AGENT_SERVICE_NAME } from '@preevy/common' import { generateBasicAuthCredentials, jwtGenerator } from '../credentials' -import { COMPOSE_TUNNEL_AGENT_SERVICE_NAME, queryTunnels } from '../compose-tunnel-agent-client' +import { queryTunnels } from '../compose-tunnel-agent-client' import { FlatTunnel, flattenTunnels } from '../tunneling' const tunnelFilter = ({ serviceAndPort, showPreevyService }: { diff --git a/packages/core/src/compose-tunnel-agent-client.ts b/packages/core/src/compose-tunnel-agent-client.ts index 11e99adf..a83e3ba4 100644 --- a/packages/core/src/compose-tunnel-agent-client.ts +++ b/packages/core/src/compose-tunnel-agent-client.ts @@ -3,7 +3,7 @@ import fetch from 'node-fetch' import retry from 'p-retry' import util from 'util' import { mapValues, merge } from 'lodash' -import { MachineStatusCommand, dateReplacer } from '@preevy/common' +import { COMPOSE_TUNNEL_AGENT_PORT, COMPOSE_TUNNEL_AGENT_SERVICE_LABELS, COMPOSE_TUNNEL_AGENT_SERVICE_NAME, MachineStatusCommand, dateReplacer } from '@preevy/common' import { ComposeModel, ComposeService, composeModelFilename } from './compose/model' import { TunnelOpts } from './ssh/url' import { Tunnel } from './tunneling' @@ -13,8 +13,6 @@ import { REMOTE_DIR_BASE } from './remote-files' import { isPacked, pkgSnapshotDir } from './pkg' import { EnvId } from './env-id' -export const COMPOSE_TUNNEL_AGENT_SERVICE_NAME = 'preevy_proxy' -export const COMPOSE_TUNNEL_AGENT_PORT = 3000 const COMPOSE_TUNNEL_AGENT_DIR = path.join(path.dirname(require.resolve('@preevy/compose-tunnel-agent')), '..') const baseDockerProxyService = () => { @@ -32,7 +30,7 @@ const baseDockerProxyService = () => { }, ], labels: { - 'preevy.access': 'private', + [COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.ACCESS]: 'private', }, } as ComposeService } @@ -60,6 +58,10 @@ export const addComposeTunnelAgentService = ( machineStatusCommand, envMetadata, composeModelPath, + composeProject, + profileThumbprint, + privateMode, + defaultAccess, }: { tunnelOpts: TunnelOpts sshPrivateKeyPath: string @@ -70,6 +72,10 @@ export const addComposeTunnelAgentService = ( machineStatusCommand?: MachineStatusCommand envMetadata: EnvMetadata composeModelPath: string + composeProject: string + profileThumbprint?: string + privateMode: boolean + defaultAccess: 'private' | 'public' }, model: ComposeModel, ): ComposeModel => ({ @@ -117,7 +123,10 @@ export const addComposeTunnelAgentService = ( ], user, labels: { - 'preevy.env_id': envId, + [COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.ENV_ID]: envId, + [COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.PRIVATE_MODE]: privateMode, + ...profileThumbprint ? { [COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.PROFILE_THUMBPRINT]: profileThumbprint } : {}, + [COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.PRIVATE_MODE]: privateMode.toString(), }, environment: { SSH_URL: tunnelOpts.url, @@ -129,6 +138,8 @@ export const addComposeTunnelAgentService = ( ENV_METADATA_FILES: `${metadataDirectory}/${driverMetadataFilename}`, ...debug ? { DEBUG: '1' } : {}, HOME: '/preevy', + COMPOSE_PROJECT: composeProject, + DEFAULT_ACCESS_LEVEL: defaultAccess, }, }), }, diff --git a/packages/core/src/compose/model.ts b/packages/core/src/compose/model.ts index 8c652d6a..d1acd317 100644 --- a/packages/core/src/compose/model.ts +++ b/packages/core/src/compose/model.ts @@ -56,6 +56,7 @@ export type ComposeService = { export type ComposeModel = { name: string + version?: string secrets?: Record configs?: Record services?: Record @@ -75,7 +76,7 @@ export const fixModelForRemote = async ( remoteBaseDir: string }, model: ComposeModel, -): Promise<{ model: Required>; filesToCopy: FileToCopy[] }> => { +): Promise<{ model: Required>; filesToCopy: FileToCopy[] }> => { const filesToCopy: FileToCopy[] = [] const remotePath = (absolutePath: string) => { diff --git a/packages/core/src/env-metadata.ts b/packages/core/src/env-metadata.ts index 6775f1f6..f528f826 100644 --- a/packages/core/src/env-metadata.ts +++ b/packages/core/src/env-metadata.ts @@ -28,6 +28,7 @@ export type EnvMetadata = { machine?: EnvMachineMetadata lastDeployTime: Date version: string + profileThumbprint?: string } const detectGitMetadata = async (): Promise => { @@ -48,9 +49,14 @@ const detectGitMetadata = async (): Promise => { } } -export const envMetadata = async ({ envId, version }: { envId: string; version: string }): Promise> => ({ +export const envMetadata = async ({ + envId, + version, + profileThumbprint, +}: { envId: string; version: string; profileThumbprint?: string }): Promise> => ({ id: envId, git: await detectGitMetadata(), lastDeployTime: new Date(), version, + profileThumbprint, }) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3cf93dc3..bd2044bd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,7 +40,6 @@ export { CommandError, CommandExecuter, checkResult, commandWith, execResultFromOrderedOutput, ExecResult, } from './command-executer' export { - COMPOSE_TUNNEL_AGENT_SERVICE_NAME, addBaseComposeTunnelAgentService, queryTunnels, findComposeTunnelAgentUrl, From 7cf16f28ddc52967d3b4711d6af59dd2fc4954b9 Mon Sep 17 00:00:00 2001 From: Roy Razon Date: Tue, 5 Sep 2023 09:59:07 +0300 Subject: [PATCH 2/3] fastify wip --- packages/compose-tunnel-agent/package.json | 2 + tunnel-server/package.json | 4 +- tunnel-server/yarn.lock | 155 ++++++-------- yarn.lock | 235 ++++++++++++++++++++- 4 files changed, 294 insertions(+), 102 deletions(-) diff --git a/packages/compose-tunnel-agent/package.json b/packages/compose-tunnel-agent/package.json index 9537733a..0f2b8ebe 100644 --- a/packages/compose-tunnel-agent/package.json +++ b/packages/compose-tunnel-agent/package.json @@ -12,8 +12,10 @@ }, "license": "Apache-2.0", "dependencies": { + "@fastify/request-context": "^5.0.0", "@preevy/common": "0.0.50", "dockerode": "^3.3.4", + "fastify": "^4.22.2", "http-proxy": "^1.18.1", "lodash": "^4.17.21", "p-limit": "^3.1.0", diff --git a/tunnel-server/package.json b/tunnel-server/package.json index 0663d484..7ee4a045 100644 --- a/tunnel-server/package.json +++ b/tunnel-server/package.json @@ -5,9 +5,9 @@ "type": "module", "license": "Apache-2.0", "dependencies": { - "@fastify/request-context": "^4.2.0", + "@fastify/request-context": "^5.0.0", "cookies": "^0.8.0", - "fastify": "^4.12.0", + "fastify": "^4.22.2", "http-proxy": "^1.18.1", "jose": "^4.14.4", "lodash": "^4.17.21", diff --git a/tunnel-server/yarn.lock b/tunnel-server/yarn.lock index efd15ed5..52645663 100644 --- a/tunnel-server/yarn.lock +++ b/tunnel-server/yarn.lock @@ -336,7 +336,7 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.39.0.tgz#58b536bcc843f4cd1e02a7e6171da5c040f4d44b" integrity sha512-kf9RB0Fg7NZfap83B3QOqOGg9QmD9yBudqQXzzOtn3i4y7ZUXe5ONeW34Gwi+TxhH4mvj72R1Zc300KUMa9Bng== -"@fastify/ajv-compiler@^3.3.1": +"@fastify/ajv-compiler@^3.5.0": version "3.5.0" resolved "https://registry.yarnpkg.com/@fastify/ajv-compiler/-/ajv-compiler-3.5.0.tgz#459bff00fefbf86c96ec30e62e933d2379e46670" integrity sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA== @@ -350,24 +350,23 @@ resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a" integrity sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A== -"@fastify/error@^3.0.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@fastify/error/-/error-3.2.0.tgz#9010e0acfe07965f5fc7d2b367f58f042d0f4106" - integrity sha512-KAfcLa+CnknwVi5fWogrLXgidLic+GXnLjijXdpl8pvkvbXU5BGa37iZO9FGvsh9ZL4y+oFi5cbHBm5UOG+dmQ== +"@fastify/error@^3.2.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@fastify/error/-/error-3.3.0.tgz#eba790082e1144bfc8def0c2c8ef350064bc537b" + integrity sha512-dj7vjIn1Ar8sVXj2yAXiMNCJDmS9MQ9XMlIecX2dIzzhjSHCyKo4DdXjXMs7wKW2kj6yvVRSpuQjOZ3YLrh56w== -"@fastify/fast-json-stringify-compiler@^4.1.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.2.0.tgz#52d047fac76b0d75bd660f04a5dd606659f57c5a" - integrity sha512-ypZynRvXA3dibfPykQN3RB5wBdEUgSGgny8Qc6k163wYPLD4mEGEDkACp+00YmqkGvIm8D/xYoHajwyEdWD/eg== +"@fastify/fast-json-stringify-compiler@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz#5df89fa4d1592cbb8780f78998355feb471646d5" + integrity sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA== dependencies: - fast-json-stringify "^5.0.0" + fast-json-stringify "^5.7.0" -"@fastify/request-context@^4.2.0": - version "4.2.0" - resolved "https://registry.yarnpkg.com/@fastify/request-context/-/request-context-4.2.0.tgz#f9e3904c45dcc42e56467572be8a96f85daae68e" - integrity sha512-jmUtCEYrSgcgnqMzK1cpN7FbofAlpIFSgppOjCaxhVxWhPsluaq8MktznMQa8nU2dJUQYGUA/S0REGKFHeiQIw== +"@fastify/request-context@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@fastify/request-context/-/request-context-5.0.0.tgz#f821c98ff5a930da9d26b2dce831420d86f5db14" + integrity sha512-HEJoAF5+28PO9kcX+A7vFY0vv45k4Fllzp7rzDyaZ9Lcz99YQmMsXVS1GWkhy+24jv5SWNyyZiiJklSa6BiFPA== dependencies: - asynchronous-local-storage "^1.0.2" fastify-plugin "^4.0.0" "@humanwhocodes/config-array@^0.11.8": @@ -1130,29 +1129,15 @@ asn1@^0.2.6: dependencies: safer-buffer "~2.1.0" -async-hook-jl@^1.7.6: - version "1.7.6" - resolved "https://registry.yarnpkg.com/async-hook-jl/-/async-hook-jl-1.7.6.tgz#4fd25c2f864dbaf279c610d73bf97b1b28595e68" - integrity sha512-gFaHkFfSxTjvoxDMYqDuGHlcRyUuamF8s+ZTtJdDzqjws4mCt7v0vuV79/E2Wr2/riMQgtG4/yUtXWs1gZ7JMg== - dependencies: - stack-chain "^1.3.7" - -asynchronous-local-storage@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/asynchronous-local-storage/-/asynchronous-local-storage-1.0.2.tgz#df2491534707566e039e2508a019b6e348ef49cc" - integrity sha512-VEsn1B7dfxb2x8sl0fruvHnPj7xUFEfRQSQapnPvI5H3b0GeRVnoI3MRKj6h9upC4ltVKoAvVWusPv7rMCXiaA== - dependencies: - cls-hooked "^4.2.2" - atomic-sleep@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b" integrity sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ== -avvio@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/avvio/-/avvio-8.2.0.tgz#aff28b0266617bf07ffc1c2d5f4220c3663ce1c2" - integrity sha512-bbCQdg7bpEv6kGH41RO/3B2/GMMmJSo2iBK+X8AWN9mujtfUipMDfIjsgHCfpnKqoGEQrrmCDKSa5OQ19+fDmg== +avvio@^8.2.1: + version "8.2.1" + resolved "https://registry.yarnpkg.com/avvio/-/avvio-8.2.1.tgz#b5a482729847abb84d5aadce06511c04a0a62f82" + integrity sha512-TAlMYvOuwGyLK3PfBb5WKBXZmXz2fVCgv23d6zZFdle/q3gPjmxBaeuC0pY0Dzs5PWMSgfqqEZkrye19GlDTgw== dependencies: archy "^1.0.0" debug "^4.0.0" @@ -1385,15 +1370,6 @@ cliui@^8.0.1: strip-ansi "^6.0.1" wrap-ansi "^7.0.0" -cls-hooked@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/cls-hooked/-/cls-hooked-4.2.2.tgz#ad2e9a4092680cdaffeb2d3551da0e225eae1908" - integrity sha512-J4Xj5f5wq/4jAvcdgoGsL3G103BtWpZrMo8NEinRltN+xpTZdI+M38pyQqhuFU/P792xkMFvnKSf+Lm81U1bxw== - dependencies: - async-hook-jl "^1.7.6" - emitter-listener "^1.0.1" - semver "^5.4.1" - co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -1556,13 +1532,6 @@ electron-to-chromium@^1.4.477: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.494.tgz#588f7a3d19d32a31f3a7e05d81b61d95d25b1555" integrity sha512-KF7wtsFFDu4ws1ZsSOt4pdmO1yWVNWCFtijVYZPUeW4SV7/hy/AESjLn/+qIWgq7mHscNOKAwN5AIM1+YAy+Ww== -emitter-listener@^1.0.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/emitter-listener/-/emitter-listener-1.1.2.tgz#56b140e8f6992375b3d7cb2cab1cc7432d9632e8" - integrity sha512-Bt1sBAGFHY9DKY+4/2cV6izcKJUf5T7/gkdmkxzX/qv9CcGH8xSwVRW5mtX03SWJtRTWSOpzCuWN9rBFYZepZQ== - dependencies: - shimmer "^1.2.0" - emittery@^0.13.1: version "0.13.1" resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" @@ -1805,10 +1774,10 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== -fast-json-stringify@^5.0.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-5.5.0.tgz#6655cb944df8da43f6b15312a9564b81c55dadab" - integrity sha512-rmw2Z8/mLkND8zI+3KTYIkNPEoF5v6GqDP/o+g7H3vjdWjBwuKpgAYFHIzL6ORRB+iqDjjtJnLIW9Mzxn5szOA== +fast-json-stringify@^5.7.0: + version "5.8.0" + resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-5.8.0.tgz#b229ed01ac5f92f3b82001a916c31324652f46d7" + integrity sha512-VVwK8CFMSALIvt14U8AvrSzQAwN/0vaVRiFFUVlpnXSnDGrSkOAO5MtzyN8oQNjLd5AqTW5OZRgyjoNuAuR3jQ== dependencies: "@fastify/deepmerge" "^1.0.0" ajv "^8.10.0" @@ -1849,26 +1818,27 @@ fastify-plugin@^4.0.0: resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-4.5.0.tgz#8b853923a0bba6ab6921bb8f35b81224e6988d91" integrity sha512-79ak0JxddO0utAXAQ5ccKhvs6vX2MGyHHMMsmZkBANrq3hXc1CHzvNPHOcvTsVMEPl5I+NT+RO4YKMGehOfSIg== -fastify@^4.12.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/fastify/-/fastify-4.12.0.tgz#e5330215d95702336693b38b2e66d34ee8300d3e" - integrity sha512-Hh2GCsOCqnOuewWSvqXlpq5V/9VA+/JkVoooQWUhrU6gryO9+/UGOoF/dprGcKSDxkM/9TkMXSffYp8eA/YhYQ== +fastify@^4.22.2: + version "4.22.2" + resolved "https://registry.yarnpkg.com/fastify/-/fastify-4.22.2.tgz#ad5ad555c9612874e8dcd7181a248fe3674142e7" + integrity sha512-rK8mF/1mZJHH6H/L22OhmilTgrp5XMkk3RHcSy03LC+TJ6+wLhbq+4U62bjns15VzIbBNgxTqAForBqtGAa0NQ== dependencies: - "@fastify/ajv-compiler" "^3.3.1" - "@fastify/error" "^3.0.0" - "@fastify/fast-json-stringify-compiler" "^4.1.0" + "@fastify/ajv-compiler" "^3.5.0" + "@fastify/error" "^3.2.0" + "@fastify/fast-json-stringify-compiler" "^4.3.0" abstract-logging "^2.0.1" - avvio "^8.2.0" + avvio "^8.2.1" fast-content-type-parse "^1.0.0" - find-my-way "^7.3.0" - light-my-request "^5.6.1" - pino "^8.5.0" - process-warning "^2.0.0" + fast-json-stringify "^5.7.0" + find-my-way "^7.6.0" + light-my-request "^5.9.1" + pino "^8.12.0" + process-warning "^2.2.0" proxy-addr "^2.0.7" rfdc "^1.3.0" secure-json-parse "^2.5.0" - semver "^7.3.7" - tiny-lru "^10.0.0" + semver "^7.5.0" + tiny-lru "^11.0.1" fastq@^1.6.0, fastq@^1.6.1: version "1.15.0" @@ -1898,10 +1868,10 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" -find-my-way@^7.3.0: - version "7.4.0" - resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-7.4.0.tgz#22363e6cd1c466f88883703e169a20c983f9c9cc" - integrity sha512-JFT7eURLU5FumlZ3VBGnveId82cZz7UR7OUu+THQJOwdQXxmS/g8v0KLoFhv97HreycOrmAbqjXD/4VG2j0uMQ== +find-my-way@^7.6.0: + version "7.6.2" + resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-7.6.2.tgz#4dd40200d3536aeef5c7342b10028e04cf79146c" + integrity sha512-0OjHn1b1nCX3eVbm9ByeEHiscPYiHLfhei1wOUU9qffQkk98wE0Lo8VrVYfSGMgnSnDh86DxedduAnBf4nwUEw== dependencies: fast-deep-equal "^3.1.3" fast-querystring "^1.0.0" @@ -2701,10 +2671,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -light-my-request@^5.6.1: - version "5.8.0" - resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-5.8.0.tgz#93b28615d4cd134b4e2370bcf2ff7e35b51c8d29" - integrity sha512-4BtD5C+VmyTpzlDPCZbsatZMJVgUIciSOwYhJDCbLffPZ35KoDkDj4zubLeHDEb35b4kkPeEv5imbh+RJxK/Pg== +light-my-request@^5.9.1: + version "5.10.0" + resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-5.10.0.tgz#0a2bbc1d1bb573ed3b78143960920ecdc05bf157" + integrity sha512-ZU2D9GmAcOUculTTdH9/zryej6n8TzT+fNGdNtm6SDp5MMMpHrJJkvAdE3c6d8d2chE9i+a//dS9CWZtisknqA== dependencies: cookie "^0.5.0" process-warning "^2.0.0" @@ -3055,10 +3025,10 @@ pino@^8.11.0: sonic-boom "^3.1.0" thread-stream "^2.0.0" -pino@^8.5.0: - version "8.9.0" - resolved "https://registry.yarnpkg.com/pino/-/pino-8.9.0.tgz#d7a66968aa8c8140f9335624bbcf2f06082d168c" - integrity sha512-/x9qSrFW4wh+7OL5bLIbfl06aK/8yCSIldhD3VmVGiVYWSdFFpXvJh/4xWKENs+DhG1VkJnnpWxMF6fZ2zGXeg== +pino@^8.12.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-8.15.0.tgz#67c61d5e397bf297e5a0433976a7f7b8aa6f876b" + integrity sha512-olUADJByk4twxccmAxb1RiGKOSvddHugCV3wkqjyv+3Sooa2KLrmXrKEWOKi0XPCLasRR5jBXxioE1jxUa4KzQ== dependencies: atomic-sleep "^1.0.0" fast-redact "^3.1.1" @@ -3103,6 +3073,11 @@ process-warning@^2.0.0: resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.1.0.tgz#1e60e3bfe8183033bbc1e702c2da74f099422d1a" integrity sha512-9C20RLxrZU/rFnxWncDkuF6O999NdIf3E1ws4B0ZeY3sRVPzWBMsYDE2lxjxhiXxg464cQTgKUGm8/i6y2YGXg== +process-warning@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.2.0.tgz#008ec76b579820a8e5c35d81960525ca64feb626" + integrity sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg== + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -3302,7 +3277,7 @@ secure-json-parse@^2.4.0, secure-json-parse@^2.5.0: resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== -semver@^5.4.1, semver@^5.7.1: +semver@^5.7.1: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== @@ -3312,7 +3287,7 @@ semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.3.7, semver@^7.5.3: +semver@^7.3.7, semver@^7.5.0, semver@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -3341,11 +3316,6 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -shimmer@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/shimmer/-/shimmer-1.2.1.tgz#610859f7de327b587efebf501fb43117f9aff337" - integrity sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw== - signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -3409,11 +3379,6 @@ ssh2@^1.12.0: cpu-features "~0.0.7" nan "^2.17.0" -stack-chain@^1.3.7: - version "1.3.7" - resolved "https://registry.yarnpkg.com/stack-chain/-/stack-chain-1.3.7.tgz#d192c9ff4ea6a22c94c4dd459171e3f00cea1285" - integrity sha512-D8cWtWVdIe/jBA7v5p5Hwl5yOSOrmZPWDPe2KxQ5UAGD+nxbxU0lKXA4h85Ta6+qgdKVL3vUxsbIZjc1kBG7ug== - stack-utils@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -3521,10 +3486,10 @@ thread-stream@^2.0.0: dependencies: real-require "^0.2.0" -tiny-lru@^10.0.0: - version "10.0.1" - resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-10.0.1.tgz#aaf5d22207e641ed1b176ac2e616d6cc2fc9ef66" - integrity sha512-Vst+6kEsWvb17Zpz14sRJV/f8bUWKhqm6Dc+v08iShmIJ/WxqWytHzCTd6m88pS33rE2zpX34TRmOpAJPloNCA== +tiny-lru@^11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-11.0.1.tgz#629d6ddd88bd03c0929722680167f1feadf576f2" + integrity sha512-iNgFugVuQgBKrqeO/mpiTTgmBsTP0WL6yeuLfLs/Ctf0pI/ixGqIRm8sDCwMcXGe9WWvt2sGXI5mNqZbValmJg== tmpl@1.0.5: version "1.0.5" diff --git a/yarn.lock b/yarn.lock index deee3dd2..9275c4c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2926,6 +2926,39 @@ resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.36.0.tgz#9837f768c03a1e4a30bd304a64fb8844f0e72efe" integrity sha512-lxJ9R5ygVm8ZWgYdUweoq5ownDlJ4upvoWmO4eLxBYHdMo+vZ/Rx0EN6MbKWDJOSUGrqJy2Gt+Dyv/VKml0fjg== +"@fastify/ajv-compiler@^3.5.0": + version "3.5.0" + resolved "https://registry.yarnpkg.com/@fastify/ajv-compiler/-/ajv-compiler-3.5.0.tgz#459bff00fefbf86c96ec30e62e933d2379e46670" + integrity sha512-ebbEtlI7dxXF5ziNdr05mOY8NnDiPB1XvAlLHctRt/Rc+C3LCOVW5imUVX+mhvUhnNzmPBHewUkOFgGlCxgdAA== + dependencies: + ajv "^8.11.0" + ajv-formats "^2.1.1" + fast-uri "^2.0.0" + +"@fastify/deepmerge@^1.0.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a" + integrity sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A== + +"@fastify/error@^3.2.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@fastify/error/-/error-3.3.0.tgz#eba790082e1144bfc8def0c2c8ef350064bc537b" + integrity sha512-dj7vjIn1Ar8sVXj2yAXiMNCJDmS9MQ9XMlIecX2dIzzhjSHCyKo4DdXjXMs7wKW2kj6yvVRSpuQjOZ3YLrh56w== + +"@fastify/fast-json-stringify-compiler@^4.3.0": + version "4.3.0" + resolved "https://registry.yarnpkg.com/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-4.3.0.tgz#5df89fa4d1592cbb8780f78998355feb471646d5" + integrity sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA== + dependencies: + fast-json-stringify "^5.7.0" + +"@fastify/request-context@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@fastify/request-context/-/request-context-5.0.0.tgz#f821c98ff5a930da9d26b2dce831420d86f5db14" + integrity sha512-HEJoAF5+28PO9kcX+A7vFY0vv45k4Fllzp7rzDyaZ9Lcz99YQmMsXVS1GWkhy+24jv5SWNyyZiiJklSa6BiFPA== + dependencies: + fastify-plugin "^4.0.0" + "@gar/promisify@^1.0.1", "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -5241,6 +5274,11 @@ abort-controller@^3.0.0: dependencies: event-target-shim "^5.0.0" +abstract-logging@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/abstract-logging/-/abstract-logging-2.0.1.tgz#6b0c371df212db7129b57d2e7fcf282b8bf1c839" + integrity sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA== + acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -5285,6 +5323,13 @@ aggregate-error@^3.0.0, aggregate-error@^3.1.0: clean-stack "^2.0.0" indent-string "^4.0.0" +ajv-formats@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== + dependencies: + ajv "^8.0.0" + ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" @@ -5295,6 +5340,16 @@ ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.0.0, ajv@^8.10.0, ajv@^8.11.0: + version "8.12.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.12.0.tgz#d1a0527323e22f53562c567c00991577dfbe19d1" + integrity sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA== + dependencies: + fast-deep-equal "^3.1.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + uri-js "^4.2.2" + ansi-colors@^4.1.1: version "4.1.3" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -5379,6 +5434,11 @@ anymatch@^3.0.3: resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc" integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ== +archy@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/archy/-/archy-1.0.0.tgz#f9c8c13757cc1dd7bc379ac77b2c62a5c2868c40" + integrity sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw== + are-we-there-yet@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz#372e0e7bd279d8e94c653aaa1f67200884bf3e1c" @@ -5553,6 +5613,15 @@ available-typed-arrays@^1.0.5: resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== +avvio@^8.2.1: + version "8.2.1" + resolved "https://registry.yarnpkg.com/avvio/-/avvio-8.2.1.tgz#b5a482729847abb84d5aadce06511c04a0a62f82" + integrity sha512-TAlMYvOuwGyLK3PfBb5WKBXZmXz2fVCgv23d6zZFdle/q3gPjmxBaeuC0pY0Dzs5PWMSgfqqEZkrye19GlDTgw== + dependencies: + archy "^1.0.0" + debug "^4.0.0" + fastq "^1.6.1" + aws-sdk@^2.1231.0: version "2.1325.0" resolved "https://registry.yarnpkg.com/aws-sdk/-/aws-sdk-2.1325.0.tgz#4529ede089ee8db79d6eb04ab46a211bfddbbe5b" @@ -6502,6 +6571,11 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +cookie@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" + integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== + core-util-is@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" @@ -6616,7 +6690,7 @@ dateformat@^4.5.0, dateformat@^4.6.3: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-4.6.3.tgz#556fa6497e5217fedb78821424f8a1c22fa3f4b5" integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -7648,11 +7722,21 @@ fancy-test@^2.0.12: nock "^13.3.0" stdout-stderr "^0.1.9" +fast-content-type-parse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fast-content-type-parse/-/fast-content-type-parse-1.0.0.tgz#cddce00df7d7efb3727d375a598e4904bfcb751c" + integrity sha512-Xbc4XcysUXcsP5aHUU7Nq3OwvHq97C+WnbkeIefpeYLX+ryzFJlU6OStFJhs6Ol0LkUGpcK+wL0JwfM+FCU5IA== + fast-copy@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/fast-copy/-/fast-copy-3.0.1.tgz#9e89ef498b8c04c1cd76b33b8e14271658a732aa" integrity sha512-Knr7NOtK3HWRYGtHoJrjkaWepqT8thIVGAwt0p0aUs1zqkAzXZV4vo9fFNwyb5fcqK1GKYFYxldQdIDVKhUAfA== +fast-decode-uri-component@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz#46f8b6c22b30ff7a81357d4f59abfae938202543" + integrity sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -7690,6 +7774,18 @@ fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.0.0, fast-json-sta resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== +fast-json-stringify@^5.7.0: + version "5.8.0" + resolved "https://registry.yarnpkg.com/fast-json-stringify/-/fast-json-stringify-5.8.0.tgz#b229ed01ac5f92f3b82001a916c31324652f46d7" + integrity sha512-VVwK8CFMSALIvt14U8AvrSzQAwN/0vaVRiFFUVlpnXSnDGrSkOAO5MtzyN8oQNjLd5AqTW5OZRgyjoNuAuR3jQ== + dependencies: + "@fastify/deepmerge" "^1.0.0" + ajv "^8.10.0" + ajv-formats "^2.1.1" + fast-deep-equal "^3.1.3" + fast-uri "^2.1.0" + rfdc "^1.2.0" + fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" @@ -7702,6 +7798,13 @@ fast-levenshtein@^3.0.0: dependencies: fastest-levenshtein "^1.0.7" +fast-querystring@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/fast-querystring/-/fast-querystring-1.1.2.tgz#a6d24937b4fc6f791b4ee31dcb6f53aeafb89f53" + integrity sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg== + dependencies: + fast-decode-uri-component "^1.0.1" + fast-redact@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/fast-redact/-/fast-redact-3.1.2.tgz#d58e69e9084ce9fa4c1a6fa98a3e1ecf5d7839aa" @@ -7717,6 +7820,11 @@ fast-text-encoding@^1.0.0, fast-text-encoding@^1.0.3: resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.6.tgz#0aa25f7f638222e3396d72bf936afcf1d42d6867" integrity sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w== +fast-uri@^2.0.0, fast-uri@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-2.2.0.tgz#519a0f849bef714aad10e9753d69d8f758f7445a" + integrity sha512-cIusKBIt/R/oI6z/1nyfe2FvGKVTohVRfvkOhvx0nCEW+xf5NoCXjAHcWp93uOUBchzYcsvPlrapAdX1uW+YGg== + fast-xml-parser@4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.1.2.tgz#5a98c18238d28a57bbdfa9fe4cda01211fff8f4a" @@ -7729,7 +7837,34 @@ fastest-levenshtein@^1.0.7: resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== -fastq@^1.6.0: +fastify-plugin@^4.0.0: + version "4.5.1" + resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-4.5.1.tgz#44dc6a3cc2cce0988bc09e13f160120bbd91dbee" + integrity sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ== + +fastify@^4.22.2: + version "4.22.2" + resolved "https://registry.yarnpkg.com/fastify/-/fastify-4.22.2.tgz#ad5ad555c9612874e8dcd7181a248fe3674142e7" + integrity sha512-rK8mF/1mZJHH6H/L22OhmilTgrp5XMkk3RHcSy03LC+TJ6+wLhbq+4U62bjns15VzIbBNgxTqAForBqtGAa0NQ== + dependencies: + "@fastify/ajv-compiler" "^3.5.0" + "@fastify/error" "^3.2.0" + "@fastify/fast-json-stringify-compiler" "^4.3.0" + abstract-logging "^2.0.1" + avvio "^8.2.1" + fast-content-type-parse "^1.0.0" + fast-json-stringify "^5.7.0" + find-my-way "^7.6.0" + light-my-request "^5.9.1" + pino "^8.12.0" + process-warning "^2.2.0" + proxy-addr "^2.0.7" + rfdc "^1.3.0" + secure-json-parse "^2.5.0" + semver "^7.5.0" + tiny-lru "^11.0.1" + +fastq@^1.6.0, fastq@^1.6.1: version "1.15.0" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a" integrity sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw== @@ -7776,6 +7911,15 @@ fill-range@^7.0.1: dependencies: to-regex-range "^5.0.1" +find-my-way@^7.6.0: + version "7.6.2" + resolved "https://registry.yarnpkg.com/find-my-way/-/find-my-way-7.6.2.tgz#4dd40200d3536aeef5c7342b10028e04cf79146c" + integrity sha512-0OjHn1b1nCX3eVbm9ByeEHiscPYiHLfhei1wOUU9qffQkk98wE0Lo8VrVYfSGMgnSnDh86DxedduAnBf4nwUEw== + dependencies: + fast-deep-equal "^3.1.3" + fast-querystring "^1.0.0" + safe-regex2 "^2.0.0" + find-up@5.0.0, find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -7900,6 +8044,11 @@ form-data@~2.3.2: combined-stream "^1.0.6" mime-types "^2.1.12" +forwarded@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" + integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== + from2@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" @@ -8831,6 +8980,11 @@ ip@^2.0.0: resolved "https://registry.yarnpkg.com/ip/-/ip-2.0.0.tgz#4cf4ab182fee2314c75ede1276f8c80b479936da" integrity sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ== +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + is-arguments@^1.0.4, is-arguments@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" @@ -9859,6 +10013,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-schema-traverse@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" + integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== + json-schema@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" @@ -10181,6 +10340,15 @@ libnpmpublish@7.1.4: sigstore "^1.4.0" ssri "^10.0.1" +light-my-request@^5.9.1: + version "5.10.0" + resolved "https://registry.yarnpkg.com/light-my-request/-/light-my-request-5.10.0.tgz#0a2bbc1d1bb573ed3b78143960920ecdc05bf157" + integrity sha512-ZU2D9GmAcOUculTTdH9/zryej6n8TzT+fNGdNtm6SDp5MMMpHrJJkvAdE3c6d8d2chE9i+a//dS9CWZtisknqA== + dependencies: + cookie "^0.5.0" + process-warning "^2.0.0" + set-cookie-parser "^2.4.1" + lilconfig@2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" @@ -12049,6 +12217,23 @@ pino@^8.11.0: sonic-boom "^3.1.0" thread-stream "^2.0.0" +pino@^8.12.0: + version "8.15.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-8.15.0.tgz#67c61d5e397bf297e5a0433976a7f7b8aa6f876b" + integrity sha512-olUADJByk4twxccmAxb1RiGKOSvddHugCV3wkqjyv+3Sooa2KLrmXrKEWOKi0XPCLasRR5jBXxioE1jxUa4KzQ== + dependencies: + atomic-sleep "^1.0.0" + fast-redact "^3.1.1" + on-exit-leak-free "^2.1.0" + pino-abstract-transport v1.0.0 + pino-std-serializers "^6.0.0" + process-warning "^2.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^3.1.0" + thread-stream "^2.0.0" + pirates@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.5.tgz#feec352ea5c3268fb23a37c702ab1699f35a5f3b" @@ -12199,6 +12384,11 @@ process-warning@^2.0.0: resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.1.0.tgz#1e60e3bfe8183033bbc1e702c2da74f099422d1a" integrity sha512-9C20RLxrZU/rFnxWncDkuF6O999NdIf3E1ws4B0ZeY3sRVPzWBMsYDE2lxjxhiXxg464cQTgKUGm8/i6y2YGXg== +process-warning@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-2.2.0.tgz#008ec76b579820a8e5c35d81960525ca64feb626" + integrity sha512-/1WZ8+VQjR6avWOgHeEPd7SDQmFQ1B5mC1eRXsCm5TarlNmx/wCsa5GEaxGm05BORRtyG/Ex/3xq3TuRvq57qg== + process@^0.11.10: version "0.11.10" resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182" @@ -12312,6 +12502,14 @@ protocols@^2.0.0, protocols@^2.0.1: resolved "https://registry.yarnpkg.com/protocols/-/protocols-2.0.1.tgz#8f155da3fc0f32644e83c5782c8e8212ccf70a86" integrity sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q== +proxy-addr@^2.0.7: + version "2.0.7" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" + integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + dependencies: + forwarded "0.2.0" + ipaddr.js "1.9.1" + proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" @@ -12681,6 +12879,11 @@ require-directory@^2.1.1: resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== +require-from-string@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" + integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== + requires-port@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" @@ -12762,6 +12965,11 @@ restore-cursor@^3.1.0: onetime "^5.1.0" signal-exit "^3.0.2" +ret@~0.2.0: + version "0.2.2" + resolved "https://registry.yarnpkg.com/ret/-/ret-0.2.2.tgz#b6861782a1f4762dce43402a71eb7a283f44573c" + integrity sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ== + retry-request@^5.0.0: version "5.0.2" resolved "https://registry.yarnpkg.com/retry-request/-/retry-request-5.0.2.tgz#143d85f90c755af407fcc46b7166a4ba520e44da" @@ -12790,7 +12998,7 @@ rfc4648@^1.3.0: resolved "https://registry.yarnpkg.com/rfc4648/-/rfc4648-1.5.2.tgz#cf5dac417dd83e7f4debf52e3797a723c1373383" integrity sha512-tLOizhR6YGovrEBLatX1sdcuhoSCXddw3mqNVAcKxGJ+J0hFeJ+SjeWCv5UPA/WU3YzWPPuCVYgXBKZUPGpKtg== -rfdc@^1.3.0: +rfdc@^1.2.0, rfdc@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b" integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA== @@ -12861,6 +13069,13 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" +safe-regex2@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/safe-regex2/-/safe-regex2-2.0.0.tgz#b287524c397c7a2994470367e0185e1916b1f5b9" + integrity sha512-PaUSFsUaNNuKwkBijoAPHAK6/eM6VirvyPWlZ7BAQy4D+hCvh4B6lIG+nPdhbFfIbP+gTGBcrdsOaUs0F+ZBOQ== + dependencies: + ret "~0.2.0" + safe-regex@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/safe-regex/-/safe-regex-2.1.1.tgz#f7128f00d056e2fe5c11e81a1324dd974aadced2" @@ -12893,7 +13108,7 @@ scoped-regex@^2.0.0: resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-2.1.0.tgz#7b9be845d81fd9d21d1ec97c61a0b7cf86d2015f" integrity sha512-g3WxHrqSWCZHGHlSrF51VXFdjImhwvH8ZO/pryFH56Qi0cDsZfylQa/t0jCzVQFNbNvM00HfHjkDPEuarKDSWQ== -secure-json-parse@^2.4.0: +secure-json-parse@^2.4.0, secure-json-parse@^2.5.0: version "2.7.0" resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.7.0.tgz#5a5f9cd6ae47df23dba3151edd06855d47e09862" integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw== @@ -12917,7 +13132,7 @@ semver@7.3.8: dependencies: lru-cache "^6.0.0" -semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.2, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.3: +semver@7.x, semver@^7.0.0, semver@^7.1.1, semver@^7.1.2, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7, semver@^7.3.8, semver@^7.5.0, semver@^7.5.3: version "7.5.4" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== @@ -12934,6 +13149,11 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== +set-cookie-parser@^2.4.1: + version "2.6.0" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz#131921e50f62ff1a66a461d7d62d7b21d5d15a51" + integrity sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ== + shallow-clone@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" @@ -13759,6 +13979,11 @@ through@2, "through@>=2.2.7 <3", through@^2.3.4, through@^2.3.6, through@^2.3.8: resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== +tiny-lru@^11.0.1: + version "11.0.1" + resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-11.0.1.tgz#629d6ddd88bd03c0929722680167f1feadf576f2" + integrity sha512-iNgFugVuQgBKrqeO/mpiTTgmBsTP0WL6yeuLfLs/Ctf0pI/ixGqIRm8sDCwMcXGe9WWvt2sGXI5mNqZbValmJg== + tmp-promise@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-3.0.3.tgz#60a1a1cc98c988674fcbfd23b6e3367bdeac4ce7" From e7a2fb10b7a3a7b2f838d730a0ea7d91b5e338ca Mon Sep 17 00:00:00 2001 From: Roy Razon Date: Wed, 6 Sep 2023 13:31:51 +0300 Subject: [PATCH 3/3] convert cta http to fastify --- packages/compose-tunnel-agent/index.ts | 44 ++---- packages/compose-tunnel-agent/package.json | 6 +- .../src/api-server/containers/errors.ts | 7 + .../{ws/handlers => containers}/exec.ts | 46 +++--- .../src/api-server/containers/filter.ts | 10 ++ .../src/api-server/containers/index.ts | 31 ++++ .../src/api-server/containers/logs.ts | 56 +++++++ .../src/api-server/containers/schema.ts | 19 +++ .../src/api-server/env.ts | 32 ++++ .../src/api-server/errors.ts | 13 -- .../src/api-server/http-errors.ts | 41 +++++ .../src/api-server/http-server-helpers.ts | 148 ------------------ .../src/api-server/index.test.ts | 30 ++-- .../src/api-server/index.ts | 94 +++-------- .../src/api-server/query-params.ts | 20 --- .../src/api-server/ws/handler.ts | 33 ---- .../src/api-server/ws/handlers/logs.ts | 43 ----- .../src/api-server/ws/index.ts | 10 -- yarn.lock | 42 ++++- 19 files changed, 311 insertions(+), 414 deletions(-) create mode 100644 packages/compose-tunnel-agent/src/api-server/containers/errors.ts rename packages/compose-tunnel-agent/src/api-server/{ws/handlers => containers}/exec.ts (53%) create mode 100644 packages/compose-tunnel-agent/src/api-server/containers/filter.ts create mode 100644 packages/compose-tunnel-agent/src/api-server/containers/index.ts create mode 100644 packages/compose-tunnel-agent/src/api-server/containers/logs.ts create mode 100644 packages/compose-tunnel-agent/src/api-server/containers/schema.ts create mode 100644 packages/compose-tunnel-agent/src/api-server/env.ts delete mode 100644 packages/compose-tunnel-agent/src/api-server/errors.ts create mode 100644 packages/compose-tunnel-agent/src/api-server/http-errors.ts delete mode 100644 packages/compose-tunnel-agent/src/api-server/http-server-helpers.ts delete mode 100644 packages/compose-tunnel-agent/src/api-server/query-params.ts delete mode 100644 packages/compose-tunnel-agent/src/api-server/ws/handler.ts delete mode 100644 packages/compose-tunnel-agent/src/api-server/ws/handlers/logs.ts delete mode 100644 packages/compose-tunnel-agent/src/api-server/ws/index.ts diff --git a/packages/compose-tunnel-agent/index.ts b/packages/compose-tunnel-agent/index.ts index 6184fa66..ee5a0c54 100644 --- a/packages/compose-tunnel-agent/index.ts +++ b/packages/compose-tunnel-agent/index.ts @@ -1,12 +1,9 @@ import fs from 'fs' import path from 'path' import Docker from 'dockerode' -import { inspect } from 'node:util' -import http from 'node:http' import { rimraf } from 'rimraf' import pino from 'pino' import pinoPretty from 'pino-pretty' -import { EOL } from 'os' import { requiredEnv, formatPublicKey, @@ -16,9 +13,8 @@ import { MachineStatusCommand, COMPOSE_TUNNEL_AGENT_PORT, } from '@preevy/common' -import createApiServerHandler from './src/api-server' +import { createApp } from './src/api-server' import { sshClient as createSshClient } from './src/ssh' -import { tryHandler, tryUpgradeHandler } from './src/api-server/http-server-helpers' import { runMachineStatusCommand } from './src/machine-status' import { envMetadata } from './src/metadata' import { readAllFiles } from './src/files' @@ -54,7 +50,15 @@ const sshConnectionConfigFromEnv = async (): Promise<{ connectionConfig: SshConn } } -const writeLineToStdout = (s: string) => [s, EOL].forEach(d => process.stdout.write(d)) +const fastifyListenArgsFromEnv = async () => { + const portOrPath = process.env.PORT ?? COMPOSE_TUNNEL_AGENT_PORT + const portNumber = Number(portOrPath) + if (typeof portOrPath === 'string' && Number.isNaN(portNumber)) { + await rimraf(portOrPath) + return { path: portOrPath } + } + return { port: portNumber, host: '0.0.0.0' } +} const machineStatusCommand = process.env.MACHINE_STATUS_COMMAND ? JSON.parse(process.env.MACHINE_STATUS_COMMAND) as MachineStatusCommand @@ -99,16 +103,10 @@ const main = async () => { void dockerClient.startListening({ onChange: async services => { currentTunnels = sshClient.updateTunnels(services) - void currentTunnels.then(ssh => writeLineToStdout(JSON.stringify(ssh))) }, }) - const apiListenAddress = process.env.PORT ?? COMPOSE_TUNNEL_AGENT_PORT - if (typeof apiListenAddress === 'string' && Number.isNaN(Number(apiListenAddress))) { - await rimraf(apiListenAddress) - } - - const { handler, upgradeHandler } = createApiServerHandler({ + const app = await createApp({ log: log.child({ name: 'api' }), currentSshState: async () => (await currentTunnels), machineStatus: machineStatusCommand @@ -120,24 +118,8 @@ const main = async () => { dockerFilter: dockerFilteredClient({ docker, composeProject: targetComposeProject }), }) - const httpLog = log.child({ name: 'http' }) - - const httpServer = http.createServer(tryHandler({ log: httpLog }, async (req, res) => { - httpLog.debug('request %s %s', req.method, req.url) - return await handler(req, res) - })) - .on('upgrade', tryUpgradeHandler({ log: httpLog }, async (req, socket, head) => { - httpLog.debug('upgrade %s %s', req.method, req.url) - return await upgradeHandler(req, socket, head) - })) - .listen(apiListenAddress, () => { - httpLog.info(`API server listening on ${inspect(httpServer.address())}`) - }) - .on('error', err => { - httpLog.error(err) - process.exit(1) - }) - .unref() + void app.listen({ ...await fastifyListenArgsFromEnv() }) + app.server.unref() } void main(); diff --git a/packages/compose-tunnel-agent/package.json b/packages/compose-tunnel-agent/package.json index 0f2b8ebe..d3b2279c 100644 --- a/packages/compose-tunnel-agent/package.json +++ b/packages/compose-tunnel-agent/package.json @@ -12,10 +12,13 @@ }, "license": "Apache-2.0", "dependencies": { + "@fastify/cors": "^8.3.0", "@fastify/request-context": "^5.0.0", + "@fastify/websocket": "^8.2.0", "@preevy/common": "0.0.50", "dockerode": "^3.3.4", "fastify": "^4.22.2", + "fastify-type-provider-zod": "^1.1.9", "http-proxy": "^1.18.1", "lodash": "^4.17.21", "p-limit": "^3.1.0", @@ -23,7 +26,8 @@ "pino-pretty": "^9.4.0", "rimraf": "^5.0.0", "ssh2": "^1.12.0", - "ws": "^8.13.0" + "ws": "^8.13.0", + "zod": "^3.21.4" }, "devDependencies": { "@jest/globals": "^29.5.0", diff --git a/packages/compose-tunnel-agent/src/api-server/containers/errors.ts b/packages/compose-tunnel-agent/src/api-server/containers/errors.ts new file mode 100644 index 00000000..a9f510b8 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/containers/errors.ts @@ -0,0 +1,7 @@ +import { NotFoundError } from '../http-errors' + +export class ContainerNotFoundError extends NotFoundError { + constructor(containerId: string) { + super(`Container "${containerId}" does not exist or is not managed by this agent`) + } +} diff --git a/packages/compose-tunnel-agent/src/api-server/ws/handlers/exec.ts b/packages/compose-tunnel-agent/src/api-server/containers/exec.ts similarity index 53% rename from packages/compose-tunnel-agent/src/api-server/ws/handlers/exec.ts rename to packages/compose-tunnel-agent/src/api-server/containers/exec.ts index 6ab0a305..0bbe44c2 100644 --- a/packages/compose-tunnel-agent/src/api-server/ws/handlers/exec.ts +++ b/packages/compose-tunnel-agent/src/api-server/containers/exec.ts @@ -1,21 +1,27 @@ import { inspect } from 'util' import { createWebSocketStream } from 'ws' -import { parseQueryParams, queryParamBoolean } from '../../query-params' -import { wsHandler } from '../handler' -import { NotFoundError } from '../../http-server-helpers' - -const handler = wsHandler( - /^\/container\/([^/?]+)\/exec($|\?)/, - async (ws, req, match, { log, docker, dockerFilter }) => { - const containerId = match[1] - if (!await dockerFilter.inspectContainer(containerId)) { - throw new NotFoundError() - } - const { obj: { tty: ttyQueryParam }, search } = parseQueryParams(req.url ?? '', { tty: true }) - const cmdQueryParams = search.getAll('cmd') - const cmd = cmdQueryParams.length ? cmdQueryParams : ['sh'] +import z from 'zod' +import { FastifyPluginAsync } from 'fastify' +import Dockerode from 'dockerode' +import { DockerFilterClient } from '../../docker' +import { containerIdSchema, execQueryString } from './schema' +import { inspectFilteredContainer } from './filter' - const tty = queryParamBoolean(ttyQueryParam) +const handler: FastifyPluginAsync<{ + docker: Dockerode + dockerFilter: DockerFilterClient +}> = async (app, { docker, dockerFilter }) => { + app.get<{ + Params: z.infer + Querystring: z.infer + }>('/:containerId/exec', { + schema: { + params: containerIdSchema, + querystring: execQueryString, + }, + websocket: true, + }, async (connection, { params: { containerId }, query: { tty, cmd }, log }) => { + await inspectFilteredContainer(dockerFilter, containerId) const abort = new AbortController() const exec = await docker.getContainer(containerId).exec({ AttachStdin: true, @@ -32,9 +38,9 @@ const handler = wsHandler( Tty: tty, }) - execStream.on('close', () => { ws.close() }) + execStream.on('close', () => { connection.socket.close() }) execStream.on('error', err => { log.warn('execStream error %j', inspect(err)) }) - ws.on('close', () => { + connection.socket.on('close', () => { abort.abort() execStream.destroy() }) @@ -42,7 +48,7 @@ const handler = wsHandler( const inspectResults = await exec.inspect() log.debug('exec %s: %j', containerId, inspect(inspectResults)) - const wsStream = createWebSocketStream(ws) + const wsStream = createWebSocketStream(connection.socket) wsStream.on('error', err => { const level = err.message === 'aborted' || err.message.includes('WebSocket is not open') ? 'debug' : 'warn' log[level]('wsStream error %j', inspect(err)) @@ -56,7 +62,7 @@ const handler = wsHandler( } return undefined - }, -) + }) +} export default handler diff --git a/packages/compose-tunnel-agent/src/api-server/containers/filter.ts b/packages/compose-tunnel-agent/src/api-server/containers/filter.ts new file mode 100644 index 00000000..e99a0b64 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/containers/filter.ts @@ -0,0 +1,10 @@ +import { DockerFilterClient } from '../../docker' +import { ContainerNotFoundError } from './errors' + +export const inspectFilteredContainer = async (dockerFilter: DockerFilterClient, containerId: string) => { + const container = await dockerFilter.inspectContainer(containerId) + if (!container) { + throw new ContainerNotFoundError(containerId) + } + return container +} diff --git a/packages/compose-tunnel-agent/src/api-server/containers/index.ts b/packages/compose-tunnel-agent/src/api-server/containers/index.ts new file mode 100644 index 00000000..6e576868 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/containers/index.ts @@ -0,0 +1,31 @@ +import Dockerode from 'dockerode' +import { FastifyPluginAsync } from 'fastify' +import z from 'zod' +import fastifyWebsocket from '@fastify/websocket' +import { DockerFilterClient } from '../../docker' +import { containerIdSchema } from './schema' +import exec from './exec' +import logs from './logs' +import { inspectFilteredContainer } from './filter' + +export const containers: FastifyPluginAsync<{ + docker: Dockerode + dockerFilter: DockerFilterClient +}> = async (app, { docker, dockerFilter }) => { + app.get('/', async () => await dockerFilter.listContainers()) + + app.get<{ + Params: z.infer + }>('/:containerId', { + schema: { + params: containerIdSchema, + }, + }, async ({ params: { containerId } }, res) => { + const container = await inspectFilteredContainer(dockerFilter, containerId) + void res.send(container) + }) + + await app.register(fastifyWebsocket) + await app.register(exec, { docker, dockerFilter }) + await app.register(logs, { docker, dockerFilter }) +} diff --git a/packages/compose-tunnel-agent/src/api-server/containers/logs.ts b/packages/compose-tunnel-agent/src/api-server/containers/logs.ts new file mode 100644 index 00000000..b3e814e2 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/containers/logs.ts @@ -0,0 +1,56 @@ +import { inspect } from 'util' +import { createWebSocketStream } from 'ws' +import z from 'zod' +import { FastifyPluginAsync } from 'fastify' +import Dockerode from 'dockerode' +import { DockerFilterClient } from '../../docker' +import { containerIdSchema, logsQueryString } from './schema' +import { inspectFilteredContainer } from './filter' + +const handler: FastifyPluginAsync<{ + docker: Dockerode + dockerFilter: DockerFilterClient +}> = async (app, { docker, dockerFilter }) => { + app.get<{ + Params: z.infer + Querystring: z.infer + }>('/:containerId/logs', { + schema: { + params: containerIdSchema, + querystring: logsQueryString, + }, + websocket: true, + }, async ( + connection, + { params: { containerId }, query: { stdout, stderr, since, until, timestamps, tail }, log } + ) => { + await inspectFilteredContainer(dockerFilter, containerId) + const abort = new AbortController() + const logStream = await docker.getContainer(containerId).logs({ + stdout, + stderr, + since, + until, + timestamps, + tail, + follow: true, + abortSignal: abort.signal, + }) + + logStream.on('close', async () => { connection.socket.close() }) + logStream.on('error', err => { + if (err.message !== 'aborted') { + log.error('logs stream error %j', inspect(err)) + } + }) + connection.socket.on('close', () => { abort.abort() }) + + const wsStream = createWebSocketStream(connection.socket) + wsStream.on('error', err => { log.error('wsStream error %j', inspect(err)) }) + docker.modem.demuxStream(logStream, wsStream, wsStream) + + return undefined + }) +} + +export default handler diff --git a/packages/compose-tunnel-agent/src/api-server/containers/schema.ts b/packages/compose-tunnel-agent/src/api-server/containers/schema.ts new file mode 100644 index 00000000..75a041f7 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/containers/schema.ts @@ -0,0 +1,19 @@ +import z from 'zod' + +export const containerIdSchema = z.object({ + containerId: z.string(), +}) + +export const execQueryString = z.object({ + cmd: z.array(z.string()).optional().default(['sh']), + tty: z.coerce.boolean().optional().default(true), +}) + +export const logsQueryString = z.object({ + stdout: z.coerce.boolean().optional(), + stderr: z.coerce.boolean().optional(), + since: z.string().optional(), + until: z.string().optional(), + timestamps: z.coerce.boolean().optional(), + tail: z.coerce.number().optional(), +}) diff --git a/packages/compose-tunnel-agent/src/api-server/env.ts b/packages/compose-tunnel-agent/src/api-server/env.ts new file mode 100644 index 00000000..cbee3e5c --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/env.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs' +import { FastifyPluginAsync } from 'fastify' +import { SshState } from '../ssh' + +export const env: FastifyPluginAsync<{ + currentSshState: () => Promise + machineStatus?: () => Promise<{ data: Buffer; contentType: string }> + envMetadata?: Record + composeModelPath: string +}> = async (app, { currentSshState, machineStatus, envMetadata, composeModelPath }) => { + app.get('/tunnels', async () => await currentSshState()) + + if (machineStatus) { + app.get('/machine-status', async (_req, res) => { + const { data, contentType } = await machineStatus() + void res + .header('Content-Type', contentType) + .send(data) + }) + } + + if (envMetadata) { + app.get('/env-metadata', async () => envMetadata) + } + + app.get('/compose-model', async ({ log }, res) => { + log.debug('compose-model handler') + void res + .header('Content-Type', 'application/x-yaml') + .send(await fs.promises.readFile(composeModelPath, { encoding: 'utf-8' })) + }) +} diff --git a/packages/compose-tunnel-agent/src/api-server/errors.ts b/packages/compose-tunnel-agent/src/api-server/errors.ts deleted file mode 100644 index 6b9034f3..00000000 --- a/packages/compose-tunnel-agent/src/api-server/errors.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { BadRequestError, NotFoundError } from './http-server-helpers' - -export class MissingContainerIdError extends BadRequestError { - constructor() { - super('Missing container id') - } -} - -export class ContainerNotFoundError extends NotFoundError { - constructor(containerId: string) { - super(`Container "${containerId}" does not exist or is not managed by this agent`) - } -} diff --git a/packages/compose-tunnel-agent/src/api-server/http-errors.ts b/packages/compose-tunnel-agent/src/api-server/http-errors.ts new file mode 100644 index 00000000..4f246966 --- /dev/null +++ b/packages/compose-tunnel-agent/src/api-server/http-errors.ts @@ -0,0 +1,41 @@ +export class HttpError extends Error { + constructor( + readonly statusCode: number, + message: string, + readonly cause?: unknown, + readonly responseHeaders?: Record + ) { + super(message) + } +} + +export class NotFoundError extends HttpError { + static defaultMessage = 'Not found' + constructor(message = NotFoundError.defaultMessage) { + super(404, message) + } +} + +export class InternalError extends HttpError { + static status = 500 + static defaultMessage = 'Internal error' + constructor(err: unknown, message = InternalError.defaultMessage) { + super(InternalError.status, message, err) + } +} + +export class BadGatewayError extends HttpError { + static status = 502 + static defaultMessage = 'Bad gateway' + constructor(message = InternalError.defaultMessage) { + super(BadGatewayError.status, message) + } +} + +export class BadRequestError extends HttpError { + static status = 400 + static message = 'Bad request' + constructor(reason?: string) { + super(BadGatewayError.status, reason ? `${BadRequestError.message}: ${reason}` : BadRequestError.message) + } +} diff --git a/packages/compose-tunnel-agent/src/api-server/http-server-helpers.ts b/packages/compose-tunnel-agent/src/api-server/http-server-helpers.ts deleted file mode 100644 index 1fcf92b8..00000000 --- a/packages/compose-tunnel-agent/src/api-server/http-server-helpers.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Logger } from '@preevy/common' -import http from 'node:http' -import stream from 'node:stream' -import { inspect } from 'node:util' -import { WebSocket } from 'ws' - -export const respond = (res: http.ServerResponse, content: string | Buffer, type = 'text/plain', status = 200) => { - res.writeHead(status, { 'Content-Type': type }) - res.end(content) -} - -export const respondJson = ( - res: http.ServerResponse, - content: unknown, - status = 200, -) => respond(res, JSON.stringify(content), 'application/json', status) - -export const respondAccordingToAccept = ( - req: http.IncomingMessage, - res: http.ServerResponse, - message: string, - status = 200, -) => (req.headers.accept?.toLowerCase().includes('json') - ? respondJson(res, { message }, status) - : respond(res, message, 'text/plain', status)) - -export class HttpError extends Error { - constructor( - readonly status: number, - readonly clientMessage: string, - readonly cause?: unknown, - readonly responseHeaders?: Record - ) { - super(clientMessage) - } -} - -export class NotFoundError extends HttpError { - static defaultMessage = 'Not found' - constructor(clientMessage = NotFoundError.defaultMessage) { - super(404, clientMessage) - } -} - -export class InternalError extends HttpError { - static status = 500 - static defaultMessage = 'Internal error' - constructor(err: unknown, clientMessage = InternalError.defaultMessage) { - super(InternalError.status, clientMessage, err) - } -} - -export class BadGatewayError extends HttpError { - static status = 502 - static defaultMessage = 'Bad gateway' - constructor(clientMessage = InternalError.defaultMessage) { - super(BadGatewayError.status, clientMessage) - } -} - -export class BadRequestError extends HttpError { - static status = 400 - static defaultMessage = 'Bad request' - constructor(reason?: string) { - super(BadGatewayError.status, reason ? `${BadRequestError.defaultMessage}: ${reason}` : BadRequestError.defaultMessage) - } -} - -export const errorHandler = ( - log: Logger, - err: unknown, - req: http.IncomingMessage, - res: http.ServerResponse, -) => { - const [clientMessage, status, responseHeaders] = err instanceof HttpError - ? [err.clientMessage, err.status, err.responseHeaders] - : [InternalError.defaultMessage, InternalError.status, undefined] - - respondAccordingToAccept(req, res, clientMessage, status) - Object.entries(responseHeaders || {}).forEach(([k, v]) => res.setHeader(k, v)) - log.warn('caught error: %j in %s %s', inspect(err), req.method || '', req.url || '') -} - -export const tryHandler = ( - { log }: { log: Logger }, - f: (req: http.IncomingMessage, res: http.ServerResponse) => Promise -) => async (req: http.IncomingMessage, res: http.ServerResponse) => { - try { - await f(req, res) - } catch (err) { - errorHandler(log, err, req, res) - } -} - -export const errorUpgradeHandler = ( - log: Logger, - err: unknown, - req: http.IncomingMessage, - socket: stream.Duplex, -) => { - const message: string = err instanceof HttpError - ? err.clientMessage - : InternalError.defaultMessage - - socket.end(message) - log.warn('caught error: %j in upgrade %s %s', inspect(err), req.method || '', req.url || '') -} - -export type UpgradeHandler = (req: http.IncomingMessage, socket: stream.Duplex, head: Buffer) => Promise - -export const tryUpgradeHandler = ( - { log }: { log: Logger }, - f: UpgradeHandler, -) => async (req: http.IncomingMessage, socket: stream.Duplex, head: Buffer) => { - try { - await f(req, socket, head) - } catch (err) { - errorUpgradeHandler(log, err, req, socket) - } -} - -export const errorWsHandler = ( - log: Logger, - err: unknown, - ws: WebSocket, - req: http.IncomingMessage, -) => { - const [code, message]: [number, string] = err instanceof HttpError - ? [err.status, err.clientMessage] - : [500, InternalError.defaultMessage] - - const wsCode = 4000 + code // https://github.com/websockets/ws/issues/715#issuecomment-504702511 - ws.close(wsCode, message) - log.warn('caught error: %j in ws %s %s', inspect(err), req.method || '', req.url || '') -} - -export type WsHandler = (ws: WebSocket, req: http.IncomingMessage) => Promise - -export const tryWsHandler = ( - { log }: { log: Logger }, - f: WsHandler, -) => async (ws: WebSocket, req: http.IncomingMessage) => { - try { - await f(ws, req) - } catch (err) { - errorWsHandler(log, err, ws, req) - } -} diff --git a/packages/compose-tunnel-agent/src/api-server/index.test.ts b/packages/compose-tunnel-agent/src/api-server/index.test.ts index 8b8f0ce7..31da9da5 100644 --- a/packages/compose-tunnel-agent/src/api-server/index.test.ts +++ b/packages/compose-tunnel-agent/src/api-server/index.test.ts @@ -1,5 +1,4 @@ -import http from 'node:http' -import net from 'node:net' +import { AddressInfo } from 'node:net' import { describe, expect, beforeAll, afterAll, jest, it } from '@jest/globals' import { ChildProcess, spawn, exec } from 'child_process' import pino from 'pino' @@ -10,7 +9,7 @@ import { inspect, promisify } from 'node:util' import waitForExpect from 'wait-for-expect' import WebSocket from 'ws' import stripAnsi from 'strip-ansi' -import createApiServerHandlers from '.' +import { createApp } from '.' import { filteredClient } from '../docker' import { SshState } from '../ssh' import { COMPOSE_PROJECT_LABEL } from '../docker/labels' @@ -60,30 +59,25 @@ const setupApiServer = () => { level: 'debug', }, pinoPretty({ destination: pino.destination(process.stderr) })) - let server: http.Server + let app: Awaited> let serverBaseUrl: string beforeAll(async () => { const docker = new Dockerode() - const handlers = createApiServerHandlers({ + app = await createApp({ log, docker, dockerFilter: filteredClient({ docker, composeProject: TEST_COMPOSE_PROJECT }), composeModelPath: '', currentSshState: () => Promise.resolve({} as unknown as SshState), }) - server = http.createServer(handlers.handler).on('upgrade', handlers.upgradeHandler) - - const serverPort = await new Promise(resolve => { - server.listen(0, () => { - resolve((server.address() as net.AddressInfo).port) - }) - }) - serverBaseUrl = `localhost:${serverPort}` + await app.listen({ port: 0 }) + const { port } = app.server.address() as AddressInfo + serverBaseUrl = `localhost:${port}` }) afterAll(async () => { - await promisify(server.close.bind(server))() + await app.close() }) return { @@ -156,7 +150,7 @@ describe('docker api', () => { describe('tty=true', () => { it('should communicate via websocket', async () => { - const { receivedBuffers, send, close } = await openWebSocket(`ws://${serverBaseUrl()}/container/${containerId}/exec`) + const { receivedBuffers, send, close } = await openWebSocket(`ws://${serverBaseUrl()}/containers/${containerId}/exec`) await waitForExpect(() => expect(receivedBuffers.length).toBeGreaterThan(0)) await send('ls \n') await waitForExpect(() => { @@ -171,7 +165,7 @@ describe('docker api', () => { describe('tty=false', () => { it('should communicate via websocket', async () => { - const { receivedBuffers, send, close } = await openWebSocket(`ws://${serverBaseUrl()}/container/${containerId}/exec`) + const { receivedBuffers, send, close } = await openWebSocket(`ws://${serverBaseUrl()}/containers/${containerId}/exec`) await waitForExpect(async () => { await send('ls\n') const received = Buffer.concat(receivedBuffers).toString('utf-8') @@ -194,7 +188,7 @@ describe('docker api', () => { const testStream = (...s: LogStream[]) => { describe(`${s.join(' and ')}`, () => { it(`should show the ${s.join(' and ')} logs via websocket`, async () => { - const { receivedBuffers, close } = await openWebSocket(`ws://${serverBaseUrl()}/container/${containerId}/logs?${s.map(st => `${st}=true`).join('&')}`) + const { receivedBuffers, close } = await openWebSocket(`ws://${serverBaseUrl()}/containers/${containerId}/logs?${s.map(st => `${st}=true`).join('&')}`) await waitForExpect(() => expect(receivedBuffers.length).toBeGreaterThan(0)) const length1 = receivedBuffers.length await waitForExpect(() => { @@ -220,7 +214,7 @@ describe('docker api', () => { describe('timestamps', () => { it('should show the logs with a timestamp', async () => { - const { receivedBuffers, close } = await openWebSocket(`ws://${serverBaseUrl()}/container/${containerId}/logs?stdout=true×tamps=true`) + const { receivedBuffers, close } = await openWebSocket(`ws://${serverBaseUrl()}/containers/${containerId}/logs?stdout=true×tamps=true`) await waitForExpect(() => expect(receivedBuffers.length).toBeGreaterThan(0)) const received = Buffer.concat(receivedBuffers).toString('utf-8') expect(received).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d*Z/) diff --git a/packages/compose-tunnel-agent/src/api-server/index.ts b/packages/compose-tunnel-agent/src/api-server/index.ts index 9d17ad52..88981110 100644 --- a/packages/compose-tunnel-agent/src/api-server/index.ts +++ b/packages/compose-tunnel-agent/src/api-server/index.ts @@ -1,17 +1,14 @@ -import fs from 'node:fs' -import url from 'node:url' -import { WebSocketServer } from 'ws' import { Logger } from 'pino' import Dockerode from 'dockerode' +import fastify from 'fastify' +import cors from '@fastify/cors' +import { validatorCompiler, serializerCompiler, ZodTypeProvider } from 'fastify-type-provider-zod' import { SshState } from '../ssh' -import { NotFoundError, respond, respondAccordingToAccept, respondJson, tryHandler, tryUpgradeHandler, tryWsHandler } from './http-server-helpers' import { DockerFilterClient } from '../docker' -import { findHandler as findWsHandler, handlers as wsHandlers } from './ws' -import { ContainerNotFoundError, MissingContainerIdError } from './errors' +import { containers } from './containers' +import { env } from './env' -// const pathRe = /^\/(?[^/]+)(\/(?[^/]+)(?\/[^/]+)?)?$/ - -const createApiServerHandlers = ({ +export const createApp = async ({ log, currentSshState, machineStatus, @@ -28,77 +25,22 @@ const createApiServerHandlers = ({ dockerFilter: DockerFilterClient docker: Dockerode }) => { - const handler = tryHandler({ log }, async (req, res) => { - const { pathname: path } = url.parse(req.url || '') - - if (req.method === 'GET' && path === '/healthz') { - respondAccordingToAccept(req, res, 'OK') - return - } - - if (req.method === 'GET' && path === '/tunnels') { - respondJson(res, await currentSshState()) - return - } - - if (req.method === 'GET' && path === '/machine-status' && machineStatus) { - const { data, contentType } = await machineStatus() - respond(res, data, contentType) - return - } - - if (req.method === 'GET' && path === '/metadata' && envMetadata) { - respondJson(res, envMetadata) - return - } - - if (req.method === 'GET' && path === '/compose-model') { - respond(res, await fs.promises.readFile(composeModelPath, { encoding: 'utf-8' }), 'application/x-yaml') - return - } + const app = await fastify({ logger: log }) + app.setValidatorCompiler(validatorCompiler) + app.setSerializerCompiler(serializerCompiler) - if (req.method === 'GET' && path === '/containers') { - respondJson(res, await dockerFilter.listContainers()) - return - } + app.withTypeProvider() - if (req.method === 'GET' && path?.startsWith('/container/')) { - const containerId = path.substring('/container/'.length) - if (!containerId) { - throw new MissingContainerIdError() - } - const container = await dockerFilter.inspectContainer(containerId) - if (!container) { - throw new ContainerNotFoundError(containerId) - } - respondJson(res, container) - return - } - - throw new NotFoundError() + await app.register(cors, { + allowedHeaders: ['Authorization', 'Content-Type', 'Accept'], + origin: '*', + methods: '*', }) - const wss = new WebSocketServer({ noServer: true }) - - wss.on('connection', tryWsHandler({ log }, async (ws, req) => { - const foundHandler = findWsHandler(wsHandlers, req) - if (!foundHandler) { - throw new NotFoundError() - } - await foundHandler.handler.handler(ws, req, foundHandler.match, { log, docker, dockerFilter }) - })) + app.get('/healthz', { logLevel: 'warn' }, async () => 'OK') - const upgradeHandler = tryUpgradeHandler({ log }, async (req, socket, head) => { - if (req.headers.upgrade?.toLowerCase() !== 'websocket') { - throw new NotFoundError() - } + await app.register(env, { composeModelPath, currentSshState, envMetadata, machineStatus }) + await app.register(containers, { docker, dockerFilter, prefix: '/containers' }) - wss.handleUpgrade(req, socket, head, client => { - wss.emit('connection', client, req) - }) - }) - - return { handler, upgradeHandler } + return app } - -export default createApiServerHandlers diff --git a/packages/compose-tunnel-agent/src/api-server/query-params.ts b/packages/compose-tunnel-agent/src/api-server/query-params.ts deleted file mode 100644 index efe9a0c5..00000000 --- a/packages/compose-tunnel-agent/src/api-server/query-params.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { defaults } from 'lodash' -import url from 'node:url' - -export const parseQueryParams = < -T extends Record ->(requestUrl: string, defaultValues: Partial = {}) => { - const { search } = url.parse(requestUrl) - const queryParams = new URLSearchParams(search || '') - return { search: queryParams, obj: defaults(Object.fromEntries(queryParams), defaultValues) } -} - -export const queryParamBoolean = (v: string | boolean | undefined, defaultValue = false): boolean => { - if (typeof v === 'boolean') { - return v - } - if (typeof v === 'undefined' || v === '') { - return defaultValue - } - return v === '1' || v.toLowerCase() === 'true' -} diff --git a/packages/compose-tunnel-agent/src/api-server/ws/handler.ts b/packages/compose-tunnel-agent/src/api-server/ws/handler.ts deleted file mode 100644 index 7f5aa9b9..00000000 --- a/packages/compose-tunnel-agent/src/api-server/ws/handler.ts +++ /dev/null @@ -1,33 +0,0 @@ -import http from 'node:http' -import { Logger } from 'pino' -import WebSocket from 'ws' -import Dockerode from 'dockerode' -import { DockerFilterClient } from '../../docker' - -type Context = { log: Logger; docker: Dockerode; dockerFilter: DockerFilterClient } -export type WsHandlerFunc = ( - ws: WebSocket, - req: http.IncomingMessage, - match: RegExpMatchArray, - context: Context, -) => Promise - -export type WsHandler = { - matchRequest: RegExp - handler: WsHandlerFunc -} - -export const wsHandler = ( - matchRequest: RegExp, - handler: WsHandlerFunc -) => ({ matchRequest, handler }) - -export const findHandler = (handlers: WsHandler[], req: http.IncomingMessage) => { - for (const handler of handlers) { - const match = handler.matchRequest.exec(req.url ?? '') - if (match) { - return { handler, match } - } - } - return undefined -} diff --git a/packages/compose-tunnel-agent/src/api-server/ws/handlers/logs.ts b/packages/compose-tunnel-agent/src/api-server/ws/handlers/logs.ts deleted file mode 100644 index a17120fd..00000000 --- a/packages/compose-tunnel-agent/src/api-server/ws/handlers/logs.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { inspect } from 'util' -import { createWebSocketStream } from 'ws' -import { parseQueryParams, queryParamBoolean } from '../../query-params' -import { wsHandler } from '../handler' -import { NotFoundError } from '../../http-server-helpers' - -const handler = wsHandler( - /^\/container\/([^/?]+)\/logs($|\?)/, - async (ws, req, match, { log, docker, dockerFilter }) => { - const id = match[1] - if (!await dockerFilter.inspectContainer(id)) { - throw new NotFoundError() - } - const { stdout, stderr, since, until, timestamps, tail } = parseQueryParams(req.url ?? '').obj - const abort = new AbortController() - const logStream = await docker.getContainer(id).logs({ - stdout: queryParamBoolean(stdout), - stderr: queryParamBoolean(stderr), - since, - until, - timestamps: queryParamBoolean(timestamps), - tail: tail !== undefined ? Number(tail) : undefined, - follow: true, - abortSignal: abort.signal, - }) - - logStream.on('close', async () => { ws.close() }) - logStream.on('error', err => { - if (err.message !== 'aborted') { - log.error('logs stream error %j', inspect(err)) - } - }) - ws.on('close', () => { abort.abort() }) - - const wsStream = createWebSocketStream(ws) - wsStream.on('error', err => { log.error('wsStream error %j', inspect(err)) }) - docker.modem.demuxStream(logStream, wsStream, wsStream) - - return undefined - }, -) - -export default handler diff --git a/packages/compose-tunnel-agent/src/api-server/ws/index.ts b/packages/compose-tunnel-agent/src/api-server/ws/index.ts deleted file mode 100644 index c3acb87a..00000000 --- a/packages/compose-tunnel-agent/src/api-server/ws/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { WsHandler } from './handler' -import exec from './handlers/exec' -import logs from './handlers/logs' - -export const handlers: WsHandler[] = [ - exec, - logs, -] - -export { findHandler } from './handler' diff --git a/yarn.lock b/yarn.lock index 9275c4c2..e2ce7039 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2935,6 +2935,14 @@ ajv-formats "^2.1.1" fast-uri "^2.0.0" +"@fastify/cors@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@fastify/cors/-/cors-8.3.0.tgz#f03d745731b770793a1a15344da7220ca0d19619" + integrity sha512-oj9xkka2Tg0MrwuKhsSUumcAkfp2YCnKxmFEusi01pjk1YrdDsuSYTHXEelWNW+ilSy/ApZq0c2SvhKrLX0H1g== + dependencies: + fastify-plugin "^4.0.0" + mnemonist "0.39.5" + "@fastify/deepmerge@^1.0.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@fastify/deepmerge/-/deepmerge-1.3.0.tgz#8116858108f0c7d9fd460d05a7d637a13fe3239a" @@ -2959,6 +2967,14 @@ dependencies: fastify-plugin "^4.0.0" +"@fastify/websocket@^8.2.0": + version "8.2.0" + resolved "https://registry.yarnpkg.com/@fastify/websocket/-/websocket-8.2.0.tgz#2f5785a88e8188bff9cc17821d5d3d09458a6714" + integrity sha512-B4tlHFBKCX7tenEG9aUcQEpksW2e0+dgRTaH/05+cro1Xsq1+kSj+9IB9Gep7a0KbHZGrat+zBsOas6lRs5dFQ== + dependencies: + fastify-plugin "^4.0.0" + ws "^8.0.0" + "@gar/promisify@^1.0.1", "@gar/promisify@^1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" @@ -7842,6 +7858,13 @@ fastify-plugin@^4.0.0: resolved "https://registry.yarnpkg.com/fastify-plugin/-/fastify-plugin-4.5.1.tgz#44dc6a3cc2cce0988bc09e13f160120bbd91dbee" integrity sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ== +fastify-type-provider-zod@^1.1.9: + version "1.1.9" + resolved "https://registry.yarnpkg.com/fastify-type-provider-zod/-/fastify-type-provider-zod-1.1.9.tgz#fcbb089e20cb91b9798ca8080a52217df191ab7f" + integrity sha512-Ztnu1ZWJEKJouZvdTyfgjuVqS+A4JLoCbWBvFqFhfnrg6YQvEvW+5cJvP98kNbuV5gjfpWmHSOTi3BpkidJPQg== + dependencies: + zod-to-json-schema "^3.17.1" + fastify@^4.22.2: version "4.22.2" resolved "https://registry.yarnpkg.com/fastify/-/fastify-4.22.2.tgz#ad5ad555c9612874e8dcd7181a248fe3674142e7" @@ -10974,6 +10997,13 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mnemonist@0.39.5: + version "0.39.5" + resolved "https://registry.yarnpkg.com/mnemonist/-/mnemonist-0.39.5.tgz#5850d9b30d1b2bc57cc8787e5caa40f6c3420477" + integrity sha512-FPUtkhtJ0efmEFGpU14x7jGbTB+s18LrzRL2KgoWz9YvcY3cPomz8tih01GbHwnGk/OmkOKfqd/RAQoc8Lm7DQ== + dependencies: + obliterator "^2.0.1" + mock-stdin@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/mock-stdin/-/mock-stdin-1.0.0.tgz#efcfaf4b18077e14541742fd758b9cae4e5365ea" @@ -11605,6 +11635,11 @@ object.values@^1.1.6: define-properties "^1.1.4" es-abstract "^1.20.4" +obliterator@^2.0.1: + version "2.0.4" + resolved "https://registry.yarnpkg.com/obliterator/-/obliterator-2.0.4.tgz#fa650e019b2d075d745e44f1effeb13a2adbe816" + integrity sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ== + oclif@^3: version "3.7.0" resolved "https://registry.yarnpkg.com/oclif/-/oclif-3.7.0.tgz#6507033312dbcae25e99050010c33d67a31e93a3" @@ -14698,7 +14733,7 @@ write-pkg@4.0.0: type-fest "^0.4.1" write-json-file "^3.2.0" -ws@^8.11.0, ws@^8.13.0: +ws@^8.0.0, ws@^8.11.0, ws@^8.13.0: version "8.13.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== @@ -14897,6 +14932,11 @@ yosay@^2.0.2: taketalk "^1.0.0" wrap-ansi "^2.0.0" +zod-to-json-schema@^3.17.1: + version "3.21.4" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.21.4.tgz#de97c5b6d4a25e9d444618486cb55c0c7fb949fd" + integrity sha512-fjUZh4nQ1s6HMccgIeE0VP4QG/YRGPmyjO9sAh890aQKPEk3nqbfUXhMFaC+Dr5KvYBm8BCyvfpZf2jY9aGSsw== + zod@3.20.6: version "3.20.6" resolved "https://registry.yarnpkg.com/zod/-/zod-3.20.6.tgz#2f2f08ff81291d47d99e86140fedb4e0db08361a"