Files
gitea.efforting.tech/ci/experiments/01-rsync-publish/index.mjs

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)
})