Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
Roy Razon committed Dec 19, 2023
1 parent eb5bb52 commit 132a659
Show file tree
Hide file tree
Showing 15 changed files with 237 additions and 142 deletions.
1 change: 1 addition & 0 deletions packages/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export { tunnelNameResolver, TunnelNameResolver } from './src/tunnel-name.js'
export { editUrl } from './src/url.js'
export {
ScriptInjection,
ContainerScriptInjection,
parseScriptInjectionLabels,
scriptInjectionsToLabels,
COMPOSE_TUNNEL_AGENT_PORT,
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/compose-tunnel-agent/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { COMPOSE_TUNNEL_AGENT_SERVICE_LABELS } from './labels.js'
export { ScriptInjection, parseScriptInjectionLabels, scriptInjectionsToLabels } from './script-injection.js'
export { ScriptInjection, ContainerScriptInjection, parseScriptInjectionLabels, scriptInjectionsToLabels } from './script-injection.js'

export const COMPOSE_TUNNEL_AGENT_SERVICE_NAME = 'preevy_proxy'
export const COMPOSE_TUNNEL_AGENT_PORT = 3000
13 changes: 13 additions & 0 deletions packages/common/src/compose-tunnel-agent/script-injection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ describe('script injection labels', () => {
'preevy.inject_script.widget.defer': 'true',
'preevy.inject_script.widget.async': 'false',
'preevy.inject_script.widget.path_regex': 't.*t',
'preevy.inject_script.widget.port': '3000',
}

const [scripts, errors] = parseScriptInjectionLabels(labels)
Expand All @@ -19,6 +20,7 @@ describe('script injection labels', () => {
defer: true,
async: false,
pathRegex: expect.any(RegExp),
port: 3000,
})
expect(scripts[0].pathRegex?.source).toBe('t.*t')
})
Expand All @@ -42,6 +44,17 @@ describe('script injection labels', () => {
expect(errors).toHaveLength(1)
})

test('should drop scripts with an invalid number as port', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget.defer': 'true',
'preevy.inject_script.widget.port': 'a',
}
const [scripts, errors] = parseScriptInjectionLabels(labels)
expect(scripts).toHaveLength(0)
expect(errors).toHaveLength(1)
})

test('should support multiple scripts', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
Expand Down
26 changes: 20 additions & 6 deletions packages/common/src/compose-tunnel-agent/script-injection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,23 @@ export type ScriptInjection = {
async?: boolean
}

const parseBooleanLabelValue = (s:string) => s === 'true' || s === '1'
export type ContainerScriptInjection = ScriptInjection & {
port?: number
}

const parseBooleanLabelValue = (s: string) => s === 'true' || s === '1'

const parseNumber = (s: string): number => {
const result = Number(s)
if (Number.isNaN(result)) {
throw new Error(`invalid number "${s}"`)
}
return result
}

const parseScriptInjection = (o: Record<string, string>): ScriptInjection | Error => {
const parseScriptInjection = (o: Record<string, string>): ContainerScriptInjection | Error => {
// eslint-disable-next-line camelcase
const { src, defer, async, path_regex } = o
const { src, defer, async, path_regex, port } = o
try {
if (!src) {
throw new Error('missing src')
Expand All @@ -23,6 +35,7 @@ const parseScriptInjection = (o: Record<string, string>): ScriptInjection | Erro
...path_regex && { pathRegex: new RegExp(path_regex) },
...defer && { defer: parseBooleanLabelValue(defer) },
...async && { async: parseBooleanLabelValue(async) },
...port && { port: parseNumber(port) },
src,
}
} catch (e) {
Expand All @@ -32,12 +45,13 @@ const parseScriptInjection = (o: Record<string, string>): ScriptInjection | Erro

const scriptInjectionToLabels = (
id: string,
{ src, async, defer, pathRegex }: ScriptInjection,
{ src, async, defer, pathRegex, port }: ContainerScriptInjection,
): Record<string, string> => mapKeys<Record<string, string>>({
src,
...async && { async: 'true' },
...defer && { defer: 'true' },
...pathRegex && { path_regex: pathRegex.source },
...port && { port: port.toString() },
}, (_value, key) => [COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.INJECT_SCRIPT_PREFIX, id, key].join('.'))

export const scriptInjectionsToLabels = (
Expand Down Expand Up @@ -66,11 +80,11 @@ const parseLabelsWithPrefixAndId = (

export const parseScriptInjectionLabels = (
labels: Record<string, string>,
): [ScriptInjection[], Error[]] => {
): [ContainerScriptInjection[], Error[]] => {
const stringifiedInjections = parseLabelsWithPrefixAndId(
labels,
COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.INJECT_SCRIPT_PREFIX,
)
const injectionOrErrors = stringifiedInjections.map(parseScriptInjection)
return partition(injectionOrErrors, x => !(x instanceof Error)) as [ScriptInjection[], Error[]]
return partition(injectionOrErrors, x => !(x instanceof Error)) as [ContainerScriptInjection[], Error[]]
}
20 changes: 11 additions & 9 deletions packages/compose-tunnel-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { runMachineStatusCommand } from './src/machine-status.js'
import { envMetadata } from './src/metadata.js'
import { readAllFiles } from './src/files.js'
import { eventsClient as dockerEventsClient, filteredClient as dockerFilteredClient } from './src/docker/index.js'
import { anyComposeProjectFilters, composeProjectFilters } from './src/docker/filters.js'

const PinoPretty = pinoPrettyModule.default

Expand Down Expand Up @@ -81,21 +82,22 @@ const main = async () => {
clientPublicKey: formatPublicKey(connectionConfig.clientPrivateKey),
})

const dockerFilters = targetComposeProject
? composeProjectFilters({ composeProject: targetComposeProject })
: anyComposeProjectFilters

const docker = new Docker({ socketPath: dockerSocket })
const dockerClient = dockerEventsClient({
log: log.child({ name: 'docker' }),
docker,
debounceWait: 500,
defaultAccess,
composeProject: targetComposeProject,
filters: dockerFilters,
tunnelNameResolver: tunnelNameResolver({ envId: requiredEnv('PREEVY_ENV_ID') }),
})

const sshLog = log.child({ name: 'ssh' })
const sshClient = await createSshClient({
connectionConfig,
tunnelNameResolver: tunnelNameResolver({ envId: requiredEnv('PREEVY_ENV_ID') }),
log: sshLog,
})
const sshClient = await createSshClient({ connectionConfig, log: sshLog })

sshClient.ssh.on('close', () => {
if (!endRequested) {
Expand All @@ -106,11 +108,11 @@ const main = async () => {
})

sshLog.info('ssh client connected to %j', sshUrl)
let currentTunnels = dockerClient.getRunningServices().then(services => sshClient.updateTunnels(services))
let currentTunnels = dockerClient.getForwards().then(services => sshClient.updateTunnels(services))

void dockerClient.startListening({
onChange: async services => {
currentTunnels = sshClient.updateTunnels(services)
onChange: async forwards => {
currentTunnels = sshClient.updateTunnels(forwards)
},
})

Expand Down
1 change: 1 addition & 0 deletions packages/compose-tunnel-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"pino-pretty": "^10.2.3",
"rimraf": "^5.0.5",
"ssh2": "^1.12.0",
"tseep": "^1.1.3",
"ws": "^8.13.0",
"zod": "^3.21.4"
},
Expand Down
40 changes: 17 additions & 23 deletions packages/compose-tunnel-agent/src/docker/events-client.ts
Original file line number Diff line number Diff line change
@@ -1,48 +1,42 @@
import Docker from 'dockerode'
import { tryParseJson, Logger, ScriptInjection } from '@preevy/common'
import { tryParseJson, Logger, TunnelNameResolver } from '@preevy/common'
import { throttle } from 'lodash-es'
import { inspect } from 'util'
import { filters } from './filters.js'
import { containerToService } from './services.js'

export type RunningService = {
project: string
name: string
networks: string[]
ports: number[]
access: 'private' | 'public'
inject: ScriptInjection[]
}
import { DockerFilters } from './filters.js'
import { composeContainerToForwards } from './services.js'
import { Forward } from '../ssh/tunnel-client.js'

export const eventsClient = ({
log,
docker,
debounceWait,
composeProject,
filters: { apiFilter },
tunnelNameResolver,
defaultAccess,
}: {
log: Logger
docker: Pick<Docker, 'getEvents' | 'listContainers' | 'getContainer'>
debounceWait: number
composeProject?: string
filters: Pick<DockerFilters, 'apiFilter'>
tunnelNameResolver: TunnelNameResolver
defaultAccess: 'private' | 'public'
}) => {
const { listContainers, apiFilter } = filters({ docker, composeProject })

const getRunningServices = async (): Promise<RunningService[]> => (await listContainers()).map(container => {
const { errors, ...service } = containerToService({ container, defaultAccess })
const getForwards = async (): Promise<Forward[]> => (
await docker.listContainers({ all: true, filters: { ...apiFilter } })
).flatMap(container => {
const { errors, forwards } = composeContainerToForwards({ container, defaultAccess, tunnelNameResolver })
if (errors.length) {
log.warn('error parsing docker container "%s" info, some information may be missing: %j', container.Names?.[0], inspect(errors))
}
return service
return forwards
})

const startListening = async ({ onChange }: { onChange: (services: RunningService[]) => void }) => {
const startListening = async ({ onChange }: { onChange: (forwards: Forward[]) => void }) => {
const handler = throttle(async (data?: Buffer) => {
log.debug('event handler: %j', data && tryParseJson(data.toString()))

const services = await getRunningServices()
onChange(services)
const forwards = await getForwards()
onChange(forwards)
}, debounceWait, { leading: true, trailing: true })

const stream = await docker.getEvents({
Expand All @@ -59,7 +53,7 @@ export const eventsClient = ({
return { close: () => stream.removeAllListeners() }
}

return { getRunningServices, startListening }
return { getForwards, startListening }
}

export type DockerEventsClient = ReturnType<typeof eventsClient>
17 changes: 10 additions & 7 deletions packages/compose-tunnel-agent/src/docker/filtered-client.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Docker from 'dockerode'
import { filters } from './filters.js'
import { anyComposeProjectFilters, composeProjectFilters, filters } from './filters.js'

export const filteredClient = ({
docker,
Expand All @@ -8,14 +8,17 @@ export const filteredClient = ({
docker: Pick<Docker, 'getEvents' | 'listContainers' | 'getContainer'>
composeProject?: string
}) => {
const { listContainers, adhocFilter } = filters({ docker, composeProject })
const { apiFilter, adhocFilter } = composeProject
? composeProjectFilters({ composeProject })
: anyComposeProjectFilters

const inspectContainer = async (id: string) => {
const result = await docker.getContainer(id).inspect()
return result && adhocFilter(result.Config) ? result : undefined
return {
listContainers: docker.listContainers({ all: true, filters: { ...apiFilter } }),
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<typeof filteredClient>
44 changes: 23 additions & 21 deletions packages/compose-tunnel-agent/src/docker/filters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Docker from 'dockerode'
import Docker, { GetEventsOptions } from 'dockerode'
import { COMPOSE_TUNNEL_AGENT_SERVICE_LABELS } from '@preevy/common'
import { COMPOSE_PROJECT_LABEL } from './labels.js'

Expand All @@ -22,27 +22,29 @@ export const portFilter = (
return (p: Docker.Port) => Boolean(p.PublicPort)
}

export const filters = ({
docker,
composeProject,
}: {
docker: Pick<Docker, 'listContainers'>
composeProject?: string
}) => {
const apiFilter = {
label: composeProject ? [`${COMPOSE_PROJECT_LABEL}=${composeProject}`] : [COMPOSE_PROJECT_LABEL],
}
export type DockerApiFilter = {
label?: string[]
}

const listContainers = async () => await docker.listContainers({
all: true,
filters: { ...apiFilter },
})
export type DockerFilters = {
apiFilter: DockerApiFilter
adhocFilter: (c: Docker.ContainerInfo) => boolean
}

const adhocFilter = (c: Pick<Docker.ContainerInfo, 'Labels'>): boolean => (
composeProject
? c.Labels[COMPOSE_PROJECT_LABEL] === composeProject
: COMPOSE_PROJECT_LABEL in c.Labels
)
export const composeProjectFilters = ({
composeProject,
}: {
composeProject: string
}): DockerFilters => ({
apiFilter: {
label: [`${COMPOSE_PROJECT_LABEL}=${composeProject}`],
},
adhocFilter: (c: Pick<Docker.ContainerInfo, 'Labels'>): boolean => c.Labels[COMPOSE_PROJECT_LABEL] === composeProject,
})

return { listContainers, adhocFilter, apiFilter }
export const anyComposeProjectFilters: DockerFilters = {
apiFilter: {
label: [COMPOSE_PROJECT_LABEL],
},
adhocFilter: (c: Pick<Docker.ContainerInfo, 'Labels'>): boolean => COMPOSE_PROJECT_LABEL in c.Labels,
}
Loading

0 comments on commit 132a659

Please sign in to comment.