7.0 KB246 lines
Blame
1import { readdirSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "fs";
2import { join } from "path";
3import { spawn } from "child_process";
4import type { FastifyBaseLogger } from "fastify";
5
6const REPO_CONFIG_TOML = `storage_config = "default"
7
8[hook_manager_params]
9disable_acl_checker = true
10
11[push]
12pure_push_allowed = true
13
14[pushrebase]
15rewritedates = false
16
17[source_control_service]
18permit_writes = true
19permit_service_writes = true
20permit_commits_without_parents = true
21
22[git_configs.git_bundle_uri_config.uri_generator_type.local_fs]
23
24[infinitepush]
25allow_writes = true
26
27[commit_cloud_config]
28
29[derived_data_config]
30enabled_config_name = "default"
31
32[derived_data_config.available_configs.default]
33types = [
34 "blame",
35 "changeset_info",
36 "fastlog",
37 "filenodes",
38 "fsnodes",
39 "git_commits",
40 "git_delta_manifests_v2",
41 "unodes",
42 "hgchangesets",
43 "skeleton_manifests",
44 "skeleton_manifests_v2",
45 "ccsm",
46]
47
48[derived_data_config.available_configs.default.git_delta_manifest_v2_config]
49max_inlined_object_size = 2000
50max_inlined_delta_size = 2000
51delta_chunk_size = 1000000
52`;
53
54/** Flatten org/repo names to a single directory component (e.g. "letterpress-labs/site" → "letterpress-labs__site") */
55function repoDirName(repoName: string): string {
56 return repoName.replace(/\//g, "__");
57}
58
59export class MononokeProvisioner {
60 constructor(
61 private configPath: string,
62 private log?: FastifyBaseLogger,
63 private bridgeUrl?: string
64 ) {}
65
66 /**
67 * Find the next available repo_id.
68 * Uses a monotonic counter file to avoid reusing IDs from deleted repos
69 * (whose blobstore data may still exist).
70 */
71 getNextRepoId(): number {
72 const repoDefsDir = join(this.configPath, "repo_definitions");
73
74 // Scan existing configs to find the current max
75 let maxId = -1;
76 if (existsSync(repoDefsDir)) {
77 for (const dir of readdirSync(repoDefsDir)) {
78 const tomlPath = join(repoDefsDir, dir, "server.toml");
79 if (!existsSync(tomlPath)) continue;
80 const content = readFileSync(tomlPath, "utf-8");
81 const match = content.match(/repo_id\s*=\s*(\d+)/);
82 if (match) {
83 const id = parseInt(match[1]);
84 if (id > maxId) maxId = id;
85 }
86 }
87 }
88
89 // Also check the high-water mark file (persists across repo deletions)
90 const hwmPath = join(this.configPath, ".max_repo_id");
91 if (existsSync(hwmPath)) {
92 const hwm = parseInt(readFileSync(hwmPath, "utf-8").trim());
93 if (!isNaN(hwm) && hwm > maxId) maxId = hwm;
94 }
95
96 const nextId = maxId + 1;
97
98 // Persist the new high-water mark
99 mkdirSync(this.configPath, { recursive: true });
100 writeFileSync(hwmPath, String(nextId));
101
102 return nextId;
103 }
104
105 /**
106 * Write TOML config files for a new Mononoke repo.
107 * Creates repo_definitions/<name>/server.toml and repos/<name>/server.toml.
108 * Returns the assigned repo_id.
109 */
110 provisionRepo(repoName: string): number {
111 const repoId = this.getNextRepoId();
112 const dirName = repoDirName(repoName);
113
114 const repoDefDir = join(this.configPath, "repo_definitions", dirName);
115 const repoCfgDir = join(this.configPath, "repos", dirName);
116
117 mkdirSync(repoDefDir, { recursive: true });
118 mkdirSync(repoCfgDir, { recursive: true });
119
120 writeFileSync(
121 join(repoDefDir, "server.toml"),
122 [
123 `repo_id = ${repoId}`,
124 `repo_name = "${repoName}"`,
125 `repo_config = "${dirName}"`,
126 `enabled = true`,
127 `hipster_acl = "default"`,
128 "",
129 ].join("\n")
130 );
131
132 writeFileSync(join(repoCfgDir, "server.toml"), REPO_CONFIG_TOML);
133
134 this.log?.info({ repoName, repoId }, "Provisioned Mononoke repo config");
135 return repoId;
136 }
137
138 /**
139 * Remove Mononoke config for a repo so it won't be loaded on next restart.
140 * Does not clean up blobstore data (would need Mononoke internals).
141 */
142 deprovisionRepo(repoName: string): void {
143 const dirName = repoDirName(repoName);
144 const repoDefDir = join(this.configPath, "repo_definitions", dirName);
145 const repoCfgDir = join(this.configPath, "repos", dirName);
146
147 rmSync(repoDefDir, { recursive: true, force: true });
148 rmSync(repoCfgDir, { recursive: true, force: true });
149
150 this.log?.info({ repoName }, "Deprovisioned Mononoke repo config");
151 }
152
153 /**
154 * Restart Mononoke containers so they pick up new repo config.
155 * Uses Docker CLI (available via docker-cli in the container image).
156 * After restarting, waits for EdenAPI to become healthy.
157 */
158 async restartMononoke(): Promise<void> {
159 const services = ["mononoke-slapi", "grove-bridge", "mononoke-git"];
160 const errors: string[] = [];
161
162 for (const name of services) {
163 try {
164 await this.restartContainer(name);
165 this.log?.info({ service: name }, "Restarted Mononoke container");
166 } catch (err) {
167 const msg = err instanceof Error ? err.message : String(err);
168 errors.push(`${name}: ${msg}`);
169 this.log?.error({ err, service: name }, "Failed to restart container");
170 }
171 }
172
173 if (errors.length === services.length) {
174 throw new Error(`All Mononoke restarts failed: ${errors.join("; ")}`);
175 }
176
177 // Wait for EdenAPI to become healthy
178 await this.waitForHealthy();
179 }
180
181 /**
182 * Poll the grove-bridge until it responds, or timeout after 30s.
183 * The bridge depends on Mononoke, so if it's healthy, Mononoke is too.
184 */
185 private async waitForHealthy(): Promise<void> {
186 const bridgeUrl = this.bridgeUrl;
187 if (!bridgeUrl) {
188 this.log?.warn("No bridge URL configured, skipping health check");
189 return;
190 }
191
192 const maxAttempts = 15;
193 const delayMs = 2000;
194
195 for (let i = 0; i < maxAttempts; i++) {
196 try {
197 const res = await fetch(`${bridgeUrl}/repos/grove/bookmarks`, {
198 signal: AbortSignal.timeout(3000),
199 });
200 if (res.ok) {
201 this.log?.info("Mononoke is healthy after restart");
202 return;
203 }
204 } catch {
205 // not ready yet
206 }
207 await new Promise((r) => setTimeout(r, delayMs));
208 }
209
210 this.log?.warn("Mononoke did not become healthy within 30s after restart");
211 }
212
213 private restartContainer(nameFilter: string): Promise<void> {
214 return new Promise((resolve, reject) => {
215 // Find container ID by name filter
216 const ps = spawn("docker", [
217 "ps",
218 "--filter",
219 `name=${nameFilter}`,
220 "--format",
221 "{{.ID}}",
222 ]);
223
224 let containerId = "";
225 ps.stdout.on("data", (data: Buffer) => {
226 containerId += data.toString().trim();
227 });
228
229 ps.on("close", (code) => {
230 if (!containerId) {
231 this.log?.warn({ service: nameFilter }, "Container not found, skipping restart");
232 resolve();
233 return;
234 }
235 // Take first ID if multiple lines
236 const id = containerId.split("\n")[0].trim();
237 const restart = spawn("docker", ["restart", id]);
238 restart.on("close", (rc) => {
239 if (rc === 0) resolve();
240 else reject(new Error(`docker restart ${nameFilter} exited with ${rc}`));
241 });
242 });
243 });
244 }
245}
246