Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/installer/src/installer/getInstallerPackageData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -197,6 +200,70 @@ 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<string, string> = {};
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"] });
}

// 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
*
Expand Down
297 changes: 297 additions & 0 deletions packages/installer/test/unit/installer/persistCoreSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
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, string> | 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, string> | 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");
});
});
Loading