api/src/services/mononoke-provisioner.tsblame
View source
ab61b9d1import { readdirSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "fs";
966d71f2import { join } from "path";
966d71f3import { spawn } from "child_process";
966d71f4import type { FastifyBaseLogger } from "fastify";
966d71f5
966d71f6const REPO_CONFIG_TOML = `storage_config = "default"
966d71f7
0d60b208[hook_manager_params]
0d60b209disable_acl_checker = true
0d60b2010
966d71f11[push]
966d71f12pure_push_allowed = true
966d71f13
966d71f14[pushrebase]
966d71f15rewritedates = false
966d71f16
966d71f17[source_control_service]
966d71f18permit_writes = true
966d71f19permit_service_writes = true
6dbc79520permit_commits_without_parents = true
966d71f21
6c9fcae22[git_configs.git_bundle_uri_config.uri_generator_type.local_fs]
6c9fcae23
0d60b2024[infinitepush]
0d60b2025allow_writes = true
0d60b2026
0d60b2027[commit_cloud_config]
0d60b2028
966d71f29[derived_data_config]
966d71f30enabled_config_name = "default"
966d71f31
966d71f32[derived_data_config.available_configs.default]
966d71f33types = [
966d71f34 "blame",
966d71f35 "changeset_info",
966d71f36 "fastlog",
966d71f37 "filenodes",
966d71f38 "fsnodes",
966d71f39 "git_commits",
966d71f40 "git_delta_manifests_v2",
6c9fcae41 "unodes",
966d71f42 "hgchangesets",
966d71f43 "skeleton_manifests",
1688ad144 "skeleton_manifests_v2",
6c9fcae45 "ccsm",
966d71f46]
0d60b2047
0d60b2048[derived_data_config.available_configs.default.git_delta_manifest_v2_config]
0d60b2049max_inlined_object_size = 2000
0d60b2050max_inlined_delta_size = 2000
0d60b2051delta_chunk_size = 1000000
966d71f52`;
966d71f53
b5baf6d54/** Flatten org/repo names to a single directory component (e.g. "letterpress-labs/site" → "letterpress-labs__site") */
b5baf6d55function repoDirName(repoName: string): string {
b5baf6d56 return repoName.replace(/\//g, "__");
b5baf6d57}
b5baf6d58
966d71f59export class MononokeProvisioner {
966d71f60 constructor(
966d71f61 private configPath: string,
6c9fcae62 private log?: FastifyBaseLogger,
6c9fcae63 private bridgeUrl?: string
966d71f64 ) {}
966d71f65
966d71f66 /**
baf084767 * Find the next available repo_id.
baf084768 * Uses a monotonic counter file to avoid reusing IDs from deleted repos
baf084769 * (whose blobstore data may still exist).
966d71f70 */
966d71f71 getNextRepoId(): number {
966d71f72 const repoDefsDir = join(this.configPath, "repo_definitions");
966d71f73
baf084774 // Scan existing configs to find the current max
966d71f75 let maxId = -1;
baf084776 if (existsSync(repoDefsDir)) {
baf084777 for (const dir of readdirSync(repoDefsDir)) {
baf084778 const tomlPath = join(repoDefsDir, dir, "server.toml");
baf084779 if (!existsSync(tomlPath)) continue;
baf084780 const content = readFileSync(tomlPath, "utf-8");
baf084781 const match = content.match(/repo_id\s*=\s*(\d+)/);
baf084782 if (match) {
baf084783 const id = parseInt(match[1]);
baf084784 if (id > maxId) maxId = id;
baf084785 }
966d71f86 }
966d71f87 }
baf084788
baf084789 // Also check the high-water mark file (persists across repo deletions)
baf084790 const hwmPath = join(this.configPath, ".max_repo_id");
baf084791 if (existsSync(hwmPath)) {
baf084792 const hwm = parseInt(readFileSync(hwmPath, "utf-8").trim());
baf084793 if (!isNaN(hwm) && hwm > maxId) maxId = hwm;
baf084794 }
baf084795
baf084796 const nextId = maxId + 1;
baf084797
baf084798 // Persist the new high-water mark
baf084799 mkdirSync(this.configPath, { recursive: true });
baf0847100 writeFileSync(hwmPath, String(nextId));
baf0847101
baf0847102 return nextId;
966d71f103 }
966d71f104
966d71f105 /**
966d71f106 * Write TOML config files for a new Mononoke repo.
966d71f107 * Creates repo_definitions/<name>/server.toml and repos/<name>/server.toml.
966d71f108 * Returns the assigned repo_id.
966d71f109 */
966d71f110 provisionRepo(repoName: string): number {
966d71f111 const repoId = this.getNextRepoId();
b5baf6d112 const dirName = repoDirName(repoName);
966d71f113
b5baf6d114 const repoDefDir = join(this.configPath, "repo_definitions", dirName);
b5baf6d115 const repoCfgDir = join(this.configPath, "repos", dirName);
966d71f116
966d71f117 mkdirSync(repoDefDir, { recursive: true });
966d71f118 mkdirSync(repoCfgDir, { recursive: true });
966d71f119
966d71f120 writeFileSync(
966d71f121 join(repoDefDir, "server.toml"),
966d71f122 [
966d71f123 `repo_id = ${repoId}`,
966d71f124 `repo_name = "${repoName}"`,
b5baf6d125 `repo_config = "${dirName}"`,
966d71f126 `enabled = true`,
6c9fcae127 `hipster_acl = "default"`,
966d71f128 "",
966d71f129 ].join("\n")
966d71f130 );
966d71f131
966d71f132 writeFileSync(join(repoCfgDir, "server.toml"), REPO_CONFIG_TOML);
966d71f133
966d71f134 this.log?.info({ repoName, repoId }, "Provisioned Mononoke repo config");
966d71f135 return repoId;
966d71f136 }
966d71f137
ab61b9d138 /**
ab61b9d139 * Remove Mononoke config for a repo so it won't be loaded on next restart.
ab61b9d140 * Does not clean up blobstore data (would need Mononoke internals).
ab61b9d141 */
ab61b9d142 deprovisionRepo(repoName: string): void {
b5baf6d143 const dirName = repoDirName(repoName);
b5baf6d144 const repoDefDir = join(this.configPath, "repo_definitions", dirName);
b5baf6d145 const repoCfgDir = join(this.configPath, "repos", dirName);
ab61b9d146
ab61b9d147 rmSync(repoDefDir, { recursive: true, force: true });
ab61b9d148 rmSync(repoCfgDir, { recursive: true, force: true });
ab61b9d149
ab61b9d150 this.log?.info({ repoName }, "Deprovisioned Mononoke repo config");
ab61b9d151 }
ab61b9d152
966d71f153 /**
966d71f154 * Restart Mononoke containers so they pick up new repo config.
966d71f155 * Uses Docker CLI (available via docker-cli in the container image).
6c9fcae156 * After restarting, waits for EdenAPI to become healthy.
966d71f157 */
966d71f158 async restartMononoke(): Promise<void> {
966d71f159 const services = ["mononoke-slapi", "grove-bridge", "mononoke-git"];
6c9fcae160 const errors: string[] = [];
966d71f161
966d71f162 for (const name of services) {
966d71f163 try {
966d71f164 await this.restartContainer(name);
966d71f165 this.log?.info({ service: name }, "Restarted Mononoke container");
966d71f166 } catch (err) {
6c9fcae167 const msg = err instanceof Error ? err.message : String(err);
6c9fcae168 errors.push(`${name}: ${msg}`);
966d71f169 this.log?.error({ err, service: name }, "Failed to restart container");
966d71f170 }
966d71f171 }
6c9fcae172
6c9fcae173 if (errors.length === services.length) {
6c9fcae174 throw new Error(`All Mononoke restarts failed: ${errors.join("; ")}`);
6c9fcae175 }
6c9fcae176
6c9fcae177 // Wait for EdenAPI to become healthy
6c9fcae178 await this.waitForHealthy();
6c9fcae179 }
6c9fcae180
6c9fcae181 /**
6c9fcae182 * Poll the grove-bridge until it responds, or timeout after 30s.
6c9fcae183 * The bridge depends on Mononoke, so if it's healthy, Mononoke is too.
6c9fcae184 */
6c9fcae185 private async waitForHealthy(): Promise<void> {
6c9fcae186 const bridgeUrl = this.bridgeUrl;
6c9fcae187 if (!bridgeUrl) {
6c9fcae188 this.log?.warn("No bridge URL configured, skipping health check");
6c9fcae189 return;
6c9fcae190 }
6c9fcae191
6c9fcae192 const maxAttempts = 15;
6c9fcae193 const delayMs = 2000;
6c9fcae194
6c9fcae195 for (let i = 0; i < maxAttempts; i++) {
6c9fcae196 try {
6c9fcae197 const res = await fetch(`${bridgeUrl}/repos/grove/bookmarks`, {
6c9fcae198 signal: AbortSignal.timeout(3000),
6c9fcae199 });
6c9fcae200 if (res.ok) {
6c9fcae201 this.log?.info("Mononoke is healthy after restart");
6c9fcae202 return;
6c9fcae203 }
6c9fcae204 } catch {
6c9fcae205 // not ready yet
6c9fcae206 }
6c9fcae207 await new Promise((r) => setTimeout(r, delayMs));
6c9fcae208 }
6c9fcae209
6c9fcae210 this.log?.warn("Mononoke did not become healthy within 30s after restart");
966d71f211 }
966d71f212
966d71f213 private restartContainer(nameFilter: string): Promise<void> {
966d71f214 return new Promise((resolve, reject) => {
966d71f215 // Find container ID by name filter
966d71f216 const ps = spawn("docker", [
966d71f217 "ps",
966d71f218 "--filter",
966d71f219 `name=${nameFilter}`,
966d71f220 "--format",
966d71f221 "{{.ID}}",
966d71f222 ]);
966d71f223
966d71f224 let containerId = "";
966d71f225 ps.stdout.on("data", (data: Buffer) => {
966d71f226 containerId += data.toString().trim();
966d71f227 });
966d71f228
966d71f229 ps.on("close", (code) => {
966d71f230 if (!containerId) {
6c9fcae231 this.log?.warn({ service: nameFilter }, "Container not found, skipping restart");
6c9fcae232 resolve();
966d71f233 return;
966d71f234 }
966d71f235 // Take first ID if multiple lines
966d71f236 const id = containerId.split("\n")[0].trim();
966d71f237 const restart = spawn("docker", ["restart", id]);
966d71f238 restart.on("close", (rc) => {
966d71f239 if (rc === 0) resolve();
966d71f240 else reject(new Error(`docker restart ${nameFilter} exited with ${rc}`));
966d71f241 });
966d71f242 });
966d71f243 });
966d71f244 }
966d71f245}