diff --git a/packages/cli/src/commands/proxy/connect.ts b/packages/cli/src/commands/proxy/connect.ts index f07f3761..59e84021 100644 --- a/packages/cli/src/commands/proxy/connect.ts +++ b/packages/cli/src/commands/proxy/connect.ts @@ -21,6 +21,16 @@ export default class Connect extends ProfileCommand { 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, @@ -85,10 +95,12 @@ export default class Connect extends ProfileCommand { 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), }) diff --git a/packages/common/src/compose-tunnel-agent.test.ts b/packages/common/src/compose-tunnel-agent.test.ts new file mode 100644 index 00000000..cabbe77c --- /dev/null +++ b/packages/common/src/compose-tunnel-agent.test.ts @@ -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', + }, + ]) + }) +}) diff --git a/packages/common/src/compose-tunnel-agent.ts b/packages/common/src/compose-tunnel-agent.ts index 54aa843c..c4e70835 100644 --- a/packages/common/src/compose-tunnel-agent.ts +++ b/packages/common/src/compose-tunnel-agent.ts @@ -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' @@ -18,3 +18,55 @@ export type ScriptInjection = { defer?: boolean async?: boolean } + +type Stringified = { + [k in keyof T]: string +} +const parseScriptInjection = ({ pathRegex, defer, async, src }: Stringified): + 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 = (prefix: string, labels: Record) => { + const re = new RegExp(`^${prefix.replace(/\./g, '\\.')}\\.(?.+?)\\.(?[^.]+)$`) + 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): ScriptInjection[] => { + const scripts = extractSectionsFromLabels>( + 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) => + Object.fromEntries(Object.entries(section).map(([key, value]) => ([`${prefix}.${snakeCase(key)}`, formatValueLabel(value)]))) diff --git a/packages/compose-tunnel-agent/src/docker/events-client.ts b/packages/compose-tunnel-agent/src/docker/events-client.ts index 41659277..6b82aade 100644 --- a/packages/compose-tunnel-agent/src/docker/events-client.ts +++ b/packages/compose-tunnel-agent/src/docker/events-client.ts @@ -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 @@ -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): ScriptInjection[] => [ - ...(scriptsText ? (tryParseJson(scriptsText) || tryParseYaml(scriptsText) || []) : []), - ...src ? [{ src, ...pathRegex && { pathRegex } }] : [], -].map(reviveScriptInjection) +export const scriptInjectionFromLabels = (labels : Record): ScriptInjection[] => { + const re = /^preevy\.inject_script\.(?.+?)\.(?[^.]+)$/ + const scripts:{[id:string]: Partial } = {} + 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, @@ -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 => (await listContainers()).map(containerToService) + const toService = (container: Docker.ContainerInfo) => containerToService({ container, defaultAccess }) + const getRunningServices = async (): Promise => (await listContainers()).map(toService) const startListening = async ({ onChange }: { onChange: (services: RunningService[]) => void }) => { const handler = throttle(async (data?: Buffer) => { diff --git a/packages/compose-tunnel-agent/src/docker/services.test.ts b/packages/compose-tunnel-agent/src/docker/services.test.ts new file mode 100644 index 00000000..e1df42bc --- /dev/null +++ b/packages/compose-tunnel-agent/src/docker/services.test.ts @@ -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', + }, + ]) + }) +}) diff --git a/packages/compose-tunnel-agent/src/docker/services.ts b/packages/compose-tunnel-agent/src/docker/services.ts new file mode 100644 index 00000000..a9f6fe66 --- /dev/null +++ b/packages/compose-tunnel-agent/src/docker/services.ts @@ -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), +}) diff --git a/packages/core/src/commands/proxy.ts b/packages/core/src/commands/proxy.ts index 04f46f68..8d11b9ef 100644 --- a/packages/core/src/commands/proxy.ts +++ b/packages/core/src/commands/proxy.ts @@ -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' @@ -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', @@ -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', @@ -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 } : diff --git a/packages/core/src/compose/model.ts b/packages/core/src/compose/model.ts index d1acd317..1f80edb2 100644 --- a/packages/core/src/compose/model.ts +++ b/packages/core/src/compose/model.ts @@ -51,7 +51,7 @@ export type ComposeService = { ports?: ComposePort[] environment?: Record | EnvString[] user?: string - labels?: Record | `${string}=${string}`[] + labels?: Record } export type ComposeModel = { diff --git a/packages/core/src/compose/script-injection.test.ts b/packages/core/src/compose/script-injection.test.ts new file mode 100644 index 00000000..ea21d6d9 --- /dev/null +++ b/packages/core/src/compose/script-injection.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/compose/script-injection.ts b/packages/core/src/compose/script-injection.ts new file mode 100644 index 00000000..2833dfcd --- /dev/null +++ b/packages/core/src/compose/script-injection.ts @@ -0,0 +1,38 @@ +import { ScriptInjection, sectionToLabels } from '@preevy/common' +import { ComposeModel, ComposeService } from './model' + +export const addScript = (model: ComposeModel, service: string, { id, ...script } : + {id: string} & ScriptInjection):ComposeModel => { + const { services } = model + if (!services || !(service in services)) { + return model + } + const serviceDef = services[service] + return { + ...model, + services: { + ...model.services, + [service]: { + ...serviceDef, + labels: { + ...serviceDef.labels, + ...sectionToLabels(`preevy.inject_script.${id}`, script), + }, + }, + }, + } +} + +export const scriptInjector = (id : string, script: ScriptInjection) => { + const injectScript = (model:ComposeModel, service:string) => addScript(model, service, { id, ...script }) + const injectAll = (_serviceName:string, _def: ComposeService) => true + return { + inject: (model: ComposeModel, serviceFilter = injectAll) => + Object.keys(model.services ?? {}) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .filter(s => serviceFilter(s, model.services![s])) + .reduce(injectScript, model), + } +} + +export const widgetScriptInjector = (url:string) => scriptInjector('preevy-widget', {src: url})