From d58bd24b91cc4656293ffe7177e793f7bde2ba65 Mon Sep 17 00:00:00 2001 From: Roy Razon Date: Wed, 30 Aug 2023 11:55:11 +0300 Subject: [PATCH] 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,