diff --git a/docs/AURORA_NATIVE_RUNTIME.md b/docs/AURORA_NATIVE_RUNTIME.md index 767a60c..58247b4 100644 --- a/docs/AURORA_NATIVE_RUNTIME.md +++ b/docs/AURORA_NATIVE_RUNTIME.md @@ -26,11 +26,12 @@ Every project receives reserved loopback ports, generated service configuration, 2. Native process supervisor and loopback port allocator. 3. Linux x64 bundle with PHP 8.5, nginx, MariaDB 11.8, and WP-CLI. 4. Native project lifecycle and single-site WordPress provisioning. -5. Native logs, database import/export, and database administration. -6. macOS arm64/x64 and Windows x64 bundles. -7. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services. +5. Native service logs, compressed snapshots, and database import/export. +6. Native database administration. +7. macOS arm64/x64 and Windows x64 bundles. +8. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services. -Dockside enables native project creation only when an installed platform bundle contains the selected PHP branch, nginx, MariaDB 11.8, and WP-CLI. Existing projects default to the container engine for backward compatibility. The native smoke suite starts all three services, serves PHP through FastCGI, provisions a real WordPress site, and verifies its HTTP response and cleanup. +Dockside enables native project creation only when an installed platform bundle contains the selected PHP branch, nginx, MariaDB 11.8, and WP-CLI. Existing projects default to the container engine for backward compatibility. The native smoke suite starts all three services, serves PHP through FastCGI, provisions a real WordPress site, round-trips its database through the bundled dump and restore clients, and verifies its HTTP response and cleanup. ## Runtime update notifications diff --git a/runtime-build/linux-x64/stage-runtime.sh b/runtime-build/linux-x64/stage-runtime.sh index 6780064..985df54 100644 --- a/runtime-build/linux-x64/stage-runtime.sh +++ b/runtime-build/linux-x64/stage-runtime.sh @@ -26,6 +26,8 @@ copy_binary_and_libraries /usr/local/bin/php copy_binary_and_libraries /usr/local/sbin/php-fpm copy_binary_and_libraries /usr/sbin/nginx copy_binary_and_libraries /usr/bin/mariadbd +copy_binary_and_libraries /usr/bin/mariadb +copy_binary_and_libraries /usr/bin/mariadb-dump copy_binary_and_libraries /usr/bin/mariadb-install-db copy_binary_and_libraries /usr/bin/my_print_defaults copy_binary_and_libraries /usr/bin/resolveip @@ -79,7 +81,7 @@ done # Put the ELF programs behind relocatable wrappers so those calls also use the # bundled musl loader instead of the host's /lib interpreter. mkdir -p "$root/usr/libexec/aurora" -for name in mariadbd my_print_defaults resolveip; do +for name in mariadbd mariadb mariadb-dump my_print_defaults resolveip; do mv "$root/usr/bin/$name" "$root/usr/libexec/aurora/$name" cat > "$root/usr/bin/$name" < "$stage/bin/$name" < "$stage/bin/wp" <<'EOF' #!/bin/sh @@ -124,6 +134,8 @@ cat > "$stage/runtime.template.json" < item.id === 'wp-cli')?.version + ], + [ + 'MariaDB client', + join(root, 'bin/mariadb'), + ['--version'], + template.components.find((item) => item.id === 'mariadb')?.version + ], + [ + 'MariaDB dump', + join(root, 'bin/mariadb-dump'), + ['--version'], + template.components.find((item) => item.id === 'mariadb')?.version ] ] for (const [name, command, args, version] of checks) { diff --git a/src/main/auroraEngine.ts b/src/main/auroraEngine.ts index f335293..d670dd5 100644 --- a/src/main/auroraEngine.ts +++ b/src/main/auroraEngine.ts @@ -702,11 +702,8 @@ export async function getNativeProjectDefinition( return config.runtimeEngine === 'native' ? nativeDefinition(root, config) : null } -export async function getProjectConfigByRoot( - root: string -): Promise<{ database: 'mariadb' | 'mysql' | 'postgres'; databaseVersion: string; name: string }> { - const config = await readConfig(root) - return { database: config.database, databaseVersion: config.databaseVersion, name: config.name } +export async function getProjectConfigByRoot(root: string): Promise { + return readConfig(root) } async function ensureCertificateAuthority(): Promise { diff --git a/src/main/ipc/database.ts b/src/main/ipc/database.ts index c482168..e11b13d 100644 --- a/src/main/ipc/database.ts +++ b/src/main/ipc/database.ts @@ -5,6 +5,8 @@ import { basename, join } from 'path' import { spawn } from 'child_process' import { createGunzip, createGzip } from 'zlib' import { AURORA_ENV, getProjectConfigByRoot } from '../auroraEngine' +import { startNativeProject } from '../native/nativeProject' +import { runtimeRoot } from '../nativeRuntime' import type { AuroraSnapshot } from '../../shared/types' const snapshotsDir = (root: string): string => join(root, '.aurora', 'snapshots') @@ -16,7 +18,10 @@ function safeSnapshotName(value?: string): string { return safe || fallback } -function dbCommand(type: 'mariadb' | 'mysql' | 'postgres', mode: 'dump' | 'restore'): { command: string; args: string[] } { +function dbCommand( + type: 'mariadb' | 'mysql' | 'postgres', + mode: 'dump' | 'restore' +): { command: string; args: string[] } { if (type === 'postgres') { return mode === 'dump' ? { command: 'pg_dump', args: ['-U', 'db', '-d', 'db', '--clean', '--if-exists'] } @@ -24,29 +29,72 @@ function dbCommand(type: 'mariadb' | 'mysql' | 'postgres', mode: 'dump' | 'resto } if (type === 'mysql') { return mode === 'dump' - ? { command: 'mysqldump', args: ['-udb', '-pdb', '--single-transaction', '--routines', '--triggers', 'db'] } + ? { + command: 'mysqldump', + args: ['-udb', '-pdb', '--single-transaction', '--routines', '--triggers', 'db'] + } : { command: 'mysql', args: ['-udb', '-pdb', 'db'] } } return mode === 'dump' - ? { command: 'mariadb-dump', args: ['-udb', '-pdb', '--single-transaction', '--routines', '--triggers', 'db'] } + ? { + command: 'mariadb-dump', + args: ['-udb', '-pdb', '--single-transaction', '--routines', '--triggers', 'db'] + } : { command: 'mariadb', args: ['-udb', '-pdb', 'db'] } } -function runDatabasePipe(root: string, type: 'mariadb' | 'mysql' | 'postgres', mode: 'dump' | 'restore', filePath: string): Promise { +async function runDatabasePipe( + root: string, + mode: 'dump' | 'restore', + filePath: string +): Promise { + const config = await getProjectConfigByRoot(root) + let command: string + let args: string[] + if (config.runtimeEngine === 'native') { + if (!config.nativePorts) throw new Error('Native project has no allocated database port.') + if (config.database !== 'mariadb') + throw new Error('Aurora Native database portability currently supports MariaDB.') + await startNativeProject({ + name: config.name, + root, + docroot: config.docroot, + ports: config.nativePorts + }) + command = join(runtimeRoot(), 'bin', mode === 'dump' ? 'mariadb-dump' : 'mariadb') + args = [ + '--host=127.0.0.1', + `--port=${config.nativePorts.database}`, + '--user=db', + '--password=db', + ...(mode === 'dump' ? ['--single-transaction', '--routines', '--triggers'] : []), + 'db' + ] + } else { + const db = dbCommand(config.database, mode) + command = 'docker' + args = ['compose', '-f', composeFile(root), 'exec', '-T', 'db', db.command, ...db.args] + } return new Promise((resolve, reject) => { - const db = dbCommand(type, mode) - const child = spawn('docker', ['compose', '-f', composeFile(root), 'exec', '-T', 'db', db.command, ...db.args], { - cwd: root, env: AURORA_ENV, stdio: ['pipe', 'pipe', 'pipe'] + const child = spawn(command, args, { + cwd: root, + env: AURORA_ENV, + stdio: ['pipe', 'pipe', 'pipe'] }) let stderr = '' - child.stderr.on('data', (chunk) => { stderr += chunk.toString() }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) const gz = filePath.endsWith('.gz') let outputFinished = Promise.resolve() if (mode === 'dump') { const output = createWriteStream(filePath) const source = gz ? child.stdout.pipe(createGzip()) : child.stdout source.pipe(output) - outputFinished = new Promise((done, fail) => { output.on('finish', done); output.on('error', fail) }) + outputFinished = new Promise((done, fail) => { + output.on('finish', done) + output.on('error', fail) + }) } else { const input = createReadStream(filePath) const source = gz ? input.pipe(createGunzip()) : input @@ -55,8 +103,14 @@ function runDatabasePipe(root: string, type: 'mariadb' | 'mysql' | 'postgres', m } child.on('error', reject) child.on('close', async (code) => { - if (code !== 0) return reject(new Error(stderr.trim() || `Database command exited with code ${code}`)) - try { await outputFinished; resolve() } catch (error) { reject(error) } + if (code !== 0) + return reject(new Error(stderr.trim() || `Database command exited with code ${code}`)) + try { + await outputFinished + resolve() + } catch (error) { + reject(error) + } }) }) } @@ -65,22 +119,72 @@ async function listSnapshots(root: string): Promise { const dir = snapshotsDir(root) await mkdir(dir, { recursive: true }) const files = (await readdir(dir)).filter((name) => name.endsWith('.sql.gz')) - const rows = await Promise.all(files.map(async (name) => ({ Name: name.replace(/\.sql\.gz$/, ''), Created: (await stat(join(dir, name))).mtime.toISOString() }))) + const rows = await Promise.all( + files.map(async (name) => ({ + Name: name.replace(/\.sql\.gz$/, ''), + Created: (await stat(join(dir, name))).mtime.toISOString() + })) + ) return rows.sort((a, b) => b.Created.localeCompare(a.Created)) } -export function registerDatabaseIpc():void { - ipcMain.handle('database:listSnapshots', (_event, _name: string, root: string) => listSnapshots(root)) - ipcMain.handle('database:createSnapshot', async (_event, _operationId: string, root: string, name?: string) => { - const config = await getProjectConfigByRoot(root); await mkdir(snapshotsDir(root), { recursive: true }) - await runDatabasePipe(root, config.database, 'dump', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`)) +export function registerDatabaseIpc(): void { + ipcMain.handle('database:listSnapshots', (_event, _name: string, root: string) => + listSnapshots(root) + ) + ipcMain.handle( + 'database:createSnapshot', + async (_event, _operationId: string, root: string, name?: string) => { + await mkdir(snapshotsDir(root), { recursive: true }) + await runDatabasePipe( + root, + 'dump', + join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`) + ) + } + ) + ipcMain.handle( + 'database:restoreSnapshot', + async (_event, _operationId: string, root: string, name: string) => { + await runDatabasePipe( + root, + 'restore', + join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`) + ) + } + ) + ipcMain.handle( + 'database:deleteSnapshot', + async (_event, _operationId: string, root: string, name: string) => + rm(join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`), { force: true }) + ) + ipcMain.handle( + 'database:importFile', + async (_event, _operationId: string, root: string, filePath: string) => { + await runDatabasePipe(root, 'restore', filePath) + } + ) + ipcMain.handle( + 'database:exportFile', + async (_event, _operationId: string, root: string, filePath: string) => { + await runDatabasePipe(root, 'dump', filePath) + } + ) + ipcMain.handle('database:pickImportFile', async () => { + const r = await dialog.showOpenDialog({ + properties: ['openFile'], + filters: [{ name: 'SQL dumps', extensions: ['sql', 'gz'] }] + }) + return r.canceled ? null : r.filePaths[0] }) - ipcMain.handle('database:restoreSnapshot', async (_event, _operationId: string, root: string, name: string) => { - const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`)) + ipcMain.handle('database:pickExportPath', async (_e, name: string) => { + const r = await dialog.showSaveDialog({ + defaultPath: basename(name).endsWith('.gz') ? name : `${name}.sql.gz`, + filters: [ + { name: 'Compressed SQL dump', extensions: ['gz'] }, + { name: 'SQL dump', extensions: ['sql'] } + ] + }) + return r.canceled ? null : r.filePath }) - ipcMain.handle('database:deleteSnapshot', async (_event, _operationId: string, root: string, name: string) => rm(join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`), { force: true })) - ipcMain.handle('database:importFile', async (_event, _operationId: string, root: string, filePath: string) => { const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', filePath) }) - ipcMain.handle('database:exportFile', async (_event, _operationId: string, root: string, filePath: string) => { const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'dump', filePath) }) - ipcMain.handle('database:pickImportFile', async () => { const r=await dialog.showOpenDialog({properties:['openFile'],filters:[{name:'SQL dumps',extensions:['sql','gz']}]}); return r.canceled?null:r.filePaths[0] }) - ipcMain.handle('database:pickExportPath', async (_e,name:string) => { const r=await dialog.showSaveDialog({defaultPath: basename(name).endsWith('.gz') ? name : `${name}.sql.gz`, filters:[{name:'Compressed SQL dump',extensions:['gz']},{name:'SQL dump',extensions:['sql']}]}); return r.canceled?null:r.filePath }) } diff --git a/src/main/ipc/logs.ts b/src/main/ipc/logs.ts index ac946fc..7ddd88b 100644 --- a/src/main/ipc/logs.ts +++ b/src/main/ipc/logs.ts @@ -1,4 +1,43 @@ import { ipcMain } from 'electron' -import { getProjectRoot } from '../auroraEngine' +import { join } from 'path' +import { getProjectConfigByRoot, getProjectRoot } from '../auroraEngine' import { startLogStream } from '../commandRunner' -export function registerLogsIpc():void{ipcMain.handle('logs:start',async(event,id:string,name:string,service:string)=>{const root=await getProjectRoot(name);startLogStream(id,'docker',['compose','-f',`${root}/.aurora/compose.yaml`,'logs','-f','--tail','200',service],event.sender,{cwd:root})})} + +function nativeLogFiles(root: string, service: string): string[] { + const logs = join(root, '.aurora', 'native', 'logs') + const files: Record = { + database: ['database-process.log', 'mariadb.log'], + php: ['php-process.log', 'php.log'], + web: ['web-process.log', 'nginx.log', 'nginx-access.log'] + } + const selected = files[service] + if (!selected) throw new Error(`Unknown native service '${service}'.`) + return selected.map((file) => join(logs, file)) +} + +export function registerLogsIpc(): void { + ipcMain.handle( + 'logs:start', + async (event, operationId: string, name: string, service: string) => { + const root = await getProjectRoot(name) + const config = await getProjectConfigByRoot(root) + if (config.runtimeEngine === 'native') { + startLogStream( + operationId, + 'tail', + ['-n', '200', '-F', ...nativeLogFiles(root, service)], + event.sender, + { cwd: root } + ) + return + } + startLogStream( + operationId, + 'docker', + ['compose', '-f', `${root}/.aurora/compose.yaml`, 'logs', '-f', '--tail', '200', service], + event.sender, + { cwd: root } + ) + } + ) +} diff --git a/src/main/native/nativeProject.test.ts b/src/main/native/nativeProject.test.ts index 5d31701..f3e0400 100644 --- a/src/main/native/nativeProject.test.ts +++ b/src/main/native/nativeProject.test.ts @@ -1,4 +1,4 @@ -import { execFile } from 'child_process' +import { execFile, spawn } from 'child_process' import { createRequire } from 'module' import { mkdtemp, readFile, writeFile } from 'fs/promises' import { tmpdir } from 'os' @@ -22,6 +22,22 @@ const project = { } const execFileAsync = promisify(execFile) +function runWithInput(command: string, args: string[], cwd: string, input: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env: process.env, stdio: ['pipe', 'pipe', 'pipe'] }) + let stderr = '' + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) resolve() + else reject(new Error(stderr.trim() || `${command} exited with code ${code}`)) + }) + child.stdin.end(input) + }) +} + describe('native project configuration', () => { it('isolates PHP-FPM on its allocated loopback port', () => expect(renderPhpFpmConfig(project)).toContain('listen = 127.0.0.1:41002')) @@ -119,6 +135,41 @@ it.runIf(Boolean(process.env.AURORA_NATIVE_WORDPRESS_SMOKE_ROOT))( const response = await fetch(`http://127.0.0.1:${definition.ports.http}`) expect(response.ok).toBe(true) expect(await response.text()).toContain('Aurora native smoke') + + const databaseArgs = [ + '--host=127.0.0.1', + `--port=${definition.ports.database}`, + '--user=db', + '--password=db', + 'db' + ] + const dump = await execFileAsync( + join(runtime, 'bin', 'mariadb-dump'), + [...databaseArgs.slice(0, -1), '--single-transaction', 'db'], + { cwd: root, env: process.env, maxBuffer: 32 * 1024 * 1024 } + ) + await execFileAsync( + join(runtime, 'bin', 'mariadb'), + [ + ...databaseArgs, + '-e', + "UPDATE wp_options SET option_value='Changed' WHERE option_name='blogname'" + ], + { cwd: root, env: process.env } + ) + await runWithInput(join(runtime, 'bin', 'mariadb'), databaseArgs, root, dump.stdout) + const restored = await execFileAsync( + join(runtime, 'bin', 'mariadb'), + [ + ...databaseArgs, + '--batch', + '--skip-column-names', + '-e', + "SELECT option_value FROM wp_options WHERE option_name='blogname'" + ], + { cwd: root, env: process.env } + ) + expect(restored.stdout.trim()).toBe('Aurora native smoke') } finally { await stopNativeProject(definition) }