mirror of
https://github.com/aipoch/open-science.git
synced 2026-10-02 02:14:40 +08:00
* feat(linux): add native ARM64 distribution support * fix(linux): complete ARM64 feed promotion and web packaging * test(linux): cover ARM64 channel promotion fixtures * fix(release): bootstrap the first ARM64 update channel * test(release): include ARM64 bootstrap in workflow contract * ci(runtime): gate PRs on ARM64 bundle readiness
434 lines
15 KiB
JavaScript
434 lines
15 KiB
JavaScript
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
|
|
|
import { spawn } from 'node:child_process'
|
|
import { access, chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
import {
|
|
parsePackagedSqliteVersion,
|
|
seedLegacyDatabase,
|
|
verifyDatabaseMigrationLedger,
|
|
verifyLegacyProjectPreserved,
|
|
writeDatabaseMigrationCertification
|
|
} from './database-migration-ledger-smoke.mjs'
|
|
import { authenticatePackagedAppEndpoint } from './packaged-web-service-auth.mjs'
|
|
|
|
const APPIMAGE_PATTERN = /^aipoch-open-science-(.+)-linux-(?:x64|x86_64|arm64)\.AppImage$/
|
|
const SMOKE_ROOT_PREFIX = 'open-science-linux-package-smoke-'
|
|
const STARTUP_TIMEOUT_MS = 60_000
|
|
const REQUIRED_LINUX_PRISMA_ENGINES = {
|
|
x64: [
|
|
'libquery_engine-debian-openssl-3.0.x.so.node',
|
|
'libquery_engine-rhel-openssl-3.0.x.so.node'
|
|
],
|
|
arm64: ['libquery_engine-linux-arm64-openssl-3.0.x.so.node']
|
|
}
|
|
|
|
const delay = (milliseconds) =>
|
|
new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds))
|
|
|
|
const findOne = async (directory, pattern, description) => {
|
|
const matches = (await readdir(directory, { withFileTypes: true }))
|
|
.filter((entry) => entry.isFile() && pattern.test(entry.name))
|
|
.map((entry) => join(directory, entry.name))
|
|
if (matches.length !== 1) {
|
|
throw new Error(`Expected exactly one ${description} in ${directory}; found ${matches.length}.`)
|
|
}
|
|
return matches[0]
|
|
}
|
|
|
|
const appImageVersion = (path) => {
|
|
const match = basename(path).match(APPIMAGE_PATTERN)
|
|
if (!match) throw new Error(`Cannot derive the app version from AppImage: ${path}`)
|
|
return match[1]
|
|
}
|
|
|
|
const parsePackagedAppEndpoint = (output) => {
|
|
const match = output.match(
|
|
/Open-Science Web:\s+(http:\/\/127\.0\.0\.1:\d+\/(?:\?token=[A-Za-z0-9_-]+)?)/
|
|
)
|
|
if (!match) return undefined
|
|
const url = new URL(match[1])
|
|
const token = url.searchParams.get('token')
|
|
return {
|
|
endpoint: url.origin,
|
|
...(token ? { auth: `token=${encodeURIComponent(token)}` } : {})
|
|
}
|
|
}
|
|
|
|
const pathExists = async (path) => {
|
|
try {
|
|
await access(path)
|
|
return true
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
const waitFor = async (description, check, timeoutMs = STARTUP_TIMEOUT_MS) => {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (Date.now() < deadline) {
|
|
const value = await check().catch(() => undefined)
|
|
if (value !== undefined && value !== false) return value
|
|
await delay(250)
|
|
}
|
|
throw new Error(`Timed out waiting for ${description}.`)
|
|
}
|
|
|
|
const runProcess = (executable, args, options = {}) =>
|
|
new Promise((resolveProcess, rejectProcess) => {
|
|
const child = spawn(executable, args, {
|
|
cwd: options.cwd,
|
|
env: options.env,
|
|
stdio: options.stdio ?? 'pipe'
|
|
})
|
|
let stdout = ''
|
|
let stderr = ''
|
|
child.stdout?.setEncoding('utf8')
|
|
child.stderr?.setEncoding('utf8')
|
|
child.stdout?.on('data', (chunk) => (stdout += chunk))
|
|
child.stderr?.on('data', (chunk) => (stderr += chunk))
|
|
child.once('error', rejectProcess)
|
|
child.once('exit', (code) => {
|
|
if (code === 0) resolveProcess({ stdout, stderr })
|
|
else
|
|
rejectProcess(new Error(`${basename(executable)} exited with ${code}.\n${stdout}${stderr}`))
|
|
})
|
|
})
|
|
|
|
const packagedResourcePaths = (
|
|
executable,
|
|
resourceRoot = join(dirname(executable), 'resources')
|
|
) => {
|
|
return [executable, join(resourceRoot, 'app.asar'), join(resourceRoot, 'micromamba')]
|
|
}
|
|
|
|
const findResourceRoot = async (executable, resolvedExecutable = executable) => {
|
|
const bundleRoot = dirname(executable)
|
|
const candidates = [
|
|
join(dirname(resolvedExecutable), 'resources'),
|
|
join(bundleRoot, 'resources'),
|
|
join(bundleRoot, 'usr', 'lib', 'open-science', 'resources'),
|
|
join(bundleRoot, 'usr', 'lib', 'Open-Science', 'resources')
|
|
]
|
|
for (const candidate of [...new Set(candidates)]) {
|
|
if (await pathExists(join(candidate, 'app.asar'))) return candidate
|
|
}
|
|
throw new Error(`Packaged Linux app.asar was not found for ${executable}.`)
|
|
}
|
|
|
|
const assertPackagedResources = async (
|
|
executable,
|
|
resourceRoot = join(dirname(executable), 'resources'),
|
|
arch = process.arch
|
|
) => {
|
|
const requiredEngines = REQUIRED_LINUX_PRISMA_ENGINES[arch]
|
|
if (!requiredEngines) throw new Error(`Unsupported Linux package architecture: ${arch}`)
|
|
for (const path of packagedResourcePaths(executable, resourceRoot)) {
|
|
if (!(await pathExists(path))) throw new Error(`Packaged Linux resource is missing: ${path}`)
|
|
}
|
|
const prismaRoot = join(resourceRoot, 'node_modules', '.prisma', 'client')
|
|
const engines = await readdir(prismaRoot).catch(() => [])
|
|
const nativeEngines = engines.filter(
|
|
(name) => name.includes('query_engine-') && name.endsWith('.node')
|
|
)
|
|
const missingEngines = requiredEngines.filter((name) => !nativeEngines.includes(name))
|
|
const unexpectedEngines = nativeEngines.filter((name) => !requiredEngines.includes(name))
|
|
if (missingEngines.length > 0 || unexpectedEngines.length > 0) {
|
|
throw new Error(
|
|
`Packaged Linux must contain Prisma engines ${requiredEngines.join(', ')}; ` +
|
|
`found ${nativeEngines.join(', ') || 'none'} in ${prismaRoot}.`
|
|
)
|
|
}
|
|
}
|
|
|
|
const launchAndProbe = async ({ executable, expectedVersion, env }) => {
|
|
// Headless packaged launches have no Secret Service desktop, so OS credential mode fails closed;
|
|
// the file backend is the supported headless mode on Linux (matching the CLI smoke launch).
|
|
const child = spawn(
|
|
executable,
|
|
['--open-science-headless', '--serve=0', '--no-sandbox', '--credential-store=file'],
|
|
{
|
|
env,
|
|
stdio: ['ignore', 'pipe', 'pipe']
|
|
}
|
|
)
|
|
let output = ''
|
|
child.stdout?.setEncoding('utf8')
|
|
child.stderr?.setEncoding('utf8')
|
|
child.stdout?.on('data', (chunk) => (output += chunk))
|
|
child.stderr?.on('data', (chunk) => (output += `\n${chunk}`))
|
|
const exit = new Promise((resolveExit, rejectExit) => {
|
|
child.once('error', rejectExit)
|
|
child.once('exit', resolveExit)
|
|
})
|
|
|
|
try {
|
|
const service = await Promise.race([
|
|
waitFor('the packaged Linux web service', async () =>
|
|
authenticatePackagedAppEndpoint(output, [env.OPEN_SCIENCE_E2E_STORAGE_ROOT])
|
|
),
|
|
exit.then((code) => {
|
|
throw new Error(`Packaged Linux app exited before becoming healthy (${code}).\n${output}`)
|
|
})
|
|
])
|
|
const response = await fetch(`${service.endpoint}/api/bootstrap?${service.auth}`, {
|
|
signal: AbortSignal.timeout(15_000)
|
|
})
|
|
if (!response.ok) throw new Error(`Packaged Linux bootstrap returned HTTP ${response.status}.`)
|
|
const bootstrap = await response.json()
|
|
if (
|
|
bootstrap.appName !== 'Open-Science' ||
|
|
bootstrap.appVersion !== expectedVersion ||
|
|
bootstrap.platform !== 'linux'
|
|
) {
|
|
throw new Error(`Unexpected packaged Linux bootstrap: ${JSON.stringify(bootstrap)}`)
|
|
}
|
|
const shutdown = await fetch(`${service.endpoint}/api/shutdown?${service.auth}`, {
|
|
method: 'POST',
|
|
signal: AbortSignal.timeout(15_000)
|
|
})
|
|
await shutdown.text()
|
|
if (shutdown.status !== 202)
|
|
throw new Error(`Packaged Linux shutdown returned ${shutdown.status}.`)
|
|
const exitCode = await Promise.race([
|
|
exit,
|
|
delay(60_000).then(() => {
|
|
throw new Error('Packaged Linux app did not exit after shutdown.')
|
|
})
|
|
])
|
|
if (exitCode !== 0) throw new Error(`Packaged Linux app exited with ${exitCode}.\n${output}`)
|
|
return parsePackagedSqliteVersion(output)
|
|
} catch (error) {
|
|
child.kill('SIGKILL')
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const smokeExecutable = async ({
|
|
executable,
|
|
expectedVersion,
|
|
env,
|
|
storageRoot,
|
|
expectLegacyProject = false
|
|
}) => {
|
|
const resolvedExecutable = await realpath(executable)
|
|
const resourceRoot = await findResourceRoot(executable, resolvedExecutable)
|
|
await assertPackagedResources(resolvedExecutable, resourceRoot)
|
|
await runProcess(join(resourceRoot, 'micromamba'), ['--version'], { env })
|
|
const sqliteVersions = [
|
|
await launchAndProbe({ executable, expectedVersion, env }),
|
|
await launchAndProbe({ executable, expectedVersion, env })
|
|
]
|
|
await verifyDatabaseMigrationLedger(storageRoot)
|
|
if (expectLegacyProject) await verifyLegacyProjectPreserved(storageRoot)
|
|
return sqliteVersions
|
|
}
|
|
|
|
// Exercise the installed public command with an empty user profile and no display server.
|
|
// Tokens are inspected in memory only; certification output must never print them.
|
|
const smokeInstalledCli = async ({ executable, expectedVersion, root, env }) => {
|
|
const cliEnv = {
|
|
...env,
|
|
HOME: join(root, 'cli home 数据'),
|
|
XDG_CONFIG_HOME: join(root, 'cli config 数据')
|
|
}
|
|
for (const name of [
|
|
'DISPLAY',
|
|
'WAYLAND_DISPLAY',
|
|
'OPEN_SCIENCE_APP_PATH',
|
|
'ELECTRON_RUN_AS_NODE',
|
|
'OPEN_SCIENCE_CONFIG_ROOT',
|
|
'OPEN_SCIENCE_STORAGE_ROOT',
|
|
'OPEN_SCIENCE_E2E_STORAGE_ROOT'
|
|
]) {
|
|
delete cliEnv[name]
|
|
}
|
|
await mkdir(cliEnv.HOME, { recursive: true })
|
|
const invoke = (args) => runProcess(executable, args, { env: cliEnv })
|
|
const initialized = JSON.parse((await invoke(['init', '--json'])).stdout)
|
|
if (initialized.configRoot !== join(cliEnv.HOME, '.open-science')) {
|
|
throw new Error('Installed CLI did not initialize the isolated production profile.')
|
|
}
|
|
let failure
|
|
try {
|
|
const started = await invoke([
|
|
'start',
|
|
'--no-open',
|
|
'--port',
|
|
'44109',
|
|
'--credential-store=file'
|
|
])
|
|
const statePath = join(initialized.configRoot, 'web-service.json')
|
|
const firstState = JSON.parse(await readFile(statePath, 'utf8'))
|
|
const reused = await invoke(['start', '--no-open'])
|
|
const secondState = JSON.parse(await readFile(statePath, 'utf8'))
|
|
if (firstState.pid !== secondState.pid || firstState.port !== 44109) {
|
|
throw new Error('Installed CLI failed daemon reuse or custom-port selection.')
|
|
}
|
|
const browserUrl = new URL((await invoke(['url'])).stdout.trim())
|
|
const token = browserUrl.searchParams.get('token')
|
|
if (browserUrl.hostname !== '127.0.0.1' || browserUrl.port !== '44109' || !token) {
|
|
throw new Error('Installed CLI did not return an authenticated loopback URL.')
|
|
}
|
|
const response = await fetch(new URL('/api/bootstrap', browserUrl), {
|
|
headers: { authorization: `Bearer ${token}` },
|
|
signal: AbortSignal.timeout(15_000)
|
|
})
|
|
const bootstrap = await response.json()
|
|
if (
|
|
!response.ok ||
|
|
bootstrap.appVersion !== expectedVersion ||
|
|
bootstrap.platform !== 'linux'
|
|
) {
|
|
throw new Error('Installed CLI started an unexpected or unhealthy application.')
|
|
}
|
|
const logs = await readFile(join(initialized.configRoot, 'cli-daemon.log'), 'utf8')
|
|
if (
|
|
[started.stdout, started.stderr, reused.stdout, reused.stderr, logs].some((text) =>
|
|
text.includes(token)
|
|
)
|
|
) {
|
|
throw new Error('A CLI startup output or daemon log disclosed the browser token.')
|
|
}
|
|
console.log(
|
|
'Installed CLI: init, headless start, custom port, daemon reuse and authenticated URL passed (token redacted).'
|
|
)
|
|
} catch (error) {
|
|
failure = error
|
|
} finally {
|
|
try {
|
|
await invoke(['stop'])
|
|
} catch (error) {
|
|
failure ??= error
|
|
}
|
|
}
|
|
if (failure) {
|
|
const token = await readFile(join(initialized.configRoot, 'web-token'), 'utf8')
|
|
.then((text) => text.trim())
|
|
.catch(() => '')
|
|
const logs = await readFile(join(initialized.configRoot, 'cli-daemon.log'), 'utf8').catch(
|
|
() => '(daemon log unavailable)'
|
|
)
|
|
const diagnostic = `${failure.message}\n${logs}`
|
|
throw new Error(token ? diagnostic.replaceAll(token, '<REDACTED>') : diagnostic)
|
|
}
|
|
const stoppedUrl = await invoke(['url']).then(
|
|
() => false,
|
|
() => true
|
|
)
|
|
if (!stoppedUrl) throw new Error('The URL command succeeded after the daemon was stopped.')
|
|
console.log(
|
|
'Installed CLI: stop and unavailable URL passed; no desktop or display server was used.'
|
|
)
|
|
}
|
|
|
|
const parseArguments = (argv) => {
|
|
const valueFor = (name) => {
|
|
const index = argv.indexOf(name)
|
|
return index === -1 ? undefined : argv[index + 1]
|
|
}
|
|
const artifactDirectory = valueFor('--artifact-dir')
|
|
const installedExecutable = valueFor('--installed-executable')
|
|
const installedCli = valueFor('--installed-cli')
|
|
if (!artifactDirectory || !installedExecutable) {
|
|
throw new Error(
|
|
'Usage: --artifact-dir <path> --installed-executable <path-to-installed-open-science>'
|
|
)
|
|
}
|
|
return {
|
|
artifactDirectory: resolve(artifactDirectory),
|
|
installedExecutable: resolve(installedExecutable),
|
|
installedCli: resolve(installedCli ?? '/usr/bin/open-science')
|
|
}
|
|
}
|
|
|
|
const main = async () => {
|
|
if (process.platform !== 'linux') throw new Error('Linux package smoke requires Linux.')
|
|
const options = parseArguments(process.argv.slice(2))
|
|
const appImage = await findOne(options.artifactDirectory, APPIMAGE_PATTERN, 'Linux AppImage')
|
|
const expectedVersion = appImageVersion(appImage)
|
|
const root = await mkdtemp(join(process.env.RUNNER_TEMP || tmpdir(), SMOKE_ROOT_PREFIX))
|
|
const baseEnv = {
|
|
...process.env,
|
|
HOME: join(root, 'home 数据 with spaces'),
|
|
XDG_CONFIG_HOME: join(root, 'config 数据 with spaces')
|
|
}
|
|
const launchProfiles = (format) => [
|
|
{ storageRoot: join(root, `${format} legacy 数据`), expectLegacyProject: true },
|
|
{ storageRoot: join(root, `${format} fresh 数据`), expectLegacyProject: false }
|
|
]
|
|
|
|
try {
|
|
await smokeInstalledCli({
|
|
executable: options.installedCli,
|
|
expectedVersion,
|
|
root,
|
|
env: baseEnv
|
|
})
|
|
const sqliteVersions = []
|
|
const debProfiles = launchProfiles('deb')
|
|
await seedLegacyDatabase(debProfiles[0].storageRoot)
|
|
for (const profile of debProfiles) {
|
|
sqliteVersions.push(
|
|
...(await smokeExecutable({
|
|
executable: options.installedExecutable,
|
|
expectedVersion,
|
|
env: { ...baseEnv, OPEN_SCIENCE_E2E_STORAGE_ROOT: profile.storageRoot },
|
|
...profile
|
|
}))
|
|
)
|
|
}
|
|
await chmod(appImage, 0o755)
|
|
await runProcess(appImage, ['--appimage-extract'], { cwd: root, env: baseEnv })
|
|
const appImageProfiles = launchProfiles('appimage')
|
|
await seedLegacyDatabase(appImageProfiles[0].storageRoot)
|
|
for (const profile of appImageProfiles) {
|
|
sqliteVersions.push(
|
|
...(await smokeExecutable({
|
|
executable: join(root, 'squashfs-root', 'AppRun'),
|
|
expectedVersion,
|
|
env: { ...baseEnv, OPEN_SCIENCE_E2E_STORAGE_ROOT: profile.storageRoot },
|
|
...profile
|
|
}))
|
|
)
|
|
}
|
|
await writeDatabaseMigrationCertification({
|
|
output: join(options.artifactDirectory, 'database-migration-certification.json'),
|
|
sqliteVersions,
|
|
checks: {
|
|
freshInstall: 'passed',
|
|
legacyAdoption: 'passed',
|
|
reopen: 'passed',
|
|
specialPath: 'passed'
|
|
}
|
|
})
|
|
console.log('Linux deb install and AppImage launch smoke completed successfully.')
|
|
} finally {
|
|
await rm(root, { force: true, maxRetries: 5, recursive: true, retryDelay: 200 })
|
|
}
|
|
}
|
|
|
|
const invokedAsScript =
|
|
process.argv[1] !== undefined && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
if (invokedAsScript) {
|
|
main().catch((error) => {
|
|
console.error(error)
|
|
process.exitCode = 1
|
|
})
|
|
}
|
|
|
|
export {
|
|
authenticatePackagedAppEndpoint,
|
|
appImageVersion,
|
|
assertPackagedResources,
|
|
findOne,
|
|
findResourceRoot,
|
|
packagedResourcePaths,
|
|
parseArguments,
|
|
parsePackagedAppEndpoint,
|
|
smokeInstalledCli
|
|
}
|