From 999c6ec98d010429af8be77a4db28a6a412f375c Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Sun, 1 Oct 2023 11:09:27 +0300 Subject: [PATCH 1/9] auth refactor --- tunnel-server/src/app.ts | 12 +-- tunnel-server/src/auth.ts | 131 +++++++++++++------------------ tunnel-server/src/proxy/index.ts | 8 +- 3 files changed, 68 insertions(+), 83 deletions(-) diff --git a/tunnel-server/src/app.ts b/tunnel-server/src/app.ts index 7ec89d46..671ab3f0 100644 --- a/tunnel-server/src/app.ts +++ b/tunnel-server/src/app.ts @@ -4,7 +4,7 @@ import http from 'http' import { Logger } from 'pino' import { KeyObject } from 'crypto' import { SessionStore } from './session' -import { Claims, createGetVerificationData, jwtAuthenticator } from './auth' +import { Claims, cliTokenIssuer, jwtAuthenticator, saasJWTIssuer } from './auth' import { ActiveTunnelStore } from './tunnel-store' import { editUrl } from './url' import { Proxy } from './proxy' @@ -21,8 +21,9 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi proxy: Proxy saasPublicKey: KeyObject jwtSaasIssuer: string -}) => - Fastify({ +}) => { + const saasIssuer = saasJWTIssuer(jwtSaasIssuer, saasPublicKey) + return Fastify({ serverFactory: handler => { const baseHostname = baseUrl.hostname const authHostname = `auth.${baseHostname}` @@ -82,7 +83,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi if (!session.user) { const auth = jwtAuthenticator( activeTunnel.publicKeyThumbprint, - createGetVerificationData(saasPublicKey, jwtSaasIssuer)(activeTunnel.publicKey) + [saasIssuer, cliTokenIssuer(activeTunnel.publicKey, activeTunnel.publicKeyThumbprint)] ) const result = await auth(req.raw) if (!result.isAuthenticated) { @@ -107,7 +108,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi const auth = jwtAuthenticator( profileId, - createGetVerificationData(saasPublicKey, jwtSaasIssuer)(tunnels[0].publicKey) + [saasIssuer, cliTokenIssuer(tunnels[0].publicKey, tunnels[0].publicKeyThumbprint)] ) const result = await auth(req.raw) @@ -126,3 +127,4 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi }) .get('/healthz', { logLevel: 'warn' }, async () => 'OK') +} diff --git a/tunnel-server/src/auth.ts b/tunnel-server/src/auth.ts index 5cbfb95f..dfb39765 100644 --- a/tunnel-server/src/auth.ts +++ b/tunnel-server/src/auth.ts @@ -1,5 +1,5 @@ import { IncomingMessage, ServerResponse } from 'http' -import { JWTPayload, JWTVerifyResult, jwtVerify, errors, decodeJwt } from 'jose' +import { JWTVerifyResult, jwtVerify, errors, decodeJwt, JWTPayload } from 'jose' import { match } from 'ts-pattern' import { ZodError, z } from 'zod' import Cookies from 'cookies' @@ -30,6 +30,8 @@ export type AuthenticationResult = { claims: Claims } | { isAuthenticated: false } +export type Authenticator = (req: IncomingMessage)=> Promise + export type BasicAuthorizationHeader = { scheme: 'Basic' username: string @@ -42,15 +44,13 @@ export type BearerAuthorizationHeader = { } export type AuthorizationHeader = BasicAuthorizationHeader | BearerAuthorizationHeader - -const cookieName = 'preevy-saas-jwt' - -type VerificationData = { - pk: KeyObject - extractClaims: (token: JWTPayload, thumbprint: string) => Claims +export type JWTIssuer = { + issuer: string + publicKey: KeyObject + mapClaims: (issuer: JWTPayload, context: { pkThumbprint: string }) => Claims } -// export const SAAS_JWT_ISSUER = process.env.SAAS_JWT_ISSUER ?? 'app.livecycle.run' +const cookieName = 'preevy-saas-jwt' export const saasJWTSchema = z.object({ iss: z.string(), @@ -86,8 +86,8 @@ const extractAuthorizationHeader = (req: IncomingMessage): AuthorizationHeader | export const jwtAuthenticator = ( publicKeyThumbprint: string, - getVerificationData: (issuer: string, publicKeyThumbprint: string) => VerificationData -) => async (req: IncomingMessage): Promise => { + issuers: JWTIssuer[] +) : Authenticator => async req => { const authHeader = extractAuthorizationHeader(req) const jwt = match(authHeader) .with({ scheme: 'Basic', username: 'x-preevy-profile-key' }, ({ password }) => password) @@ -101,23 +101,18 @@ export const jwtAuthenticator = ( const parsedJwt = decodeJwt(jwt) if (parsedJwt.iss === undefined) throw new AuthError('Could not find issuer in JWT') - let verificationData: VerificationData - try { - verificationData = getVerificationData(parsedJwt.iss, publicKeyThumbprint) - } catch (e) { - if (e instanceof AuthError) return { isAuthenticated: false } - - throw e + const jwtIssuer = issuers.find(x => x.issuer === parsedJwt.iss) + if (!jwtIssuer) { + return { isAuthenticated: false } } - const { pk, extractClaims } = verificationData + const { publicKey, mapClaims } = jwtIssuer let token: JWTVerifyResult try { - token = await jwtVerify(jwt, pk,) + token = await jwtVerify(jwt, publicKey) } catch (e) { if (e instanceof errors.JOSEError) throw new AuthError(`Could not verify JWT. ${e.message}`, { cause: e }) - throw e } @@ -125,67 +120,53 @@ export const jwtAuthenticator = ( method: { type: 'header', header: 'authorization' }, isAuthenticated: true, login: isBrowser(req) && authHeader?.scheme !== 'Bearer', - claims: extractClaims(token.payload, publicKeyThumbprint), + claims: mapClaims(token.payload, { pkThumbprint: publicKeyThumbprint }), } } -export const authenticator = (authenticators: ((req: IncomingMessage)=> Promise)[]) => +export const saasJWTIssuer = (sassIssuer:string, saasPublicKey: KeyObject): JWTIssuer => ({ + issuer: sassIssuer, + publicKey: saasPublicKey, + mapClaims: (token, { pkThumbprint: profile }) => { + let parsedToken: SaasJWTSchema + try { + parsedToken = saasJWTSchema.parse(token) + } catch (e) { + if (e instanceof ZodError) { + throw new AuthError(`JWT schema is incorrect. ${e.message}`, { cause: e }) + } + throw e + } + + return { + exp: parsedToken.exp, + iat: parsedToken.iat, + sub: parsedToken.sub, + role: parsedToken.profiles.includes(profile) ? 'admin' : 'guest', + type: 'profile', + scopes: parsedToken.profiles.includes(profile) ? ['admin'] : [], + } + }, +}) + +export const cliTokenIssuer = (publicKey: KeyObject, publicKeyThumbprint:string): JWTIssuer => ({ + issuer: `preevy://${publicKeyThumbprint}`, + publicKey, + mapClaims: (token, { pkThumbprint: profile }) => ({ + role: 'admin', + type: 'profile', + exp: token.exp, + scopes: ['admin'], + sub: `preevy-profile:${profile}`, + }), +}) + +/* not really in use, can be if we support non-jwt authenticators +export const combineAuthenticators = (authenticators: Authenticator[]) => async (req: IncomingMessage):Promise => { const authInfos = (await Promise.all(authenticators.map(authn => authn(req)))) const found = authInfos.find(info => info.isAuthenticated) if (found !== undefined) return found return { isAuthenticated: false } } - -export const createGetVerificationData = (saasPublicKey: KeyObject, jwtSaasIssuer: string) => { - const getSaasTokenVerificationData = (): VerificationData => ({ - pk: saasPublicKey, - extractClaims: (token, thumbprint) => { - let parsedToken: SaasJWTSchema - try { - parsedToken = saasJWTSchema.parse(token) - } catch (e) { - if (e instanceof ZodError) { - throw new AuthError(`JWT schema is incorrect. ${e.message}`, { cause: e }) - } - throw e - } - - return { - exp: parsedToken.exp, - iat: parsedToken.iat, - sub: parsedToken.sub, - role: parsedToken.profiles.includes(thumbprint) ? 'admin' : 'guest', - type: 'profile', - scopes: parsedToken.profiles.includes(thumbprint) ? ['admin'] : [], - } - }, - }) - - const getCliTokenVerificationData = (pk: KeyObject,) => - (): VerificationData => ({ - pk, - extractClaims: (token, thumbprint) => ({ - role: 'admin', - type: 'profile', - exp: token.exp, - scopes: ['admin'], - sub: `preevy-profile:${thumbprint}`, - }), - }) - - const getCliIssuerFromPk = (publicKeyThumbprint: string) => `preevy://${publicKeyThumbprint}` - - return (publicKey: KeyObject) => - (issuer: string, publicKeyThumbprint: string) => { - if (issuer === jwtSaasIssuer) { - return getSaasTokenVerificationData() - } - - if (issuer === getCliIssuerFromPk(publicKeyThumbprint)) { - return getCliTokenVerificationData(publicKey)() - } - - throw new AuthError(`Unsupported issuer ${issuer}`) - } -} +*/ diff --git a/tunnel-server/src/proxy/index.ts b/tunnel-server/src/proxy/index.ts index 1fdf1b92..27ff68f5 100644 --- a/tunnel-server/src/proxy/index.ts +++ b/tunnel-server/src/proxy/index.ts @@ -7,7 +7,7 @@ import { KeyObject } from 'crypto' import stream from 'stream' import { ActiveTunnel, ActiveTunnelStore } from '../tunnel-store' import { requestsCounter } from '../metrics' -import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, createGetVerificationData } from '../auth' +import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasJWTIssuer } from '../auth' import { SessionStore } from '../session' import { BadGatewayError, BadRequestError, BasicAuthUnauthorizedError, RedirectError, UnauthorizedError, errorHandler, errorUpgradeHandler, tryHandler, tryUpgradeHandler } from '../http-server-helpers' import { TunnelFinder, proxyRouter } from './router' @@ -47,6 +47,7 @@ export const proxy = ({ theProxy.on('proxyRes', injectScripts) const loginRedirectUrlForRequest = loginRedirectUrl(loginUrl) + const saasIssuer = saasJWTIssuer(jwtSaasIssuer, saasPublicKey) const validatePrivateTunnelRequest = async ( req: IncomingMessage, @@ -61,7 +62,7 @@ export const proxy = ({ const authenticate = jwtAuthenticator( tunnel.publicKeyThumbprint, - createGetVerificationData(saasPublicKey, jwtSaasIssuer)(tunnel.publicKey) + [saasIssuer] ) let authResult: AuthenticationResult @@ -149,6 +150,7 @@ export const proxy = ({ { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore + target: { socketPath: activeTunnel.target, }, @@ -164,7 +166,7 @@ export const proxy = ({ const { req: mutatedReq, activeTunnel } = await validateProxyRequest( tunnelFinder, req, - pkThumbprint => sessionStore(req, undefined as unknown as ServerResponse, pkThumbprint), + pkThumbprint => sessionStore(req, new ServerResponse(req), pkThumbprint), ) const upgrade = mutatedReq.headers.upgrade?.toLowerCase() From 927fac7807ae6db020a35447b8f5672a5af4e9d1 Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Sun, 1 Oct 2023 13:30:56 +0300 Subject: [PATCH 2/9] CR fix --- tunnel-server/src/app.ts | 8 ++++---- tunnel-server/src/auth.ts | 24 +++++++----------------- tunnel-server/src/proxy/index.ts | 10 +++++----- tunnel-server/src/session.ts | 4 ++-- 4 files changed, 18 insertions(+), 28 deletions(-) diff --git a/tunnel-server/src/app.ts b/tunnel-server/src/app.ts index 671ab3f0..e969f50f 100644 --- a/tunnel-server/src/app.ts +++ b/tunnel-server/src/app.ts @@ -4,7 +4,7 @@ import http from 'http' import { Logger } from 'pino' import { KeyObject } from 'crypto' import { SessionStore } from './session' -import { Claims, cliTokenIssuer, jwtAuthenticator, saasJWTIssuer } from './auth' +import { Claims, cliIdentityProvider, jwtAuthenticator, saasIdentityProvider } from './auth' import { ActiveTunnelStore } from './tunnel-store' import { editUrl } from './url' import { Proxy } from './proxy' @@ -22,7 +22,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi saasPublicKey: KeyObject jwtSaasIssuer: string }) => { - const saasIssuer = saasJWTIssuer(jwtSaasIssuer, saasPublicKey) + const saasIdp = saasIdentityProvider(jwtSaasIssuer, saasPublicKey) return Fastify({ serverFactory: handler => { const baseHostname = baseUrl.hostname @@ -83,7 +83,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi if (!session.user) { const auth = jwtAuthenticator( activeTunnel.publicKeyThumbprint, - [saasIssuer, cliTokenIssuer(activeTunnel.publicKey, activeTunnel.publicKeyThumbprint)] + [saasIdp, cliIdentityProvider(activeTunnel.publicKey, activeTunnel.publicKeyThumbprint)] ) const result = await auth(req.raw) if (!result.isAuthenticated) { @@ -108,7 +108,7 @@ export const app = ({ proxy, sessionStore, baseUrl, activeTunnelStore, log, logi const auth = jwtAuthenticator( profileId, - [saasIssuer, cliTokenIssuer(tunnels[0].publicKey, tunnels[0].publicKeyThumbprint)] + [saasIdp, cliIdentityProvider(tunnels[0].publicKey, tunnels[0].publicKeyThumbprint)] ) const result = await auth(req.raw) diff --git a/tunnel-server/src/auth.ts b/tunnel-server/src/auth.ts index dfb39765..4d0d70cb 100644 --- a/tunnel-server/src/auth.ts +++ b/tunnel-server/src/auth.ts @@ -44,7 +44,7 @@ export type BearerAuthorizationHeader = { } export type AuthorizationHeader = BasicAuthorizationHeader | BearerAuthorizationHeader -export type JWTIssuer = { +export type IdentityProvider = { issuer: string publicKey: KeyObject mapClaims: (issuer: JWTPayload, context: { pkThumbprint: string }) => Claims @@ -86,7 +86,7 @@ const extractAuthorizationHeader = (req: IncomingMessage): AuthorizationHeader | export const jwtAuthenticator = ( publicKeyThumbprint: string, - issuers: JWTIssuer[] + identityProviders: IdentityProvider[] ) : Authenticator => async req => { const authHeader = extractAuthorizationHeader(req) const jwt = match(authHeader) @@ -101,12 +101,12 @@ export const jwtAuthenticator = ( const parsedJwt = decodeJwt(jwt) if (parsedJwt.iss === undefined) throw new AuthError('Could not find issuer in JWT') - const jwtIssuer = issuers.find(x => x.issuer === parsedJwt.iss) - if (!jwtIssuer) { + const idp = identityProviders.find(x => x.issuer === parsedJwt.iss) + if (!idp) { return { isAuthenticated: false } } - const { publicKey, mapClaims } = jwtIssuer + const { publicKey, mapClaims } = idp let token: JWTVerifyResult try { @@ -124,7 +124,7 @@ export const jwtAuthenticator = ( } } -export const saasJWTIssuer = (sassIssuer:string, saasPublicKey: KeyObject): JWTIssuer => ({ +export const saasIdentityProvider = (sassIssuer:string, saasPublicKey: KeyObject): IdentityProvider => ({ issuer: sassIssuer, publicKey: saasPublicKey, mapClaims: (token, { pkThumbprint: profile }) => { @@ -149,7 +149,7 @@ export const saasJWTIssuer = (sassIssuer:string, saasPublicKey: KeyObject): JWTI }, }) -export const cliTokenIssuer = (publicKey: KeyObject, publicKeyThumbprint:string): JWTIssuer => ({ +export const cliIdentityProvider = (publicKey: KeyObject, publicKeyThumbprint:string): IdentityProvider => ({ issuer: `preevy://${publicKeyThumbprint}`, publicKey, mapClaims: (token, { pkThumbprint: profile }) => ({ @@ -160,13 +160,3 @@ export const cliTokenIssuer = (publicKey: KeyObject, publicKeyThumbprint:string) sub: `preevy-profile:${profile}`, }), }) - -/* not really in use, can be if we support non-jwt authenticators -export const combineAuthenticators = (authenticators: Authenticator[]) => - async (req: IncomingMessage):Promise => { - const authInfos = (await Promise.all(authenticators.map(authn => authn(req)))) - const found = authInfos.find(info => info.isAuthenticated) - if (found !== undefined) return found - return { isAuthenticated: false } - } -*/ diff --git a/tunnel-server/src/proxy/index.ts b/tunnel-server/src/proxy/index.ts index 27ff68f5..1a50f812 100644 --- a/tunnel-server/src/proxy/index.ts +++ b/tunnel-server/src/proxy/index.ts @@ -1,5 +1,5 @@ import httpProxy from 'http-proxy' -import { IncomingMessage, ServerResponse } from 'http' +import { IncomingMessage } from 'http' import net from 'net' import type { Logger } from 'pino' import { inspect } from 'util' @@ -7,7 +7,7 @@ import { KeyObject } from 'crypto' import stream from 'stream' import { ActiveTunnel, ActiveTunnelStore } from '../tunnel-store' import { requestsCounter } from '../metrics' -import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasJWTIssuer } from '../auth' +import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasIdentityProvider } from '../auth' import { SessionStore } from '../session' import { BadGatewayError, BadRequestError, BasicAuthUnauthorizedError, RedirectError, UnauthorizedError, errorHandler, errorUpgradeHandler, tryHandler, tryUpgradeHandler } from '../http-server-helpers' import { TunnelFinder, proxyRouter } from './router' @@ -47,7 +47,7 @@ export const proxy = ({ theProxy.on('proxyRes', injectScripts) const loginRedirectUrlForRequest = loginRedirectUrl(loginUrl) - const saasIssuer = saasJWTIssuer(jwtSaasIssuer, saasPublicKey) + const saasIdp = saasIdentityProvider(jwtSaasIssuer, saasPublicKey) const validatePrivateTunnelRequest = async ( req: IncomingMessage, @@ -62,7 +62,7 @@ export const proxy = ({ const authenticate = jwtAuthenticator( tunnel.publicKeyThumbprint, - [saasIssuer] + [saasIdp] ) let authResult: AuthenticationResult @@ -166,7 +166,7 @@ export const proxy = ({ const { req: mutatedReq, activeTunnel } = await validateProxyRequest( tunnelFinder, req, - pkThumbprint => sessionStore(req, new ServerResponse(req), pkThumbprint), + pkThumbprint => sessionStore(req, undefined, pkThumbprint), ) const upgrade = mutatedReq.headers.upgrade?.toLowerCase() diff --git a/tunnel-server/src/session.ts b/tunnel-server/src/session.ts index e2a11b0a..a3bf947e 100644 --- a/tunnel-server/src/session.ts +++ b/tunnel-server/src/session.ts @@ -14,10 +14,10 @@ export function cookieSessionStore(opts: {domain: string; schema: z.ZodSchema const keys = opts.keys ?? [generateInsecureSecret()] return function getSession( req: IncomingMessage, - res: ServerResponse, + res: ServerResponse | undefined, thumbprint: string ) { - const cookies = new Cookies(req, res, { + const cookies = new Cookies(req, res ?? new ServerResponse(req), { secure: true, keys, }) From ddc45b2f92cb1227ac6f734eb9e5ce7337d9e0b8 Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Sun, 1 Oct 2023 20:08:15 +0300 Subject: [PATCH 3/9] add missing issuer --- tunnel-server/src/proxy/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tunnel-server/src/proxy/index.ts b/tunnel-server/src/proxy/index.ts index 1a50f812..16f12d33 100644 --- a/tunnel-server/src/proxy/index.ts +++ b/tunnel-server/src/proxy/index.ts @@ -7,7 +7,7 @@ import { KeyObject } from 'crypto' import stream from 'stream' import { ActiveTunnel, ActiveTunnelStore } from '../tunnel-store' import { requestsCounter } from '../metrics' -import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasIdentityProvider } from '../auth' +import { Claims, jwtAuthenticator, AuthenticationResult, AuthError, saasIdentityProvider, cliIdentityProvider } from '../auth' import { SessionStore } from '../session' import { BadGatewayError, BadRequestError, BasicAuthUnauthorizedError, RedirectError, UnauthorizedError, errorHandler, errorUpgradeHandler, tryHandler, tryUpgradeHandler } from '../http-server-helpers' import { TunnelFinder, proxyRouter } from './router' @@ -62,7 +62,7 @@ export const proxy = ({ const authenticate = jwtAuthenticator( tunnel.publicKeyThumbprint, - [saasIdp] + [saasIdp, cliIdentityProvider(tunnel.publicKey, tunnel.publicKeyThumbprint)] ) let authResult: AuthenticationResult From b933cfa584901744e03a1196cdccf80fe3cd3e42 Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Mon, 2 Oct 2023 11:05:49 +0300 Subject: [PATCH 4/9] add support for script injection in connect. refactor script injection format. added tests --- packages/cli/src/commands/proxy/connect.ts | 12 ++++ .../common/src/compose-tunnel-agent.test.ts | 68 +++++++++++++++++++ packages/common/src/compose-tunnel-agent.ts | 58 +++++++++++++++- .../src/docker/events-client.ts | 40 +++++------ .../src/docker/services.test.ts | 68 +++++++++++++++++++ .../src/docker/services.ts | 26 +++++++ packages/core/src/commands/proxy.ts | 13 +++- packages/core/src/compose/model.ts | 2 +- .../core/src/compose/script-injection.test.ts | 44 ++++++++++++ packages/core/src/compose/script-injection.ts | 38 +++++++++++ 10 files changed, 340 insertions(+), 29 deletions(-) create mode 100644 packages/common/src/compose-tunnel-agent.test.ts create mode 100644 packages/compose-tunnel-agent/src/docker/services.test.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 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}) From a517d70ee8e452d9f60ad6a9d1e1a1dd3e0c918e Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Mon, 2 Oct 2023 11:07:36 +0300 Subject: [PATCH 5/9] formatting and removed dead code --- packages/cli/src/commands/proxy/connect.ts | 2 +- .../src/docker/events-client.ts | 19 +------------------ .../core/src/compose/script-injection.test.ts | 2 +- packages/core/src/compose/script-injection.ts | 2 +- 4 files changed, 4 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/proxy/connect.ts b/packages/cli/src/commands/proxy/connect.ts index 59e84021..41cac82a 100644 --- a/packages/cli/src/commands/proxy/connect.ts +++ b/packages/cli/src/commands/proxy/connect.ts @@ -29,7 +29,7 @@ export default class Connect extends ProfileCommand { required: true, hidden: true, env: 'LIVECYCLE_WIDGET_URL', - default: 'https://app.livecycle.run/widget/widget-bootstrap.js' + default: 'https://app.livecycle.run/widget/widget-bootstrap.js', }), 'private-env': Flags.boolean({ description: 'Mark all services as private', diff --git a/packages/compose-tunnel-agent/src/docker/events-client.ts b/packages/compose-tunnel-agent/src/docker/events-client.ts index 6b82aade..cdf41e58 100644 --- a/packages/compose-tunnel-agent/src/docker/events-client.ts +++ b/packages/compose-tunnel-agent/src/docker/events-client.ts @@ -1,6 +1,6 @@ import Docker from 'dockerode' import { tryParseJson, Logger, ScriptInjection } from '@preevy/common' -import { set, throttle } from 'lodash' +import { throttle } from 'lodash' import { filters } from './filters' import { containerToService } from './services' @@ -13,23 +13,6 @@ export type RunningService = { inject: ScriptInjection[] } -const reviveScriptInjection = ({ pathRegex, ...v }: ScriptInjection) => ({ - ...pathRegex && { pathRegex: new RegExp(pathRegex) }, - ...v, -}) - -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, docker, diff --git a/packages/core/src/compose/script-injection.test.ts b/packages/core/src/compose/script-injection.test.ts index ea21d6d9..1cc31f5b 100644 --- a/packages/core/src/compose/script-injection.test.ts +++ b/packages/core/src/compose/script-injection.test.ts @@ -16,7 +16,7 @@ describe('script injection', () => { }, } - const injector = scriptInjector('test', { src: 'https://mydomain.com/myscript.ts', async: true, pathRegex: /.*/ }) + 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': '.*' }) diff --git a/packages/core/src/compose/script-injection.ts b/packages/core/src/compose/script-injection.ts index 2833dfcd..d9d79e09 100644 --- a/packages/core/src/compose/script-injection.ts +++ b/packages/core/src/compose/script-injection.ts @@ -35,4 +35,4 @@ export const scriptInjector = (id : string, script: ScriptInjection) => { } } -export const widgetScriptInjector = (url:string) => scriptInjector('preevy-widget', {src: url}) +export const widgetScriptInjector = (url:string) => scriptInjector('preevy-widget', { src: url }) From 73035a6abd8383c785eb32dbc983423ff013569e Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Mon, 2 Oct 2023 14:23:00 +0300 Subject: [PATCH 6/9] CR Fixes --- packages/common/src/compose-tunnel-agent.ts | 29 +++---------------- packages/common/src/compose-utils.ts | 25 ++++++++++++++++ packages/core/src/compose/script-injection.ts | 2 +- 3 files changed, 30 insertions(+), 26 deletions(-) create mode 100644 packages/common/src/compose-utils.ts diff --git a/packages/common/src/compose-tunnel-agent.ts b/packages/common/src/compose-tunnel-agent.ts index c4e70835..e2306089 100644 --- a/packages/common/src/compose-tunnel-agent.ts +++ b/packages/common/src/compose-tunnel-agent.ts @@ -1,4 +1,4 @@ -import { camelCase, set, snakeCase } from 'lodash' +import { extractSectionsFromLabels, parseBooleanLabelValue } from './compose-utils' export const COMPOSE_TUNNEL_AGENT_SERVICE_LABELS = { PROFILE_THUMBPRINT: 'preevy.profile_thumbprint', @@ -22,6 +22,7 @@ export type ScriptInjection = { type Stringified = { [k in keyof T]: string } + const parseScriptInjection = ({ pathRegex, defer, async, src }: Stringified): ScriptInjection | Error => { try { @@ -30,8 +31,8 @@ const parseScriptInjection = ({ pathRegex, defer, async, src }: Stringified(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, @@ -60,13 +49,3 @@ export const scriptInjectionFromLabels = (labels : Record): Scri .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/common/src/compose-utils.ts b/packages/common/src/compose-utils.ts new file mode 100644 index 00000000..24733e63 --- /dev/null +++ b/packages/common/src/compose-utils.ts @@ -0,0 +1,25 @@ +import { set, camelCase, snakeCase } from 'lodash' + +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 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)]))) \ No newline at end of file diff --git a/packages/core/src/compose/script-injection.ts b/packages/core/src/compose/script-injection.ts index d9d79e09..cf38e445 100644 --- a/packages/core/src/compose/script-injection.ts +++ b/packages/core/src/compose/script-injection.ts @@ -35,4 +35,4 @@ export const scriptInjector = (id : string, script: ScriptInjection) => { } } -export const widgetScriptInjector = (url:string) => scriptInjector('preevy-widget', { src: url }) +export const widgetScriptInjector = (url:string) => scriptInjector('livecycle-widget', { src: url }) From b3ebb186202c12b48df14b589ba948d14e401914 Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Mon, 2 Oct 2023 14:27:09 +0300 Subject: [PATCH 7/9] CR fixes --- packages/common/index.ts | 1 + packages/common/src/compose-tunnel-agent.test.ts | 9 +++++++-- packages/common/src/compose-utils.ts | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) 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 index cabbe77c..953d8723 100644 --- a/packages/common/src/compose-tunnel-agent.test.ts +++ b/packages/common/src/compose-tunnel-agent.test.ts @@ -7,7 +7,6 @@ describe('parse script injection labels', () => { 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) @@ -16,7 +15,7 @@ describe('parse script injection labels', () => { expect(script).toMatchObject({ src: 'https://my-script', defer: true, - async: false, + async: true, pathRegex: expect.any(RegExp), }) }) @@ -48,20 +47,26 @@ describe('parse script injection labels', () => { 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-utils.ts b/packages/common/src/compose-utils.ts index 24733e63..5200846b 100644 --- a/packages/common/src/compose-utils.ts +++ b/packages/common/src/compose-utils.ts @@ -22,4 +22,4 @@ const formatValueLabel = (x:unknown) => { } export const sectionToLabels = (prefix: string, section: Record) => - Object.fromEntries(Object.entries(section).map(([key, value]) => ([`${prefix}.${snakeCase(key)}`, formatValueLabel(value)]))) \ No newline at end of file + Object.fromEntries(Object.entries(section).map(([key, value]) => ([`${prefix}.${snakeCase(key)}`, formatValueLabel(value)]))) From e66e8a34547c64ea12ffaf2a33c51b8ef0c4cd34 Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Mon, 2 Oct 2023 14:29:16 +0300 Subject: [PATCH 8/9] fix --- packages/common/src/compose-tunnel-agent.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/src/compose-tunnel-agent.test.ts b/packages/common/src/compose-tunnel-agent.test.ts index 953d8723..2d7a7b01 100644 --- a/packages/common/src/compose-tunnel-agent.test.ts +++ b/packages/common/src/compose-tunnel-agent.test.ts @@ -58,7 +58,7 @@ describe('parse script injection labels', () => { expect(scripts).toMatchObject([ { src: 'https://my-script', - defer: true + defer: true, }, { src: 'https://my-script2', From b3141191f71a9ffa1a69c521c361e76994b81f8e Mon Sep 17 00:00:00 2001 From: Yshay Yaacobi Date: Mon, 2 Oct 2023 14:40:15 +0300 Subject: [PATCH 9/9] small refactor --- packages/common/src/compose-tunnel-agent.test.ts | 3 ++- packages/common/src/compose-utils.ts | 12 +++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/common/src/compose-tunnel-agent.test.ts b/packages/common/src/compose-tunnel-agent.test.ts index 2d7a7b01..907511b2 100644 --- a/packages/common/src/compose-tunnel-agent.test.ts +++ b/packages/common/src/compose-tunnel-agent.test.ts @@ -7,6 +7,7 @@ describe('parse script injection labels', () => { 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) @@ -15,7 +16,7 @@ describe('parse script injection labels', () => { expect(script).toMatchObject({ src: 'https://my-script', defer: true, - async: true, + async: false, pathRegex: expect.any(RegExp), }) }) diff --git a/packages/common/src/compose-utils.ts b/packages/common/src/compose-utils.ts index 5200846b..854ef208 100644 --- a/packages/common/src/compose-utils.ts +++ b/packages/common/src/compose-utils.ts @@ -1,14 +1,12 @@ import { set, camelCase, snakeCase } from 'lodash' 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) - } - } + 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 }