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
+5 -4
View File
@@ -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
+13 -1
View File
@@ -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" <<EOF
#!/bin/sh
@@ -103,6 +105,14 @@ exec "$(dirname "$0")/aurora-exec" /usr/libexec/aurora/mariadbd "$@"
EOF
chmod +x "$stage/bin/mariadbd"
for name in mariadb mariadb-dump; do
cat > "$stage/bin/$name" <<EOF
#!/bin/sh
exec "\$(dirname "\$0")/aurora-exec" "/usr/libexec/aurora/$name" "\$@"
EOF
chmod +x "$stage/bin/$name"
done
cp /tmp/wp-cli.phar "$root/usr/local/bin/wp-cli.phar"
cat > "$stage/bin/wp" <<'EOF'
#!/bin/sh
@@ -124,6 +134,8 @@ cat > "$stage/runtime.template.json" <<EOF
{ "id": "php", "version": "$php_version", "executable": "bin/php-fpm" },
{ "id": "nginx", "version": "$nginx_version", "executable": "bin/nginx" },
{ "id": "mariadb", "version": "$mariadb_version", "executable": "bin/mariadbd" },
{ "id": "mariadb-client", "version": "$mariadb_version", "executable": "bin/mariadb" },
{ "id": "mariadb-dump", "version": "$mariadb_version", "executable": "bin/mariadb-dump" },
{ "id": "wp-cli", "version": "2.12.0", "executable": "bin/wp" }
]
}
+12
View File
@@ -34,6 +34,18 @@ const checks = [
join(root, 'bin/wp'),
['--version'],
template.components.find((item) => 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) {
+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> {
+129 -25
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`))
})
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: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 })
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: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)
}