clawdie-ai/setup/git.ts
Mevy Assistant fe96ade042 Propagate platform naming into runtime consumers
---
Build: pass | Tests: pass — 85 passed (6 files)
2026-04-24 15:44:52 +02:00

420 lines
12 KiB
TypeScript

/**
* setup/git.ts — Provision a local Bastille git jail.
*
* Creates a thin jail with git + rsync, mirrors from REMOTE_GIT_URL
* plus any GIT_MIRROR_URLS, or creates an empty bare repo as fallback.
* Fully idempotent — safe to rerun.
*/
import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
CODE_SERVICE_INTERNAL_DOMAIN,
CODE_HOSTING_MODE,
FEATURE_GIT,
GIT_DEFAULT_REPO_NAME,
GIT_JAIL_NAME,
GIT_MIRROR_URLS,
GIT_JAIL_IP,
GIT_STORAGE_ROOT,
PLATFORM_RUNTIME_HOME,
REMOTE_GIT_URL,
SUBNET_BASE,
} from '../src/config.js';
import { logger } from '../src/logger.js';
import { readEnvFile } from '../src/env.js';
import { loadPackageList, mountPkgCacheInJail } from './packages.js';
import { getPlatform } from './platform.js';
import { emitStatus } from './status.js';
import { maybeEnableTailscaleInJail } from './tailscale.js';
import {
bastille,
bastilleTimed,
jailExists,
jailRoot,
detectFreeBSDRelease,
} from './bastille-helpers.js';
const LOG = 'logs/setup.log';
const CLONE_TIMEOUT_MS = 30 * 60 * 1000;
function repoNameFromRemote(remote: string): string | null {
const trimmed = remote.trim();
if (!trimmed) return null;
const hashIndex = trimmed.indexOf('#');
const clean = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
try {
const url = new URL(clean);
const base = path.posix.basename(url.pathname);
return base || null;
} catch {
// Fall through to SCP-style parsing.
}
const colonIndex = clean.lastIndexOf(':');
if (colonIndex !== -1 && clean.slice(colonIndex + 1).includes('/')) {
const base = path.posix.basename(clean.slice(colonIndex + 1));
return base || null;
}
const base = path.posix.basename(clean);
return base || null;
}
function repoExistsInJail(jail: string, repoPath: string): boolean {
const { ok } = bastille('cmd', jail, 'test', '-d', repoPath);
return ok;
}
function repoHasRefsInJail(jail: string, repoPath: string): boolean {
const refs = bastille('cmd', jail, 'git', '--git-dir', repoPath, 'show-ref');
return refs.ok && refs.output.trim().length > 0;
}
function repoHeadLooksInvalid(jail: string, repoPath: string): boolean {
const head = bastille('cmd', jail, 'cat', path.posix.join(repoPath, 'HEAD'));
return !head.ok || head.output.includes('refs/heads/.invalid');
}
function setupGitJailSsh(jailName: string): void {
const root = jailRoot(jailName);
// Generate host keys (idempotent — won't overwrite existing)
bastille('cmd', jailName, 'ssh-keygen', '-A');
// Write sshd_config — keys only, no passwords, no root login
const sshdDir = path.join(root, 'etc', 'ssh');
fs.mkdirSync(sshdDir, { recursive: true });
const sshdConfig = [
'Port 22',
'PermitRootLogin no',
'PasswordAuthentication no',
'PubkeyAuthentication yes',
'AuthorizedKeysFile .ssh/authorized_keys',
'',
].join('\n');
fs.writeFileSync(path.join(sshdDir, 'sshd_config'), sshdConfig);
// Create git user with restricted shell (git-shell)
const checkUser = bastille('cmd', jailName, 'id', 'git');
if (!checkUser.ok) {
bastille(
'cmd',
jailName,
'pw',
'useradd',
'git',
'-c',
'Git Service',
'-s',
'/usr/local/bin/git-shell',
'-d',
GIT_STORAGE_ROOT,
'-m',
);
logger.info({ jailName }, 'Created git user with git-shell');
}
// Deploy operator public key into git user's authorized_keys
const env = readEnvFile(['SSH_PUBLIC_KEY', 'GIT_SSH_KEY_PATH', 'HOME']);
let pubKey: string | null = null;
if (env['SSH_PUBLIC_KEY']) {
pubKey = env['SSH_PUBLIC_KEY'];
} else {
const keyPath = env['GIT_SSH_KEY_PATH']
? env['GIT_SSH_KEY_PATH'] + '.pub'
: path.join(
env['HOME'] || PLATFORM_RUNTIME_HOME,
'.ssh',
'id_ed25519.pub',
);
if (fs.existsSync(keyPath)) {
pubKey = fs.readFileSync(keyPath, 'utf-8').trim();
}
}
if (pubKey) {
const sshDir = path.join(root, GIT_STORAGE_ROOT.replace(/^\//, ''), '.ssh');
fs.mkdirSync(sshDir, { recursive: true });
const akPath = path.join(sshDir, 'authorized_keys');
// Only append if key not already present
const existing = fs.existsSync(akPath)
? fs.readFileSync(akPath, 'utf-8')
: '';
if (!existing.includes(pubKey)) {
fs.appendFileSync(akPath, pubKey + '\n');
}
bastille(
'cmd',
jailName,
'chown',
'-R',
'git:git',
`${GIT_STORAGE_ROOT}/.ssh`,
);
bastille('cmd', jailName, 'chmod', '700', `${GIT_STORAGE_ROOT}/.ssh`);
bastille(
'cmd',
jailName,
'chmod',
'600',
`${GIT_STORAGE_ROOT}/.ssh/authorized_keys`,
);
logger.info({ jailName }, 'Deployed SSH public key for git user');
} else {
logger.warn(
{ jailName },
'No SSH public key found — set SSH_PUBLIC_KEY in .env or provide ~/.ssh/id_ed25519.pub',
);
}
// Enable and start sshd
bastille('cmd', jailName, 'sysrc', 'sshd_enable=YES');
bastille('cmd', jailName, 'service', 'sshd', 'restart');
}
export async function run(_args: string[]): Promise<void> {
const jailName = GIT_JAIL_NAME;
const hostname = CODE_SERVICE_INTERNAL_DOMAIN;
const repoPath = path.posix.join(GIT_STORAGE_ROOT, GIT_DEFAULT_REPO_NAME);
const extraMirrorUrls = GIT_MIRROR_URLS.filter(
(url) => url && url !== REMOTE_GIT_URL,
);
const runBastille = (args: string[]) => bastille(...args);
// Feature gate — skip if git hosting is explicitly disabled
if (!FEATURE_GIT || CODE_HOSTING_MODE === 'external') {
emitStatus('SETUP_GIT', {
STATUS: 'skipped',
REASON: !FEATURE_GIT ? 'feature_disabled' : 'external_hosting',
LOG,
});
logger.info(
'Git jail skipped — FEATURE_GIT=%s, CODE_HOSTING_MODE=%s',
FEATURE_GIT,
CODE_HOSTING_MODE,
);
return;
}
// Platform gate
if (getPlatform() !== 'freebsd') {
emitStatus('SETUP_GIT', {
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG,
});
process.exit(1);
}
try {
const exists = jailExists(jailName);
// ── Create jail if missing ──────────────────────────────────────
if (!exists) {
const release = detectFreeBSDRelease();
const gateway = process.env.WARDEN_GATEWAY || `${SUBNET_BASE}.1`;
const bridge = process.env.WARDEN_BRIDGE || 'warden0';
logger.info({ jailName, ip: GIT_JAIL_IP, release }, 'Creating git jail');
const create = bastille(
'create',
// thin jail (no -T) to avoid ZFS disk amplification
'-B',
'-g',
gateway,
jailName,
release,
`${GIT_JAIL_IP}/24`,
bridge,
);
if (!create.ok) {
throw new Error(`bastille create failed: ${create.output}`);
}
// Set hostname and restart so it takes effect
bastille('config', jailName, 'set', 'host.hostname', hostname);
bastille('restart', jailName);
} else {
logger.info({ jailName }, 'Git jail already exists, skipping creation');
}
// ── Install packages ────────────────────────────────────────────
mountPkgCacheInJail(jailName);
const packages = loadPackageList('git-jail.txt');
const pkg = bastille('pkg', jailName, 'install', '-y', ...packages);
if (!pkg.ok) {
logger.warn({ output: pkg.output }, 'Package install had warnings');
}
maybeEnableTailscaleInJail(runBastille, jailName, jailName);
// ── SSH setup (git-shell user + key-only auth) ────────────────────
setupGitJailSsh(jailName);
// ── Create storage root ─────────────────────────────────────────
bastille('cmd', jailName, 'install', '-d', '-m', '755', GIT_STORAGE_ROOT);
// ── Create or mirror repository ─────────────────────────────────
const repoExists = repoExistsInJail(jailName, repoPath);
if (
repoExists &&
REMOTE_GIT_URL &&
(repoHeadLooksInvalid(jailName, repoPath) ||
!repoHasRefsInJail(jailName, repoPath))
) {
// A prior mirror clone may have been interrupted (e.g. SSH remote without keys),
// leaving a bare repo skeleton with no refs and an invalid HEAD.
// If a remote is configured, prefer repairing by re-cloning the mirror.
logger.warn(
{ repoPath, remote: REMOTE_GIT_URL },
'Bare repo appears incomplete — removing and re-cloning mirror',
);
bastille('cmd', jailName, 'rm', '-rf', repoPath);
}
if (!repoExistsInJail(jailName, repoPath)) {
let mirrored = false;
if (REMOTE_GIT_URL) {
logger.info({ url: REMOTE_GIT_URL }, 'Attempting mirror clone');
const clone = bastilleTimed(
CLONE_TIMEOUT_MS,
'cmd',
jailName,
'git',
'clone',
'--mirror',
REMOTE_GIT_URL,
repoPath,
);
if (clone.ok) {
mirrored = true;
} else {
logger.warn(
{ output: clone.output },
'Mirror clone failed — falling back to empty bare repo',
);
}
}
if (!mirrored) {
const init = bastille(
'cmd',
jailName,
'git',
'init',
'--bare',
repoPath,
);
if (!init.ok) {
throw new Error(`git init --bare failed: ${init.output}`);
}
}
} else {
logger.info({ repoPath }, 'Bare repo already exists, skipping');
}
if (extraMirrorUrls.length) {
for (const mirrorUrl of extraMirrorUrls) {
const repoName = repoNameFromRemote(mirrorUrl);
if (!repoName) {
logger.warn(
{ mirrorUrl },
'Unable to derive repo name for mirror URL',
);
continue;
}
if (repoName === GIT_DEFAULT_REPO_NAME) {
continue;
}
const mirrorRepoPath = path.posix.join(GIT_STORAGE_ROOT, repoName);
if (repoExistsInJail(jailName, mirrorRepoPath)) {
logger.info(
{ mirrorRepoPath },
'Mirror repo already exists, skipping',
);
continue;
}
logger.info({ mirrorUrl, mirrorRepoPath }, 'Attempting mirror clone');
const clone = bastilleTimed(
CLONE_TIMEOUT_MS,
'cmd',
jailName,
'git',
'clone',
'--mirror',
mirrorUrl,
mirrorRepoPath,
);
if (!clone.ok) {
logger.warn(
{ mirrorUrl, output: clone.output },
'Mirror clone failed — skipping extra repo',
);
}
}
}
// ── Enable on boot ──────────────────────────────────────────────
try {
execSync(`sysrc jail_list+="${jailName}"`, { stdio: 'ignore' });
} catch {
logger.warn('sysrc jail_list update failed — jail may not start on boot');
}
// ── Validate ────────────────────────────────────────────────────
const gitVer = bastille('cmd', jailName, 'git', '--version');
if (!gitVer.ok) {
throw new Error('Validation failed: git not installed in jail');
}
const isBare = bastille(
'cmd',
jailName,
'git',
'--git-dir',
repoPath,
'rev-parse',
'--is-bare-repository',
);
if (!isBare.ok || !isBare.output.includes('true')) {
throw new Error(
`Validation failed: ${repoPath} is not a valid bare repository`,
);
}
emitStatus('SETUP_GIT', {
STATUS: 'success',
JAIL_NAME: jailName,
JAIL_IP: GIT_JAIL_IP,
GIT_STORAGE_ROOT,
GIT_REPO: GIT_DEFAULT_REPO_NAME,
MIRROR_SOURCE: REMOTE_GIT_URL || 'none',
MIRROR_EXTRAS: extraMirrorUrls.join(',') || 'none',
LOG,
});
logger.info(
{ jailName, ip: GIT_JAIL_IP, repoPath },
'Git jail provisioned',
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emitStatus('SETUP_GIT', {
STATUS: 'failed',
ERROR: message,
LOG,
});
throw error;
}
}