Skip to content

Commit d0ad9e5

Browse files
committed
fix(cli): apply hardlinks via data-manager API to clear UID-mismatch EPERM
Linux's `fs.protected_hardlinks=1` (the default on most distros, including current Ubuntu) refuses non-owners' link(2) calls even when the source file is world-readable. The host CLI runs as the invoking user (commonly UID 1000), the data-manager image runs as the bundled `datamgr` (UID 1001), and any file the container has produced is therefore unlinkable from the host — `pnpm openmapx data link` and the link step inside `compose up` / `services start` failed with EPERM the moment a producer container had written into /data. Make `applyGeneratedHardlinks` async and have it POST the plan to the data-manager's `/link` endpoint when the service is reachable. The data-manager runs inside the container as the producer UID and links without issue. When the API isn't reachable (typical on the very first `compose up` where the data-manager hasn't started yet) fall back to the existing host-side `linkSync` — at that point the plan is usually empty so the fallback's permission ceiling doesn't matter. The new `via` field on the result reports which path actually ran (`"data-manager"`, `"local"`, or `"none"`).
1 parent 5f24675 commit d0ad9e5

5 files changed

Lines changed: 231 additions & 20 deletions

File tree

packages/cli/__tests__/cli-hardlinks.test.ts

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,19 @@ describe("cli hardlink helpers", () => {
100100
expect(existsSync(join(tgt, "nigiri.cache"))).toBe(true);
101101
});
102102

103-
it("applyGeneratedHardlinks can no-op when plan is missing", () => {
104-
const result = applyGeneratedHardlinks({ rootDir: tmp, requirePlan: false });
103+
it("applyGeneratedHardlinks can no-op when plan is missing", async () => {
104+
const result = await applyGeneratedHardlinks({
105+
rootDir: tmp,
106+
requirePlan: false,
107+
forceLocal: true,
108+
});
105109
expect(result.applied).toBe(false);
106110
expect(result.linked).toBe(0);
107111
expect(result.pruned).toBe(0);
112+
expect(result.via).toBe("none");
108113
});
109114

110-
it("applyGeneratedHardlinks reads generated plan from infra/docker", () => {
115+
it("applyGeneratedHardlinks reads generated plan from infra/docker", async () => {
111116
const dataRoot = join(tmp, "infra", "docker", "data");
112117
mkdirSync(join(dataRoot, "osm"), { recursive: true });
113118
writeFileSync(join(dataRoot, "osm", "planet.osm.pbf"), "PBF");
@@ -126,27 +131,136 @@ describe("cli hardlink helpers", () => {
126131
"utf-8",
127132
);
128133

129-
const result = applyGeneratedHardlinks({ rootDir: tmp, requirePlan: true, prune: true });
134+
const result = await applyGeneratedHardlinks({
135+
rootDir: tmp,
136+
requirePlan: true,
137+
prune: true,
138+
forceLocal: true,
139+
});
130140

131141
expect(result.applied).toBe(true);
132142
expect(result.linked).toBe(1);
133143
expect(result.pruned).toBe(0);
144+
expect(result.via).toBe("local");
134145
expect(readFileSync(join(dataRoot, "nominatim", "osm-pbf", "data.osm.pbf"), "utf-8")).toBe(
135146
"PBF",
136147
);
137148
});
138149

139-
it("applyGeneratedHardlinks creates data root even with an empty plan", () => {
150+
it("applyGeneratedHardlinks creates data root even with an empty plan", async () => {
140151
const dataRoot = join(tmp, "infra", "docker", "data");
141152
rmSync(dataRoot, { recursive: true, force: true });
142153
writeFileSync(join(tmp, "infra", "docker", "docker-compose.generated.hardlinks.json"), "[]");
143154

144-
const result = applyGeneratedHardlinks({ rootDir: tmp, requirePlan: true, prune: true });
155+
const result = await applyGeneratedHardlinks({
156+
rootDir: tmp,
157+
requirePlan: true,
158+
prune: true,
159+
forceLocal: true,
160+
});
145161

146162
expect(result.applied).toBe(true);
147163
expect(result.linked).toBe(0);
148164
expect(result.pruned).toBe(0);
165+
expect(result.via).toBe("local");
149166
expect(existsSync(dataRoot)).toBe(true);
150167
expect(statSync(dataRoot).isDirectory()).toBe(true);
151168
});
169+
170+
it("falls through to the local linker when the data-manager probe fails", async () => {
171+
const dataRoot = join(tmp, "infra", "docker", "data");
172+
mkdirSync(join(dataRoot, "osm"), { recursive: true });
173+
writeFileSync(join(dataRoot, "osm", "planet.osm.pbf"), "PBF");
174+
mkdirSync(join(tmp, "infra", "docker"), { recursive: true });
175+
writeFileSync(
176+
join(tmp, "infra", "docker", "docker-compose.generated.hardlinks.json"),
177+
JSON.stringify([
178+
{
179+
source: "data/osm",
180+
target: "data/valhalla/osm-pbf",
181+
consumerService: "valhalla",
182+
dataType: "osm-pbf",
183+
},
184+
]),
185+
"utf-8",
186+
);
187+
188+
// Point at a port we know nothing is listening on so the reachability
189+
// probe times out fast — verifies the fallback wiring without standing
190+
// up a fake HTTP server.
191+
const result = await applyGeneratedHardlinks({
192+
rootDir: tmp,
193+
requirePlan: true,
194+
prune: true,
195+
dataManagerUrl: "http://127.0.0.1:1",
196+
reachabilityTimeoutMs: 250,
197+
});
198+
199+
expect(result.via).toBe("local");
200+
expect(result.linked).toBe(1);
201+
});
202+
203+
it("uses the data-manager API when reachable", async () => {
204+
const dataRoot = join(tmp, "infra", "docker", "data");
205+
mkdirSync(join(dataRoot, "osm"), { recursive: true });
206+
writeFileSync(join(dataRoot, "osm", "planet.osm.pbf"), "PBF");
207+
mkdirSync(join(tmp, "infra", "docker"), { recursive: true });
208+
writeFileSync(
209+
join(tmp, "infra", "docker", "docker-compose.generated.hardlinks.json"),
210+
JSON.stringify([
211+
{
212+
source: "data/osm",
213+
target: "data/valhalla/osm-pbf",
214+
consumerService: "valhalla",
215+
dataType: "osm-pbf",
216+
},
217+
]),
218+
"utf-8",
219+
);
220+
221+
// Tiny mock data-manager: serves /status (probe) and /link (apply).
222+
const linkRequests: Array<{ plan: unknown; prune: unknown }> = [];
223+
const { createServer } = await import("node:http");
224+
const server = createServer((req, res) => {
225+
if (req.url === "/status") {
226+
res.writeHead(200, { "Content-Type": "application/json" });
227+
res.end(JSON.stringify({ ok: true, uptime: 1, dataDir: "/data" }));
228+
return;
229+
}
230+
if (req.url === "/link" && req.method === "POST") {
231+
const chunks: Buffer[] = [];
232+
req.on("data", (c) => chunks.push(Buffer.from(c)));
233+
req.on("end", () => {
234+
const body = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
235+
linkRequests.push({ plan: body.plan, prune: body.prune });
236+
res.writeHead(200, { "Content-Type": "application/json" });
237+
res.end(JSON.stringify({ ok: true, linked: 7, skipped: 2, pruned: 1 }));
238+
});
239+
return;
240+
}
241+
res.writeHead(404);
242+
res.end();
243+
});
244+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
245+
const port = (server.address() as { port: number }).port;
246+
247+
try {
248+
const result = await applyGeneratedHardlinks({
249+
rootDir: tmp,
250+
requirePlan: true,
251+
prune: true,
252+
dataManagerUrl: `http://127.0.0.1:${port}`,
253+
});
254+
255+
expect(result.via).toBe("data-manager");
256+
expect(result.linked).toBe(7);
257+
expect(result.skipped).toBe(2);
258+
expect(result.pruned).toBe(1);
259+
expect(linkRequests).toHaveLength(1);
260+
expect(linkRequests[0]?.prune).toBe(true);
261+
expect(Array.isArray(linkRequests[0]?.plan)).toBe(true);
262+
} finally {
263+
await new Promise<void>((resolve) => server.close(() => resolve()));
264+
}
265+
});
152266
});

packages/cli/src/commands/compose.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ export function registerComposeCommands(program: Command): void {
134134
});
135135
log.ok(`Rendered ${r.servicesRendered} services → ${r.composePath}`);
136136
for (const warning of r.selectionWarnings) log.warn(warning);
137-
const linked = applyGeneratedHardlinks({ prune: true, requirePlan: true });
137+
const linked = await applyGeneratedHardlinks({ prune: true, requirePlan: true });
138138
log.ok(
139139
`Applied hardlinks: ${linked.linked} linked, ${linked.skipped} already linked, ${linked.pruned} stale file${linked.pruned === 1 ? "" : "s"} pruned`,
140140
);

packages/cli/src/commands/data.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ async function runStyleDownload(client: services.DataManagerClient): Promise<voi
265265
async function renderAndApplyHardlinks(): Promise<void> {
266266
const rendered = await renderComposeForRepo({ domain: process.env.DOMAIN ?? "localhost" });
267267
for (const warning of rendered.selectionWarnings) log.warn(warning);
268-
const linked = applyGeneratedHardlinks({ prune: true, requirePlan: true });
268+
const linked = await applyGeneratedHardlinks({ prune: true, requirePlan: true });
269269
log.ok(
270270
`Applied hardlinks: ${linked.linked} linked, ${linked.skipped} already linked, ${linked.pruned} stale file${linked.pruned === 1 ? "" : "s"} pruned`,
271271
);
@@ -440,7 +440,7 @@ export function registerDataCommands(program: Command): void {
440440
// dirs the operator no longer wants.
441441
const rendered = await renderComposeForRepo({ domain: process.env.DOMAIN ?? "localhost" });
442442
for (const warning of rendered.selectionWarnings) log.warn(warning);
443-
const result = applyGeneratedHardlinks({ prune: true, requirePlan: true });
443+
const result = await applyGeneratedHardlinks({ prune: true, requirePlan: true });
444444
log.ok(
445445
`Linked ${result.linked} files (${result.skipped} already linked, ${result.pruned} stale file${result.pruned === 1 ? "" : "s"} pruned)`,
446446
);

packages/cli/src/commands/services.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ export function registerServicesCommands(program: Command): void {
292292
domain: process.env.DOMAIN ?? "localhost",
293293
});
294294
for (const warning of rendered.selectionWarnings) log.warn(warning);
295-
const linked = applyGeneratedHardlinks({ prune: true, requirePlan: true });
295+
const linked = await applyGeneratedHardlinks({ prune: true, requirePlan: true });
296296
log.ok(
297297
`Applied hardlinks: ${linked.linked} linked, ${linked.skipped} already linked, ${linked.pruned} stale file${linked.pruned === 1 ? "" : "s"} pruned`,
298298
);
@@ -330,7 +330,7 @@ export function registerServicesCommands(program: Command): void {
330330
domain: process.env.DOMAIN ?? "localhost",
331331
});
332332
for (const warning of rendered.selectionWarnings) log.warn(warning);
333-
const linked = applyGeneratedHardlinks({ prune: true, requirePlan: true });
333+
const linked = await applyGeneratedHardlinks({ prune: true, requirePlan: true });
334334
log.ok(
335335
`Applied hardlinks: ${linked.linked} linked, ${linked.skipped} already linked, ${linked.pruned} stale file${linked.pruned === 1 ? "" : "s"} pruned`,
336336
);
@@ -368,7 +368,7 @@ export function registerServicesCommands(program: Command): void {
368368
services: allIds,
369369
});
370370
for (const warning of rendered.selectionWarnings) log.warn(warning);
371-
const linked = applyGeneratedHardlinks({ prune: true, requirePlan: true });
371+
const linked = await applyGeneratedHardlinks({ prune: true, requirePlan: true });
372372
log.ok(
373373
`Applied hardlinks: ${linked.linked} linked, ${linked.skipped} already linked, ${linked.pruned} stale file${linked.pruned === 1 ? "" : "s"} pruned`,
374374
);
@@ -428,7 +428,7 @@ export function registerServicesCommands(program: Command): void {
428428
services: allIds,
429429
});
430430
for (const warning of rendered.selectionWarnings) log.warn(warning);
431-
const linked = applyGeneratedHardlinks({ prune: true, requirePlan: true });
431+
const linked = await applyGeneratedHardlinks({ prune: true, requirePlan: true });
432432
log.ok(
433433
`Applied hardlinks: ${linked.linked} linked, ${linked.skipped} already linked, ${linked.pruned} stale file${linked.pruned === 1 ? "" : "s"} pruned`,
434434
);

packages/cli/src/lib/hardlinks.ts

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
1-
// CLI-side hardlink apply. The heavy lifting lives in `@openmapx/hardlinks`
2-
// so the data-manager service and this host-side command use the same prune
3-
// semantics. See that package for the sentinel-tracked prune model.
1+
// CLI-side hardlink apply. Two paths:
2+
//
3+
// 1. Preferred — POST the plan to the data-manager's `/link` endpoint.
4+
// The data-manager runs inside its container as the same UID that
5+
// writes /data, so it can hardlink across files the host CLI user
6+
// doesn't own. With Linux's `fs.protected_hardlinks=1` (the default
7+
// on most distros) the host-side `linkSync` returns EPERM the moment
8+
// a producer container has touched the source file, even if the file
9+
// is world-readable.
10+
//
11+
// 2. Fallback — if the data-manager isn't reachable (typical on the very
12+
// first `compose up` before any container has started), do the link
13+
// with `linkSync` directly. The plan is usually empty in that
14+
// situation because no data has been produced yet.
15+
//
16+
// `@openmapx/hardlinks` provides the shared low-level planner used by both
17+
// the data-manager service (`POST /link` inside the container) and the
18+
// fallback path here, so the prune semantics stay identical regardless of
19+
// which side actually creates the links.
420

521
import { existsSync, mkdirSync, readFileSync } from "node:fs";
622
import { join } from "node:path";
23+
import { services as coreServices } from "@openmapx/core/server";
724
import {
825
type ApplyHardlinkResult,
926
applyHardlinkPlan,
1027
type HardlinkEntry,
1128
} from "@openmapx/hardlinks";
1229
import { repoPaths } from "./paths";
1330

31+
const { DataManagerClient } = coreServices;
32+
1433
export type { ApplyHardlinkResult, HardlinkEntry };
1534
export { applyHardlinkPlan };
1635

@@ -22,12 +41,33 @@ export interface ApplyGeneratedHardlinkOptions {
2241
* Otherwise, returns `applied: false`.
2342
*/
2443
requirePlan?: boolean;
44+
/**
45+
* Override the data-manager URL. Defaults to `DATA_MANAGER_URL` from the
46+
* environment, falling back to `http://localhost:4000`.
47+
*/
48+
dataManagerUrl?: string;
49+
/**
50+
* Hard-skip the data-manager API path. Useful in tests + situations where
51+
* the operator knows the API isn't reachable and wants to avoid the
52+
* connect-attempt latency. Defaults to false.
53+
*/
54+
forceLocal?: boolean;
55+
/**
56+
* Connect-attempt timeout for the data-manager reachability probe (ms).
57+
* Defaults to 1500.
58+
*/
59+
reachabilityTimeoutMs?: number;
2560
}
2661

2762
export interface ApplyGeneratedHardlinkResult extends ApplyHardlinkResult {
2863
applied: boolean;
2964
entries: number;
3065
planPath: string;
66+
/**
67+
* `"data-manager"` when the API path succeeded, `"local"` when the host
68+
* `linkSync` fallback was used, `"none"` when there was nothing to do.
69+
*/
70+
via: "data-manager" | "local" | "none";
3171
}
3272

3373
const HARDLINK_PLAN_FILENAME = "docker-compose.generated.hardlinks.json";
@@ -50,22 +90,79 @@ export function readGeneratedHardlinkPlan(rootDir?: string): {
5090
};
5191
}
5292

53-
export function applyGeneratedHardlinks(
93+
/**
94+
* Quick reachability probe. The call is intentionally cheap so the failure
95+
* path of "data-manager isn't running yet" doesn't add seconds to every CLI
96+
* invocation. Treat any connection-level error as "not reachable" and let
97+
* the caller fall back; bubble up actual HTTP failures so a misconfigured
98+
* data-manager doesn't silently skip the API path.
99+
*/
100+
async function dataManagerReachable(baseUrl: string, timeoutMs: number): Promise<boolean> {
101+
const controller = new AbortController();
102+
const timer = setTimeout(() => controller.abort(), timeoutMs);
103+
try {
104+
const res = await fetch(`${baseUrl.replace(/\/+$/, "")}/status`, {
105+
signal: controller.signal,
106+
});
107+
return res.ok;
108+
} catch {
109+
return false;
110+
} finally {
111+
clearTimeout(timer);
112+
}
113+
}
114+
115+
export async function applyGeneratedHardlinks(
54116
opts: ApplyGeneratedHardlinkOptions = {},
55-
): ApplyGeneratedHardlinkResult {
117+
): Promise<ApplyGeneratedHardlinkResult> {
56118
const paths = repoPaths(opts.rootDir);
57119
const planPath = join(paths.infraDir, HARDLINK_PLAN_FILENAME);
58120
if (!existsSync(planPath)) {
59121
if (opts.requirePlan) {
60122
throw new Error(`Hardlink plan not found at ${planPath}`);
61123
}
62-
return { applied: false, entries: 0, planPath, linked: 0, skipped: 0, pruned: 0 };
124+
return {
125+
applied: false,
126+
entries: 0,
127+
planPath,
128+
linked: 0,
129+
skipped: 0,
130+
pruned: 0,
131+
via: "none",
132+
};
63133
}
64134

65135
const { plan, dataRoot } = readGeneratedHardlinkPlan(opts.rootDir);
66136
// Pre-create the host data root as the invoking user so docker doesn't
67137
// auto-create it as root on first compose up.
68138
mkdirSync(dataRoot, { recursive: true });
139+
140+
if (!opts.forceLocal) {
141+
const baseUrl = opts.dataManagerUrl ?? process.env.DATA_MANAGER_URL ?? "http://localhost:4000";
142+
const timeout = opts.reachabilityTimeoutMs ?? 1500;
143+
if (await dataManagerReachable(baseUrl, timeout)) {
144+
const client = new DataManagerClient({ baseUrl });
145+
const result = await client.link(plan, { prune: opts.prune });
146+
return {
147+
applied: true,
148+
entries: plan.length,
149+
planPath,
150+
linked: result.linked,
151+
skipped: result.skipped,
152+
pruned: result.pruned,
153+
via: "data-manager",
154+
};
155+
}
156+
}
157+
69158
const result = applyHardlinkPlan(plan, { rootDir: dataRoot, prune: opts.prune });
70-
return { applied: true, entries: plan.length, planPath, ...result };
159+
return {
160+
applied: true,
161+
entries: plan.length,
162+
planPath,
163+
linked: result.linked,
164+
skipped: result.skipped,
165+
pruned: result.pruned,
166+
via: "local",
167+
};
71168
}

0 commit comments

Comments
 (0)