Skip to content
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
6 changes: 6 additions & 0 deletions packages/devcontainer-node/src/devcontainerCli.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,9 @@ export function forwardPorts(
},
): Promise<void>
export function disposeForwardPorts(): void

export function getLifecycleProgress(workspaceFolder: string): {
errorMessage?: string
output: string
running: boolean
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { ErrorResult } from '../SerializeError/SerializeError.ts'
import * as CliError from '../CliError/CliError.ts'
import * as CliJson from '../CliJson/CliJson.ts'
import * as ContainerFileSystem from '../ContainerFileSystem/ContainerFileSystem.ts'
import * as ForwardPorts from '../ForwardPorts/ForwardPorts.ts'
import * as LifecycleCommands from '../LifecycleCommands/LifecycleCommands.ts'
import * as RunProcess from '../RunProcess/RunProcess.ts'

export interface CliCommandSuccess {
Expand Down Expand Up @@ -43,6 +47,11 @@ export interface WorkspaceOptions {
workspaceFolder: string
}

interface CliUpOptions extends WorkspaceOptions {
overrideConfig?: string
skipNonBlockingCommands?: boolean
}

export interface ExecOptions extends WorkspaceOptions {
args?: readonly string[]
command: string
Expand Down Expand Up @@ -86,10 +95,14 @@ export const getCliReadConfigurationArgs = ({

export const getCliUpArgs = ({
containerCli = dockerPath,
overrideConfig,
skipNonBlockingCommands = false,
workspaceFolder,
}: WorkspaceOptions) => {
}: CliUpOptions) => {
return [
'up',
...(skipNonBlockingCommands ? ['--skip-non-blocking-commands'] : []),
...(overrideConfig ? ['--override-config', overrideConfig] : []),
'--workspace-folder',
workspaceFolder,
'--no-lockfile',
Expand All @@ -103,6 +116,19 @@ export const getCliUpArgs = ({
]
}

export const getDefaultWaitForOverride = (
configuration: unknown,
): Record<string, unknown> | undefined => {
if (
!configuration ||
typeof configuration !== 'object' ||
Object.hasOwn(configuration, 'waitFor')
) {
return undefined
}
return { ...configuration, waitFor: 'postCreateCommand' }
}

export const getCliExecArgs = ({
args = [],
command,
Expand Down Expand Up @@ -275,18 +301,54 @@ export const cliReadConfiguration = (options: WorkspaceOptions) => {
export const cliUp = async (
options: WorkspaceOptions,
): Promise<CliCommandResult> => {
const result = await runDevcontainerCli(
'DevContainerNode.cliUp',
[getDevcontainerCliPath(), ...getCliUpArgs(options)],
options.containerCli,
options.onOutput,
)
const configurationResult = await cliReadConfiguration(options)
const configuration = (
configurationResult.json as { configuration?: unknown } | undefined
)?.configuration
const hasExplicitWaitFor =
configuration !== null &&
typeof configuration === 'object' &&
Object.hasOwn(configuration, 'waitFor')
const waitForOverride = configurationResult.ok
? getDefaultWaitForOverride(configuration)
: undefined
const skipNonBlockingCommands = hasExplicitWaitFor || Boolean(waitForOverride)
let overrideConfigDirectory: string | undefined
let result: CliCommandResult
try {
let overrideConfig: string | undefined
if (waitForOverride) {
overrideConfigDirectory = await mkdtemp(
join(tmpdir(), 'lvce-devcontainer-up-'),
)
overrideConfig = join(overrideConfigDirectory, 'devcontainer.json')
await writeFile(overrideConfig, JSON.stringify(waitForOverride))
}
result = await runDevcontainerCli(
'DevContainerNode.cliUp',
[
getDevcontainerCliPath(),
...getCliUpArgs({
...options,
overrideConfig,
skipNonBlockingCommands,
}),
],
options.containerCli,
options.onOutput,
)
} finally {
if (overrideConfigDirectory) {
await rm(overrideConfigDirectory, { force: true, recursive: true })
}
}
if (!result.ok) return result
const json = result.json as {
containerId: string
remoteUser?: string
mergedConfiguration?: { forwardPorts?: unknown }
}
let setupErrorCode = 'DEVCONTAINER_FORWARD_PORTS_ERROR'
try {
await ForwardPorts.ensure({
containerCli: options.containerCli ?? dockerPath,
Expand All @@ -295,11 +357,20 @@ export const cliUp = async (
remoteUser: json.remoteUser,
workspaceFolder: options.workspaceFolder,
})
if (skipNonBlockingCommands) {
setupErrorCode = 'DEVCONTAINER_LIFECYCLE_ERROR'
await LifecycleCommands.start(
options.workspaceFolder,
json.containerId,
getDevcontainerCliPath(),
options.containerCli ?? dockerPath,
)
}
return result
} catch (error) {
return {
commandName: result.commandName,
errorCode: 'DEVCONTAINER_FORWARD_PORTS_ERROR',
errorCode: setupErrorCode,
errorMessage: error instanceof Error ? error.message : String(error),
errorStack: undefined,
json: result.json,
Expand All @@ -317,7 +388,11 @@ export const forwardPorts = (
remoteUser?: string
},
): Promise<void> => ForwardPorts.ensure(options)
export const disposeForwardPorts = ForwardPorts.dispose
export const disposeForwardPorts = () => {
LifecycleCommands.dispose()
ForwardPorts.dispose()
}
export const getLifecycleProgress = LifecycleCommands.getProgress

export const cliExec = (options: ExecOptions) => {
return runDevcontainerCommand(
Expand All @@ -328,6 +403,7 @@ export const cliExec = (options: ExecOptions) => {
}

export const dockerStopContainer = async (options: ContainerOptions) => {
LifecycleCommands.remove(options.containerId)
await ForwardPorts.remove(options.containerId)
return runDocker(
'DevContainerNode.dockerStopContainer',
Expand All @@ -337,6 +413,7 @@ export const dockerStopContainer = async (options: ContainerOptions) => {
}

export const dockerRemoveContainer = async (options: ContainerOptions) => {
LifecycleCommands.remove(options.containerId)
await ForwardPorts.remove(options.containerId)
return runDocker(
'DevContainerNode.dockerRemoveContainer',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import { spawn } from 'node:child_process'
import { join } from 'node:path'

interface Operation {
child: ChildProcessWithoutNullStreams
containerId: string
errorMessage?: string
output: string
running: boolean
}

const operations = new Map<string, Operation>()
const limit = 1024 * 1024

export const getProgress = (workspaceFolder: string) => {
const value = operations.get(workspaceFolder)
return {
errorMessage: value?.errorMessage,
output: value?.output ?? '',
running: value?.running ?? false,
}
}

const terminate = (value: Operation): void => {
if (!value.running || !value.child.pid) return
if (process.platform === 'win32') {
spawn(
join(process.env.SystemRoot ?? 'C:\\Windows', 'System32', 'taskkill.exe'),
['/pid', String(value.child.pid), '/T', '/F'],
{
stdio: 'ignore',
},
).on('error', () => value.child.kill())
} else {
try {
process.kill(-value.child.pid, 'SIGTERM')
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error
}
}
}

export const remove = (containerId: string): void => {
for (const [workspace, value] of operations) {
if (value.containerId !== containerId) continue
operations.delete(workspace)
terminate(value)
}
}

export const dispose = (): void => {
for (const value of operations.values()) terminate(value)
operations.clear()
}

export const start = async (
workspaceFolder: string,
containerId: string,
cliPath: string,
containerCli: string,
): Promise<void> => {
const previous = operations.get(workspaceFolder)
if (previous?.running && previous.containerId === containerId) return
if (previous) {
operations.delete(workspaceFolder)
terminate(previous)
}
const child = spawn(
process.execPath,
[
cliPath,
'run-user-commands',
'--workspace-folder',
workspaceFolder,
'--container-id',
containerId,
'--docker-path',
containerCli,
'--log-format',
'text',
],
{
detached: process.platform !== 'win32',
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
},
)
const value: Operation = { child, containerId, output: '', running: true }
operations.set(workspaceFolder, value)
const append = (chunk: string) => {
value.output = (value.output + chunk).slice(-limit)
}
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', append)
child.stderr.on('data', append)
child.stdin.on('error', (error: NodeJS.ErrnoException) => {
if (error.code !== 'EPIPE') append(String(error))
})
child.stdin.end()
child.on('error', (error) => {
value.running = false
value.errorMessage = `Devcontainer lifecycle commands failed: ${error.message}`
append(value.errorMessage)
})
child.once('close', (code, signal) => {
value.running = false
if (operations.get(workspaceFolder) !== value) return
if (code !== 0) {
const reason = signal ?? `exit code ${code}`
value.errorMessage = `Devcontainer lifecycle commands failed (${reason}). See Dev Containers output.`
append(`\n${value.errorMessage}\n`)
}
})
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve)
child.once('error', reject)
})
}
43 changes: 43 additions & 0 deletions packages/devcontainer-node/test/DevContainerCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,49 @@ test('getCliUpArgs', () => {
])
})

test('getCliUpArgs stops at the readiness boundary when requested', () => {
expect(
DevContainerCli.getCliUpArgs({
skipNonBlockingCommands: true,
workspaceFolder: '/workspace',
}),
).toContain('--skip-non-blocking-commands')
})

test('getCliUpArgs accepts a lifecycle readiness override', () => {
expect(
DevContainerCli.getCliUpArgs({
overrideConfig: '/tmp/devcontainer-override.json',
workspaceFolder: '/workspace',
}),
).toContain('--override-config')
expect(
DevContainerCli.getCliUpArgs({
overrideConfig: '/tmp/devcontainer-override.json',
workspaceFolder: '/workspace',
}),
).toContain('/tmp/devcontainer-override.json')
})

test('getDefaultWaitForOverride waits through post-create only when unset', () => {
expect(
DevContainerCli.getDefaultWaitForOverride({
features: { 'example/feature': {} },
image: 'alpine',
postCreateCommand: 'npm install',
}),
).toEqual({
features: { 'example/feature': {} },
image: 'alpine',
postCreateCommand: 'npm install',
waitFor: 'postCreateCommand',
})
expect(
DevContainerCli.getDefaultWaitForOverride({ waitFor: 'postStartCommand' }),
).toBeUndefined()
expect(DevContainerCli.getDefaultWaitForOverride(undefined)).toBeUndefined()
})

test('getCliExecArgs', () => {
expect(
DevContainerCli.getCliExecArgs({
Expand Down
Loading
Loading