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.
130 lines
4.0 KiB
JavaScript
130 lines
4.0 KiB
JavaScript
/**
|
|
* 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 }
|