Skip to content

Commit 5d0602f

Browse files
emanuelbesliuCopilot
authored andcommitted
fix(config): skeleton fallback for read-only config dir
Homepage calls process.exit(1) when it cannot copy a default config into a read-only config dir (e.g. subPath ConfigMap mounts), causing CrashLoop. It now warns and continues, and readers use a new getConfigPath() that falls back to the bundled skeleton. Refs #2172 Signed-off-by: Emanuel Besliu <32497562+emanuelbesliu@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 56e76cd commit 5d0602f

16 files changed

Lines changed: 73 additions & 35 deletions

src/__tests__/pages/api/hash.test.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ function sha256(input) {
77
return createHash("sha256").update(input).digest("hex");
88
}
99

10-
const { readFileSync, checkAndCopyConfig, CONF_DIR } = vi.hoisted(() => ({
10+
const { readFileSync, checkAndCopyConfig, CONF_DIR, getConfigPath } = vi.hoisted(() => ({
1111
readFileSync: vi.fn(),
1212
checkAndCopyConfig: vi.fn(),
1313
CONF_DIR: "/conf",
14+
getConfigPath: vi.fn((c) => `/conf/${c}`),
1415
}));
1516

1617
vi.mock("fs", () => ({
@@ -20,6 +21,7 @@ vi.mock("fs", () => ({
2021
vi.mock("utils/config/config", () => ({
2122
default: checkAndCopyConfig,
2223
CONF_DIR,
24+
getConfigPath,
2325
}));
2426

2527
import handler from "pages/api/hash";

src/pages/api/hash.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { createHash } from "crypto";
22
import { readFileSync } from "fs";
3-
import { join } from "path";
43

5-
import checkAndCopyConfig, { CONF_DIR } from "utils/config/config";
4+
import checkAndCopyConfig, { getConfigPath } from "utils/config/config";
65

76
const configs = [
87
"docker.yaml",
@@ -23,7 +22,7 @@ function hash(buffer) {
2322
export default async function handler(req, res) {
2423
const hashes = configs.map((config) => {
2524
checkAndCopyConfig(config);
26-
const configYaml = join(CONF_DIR, config);
25+
const configYaml = getConfigPath(config);
2726
return hash(readFileSync(configYaml, "utf8"));
2827
});
2928

src/utils/config/api-response.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { promises as fs } from "fs";
2-
import path from "path";
32

43
import yaml from "js-yaml";
54

6-
import checkAndCopyConfig, { CONF_DIR, getSettings, substituteEnvironmentVars } from "utils/config/config";
5+
import checkAndCopyConfig, { getConfigPath, getSettings, substituteEnvironmentVars } from "utils/config/config";
76
import {
87
cleanServiceGroups,
98
findGroupByName,
@@ -27,7 +26,7 @@ function compareServices(service1, service2) {
2726
export async function bookmarksResponse() {
2827
checkAndCopyConfig("bookmarks.yaml");
2928

30-
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
29+
const bookmarksYaml = getConfigPath("bookmarks.yaml");
3130
const rawFileContents = await fs.readFile(bookmarksYaml, "utf8");
3231
const fileContents = substituteEnvironmentVars(rawFileContents);
3332
const bookmarks = yaml.load(fileContents);

src/utils/config/api-response.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const { fs, yaml, config, widgetHelpers, serviceHelpers } = vi.hoisted(() => ({
99
},
1010
config: {
1111
CONF_DIR: "/conf",
12+
getConfigPath: vi.fn((c) => `/conf/${c}`),
1213
getSettings: vi.fn(),
1314
substituteEnvironmentVars: vi.fn((s) => s),
1415
default: vi.fn(),

src/utils/config/config.check-copy.test.js

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,25 @@ describe("utils/config/config checkAndCopyConfig", () => {
5353
infoSpy.mockRestore();
5454
});
5555

56-
it("exits the process when copying the skeleton fails", async () => {
56+
it("warns and continues when copying the skeleton fails (read-only config dir)", async () => {
5757
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
5858
throw new Error("exit");
5959
});
60-
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
60+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
6161

6262
fs.existsSync.mockReturnValueOnce(true);
6363
fs.existsSync.mockReturnValueOnce(false);
6464
fs.copyFileSync.mockImplementationOnce(() => {
65-
throw new Error("copy failed");
65+
throw Object.assign(new Error("read-only file system"), { code: "EROFS" });
6666
});
6767

6868
const mod = await import("./config");
69-
expect(() => mod.default("services.yaml")).toThrow("exit");
70-
expect(exitSpy).toHaveBeenCalledWith(1);
71-
expect(errSpy).toHaveBeenCalled();
69+
expect(mod.default("services.yaml")).toBe(true);
70+
expect(exitSpy).not.toHaveBeenCalled();
71+
expect(warnSpy).toHaveBeenCalled();
7272

7373
exitSpy.mockRestore();
74-
errSpy.mockRestore();
74+
warnSpy.mockRestore();
7575
});
7676

7777
it("returns a parse error with config name when YAML is invalid", async () => {
@@ -88,3 +88,23 @@ describe("utils/config/config checkAndCopyConfig", () => {
8888
expect(result).toEqual(expect.objectContaining({ name: "YAMLException", config: "services.yaml" }));
8989
});
9090
});
91+
92+
describe("utils/config/config getConfigPath", () => {
93+
beforeEach(() => {
94+
vi.clearAllMocks();
95+
vi.resetModules();
96+
process.env = { ...process.env, HOMEPAGE_CONFIG_DIR: "/conf" };
97+
});
98+
99+
it("returns the config-dir path when the file exists there", async () => {
100+
fs.existsSync.mockReturnValueOnce(true);
101+
const mod = await import("./config");
102+
expect(mod.getConfigPath("services.yaml")).toBe("/conf/services.yaml");
103+
});
104+
105+
it("falls back to the bundled skeleton when the file is missing", async () => {
106+
fs.existsSync.mockReturnValueOnce(false);
107+
const mod = await import("./config");
108+
expect(mod.getConfigPath("services.yaml")).toBe(mod.SKELETON_DIR + "/services.yaml");
109+
});
110+
});

src/utils/config/config.js

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,18 @@ export const CONF_DIR = process.env.HOMEPAGE_CONFIG_DIR
1212
? process.env.HOMEPAGE_CONFIG_DIR
1313
: join(process.cwd(), "config");
1414

15+
export const SKELETON_DIR = join(process.cwd(), "src", "skeleton");
16+
17+
// Prefer the user's config file, falling back to the bundled skeleton when it
18+
// is absent (e.g. a read-only config dir from subPath ConfigMap mounts). #2172
19+
export function getConfigPath(config) {
20+
const configYaml = join(CONF_DIR, config);
21+
if (existsSync(configYaml)) {
22+
return configYaml;
23+
}
24+
return join(SKELETON_DIR, config);
25+
}
26+
1527
export default function checkAndCopyConfig(config) {
1628
// Ensure config directory exists
1729
if (!existsSync(CONF_DIR)) {
@@ -27,15 +39,19 @@ export default function checkAndCopyConfig(config) {
2739

2840
// If the config file doesn't exist, try to copy the skeleton
2941
if (!existsSync(configYaml)) {
30-
const configSkeleton = join(process.cwd(), "src", "skeleton", config);
42+
const configSkeleton = join(SKELETON_DIR, config);
3143
try {
3244
copyFileSync(configSkeleton, configYaml);
3345
console.info("%s was copied to the config folder", config);
3446
} catch (err) {
35-
console.error("❌ Failed to initialize required config: %s", configYaml);
36-
console.error("Reason: %s", err.message);
37-
console.error("Hint: Make /app/config writable or manually place the config file.");
38-
process.exit(1);
47+
// Config dir may be read-only (e.g. subPath ConfigMap mounts); fall back
48+
// to the bundled skeleton via getConfigPath instead of crashing. #2172
49+
console.warn(
50+
"Could not copy default %s into %s (%s); falling back to the bundled skeleton.",
51+
config,
52+
CONF_DIR,
53+
err.code || err.message,
54+
);
3955
}
4056

4157
return true;
@@ -82,7 +98,7 @@ export function substituteEnvironmentVars(str) {
8298
export function getSettings() {
8399
checkAndCopyConfig("settings.yaml");
84100

85-
const settingsYaml = join(CONF_DIR, "settings.yaml");
101+
const settingsYaml = getConfigPath("settings.yaml");
86102
const rawFileContents = readFileSync(settingsYaml, "utf8");
87103
const fileContents = substituteEnvironmentVars(rawFileContents);
88104
const initialSettings = yaml.load(fileContents) ?? {};

src/utils/config/docker.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import path from "path";
33

44
import yaml from "js-yaml";
55

6-
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
6+
import checkAndCopyConfig, { CONF_DIR, getConfigPath, substituteEnvironmentVars } from "utils/config/config";
77

88
export function getDefaultDockerArgs(platform = process.platform) {
99
if (platform !== "win32" && platform !== "darwin") {
@@ -16,7 +16,7 @@ export function getDefaultDockerArgs(platform = process.platform) {
1616
export default function getDockerArguments(server) {
1717
checkAndCopyConfig("docker.yaml");
1818

19-
const configFile = path.join(CONF_DIR, "docker.yaml");
19+
const configFile = getConfigPath("docker.yaml");
2020
const rawConfigData = readFileSync(configFile, "utf8");
2121
const configData = substituteEnvironmentVars(rawConfigData);
2222
const servers = yaml.load(configData);

src/utils/config/docker.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const { fs, yaml, config, checkAndCopyConfig } = vi.hoisted(() => ({
1212
},
1313
config: {
1414
CONF_DIR: "/conf",
15+
getConfigPath: vi.fn((c) => `/conf/${c}`),
1516
substituteEnvironmentVars: vi.fn((s) => s),
1617
},
1718
checkAndCopyConfig: vi.fn(),

src/utils/config/kubernetes.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { readFileSync } from "fs";
2-
import path from "path";
32

43
import { ApiextensionsV1Api, KubeConfig } from "@kubernetes/client-node";
54
import yaml from "js-yaml";
65

7-
import checkAndCopyConfig, { CONF_DIR, substituteEnvironmentVars } from "utils/config/config";
6+
import checkAndCopyConfig, { getConfigPath, substituteEnvironmentVars } from "utils/config/config";
87

98
export function getKubernetes() {
109
checkAndCopyConfig("kubernetes.yaml");
11-
const configFile = path.join(CONF_DIR, "kubernetes.yaml");
10+
const configFile = getConfigPath("kubernetes.yaml");
1211
const rawConfigData = readFileSync(configFile, "utf8");
1312
const configData = substituteEnvironmentVars(rawConfigData);
1413
return yaml.load(configData);

src/utils/config/kubernetes.test.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const { fs, yaml, config, checkAndCopyConfig, kube, apiExt } = vi.hoisted(() =>
2020
},
2121
config: {
2222
CONF_DIR: "/conf",
23+
getConfigPath: vi.fn((c) => `/conf/${c}`),
2324
substituteEnvironmentVars: vi.fn((s) => s),
2425
},
2526
checkAndCopyConfig: vi.fn(),

0 commit comments

Comments
 (0)