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)
386 lines
9.8 KiB
TypeScript
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;
|
|
}
|
|
}
|