diff --git a/core/src/garden.ts b/core/src/garden.ts index 996cb1ccda..8abd21ec2e 100644 --- a/core/src/garden.ts +++ b/core/src/garden.ts @@ -9,7 +9,6 @@ import Bluebird from "bluebird" import chalk from "chalk" import { ensureDir } from "fs-extra" -import dedent from "dedent" import { platform, arch } from "os" import { relative, resolve } from "path" import cloneDeep from "fast-copy" @@ -42,7 +41,7 @@ import { getCloudDistributionName, getCloudLogSectionName, } from "./util/util" -import { ConfigurationError, InternalError, isGardenError, GardenError, PluginError, RuntimeError } from "./exceptions" +import { ConfigurationError, isGardenError, GardenError, InternalError, PluginError, RuntimeError } from "./exceptions" import { VcsHandler, ModuleVersion, getModuleVersionString, VcsInfo } from "./vcs/vcs" import { GitHandler } from "./vcs/git" import { BuildStaging } from "./build-staging/build-staging" @@ -79,8 +78,6 @@ import { findConfigPathsInPath, getWorkingCopyId, fixedProjectExcludes, - detectModuleOverlap, - ModuleOverlap, defaultConfigFilename, defaultDotIgnoreFile, } from "./util/fs" @@ -154,6 +151,7 @@ import { OtelTraced } from "./util/open-telemetry/decorators" import { wrapActiveSpan } from "./util/open-telemetry/spans" import { GitRepoHandler } from "./vcs/git-repo" import { configureNoOpExporter } from "./util/open-telemetry/tracing" +import { detectModuleOverlap, makeOverlapErrors, ModuleOverlapDescription } from "./util/module-overlap" const defaultLocalAddress = "localhost" @@ -368,7 +366,10 @@ export class Garden { } if (!SUPPORTED_ARCHITECTURES.includes(currentArch)) { - throw new RuntimeError({ message: `Unsupported CPU architecture: ${currentArch}`, detail: { arch: currentArch } }) + throw new RuntimeError({ + message: `Unsupported CPU architecture: ${currentArch}`, + detail: { arch: currentArch }, + }) } this.state.configsScanned = false @@ -990,8 +991,18 @@ export class Garden { moduleConfigs: resolvedModules, }) if (overlaps.length > 0) { - const { message, detail } = this.makeOverlapError(overlaps) - throw new ConfigurationError({ message, detail }) + const overlapErrors = makeOverlapErrors(this.projectRoot, overlaps) + const messages: string[] = [] + const overlappingModules: ModuleOverlapDescription[] = [] + for (const overlapError of overlapErrors) { + const { message, detail } = overlapError + messages.push(message) + overlappingModules.push(...detail.overlappingModules) + } + throw new ConfigurationError({ + message: messages.join("\n\n"), + detail: { overlappingModules }, + }) } // Convert modules to actions @@ -1262,8 +1273,8 @@ export class Garden { }) } - /* - Scans the project root for modules and workflows and adds them to the context. + /** + * Scans the project root for modules and workflows and adds them to the context. */ @OtelTraced({ name: "scanAndAddConfigs", @@ -1548,40 +1559,6 @@ export class Garden { return path } - public makeOverlapError(moduleOverlaps: ModuleOverlap[]) { - const overlapList = sortBy(moduleOverlaps, (o) => o.module.name) - .map(({ module, overlaps }) => { - const formatted = overlaps.map((o) => { - const detail = o.path === module.path ? "same path" : "nested" - return `${chalk.bold(o.name)} (${detail})` - }) - return `Module ${chalk.bold(module.name)} overlaps with module(s) ${naturalList(formatted)}.` - }) - .join("\n\n") - const message = chalk.red(dedent` - Found multiple enabled modules that share the same garden.yml file or are nested within another: - - ${overlapList} - - If this was intentional, there are two options to resolve this error: - - - You can add ${chalk.bold("include")} and/or ${chalk.bold("exclude")} directives on the affected modules. - With explicitly including / encluding files, the modules are actually allowed to overlap in case that is - what you want. - - You can use the ${chalk.bold("disabled")} directive to make sure that only one of the modules is enabled - in any given moment. For example, you can make sure that the modules are enabled only in their exclusive - environment. - `) - // Sanitize error details - const overlappingModules = moduleOverlaps.map(({ module, overlaps }) => { - return { - module: { name: module.name, path: resolve(this.projectRoot, module.path) }, - overlaps: overlaps.map(({ name, path }) => ({ name, path: resolve(this.projectRoot, path) })), - } - }) - return { message, detail: { overlappingModules } } - } - public getEnvironmentConfig() { for (const config of this.projectConfig.environments) { if (config.name === this.environmentName) { @@ -1899,7 +1876,12 @@ export const resolveGardenParams = profileAsync(async function _resolveGardenPar try { secrets = await wrapActiveSpan( "getSecrets", - async () => await cloudApi.getSecrets({ log: cloudLog, projectId: cloudProject!.id, environmentName }) + async () => + await cloudApi.getSecrets({ + log: cloudLog, + projectId: cloudProject!.id, + environmentName, + }) ) cloudLog.verbose(chalk.green("Ready")) cloudLog.debug(`Fetched ${Object.keys(secrets).length} secrets from ${cloudDomain}`) diff --git a/core/src/util/fs.ts b/core/src/util/fs.ts index 566b2fd5a2..d2dfd35b71 100644 --- a/core/src/util/fs.ts +++ b/core/src/util/fs.ts @@ -17,8 +17,6 @@ import { platform } from "os" import { FilesystemError } from "../exceptions" import { VcsHandler } from "../vcs/vcs" import { Log } from "../logger/log-entry" -import { ModuleConfig } from "../config/module" -import pathIsInside from "path-is-inside" import { exec } from "./util" import type Micromatch from "micromatch" import { uuidv4 } from "./random" @@ -63,54 +61,6 @@ export async function* scanDirectory(path: string, opts?: klaw.Options): AsyncIt } } -/** - * Returns a list of overlapping modules. - * - * If a module does not set `include` or `exclude`, and another module is in its path (including - * when the other module has the same path), the module overlaps with the other module. - */ -export interface ModuleOverlap { - module: ModuleConfig - overlaps: ModuleConfig[] -} - -export function detectModuleOverlap({ - projectRoot, - gardenDirPath, - moduleConfigs, -}: { - projectRoot: string - gardenDirPath: string - moduleConfigs: ModuleConfig[] -}): ModuleOverlap[] { - // Don't consider overlap between disabled modules, or where one of the modules is disabled - const enabledModules = moduleConfigs.filter((m) => !m.disabled) - - let overlaps: ModuleOverlap[] = [] - for (const config of enabledModules) { - if (!!config.include || !!config.exclude) { - continue - } - const matches = enabledModules - .filter( - (compare) => - config.name !== compare.name && - pathIsInside(compare.path, config.path) && - // Don't consider overlap between modules in root and those in the .garden directory - !(config.path === projectRoot && pathIsInside(compare.path, gardenDirPath)) - ) - .sort((a, b) => (a.name > b.name ? 1 : -1)) - - if (matches.length > 0) { - overlaps.push({ - module: config, - overlaps: matches, - }) - } - } - return overlaps -} - /** * Helper function to check whether a given filename is a valid Garden config filename */ diff --git a/core/src/util/module-overlap.ts b/core/src/util/module-overlap.ts new file mode 100644 index 0000000000..2683dde965 --- /dev/null +++ b/core/src/util/module-overlap.ts @@ -0,0 +1,264 @@ +/* + * Copyright (C) 2018-2023 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { posix, resolve } from "path" +import { GenerateFileSpec, ModuleConfig } from "../config/module" +import pathIsInside from "path-is-inside" +import { groupBy, intersection } from "lodash" +import chalk from "chalk" +import { naturalList } from "./string" +import dedent from "dedent" +import { InternalError } from "../exceptions" + +export const moduleOverlapTypes = ["path", "generateFiles"] as const +export type ModuleOverlapType = (typeof moduleOverlapTypes)[number] + +/** + * Data structure to describe overlapping modules. + */ +export interface ModuleOverlap { + config: ModuleConfig + overlaps: ModuleConfig[] + type: ModuleOverlapType + generateFilesOverlaps?: string[] +} + +interface ModuleOverlapFinderParams { + leftConfig: ModuleConfig + rightConfig: ModuleConfig + projectRoot: string + gardenDirPath: string +} + +// Here `type` and `overlap` can be undefined if no overlap found +interface ModuleOverlapFinderResult { + pivot: ModuleConfig + overlap: ModuleConfig | undefined + type: ModuleOverlapType | undefined + generateFilesOverlaps?: string[] +} + +// The implementation must a commutative function +type ModuleOverlapMatcher = (params: ModuleOverlapFinderParams) => ModuleOverlapFinderResult + +const hasInclude = (m: ModuleConfig) => !!m.include +const hasExclude = (m: ModuleConfig) => !!m.exclude + +const isModulePathOverlap: ModuleOverlapMatcher = ({ + leftConfig, + rightConfig, + projectRoot, + gardenDirPath, +}: ModuleOverlapFinderParams) => { + // Do not compare module against itself + if (leftConfig.name === rightConfig.name) { + return { pivot: leftConfig, overlap: undefined, type: undefined } + } + + const leftIsOverlapSafe = hasInclude(leftConfig) || hasExclude(leftConfig) + const rightIsOverlapSafe = hasInclude(rightConfig) || hasExclude(rightConfig) + if (leftIsOverlapSafe && rightIsOverlapSafe) { + return { pivot: leftConfig, overlap: undefined, type: undefined } + } + + // Here only one or none of 2 configs can have 'include'/'exclude' files defined. + // Let's re-assign the values if necessary to ensure commutativity of the function. + let leftResolved = leftConfig + let rightResolved = rightConfig + + // Let's always use the config without 'include'/'exclude' files as a left argument. + if (leftIsOverlapSafe) { + leftResolved = rightConfig + rightResolved = leftConfig + } + + if ( + // Don't consider overlap between modules in root and those in the .garden directory + pathIsInside(rightResolved.path, leftResolved.path) && + !(leftResolved.path === projectRoot && pathIsInside(rightResolved.path, gardenDirPath)) + ) { + return { pivot: leftResolved, overlap: rightResolved, type: "path" } + } + return { pivot: leftConfig, overlap: undefined, type: undefined } +} + +const isGenerateFilesOverlap: ModuleOverlapMatcher = ({ leftConfig, rightConfig }: ModuleOverlapFinderParams) => { + // Do not compare module against itself + if (leftConfig.name === rightConfig.name) { + return { pivot: leftConfig, overlap: undefined, type: undefined } + } + + const leftGenerateFiles = leftConfig.generateFiles || [] + const rightGenerateFiles = rightConfig.generateFiles || [] + // Nothing to return if the current module has no `generateFiles` defined + if (leftGenerateFiles.length === 0 || rightGenerateFiles.length === 0) { + return { pivot: leftConfig, overlap: undefined, type: undefined } + } + + const leftTargetPaths = resolveGenerateFilesTargetPaths(leftConfig.path, leftGenerateFiles) + const rightTargetPaths = resolveGenerateFilesTargetPaths(rightConfig.path, rightGenerateFiles) + const generateFilesOverlaps = intersection(leftTargetPaths, rightTargetPaths) + + if (generateFilesOverlaps.length === 0) { + return { pivot: leftConfig, overlap: undefined, type: undefined } + } + + return { pivot: leftConfig, overlap: rightConfig, type: "generateFiles", generateFilesOverlaps } +} + +const moduleOverlapMatchers: ModuleOverlapMatcher[] = [isModulePathOverlap, isGenerateFilesOverlap] + +const moduleNameComparator = (a, b) => (a.name > b.name ? 1 : -1) + +function resolveGenerateFilesTargetPaths(modulePath: string, generateFiles: GenerateFileSpec[]): string[] { + return generateFiles.map((f) => f.targetPath).map((p) => resolve(modulePath, ...p.split(posix.sep))) +} + +/** + * Returns a list of overlapping modules. + * + * If a module does not set `include` or `exclude`, and another module is in its path (including + * when the other module has the same path), the module overlaps with the other module. + * + * If two modules have `generateFiles`, and at least one `generateFiles.targetPath` is the same in both modules, + * then the modules overlap. + */ +export function detectModuleOverlap({ + projectRoot, + gardenDirPath, + moduleConfigs, +}: { + projectRoot: string + gardenDirPath: string + moduleConfigs: ModuleConfig[] +}): ModuleOverlap[] { + // Don't consider overlap between disabled modules, or where one of the modules is disabled + const enabledModules = moduleConfigs.filter((m) => !m.disabled).sort(moduleNameComparator) + if (enabledModules.length < 2) { + return [] + } + + const foundOverlaps: ModuleOverlap[] = [] + for (let i = 0; i < enabledModules.length; i++) { + const leftConfig = enabledModules[i] + for (let j = i + 1; j < enabledModules.length; j++) { + const rightConfig = enabledModules[j] + for (const moduleOverlapMatcher of moduleOverlapMatchers) { + const { pivot, overlap, type, generateFilesOverlaps } = moduleOverlapMatcher({ + leftConfig, + rightConfig, + projectRoot, + gardenDirPath, + }) + if (!!overlap) { + if (!type) { + throw new InternalError({ + message: "Got some module overlap errors with undefined type. This is a bug, please report it.", + detail: { config: pivot, overlap }, + }) + } + foundOverlaps.push({ config: pivot, overlaps: [overlap], type, generateFilesOverlaps }) + } + } + } + } + return foundOverlaps +} + +interface ModuleDesc { + path: string + name: string +} + +export interface ModuleOverlapDescription { + module: ModuleDesc + overlaps: ModuleDesc[] +} + +export interface OverlapErrorDescription { + detail: { overlappingModules: ModuleOverlapDescription[] } + message: string +} + +type ModuleOverlapRenderer = (projectRoot: string, moduleOverlaps: ModuleOverlap[]) => OverlapErrorDescription + +function sanitizeErrorDetails(projectRoot: string, moduleOverlaps: ModuleOverlap[]): ModuleOverlapDescription[] { + return moduleOverlaps.map(({ config, overlaps }) => { + return { + module: { name: config.name, path: resolve(projectRoot, config.path) }, + overlaps: overlaps.map(({ name, path }) => ({ name, path: resolve(projectRoot, path) })), + } + }) +} + +const makePathOverlapError: ModuleOverlapRenderer = ( + projectRoot: string, + moduleOverlaps: ModuleOverlap[] +): OverlapErrorDescription => { + const overlapList = moduleOverlaps.map(({ config, overlaps }) => { + const formatted = overlaps.map((o) => { + const detail = o.path === config.path ? "same path" : "nested" + return `${chalk.bold(o.name)} (${detail})` + }) + return `Module ${chalk.bold(config.name)} overlaps with module(s) ${naturalList(formatted)}.` + }) + const message = chalk.red(dedent` + Found multiple enabled modules that share the same garden.yml file or are nested within another: + + ${overlapList.join("\n\n")} + + If this was intentional, there are two options to resolve this error: + + - You can add ${chalk.bold("include")} and/or ${chalk.bold("exclude")} directives on the affected modules. + By explicitly including / excluding files, the modules are actually allowed to overlap in case that is + what you want. + - You can use the ${chalk.bold("disabled")} directive to make sure that only one of the modules is enabled + at any given time. For example, you can make sure that the modules are enabled only in a certain + environment. + `) + const overlappingModules = sanitizeErrorDetails(projectRoot, moduleOverlaps) + return { message, detail: { overlappingModules } } +} + +const makeGenerateFilesOverlapError: ModuleOverlapRenderer = ( + projectRoot: string, + moduleOverlaps: ModuleOverlap[] +): OverlapErrorDescription => { + const moduleOverlapList = moduleOverlaps.map(({ config, overlaps, generateFilesOverlaps }) => { + const formatted = overlaps.map((o) => { + return `${chalk.bold(o.name)}` + }) + return `Module ${chalk.bold(config.name)} overlaps with module(s) ${naturalList(formatted)} in ${naturalList( + generateFilesOverlaps || [] + )}.` + }) + const message = chalk.red(dedent` + Found multiple enabled modules that share the same value(s) in ${chalk.bold("generateFiles[].targetPath")}: + + ${moduleOverlapList.join("\n\n")} + `) + const overlappingModules = sanitizeErrorDetails(projectRoot, moduleOverlaps) + return { + message, + detail: { overlappingModules }, + } +} + +// This explicit type ensures that every `ModuleOverlapType` has a defined renderer +const moduleOverlapRenderers: { [k in ModuleOverlapType]: ModuleOverlapRenderer } = { + path: makePathOverlapError, + generateFiles: makeGenerateFilesOverlapError, +} + +export function makeOverlapErrors(projectRoot: string, moduleOverlaps: ModuleOverlap[]): OverlapErrorDescription[] { + return Object.entries(groupBy(moduleOverlaps, "type")).map(([type, overlaps]) => { + const moduleOverlapType = type as ModuleOverlapType + const renderer = moduleOverlapRenderers[moduleOverlapType] + return renderer(projectRoot, overlaps) + }) +} diff --git a/core/test/unit/src/garden.ts b/core/test/unit/src/garden.ts index a89c86a958..1b26c73354 100644 --- a/core/test/unit/src/garden.ts +++ b/core/test/unit/src/garden.ts @@ -2637,8 +2637,11 @@ describe("Garden", () => { await expectError(() => garden.resolveModules({ log: garden.log }), { contains: [ "found multiple enabled modules that share the same garden.yml file or are nested within another", - "Module module-no-include-b overlaps with module(s) module-a1 (nested), module-a2 (nested) and module-no-include-a (same path).", - "Module module-no-include-a overlaps with module(s) module-a1 (nested), module-a2 (nested) and module-no-include-b (same path).", + "Module module-no-include-a overlaps with module(s) module-a1 (nested).", + "Module module-no-include-a overlaps with module(s) module-a2 (nested).", + "Module module-no-include-a overlaps with module(s) module-no-include-b (same path).", + "Module module-no-include-b overlaps with module(s) module-a1 (nested).", + "Module module-no-include-b overlaps with module(s) module-a2 (nested).", "if this was intentional, there are two options to resolve this error", "you can add include and/or exclude directives on the affected modules", "you can use the disabled directive to make sure that only one of the modules is enabled", diff --git a/core/test/unit/src/util/fs.ts b/core/test/unit/src/util/fs.ts index afd40bebfc..81cdd98c54 100644 --- a/core/test/unit/src/util/fs.ts +++ b/core/test/unit/src/util/fs.ts @@ -16,221 +16,11 @@ import { isConfigFilename, getWorkingCopyId, findConfigPathsInPath, - detectModuleOverlap, joinWithPosix, } from "../../../../src/util/fs" import { withDir } from "tmp-promise" -import { ModuleConfig } from "../../../../src/config/module" import { mkdirp, writeFile } from "fs-extra" -describe("detectModuleOverlap", () => { - const projectRoot = join("/", "user", "code") - const gardenDirPath = join(projectRoot, ".garden") - - it("should detect if modules have the same root", () => { - const moduleA = { - name: "module-a", - path: join(projectRoot, "foo"), - } as ModuleConfig - const moduleB = { - name: "module-b", - path: join(projectRoot, "foo"), - } as ModuleConfig - const moduleC = { - name: "module-c", - path: join(projectRoot, "foo"), - } as ModuleConfig - const moduleD = { - name: "module-d", - path: join(projectRoot, "bas"), - } as ModuleConfig - expect( - detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB, moduleC, moduleD] }) - ).to.eql([ - { - module: moduleA, - overlaps: [moduleB, moduleC], - }, - { - module: moduleB, - overlaps: [moduleA, moduleC], - }, - { - module: moduleC, - overlaps: [moduleA, moduleB], - }, - ]) - }) - it("should detect if a module has another module in its path", () => { - const moduleA = { - name: "module-a", - path: join(projectRoot, "foo"), - } as ModuleConfig - const moduleB = { - name: "module-b", - path: join(projectRoot, "foo", "bar"), - } as ModuleConfig - const moduleC = { - name: "module-c", - path: join(projectRoot, "foo", "bar", "bas"), - } as ModuleConfig - const moduleD = { - name: "module-d", - path: join(projectRoot, "bas", "bar", "bas"), - } as ModuleConfig - expect( - detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB, moduleC, moduleD] }) - ).to.eql([ - { - module: moduleA, - overlaps: [moduleB, moduleC], - }, - { - module: moduleB, - overlaps: [moduleC], - }, - ]) - }) - - context("same root", () => { - it("should ignore modules that set includes", () => { - const moduleA = { - name: "module-a", - path: join(projectRoot, "foo"), - include: [""], - } as ModuleConfig - const moduleB = { - name: "module-b", - path: join(projectRoot, "foo"), - } as ModuleConfig - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.eql([ - { - module: moduleB, - overlaps: [moduleA], - }, - ]) - }) - it("should ignore modules that set excludes", () => { - const moduleA = { - name: "module-a", - path: join(projectRoot, "foo"), - exclude: [""], - } as ModuleConfig - const moduleB = { - name: "module-b", - path: join(projectRoot, "foo"), - } as ModuleConfig - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.eql([ - { - module: moduleB, - overlaps: [moduleA], - }, - ]) - }) - it("should ignore modules that are disabled", () => { - const moduleA = { - name: "module-a", - path: join(projectRoot, "foo"), - disabled: true, - } as ModuleConfig - const moduleB = { - name: "module-b", - path: join(projectRoot, "foo"), - } as ModuleConfig - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.be.empty - }) - }) - - context("nested modules", () => { - it("should ignore modules that set includes", () => { - const moduleA = { - name: "module-a", - path: join(projectRoot, "foo"), - include: [""], - } as ModuleConfig - const moduleB = { - name: "module-b", - path: join(projectRoot, "foo", "bar"), - } as ModuleConfig - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.be.empty - }) - - it("should ignore modules that set excludes", () => { - const moduleA = { - name: "module-a", - path: join(projectRoot, "foo"), - exclude: [""], - } as ModuleConfig - const moduleB = { - name: "module-b", - path: join(projectRoot, "foo", "bar"), - } as ModuleConfig - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.be.empty - }) - - it("should ignore modules that are disabled", () => { - const moduleA = { - name: "module-a", - path: join(projectRoot, "foo"), - disabled: true, - } as ModuleConfig - const moduleB = { - name: "module-b", - path: join(projectRoot, "foo", "bar"), - } as ModuleConfig - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.be.empty - }) - - it("should detect overlaps if only nested module has includes/excludes", () => { - const moduleA1 = { - name: "module-a", - path: join(projectRoot, "foo"), - } as ModuleConfig - const moduleB1 = { - name: "module-b", - path: join(projectRoot, "foo", "bar"), - include: [""], - } as ModuleConfig - const moduleA2 = { - name: "module-a", - path: join(projectRoot, "foo"), - } as ModuleConfig - const moduleB2 = { - name: "module-b", - path: join(projectRoot, "foo", "bar"), - exclude: [""], - } as ModuleConfig - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA1, moduleB1] })).to.eql([ - { - module: moduleA1, - overlaps: [moduleB1], - }, - ]) - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA2, moduleB2] })).to.eql([ - { - module: moduleA2, - overlaps: [moduleB2], - }, - ]) - }) - - it("should not consider remote source modules to overlap with module in project root", () => { - const remoteModule = { - name: "remote-module", - path: join(gardenDirPath, "sources", "foo", "bar"), - } as ModuleConfig - - const moduleFoo = { - name: "module-foo", - path: join(projectRoot, "foo"), - include: [""], - } as ModuleConfig - - expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleFoo, remoteModule] })).to.eql([]) - }) - }) -}) - describe("scanDirectory", () => { it("should iterate through all files in a directory", async () => { const testPath = getDataDir("scanDirectory") diff --git a/core/test/unit/src/util/module-overlap.ts b/core/test/unit/src/util/module-overlap.ts new file mode 100644 index 0000000000..ff9ae55429 --- /dev/null +++ b/core/test/unit/src/util/module-overlap.ts @@ -0,0 +1,361 @@ +/* + * Copyright (C) 2018-2023 Garden Technologies, Inc. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { expect } from "chai" +import { join } from "path" +import { detectModuleOverlap, ModuleOverlap } from "../../../../src/util/module-overlap" +import { ModuleConfig } from "../../../../src/config/module" + +describe("detectModuleOverlap", () => { + const projectRoot = join("/", "user", "code") + const gardenDirPath = join(projectRoot, ".garden") + + context("for homogenous overlaps of ModuleOverlapType = 'path'", () => { + it("should detect if modules have the same root", () => { + const moduleA = { + name: "module-a", + path: join(projectRoot, "foo"), + } as ModuleConfig + const moduleB = { + name: "module-b", + path: join(projectRoot, "foo"), + } as ModuleConfig + const moduleC = { + name: "module-c", + path: join(projectRoot, "foo"), + } as ModuleConfig + const moduleD = { + name: "module-d", + path: join(projectRoot, "bas"), + } as ModuleConfig + const expectedOverlaps: ModuleOverlap[] = [ + { + config: moduleA, + overlaps: [moduleB], + type: "path", + generateFilesOverlaps: undefined, + }, + { + config: moduleA, + overlaps: [moduleC], + type: "path", + generateFilesOverlaps: undefined, + }, + { + config: moduleB, + overlaps: [moduleC], + type: "path", + generateFilesOverlaps: undefined, + }, + ] + expect( + detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB, moduleC, moduleD] }) + ).to.eql(expectedOverlaps) + }) + + it("should detect if a module has another module in its path", () => { + const moduleA = { + name: "module-a", + path: join(projectRoot, "foo"), + } as ModuleConfig + const moduleB = { + name: "module-b", + path: join(projectRoot, "foo", "bar"), + } as ModuleConfig + const moduleC = { + name: "module-c", + path: join(projectRoot, "foo", "bar", "bas"), + } as ModuleConfig + const moduleD = { + name: "module-d", + path: join(projectRoot, "bas", "bar", "bas"), + } as ModuleConfig + const expectedOverlaps: ModuleOverlap[] = [ + { + config: moduleA, + overlaps: [moduleB], + type: "path", + generateFilesOverlaps: undefined, + }, + { + config: moduleA, + overlaps: [moduleC], + type: "path", + generateFilesOverlaps: undefined, + }, + { + config: moduleB, + overlaps: [moduleC], + type: "path", + generateFilesOverlaps: undefined, + }, + ] + expect( + detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB, moduleC, moduleD] }) + ).to.eql(expectedOverlaps) + }) + + context("same root", () => { + it("should ignore modules that set includes", () => { + const moduleA = { + name: "module-a", + path: join(projectRoot, "foo"), + include: [""], + } as ModuleConfig + const moduleB = { + name: "module-b", + path: join(projectRoot, "foo"), + } as ModuleConfig + const expectedOverlaps: ModuleOverlap[] = [ + { + config: moduleB, + overlaps: [moduleA], + type: "path", + generateFilesOverlaps: undefined, + }, + ] + expect( + detectModuleOverlap({ + projectRoot, + gardenDirPath, + moduleConfigs: [moduleA, moduleB], + }) + ).to.eql(expectedOverlaps) + }) + + it("should ignore modules that set excludes", () => { + const moduleA = { + name: "module-a", + path: join(projectRoot, "foo"), + exclude: [""], + } as ModuleConfig + const moduleB = { + name: "module-b", + path: join(projectRoot, "foo"), + } as ModuleConfig + const expectedOverlaps: ModuleOverlap[] = [ + { + config: moduleB, + overlaps: [moduleA], + type: "path", + generateFilesOverlaps: undefined, + }, + ] + expect( + detectModuleOverlap({ + projectRoot, + gardenDirPath, + moduleConfigs: [moduleA, moduleB], + }) + ).to.eql(expectedOverlaps) + }) + + it("should ignore modules that are disabled", () => { + const moduleA = { + name: "module-a", + path: join(projectRoot, "foo"), + disabled: true, + } as ModuleConfig + const moduleB = { + name: "module-b", + path: join(projectRoot, "foo"), + } as ModuleConfig + expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.be.empty + }) + }) + + context("nested modules", () => { + it("should ignore modules that set includes", () => { + const moduleA = { + name: "module-a", + path: join(projectRoot, "foo"), + include: [""], + } as ModuleConfig + const moduleB = { + name: "module-b", + path: join(projectRoot, "foo", "bar"), + } as ModuleConfig + expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.be.empty + }) + + it("should ignore modules that set excludes", () => { + const moduleA = { + name: "module-a", + path: join(projectRoot, "foo"), + exclude: [""], + } as ModuleConfig + const moduleB = { + name: "module-b", + path: join(projectRoot, "foo", "bar"), + } as ModuleConfig + expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.be.empty + }) + + it("should ignore modules that are disabled", () => { + const moduleA = { + name: "module-a", + path: join(projectRoot, "foo"), + disabled: true, + } as ModuleConfig + const moduleB = { + name: "module-b", + path: join(projectRoot, "foo", "bar"), + } as ModuleConfig + expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.be.empty + }) + + it("should detect overlaps if only nested module has includes/excludes", () => { + const moduleA1 = { + name: "module-a", + path: join(projectRoot, "foo"), + } as ModuleConfig + const moduleB1 = { + name: "module-b", + path: join(projectRoot, "foo", "bar"), + include: [""], + } as ModuleConfig + const moduleA2 = { + name: "module-a", + path: join(projectRoot, "foo"), + } as ModuleConfig + const moduleB2 = { + name: "module-b", + path: join(projectRoot, "foo", "bar"), + exclude: [""], + } as ModuleConfig + const expectedOverlapsA1B1: ModuleOverlap[] = [ + { + config: moduleA1, + overlaps: [moduleB1], + type: "path", + generateFilesOverlaps: undefined, + }, + ] + expect( + detectModuleOverlap({ + projectRoot, + gardenDirPath, + moduleConfigs: [moduleA1, moduleB1], + }) + ).to.eql(expectedOverlapsA1B1) + const expectedOverlapsA2B2: ModuleOverlap[] = [ + { + config: moduleA2, + overlaps: [moduleB2], + type: "path", + generateFilesOverlaps: undefined, + }, + ] + expect( + detectModuleOverlap({ + projectRoot, + gardenDirPath, + moduleConfigs: [moduleA2, moduleB2], + }) + ).to.eql(expectedOverlapsA2B2) + }) + + it("should not consider remote source modules to overlap with module in project root", () => { + const remoteModule = { + name: "remote-module", + path: join(gardenDirPath, "sources", "foo", "bar"), + } as ModuleConfig + + const moduleFoo = { + name: "module-foo", + path: join(projectRoot, "foo"), + include: [""], + } as ModuleConfig + + expect( + detectModuleOverlap({ + projectRoot, + gardenDirPath, + moduleConfigs: [moduleFoo, remoteModule], + }) + ).to.eql([]) + }) + }) + }) + + context("for homogeneous overlaps of ModuleOverlapType = 'generateFiles'", () => { + it("should detect if modules have the same resolved path in generateFiles[].targetPath", () => { + const path = join(projectRoot, "foo") + const sourcePath = "manifests.yml" + const targetPath = "./.manifests/manifests.yaml" + + // here we use include to avoid errors on intersecting module paths + const moduleA = { + name: "module-a", + path, + include: [""], + generateFiles: [{ sourcePath, targetPath, resolveTemplates: true }], + } as ModuleConfig + + const moduleB = { + name: "module-b", + path, + include: [""], + generateFiles: [{ sourcePath, targetPath, resolveTemplates: true }], + } as ModuleConfig + + const expectedGenerateFilesOverlaps = [join(path, targetPath)] + const expectedOverlaps: ModuleOverlap[] = [ + { + config: moduleA, + overlaps: [moduleB], + type: "generateFiles", + generateFilesOverlaps: expectedGenerateFilesOverlaps, + }, + ] + expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.eql( + expectedOverlaps + ) + }) + }) + + context("for heterogeneous overlaps of mixed ModuleOverlapType", () => { + it("should detect different kinds of overlaps", () => { + const path = join(projectRoot, "foo") + const sourcePath = "manifests.yml" + const targetPath = "./.manifests/manifests.yaml" + + // here we use don't use include/exclude to get different types of module overlaps + const moduleA = { + name: "module-a", + path, + generateFiles: [{ sourcePath, targetPath, resolveTemplates: true }], + } as ModuleConfig + + const moduleB = { + name: "module-b", + path, + generateFiles: [{ sourcePath, targetPath, resolveTemplates: true }], + } as ModuleConfig + + const expectedGenerateFilesOverlaps = [join(path, targetPath)] + const expectedOverlaps: ModuleOverlap[] = [ + { + config: moduleA, + overlaps: [moduleB], + type: "path", + generateFilesOverlaps: undefined, + }, + { + config: moduleA, + overlaps: [moduleB], + type: "generateFiles", + generateFilesOverlaps: expectedGenerateFilesOverlaps, + }, + ] + expect(detectModuleOverlap({ projectRoot, gardenDirPath, moduleConfigs: [moduleA, moduleB] })).to.eql( + expectedOverlaps + ) + }) + }) +})