Complete roadmap optionals: Holesail, Swarm UI, GitOps, virtualization.
CI / test (push) Successful in 9m58s
CI / test (push) Successful in 9m58s
Add tunnel persistence and container one-click tunnels with local Holesail client bind, Swarm services/nodes/tasks view, GitOps stack sync from git, deploy wizard step chrome, virtualized container lists, and structure regression tests. Mark Tracks A–D and optional future done.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Minimal GitOps helper: shallow-clone a repo and read a compose file.
|
||||
* Requires `git` on PATH on the peardock server host.
|
||||
*/
|
||||
import { spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
/**
|
||||
* @param {string} cmd
|
||||
* @param {string[]} args
|
||||
* @param {{ cwd?: string, timeoutMs?: number }} [opts]
|
||||
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
||||
*/
|
||||
function run(cmd, args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`Command timed out: ${cmd} ${args.join(' ')}`))
|
||||
}, opts.timeoutMs || 120_000)
|
||||
child.stdout?.on('data', (d) => {
|
||||
stdout += d.toString()
|
||||
})
|
||||
child.stderr?.on('data', (d) => {
|
||||
stderr += d.toString()
|
||||
})
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ code: code ?? 1, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow clone and read compose YAML.
|
||||
* @param {{
|
||||
* repoUrl: string,
|
||||
* ref?: string,
|
||||
* composePath?: string,
|
||||
* }} opts
|
||||
* @returns {Promise<{ composeContent: string, commit?: string, path: string }>}
|
||||
*/
|
||||
export async function fetchComposeFromGit(opts) {
|
||||
const repoUrl = String(opts.repoUrl || '').trim()
|
||||
if (!repoUrl) throw Object.assign(new Error('repoUrl required'), { code: 'INVALID_ARGS' })
|
||||
if (!/^https?:\/\//i.test(repoUrl) && !/^git@/i.test(repoUrl)) {
|
||||
throw Object.assign(
|
||||
new Error('repoUrl must be http(s) or git@ URL'),
|
||||
{ code: 'INVALID_ARGS' }
|
||||
)
|
||||
}
|
||||
const ref = String(opts.ref || 'main').trim() || 'main'
|
||||
let composePath = String(opts.composePath || 'docker-compose.yml').trim() || 'docker-compose.yml'
|
||||
// Path traversal guard
|
||||
if (composePath.includes('..') || path.isAbsolute(composePath)) {
|
||||
throw Object.assign(new Error('composePath must be a relative path without ..'), {
|
||||
code: 'INVALID_ARGS',
|
||||
})
|
||||
}
|
||||
|
||||
const tmpRoot = path.join(os.tmpdir(), `peardock-gitops-${randomBytes(6).toString('hex')}`)
|
||||
fs.mkdirSync(tmpRoot, { recursive: true })
|
||||
|
||||
try {
|
||||
const clone = await run(
|
||||
'git',
|
||||
['clone', '--depth', '1', '--branch', ref, '--single-branch', repoUrl, tmpRoot],
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
if (clone.code !== 0) {
|
||||
// Retry without branch if ref is a tag/sha that needs full history hint
|
||||
const clone2 = await run('git', ['clone', '--depth', '1', repoUrl, tmpRoot], {
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
if (clone2.code !== 0) {
|
||||
throw new Error(
|
||||
`git clone failed: ${(clone.stderr || clone2.stderr || clone.stdout).slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
if (ref && ref !== 'main' && ref !== 'master') {
|
||||
await run('git', ['checkout', ref], { cwd: tmpRoot, timeoutMs: 60_000 })
|
||||
}
|
||||
}
|
||||
|
||||
const full = path.join(tmpRoot, composePath)
|
||||
if (!fs.existsSync(full)) {
|
||||
// try compose.yaml
|
||||
const alt = path.join(tmpRoot, 'compose.yaml')
|
||||
if (composePath === 'docker-compose.yml' && fs.existsSync(alt)) {
|
||||
composePath = 'compose.yaml'
|
||||
} else {
|
||||
throw new Error(`Compose file not found in repo: ${composePath}`)
|
||||
}
|
||||
}
|
||||
const filePath = path.join(tmpRoot, composePath)
|
||||
const composeContent = fs.readFileSync(filePath, 'utf8')
|
||||
if (!composeContent.trim()) throw new Error('Compose file is empty')
|
||||
|
||||
let commit = ''
|
||||
try {
|
||||
const rev = await run('git', ['rev-parse', 'HEAD'], { cwd: tmpRoot, timeoutMs: 10_000 })
|
||||
if (rev.code === 0) commit = rev.stdout.trim()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return { composeContent, commit, path: composePath }
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default { fetchComposeFromGit }
|
||||
Reference in New Issue
Block a user