forked from efforting.tech/gitea.efforting.tech
215 lines
6.5 KiB
JavaScript
215 lines
6.5 KiB
JavaScript
/**
|
|
* Experiment 01 — rsync publish
|
|
*
|
|
* Walks through the basic lifecycle:
|
|
* 1. Prepare a base worktree Docker volume for (owner, repo, revision)
|
|
* — runs a checkout container if the volume doesn't exist yet
|
|
* 2. Run the rsync container with the worktree volume mounted read-only
|
|
* and the deploy target bind-mounted writable
|
|
*
|
|
* Everything is explicit and manual here — no framework, no abstraction.
|
|
* The goal is to understand the mechanics before building on top of them.
|
|
*/
|
|
|
|
import { execFile as exec_file } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { createHash } from 'node:crypto'
|
|
import * as path from 'node:path'
|
|
import { promisify } from 'node:util'
|
|
import { fileURLToPath } from 'node:url'
|
|
import yaml from 'js-yaml'
|
|
|
|
const exec = promisify(exec_file)
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config — loaded from config.yml next to this file
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CONFIG = yaml.load(readFileSync(path.join(HERE, 'config.yml'), 'utf8'))
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function run(cmd, args = [], opts = {}) {
|
|
console.log(`$ ${cmd} ${args.join(' ')}`)
|
|
const { stdout, stderr } = await exec(cmd, args, opts)
|
|
if (stdout) {
|
|
process.stdout.write(stdout)
|
|
}
|
|
if (stderr) {
|
|
process.stderr.write(stderr)
|
|
}
|
|
return stdout.trim()
|
|
}
|
|
|
|
async function docker(...args) {
|
|
return run('docker', args)
|
|
}
|
|
|
|
// Run docker compose with a generated config passed via stdin (-f -)
|
|
async function compose(compose_config, args) {
|
|
const compose_yaml = yaml.dump(compose_config)
|
|
console.log(`$ docker compose -f - ${args.join(' ')}`)
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = exec_file('docker', ['compose', '-f', '-', ...args])
|
|
|
|
child.stdin.write(compose_yaml)
|
|
child.stdin.end()
|
|
|
|
let stdout = ''
|
|
let stderr = ''
|
|
child.stdout.on('data', chunk => { process.stdout.write(chunk); stdout += chunk })
|
|
child.stderr.on('data', chunk => { process.stderr.write(chunk); stderr += chunk })
|
|
|
|
child.on('close', code => {
|
|
if (code === 0) {
|
|
resolve(stdout.trim())
|
|
} else {
|
|
reject(new Error(`docker compose exited with code ${code}\n${stderr}`))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
// Hash the entire checkout definition to get an unambiguous volume name.
|
|
// Any change to any field (repo, revision, key, submodules, etc.) produces
|
|
// a different volume. Human-readable metadata is stored as Docker volume labels.
|
|
function worktree_volume_name(owner, repo_def) {
|
|
const sorted_keys = Object.keys(repo_def).sort()
|
|
const canonical = JSON.stringify(Object.fromEntries(sorted_keys.map(k => [k, repo_def[k]])))
|
|
const hash = createHash('sha256')
|
|
.update(owner).update('\0')
|
|
.update(canonical)
|
|
.digest('hex')
|
|
.slice(0, 24)
|
|
return `ci-worktree-${hash}`
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step 1: Prepare base worktree volume
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function prepare_worktree(owner, repo_def, private_key_path) {
|
|
const { repo_url, revision, mutable } = repo_def
|
|
const volume_name = worktree_volume_name(owner, repo_def)
|
|
|
|
// Check if the volume already exists
|
|
let exists = false
|
|
try {
|
|
await docker('volume', 'inspect', volume_name)
|
|
exists = true
|
|
} catch {
|
|
exists = false
|
|
}
|
|
|
|
if (exists && !mutable) {
|
|
// Immutable — volume is permanent once created
|
|
console.log(`Base worktree volume ${volume_name} already exists, reusing.`)
|
|
return volume_name
|
|
}
|
|
|
|
if (!exists) {
|
|
await docker('volume', 'create',
|
|
'--label', `ci.owner=${owner}`,
|
|
'--label', `ci.repo_url=${repo_url}`,
|
|
'--label', `ci.revision=${revision}`,
|
|
'--label', `ci.mutable=${mutable}`,
|
|
volume_name,
|
|
)
|
|
}
|
|
|
|
// Mutable: always re-checkout (ref may have moved)
|
|
// Immutable + not exists: first checkout
|
|
console.log(`${mutable ? 'Re-checking out mutable' : 'Checking out immutable'} revision "${revision}"`)
|
|
await docker(
|
|
'run', '--rm',
|
|
'--volume', `${volume_name}:/worktree`,
|
|
'--volume', `${private_key_path}:/git_identity:ro`,
|
|
'--network', 'host',
|
|
'ci-checkout',
|
|
'git', '-c', 'core.sshCommand=ssh -i /git_identity -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null',
|
|
'clone', repo_url, '--branch', revision, '.', '/worktree',
|
|
)
|
|
|
|
return volume_name
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Compose config builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function build_rsync_compose_config({ container_name, worktree_volume, deploy_target }) {
|
|
return {
|
|
name: container_name,
|
|
services: {
|
|
rsync: {
|
|
container_name,
|
|
build: {
|
|
context: HERE,
|
|
dockerfile: 'Dockerfile',
|
|
},
|
|
image: 'ci-rsync',
|
|
volumes: [
|
|
`${worktree_volume}:/src:ro`,
|
|
`${deploy_target}:/deploy`,
|
|
],
|
|
restart: 'no',
|
|
network_mode: 'none',
|
|
environment: {
|
|
TERM: 'xterm-256color',
|
|
FORCE_COLOR: '1',
|
|
CLICOLOR_FORCE: '1',
|
|
},
|
|
},
|
|
},
|
|
volumes: {
|
|
[worktree_volume]: { external: true },
|
|
},
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Step 2: Run rsync container
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function run_rsync(compose_config) {
|
|
// No --detach — blocks until container exits, streaming output
|
|
// Stopped container is retained for inspection or restart
|
|
await compose(compose_config, ['up', '--build'])
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function main() {
|
|
console.log('=== Experiment 01: rsync publish ===\n')
|
|
|
|
const { secrets_base, user, checkout, task } = CONFIG
|
|
const repo = checkout.repositories[0]
|
|
const private_key_path = path.join(secrets_base, 'git-ssh', user, 'private', repo.key)
|
|
|
|
const worktree_volume = await prepare_worktree(user, repo, private_key_path)
|
|
console.log(`\nWorktree volume: ${worktree_volume}\n`)
|
|
|
|
const container_name = `ci-rsync-${worktree_volume.slice('ci-worktree-'.length)}`
|
|
|
|
const rsync_config = build_rsync_compose_config({
|
|
container_name,
|
|
worktree_volume,
|
|
deploy_target: task.deploy_target,
|
|
})
|
|
|
|
await run_rsync(rsync_config)
|
|
|
|
console.log('\n=== Done ===')
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error(err)
|
|
process.exit(1)
|
|
})
|