init..
This commit is contained in:
+324
-23
@@ -1,11 +1,14 @@
|
||||
import { app, BrowserWindow, globalShortcut, ipcMain, screen } from 'electron'
|
||||
import type { WebContents } from 'electron'
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
|
||||
import { existsSync } from 'fs'
|
||||
import { createWriteStream, existsSync } from 'fs'
|
||||
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
|
||||
import { homedir } from 'os'
|
||||
import { basename, dirname, join, relative, resolve } from 'path'
|
||||
import { is } from '@electron-toolkit/utils'
|
||||
import { createHash } from 'crypto'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let splashWindow: BrowserWindow | null = null
|
||||
@@ -13,6 +16,7 @@ const terminalProcesses = new Map<string, ChildProcessWithoutNullStreams>()
|
||||
const terminalStopTimers = new Map<string, NodeJS.Timeout>()
|
||||
const intentionalTerminalStops = new Set<string>()
|
||||
const moduleCacheRoot = resolve(homedir(), '.caches')
|
||||
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
|
||||
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
|
||||
const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda'
|
||||
const autowareRosBridgeImage = 'autoware-ros-bridge'
|
||||
@@ -83,6 +87,8 @@ type CachedModuleInfo = {
|
||||
sourcePath: string
|
||||
workspacePath: string | null
|
||||
templateRepositoryUrl?: string | null
|
||||
sourceType?: string | null
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
parameters: string[]
|
||||
cpu: number | null
|
||||
memory: number | null
|
||||
@@ -95,6 +101,30 @@ type CourseModuleInput = {
|
||||
name: string
|
||||
folderName: string
|
||||
templateRepositoryUrl?: string | null
|
||||
sourceType?: string | null
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
}
|
||||
|
||||
type ModulePrebuiltArtifact = {
|
||||
type?: string | null
|
||||
url?: string | null
|
||||
fileName?: string | null
|
||||
checksumSha256?: string | null
|
||||
extractPath?: string | null
|
||||
required?: boolean | null
|
||||
}
|
||||
|
||||
type RuntimeImageArtifact = {
|
||||
image?: string | null
|
||||
url?: string | null
|
||||
fileName?: string | null
|
||||
checksumSha256?: string | null
|
||||
required?: boolean | null
|
||||
}
|
||||
|
||||
type ModuleInstallOptions = {
|
||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||
runtimeImages?: RuntimeImageArtifact[] | null
|
||||
}
|
||||
|
||||
type RemoteWorkspaceTarget = {
|
||||
@@ -144,6 +174,7 @@ type EnvironmentProfileConfig = {
|
||||
logFile?: string
|
||||
}>
|
||||
}
|
||||
runtimeImages?: RuntimeImageArtifact[] | null
|
||||
bridge?: {
|
||||
name?: string
|
||||
image?: string
|
||||
@@ -337,6 +368,8 @@ async function collectAvailableModules(
|
||||
sourcePath,
|
||||
workspacePath,
|
||||
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
|
||||
sourceType: module.sourceType ?? null,
|
||||
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
|
||||
parameters: splitRosParams(rosParams),
|
||||
cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null,
|
||||
memory: dockerStatus?.running ? dockerStat?.memory ?? null : null,
|
||||
@@ -519,6 +552,120 @@ async function runCommand(command: string, args: string[], cwd?: string, env?: N
|
||||
await runCommandOutput(command, args, cwd, env, timeoutMs)
|
||||
}
|
||||
|
||||
function safeArtifactFileName(url: string, fileName?: string | null): string {
|
||||
const rawName = fileName?.trim() || basename(new URL(url).pathname) || 'artifact.tar'
|
||||
return rawName.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
}
|
||||
|
||||
function artifactCachePath(url: string, fileName?: string | null): string {
|
||||
return resolve(artifactCacheRoot, safeArtifactFileName(url, fileName))
|
||||
}
|
||||
|
||||
async function downloadArtifact(url: string, targetPath: string, redirects = 0): Promise<void> {
|
||||
if (redirects > 5) throw new Error('artifact redirect limit exceeded')
|
||||
await mkdir(dirname(targetPath), { recursive: true })
|
||||
|
||||
await new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
const parsed = new URL(url)
|
||||
const client = parsed.protocol === 'http:' ? http : https
|
||||
const request = client.get(parsed, (response) => {
|
||||
const statusCode = response.statusCode ?? 0
|
||||
const location = response.headers.location
|
||||
if (statusCode >= 300 && statusCode < 400 && location) {
|
||||
response.resume()
|
||||
const nextUrl = new URL(location, parsed).toString()
|
||||
downloadArtifact(nextUrl, targetPath, redirects + 1).then(resolvePromise, rejectPromise)
|
||||
return
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300) {
|
||||
response.resume()
|
||||
rejectPromise(new Error(`artifact download failed: HTTP ${statusCode}`))
|
||||
return
|
||||
}
|
||||
|
||||
const file = createWriteStream(targetPath)
|
||||
response.pipe(file)
|
||||
file.on('finish', () => file.close(() => resolvePromise()))
|
||||
file.on('error', rejectPromise)
|
||||
})
|
||||
request.on('error', rejectPromise)
|
||||
})
|
||||
}
|
||||
|
||||
async function ensureArtifactFile(url?: string | null, fileName?: string | null, checksumSha256?: string | null): Promise<string | null> {
|
||||
const artifactUrl = url?.trim()
|
||||
if (!artifactUrl) return null
|
||||
|
||||
const targetPath = artifactCachePath(artifactUrl, fileName)
|
||||
if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath)
|
||||
if (checksumSha256?.trim()) {
|
||||
const actual = createHash('sha256').update(await readFile(targetPath)).digest('hex')
|
||||
if (actual.toLowerCase() !== checksumSha256.trim().toLowerCase()) {
|
||||
await rm(targetPath, { force: true }).catch(() => undefined)
|
||||
throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`)
|
||||
}
|
||||
}
|
||||
return targetPath
|
||||
}
|
||||
|
||||
function tarArgsFor(filePath: string, mode: 'list' | 'extract'): string[] {
|
||||
const lower = filePath.toLowerCase()
|
||||
if (lower.endsWith('.tar.zst') || lower.endsWith('.tzst')) return mode === 'list' ? ['--zstd', '-tf'] : ['--zstd', '-xf']
|
||||
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return mode === 'list' ? ['-tzf'] : ['-xzf']
|
||||
return mode === 'list' ? ['-tf'] : ['-xf']
|
||||
}
|
||||
|
||||
function ensureSafeTarEntry(entry: string): void {
|
||||
const normalized = entry.trim()
|
||||
if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized === '..' || normalized.startsWith('..\\')) {
|
||||
throw new Error(`unsafe artifact path: ${entry}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function extractArtifact(filePath: string, targetRoot: string, extractPath?: string | null): Promise<void> {
|
||||
const destination = resolve(targetRoot, extractPath?.trim() || '.')
|
||||
if (destination !== targetRoot && !destination.startsWith(`${targetRoot}/`)) throw new Error('artifact extract path is outside module root')
|
||||
const entries = await runCommandOutput('tar', [...tarArgsFor(filePath, 'list'), filePath], undefined, undefined, 120000)
|
||||
entries.split('\n').filter(Boolean).forEach(ensureSafeTarEntry)
|
||||
await mkdir(destination, { recursive: true })
|
||||
await runCommand('tar', [...tarArgsFor(filePath, 'extract'), filePath, '-C', destination], undefined, undefined, 900000)
|
||||
}
|
||||
|
||||
async function applyModulePrebuiltArtifacts(targetRoot: string, artifacts?: ModulePrebuiltArtifact[] | null): Promise<void> {
|
||||
const normalizedArtifacts = (artifacts ?? []).filter((artifact) => artifact?.url?.trim())
|
||||
for (const artifact of normalizedArtifacts) {
|
||||
const filePath = await ensureArtifactFile(artifact.url, artifact.fileName, artifact.checksumSha256)
|
||||
if (!filePath) {
|
||||
if (artifact.required) throw new Error('required module artifact url is missing')
|
||||
continue
|
||||
}
|
||||
await extractArtifact(filePath, targetRoot, artifact.extractPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRuntimeImages(images?: RuntimeImageArtifact[] | null): Promise<void> {
|
||||
const normalizedImages = (images ?? []).filter((image) => image?.image?.trim())
|
||||
for (const image of normalizedImages) {
|
||||
const imageName = image.image?.trim()
|
||||
if (!imageName) continue
|
||||
const exists = await runCommand('docker', ['image', 'inspect', imageName], undefined, undefined, 10000)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (exists) continue
|
||||
|
||||
const filePath = await ensureArtifactFile(image.url, image.fileName, image.checksumSha256)
|
||||
if (!filePath) {
|
||||
if (image.required) throw new Error(`runtime image artifact url is missing: ${imageName}`)
|
||||
continue
|
||||
}
|
||||
if (filePath.toLowerCase().endsWith('.tar.zst') || filePath.toLowerCase().endsWith('.tzst')) {
|
||||
await runCommand('bash', ['-lc', 'zstd -dc "$1" | docker load', 'autonomy-studio-runtime-image', filePath], undefined, undefined, 900000)
|
||||
} else {
|
||||
await runCommand('docker', ['load', '-i', filePath], undefined, undefined, 900000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSshIdentityFile(): string {
|
||||
return join(homedir(), '.autonomy-studio', 'ssh', 'id_ed25519')
|
||||
}
|
||||
@@ -1301,11 +1448,55 @@ function visualizationBridgeContainerName(stack: VisualizationStack): string {
|
||||
}
|
||||
|
||||
function visualizationBridgeImageName(stack: VisualizationStack): string {
|
||||
if (stack === 'autoware') return autowareRosBridgeImage
|
||||
if (stack === 'autoware') return autowareDockerImage
|
||||
if (stack === 'nav2') return nav2RosBridgeImage
|
||||
return slamRosBridgeImage
|
||||
}
|
||||
|
||||
function visualizationBridgeResourceContext(stack: VisualizationStack, bridge?: EnvironmentProfileConfig['bridge'] | null): string | null {
|
||||
if (bridge?.buildContext?.trim()) return bridge.buildContext.trim()
|
||||
if (stack === 'autoware') return 'resources/autoware-ros-bridge'
|
||||
if (stack === 'nav2') return 'resources/nav2-ros-bridge'
|
||||
return 'resources/slam-ros-bridge'
|
||||
}
|
||||
|
||||
function resolveVisualizationBridgeImage(
|
||||
stack: VisualizationStack,
|
||||
bridge?: EnvironmentProfileConfig['bridge'] | null,
|
||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): string {
|
||||
const configuredImage = bridge?.image?.trim()
|
||||
if (stack === 'autoware') {
|
||||
if (!configuredImage || configuredImage === autowareRosBridgeImage) {
|
||||
return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || autowareDockerImage
|
||||
}
|
||||
return configuredImage
|
||||
}
|
||||
if (stack === 'slam') {
|
||||
if (!configuredImage || configuredImage === slamRosBridgeImage) {
|
||||
return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || slamSimulationRuntimeImage
|
||||
}
|
||||
return configuredImage
|
||||
}
|
||||
return configuredImage || visualizationBridgeImageName(stack)
|
||||
}
|
||||
|
||||
function bridgeContainerCommand(stack: VisualizationStack): string {
|
||||
const setupWorkspace = stack === 'autoware'
|
||||
? 'if [ -f /autoware_ws/install/setup.bash ]; then source /autoware_ws/install/setup.bash; fi'
|
||||
: ''
|
||||
const bridgePath = stack === 'slam' ? '/app/bridge.py' : '/ros_bridge/bridge.py'
|
||||
const requirementPath = stack === 'slam' ? '/app/requirements.txt' : '/ros_bridge/requirements.txt'
|
||||
return [
|
||||
'source /opt/ros/humble/setup.bash',
|
||||
setupWorkspace,
|
||||
`python3 - <<'PY' || python3 -m pip install --no-cache-dir -r ${requirementPath}`,
|
||||
'import fastapi, uvicorn, websockets',
|
||||
'PY',
|
||||
`python3 ${bridgePath}`
|
||||
].filter(Boolean).join(' && ')
|
||||
}
|
||||
|
||||
function normalizeVisualizationStack(value?: string | null): VisualizationStack {
|
||||
if (value === 'slam') return 'slam'
|
||||
return value === 'nav2' ? 'nav2' : 'autoware'
|
||||
@@ -1554,7 +1745,7 @@ async function startVisualizationBridge(
|
||||
const target = normalizeRuntimeTarget(resolvedProfile?.target)
|
||||
const bridge = resolvedProfile?.bridge
|
||||
const containerName = bridge?.name?.trim() || visualizationBridgeContainerName(stack)
|
||||
const bridgeImage = bridge?.image?.trim() || visualizationBridgeImageName(stack)
|
||||
const bridgeImage = resolveVisualizationBridgeImage(stack, bridge, resolvedProfile?.runtimeImages)
|
||||
const bridgePort = bridge?.port ?? rosBridgePort
|
||||
const otherContainerNames = (['autoware', 'nav2', 'slam'] as VisualizationStack[])
|
||||
.filter((item) => item !== stack)
|
||||
@@ -1585,17 +1776,21 @@ async function startVisualizationBridge(
|
||||
await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined)
|
||||
}
|
||||
|
||||
await prepareRuntimeImages(resolvedProfile?.runtimeImages)
|
||||
if (resolvedProfile?.runtime?.containers?.length) {
|
||||
await ensureEnvironmentRuntime(resolvedProfile, rosDomainId)
|
||||
const runtimeProfile = {
|
||||
...resolvedProfile,
|
||||
runtime: {
|
||||
...resolvedProfile.runtime,
|
||||
containers: resolvedProfile.runtime.containers.filter((container) => container.name?.trim() !== containerName)
|
||||
}
|
||||
}
|
||||
await ensureEnvironmentRuntime(runtimeProfile, rosDomainId)
|
||||
}
|
||||
if (bridge?.buildContext) {
|
||||
await ensureBridgeImage(bridgeImage, bridge.buildContext)
|
||||
} else if (stack === 'autoware') {
|
||||
await ensureBridgeImage(bridgeImage, 'resources/autoware-ros-bridge')
|
||||
} else if (stack === 'nav2') {
|
||||
await ensureBridgeImage(bridgeImage, 'resources/nav2-ros-bridge')
|
||||
} else if (stack === 'slam') {
|
||||
await ensureBridgeImage(bridgeImage, 'resources/slam-ros-bridge')
|
||||
const bridgeContextPath = resolveResourceContextPath(visualizationBridgeResourceContext(stack, bridge) ?? undefined)
|
||||
if (!bridgeContextPath) throw new Error(`브릿지 리소스를 찾을 수 없습니다: ${visualizationBridgeResourceContext(stack, bridge) ?? stack}`)
|
||||
if (stack !== 'autoware' && stack !== 'slam') {
|
||||
await ensureBridgeImage(bridgeImage, visualizationBridgeResourceContext(stack, bridge))
|
||||
}
|
||||
|
||||
const args = [
|
||||
@@ -1606,6 +1801,8 @@ async function startVisualizationBridge(
|
||||
'--net=host',
|
||||
'-v',
|
||||
'/dev/shm:/dev/shm',
|
||||
'-v',
|
||||
`${bridgeContextPath}:${stack === 'slam' ? '/app' : '/ros_bridge'}:ro`,
|
||||
'-e',
|
||||
`ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`,
|
||||
'-e',
|
||||
@@ -1644,6 +1841,7 @@ async function startVisualizationBridge(
|
||||
}
|
||||
|
||||
args.push(bridgeImage)
|
||||
args.push('bash', '-lc', bridgeContainerCommand(stack))
|
||||
await runCommand('docker', args, undefined, undefined, 15000)
|
||||
await waitForVisualizationBridgeReady(stack, bridgePort)
|
||||
return readVisualizationBridgeStatus()
|
||||
@@ -1724,6 +1922,7 @@ async function installModuleFromCache(
|
||||
folderName?: string | null,
|
||||
templateRepositoryUrl?: string | null,
|
||||
studentRepositoryUrl?: string | null,
|
||||
options?: ModuleInstallOptions | null,
|
||||
): Promise<{ ok: boolean; module: CachedModuleInfo }> {
|
||||
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
|
||||
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
@@ -1760,6 +1959,7 @@ async function installModuleFromCache(
|
||||
await cp(sourceRoot, targetRoot, { recursive: true, force: false, errorOnExist: true })
|
||||
}
|
||||
}
|
||||
await applyModulePrebuiltArtifacts(targetRoot, options?.prebuiltArtifacts)
|
||||
|
||||
const { name, category } = parseModuleFolderName(targetFolderName)
|
||||
return {
|
||||
@@ -1773,6 +1973,8 @@ async function installModuleFromCache(
|
||||
sourcePath: templateRepositoryUrl?.trim() ? templateRepositoryUrl.trim() : resolve(moduleCacheRoot, targetFolderName),
|
||||
workspacePath: targetRoot,
|
||||
templateRepositoryUrl: templateRepositoryUrl ?? null,
|
||||
sourceType: 'GIT',
|
||||
prebuiltArtifacts: options?.prebuiltArtifacts ?? null,
|
||||
parameters: [],
|
||||
cpu: null,
|
||||
memory: null,
|
||||
@@ -1787,6 +1989,7 @@ async function installRemoteWorkspaceModule(
|
||||
folderName?: string | null,
|
||||
templateRepositoryUrl?: string | null,
|
||||
studentRepositoryUrl?: string | null,
|
||||
options?: ModuleInstallOptions | null,
|
||||
): Promise<{ ok: boolean; module: CachedModuleInfo }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const targetFolderName = folderName?.trim() || moduleName
|
||||
@@ -1803,6 +2006,16 @@ async function installRemoteWorkspaceModule(
|
||||
? withGitCredential(remoteStudentRepositoryUrl)
|
||||
: ''
|
||||
const targetRoot = remotePathJoin(target.workspaceRoot, targetFolderName)
|
||||
const prebuiltArtifacts = (options?.prebuiltArtifacts ?? [])
|
||||
.filter((artifact) => artifact?.url?.trim())
|
||||
.map((artifact) => ({
|
||||
url: artifact.url?.trim() ?? '',
|
||||
fileName: safeArtifactFileName(artifact.url?.trim() ?? '', artifact.fileName),
|
||||
checksumSha256: artifact.checksumSha256?.trim() ?? '',
|
||||
extractPath: artifact.extractPath?.trim() ?? '.',
|
||||
required: Boolean(artifact.required),
|
||||
}))
|
||||
const prebuiltJson = JSON.stringify(prebuiltArtifacts)
|
||||
const script = [
|
||||
'set -e',
|
||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||
@@ -1811,6 +2024,7 @@ async function installRemoteWorkspaceModule(
|
||||
`TARGET="$ROOT/$FOLDER"`,
|
||||
`TEMPLATE_REPO="$(printf %s ${quoteShellSingle(encodeBase64(templateRepositoryWithCredential))} | base64 -d)"`,
|
||||
`STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`,
|
||||
`PREBUILT_JSON="$(printf %s ${quoteShellSingle(encodeBase64(prebuiltJson))} | base64 -d)"`,
|
||||
'mkdir -p "$ROOT"',
|
||||
'case "$ROOT" in',
|
||||
' /tmp/autonomy-studio/sessions/*) rm -rf "$TARGET" ;;',
|
||||
@@ -1829,6 +2043,39 @@ async function installRemoteWorkspaceModule(
|
||||
' fi',
|
||||
'fi',
|
||||
'rm -rf "$TARGET/build" "$TARGET/install" "$TARGET/log"',
|
||||
'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
|
||||
'mkdir -p "$ARTIFACT_CACHE"',
|
||||
'python3 - "$PREBUILT_JSON" "$ARTIFACT_CACHE" "$TARGET" <<\'PY\'',
|
||||
'import json, os, subprocess, sys',
|
||||
'artifacts = json.loads(sys.argv[1] or "[]")',
|
||||
'cache = sys.argv[2]',
|
||||
'target = sys.argv[3]',
|
||||
'def tar_args(path, mode):',
|
||||
' lower = path.lower()',
|
||||
' if lower.endswith(".tar.zst") or lower.endswith(".tzst"): return ["--zstd", "-tf" if mode == "list" else "-xf"]',
|
||||
' if lower.endswith(".tar.gz") or lower.endswith(".tgz"): return ["-tzf" if mode == "list" else "-xzf"]',
|
||||
' return ["-tf" if mode == "list" else "-xf"]',
|
||||
'for item in artifacts:',
|
||||
' url = (item.get("url") or "").strip()',
|
||||
' if not url: continue',
|
||||
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"',
|
||||
' path = os.path.join(cache, name)',
|
||||
' if not os.path.exists(path):',
|
||||
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
||||
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
||||
' if expected:',
|
||||
' import hashlib',
|
||||
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
|
||||
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")',
|
||||
' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)',
|
||||
' for entry in entries.splitlines():',
|
||||
' entry = entry.strip()',
|
||||
' if entry.startswith("/") or "../" in entry or entry == "..": raise SystemExit(f"unsafe artifact path: {entry}")',
|
||||
' extract_path = os.path.abspath(os.path.join(target, item.get("extractPath") or "."))',
|
||||
' if extract_path != target and not extract_path.startswith(target + os.sep): raise SystemExit("artifact extract path is outside module root")',
|
||||
' os.makedirs(extract_path, exist_ok=True)',
|
||||
' subprocess.run(["tar", *tar_args(path, "extract"), path, "-C", extract_path], check=True)',
|
||||
'PY',
|
||||
'printf "OK\\t%s\\n" "$TARGET"'
|
||||
].join('\n')
|
||||
|
||||
@@ -1845,6 +2092,8 @@ async function installRemoteWorkspaceModule(
|
||||
sourcePath: normalizedTemplateRepositoryUrl,
|
||||
workspacePath: targetRoot,
|
||||
templateRepositoryUrl: normalizedTemplateRepositoryUrl,
|
||||
sourceType: 'GIT',
|
||||
prebuiltArtifacts: options?.prebuiltArtifacts ?? null,
|
||||
parameters: [],
|
||||
cpu: null,
|
||||
memory: null,
|
||||
@@ -2029,6 +2278,7 @@ async function startWorkspaceModule(
|
||||
rosParamsOverride?: string | null,
|
||||
gpuEnabled?: boolean | null,
|
||||
rosDomainId?: string | null,
|
||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
|
||||
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||
@@ -2042,6 +2292,7 @@ async function startWorkspaceModule(
|
||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||
|
||||
await prepareRuntimeImages(runtimeImages)
|
||||
await ensureStudioRuntimeAssets()
|
||||
const scriptPath = resolveAutowareDockerRunScript()
|
||||
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
|
||||
@@ -2084,6 +2335,7 @@ async function startRemoteWorkspaceModule(
|
||||
rosParamsOverride?: string | null,
|
||||
gpuEnabled?: boolean | null,
|
||||
rosDomainId?: string | null,
|
||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const targetFolderName = folderName?.trim()
|
||||
@@ -2091,6 +2343,16 @@ async function startRemoteWorkspaceModule(
|
||||
|
||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||
const runtimeImageArtifacts = (runtimeImages ?? [])
|
||||
.filter((image) => image?.image?.trim())
|
||||
.map((image) => ({
|
||||
image: image.image?.trim() ?? '',
|
||||
url: image.url?.trim() ?? '',
|
||||
fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '',
|
||||
checksumSha256: image.checksumSha256?.trim() ?? '',
|
||||
required: Boolean(image.required),
|
||||
}))
|
||||
const runtimeImageJson = JSON.stringify(runtimeImageArtifacts)
|
||||
|
||||
const existsOutput = await runSshOutput(target, [
|
||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||
@@ -2102,6 +2364,43 @@ async function startRemoteWorkspaceModule(
|
||||
if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||
|
||||
const runtimeRoot = await ensureRemoteStudioRuntimeAssets(target)
|
||||
await runSsh(
|
||||
target,
|
||||
[
|
||||
'set -e',
|
||||
`RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(runtimeImageJson))} | base64 -d)"`,
|
||||
'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
|
||||
'mkdir -p "$ARTIFACT_CACHE"',
|
||||
'python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'',
|
||||
'import json, os, subprocess, sys',
|
||||
'images = json.loads(sys.argv[1] or "[]")',
|
||||
'cache = sys.argv[2]',
|
||||
'for item in images:',
|
||||
' image = (item.get("image") or "").strip()',
|
||||
' if not image: continue',
|
||||
' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0',
|
||||
' if exists: continue',
|
||||
' url = (item.get("url") or "").strip()',
|
||||
' if not url:',
|
||||
' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")',
|
||||
' continue',
|
||||
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"',
|
||||
' path = os.path.join(cache, name)',
|
||||
' if not os.path.exists(path): subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
||||
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
||||
' if expected:',
|
||||
' import hashlib',
|
||||
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
|
||||
' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")',
|
||||
' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):',
|
||||
' import shlex',
|
||||
' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)',
|
||||
' else:',
|
||||
' subprocess.run(["docker", "load", "-i", path], check=True)',
|
||||
'PY'
|
||||
].join('\n'),
|
||||
900000
|
||||
)
|
||||
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
|
||||
if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
|
||||
if (currentStatus.trim()) {
|
||||
@@ -2292,9 +2591,10 @@ async function restartWorkspaceModule(
|
||||
rosParamsOverride?: string | null,
|
||||
gpuEnabled?: boolean | null,
|
||||
rosDomainId?: string | null,
|
||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
await stopWorkspaceModule(moduleName, workspaceRoot, folderName)
|
||||
return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId)
|
||||
return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages)
|
||||
}
|
||||
|
||||
async function restartRemoteWorkspaceModule(
|
||||
@@ -2304,9 +2604,10 @@ async function restartRemoteWorkspaceModule(
|
||||
rosParamsOverride?: string | null,
|
||||
gpuEnabled?: boolean | null,
|
||||
rosDomainId?: string | null,
|
||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||
): Promise<{ ok: boolean; message: string }> {
|
||||
await stopRemoteWorkspaceModule(target, folderName)
|
||||
return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId)
|
||||
return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages)
|
||||
}
|
||||
|
||||
async function readWorkspaceModuleLogs(
|
||||
@@ -2874,22 +3175,22 @@ function createWindow(): void {
|
||||
|
||||
function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules))
|
||||
ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl))
|
||||
ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||
ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId))
|
||||
ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
|
||||
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
|
||||
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId))
|
||||
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
|
||||
ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules))
|
||||
ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl))
|
||||
ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId))
|
||||
ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||
ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, folderName))
|
||||
ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules))
|
||||
ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => resetRemoteWorkspaceSession(target, modules))
|
||||
ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId))
|
||||
ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, folderName))
|
||||
ipcMain.handle('remote-workspace:git:status', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null) => readRemoteWorkspaceGitStatus(target, moduleName, folderName, selectedFilePath))
|
||||
ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId))
|
||||
@@ -3045,13 +3346,13 @@ app.whenReady().then(() => {
|
||||
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
|
||||
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
|
||||
ipcMain.handle('modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules))
|
||||
ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl))
|
||||
ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||
ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId))
|
||||
ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
|
||||
ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers())
|
||||
ipcMain.handle('modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId))
|
||||
ipcMain.handle('modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||
ipcMain.handle('modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
|
||||
ipcMain.handle('visualization:status', () => readVisualizationBridgeStatus())
|
||||
|
||||
Reference in New Issue
Block a user