Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wait for tunnels to be ready (take 2) #488

Merged
merged 1 commit into from
May 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/cli-common/src/lib/common-flags/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,9 @@ export const urlFlags = {
summary: 'Timeout for fetching URLs request in milliseconds',
default: 2500,
}),
wait: Flags.boolean({
description: 'Wait for all tunnels to be ready',
default: true,
allowNo: true,
}),
} as const
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,4 @@
"preview"
],
"types": "dist/index.d.ts"
}
}
10 changes: 3 additions & 7 deletions packages/cli/src/commands/up.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { inspect } from 'util'
import { editUrl, tunnelNameResolver } from '@preevy/common'
import MachineCreationDriverCommand from '../machine-creation-driver-command.js'
import { envIdFlags, urlFlags } from '../common-flags.js'
import { filterUrls, printUrls, writeUrlsToFile } from './urls.js'
import { filterUrls, printUrls, urlsRetryOpts, writeUrlsToFile } from './urls.js'
import { connectToTunnelServerSsh } from '../tunnel-server-client.js'

const fetchTunnelServerDetails = async ({
Expand Down Expand Up @@ -207,13 +207,9 @@ export default class Up extends MachineCreationDriverCommand<typeof Up> {
tunnelingKey,
includeAccessCredentials: flags['include-access-credentials'] && (flags['access-credentials-type'] as 'api' | 'browser'),
showPreevyService: flags['show-preevy-service-urls'],
retryOpts: {
minTimeout: 1000,
maxTimeout: 2000,
retries: 10,
onFailedAttempt: e => { this.logger.debug(`Failed to query tunnels: ${inspect(e)}`) },
},
retryOpts: urlsRetryOpts(this.logger),
fetchTimeout: flags['fetch-urls-timeout'],
waitForAllTunnels: flags.wait,
}), { text: 'Getting tunnel URLs...' })

const urls = await filterUrls({
Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/commands/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FlatTunnel, Logger, TunnelOpts, addBaseComposeTunnelAgentService, comma
import { HooksListeners, PluginContext, parseTunnelServerFlags, tableFlags, text, tunnelServerFlags } from '@preevy/cli-common'
import { asyncReduce } from 'iter-tools-es'
import { tunnelNameResolver } from '@preevy/common'
import { inspect } from 'util'
import { connectToTunnelServerSsh } from '../tunnel-server-client.js'
import ProfileCommand from '../profile-command.js'
import { envIdFlags, urlFlags } from '../common-flags.js'
Expand Down Expand Up @@ -49,6 +50,15 @@ export const filterUrls = ({ flatTunnels, context, filters }: {
filters,
)

export const urlsRetryOpts = (log: Logger) => ({
minTimeout: 1000,
maxTimeout: 2000,
retries: 10,
onFailedAttempt: (e: unknown) => { log.debug(`Failed to query tunnels: ${inspect(e)}`) },
} as const)

export const noWaitUrlsRetryOpts = { retries: 2 } as const

// eslint-disable-next-line no-use-before-define
export default class Urls extends ProfileCommand<typeof Urls> {
static description = 'Show urls for an existing environment'
Expand Down Expand Up @@ -128,8 +138,9 @@ export default class Urls extends ProfileCommand<typeof Urls> {
tunnelingKey,
includeAccessCredentials: flags['include-access-credentials'] && (flags['access-credentials-type'] as 'api' | 'browser'),
showPreevyService: flags['show-preevy-service-urls'],
retryOpts: { retries: 2 },
retryOpts: flags.wait ? urlsRetryOpts(this.logger) : noWaitUrlsRetryOpts,
fetchTimeout: flags['fetch-urls-timeout'],
waitForAllTunnels: flags.wait,
})

const urls = await filterUrls({
Expand Down
1 change: 1 addition & 0 deletions packages/common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {
COMPOSE_TUNNEL_AGENT_PORT,
COMPOSE_TUNNEL_AGENT_SERVICE_LABELS,
COMPOSE_TUNNEL_AGENT_SERVICE_NAME,
ComposeTunnelAgentState,
} from './src/compose-tunnel-agent/index.js'
export { MachineStatusCommand, DockerMachineStatusCommandRecipe } from './src/machine-status-command.js'
export { ProcessOutputBuffers, orderedOutput, OrderedOutput } from './src/process-output-buffers.js'
10 changes: 10 additions & 0 deletions packages/common/src/compose-tunnel-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,13 @@ export { ScriptInjection, parseScriptInjectionLabels, scriptInjectionsToLabels }

export const COMPOSE_TUNNEL_AGENT_SERVICE_NAME = 'preevy_proxy'
export const COMPOSE_TUNNEL_AGENT_PORT = 3000

export type ComposeTunnelAgentState = {
state: 'unknown'
reason: string
} | {
state: 'pending'
pendingServices: string[]
} | {
state: 'stable'
}
30 changes: 25 additions & 5 deletions packages/compose-tunnel-agent/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Docker from 'dockerode'
import { rimraf } from 'rimraf'
import { pino } from 'pino'
import pinoPrettyModule from 'pino-pretty'
import yaml from 'yaml'
import {
requiredEnv,
formatPublicKey,
Expand All @@ -20,11 +21,13 @@ import { runMachineStatusCommand } from './src/machine-status.js'
import { envMetadata } from './src/metadata.js'
import { readAllFiles } from './src/files.js'
import { eventsClient as dockerEventsClient, filteredClient as dockerFilteredClient } from './src/docker/index.js'
import { tunnelsStateCalculator } from './src/tunnels-state.js'

const PinoPretty = pinoPrettyModule.default

const homeDir = process.env.HOME || '/root'
const dockerSocket = '/var/run/docker.sock'
const COMPOSE_FILE_PATH = '/preevy/docker-compose.yaml'

const targetComposeProject = process.env.COMPOSE_PROJECT
const defaultAccess = process.env.DEFAULT_ACCESS_LEVEL === 'private' ? 'private' : 'public'
Expand Down Expand Up @@ -106,22 +109,39 @@ const main = async () => {
})

sshLog.info('ssh client connected to %j', sshUrl)
let currentTunnels = dockerClient.getRunningServices().then(services => sshClient.updateTunnels(services))
let currentState = dockerClient.getRunningServices().then(async runningServices => ({
runningServices,
sshTunnels: await sshClient.updateTunnels(runningServices),
}))

void dockerClient.startListening({
onChange: async services => {
currentTunnels = sshClient.updateTunnels(services)
onChange: runningServices => {
currentState = (async () => ({
runningServices,
sshTunnels: await sshClient.updateTunnels(runningServices),
}))()
},
})

const calcTunnelsState = tunnelsStateCalculator({
composeProject: targetComposeProject,
composeModelReader: async () => yaml.parse(await fs.promises.readFile(COMPOSE_FILE_PATH, { encoding: 'utf8' })),
})

const app = await createApp({
log: log.child({ name: 'api' }),
currentSshState: async () => (await currentTunnels),
tunnels: async () => {
const { sshTunnels, runningServices } = await currentState
return {
...sshTunnels,
state: await calcTunnelsState(runningServices),
}
},
machineStatus: machineStatusCommand
? async () => await runMachineStatusCommand({ log, docker })(machineStatusCommand)
: undefined,
envMetadata: await envMetadata({ env: process.env, log }),
composeModelPath: '/preevy/docker-compose.yaml',
composeModelPath: COMPOSE_FILE_PATH,
docker,
dockerFilter: dockerFilteredClient({ docker, composeProject: targetComposeProject }),
})
Expand Down
3 changes: 2 additions & 1 deletion packages/compose-tunnel-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"rimraf": "^5.0.5",
"ssh2": "^1.12.0",
"ws": "^8.13.0",
"yaml": "^2.3.2",
"zod": "^3.21.4"
},
"devDependencies": {
Expand Down Expand Up @@ -65,4 +66,4 @@
"prepare": "cd ../.. && husky install",
"test": "node --no-warnings=ExperimentalWarning --experimental-vm-modules ../../node_modules/.bin/jest"
}
}
}
11 changes: 8 additions & 3 deletions packages/compose-tunnel-agent/src/api-server/env.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import fs from 'node:fs'
import { FastifyPluginAsync } from 'fastify'
import { ComposeTunnelAgentState } from '@preevy/common'
import { SshState } from '../ssh/index.js'

export const env: FastifyPluginAsync<{
currentSshState: () => Promise<SshState>
tunnels: () => Promise<SshState & { state: ComposeTunnelAgentState }>
machineStatus?: () => Promise<{ data: Buffer; contentType: string }>
envMetadata?: Record<string, unknown>
composeModelPath: string
}> = async (app, { currentSshState, machineStatus, envMetadata, composeModelPath }) => {
app.get('/tunnels', async () => await currentSshState())
}> = async (app, { tunnels, machineStatus, envMetadata, composeModelPath }) => {
app.get('/tunnels', async ({ log }) => {
const response = await tunnels()
log.debug('tunnels response: %j', response)
return response
})

if (machineStatus) {
app.get('/machine-status', async (_req, res) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/compose-tunnel-agent/src/api-server/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { inspect, promisify } from 'node:util'
import waitForExpect from 'wait-for-expect'
import WebSocket from 'ws'
import stripAnsi from 'strip-ansi'
import { ComposeTunnelAgentState } from '@preevy/common'
import { createApp } from './index.js'
import { filteredClient } from '../docker/index.js'
import { SshState } from '../ssh/index.js'
Expand Down Expand Up @@ -71,7 +72,7 @@ const setupApiServer = () => {
docker,
dockerFilter: filteredClient({ docker, composeProject: TEST_COMPOSE_PROJECT }),
composeModelPath: '',
currentSshState: () => Promise.resolve({} as unknown as SshState),
tunnels: () => Promise.resolve({} as unknown as SshState & { state: ComposeTunnelAgentState }),
})
await app.listen({ port: 0 })
const { port } = app.server.address() as AddressInfo
Expand Down
7 changes: 4 additions & 3 deletions packages/compose-tunnel-agent/src/api-server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,23 @@ import Dockerode from 'dockerode'
import fastify from 'fastify'
import cors from '@fastify/cors'
import { validatorCompiler, serializerCompiler, ZodTypeProvider } from 'fastify-type-provider-zod'
import { ComposeTunnelAgentState } from '@preevy/common'
import { SshState } from '../ssh/index.js'
import { DockerFilterClient } from '../docker/index.js'
import { containers } from './containers/index.js'
import { env } from './env.js'

export const createApp = async ({
log,
currentSshState,
tunnels,
machineStatus,
envMetadata,
composeModelPath,
dockerFilter,
docker,
}: {
log: Logger
currentSshState: () => Promise<SshState>
tunnels: () => Promise<SshState & { state: ComposeTunnelAgentState }>
machineStatus?: () => Promise<{ data: Buffer; contentType: string }>
envMetadata?: Record<string, unknown>
composeModelPath: string
Expand All @@ -39,7 +40,7 @@ export const createApp = async ({

app.get('/healthz', { logLevel: 'warn' }, async () => 'OK')

await app.register(env, { composeModelPath, currentSshState, envMetadata, machineStatus })
await app.register(env, { composeModelPath, tunnels, envMetadata, machineStatus })
await app.register(containers, { docker, dockerFilter, prefix: '/containers' })

return app
Expand Down
51 changes: 51 additions & 0 deletions packages/compose-tunnel-agent/src/tunnels-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { inspect } from 'util'
import { ComposeTunnelAgentState } from '@preevy/common'
import { RunningService } from './docker/index.js'

const findPendingComposeServiceTunnels = ({
composeProject,
composeModel,
runningServices,
}: {
composeProject: string
composeModel: { services: Record<string, unknown> }
runningServices: Pick<RunningService, 'name' | 'project'>[]
}) => {
const composeServiceNames = Object.keys(composeModel.services)

const runningServiceNames = new Set(
runningServices
.filter(({ project }) => project === composeProject)
.map(({ name }) => name)
)

return composeServiceNames.filter(service => !runningServiceNames.has(service))
}

export const tunnelsStateCalculator = ({
composeProject,
composeModelReader,
}: {
composeProject?: string
composeModelReader: () => Promise<{ services: Record<string, unknown> }>
}) => async (
runningServices: Pick<RunningService, 'name' | 'project'>[]
): Promise<ComposeTunnelAgentState> => {
if (!composeProject) {
return { state: 'unknown', reason: 'COMPOSE_PROJECT not set' }
}

let composeModel: { services: Record<string, unknown> }
try {
composeModel = await composeModelReader()
} catch (e) {
return { state: 'unknown', reason: `Could not read compose file: ${inspect(e)}` }
}

const pendingServices = findPendingComposeServiceTunnels({ composeProject, composeModel, runningServices })
if (pendingServices.length) {
return { state: 'pending', pendingServices }
}

return { state: 'stable' }
}
3 changes: 3 additions & 0 deletions packages/core/src/commands/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const urls = async ({
showPreevyService,
composeTunnelServiceUrl,
fetchTimeout,
waitForAllTunnels,
}: {
serviceAndPort?: { service: string; port?: number }
tunnelingKey: string | Buffer
Expand All @@ -34,6 +35,7 @@ export const urls = async ({
showPreevyService: boolean
composeTunnelServiceUrl: string
fetchTimeout: number
waitForAllTunnels?: boolean
}) => {
const credentials = await generateBasicAuthCredentials(jwtGenerator(tunnelingKey))

Expand All @@ -43,6 +45,7 @@ export const urls = async ({
credentials,
includeAccessCredentials,
fetchTimeout,
waitForAllTunnels,
})

return flattenTunnels(tunnels).filter(tunnelFilter({ serviceAndPort, showPreevyService }))
Expand Down
21 changes: 16 additions & 5 deletions packages/core/src/compose-tunnel-agent-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import path from 'path'
import retry from 'p-retry'
import util from 'util'
import { inspect } from 'util'
import { createRequire } from 'module'
import { mapValues, merge } from 'lodash-es'
import { COMPOSE_TUNNEL_AGENT_PORT, COMPOSE_TUNNEL_AGENT_SERVICE_LABELS, COMPOSE_TUNNEL_AGENT_SERVICE_NAME, MachineStatusCommand, ScriptInjection, dateReplacer } from '@preevy/common'
import { COMPOSE_TUNNEL_AGENT_PORT, COMPOSE_TUNNEL_AGENT_SERVICE_LABELS, COMPOSE_TUNNEL_AGENT_SERVICE_NAME, ComposeTunnelAgentState, MachineStatusCommand, ScriptInjection, dateReplacer } from '@preevy/common'
import { ComposeModel, ComposeService, composeModelFilename } from './compose/model.js'
import { TunnelOpts } from './ssh/url.js'
import { Tunnel } from './tunneling/index.js'
Expand Down Expand Up @@ -150,7 +150,7 @@ export const findComposeTunnelAgentUrl = (
)?.url

if (!serviceUrl) {
throw new Error(`Cannot find compose tunnel agent API service URL ${COMPOSE_TUNNEL_AGENT_SERVICE_NAME}:${COMPOSE_TUNNEL_AGENT_PORT} in: ${util.inspect(serviceUrls)}`)
throw new Error(`Cannot find compose tunnel agent API service URL ${COMPOSE_TUNNEL_AGENT_SERVICE_NAME}:${COMPOSE_TUNNEL_AGENT_PORT} in: ${inspect(serviceUrls)}`)
}

return serviceUrl
Expand Down Expand Up @@ -182,16 +182,27 @@ const fetchFromComposeTunnelAgent = async ({
return r
}, retryOpts)

type TunnelsResponse = {
tunnels: Tunnel[]
state: ComposeTunnelAgentState
}

export const queryTunnels = async ({
includeAccessCredentials,
waitForAllTunnels,
...fetchOpts
}: ComposeTunnelAgentFetchOpts & {
includeAccessCredentials: false | 'browser' | 'api'
waitForAllTunnels?: boolean
}) => {
const r = await fetchFromComposeTunnelAgent({ ...fetchOpts, pathAndQuery: 'tunnels' })
const { tunnels } = await (r.json() as Promise<{ tunnels: Tunnel[] }>)
const response = await (r.json() as Promise<TunnelsResponse>)

if (waitForAllTunnels && response.state.state !== 'stable') {
throw new AgentFetchError(`Not all configured tunnels are ready yet: ${inspect(response, { depth: null })}`)
}

return tunnels
return response.tunnels
.map(tunnel => ({
...tunnel,
ports: mapValues(
Expand Down
Loading