Files
peardock/server/utils/gitops.js
T
snxraven 02f25e0981
CI / test (push) Successful in 9m58s
Complete roadmap optionals: Holesail, Swarm UI, GitOps, virtualization.
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.
2026-07-10 22:53:30 -04:00

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 }