diff --git a/packages/installer/src/installer/getInstallerPackageData.ts b/packages/installer/src/installer/getInstallerPackageData.ts index 2a99279805..8e496ca430 100644 --- a/packages/installer/src/installer/getInstallerPackageData.ts +++ b/packages/installer/src/installer/getInstallerPackageData.ts @@ -98,6 +98,9 @@ function getInstallerPackageData( // Persist critical dappmanager env vars and volume paths across updates persistDappmanagerSettings(compose, dnpName, isCore); + // Persist critical core env vars and volume paths across updates + persistCoreSettings(compose, dnpName, isCore); + const dockerTimeout = parseTimeoutSeconds(release.manifest.dockerTimeout); return { @@ -197,6 +200,85 @@ export function persistDappmanagerSettings(compose: ComposeEditor, dnpName: stri } } +/** + * When updating the core package, certain environment variables and volume + * settings from the currently installed dappmanager compose must be propagated. + * This ensures: + * - DISABLE_HOST_SCRIPTS is present in the core compose environment + * - The /usr/src/dappnode/ volume bind mount uses the correct host path from DAPPNODE_CORE_DIR + */ +export function persistCoreSettings(compose: ComposeEditor, dnpName: string, _isCore: boolean): void { + if (dnpName !== params.coreDnpName) return; + + // Read the currently installed dappmanager compose to get env values + let installedDappmanagerCompose: ComposeFileEditor; + try { + installedDappmanagerCompose = new ComposeFileEditor(params.dappmanagerDnpName, true); + } catch (e) { + if (!isNotFoundError(e)) throw e; + logs.info("No installed dappmanager compose found, skipping core settings persistence"); + return; + } + + const DAPPNODE_CONTAINER_PATH = "/usr/src/dappnode"; + + // Collect env values from the installed dappmanager compose services + const installedEnvs: Record = {}; + for (const serviceEditor of Object.values(installedDappmanagerCompose.services())) { + const envs = serviceEditor.getEnvs(); + if (envs["DISABLE_HOST_SCRIPTS"] !== undefined && envs["DISABLE_HOST_SCRIPTS"] !== "") { + installedEnvs["DISABLE_HOST_SCRIPTS"] = envs["DISABLE_HOST_SCRIPTS"]; + } + if (envs["DAPPNODE_CORE_DIR"] !== undefined && envs["DAPPNODE_CORE_DIR"] !== "") { + installedEnvs["DAPPNODE_CORE_DIR"] = envs["DAPPNODE_CORE_DIR"]; + } + } + + if (Object.keys(installedEnvs).length === 0) return; + + logs.info("Persisting core settings from installed dappmanager compose", installedEnvs); + + // Apply persisted envs and volume mapping to new core compose services + for (const serviceEditor of Object.values(compose.services())) { + // Merge DISABLE_HOST_SCRIPTS into the core compose + if (installedEnvs["DISABLE_HOST_SCRIPTS"]) { + serviceEditor.mergeEnvs({ DISABLE_HOST_SCRIPTS: installedEnvs["DISABLE_HOST_SCRIPTS"] }); + } + + // Remove /etc:/etc volume when DISABLE_HOST_SCRIPTS is enabled. + // This bind mount is not needed when host scripts are disabled and causes + // Docker failures on non-Linux platforms (e.g., macOS) because Docker cannot + // create its internal mountpoints (/etc/hostname, /etc/hosts) inside a bind-mounted /etc + if (installedEnvs["DISABLE_HOST_SCRIPTS"] === "true") { + const service = serviceEditor.get(); + if (service.volumes) { + const volumeMappings = parseVolumeMappings(service.volumes); + const filteredVolumes = volumeMappings.filter((vol) => vol.container !== "/etc"); + if (filteredVolumes.length !== volumeMappings.length) { + service.volumes = stringifyVolumeMappings(filteredVolumes); + } + } + } + + // Update the /usr/src/dappnode/ volume host path to match DAPPNODE_CORE_DIR + if (installedEnvs["DAPPNODE_CORE_DIR"]) { + const dappnodeHostDir = installedEnvs["DAPPNODE_CORE_DIR"]; + const service = serviceEditor.get(); + if (service.volumes) { + const volumeMappings = parseVolumeMappings(service.volumes); + const updatedVolumes = volumeMappings.map((vol) => { + // Match the volume whose container side is /usr/src/dappnode/ + if (vol.container === DAPPNODE_CONTAINER_PATH) { + return { ...vol, host: dappnodeHostDir, name: undefined }; + } + return vol; + }); + service.volumes = stringifyVolumeMappings(updatedVolumes); + } + } + } +} + /** * Migrates the user settings from the old service name to the new service name * diff --git a/packages/installer/test/unit/installer/persistCoreSettings.test.ts b/packages/installer/test/unit/installer/persistCoreSettings.test.ts new file mode 100644 index 0000000000..1bbac581f5 --- /dev/null +++ b/packages/installer/test/unit/installer/persistCoreSettings.test.ts @@ -0,0 +1,340 @@ +import "mocha"; +import { expect } from "chai"; +import fs from "fs"; +import path from "path"; +import { ComposeEditor } from "@dappnode/dockercompose"; +import { Compose } from "@dappnode/types"; +import { yamlDump, getDockerComposePath, parseEnvironment } from "@dappnode/utils"; +import { params } from "@dappnode/params"; +import { persistCoreSettings } from "../../../src/installer/getInstallerPackageData.js"; + +const coreDnpName = params.coreDnpName; +const dappmanagerDnpName = params.dappmanagerDnpName; +const isCore = true; + +/** + * Helper to write the dappmanager installed compose (source of env values) + */ +function writeDappmanagerCompose(compose: Compose): string { + const composePath = getDockerComposePath(dappmanagerDnpName, isCore); + const dir = path.dirname(composePath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(composePath, yamlDump(compose)); + return composePath; +} + +/** + * Cleanup the dappmanager installed compose file + */ +function removeDappmanagerCompose(): void { + const composePath = getDockerComposePath(dappmanagerDnpName, isCore); + if (fs.existsSync(composePath)) fs.unlinkSync(composePath); +} + +/** + * Build a minimal dappmanager compose + */ +function buildDappmanagerCompose(overrides?: { environment?: Record | string[] }): Compose { + return { + version: "3.5", + services: { + "dappmanager.dnp.dappnode.eth": { + image: "dappmanager.dnp.dappnode.eth:0.2.71", + container_name: "DAppNodeCore-dappmanager.dnp.dappnode.eth", + volumes: ["/usr/src/dappnode/DNCORE:/usr/src/app/DNCORE"], + environment: overrides?.environment ?? { LOG_LEVEL: "info" } + } + }, + networks: { + dncore_network: { external: true } + } + }; +} + +/** + * Build a minimal core compose + */ +function buildCoreCompose(overrides?: { + environment?: Record | string[]; + volumes?: string[]; +}): Compose { + return { + version: "3.5", + services: { + "core.dnp.dappnode.eth": { + image: "core.dnp.dappnode.eth:0.2.71", + container_name: "DAppNodeCore-core.dnp.dappnode.eth", + volumes: overrides?.volumes ?? [ + "/usr/src/dappnode/:/usr/src/dappnode/", + "/var/run/docker.sock:/var/run/docker.sock" + ], + environment: overrides?.environment ?? { + LOG_LEVEL: "info" + } + } + }, + networks: { + dncore_network: { external: true } + } + }; +} + +describe("persistCoreSettings", () => { + afterEach(() => { + removeDappmanagerCompose(); + }); + + it("Should be a no-op for non-core packages", () => { + const compose = new ComposeEditor(buildCoreCompose(), { dnpName: coreDnpName }); + const before = JSON.stringify(compose.compose); + + persistCoreSettings(compose, "other.dnp.dappnode.eth", isCore); + + expect(JSON.stringify(compose.compose)).to.equal(before); + }); + + it("Should be a no-op when no installed dappmanager compose exists", () => { + removeDappmanagerCompose(); + + const newCompose = buildCoreCompose(); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + const before = JSON.stringify(compose.compose); + + persistCoreSettings(compose, coreDnpName, isCore); + + expect(JSON.stringify(compose.compose)).to.equal(before); + }); + + it("Should persist DISABLE_HOST_SCRIPTS from dappmanager compose to core compose", () => { + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + LOG_LEVEL: "info", + DISABLE_HOST_SCRIPTS: "true" + } + }) + ); + + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "info" } + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + const envs = parseEnvironment(service.environment || []); + expect(envs["DISABLE_HOST_SCRIPTS"]).to.equal("true"); + expect(envs["LOG_LEVEL"]).to.equal("info"); + }); + + it("Should update /usr/src/dappnode/ volume host path to match DAPPNODE_CORE_DIR", () => { + const customDir = "/custom/path/dappnode/"; + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + DAPPNODE_CORE_DIR: customDir + } + }) + ); + + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "info" }, + volumes: ["/usr/src/dappnode/:/usr/src/dappnode/", "/var/run/docker.sock:/var/run/docker.sock"] + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + // host path keeps trailing slash from env, container path is normalized + expect(service.volumes).to.include(`/custom/path/dappnode/:/usr/src/dappnode`); + expect(service.volumes).to.include("/var/run/docker.sock:/var/run/docker.sock"); + }); + + it("Should persist both DISABLE_HOST_SCRIPTS and update volume path", () => { + const customDir = "/custom/path/dappnode/"; + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + LOG_LEVEL: "info", + DISABLE_HOST_SCRIPTS: "true", + DAPPNODE_CORE_DIR: customDir + } + }) + ); + + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "debug" }, + volumes: ["/usr/src/dappnode/:/usr/src/dappnode/", "/var/run/docker.sock:/var/run/docker.sock"] + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + const envs = parseEnvironment(service.environment || []); + expect(envs["DISABLE_HOST_SCRIPTS"]).to.equal("true"); + expect(envs["LOG_LEVEL"]).to.equal("debug"); + // host path keeps trailing slash from env, container path is normalized + expect(service.volumes).to.include("/custom/path/dappnode/:/usr/src/dappnode"); + }); + + it("Should not modify volumes when DAPPNODE_CORE_DIR is not set", () => { + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + DISABLE_HOST_SCRIPTS: "true" + } + }) + ); + + const volumes = ["/usr/src/dappnode/:/usr/src/dappnode/", "/var/run/docker.sock:/var/run/docker.sock"]; + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "info" }, + volumes + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + expect(service.volumes).to.include("/usr/src/dappnode/:/usr/src/dappnode/"); + }); + + it("Should be a no-op when dappmanager compose has no relevant envs", () => { + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + LOG_LEVEL: "info", + SOME_OTHER_VAR: "value" + } + }) + ); + + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "debug" } + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + const envsBefore = parseEnvironment(compose.compose.services["core.dnp.dappnode.eth"].environment || []); + + persistCoreSettings(compose, coreDnpName, isCore); + + const envsAfter = parseEnvironment(compose.compose.services["core.dnp.dappnode.eth"].environment || []); + expect(envsAfter).to.deep.equal(envsBefore); + }); + + it("Should handle dappmanager compose with environment as array format", () => { + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: ["LOG_LEVEL=info", "DISABLE_HOST_SCRIPTS=true", "DAPPNODE_CORE_DIR=/custom/dir/"] + }) + ); + + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "info" } + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + const envs = parseEnvironment(service.environment || []); + expect(envs["DISABLE_HOST_SCRIPTS"]).to.equal("true"); + }); + + it("Should not modify volumes if no volume matches the container path", () => { + const customDir = "/custom/path/dappnode/"; + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + DAPPNODE_CORE_DIR: customDir + } + }) + ); + + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "info" }, + volumes: ["/var/run/docker.sock:/var/run/docker.sock"] + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + expect(service.volumes).to.have.lengthOf(1); + expect(service.volumes).to.include("/var/run/docker.sock:/var/run/docker.sock"); + }); + + it("Should add environment section even if core compose has no environment", () => { + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + DISABLE_HOST_SCRIPTS: "true" + } + }) + ); + + // Core compose with no environment + const newCompose: Compose = { + version: "3.5", + services: { + "core.dnp.dappnode.eth": { + image: "core.dnp.dappnode.eth:0.2.71", + container_name: "DAppNodeCore-core.dnp.dappnode.eth", + volumes: ["/usr/src/dappnode/:/usr/src/dappnode/"] + } + } + }; + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + const envs = parseEnvironment(service.environment || []); + expect(envs["DISABLE_HOST_SCRIPTS"]).to.equal("true"); + }); + + it("Should remove /etc:/etc volume when DISABLE_HOST_SCRIPTS is true", () => { + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + DISABLE_HOST_SCRIPTS: "true" + } + }) + ); + + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "info" }, + volumes: ["/etc:/etc", "/usr/src/dappnode/:/usr/src/dappnode/", "/var/run/docker.sock:/var/run/docker.sock"] + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + expect(service.volumes).to.not.include("/etc:/etc"); + expect(service.volumes).to.include("/var/run/docker.sock:/var/run/docker.sock"); + }); + + it("Should keep /etc:/etc volume when DISABLE_HOST_SCRIPTS is not set", () => { + writeDappmanagerCompose( + buildDappmanagerCompose({ + environment: { + DAPPNODE_CORE_DIR: "/custom/path/dappnode/" + } + }) + ); + + const newCompose = buildCoreCompose({ + environment: { LOG_LEVEL: "info" }, + volumes: ["/etc:/etc", "/usr/src/dappnode/:/usr/src/dappnode/", "/var/run/docker.sock:/var/run/docker.sock"] + }); + const compose = new ComposeEditor(newCompose, { dnpName: coreDnpName }); + + persistCoreSettings(compose, coreDnpName, isCore); + + const service = compose.compose.services["core.dnp.dappnode.eth"]; + expect(service.volumes).to.include("/etc:/etc"); + }); +});