Skip to content

Commit 7715d07

Browse files
committed
fix: preserve MOTIS staging permissions
1 parent 34bf688 commit 7715d07

2 files changed

Lines changed: 39 additions & 3 deletions

File tree

services/data-manager/__tests__/transitous/promote.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import {
2+
chmodSync,
23
cpSync,
34
existsSync,
45
mkdirSync,
56
mkdtempSync,
67
readdirSync,
78
readFileSync,
89
rmSync,
10+
statSync,
911
writeFileSync,
1012
} from "node:fs";
1113
import { tmpdir } from "node:os";
@@ -266,6 +268,25 @@ describe("promote stage", () => {
266268
expect(artifacts.previousDir).toBe(fx.previousDir);
267269
});
268270

271+
it("preserves staging directory permissions for the next import", async () => {
272+
const fx = setupFixture({ staging: true, current: true });
273+
chmodSync(fx.stagingDir, 0o777);
274+
globalThis.fetch = vi.fn(async (input: unknown) => {
275+
const url = typeof input === "string" ? input : (input as Request | URL).toString();
276+
return jsonResponse(successfulBody(url));
277+
}) as unknown as typeof fetch;
278+
279+
const result = await promoteRun(
280+
makeCtx({
281+
dataDir: fx.dataDir,
282+
runner: async () => {},
283+
}),
284+
);
285+
286+
expect(result.status).toBe("ok");
287+
expect(statSync(fx.stagingDir).mode & 0o777).toBe(0o777);
288+
});
289+
269290
it("rejects a candidate whose epoch duplicates the active dataset", async () => {
270291
const fx = setupFixture({ staging: true });
271292
cpSync(fx.stagingDir, fx.currentDir, { recursive: true });

services/data-manager/src/jobs/transitous/promote.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
1+
import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
22
import { join } from "node:path";
33
import {
44
CANDIDATE_MANIFEST_FILENAME,
@@ -355,7 +355,8 @@ async function promoteTwoSlot(
355355
* 3. `docker stop motis`, then rename `data/motis/live` →
356356
* `data/motis/live.previous` and `data/motis/staging` →
357357
* `data/motis/live` (so the rename never happens under a running mount).
358-
* Recreate an empty staging dir so the next pipeline run has a clean target.
358+
* Recreate an empty staging dir with the same permissions so the next
359+
* pipeline run has a clean, writable target.
359360
* 4. `docker restart motis` and poll `/api/v1/map/initial` until it responds
360361
* (5-minute budget).
361362
*
@@ -471,6 +472,17 @@ export const run: StageFn = async (ctx) => {
471472
}
472473

473474
// Second rename: staging → current.
475+
// Preserve the staging root's access mode across the rename. The next
476+
// import writes MOTIS's compiled data into a newly-created staging dir;
477+
// mkdir's default mode would otherwise apply the process umask and can
478+
// silently turn a deliberately group/world-writable bind dir into 0755.
479+
let stagingMode = 0o755;
480+
try {
481+
stagingMode = statSync(stagingDir).mode & 0o777;
482+
} catch {
483+
// The rename below will provide the authoritative failure if the path
484+
// disappeared between the pre-flight check and the swap.
485+
}
474486
try {
475487
renameSync(stagingDir, currentDir);
476488
} catch (error) {
@@ -497,7 +509,10 @@ export const run: StageFn = async (ctx) => {
497509

498510
// Recreate an empty staging dir for the next cycle.
499511
try {
500-
mkdirSync(stagingDir, { recursive: true });
512+
mkdirSync(stagingDir, { recursive: true, mode: stagingMode });
513+
// mkdir applies the process umask, so explicitly restore the exact mode
514+
// captured above rather than relying on the requested mode alone.
515+
chmodSync(stagingDir, stagingMode);
501516
} catch {
502517
// Non-fatal: the next pipeline run will recreate it.
503518
}

0 commit comments

Comments
 (0)