From 1db2036a3dc860c1e37e745f16c81da10644ecea Mon Sep 17 00:00:00 2001 From: Yshayy Date: Mon, 2 Oct 2023 12:39:50 +0000 Subject: [PATCH] Add support for widget injection in connect command (#238) * 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 --- packages/cli/src/commands/proxy/connect.ts | 12 +++ packages/common/index.ts | 1 + .../common/src/compose-tunnel-agent.test.ts | 74 +++++++++++++++++++ packages/common/src/compose-tunnel-agent.ts | 37 +++++++++- packages/common/src/compose-utils.ts | 23 ++++++ .../src/docker/events-client.ts | 33 ++------- .../src/docker/services.ts | 26 +++++++ packages/core/src/commands/proxy.ts | 12 ++- packages/core/src/compose/model.ts | 2 +- .../core/src/compose/script-injection.test.ts | 44 +++++++++++ packages/core/src/compose/script-injection.ts | 38 ++++++++++ 11 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 packages/common/src/compose-tunnel-agent.test.ts create mode 100644 packages/common/src/compose-utils.ts create mode 100644 packages/compose-tunnel-agent/src/docker/services.ts create mode 100644 packages/core/src/compose/script-injection.test.ts create mode 100644 packages/core/src/compose/script-injection.ts diff --git a/packages/cli/src/commands/proxy/connect.ts b/packages/cli/src/commands/proxy/connect.ts index de9f9b8c..bf2ca5b9 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 = 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, }) diff --git a/packages/common/index.ts b/packages/common/index.ts index 0af75941..d7c9cdc0 100644 --- a/packages/common/index.ts +++ b/packages/common/index.ts @@ -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' 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..907511b2 --- /dev/null +++ b/packages/common/src/compose-tunnel-agent.test.ts @@ -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, + }, + ]) + }) +}) diff --git a/packages/common/src/compose-tunnel-agent.ts b/packages/common/src/compose-tunnel-agent.ts index 54aa843c..e2306089 100644 --- a/packages/common/src/compose-tunnel-agent.ts +++ b/packages/common/src/compose-tunnel-agent.ts @@ -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' @@ -18,3 +18,34 @@ 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: parseBooleanLabelValue(defer) }, + ...async && { async: parseBooleanLabelValue(async) }, + src, + } + } catch (e) { + return e as Error + } +} + +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)) +} diff --git a/packages/common/src/compose-utils.ts b/packages/common/src/compose-utils.ts new file mode 100644 index 00000000..854ef208 --- /dev/null +++ b/packages/common/src/compose-utils.ts @@ -0,0 +1,23 @@ +import { set, camelCase, snakeCase } from 'lodash' + +export const extractSectionsFromLabels = (prefix: string, labels: Record) => { + 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) => + 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..cdf41e58 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 { 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 @@ -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): ScriptInjection[] => [ - ...(scriptsText ? (tryParseJson(scriptsText) || tryParseYaml(scriptsText) || []) : []), - ...src ? [{ src, ...pathRegex && { pathRegex } }] : [], -].map(reviveScriptInjection) - export const eventsClient = ({ log, docker, @@ -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 => (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.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 f782cb9b..528636c0 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' @@ -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 = { @@ -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', @@ -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 } : 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..1cc31f5b --- /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..cf38e445 --- /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('livecycle-widget', { src: url })