/** * 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}`)) } }) }) } // Derive the mount name for a repo inside the container. // Mimics git clone behaviour (uses repo basename) unless overridden by repo_def.name. function repo_mount_name(repo_def) { return repo_def.name ?? path.basename(repo_def.repo_url, '.git') } // 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 // --------------------------------------------------------------------------- // worktree_volumes: array of { volume_name, mount_name } objects function build_rsync_compose_config({ container_name, worktree_volumes, deploy_target }) { return { name: container_name, services: { rsync: { container_name, build: { context: HERE, dockerfile: 'Dockerfile', }, image: 'ci-rsync', volumes: [ ...worktree_volumes.map(({ volume_name, mount_name }) => `${volume_name}:/src/${mount_name}:ro` ), `${deploy_target}:/deploy`, ], restart: 'no', network_mode: 'none', environment: { TERM: 'xterm-256color', FORCE_COLOR: '1', CLICOLOR_FORCE: '1', }, }, }, volumes: Object.fromEntries( worktree_volumes.map(({ volume_name }) => [volume_name, { 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 // Prepare one worktree volume per repository entry const worktree_volumes = await Promise.all( checkout.repositories.map(async repo_def => { const private_key_path = path.join(secrets_base, 'git-ssh', user, 'private', repo_def.key) const volume_name = await prepare_worktree(user, repo_def, private_key_path) const mount_name = repo_mount_name(repo_def) console.log(`Worktree: ${volume_name} → /src/${mount_name}`) return { volume_name, mount_name } }) ) // Container name is a hash of the full checkout set so it is stable across runs const run_hash = createHash('sha256') .update(JSON.stringify(checkout.repositories.map(r => worktree_volume_name(user, r)).sort())) .digest('hex') .slice(0, 24) const container_name = `ci-rsync-${run_hash}` const rsync_config = build_rsync_compose_config({ container_name, worktree_volumes, deploy_target: task.deploy_target, }) await run_rsync(rsync_config) console.log('\n=== Done ===') } main().catch(err => { console.error(err) process.exit(1) })