feat: add native logs and database portability

This commit is contained in:
reaper
2026-08-22 04:17:22 -05:00
parent 8356a6949a
commit 92b1c98f36
7 changed files with 253 additions and 37 deletions
+2 -5
View File
@@ -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<AuroraConfig> {
return readConfig(root)
}
async function ensureCertificateAuthority(): Promise<void> {
+128 -24
View File
@@ -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<void> {
async function runDatabasePipe(
root: string,
mode: 'dump' | 'restore',
filePath: string
): Promise<void> {
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<void>((done, fail) => { output.on('finish', done); output.on('error', fail) })
outputFinished = new Promise<void>((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<AuroraSnapshot[]> {
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 })
}
+41 -2
View File
@@ -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<string, string[]> = {
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 }
)
}
)
}
+52 -1
View File
@@ -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<void> {
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)
}