feat: add native logs and database portability
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user