Skip to content

Commit

Permalink
Add support for widget injection in connect command (#238)
Browse files Browse the repository at this point in the history
* auth refactor

* CR fix

* add missing issuer

* add support for script injection in connect.
refactor script injection format.
added tests

* formatting and removed dead code

* CR Fixes

* CR fixes

* fix

* small refactor
  • Loading branch information
Yshayy authored Oct 2, 2023
1 parent 116a7fc commit 1db2036
Show file tree
Hide file tree
Showing 11 changed files with 268 additions and 34 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 = await 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),
projectDirectory,
})
Expand Down
1 change: 1 addition & 0 deletions packages/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ 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 * from './src/compose-utils'
export { MachineStatusCommand, DockerMachineStatusCommandRecipe } from './src/machine-status-command'
export { ProcessOutputBuffers, orderedOutput, OrderedOutput } from './src/process-output-buffers'
74 changes: 74 additions & 0 deletions packages/common/src/compose-tunnel-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* 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.widget.defer': '1',
'preevy.inject_script.widget2.src': 'https://my-script2',
'preevy.inject_script.widget2.defer': 'false',
'preevy.inject_script.widget3.src': 'https://my-script3',
'preevy.inject_script.widget3.defer': '0',
}
const scripts = scriptInjectionFromLabels(labels)
expect(scripts).toHaveLength(3)
expect(scripts).toMatchObject([
{
src: 'https://my-script',
defer: true,
},
{
src: 'https://my-script2',
defer: false,
},
{
src: 'https://my-script3',
defer: false,
},
])
})
})
37 changes: 34 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 { extractSectionsFromLabels, parseBooleanLabelValue } from './compose-utils'

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,34 @@ 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: parseBooleanLabelValue(defer) },
...async && { async: parseBooleanLabelValue(async) },
src,
}
} catch (e) {
return e as Error
}
}

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))
}
23 changes: 23 additions & 0 deletions packages/common/src/compose-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { set, camelCase, snakeCase } from 'lodash'

export const extractSectionsFromLabels = <T>(prefix: string, labels: Record<string, string>) => {
const sections:{[id:string]: T } = {}
const normalizedPrefix = prefix.endsWith('.') ? prefix : `${prefix}.`
Object.entries(labels)
.filter(([key]) => key.startsWith(normalizedPrefix))
.map(([key, value]) => [...key.substring(normalizedPrefix.length).split('.'), value])
.forEach(([id, prop, value]) => set(sections, [id, camelCase(prop)], value))
return sections
}

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

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)])))
33 changes: 5 additions & 28 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 { tryParseJson, Logger, ScriptInjection } from '@preevy/common'
import { throttle } from 'lodash'
import { filters, portFilter } from './filters'
import { COMPOSE_PROJECT_LABEL, COMPOSE_SERVICE_LABEL } from './labels'
import { filters } from './filters'
import { containerToService } from './services'

export type RunningService = {
project: string
Expand All @@ -13,20 +13,6 @@ export type RunningService = {
inject: ScriptInjection[]
}

const reviveScriptInjection = ({ pathRegex, ...v }: ScriptInjection) => ({
...pathRegex && { pathRegex: new RegExp(pathRegex) },
...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 eventsClient = ({
log,
docker,
Expand All @@ -42,17 +28,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
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),
})
12 changes: 10 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 @@ -70,9 +71,11 @@ export async 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 = {
Expand All @@ -90,10 +93,10 @@ export async function initProxyComposeModel(opts: {
git: opts.projectDirectory ? await detectGitMetadata(opts.projectDirectory) : undefined,
}

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 @@ -104,6 +107,11 @@ export async 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
44 changes: 44 additions & 0 deletions packages/core/src/compose/script-injection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { describe, test, expect } from '@jest/globals'
import { ComposeModel } from './model'
import { scriptInjector } from './script-injection'

describe('script injection', () => {
test('inject script to all services', async () => {
const model:ComposeModel = {
name: 'my-app',
services: {
frontend1: {},
frontend2: {
labels: {
other: 'value',
},
},
},
}

const injector = scriptInjector('test', { src: 'https://mydomain.com/myscript.ts', async: true, pathRegex: /.*/ })
const newModel = injector.inject(model)
expect(newModel.services?.frontend1?.labels).toMatchObject({ 'preevy.inject_script.test.src': 'https://mydomain.com/myscript.ts', 'preevy.inject_script.test.async': 'true', 'preevy.inject_script.test.path_regex': '.*' })
expect(newModel.services?.frontend2?.labels).toMatchObject({ other: 'value', 'preevy.inject_script.test.src': 'https://mydomain.com/myscript.ts', 'preevy.inject_script.test.async': 'true', 'preevy.inject_script.test.path_regex': '.*' })
})

test('does not affect original model', async () => {
const model:ComposeModel = {
name: 'my-app',
services: {
frontend1: {},
frontend2: {
labels: {
other: 'value',
},
},
},
}

const injector = scriptInjector('test', { src: 'https://mydomain.com/myscript.ts' })
const newModel = injector.inject(model)
expect(model.services?.frontend1?.labels).toBeUndefined()
expect(model.services?.frontend2?.labels).not
.toMatchObject(newModel.services?.frontend2?.labels as Record<string, unknown>)
})
})
Loading

0 comments on commit 1db2036

Please sign in to comment.