@lincyaw/addon-exec-local 0.0.0-stage → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +30 -2
- package/components/PmLocalEnv.css +48 -0
- package/components/PmLocalEnv.tsx +190 -0
- package/package.json +47 -3
- package/src/client/index.ts +5 -0
- package/src/client/local-env.ts +68 -0
- package/src/client/messages.ts +50 -0
- package/src/client/plugin.ts +39 -0
- package/src/domain/config.ts +25 -0
- package/src/domain/index.ts +6 -0
- package/src/domain/service.ts +36 -0
- package/src/domain/workspace.ts +32 -0
- package/src/node/container.ts +163 -0
- package/src/node/devcontainer.ts +165 -0
- package/src/node/docker.ts +36 -0
- package/src/node/local-env.ts +112 -0
- package/src/node/messages.ts +80 -0
- package/src/node/presmium.ts +53 -0
- package/src/node/process.ts +68 -0
- package/src/node/replicate.ts +30 -0
- package/src/node/server.ts +116 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { environmentExecutor, EXECUTOR } from '@lincyaw/kernel'
|
|
2
|
+
import type { Executor, PluginDefinition, Unsubscribe } from '@lincyaw/kernel'
|
|
3
|
+
import { version } from '../../package.json'
|
|
4
|
+
import { DEVCONTAINER_KIND, EXEC_LOCAL_ID, execLocalConfig, LOCAL_ENV } from '../domain/index.ts'
|
|
5
|
+
import { createLocalEnvClient } from './local-env.ts'
|
|
6
|
+
|
|
7
|
+
let unregister: Unsubscribe[] = []
|
|
8
|
+
|
|
9
|
+
export const execLocalPlugin: PluginDefinition = {
|
|
10
|
+
manifest: {
|
|
11
|
+
id: EXEC_LOCAL_ID,
|
|
12
|
+
name: { 'zh-CN': '本机容器运行', en: 'Local Container Runner' },
|
|
13
|
+
version,
|
|
14
|
+
kernel: '^1.0.0',
|
|
15
|
+
provides: [LOCAL_ENV],
|
|
16
|
+
requires: [{ token: EXECUTOR, optional: true }],
|
|
17
|
+
capabilities: ['exec', 'fs'],
|
|
18
|
+
},
|
|
19
|
+
setup(ctx) {
|
|
20
|
+
const env = createLocalEnvClient()
|
|
21
|
+
ctx.provide(LOCAL_ENV, env)
|
|
22
|
+
const router = ctx.tryGet(EXECUTOR)
|
|
23
|
+
if (router) {
|
|
24
|
+
unregister = [router.register(env.executor, execLocalConfig(ctx.config).priority)]
|
|
25
|
+
for (const [name, spec] of Object.entries(ctx.environments ?? {})) {
|
|
26
|
+
if (spec.kind !== DEVCONTAINER_KIND) continue
|
|
27
|
+
const named: Executor = { id: environmentExecutor(name), supports: lang => env.executor.supports(lang), run: (request, signal) => env.executor.run(request, signal) }
|
|
28
|
+
unregister.push(router.register(named))
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
void env.status()
|
|
32
|
+
},
|
|
33
|
+
dispose() {
|
|
34
|
+
unregister.forEach(off => off())
|
|
35
|
+
unregister = []
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default execLocalPlugin
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export const EXEC_LOCAL_ID = 'presmium.execLocal'
|
|
4
|
+
|
|
5
|
+
export const DEVCONTAINER_KIND = 'devcontainer'
|
|
6
|
+
|
|
7
|
+
export interface ExecLocalConfig {
|
|
8
|
+
workspace?: string
|
|
9
|
+
priority: number
|
|
10
|
+
buildTimeoutMs: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PRIORITY = 10
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_BUILD_TIMEOUT_MS = 600_000
|
|
16
|
+
|
|
17
|
+
export function execLocalConfig(options: unknown): ExecLocalConfig {
|
|
18
|
+
const config = isRecord(options) ? options : {}
|
|
19
|
+
const buildTimeoutMs = config.buildTimeoutMs
|
|
20
|
+
return {
|
|
21
|
+
workspace: typeof config.workspace === 'string' ? config.workspace : undefined,
|
|
22
|
+
priority: typeof config.priority === 'number' ? config.priority : DEFAULT_PRIORITY,
|
|
23
|
+
buildTimeoutMs: typeof buildTimeoutMs === 'number' && buildTimeoutMs > 0 ? buildTimeoutMs : DEFAULT_BUILD_TIMEOUT_MS,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DEFAULT_BUILD_TIMEOUT_MS, DEFAULT_PRIORITY, DEVCONTAINER_KIND, EXEC_LOCAL_ID, execLocalConfig } from './config.ts'
|
|
2
|
+
export type { ExecLocalConfig } from './config.ts'
|
|
3
|
+
export { CONTAINER_EXECUTOR_ID, disconnected, failedContainerRun, LOCAL_ENV } from './service.ts'
|
|
4
|
+
export type { DisconnectedReason, LocalEnvService, LocalEnvStatus, ReplicateRequest } from './service.ts'
|
|
5
|
+
export { archiveName, DEFAULT_LANGUAGE_COMMANDS, languageCommandsFrom, workspacePath } from './workspace.ts'
|
|
6
|
+
export type { LanguageCommands } from './workspace.ts'
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineService, failedRun } from '@lincyaw/kernel'
|
|
2
|
+
import type { RunRequest, RunResult } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
export const CONTAINER_EXECUTOR_ID = 'presmium.container'
|
|
5
|
+
|
|
6
|
+
export type DisconnectedReason = 'devServerOnly' | 'noEndpoints' | 'notAllowed' | 'unreachable' | 'pluginOff'
|
|
7
|
+
|
|
8
|
+
export interface LocalEnvStatus {
|
|
9
|
+
connected: boolean
|
|
10
|
+
docker: boolean
|
|
11
|
+
devcontainer: string | null
|
|
12
|
+
languages: string[]
|
|
13
|
+
reason?: DisconnectedReason
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReplicateRequest {
|
|
17
|
+
file: string
|
|
18
|
+
content: string
|
|
19
|
+
lang?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LocalEnvService {
|
|
23
|
+
status(): Promise<LocalEnvStatus>
|
|
24
|
+
run(request: RunRequest, signal?: AbortSignal): Promise<RunResult>
|
|
25
|
+
replicate(request: ReplicateRequest): Promise<Blob>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const LOCAL_ENV = defineService<LocalEnvService>('presmium.localEnv')
|
|
29
|
+
|
|
30
|
+
export function disconnected(reason: DisconnectedReason): LocalEnvStatus {
|
|
31
|
+
return { connected: false, docker: false, devcontainer: null, languages: [], reason }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function failedContainerRun(message: string, executorId = CONTAINER_EXECUTOR_ID): RunResult {
|
|
35
|
+
return failedRun(executorId, message)
|
|
36
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { isRecord, languageId } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
/** The command that runs code of each language in the container; the code follows as its last argument. */
|
|
4
|
+
export type LanguageCommands = Record<string, string[]>
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_LANGUAGE_COMMANDS: LanguageCommands = { python: ['python3', '-c'] }
|
|
7
|
+
|
|
8
|
+
/** The commands of the devcontainer definition (`customizations.presmium.runners`) over the default ones. */
|
|
9
|
+
export function languageCommandsFrom(customizations: unknown): LanguageCommands {
|
|
10
|
+
const presmium = isRecord(customizations) ? customizations.presmium : undefined
|
|
11
|
+
const custom = isRecord(presmium) && isRecord(presmium.runners) ? presmium.runners : {}
|
|
12
|
+
const commands: LanguageCommands = { ...DEFAULT_LANGUAGE_COMMANDS }
|
|
13
|
+
for (const [lang, command] of Object.entries(custom)) {
|
|
14
|
+
if (Array.isArray(command) && command.length > 0 && command.every(part => typeof part === 'string')) {
|
|
15
|
+
commands[languageId(lang)] = command
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return commands
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function workspacePath(file: string): string {
|
|
22
|
+
const parts = file.replaceAll('\\', '/').split('/').filter(part => part !== '' && part !== '.')
|
|
23
|
+
if (!file || /^([a-z]:)?[\\/]/i.test(file) || parts.length === 0 || parts.includes('..') || parts[0] === '.git') {
|
|
24
|
+
throw new Error(`not a path inside the workspace: ${file}`)
|
|
25
|
+
}
|
|
26
|
+
return parts.join('/')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function archiveName(file: string): string {
|
|
30
|
+
const name = file.split('/').pop() ?? file
|
|
31
|
+
return `presmium-env-${name.replace(/\.[^.]*$/, '') || 'snippet'}.zip`
|
|
32
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { dirname, join } from 'node:path'
|
|
6
|
+
import { errorMessage, languageId } from '@lincyaw/kernel'
|
|
7
|
+
import type { Executor, RunContext, RunOutput, RunRequest, RunResult } from '@lincyaw/kernel'
|
|
8
|
+
import { RUN_REQUEST_FILE, runResultFile } from '@lincyaw/kernel/node'
|
|
9
|
+
import { CONTAINER_EXECUTOR_ID, failedContainerRun, workspacePath } from '../domain/index.ts'
|
|
10
|
+
import type { BuildOptions, Devcontainer } from './devcontainer.ts'
|
|
11
|
+
import { definitionCommands, definitionHash, devcontainerImage, findDefinition, readDevcontainer } from './devcontainer.ts'
|
|
12
|
+
import { dockerAvailable, dockerEnv, isDockerAvailableSync } from './docker.ts'
|
|
13
|
+
import { text } from './messages.ts'
|
|
14
|
+
import { runProcess } from './process.ts'
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
17
|
+
const RUN_DIR = '/presmium-run'
|
|
18
|
+
const PYTHON_RUNNER = `${readFileSync(createRequire(import.meta.url).resolve('@lincyaw/kernel/runner.py'), 'utf8')}\n_pm_main()\n`
|
|
19
|
+
const HOST_PATHS = new Set(['PATH', 'HOME', 'TMPDIR'])
|
|
20
|
+
|
|
21
|
+
export interface ContainerExecutor extends Executor {
|
|
22
|
+
bind(workspace: string, options?: BuildOptions): void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Runs code in a new container of the image of the bound workspace's devcontainer definition. */
|
|
26
|
+
export function createContainerExecutor(id = CONTAINER_EXECUTOR_ID): ContainerExecutor {
|
|
27
|
+
let workspace: string | undefined
|
|
28
|
+
let build: BuildOptions = {}
|
|
29
|
+
const definition = () => (workspace === undefined ? undefined : findDefinition(workspace))
|
|
30
|
+
const command = (configFile: string, lang: string) => definitionCommands(configFile)[languageId(lang)]
|
|
31
|
+
return {
|
|
32
|
+
id,
|
|
33
|
+
bind(dir, options = {}) {
|
|
34
|
+
workspace = dir
|
|
35
|
+
build = options
|
|
36
|
+
},
|
|
37
|
+
supports(lang) {
|
|
38
|
+
const configFile = definition()
|
|
39
|
+
return configFile !== undefined && command(configFile, lang) !== undefined && isDockerAvailableSync()
|
|
40
|
+
},
|
|
41
|
+
fingerprint(lang) {
|
|
42
|
+
const configFile = definition()
|
|
43
|
+
return configFile ? `${definitionHash(configFile)}:${JSON.stringify(command(configFile, lang) ?? null)}` : ''
|
|
44
|
+
},
|
|
45
|
+
async run(request, signal, ctx) {
|
|
46
|
+
const dir = workspace
|
|
47
|
+
if (dir === undefined || !definition()) throw new Error('the workspace has no devcontainer definition')
|
|
48
|
+
if (!await dockerAvailable()) throw new Error('Docker is not available')
|
|
49
|
+
const devcontainer = await readDevcontainer(dir, dockerEnv(ctx))
|
|
50
|
+
if (!devcontainer) throw new Error('cannot read the devcontainer definition')
|
|
51
|
+
const result = await runInContainer({ workspace: dir, devcontainer, request, build, signal, ctx, executorId: id })
|
|
52
|
+
if (result.exitCode === null && !result.timedOut) throw new Error(result.stderr)
|
|
53
|
+
return result
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function runDirectory(files: Record<string, string> | undefined, runnerRequest?: unknown): { parent: string, dir: string } {
|
|
59
|
+
const parent = mkdtempSync(join(tmpdir(), 'presmium-container-'))
|
|
60
|
+
chmodSync(parent, 0o700)
|
|
61
|
+
const dir = join(parent, 'run')
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(join(dir, '.presmium'), { recursive: true })
|
|
64
|
+
chmodSync(dir, 0o777)
|
|
65
|
+
chmodSync(join(dir, '.presmium'), 0o777)
|
|
66
|
+
for (const [name, content] of Object.entries(files ?? {})) {
|
|
67
|
+
const file = join(dir, workspacePath(name))
|
|
68
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o777 })
|
|
69
|
+
writeFileSync(file, content, { mode: 0o666, flag: 'wx' })
|
|
70
|
+
}
|
|
71
|
+
if (runnerRequest !== undefined) writeFileSync(join(dir, RUN_REQUEST_FILE), JSON.stringify(runnerRequest), { mode: 0o666, flag: 'wx' })
|
|
72
|
+
} catch (error) {
|
|
73
|
+
rmSync(parent, { recursive: true, force: true })
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
76
|
+
return { parent, dir }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pythonResult(dir: string): { stdout: string, stderr: string, exitCode: number, outputs?: RunOutput[] } | undefined {
|
|
80
|
+
try {
|
|
81
|
+
return JSON.parse(readFileSync(join(dir, runResultFile(0)), 'utf8'))
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ContainerRun {
|
|
89
|
+
workspace: string
|
|
90
|
+
devcontainer: Devcontainer
|
|
91
|
+
request: RunRequest
|
|
92
|
+
build?: BuildOptions
|
|
93
|
+
signal?: AbortSignal
|
|
94
|
+
ctx?: RunContext
|
|
95
|
+
executorId?: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Runs the request with `docker run` in a new container of the definition's image, without network; the workspace is mounted read-only. */
|
|
99
|
+
export async function runInContainer(run: ContainerRun): Promise<RunResult> {
|
|
100
|
+
const { workspace, devcontainer, request, signal, ctx, executorId = CONTAINER_EXECUTOR_ID } = run
|
|
101
|
+
const lang = languageId(request.lang)
|
|
102
|
+
const command = devcontainer.commands[lang]
|
|
103
|
+
if (!command) return failedContainerRun(text().unsupportedLanguage(request.lang), executorId)
|
|
104
|
+
const commandEnv = dockerEnv(ctx)
|
|
105
|
+
let image: string
|
|
106
|
+
try {
|
|
107
|
+
image = await devcontainerImage(workspace, devcontainer, run.build, signal, commandEnv)
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
return failedContainerRun(text().buildFailed(errorMessage(error)), executorId)
|
|
111
|
+
}
|
|
112
|
+
const python = lang === 'python'
|
|
113
|
+
let directory: { parent: string, dir: string }
|
|
114
|
+
try {
|
|
115
|
+
directory = runDirectory(request.files, python ? { runs: [{ code: request.code, args: request.args ?? {}, stdin: request.stdin ?? '' }], path: [devcontainer.workspaceFolder] } : undefined)
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
return failedContainerRun(errorMessage(error), executorId)
|
|
119
|
+
}
|
|
120
|
+
const { parent, dir } = directory
|
|
121
|
+
const started = Date.now()
|
|
122
|
+
const name = `presmium-run-${randomUUID()}`
|
|
123
|
+
const passed = Object.fromEntries(Object.entries(ctx?.env ?? {}).filter(([key]) => !HOST_PATHS.has(key)))
|
|
124
|
+
const env = { ...passed, ...devcontainer.env, ...python ? {} : request.args }
|
|
125
|
+
try {
|
|
126
|
+
const result = await runProcess('docker', [
|
|
127
|
+
'run', '--rm', '-i', '--init', '--network', 'none', '--name', name,
|
|
128
|
+
'--mount', `${devcontainer.workspaceMount},readonly`,
|
|
129
|
+
'--mount', `type=bind,source=${dir},target=${RUN_DIR}`,
|
|
130
|
+
'--workdir', RUN_DIR,
|
|
131
|
+
...(devcontainer.user ? ['--user', devcontainer.user] : []),
|
|
132
|
+
...Object.entries(env).flatMap(([key, value]) => ['--env', `${key}=${value}`]),
|
|
133
|
+
image,
|
|
134
|
+
...command,
|
|
135
|
+
python ? PYTHON_RUNNER : request.code,
|
|
136
|
+
], {
|
|
137
|
+
input: python ? '' : request.stdin,
|
|
138
|
+
env: commandEnv,
|
|
139
|
+
timeoutMs: request.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
140
|
+
signal,
|
|
141
|
+
onStop: () => void runProcess('docker', ['kill', name], { env: commandEnv }),
|
|
142
|
+
})
|
|
143
|
+
const output = python && !result.timedOut ? pythonResult(dir) : undefined
|
|
144
|
+
const exitCode = result.timedOut ? null : output ? output.exitCode : result.code
|
|
145
|
+
return {
|
|
146
|
+
ok: exitCode === 0,
|
|
147
|
+
stdout: output ? output.stdout : result.stdout,
|
|
148
|
+
stderr: output ? output.stderr : result.stderr,
|
|
149
|
+
exitCode,
|
|
150
|
+
durationMs: Date.now() - started,
|
|
151
|
+
timedOut: result.timedOut,
|
|
152
|
+
source: 'live',
|
|
153
|
+
executorId,
|
|
154
|
+
...output?.outputs ? { outputs: output.outputs } : {},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
try {
|
|
159
|
+
rmSync(parent, { recursive: true, force: true })
|
|
160
|
+
}
|
|
161
|
+
catch {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { createRequire } from 'node:module'
|
|
4
|
+
import { basename, dirname, join, relative, resolve } from 'node:path'
|
|
5
|
+
import { parse } from 'jsonc-parser'
|
|
6
|
+
import { DEFAULT_BUILD_TIMEOUT_MS, languageCommandsFrom } from '../domain/index.ts'
|
|
7
|
+
import type { LanguageCommands } from '../domain/index.ts'
|
|
8
|
+
import { dockerEnv } from './docker.ts'
|
|
9
|
+
import { runProcess } from './process.ts'
|
|
10
|
+
|
|
11
|
+
const CLI = createRequire(import.meta.url).resolve('@devcontainers/cli/devcontainer.js')
|
|
12
|
+
const READ_TIMEOUT_MS = 60_000
|
|
13
|
+
|
|
14
|
+
export interface Devcontainer {
|
|
15
|
+
configFile: string
|
|
16
|
+
workspaceMount: string
|
|
17
|
+
workspaceFolder: string
|
|
18
|
+
commands: LanguageCommands
|
|
19
|
+
user?: string
|
|
20
|
+
env: Record<string, string>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BuildOptions {
|
|
24
|
+
buildTimeoutMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function devcontainerCli(args: string[], timeoutMs: number, signal?: AbortSignal, env = dockerEnv()) {
|
|
28
|
+
return runProcess(process.execPath, [CLI, ...args], { timeoutMs, signal, env })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface CliOutput {
|
|
32
|
+
outcome?: string
|
|
33
|
+
message?: string
|
|
34
|
+
description?: string
|
|
35
|
+
configuration?: {
|
|
36
|
+
configFilePath?: { fsPath?: string }
|
|
37
|
+
customizations?: unknown
|
|
38
|
+
remoteUser?: string
|
|
39
|
+
containerUser?: string
|
|
40
|
+
containerEnv?: Record<string, string>
|
|
41
|
+
}
|
|
42
|
+
workspace?: { workspaceFolder?: string, workspaceMount?: string }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function lastJson(stdout: string): CliOutput | undefined {
|
|
46
|
+
const line = stdout.trim().split('\n').pop()
|
|
47
|
+
try {
|
|
48
|
+
return line ? JSON.parse(line) : undefined
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function findDefinition(workspace: string): string | undefined {
|
|
56
|
+
return ['.devcontainer/devcontainer.json', '.devcontainer.json'].map(file => join(workspace, file)).find(existsSync)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function definitionFiles(configFile: string): string[] {
|
|
60
|
+
const dir = dirname(configFile)
|
|
61
|
+
const files = new Set(basename(configFile) === '.devcontainer.json' ? [configFile]
|
|
62
|
+
: readdirSync(dir, { recursive: true, encoding: 'utf8' }).map(name => join(dir, name)).filter(path => statSync(path).isFile()))
|
|
63
|
+
const config = parse(readFileSync(configFile, 'utf8'), [], { allowTrailingComma: true }) as {
|
|
64
|
+
dockerFile?: string, build?: { dockerfile?: string, context?: string }, dockerComposeFile?: string | string[]
|
|
65
|
+
} | undefined
|
|
66
|
+
const dockerfile = config?.build?.dockerfile ?? config?.dockerFile
|
|
67
|
+
const references = [dockerfile, ...typeof config?.dockerComposeFile === 'string' ? [config.dockerComposeFile] : config?.dockerComposeFile ?? []]
|
|
68
|
+
if (dockerfile) references.push(join(config?.build?.context ?? '.', '.dockerignore'))
|
|
69
|
+
for (const reference of references) {
|
|
70
|
+
if (!reference) continue
|
|
71
|
+
const path = resolve(dir, reference)
|
|
72
|
+
if (statSync(path, { throwIfNoEntry: false })?.isFile()) files.add(path)
|
|
73
|
+
}
|
|
74
|
+
return [...files].sort()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function definitionStamp(configFile: string): string {
|
|
78
|
+
return JSON.stringify([configFile, definitionFiles(configFile).map(path => {
|
|
79
|
+
const { size, mtimeMs, ctimeMs } = statSync(path)
|
|
80
|
+
return [path, size, mtimeMs, ctimeMs]
|
|
81
|
+
})])
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function perVersion<T>(compute: (configFile: string) => T): (configFile: string) => T {
|
|
85
|
+
const values = new Map<string, T>()
|
|
86
|
+
return (configFile) => {
|
|
87
|
+
const stamp = definitionStamp(configFile)
|
|
88
|
+
if (!values.has(stamp)) values.set(stamp, compute(configFile))
|
|
89
|
+
return values.get(stamp)!
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The language commands of the devcontainer definition, read again when its inputs change. */
|
|
94
|
+
export const definitionCommands = perVersion((configFile): LanguageCommands => {
|
|
95
|
+
const config = parse(readFileSync(configFile, 'utf8'), [], { allowTrailingComma: true }) as { customizations?: unknown } | undefined
|
|
96
|
+
return languageCommandsFrom(config?.customizations)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
export const definitionHash = perVersion((configFile): string => {
|
|
100
|
+
const dir = dirname(configFile)
|
|
101
|
+
const hash = createHash('sha256')
|
|
102
|
+
for (const path of definitionFiles(configFile)) {
|
|
103
|
+
hash.update(`${relative(dir, path)}\0`).update(readFileSync(path)).update('\0')
|
|
104
|
+
}
|
|
105
|
+
return hash.digest('hex')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
const configurations = new Map<string, Promise<Devcontainer | undefined>>()
|
|
109
|
+
|
|
110
|
+
export function readDevcontainer(workspace: string, env?: Record<string, string>): Promise<Devcontainer | undefined> {
|
|
111
|
+
const configFile = findDefinition(workspace)
|
|
112
|
+
if (!configFile) return Promise.resolve(undefined)
|
|
113
|
+
const key = `${workspace}\0${definitionStamp(configFile)}`
|
|
114
|
+
let read = configurations.get(key)
|
|
115
|
+
if (!read) {
|
|
116
|
+
read = readConfiguration(workspace, env)
|
|
117
|
+
configurations.set(key, read)
|
|
118
|
+
void read.then(found => found || configurations.delete(key))
|
|
119
|
+
}
|
|
120
|
+
return read
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function readConfiguration(workspace: string, env?: Record<string, string>): Promise<Devcontainer | undefined> {
|
|
124
|
+
const result = await devcontainerCli(['read-configuration', '--workspace-folder', workspace], READ_TIMEOUT_MS, undefined, env)
|
|
125
|
+
const output = lastJson(result.stdout)
|
|
126
|
+
const config = output?.configuration
|
|
127
|
+
const configFile = config?.configFilePath?.fsPath
|
|
128
|
+
if (result.code !== 0 || !config || !configFile) return undefined
|
|
129
|
+
const workspaceFolder = output?.workspace?.workspaceFolder ?? `/workspaces/${basename(workspace)}`
|
|
130
|
+
return {
|
|
131
|
+
configFile,
|
|
132
|
+
workspaceMount: output?.workspace?.workspaceMount ?? `type=bind,source=${workspace},target=${workspaceFolder}`,
|
|
133
|
+
workspaceFolder,
|
|
134
|
+
commands: languageCommandsFrom(config.customizations),
|
|
135
|
+
user: config.remoteUser ?? config.containerUser,
|
|
136
|
+
env: { ...config.containerEnv },
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const images = new Map<string, Promise<string>>()
|
|
141
|
+
|
|
142
|
+
export function imageName(workspace: string): string {
|
|
143
|
+
return `presmium-devcontainer-${createHash('sha256').update(workspace).digest('hex').slice(0, 12)}`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function devcontainerImage(workspace: string, devcontainer: Devcontainer, options: BuildOptions = {}, signal?: AbortSignal, env?: Record<string, string>): Promise<string> {
|
|
147
|
+
const key = definitionStamp(devcontainer.configFile)
|
|
148
|
+
let image = images.get(key)
|
|
149
|
+
if (!image) {
|
|
150
|
+
const name = imageName(workspace)
|
|
151
|
+
const timeoutMs = options.buildTimeoutMs ?? DEFAULT_BUILD_TIMEOUT_MS
|
|
152
|
+
image = devcontainerCli(['build', '--workspace-folder', workspace, '--image-name', name], timeoutMs, signal, env).then((result) => {
|
|
153
|
+
if (result.timedOut) throw new Error(`stopped after ${timeoutMs} ms`)
|
|
154
|
+
const output = lastJson(result.stdout)
|
|
155
|
+
if (output?.outcome !== 'success') {
|
|
156
|
+
const detail = [output?.message, output?.description].filter(Boolean).join('\n') || result.stderr.trim().slice(-2000)
|
|
157
|
+
throw new Error(detail)
|
|
158
|
+
}
|
|
159
|
+
return name
|
|
160
|
+
})
|
|
161
|
+
image.catch(() => images.delete(key))
|
|
162
|
+
images.set(key, image)
|
|
163
|
+
}
|
|
164
|
+
return image
|
|
165
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import type { RunContext } from '@lincyaw/kernel'
|
|
3
|
+
import { minimalRunEnv } from '@lincyaw/kernel/node'
|
|
4
|
+
import { runProcess } from './process.ts'
|
|
5
|
+
|
|
6
|
+
const DOCKER_CLIENT = ['DOCKER_HOST', 'DOCKER_CONTEXT', 'DOCKER_CONFIG', 'DOCKER_CERT_PATH', 'DOCKER_TLS_VERIFY', 'XDG_RUNTIME_DIR', 'HOME']
|
|
7
|
+
|
|
8
|
+
/** The run's environment with the variables the Docker client needs to reach the daemon. */
|
|
9
|
+
export function dockerEnv(ctx?: RunContext): Record<string, string> {
|
|
10
|
+
const env: Record<string, string> = { ...ctx?.env ?? minimalRunEnv() }
|
|
11
|
+
for (const name of DOCKER_CLIENT) {
|
|
12
|
+
const value = process.env[name]
|
|
13
|
+
if (value !== undefined) env[name] = value
|
|
14
|
+
}
|
|
15
|
+
return env
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let docker: Promise<boolean> | undefined
|
|
19
|
+
|
|
20
|
+
export function dockerAvailable(): Promise<boolean> {
|
|
21
|
+
if (!docker) {
|
|
22
|
+
const probe = runProcess('docker', ['version', '--format', '{{.Server.Version}}'], { timeoutMs: 10_000, env: dockerEnv() }).then(result => result.code === 0)
|
|
23
|
+
docker = probe
|
|
24
|
+
void probe.then((answers) => {
|
|
25
|
+
if (!answers && docker === probe) docker = undefined
|
|
26
|
+
})
|
|
27
|
+
}
|
|
28
|
+
return docker
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let dockerSync: boolean | undefined
|
|
32
|
+
|
|
33
|
+
export function isDockerAvailableSync(): boolean {
|
|
34
|
+
dockerSync ??= spawnSync('docker', ['version', '--format', '{{.Server.Version}}'], { stdio: 'ignore', timeout: 10_000, env: dockerEnv() }).status === 0
|
|
35
|
+
return dockerSync
|
|
36
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { dirname, relative, resolve, sep } from 'node:path'
|
|
2
|
+
import { componentCode, docBlocks } from '@lincyaw/kernel'
|
|
3
|
+
import type { CodeNode, ComponentNode, DirectiveSpec, Executor, IRBlock, IRPass } from '@lincyaw/kernel'
|
|
4
|
+
import { EXEC_LOCAL_ID, execLocalConfig, workspacePath } from '../domain/index.ts'
|
|
5
|
+
import type { ContainerExecutor } from './container.ts'
|
|
6
|
+
import { createContainerExecutor } from './container.ts'
|
|
7
|
+
import { findDefinition } from './devcontainer.ts'
|
|
8
|
+
import { inLanguage, text } from './messages.ts'
|
|
9
|
+
import { replicateArchive } from './replicate.ts'
|
|
10
|
+
|
|
11
|
+
/** The `:workspace:` each {local-env} names, resolved, for the workspace pass of the same compile. */
|
|
12
|
+
const requestedWorkspaces = new WeakMap<ComponentNode, string>()
|
|
13
|
+
|
|
14
|
+
export const localEnvDirective: DirectiveSpec = {
|
|
15
|
+
name: 'local-env',
|
|
16
|
+
arg: true,
|
|
17
|
+
body: true,
|
|
18
|
+
options: { file: 'string', workspace: 'string' },
|
|
19
|
+
toIR(input, ctx) {
|
|
20
|
+
const lang = input.arg?.trim() || 'python'
|
|
21
|
+
let file: string
|
|
22
|
+
try {
|
|
23
|
+
file = workspacePath(String(input.options.file ?? ''))
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
const message = '{local-env} needs :file:, the path of the snippet inside the workspace'
|
|
27
|
+
ctx.report({ checkId: 'presmium.execLocal.syntax', severity: 'error', message })
|
|
28
|
+
return { node: { type: 'markdown', mdast: ctx.parse(`> ${message}`) } }
|
|
29
|
+
}
|
|
30
|
+
const code: CodeNode = { type: 'code', lang, value: input.body.replace(/\n+$/, '') }
|
|
31
|
+
const node: ComponentNode = {
|
|
32
|
+
type: 'component',
|
|
33
|
+
plugin: EXEC_LOCAL_ID,
|
|
34
|
+
name: 'PmLocalEnv',
|
|
35
|
+
props: { file, lang, code: code.value },
|
|
36
|
+
children: [{ id: 'source', role: 'visual', at: 0, source: ctx.source, node: code }],
|
|
37
|
+
fallback: { type: 'root', children: [{ type: 'code', lang, value: code.value }] },
|
|
38
|
+
}
|
|
39
|
+
if (typeof input.options.workspace === 'string') requestedWorkspaces.set(node, resolve(dirname(ctx.file), input.options.workspace))
|
|
40
|
+
return { node }
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const deckContainers = new Map<string, ContainerExecutor>()
|
|
45
|
+
|
|
46
|
+
/** Each deck runs in its own workspace: the deck's container executor takes the place of the plugin's in the executors of this compile. */
|
|
47
|
+
function bindDeckContainer(deckDir: string, executors: Executor[], pluginContainer: Executor): ContainerExecutor {
|
|
48
|
+
let container = deckContainers.get(deckDir)
|
|
49
|
+
if (!container) deckContainers.set(deckDir, container = createContainerExecutor(pluginContainer.id))
|
|
50
|
+
const at = executors.indexOf(pluginContainer)
|
|
51
|
+
if (at >= 0) executors[at] = container
|
|
52
|
+
return container
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function localEnvPass(pluginContainer: Executor): IRPass {
|
|
56
|
+
return {
|
|
57
|
+
name: 'presmium.execLocal.workspace',
|
|
58
|
+
run(doc, ctx) {
|
|
59
|
+
const envs = [...docBlocks(doc)].map(({ block }) => block)
|
|
60
|
+
.filter((block): block is IRBlock & { node: ComponentNode } => block.node.type === 'component' && block.node.plugin === EXEC_LOCAL_ID && block.node.name === 'PmLocalEnv')
|
|
61
|
+
const config = execLocalConfig(ctx.config)
|
|
62
|
+
const workspace = config.workspace !== undefined
|
|
63
|
+
? resolve(ctx.deckDir, config.workspace)
|
|
64
|
+
: envs.map(env => requestedWorkspaces.get(env.node)).find(dir => dir !== undefined) ?? ctx.workspaceRoot
|
|
65
|
+
bindDeckContainer(ctx.deckDir, ctx.executors, pluginContainer).bind(workspace, config)
|
|
66
|
+
const shared = relative(ctx.deckDir, workspace).split(sep).join('/') || '.'
|
|
67
|
+
doc.config.presmium = { ...doc.config.presmium, [EXEC_LOCAL_ID]: { ...ctx.config, workspace: shared } }
|
|
68
|
+
const named = (dir: string) => relative(ctx.deckDir, dir).split(sep).join('/') || '.'
|
|
69
|
+
|
|
70
|
+
for (const { node, source, stepStates } of envs) {
|
|
71
|
+
const dir = requestedWorkspaces.get(node) ?? workspace
|
|
72
|
+
const at = { file: source.file, line: source.line }
|
|
73
|
+
const code = componentCode(node)
|
|
74
|
+
if (!code || node.children?.filter(child => child.node.type === 'code').length !== 1) {
|
|
75
|
+
ctx.report({ checkId: EXEC_LOCAL_ID, severity: 'error', message: inLanguage(ctx.lang, () => text().codeChild), source })
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
node.props.code = code.value
|
|
79
|
+
node.props.lang = code.lang
|
|
80
|
+
delete node.props.archive
|
|
81
|
+
delete node.props.devcontainer
|
|
82
|
+
for (const state of stepStates ?? []) if (state.parameters) {
|
|
83
|
+
for (const key of ['code', 'lang', 'archive', 'devcontainer']) delete state.parameters[key]
|
|
84
|
+
}
|
|
85
|
+
if (dir !== workspace) {
|
|
86
|
+
ctx.report({
|
|
87
|
+
checkId: 'presmium.execLocal.workspace',
|
|
88
|
+
severity: 'error',
|
|
89
|
+
message: `{local-env} names the workspace ${named(dir)}, but the deck's workspace is ${named(workspace)}: the snippet would run in ${named(workspace)}`,
|
|
90
|
+
hint: 'give every {local-env} of the deck the same :workspace:, or set presmium.execLocal.workspace',
|
|
91
|
+
source: at,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
const definition = findDefinition(dir)
|
|
95
|
+
if (!definition) {
|
|
96
|
+
ctx.report({
|
|
97
|
+
checkId: 'presmium.execLocal.devcontainer',
|
|
98
|
+
severity: 'warning',
|
|
99
|
+
message: `no devcontainer definition in ${named(dir)}; the snippet cannot be run in a container or exported`,
|
|
100
|
+
source: at,
|
|
101
|
+
})
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
const file = String(node.props.file)
|
|
105
|
+
const request = { file, content: `${code.value}\n`, lang: String(node.props.lang) }
|
|
106
|
+
const { zip } = inLanguage(ctx.lang, () => replicateArchive(dir, definition, request))
|
|
107
|
+
node.props.devcontainer = relative(dir, definition).split(sep).join('/')
|
|
108
|
+
node.props.archive = Buffer.from(zip).toString('base64')
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
}
|