diff --git a/packages/installer/src/dappGet/aggregate/aggregateDependencies.ts b/packages/installer/src/dappGet/aggregate/aggregateDependencies.ts index dfb498143..c362b5a2c 100644 --- a/packages/installer/src/dappGet/aggregate/aggregateDependencies.ts +++ b/packages/installer/src/dappGet/aggregate/aggregateDependencies.ts @@ -4,6 +4,8 @@ import { sanitizeDependencies } from "../utils/sanitizeDependencies.js"; import { DappGetDnps } from "../types.js"; import { DappGetFetcher } from "../fetch/index.js"; import { DappnodeInstaller } from "../../dappnodeInstaller.js"; +import { filterSatisfiedDependencies } from "../utils/filterSatisfiedDependencies.js"; +import { parseSemverRangeToVersion } from "../utils/parseSemverRangeToVersion.js"; /** * The goal of this function is to recursively aggregate all dependencies @@ -51,11 +53,17 @@ export default async function aggregateDependencies({ .versions(dappnodeInstaller, name, versionRange) .then(sanitizeVersions); + console.log(`Versions matching the request for ${name}: ${versions}`); + await Promise.all( versions.map(async (version) => { + + // Here we know the exact versions of the requested package + // Already checked, skip. Otherwise lock request to prevent duplicate fetches if (hasVersion(dnps, name, version)) return; else setVersion(dnps, name, version, {}); + // 2. Get dependencies of this specific version // dependencies = { dnp-name-1: "semverRange", dnp-name-2: "/ipfs/Qmf53..."} const dependencies = await dappGetFetcher @@ -66,15 +74,19 @@ export default async function aggregateDependencies({ throw e; }); - // 3. Store dependencies - setVersion(dnps, name, version, dependencies); + const { nonSatisfiedDeps } = await filterSatisfiedDependencies(dependencies); + const nonSatisfiedDepsVersions = await parseSemverRangeToVersion(nonSatisfiedDeps, dappnodeInstaller); + + // 3. Store the dependencies to satisfy in the dnps object + setVersion(dnps, name, version, nonSatisfiedDepsVersions); + // 4. Fetch sub-dependencies recursively await Promise.all( - Object.keys(dependencies).map(async (dependencyName) => { + Object.keys(nonSatisfiedDepsVersions).map(async (dependencyName) => { await aggregateDependencies({ dappnodeInstaller, name: dependencyName, - versionRange: dependencies[dependencyName], + versionRange: nonSatisfiedDepsVersions[dependencyName], dnps, recursiveCount, dappGetFetcher, diff --git a/packages/installer/src/dappGet/basic.ts b/packages/installer/src/dappGet/basic.ts index f1e70c28d..bbb86b4dc 100644 --- a/packages/installer/src/dappGet/basic.ts +++ b/packages/installer/src/dappGet/basic.ts @@ -6,6 +6,9 @@ import { logs } from "@dappnode/logger"; import { DappGetResult, DappGetState } from "./types.js"; import { DappGetFetcher } from "./fetch/index.js"; import { DappnodeInstaller } from "../dappnodeInstaller.js"; +import { filterSatisfiedDependencies } from "./utils/filterSatisfiedDependencies.js"; +import { parseSemverRangeToVersion } from "./utils/parseSemverRangeToVersion.js"; +import { get } from "http"; /** * Simple version of `dappGet`, since its resolver may cause errors. @@ -19,46 +22,42 @@ export default async function dappGetBasic( req: PackageRequest ): Promise { const dappGetFetcher = new DappGetFetcher(); + const dependencies = await dappGetFetcher.dependencies( dappnodeInstaller, req.name, - req.ver + req.ver, ); + const { satisfiedDeps, nonSatisfiedDeps } = await filterSatisfiedDependencies(dependencies); + + const versionsToInstall = await parseSemverRangeToVersion(nonSatisfiedDeps, dappnodeInstaller); + + console.log("dappGet basic resolved first level dependencies", JSON.stringify(dependencies)); + // Append dependencies in the list of DNPs to install // Add current request to pacakages to install const state = { - ...dependencies, + ...versionsToInstall, [req.name]: req.ver, }; - const alreadyUpdated: DappGetState = {}; - const currentVersions: DappGetState = {}; - - // The function below does not directly affect funcionality. - // However it would prevent already installed DNPs from installing - try { - const installedDnps = await listPackages(); - for (const dnp of installedDnps) { - const prevVersion = dnp.version; - const nextVersion = state[dnp.dnpName]; - if (nextVersion && !shouldUpdate(prevVersion, nextVersion)) { - // DNP is already updated. - // Remove from the success object and add it to the alreadyUpdatedd - alreadyUpdated[dnp.dnpName] = state[dnp.dnpName]; - delete state[dnp.dnpName]; - } - if (nextVersion) { - currentVersions[dnp.dnpName] = prevVersion; - } - } - } catch (e) { - logs.error("Error listing current containers", e); - } return { message: "dappGet basic resolved first level dependencies", state, - alreadyUpdated: {}, - currentVersions, + alreadyUpdated: satisfiedDeps, + currentVersions: await getCurrentVersions(), }; } + +async function getCurrentVersions() { + const dnpList = await listPackages(); + + const currentVersions: DappGetState = {}; + + dnpList.forEach((dnp) => { + currentVersions[dnp.dnpName] = dnp.version; + }); + + return currentVersions; +} \ No newline at end of file diff --git a/packages/installer/src/dappGet/dappGet.ts b/packages/installer/src/dappGet/dappGet.ts index 9583a92b2..d2cb7e5ce 100644 --- a/packages/installer/src/dappGet/dappGet.ts +++ b/packages/installer/src/dappGet/dappGet.ts @@ -65,6 +65,7 @@ export async function dappGet( * It will not use the fetch or resolver module and only * fetch the first level dependencies of the request */ + // TODO: Add catch here? if (options && options.BYPASS_RESOLVER) return await dappGetBasic(dappnodeInstaller, req); diff --git a/packages/installer/src/dappGet/fetch/DappGetFetcher.ts b/packages/installer/src/dappGet/fetch/DappGetFetcher.ts index bf9166ff9..12d071200 100644 --- a/packages/installer/src/dappGet/fetch/DappGetFetcher.ts +++ b/packages/installer/src/dappGet/fetch/DappGetFetcher.ts @@ -1,47 +1,38 @@ -import { Dependencies } from "@dappnode/types"; +import { Dependencies, InstalledPackageData } from "@dappnode/types"; import { validRange, satisfies, valid } from "semver"; import { DappnodeInstaller } from "../../dappnodeInstaller.js"; -import { listPackageNoThrow } from "@dappnode/dockerapi"; +import { listPackages } from "@dappnode/dockerapi"; export class DappGetFetcher { /** - * Fetches the dependencies of a given DNP name and version - * Injects the optional dependencies if the package is installed + * Fetches the dependencies of a given DNP name and version. + * Injects the optional dependencies if the package is installed. * @returns dependencies: * { dnp-name-1: "semverRange", dnp-name-2: "/ipfs/Qmf53..."} */ async dependencies( dappnodeInstaller: DappnodeInstaller, name: string, - version: string + version: string, ): Promise { const manifest = await dappnodeInstaller.getManifestFromDir(name, version); const dependencies = manifest.dependencies || {}; + const optionalDependencies = manifest.optionalDependencies || {}; + const installedPackages = await listPackages(); - const optionalDependencies = manifest.optionalDependencies; - if (optionalDependencies) { - // Iterate over optional dependencies and inject them if installed - for (const [ - optionalDependencyName, - optionalDependencyVersion, - ] of Object.entries(optionalDependencies)) { - const optionalDependency = await listPackageNoThrow({ - dnpName: optionalDependencyName, - }); - if (!optionalDependency) continue; - dependencies[optionalDependencyName] = optionalDependencyVersion; - } - } + this.mergeOptionalDependencies(dependencies, optionalDependencies, installedPackages); + + console.log(`Resolved dependencies for ${name}@${version}: ${JSON.stringify(dependencies)}`); return dependencies; } /** * Fetches the available versions given a request. - * Will fetch the versions from different places according the type of version range: + * Will fetch the versions from different places according to the type of version range: * - valid semver range: Fetch the valid versions from APM * - valid semver version (not range): Return that version - * - unvalid semver version ("/ipfs/Qmre4..."): Asume it's the only version + * - invalid semver version ("/ipfs/Qmre4..."): Assume it's the only version * * @param kwargs: { * name: Name of package i.e. "kovan.dnp.dappnode.eth" @@ -78,7 +69,31 @@ export class DappGetFetcher { .filter((version) => satisfies(version, versionRange)); } } - // Case 3. unvalid semver version ("/ipfs/Qmre4..."): Asume it's the only version + // Case 3. invalid semver version ("/ipfs/Qmre4..."): Assume it's the only version return [versionRange]; } -} + + /** + * Merges optional dependencies into the main dependencies object if the corresponding + * packages are installed. + * + * @param dependencies The main dependencies object to be merged into. + * @param optionalDependencies The optional dependencies to be checked and merged. + * @param installedPackages The list of currently installed packages. + */ + private mergeOptionalDependencies( + dependencies: Dependencies, + optionalDependencies: Dependencies, + installedPackages: InstalledPackageData[] + ): void { + for (const [optionalDepName, optionalDepVersion] of Object.entries(optionalDependencies)) { + const isInstalled = installedPackages.some( + (installedPackage) => installedPackage.dnpName === optionalDepName + ); + + if (isInstalled) { + dependencies[optionalDepName] = optionalDepVersion; + } + } + } +} \ No newline at end of file diff --git a/packages/installer/src/dappGet/utils/filterSatisfiedDependencies.ts b/packages/installer/src/dappGet/utils/filterSatisfiedDependencies.ts new file mode 100644 index 000000000..fadb78d3e --- /dev/null +++ b/packages/installer/src/dappGet/utils/filterSatisfiedDependencies.ts @@ -0,0 +1,41 @@ +import { listPackages } from "@dappnode/dockerapi"; +import { Dependencies, InstalledPackageData } from "@dappnode/types"; +import { satisfies, validRange } from "semver"; + +/** + * Processes the dependencies by first filtering out those that are already satisfied by installed packages + * and then converting any remaining semver ranges to appropriate APM versions. + * + * @param dependencies The main dependencies object to be processed. + * @param installedPackages The list of currently installed packages. + */ +export async function filterSatisfiedDependencies( + dependencies: Dependencies +): Promise<{ satisfiedDeps: Dependencies, nonSatisfiedDeps: Dependencies }> { + + const installedPackages = await listPackages(); + + const satisfiedDeps: Dependencies = {}; + const nonSatisfiedDeps: Dependencies = {}; + + for (const [depName, depVersion] of Object.entries(dependencies)) { + const installedPackage = installedPackages.find( + (pkg) => pkg.dnpName === depName + ); + + if (!validRange(depVersion)) + throw new Error(`Invalid semver notation for dependency ${depName}: ${depVersion}`); + + if (installedPackage && satisfies(installedPackage.version, depVersion)) { + console.log( + `Dependency ${depName} is already installed with version ${installedPackage.version}` + ); + + satisfiedDeps[depName] = installedPackage.version; + } else { + nonSatisfiedDeps[depName] = depVersion; + } + } + + return { satisfiedDeps, nonSatisfiedDeps }; +} \ No newline at end of file diff --git a/packages/installer/src/dappGet/utils/parseSemverRangeToVersion.ts b/packages/installer/src/dappGet/utils/parseSemverRangeToVersion.ts new file mode 100644 index 000000000..c2e66e883 --- /dev/null +++ b/packages/installer/src/dappGet/utils/parseSemverRangeToVersion.ts @@ -0,0 +1,66 @@ +import { Dependencies } from "@dappnode/types"; +import { isIpfsHash } from "../../utils.js"; +import { DappnodeInstaller } from "../../dappnodeInstaller.js"; +import { maxSatisfying, validRange } from "semver"; + +/** + * Resolves and updates the given dependencies to their exact version by determining the maximum satisfying version. + * + * This method takes a set of dependencies where the version is specified as a semver range and resolves it to the exact + * version that should be installed. It does so by fetching all published versions of each dependency from the APM and + * determining the highest version that satisfies the given semver range. + * + * @param dependencies - An object representing the dependencies where the key is the dependency name and the value is the semver range or version. + * @param dappnodeInstaller - An instance of `DappnodeInstaller` used to fetch published versions from the APM. + * @throws If a semver range is invalid or if no satisfying version can be found for a dependency. + * + * @example + * Given the following dependencies object with semver ranges: + * const dependencies = { + * "example-dnp": "^1.0.0", + * "another-dnp": "2.x", + * "ipfs-dnp": "/ipfs/QmXf2...abc" + * }; + * + * And assuming the following versions are available in the APM: + * example-dnp: ["1.0.0", "1.1.0", "1.2.0"] + * another-dnp: ["2.0.0", "2.1.0", "2.5.0"] + * ipfs-dnp: ["/ipfs/QmXf2...abc"] + * + * After calling defineExactVersions, the dependencies object will be updated to: + * { + * "example-dnp": "1.2.0", // The highest version satisfying "^1.0.0" + * "another-dnp": "2.5.0", // The highest version satisfying "2.x" + * "ipfs-dnp": "/ipfs/QmXf2...abc" // Exact match + * } + */ +export async function parseSemverRangeToVersion( + dependencies: Dependencies, + dappnodeInstaller: DappnodeInstaller +): Promise { + + const parsedDeps: Dependencies = {}; + + for (const [depName, depVersion] of Object.entries(dependencies)) { + + if (isIpfsHash(depVersion)) continue; + + if (!validRange(depVersion)) + throw new Error(`Invalid semver notation for dependency ${depName}: ${depVersion}`); + + const pkgPublishments = await dappnodeInstaller.fetchApmVersionsState(depName); + + const pkgVersions = Object.values(pkgPublishments) + .map(({ version }) => version); + + const maxSatisfyingVersion = maxSatisfying(pkgVersions, depVersion); + + if (!maxSatisfyingVersion) + throw new Error(`Could not find any satisfying versions for ${depName}`); + + parsedDeps[depName] = maxSatisfyingVersion; + + } + + return parsedDeps; +} \ No newline at end of file