Skip to content

Commit

Permalink
add support for script injection in connect.
Browse files Browse the repository at this point in the history
refactor script injection format.
added tests
  • Loading branch information
Yshayy committed Oct 2, 2023
1 parent ddc45b2 commit b933cfa
Show file tree
Hide file tree
Showing 10 changed files with 340 additions and 29 deletions.
12 changes: 12 additions & 0 deletions packages/cli/src/commands/proxy/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ export default class Connect extends ProfileCommand<typeof Connect> {
description: 'specify the environment ID for this app',
required: false,
}),
'disable-widget': Flags.boolean({
default: true,
hidden: true,
}),
'livecycle-widget-url': Flags.string({
required: true,
hidden: true,
env: 'LIVECYCLE_WIDGET_URL',
default: 'https://app.livecycle.run/widget/widget-bootstrap.js'
}),
'private-env': Flags.boolean({
description: 'Mark all services as private',
default: false,
Expand Down Expand Up @@ -85,10 +95,12 @@ export default class Connect extends ProfileCommand<typeof Connect> {
const model = commands.proxy.initProxyComposeModel({
version: this.config.version,
envId,
debug: this.flags.debug,
projectName: composeProject,
tunnelOpts,
networks,
privateMode: flags['private-env'],
injectLivecycleScript: flags['disable-widget'] ? undefined : flags['livecycle-widget-url'],
tunnelingKeyThumbprint: await jwkThumbprint(tunnelingKey),
})

Expand Down
68 changes: 68 additions & 0 deletions packages/common/src/compose-tunnel-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, test, expect } from '@jest/globals'
import { scriptInjectionFromLabels } from './compose-tunnel-agent'

describe('parse script injection labels', () => {
test('should parse correctly', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget.defer': 'true',
'preevy.inject_script.widget.async': 'false',
'preevy.inject_script.widget.path_regex': 't.*t',
}
const scriptInjections = scriptInjectionFromLabels(labels)
expect(scriptInjections).toHaveLength(1)
const [script] = scriptInjections
expect(script).toMatchObject({
src: 'https://my-script',
defer: true,
async: false,
pathRegex: expect.any(RegExp),
})
})
test('should revive regex correctly', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget.path_regex': 't.*t',
}
const [script] = scriptInjectionFromLabels(labels)
expect('test').toMatch(script.pathRegex!)
expect('best').not.toMatch(script.pathRegex!)
})

test('should ignore scripts with invalid regex', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget.path_regex': '[',
}
expect(scriptInjectionFromLabels(labels)).toHaveLength(0)
})

test('should drop scripts without src', () => {
const labels = {
'preevy.inject_script.widget.defer': 'true',
}
expect(scriptInjectionFromLabels(labels)).toHaveLength(0)
})

test('should support multiple scripts', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget2.src': 'https://my-script2',
'preevy.inject_script.widget3.src': 'https://my-script3',
}
const scripts = scriptInjectionFromLabels(labels)
expect(scripts).toHaveLength(3)
expect(scripts).toMatchObject([
{
src: 'https://my-script',
},
{
src: 'https://my-script2',
},
{
src: 'https://my-script3',
},
])
})
})
58 changes: 55 additions & 3 deletions packages/common/src/compose-tunnel-agent.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { camelCase, set, snakeCase } from 'lodash'

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',
INJECT_SCRIPT_SRC: 'preevy.inject_script_src',
INJECT_SCRIPTS: 'preevy.inject_scripts',
INJECT_SCRIPT_PATH_REGEX: 'preevy.inject_script_path_regex',
INJECT_SCRIPT_PREFIX: 'preevy.inject_script',
}

export const COMPOSE_TUNNEL_AGENT_SERVICE_NAME = 'preevy_proxy'
Expand All @@ -18,3 +18,55 @@ export type ScriptInjection = {
defer?: boolean
async?: boolean
}

type Stringified<T> = {
[k in keyof T]: string
}
const parseScriptInjection = ({ pathRegex, defer, async, src }: Stringified<ScriptInjection>):
ScriptInjection | Error => {
try {
if (!src) {
throw new Error('missing src')
}
return {
...pathRegex && { pathRegex: new RegExp(pathRegex) },
...defer && { defer: defer === 'true' },
...async && { async: async === 'true' },
src,
}
} catch (e) {
return e as Error
}
}

export const extractSectionsFromLabels = <T>(prefix: string, labels: Record<string, string>) => {
const re = new RegExp(`^${prefix.replace(/\./g, '\\.')}\\.(?<id>.+?)\\.(?<key>[^.]+)$`)
const sections:{[id:string]: T } = {}
for (const [label, value] of Object.entries(labels)) {
const match = label.match(re)?.groups
if (match) {
set(sections, [match.id, camelCase(match.key)], value)
}
}
return sections
}

export const scriptInjectionFromLabels = (labels : Record<string, string>): ScriptInjection[] => {
const scripts = extractSectionsFromLabels<Stringified<ScriptInjection>>(
COMPOSE_TUNNEL_AGENT_SERVICE_LABELS.INJECT_SCRIPT_PREFIX,
labels
)
return Object.values(scripts)
.map(parseScriptInjection)
.filter((x): x is ScriptInjection => !(x instanceof Error))
}

const formatValueLabel = (x:unknown) => {
if (x instanceof RegExp) {
return x.source
}
return `${x}`
}

export const sectionToLabels = (prefix: string, section: Record<string, unknown>) =>
Object.fromEntries(Object.entries(section).map(([key, value]) => ([`${prefix}.${snakeCase(key)}`, formatValueLabel(value)])))
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,8 +1,8 @@
import Docker from 'dockerode'
import { tryParseJson, Logger, COMPOSE_TUNNEL_AGENT_SERVICE_LABELS as LABELS, ScriptInjection, tryParseYaml } from '@preevy/common'
import { throttle } from 'lodash'
import { filters, portFilter } from './filters'
import { COMPOSE_PROJECT_LABEL, COMPOSE_SERVICE_LABEL } from './labels'
import { tryParseJson, Logger, ScriptInjection } from '@preevy/common'
import { set, throttle } from 'lodash'
import { filters } from './filters'
import { containerToService } from './services'

export type RunningService = {
project: string
Expand All @@ -18,14 +18,17 @@ const reviveScriptInjection = ({ pathRegex, ...v }: ScriptInjection) => ({
...v,
})

const scriptInjectionFromLabels = ({
[LABELS.INJECT_SCRIPTS]: scriptsText,
[LABELS.INJECT_SCRIPT_SRC]: src,
[LABELS.INJECT_SCRIPT_PATH_REGEX]: pathRegex,
} : Record<string, string>): ScriptInjection[] => [
...(scriptsText ? (tryParseJson(scriptsText) || tryParseYaml(scriptsText) || []) : []),
...src ? [{ src, ...pathRegex && { pathRegex } }] : [],
].map(reviveScriptInjection)
export const scriptInjectionFromLabels = (labels : Record<string, string>): ScriptInjection[] => {
const re = /^preevy\.inject_script\.(?<id>.+?)\.(?<key>[^.]+)$/
const scripts:{[id:string]: Partial<ScriptInjection> } = {}
for (const [label, value] of Object.entries(labels)) {
const match = label.match(re)?.groups
if (match) {
set(scripts, [match.id, match.attribute], value)
}
}
return (Object.values(scripts).filter(x => !!x.src) as ScriptInjection[]).map(reviveScriptInjection)
}

export const eventsClient = ({
log,
Expand All @@ -42,17 +45,8 @@ export const eventsClient = ({
}) => {
const { listContainers, apiFilter } = filters({ docker, composeProject })

const containerToService = (c: Docker.ContainerInfo): RunningService => ({
project: c.Labels[COMPOSE_PROJECT_LABEL],
name: c.Labels[COMPOSE_SERVICE_LABEL],
access: (c.Labels[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))],
inject: scriptInjectionFromLabels(c.Labels),
})

const getRunningServices = async (): Promise<RunningService[]> => (await listContainers()).map(containerToService)
const toService = (container: Docker.ContainerInfo) => containerToService({ container, defaultAccess })
const getRunningServices = async (): Promise<RunningService[]> => (await listContainers()).map(toService)

const startListening = async ({ onChange }: { onChange: (services: RunningService[]) => void }) => {
const handler = throttle(async (data?: Buffer) => {
Expand Down
68 changes: 68 additions & 0 deletions packages/compose-tunnel-agent/src/docker/services.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { describe, test, expect } from '@jest/globals'
import { scriptInjectionFromLabels } from './services'

describe('parse script injection labels', () => {
test('should parse correctly', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget.defer': 'true',
'preevy.inject_script.widget.async': 'false',
'preevy.inject_script.widget.path_regex': 't.*t',
}
const scriptInjections = scriptInjectionFromLabels(labels)
expect(scriptInjections).toHaveLength(1)
const [script] = scriptInjections
expect(script).toMatchObject({
src: 'https://my-script',
defer: true,
async: false,
pathRegex: expect.any(RegExp),
})
})
test('should revive regex correctly', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget.path_regex': 't.*t',
}
const [script] = scriptInjectionFromLabels(labels)
expect('test').toMatch(script.pathRegex!)
expect('best').not.toMatch(script.pathRegex!)
})

test('should ignore scripts with invalid regex', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget.path_regex': '[',
}
expect(scriptInjectionFromLabels(labels)).toHaveLength(0)
})

test('should drop scripts without src', () => {
const labels = {
'preevy.inject_script.widget.defer': 'true',
}
expect(scriptInjectionFromLabels(labels)).toHaveLength(0)
})

test('should support multiple scripts', () => {
const labels = {
'preevy.inject_script.widget.src': 'https://my-script',
'preevy.inject_script.widget2.src': 'https://my-script2',
'preevy.inject_script.widget3.src': 'https://my-script3',
}
const scripts = scriptInjectionFromLabels(labels)
expect(scripts).toHaveLength(3)
expect(scripts).toMatchObject([
{
src: 'https://my-script',
},
{
src: 'https://my-script2',
},
{
src: 'https://my-script3',
},
])
})
})
26 changes: 26 additions & 0 deletions packages/compose-tunnel-agent/src/docker/services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { ScriptInjection, COMPOSE_TUNNEL_AGENT_SERVICE_LABELS as PREEVY_LABELS, scriptInjectionFromLabels } from '@preevy/common'
import Docker from 'dockerode'
import { 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'
inject: ScriptInjection[]
}

export const containerToService = ({
container,
defaultAccess,
}: {container: Docker.ContainerInfo; defaultAccess: 'private' | 'public'}): RunningService => ({
project: container.Labels[COMPOSE_PROJECT_LABEL],
name: container.Labels[COMPOSE_SERVICE_LABEL],
access: (container.Labels[PREEVY_LABELS.ACCESS] || defaultAccess) as ('private' | 'public'),
networks: Object.keys(container.NetworkSettings.Networks),
// ports may have both IPv6 and IPv4 addresses, ignoring
ports: [...new Set(container.Ports.filter(p => p.Type === 'tcp').filter(portFilter(container)).map(p => p.PrivatePort))],
inject: scriptInjectionFromLabels(container.Labels),
})
13 changes: 11 additions & 2 deletions packages/core/src/commands/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import { Connection } from '../tunneling'
import { execPromiseStdout } from '../child-process'
import { addComposeTunnelAgentService } from '../compose-tunnel-agent-client'
import { widgetScriptInjector } from '../compose/script-injection'
import { ComposeModel } from '../compose'
import { TunnelOpts } from '../ssh'
import { EnvId } from '../env-id'
Expand Down Expand Up @@ -57,9 +58,12 @@ export function initProxyComposeModel(opts: {
projectName: string
tunnelOpts: TunnelOpts
tunnelingKeyThumbprint: string
debug?: boolean
privateMode?: boolean
networks: ComposeModel['networks']
version: string
injectLivecycleScript?: string
projectDirectory?: string
}) {
const compose: ComposeModel = {
version: '3.8',
Expand All @@ -75,10 +79,10 @@ export function initProxyComposeModel(opts: {
profileThumbprint: opts.tunnelingKeyThumbprint,
}

const newComposeModel = addComposeTunnelAgentService({
let newComposeModel = addComposeTunnelAgentService({
tunnelOpts: opts.tunnelOpts,
envId: opts.envId,
debug: true,
debug: !!opts.debug,
composeModelPath: './docker-compose.yml',
envMetadata,
knownServerPublicKeyPath: './tunnel_server_public_key',
Expand All @@ -89,6 +93,11 @@ export function initProxyComposeModel(opts: {
defaultAccess: privateMode ? 'private' : 'public',
}, compose)

if (opts.injectLivecycleScript) {
const { inject } = widgetScriptInjector(opts.injectLivecycleScript)
newComposeModel = inject(newComposeModel)
}

return {
data: newComposeModel,
async write({ tunnelingKey, knownServerPublicKey } :
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/compose/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export type ComposeService = {
ports?: ComposePort[]
environment?: Record<string, string | undefined> | EnvString[]
user?: string
labels?: Record<string, string> | `${string}=${string}`[]
labels?: Record<string, string>
}

export type ComposeModel = {
Expand Down
Loading

0 comments on commit b933cfa

Please sign in to comment.