feat: download ROS bridge images from R2 and show startup progress
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
|
||||
|
||||
test('PC and Jetson bridges have separate pinned downloads', () => {
|
||||
const pc = bridgeArtifact(localBridgeImage)!
|
||||
const jetson = bridgeArtifact(jetsonBridgeImage)!
|
||||
assert.equal(pc.architecture, 'amd64')
|
||||
assert.equal(jetson.architecture, 'arm64')
|
||||
assert.notEqual(pc.url, jetson.url)
|
||||
assert.notEqual(pc.fileName, jetson.fileName)
|
||||
for (const artifact of [pc, jetson]) {
|
||||
assert.match(artifact.checksumSha256, /^[a-f0-9]{64}$/)
|
||||
assert.equal(new URL(artifact.url).protocol, 'https:')
|
||||
assert.ok(artifact.url.includes(artifact.checksumSha256.slice(0, 12)))
|
||||
assert.equal(artifact.required, true)
|
||||
}
|
||||
})
|
||||
|
||||
test('custom images do not resolve to an unrelated built-in artifact', () => {
|
||||
assert.equal(bridgeArtifact('custom-bridge:test'), undefined)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
const root = 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge'
|
||||
|
||||
export const localBridgeImage = 'slam-ros-bridge:20260813-3d-map-v2'
|
||||
export const jetsonBridgeImage = 'slam-ros-bridge:jetson_orin'
|
||||
|
||||
const artifacts = [
|
||||
{
|
||||
image: localBridgeImage,
|
||||
architecture: 'amd64',
|
||||
url: `${root}/amd64/20260915-489a3e8b370c/slam-ros-bridge-amd64.tar.zst`,
|
||||
fileName: 'slam-ros-bridge-amd64-489a3e8b370c.tar.zst',
|
||||
checksumSha256: '489a3e8b370c19903043a39e92935f2e32e4b4423c28210f130df1acab1a9889',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
image: jetsonBridgeImage,
|
||||
architecture: 'arm64',
|
||||
url: `${root}/arm64/20260819-0659d4cf5578/slam-ros-bridge-jetson-orin.tar.zst`,
|
||||
fileName: 'slam-ros-bridge-jetson-orin-0659d4cf5578.tar.zst',
|
||||
checksumSha256: '0659d4cf5578c43bc9c25e4a4fc5d226ccd5b7bec43becfdff83b5390717114e',
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
|
||||
export function bridgeArtifact(image: string) {
|
||||
return artifacts.find((artifact) => artifact.image === image)
|
||||
}
|
||||
+53
-23
@@ -1,10 +1,12 @@
|
||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
||||
import { checkDockerStatus } from './dockerStatus'
|
||||
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
|
||||
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
|
||||
import type { WebContents } from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import * as pty from 'node-pty'
|
||||
import type { IPty } from 'node-pty'
|
||||
import { appendFileSync, createWriteStream, existsSync, mkdirSync } from 'fs'
|
||||
import { appendFileSync, createReadStream, createWriteStream, existsSync, mkdirSync } 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'
|
||||
@@ -12,6 +14,7 @@ import { is } from '@electron-toolkit/utils'
|
||||
import { createHash } from 'crypto'
|
||||
import http from 'http'
|
||||
import https from 'https'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let splashWindow: BrowserWindow | null = null
|
||||
@@ -35,7 +38,7 @@ const autowareRosBridgeImage = 'autoware-ros-bridge'
|
||||
// Keep the bundled bridge image revisioned. Reusing `latest` leaves an old
|
||||
// Docker image in place after an LMS update, even when bridge dependencies or
|
||||
// protocol code changed.
|
||||
const slamRosBridgeImage = 'slam-ros-bridge:20260813-3d-map-v2'
|
||||
const slamRosBridgeImage = localBridgeImage
|
||||
const slamSimulationRuntimeImage = 'future-nav2-gazebo:humble'
|
||||
const slamSimulationRuntimeContainerName = 'nav2-slam'
|
||||
const rosBridgePort = 8765
|
||||
@@ -658,7 +661,7 @@ async function ensureSlamRosBridgeImage(): Promise<void> {
|
||||
const exists = await runCommand('docker', ['image', 'inspect', slamRosBridgeImage], undefined, undefined, 10_000)
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!exists) await ensureBridgeImage(slamRosBridgeImage, 'resources/slam-ros-bridge')
|
||||
if (!exists) await ensureBridgeImage(slamRosBridgeImage)
|
||||
}
|
||||
|
||||
async function exportWorkspaceMapPointCloud(workspaceRoot: string, databaseFileName: string): Promise<Buffer> {
|
||||
@@ -1906,14 +1909,19 @@ async function downloadArtifact(url: string, targetPath: string, redirects = 0):
|
||||
}
|
||||
|
||||
const file = createWriteStream(targetPath)
|
||||
response.pipe(file)
|
||||
file.on('finish', () => file.close(() => resolvePromise()))
|
||||
file.on('error', rejectPromise)
|
||||
void pipeline(response, file).then(resolvePromise, rejectPromise)
|
||||
})
|
||||
request.setTimeout(60000, () => request.destroy(new Error('artifact download timed out')))
|
||||
request.on('error', rejectPromise)
|
||||
})
|
||||
}
|
||||
|
||||
async function artifactSha256(filePath: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(filePath)) hash.update(chunk)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function ensureArtifactFile(url?: string | null, fileName?: string | null, checksumSha256?: string | null): Promise<string | null> {
|
||||
const artifactUrl = url?.trim()
|
||||
if (!artifactUrl) return null
|
||||
@@ -1924,14 +1932,18 @@ async function ensureArtifactFile(url?: string | null, fileName?: string | null,
|
||||
// Do not reuse a same-named local file, or a source update can run with an
|
||||
// older install tree even though R2 already contains the current artifact.
|
||||
await rm(targetPath, { force: true })
|
||||
reportVisualizationProgress('다운로드 중...')
|
||||
await downloadArtifact(artifactUrl, targetPath)
|
||||
if (expected) {
|
||||
let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
|
||||
reportVisualizationProgress('다운로드 파일 확인 중...')
|
||||
let actual = await artifactSha256(targetPath)
|
||||
if (actual !== expected) {
|
||||
// Artifact URLs are stable, so an existing filename can be an older cached release.
|
||||
await rm(targetPath, { force: true })
|
||||
reportVisualizationProgress('다운로드 중...')
|
||||
await downloadArtifact(artifactUrl, targetPath)
|
||||
actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
|
||||
reportVisualizationProgress('다운로드 파일 확인 중...')
|
||||
actual = await artifactSha256(targetPath)
|
||||
if (actual !== expected) {
|
||||
await rm(targetPath, { force: true }).catch(() => undefined)
|
||||
throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`)
|
||||
@@ -2000,12 +2012,14 @@ async function prepareRuntimeImages(images?: RuntimeImageArtifact[] | null): Pro
|
||||
if (image.required) throw new Error(`runtime image artifact url is missing: ${imageName}`)
|
||||
continue
|
||||
}
|
||||
reportVisualizationProgress('이미지 불러오는 중...')
|
||||
if (filePath.toLowerCase().endsWith('.tar.zst') || filePath.toLowerCase().endsWith('.tzst')) {
|
||||
const zstdPath = getBundledZstdPath() ?? 'zstd'
|
||||
await runCommand('bash', ['-lc', '"$1" -dc "$2" | docker load', 'autonomy-studio-runtime-image', zstdPath, filePath], undefined, undefined, 900000)
|
||||
await runCommand('bash', ['-o', 'pipefail', '-lc', '"$1" -dc "$2" | docker load', 'autonomy-studio-runtime-image', zstdPath, filePath], undefined, undefined, 900000)
|
||||
} else {
|
||||
await runCommand('docker', ['load', '-i', filePath], undefined, undefined, 900000)
|
||||
}
|
||||
reportVisualizationProgress('시각화 시작 중...')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2378,11 +2392,16 @@ async function startRemoteVisualizationBridge(
|
||||
))
|
||||
?.image
|
||||
?.trim()
|
||||
if (stack !== 'autoware' && image === slamRosBridgeImage && remoteJetsonBridgeImage) {
|
||||
image = remoteJetsonBridgeImage
|
||||
if (stack !== 'autoware' && image === slamRosBridgeImage) {
|
||||
image = remoteJetsonBridgeImage || jetsonBridgeImage
|
||||
}
|
||||
const imageArtifact = environmentProfile?.runtimeImages?.find((artifact) => artifact?.image?.trim() === image)
|
||||
if (imageArtifact?.url?.trim()) {
|
||||
const imageArtifact = bridgeArtifact(image)
|
||||
?? environmentProfile?.runtimeImages?.find((artifact) => artifact?.image?.trim() === image)
|
||||
const remoteImageExists = imageArtifact?.url?.trim()
|
||||
? (await runSshOutput(target, `docker image inspect ${quoteShellSingle(image)} >/dev/null 2>&1 && printf present || true`, 10000)).trim() === 'present'
|
||||
: false
|
||||
if (imageArtifact?.url?.trim() && !remoteImageExists) {
|
||||
reportVisualizationProgress('다운로드 및 이미지 준비 중...')
|
||||
await runSsh(target, [
|
||||
'set -e',
|
||||
`IMAGE=${quoteShellSingle(image)}`,
|
||||
@@ -2396,13 +2415,18 @@ async function startRemoteVisualizationBridge(
|
||||
'if [ -f "$ARCHIVE" ] && [ -n "$EXPECTED_SHA256" ]; then',
|
||||
' [ "$(sha256sum "$ARCHIVE" | awk \'{print $1}\')" = "$EXPECTED_SHA256" ] || rm -f "$ARCHIVE"',
|
||||
'fi',
|
||||
'if [ ! -f "$ARCHIVE" ]; then curl -fL "$URL" -o "$ARCHIVE"; fi',
|
||||
'if [ ! -f "$ARCHIVE" ]; then',
|
||||
' PART=$(mktemp "$ARCHIVE.XXXXXX")',
|
||||
' trap \'rm -f "$PART"\' EXIT',
|
||||
' curl -fL --connect-timeout 15 --max-time 750 "$URL" -o "$PART"',
|
||||
' mv "$PART" "$ARCHIVE"',
|
||||
'fi',
|
||||
'if [ -n "$EXPECTED_SHA256" ] && [ "$(sha256sum "$ARCHIVE" | awk \'{print $1}\')" != "$EXPECTED_SHA256" ]; then',
|
||||
' echo "시각화 이미지 체크섬이 일치하지 않습니다: $FILE_NAME" >&2',
|
||||
' exit 1',
|
||||
'fi',
|
||||
'case "$ARCHIVE" in',
|
||||
' *.tar.zst|*.tzst) zstd -dc "$ARCHIVE" | docker load ;;',
|
||||
' *.tar.zst|*.tzst) bash -o pipefail -c \'zstd -dc "$1" | docker load\' _ "$ARCHIVE" ;;',
|
||||
' *) docker load -i "$ARCHIVE" ;;',
|
||||
'esac',
|
||||
'docker image inspect "$IMAGE" >/dev/null 2>&1 || { echo "시각화 이미지 로드에 실패했습니다: $IMAGE" >&2; exit 1; }',
|
||||
@@ -2410,6 +2434,8 @@ async function startRemoteVisualizationBridge(
|
||||
}
|
||||
const command = bridgeContainerCommand(stack)
|
||||
|
||||
reportVisualizationProgress('시각화 시작 중...')
|
||||
|
||||
await runSsh(target, [
|
||||
'set -e',
|
||||
`ROOT=${quoteShellSingle(root)}`,
|
||||
@@ -3513,11 +3539,15 @@ async function resolveDockerResourceContextPath(contextPath = 'resources/slam-ro
|
||||
return targetPath
|
||||
}
|
||||
|
||||
async function ensureBridgeImage(image: string, buildContext?: string | null): Promise<void> {
|
||||
if (!buildContext) return
|
||||
const contextPath = await resolveDockerResourceContextPath(buildContext)
|
||||
if (!contextPath) throw new Error(`브릿지 Docker context를 찾을 수 없습니다: ${buildContext}`)
|
||||
await runCommand('docker', ['build', '-t', image, contextPath], undefined, undefined, 120000)
|
||||
async function ensureBridgeImage(image: string): Promise<void> {
|
||||
const artifact = bridgeArtifact(image)
|
||||
if (!artifact) throw new Error(`브리지 이미지의 다운로드 설정이 없습니다: ${image}`)
|
||||
const architecture = process.arch === 'x64' ? 'amd64' : process.arch
|
||||
if (architecture !== artifact.architecture) {
|
||||
throw new Error(`브리지 이미지 아키텍처가 PC와 다릅니다: ${artifact.architecture} / ${architecture}`)
|
||||
}
|
||||
await prepareRuntimeImages([artifact])
|
||||
await runCommand('docker', ['image', 'inspect', image], undefined, undefined, 10000)
|
||||
}
|
||||
|
||||
function expandDockerVolume(volume: string): string {
|
||||
@@ -3823,7 +3853,7 @@ async function startVisualizationBridge(
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
if (!bridgeImageExists && stack !== 'autoware' && stack !== 'slam') {
|
||||
await ensureBridgeImage(bridgeImage, visualizationBridgeResourceContext(stack, bridge))
|
||||
await ensureBridgeImage(bridgeImage)
|
||||
}
|
||||
|
||||
const args = [
|
||||
@@ -6327,7 +6357,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
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, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
||||
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
|
||||
ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target))
|
||||
ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork))
|
||||
ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork)))
|
||||
ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target))
|
||||
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, repositoryUrl?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))
|
||||
@@ -6370,7 +6400,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus())
|
||||
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath))
|
||||
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath)))
|
||||
ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile))
|
||||
ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath))
|
||||
ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
|
||||
export const visualizationProgress = new AsyncLocalStorage<(message: string) => void>()
|
||||
|
||||
export function reportVisualizationProgress(message: string): void {
|
||||
visualizationProgress.getStore()?.(message)
|
||||
}
|
||||
Reference in New Issue
Block a user