clawdie-ai/setup/forgejo.ts
Mevy Assistant d8f43fc4a0 Clean up controlplane naming consumers
Fix the remaining operator-surface drift after the naming cutover. This aligns controlplane defaults around ai.<base>, makes the dashboard use the shared display-date helper and approved controlplane host, reuses the derived code-service hostname in Forgejo config, and fixes local-host syncing so underscore-form tenant jails are no longer skipped.

---
Build: pass | Tests: pass — 67 passed (5 files)
2026-04-24 16:50:08 +02:00

386 lines
9.8 KiB
TypeScript

/**
* setup/forgejo.ts — Install and configure Forgejo in the git jail.
*
* Runs inside the existing ${TENANT_ID}-git jail created by setup/git.ts.
* Gated by FEATURE_GITEA=YES or CODE_HOSTING_MODE=gitea.
* Uses PostgreSQL in the db jail, listens on port 3000.
*/
import { spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import {
CODE_SERVICE_INTERNAL_DOMAIN,
CODE_HOSTING_MODE,
FEATURE_GITEA,
FORGEJO_DB_URL,
GIT_DEFAULT_REPO_NAME,
GIT_JAIL_NAME,
GIT_JAIL_IP,
GIT_STORAGE_ROOT,
TENANT_ID,
} from '../src/config.js';
import { logger } from '../src/logger.js';
import { loadPackageList, mountPkgCacheInJail } from './packages.js';
import { getPlatform } from './platform.js';
import { emitStatus } from './status.js';
import { bastille, bastilleTimed, jailExists } from './bastille-helpers.js';
const LOG = 'logs/setup.log';
const FORGEJO_PORT = 3000;
const FORGEJO_USER = 'git';
const FORGEJO_CONF_DIR = '/usr/local/etc/forgejo/conf';
const FORGEJO_DATA_DIR = '/usr/local/share/forgejo/data';
const FORGEJO_LOG_DIR = '/usr/local/share/forgejo/log';
const CONFIG_MARKER = '; Forgejo configuration — generated by setup/forgejo.ts';
const PKG_TIMEOUT_MS = 30 * 60 * 1000;
const MIGRATE_TIMEOUT_MS = 5 * 60 * 1000;
const SERVICE_TIMEOUT_MS = 60 * 1000;
const HEALTH_TIMEOUT_MS = 10 * 1000;
type ForgejoDbConfig = {
host: string;
name: string;
user: string;
password: string;
};
function parseForgejoDbUrl(dbUrl: string): ForgejoDbConfig {
let parsed: URL;
try {
parsed = new URL(dbUrl);
} catch (error) {
throw new Error(`Invalid FORGEJO_DB_URL: ${String(error)}`);
}
const name = parsed.pathname.replace(/^\//, '');
if (!name) {
throw new Error('FORGEJO_DB_URL missing database name');
}
return {
host: parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname,
name: decodeURIComponent(name),
user: decodeURIComponent(parsed.username || ''),
password: decodeURIComponent(parsed.password || ''),
};
}
function generateAppIni(
hostname: string,
repoRoot: string,
dbConfig: ForgejoDbConfig,
): string {
return `${CONFIG_MARKER}
; Edit /usr/local/etc/forgejo/conf/app.ini inside the git jail to customize.
APP_NAME = ${TENANT_ID} Git
RUN_USER = ${FORGEJO_USER}
RUN_MODE = prod
WORK_PATH = /usr/local/share/forgejo
[server]
DOMAIN = ${hostname}
ROOT_URL = http://${hostname}:${FORGEJO_PORT}/
HTTP_PORT = ${FORGEJO_PORT}
SSH_DOMAIN = ${hostname}
SSH_PORT = 22
DISABLE_SSH = true
LFS_START_SERVER = false
OFFLINE_MODE = true
[database]
DB_TYPE = postgres
HOST = ${dbConfig.host}
NAME = ${dbConfig.name}
USER = ${dbConfig.user}
PASSWD = ${dbConfig.password}
SSL_MODE = disable
[repository]
ROOT = ${repoRoot}
[security]
INSTALL_LOCK = true
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
[log]
MODE = file
LEVEL = warn
ROOT_PATH = ${FORGEJO_LOG_DIR}
[picture]
DISABLE_GRAVATAR = true
[actions]
ENABLED = false
`;
}
export async function run(_args: string[]): Promise<void> {
const jailName = GIT_JAIL_NAME;
const dbUrl = FORGEJO_DB_URL;
// Feature gate
if (!FEATURE_GITEA && CODE_HOSTING_MODE !== 'gitea') {
emitStatus('SETUP_FORGEJO', {
STATUS: 'skipped',
REASON: 'feature_disabled',
LOG,
});
logger.info(
'Forgejo skipped — FEATURE_GITEA disabled and CODE_HOSTING_MODE != gitea',
);
return;
}
// Platform gate
if (getPlatform() !== 'freebsd') {
emitStatus('SETUP_FORGEJO', {
STATUS: 'failed',
ERROR: 'unsupported_platform',
LOG,
});
process.exit(1);
}
if (!dbUrl) {
emitStatus('SETUP_FORGEJO', {
STATUS: 'failed',
ERROR: 'missing_forgejo_db_url',
LOG,
});
throw new Error('FORGEJO_DB_URL is not set — run setup --step db first');
}
// Git jail must exist (created by setup/git.ts)
if (!jailExists(jailName)) {
emitStatus('SETUP_FORGEJO', {
STATUS: 'failed',
ERROR: 'git_jail_missing',
HINT: 'Run setup --step git first',
LOG,
});
throw new Error(
`Git jail ${jailName} does not exist — run setup --step git first`,
);
}
try {
// ── Install Forgejo package ───────────────────────────────────────
mountPkgCacheInJail(jailName);
const packages = loadPackageList('forgejo-jail.txt');
const pkg = bastilleTimed(
PKG_TIMEOUT_MS,
'pkg',
jailName,
'install',
'-y',
...packages,
);
if (!pkg.ok) {
logger.warn(
{ output: pkg.output },
'Forgejo package install had warnings',
);
}
// ── Create data/log directories ───────────────────────────────────
for (const dir of [FORGEJO_DATA_DIR, FORGEJO_LOG_DIR]) {
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
FORGEJO_USER,
'-g',
FORGEJO_USER,
'-m',
'755',
dir,
);
}
// ── Ensure repository root is writable by forgejo user ────────────
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
FORGEJO_USER,
'-g',
FORGEJO_USER,
'-m',
'755',
GIT_STORAGE_ROOT,
);
bastille(
'cmd',
jailName,
'chown',
'-R',
`${FORGEJO_USER}:${FORGEJO_USER}`,
GIT_STORAGE_ROOT,
);
// ── Write app.ini (replace default sample config) ──────────────────
const jailRoot = `/usr/local/bastille/jails/${jailName}/root`;
const destConf = path.join(jailRoot, FORGEJO_CONF_DIR, 'app.ini');
let shouldWriteConfig = true;
if (fs.existsSync(destConf)) {
try {
const existing = fs.readFileSync(destConf, 'utf-8');
if (existing.includes(CONFIG_MARKER)) {
shouldWriteConfig = false;
}
} catch {
// If unreadable, overwrite.
}
}
if (shouldWriteConfig) {
logger.info('Writing Forgejo app.ini');
// Ensure conf directory exists
bastille(
'cmd',
jailName,
'install',
'-d',
'-o',
FORGEJO_USER,
'-g',
FORGEJO_USER,
'-m',
'755',
FORGEJO_CONF_DIR,
);
if (fs.existsSync(destConf)) {
const backup = path.join(
process.cwd(),
'tmp',
`forgejo-app.ini.backup-${Date.now()}`,
);
fs.mkdirSync(path.dirname(backup), { recursive: true });
fs.copyFileSync(destConf, backup);
}
const dbConfig = parseForgejoDbUrl(dbUrl);
if (!dbConfig.user || !dbConfig.password) {
throw new Error('FORGEJO_DB_URL must include a username and password');
}
fs.mkdirSync(path.dirname(destConf), { recursive: true });
fs.writeFileSync(
destConf,
generateAppIni(
CODE_SERVICE_INTERNAL_DOMAIN,
GIT_STORAGE_ROOT,
dbConfig,
),
);
// Fix ownership inside jail
bastille(
'cmd',
jailName,
'chown',
`${FORGEJO_USER}:${FORGEJO_USER}`,
`${FORGEJO_CONF_DIR}/app.ini`,
);
} else {
logger.info(
'Forgejo app.ini already managed, skipping config generation',
);
}
// ── Migrate DB schema (required for rc.d configcheck) ──────────────
const migrate = bastilleTimed(
MIGRATE_TIMEOUT_MS,
'cmd',
jailName,
'su',
'-m',
FORGEJO_USER,
'-c',
`FORGEJO_CUSTOM=${path.posix.dirname(FORGEJO_CONF_DIR)} /usr/local/sbin/forgejo migrate`,
);
if (!migrate.ok) {
throw new Error(`Forgejo migrate failed: ${migrate.output}`);
}
// ── Enable and start service ──────────────────────────────────────
bastille('sysrc', jailName, 'forgejo_enable=YES');
// rc.d preflight runs `forgejo doctor check`, which can fail even with
// DISABLE_SSH=true (e.g. authorized_keys check). We manage health via
// explicit API probe below, so disable the rc.d configcheck gate.
bastille('sysrc', jailName, 'forgejo_configcheck_enable=NO');
const start = bastilleTimed(
SERVICE_TIMEOUT_MS,
'service',
jailName,
'forgejo',
'restart',
);
if (!start.ok) {
logger.warn(
{ output: start.output },
'Forgejo service start had warnings',
);
}
// ── Validate ──────────────────────────────────────────────────────
// Give Forgejo a moment to bind
await new Promise((resolve) => setTimeout(resolve, 2000));
const health = bastilleTimed(
HEALTH_TIMEOUT_MS,
'cmd',
jailName,
'fetch',
'-qo',
'-',
`http://127.0.0.1:${FORGEJO_PORT}/api/v1/version`,
);
if (!health.ok) {
logger.warn(
'Forgejo health check failed — service may still be starting',
);
} else {
logger.info({ response: health.output }, 'Forgejo is responding');
}
emitStatus('SETUP_FORGEJO', {
STATUS: 'success',
JAIL_NAME: jailName,
JAIL_IP: GIT_JAIL_IP,
FORGEJO_PORT: FORGEJO_PORT,
FORGEJO_URL: `http://${CODE_SERVICE_INTERNAL_DOMAIN}:${FORGEJO_PORT}`,
CONFIG: `${FORGEJO_CONF_DIR}/app.ini`,
LOG,
});
logger.info(
{
jailName,
port: FORGEJO_PORT,
url: `http://${CODE_SERVICE_INTERNAL_DOMAIN}:${FORGEJO_PORT}`,
},
'Forgejo provisioned',
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emitStatus('SETUP_FORGEJO', {
STATUS: 'failed',
ERROR: message,
LOG,
});
throw error;
}
}