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. 2. Native process supervisor and loopback port allocator.
3. Linux x64 bundle with PHP 8.5, nginx, MariaDB 11.8, and WP-CLI. 3. Linux x64 bundle with PHP 8.5, nginx, MariaDB 11.8, and WP-CLI.
4. Native project lifecycle and single-site WordPress provisioning. 4. Native project lifecycle and single-site WordPress provisioning.
5. Native logs, database import/export, and database administration. 5. Native service logs, compressed snapshots, and database import/export.
6. macOS arm64/x64 and Windows x64 bundles. 6. Native database administration.
7. Additional PHP/database versions, Apache, Drupal, Node.js, and developer services. 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 ## 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/local/sbin/php-fpm
copy_binary_and_libraries /usr/sbin/nginx copy_binary_and_libraries /usr/sbin/nginx
copy_binary_and_libraries /usr/bin/mariadbd 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/mariadb-install-db
copy_binary_and_libraries /usr/bin/my_print_defaults copy_binary_and_libraries /usr/bin/my_print_defaults
copy_binary_and_libraries /usr/bin/resolveip 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 # Put the ELF programs behind relocatable wrappers so those calls also use the
# bundled musl loader instead of the host's /lib interpreter. # bundled musl loader instead of the host's /lib interpreter.
mkdir -p "$root/usr/libexec/aurora" 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" mv "$root/usr/bin/$name" "$root/usr/libexec/aurora/$name"
cat > "$root/usr/bin/$name" <<EOF cat > "$root/usr/bin/$name" <<EOF
#!/bin/sh #!/bin/sh
@@ -103,6 +105,14 @@ exec "$(dirname "$0")/aurora-exec" /usr/libexec/aurora/mariadbd "$@"
EOF EOF
chmod +x "$stage/bin/mariadbd" 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" cp /tmp/wp-cli.phar "$root/usr/local/bin/wp-cli.phar"
cat > "$stage/bin/wp" <<'EOF' cat > "$stage/bin/wp" <<'EOF'
#!/bin/sh #!/bin/sh
@@ -124,6 +134,8 @@ cat > "$stage/runtime.template.json" <<EOF
{ "id": "php", "version": "$php_version", "executable": "bin/php-fpm" }, { "id": "php", "version": "$php_version", "executable": "bin/php-fpm" },
{ "id": "nginx", "version": "$nginx_version", "executable": "bin/nginx" }, { "id": "nginx", "version": "$nginx_version", "executable": "bin/nginx" },
{ "id": "mariadb", "version": "$mariadb_version", "executable": "bin/mariadbd" }, { "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" } { "id": "wp-cli", "version": "2.12.0", "executable": "bin/wp" }
] ]
} }
+12
View File
@@ -34,6 +34,18 @@ const checks = [
join(root, 'bin/wp'), join(root, 'bin/wp'),
['--version'], ['--version'],
template.components.find((item) => item.id === 'wp-cli')?.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) { 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 return config.runtimeEngine === 'native' ? nativeDefinition(root, config) : null
} }
export async function getProjectConfigByRoot( export async function getProjectConfigByRoot(root: string): Promise<AuroraConfig> {
root: string return readConfig(root)
): Promise<{ database: 'mariadb' | 'mysql' | 'postgres'; databaseVersion: string; name: string }> {
const config = await readConfig(root)
return { database: config.database, databaseVersion: config.databaseVersion, name: config.name }
} }
async function ensureCertificateAuthority(): Promise<void> { async function ensureCertificateAuthority(): Promise<void> {
+128 -24
View File
@@ -5,6 +5,8 @@ import { basename, join } from 'path'
import { spawn } from 'child_process' import { spawn } from 'child_process'
import { createGunzip, createGzip } from 'zlib' import { createGunzip, createGzip } from 'zlib'
import { AURORA_ENV, getProjectConfigByRoot } from '../auroraEngine' import { AURORA_ENV, getProjectConfigByRoot } from '../auroraEngine'
import { startNativeProject } from '../native/nativeProject'
import { runtimeRoot } from '../nativeRuntime'
import type { AuroraSnapshot } from '../../shared/types' import type { AuroraSnapshot } from '../../shared/types'
const snapshotsDir = (root: string): string => join(root, '.aurora', 'snapshots') const snapshotsDir = (root: string): string => join(root, '.aurora', 'snapshots')
@@ -16,7 +18,10 @@ function safeSnapshotName(value?: string): string {
return safe || fallback 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') { if (type === 'postgres') {
return mode === 'dump' return mode === 'dump'
? { command: 'pg_dump', args: ['-U', 'db', '-d', 'db', '--clean', '--if-exists'] } ? { 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') { if (type === 'mysql') {
return mode === 'dump' 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'] } : { command: 'mysql', args: ['-udb', '-pdb', 'db'] }
} }
return mode === 'dump' 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'] } : { 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) => { return new Promise((resolve, reject) => {
const db = dbCommand(type, mode) const child = spawn(command, args, {
const child = spawn('docker', ['compose', '-f', composeFile(root), 'exec', '-T', 'db', db.command, ...db.args], { cwd: root,
cwd: root, env: AURORA_ENV, stdio: ['pipe', 'pipe', 'pipe'] env: AURORA_ENV,
stdio: ['pipe', 'pipe', 'pipe']
}) })
let stderr = '' let stderr = ''
child.stderr.on('data', (chunk) => { stderr += chunk.toString() }) child.stderr.on('data', (chunk) => {
stderr += chunk.toString()
})
const gz = filePath.endsWith('.gz') const gz = filePath.endsWith('.gz')
let outputFinished = Promise.resolve() let outputFinished = Promise.resolve()
if (mode === 'dump') { if (mode === 'dump') {
const output = createWriteStream(filePath) const output = createWriteStream(filePath)
const source = gz ? child.stdout.pipe(createGzip()) : child.stdout const source = gz ? child.stdout.pipe(createGzip()) : child.stdout
source.pipe(output) 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 { } else {
const input = createReadStream(filePath) const input = createReadStream(filePath)
const source = gz ? input.pipe(createGunzip()) : input 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('error', reject)
child.on('close', async (code) => { child.on('close', async (code) => {
if (code !== 0) return reject(new Error(stderr.trim() || `Database command exited with code ${code}`)) if (code !== 0)
try { await outputFinished; resolve() } catch (error) { reject(error) } 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) const dir = snapshotsDir(root)
await mkdir(dir, { recursive: true }) await mkdir(dir, { recursive: true })
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql.gz')) 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)) return rows.sort((a, b) => b.Created.localeCompare(a.Created))
} }
export function registerDatabaseIpc():void { export function registerDatabaseIpc(): void {
ipcMain.handle('database:listSnapshots', (_event, _name: string, root: string) => listSnapshots(root)) ipcMain.handle('database:listSnapshots', (_event, _name: string, root: string) =>
ipcMain.handle('database:createSnapshot', async (_event, _operationId: string, root: string, name?: string) => { listSnapshots(root)
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: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) => { ipcMain.handle('database:pickExportPath', async (_e, name: string) => {
const config = await getProjectConfigByRoot(root); await runDatabasePipe(root, config.database, 'restore', join(snapshotsDir(root), `${safeSnapshotName(name)}.sql.gz`)) 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 { ipcMain } from 'electron'
import { getProjectRoot } from '../auroraEngine' import { join } from 'path'
import { getProjectConfigByRoot, getProjectRoot } from '../auroraEngine'
import { startLogStream } from '../commandRunner' 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 { createRequire } from 'module'
import { mkdtemp, readFile, writeFile } from 'fs/promises' import { mkdtemp, readFile, writeFile } from 'fs/promises'
import { tmpdir } from 'os' import { tmpdir } from 'os'
@@ -22,6 +22,22 @@ const project = {
} }
const execFileAsync = promisify(execFile) 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', () => { describe('native project configuration', () => {
it('isolates PHP-FPM on its allocated loopback port', () => it('isolates PHP-FPM on its allocated loopback port', () =>
expect(renderPhpFpmConfig(project)).toContain('listen = 127.0.0.1:41002')) 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}`) const response = await fetch(`http://127.0.0.1:${definition.ports.http}`)
expect(response.ok).toBe(true) expect(response.ok).toBe(true)
expect(await response.text()).toContain('Aurora native smoke') 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 { } finally {
await stopNativeProject(definition) await stopNativeProject(definition)
} }