From 668abf1918302c8feaae24ba4e8a083c46c03c56 Mon Sep 17 00:00:00 2001 From: Jon Edvald Date: Mon, 6 May 2024 19:11:45 +0200 Subject: [PATCH] perf(cli): avoid unnecessary module resolution when filtering by name This applies to most common usages of the `build`, `deploy`, `run` and `test` commands when one or more names are specified, as well as in the `get modules` and `get actions` commands. For now this is enabled specifically with by setting the `GARDEN_ENABLE_PARTIAL_RESOLUTION=true` env variable. --- core/src/commands/build.ts | 11 +- core/src/commands/deploy.ts | 13 +- core/src/commands/get/get-actions.ts | 37 ++- core/src/commands/get/get-modules.ts | 8 +- core/src/commands/plugins.ts | 2 +- core/src/commands/run.ts | 10 +- core/src/commands/test.ts | 19 +- core/src/config/template-contexts/module.ts | 2 +- core/src/constants.ts | 1 + core/src/garden.ts | 24 +- core/src/graph/common.ts | 19 ++ core/src/graph/modules.ts | 38 ++- core/src/resolve-module.ts | 304 +++++++++++++++--- .../src/actions/action-configs-to-graph.ts | 56 ++-- core/test/unit/src/commands/build.ts | 64 ++++ core/test/unit/src/commands/deploy.ts | 75 +++++ .../test/unit/src/commands/get/get-actions.ts | 107 ++++++ .../test/unit/src/commands/get/get-modules.ts | 34 ++ core/test/unit/src/commands/run.ts | 62 +++- core/test/unit/src/commands/test.ts | 105 ++++++ 20 files changed, 892 insertions(+), 99 deletions(-) diff --git a/core/src/commands/build.ts b/core/src/commands/build.ts index c121320e59..0f9ed4a059 100644 --- a/core/src/commands/build.ts +++ b/core/src/commands/build.ts @@ -82,8 +82,15 @@ export class BuildCommand extends Command { await garden.clearBuilds() - const graph = await garden.getConfigGraph({ log, emit: true }) - let actions = graph.getBuilds({ names: args.names }) + let actionsFilter: string[] | undefined = undefined + + // TODO: Support partial module resolution with --with-dependants + if (args.names && !opts["with-dependants"]) { + actionsFilter = args.names.map((name) => `build.${name}`) + } + + const graph = await garden.getConfigGraph({ log, emit: true, actionsFilter }) + let actions = graph.getBuilds({ includeNames: args.names }) if (opts["with-dependants"]) { // Then we include build dependants (recursively) in the list of modules to build. diff --git a/core/src/commands/deploy.ts b/core/src/commands/deploy.ts index c8df6fddf8..fd2d87c9cf 100644 --- a/core/src/commands/deploy.ts +++ b/core/src/commands/deploy.ts @@ -197,8 +197,17 @@ export class DeployCommand extends Command { sync: opts.sync?.length === 0 ? ["deploy.*"] : opts.sync?.map((s) => "deploy." + s), } - const graph = await garden.getConfigGraph({ log, emit: true, actionModes }) - let deployActions = graph.getDeploys({ names: args.names, includeDisabled: true }) + let actionsFilter: string[] | undefined = undefined + + // TODO: Optimize partial module resolution further when --skip-dependencies=true + // TODO: Optimize partial resolution further with --skip flag + // TODO: Support partial module resolution with --with-dependants + if (args.names && !opts["with-dependants"]) { + actionsFilter = args.names.map((name) => `deploy.${name}`) + } + + const graph = await garden.getConfigGraph({ log, emit: true, actionModes, actionsFilter }) + let deployActions = graph.getDeploys({ includeNames: args.names, includeDisabled: true }) const disabled = deployActions.filter((s) => s.isDisabled()).map((s) => s.name) diff --git a/core/src/commands/get/get-actions.ts b/core/src/commands/get/get-actions.ts index bc3ab2340c..f3384e6456 100644 --- a/core/src/commands/get/get-actions.ts +++ b/core/src/commands/get/get-actions.ts @@ -111,12 +111,13 @@ export class GetActionsCommand extends Command { Examples: - garden get actions # list all actions in the project - garden get actions --include-state # list all actions in the project with state in output - garden get actions --detail # list all actions in project with detailed info - garden get actions --kind deploy # only list the actions of kind 'Deploy' - garden get actions A B --kind build --sort type # list actions A and B of kind 'Build' sorted by type - garden get actions --include-state -o=json # get json output + garden get actions # list all actions in the project + garden get actions --include-state # list all actions in the project with state in output + garden get actions --detail # list all actions in project with detailed info + garden get actions --kind deploy # only list the actions of kind 'Deploy' + garden get actions a b --kind build --sort type # list actions 'a' and 'b' of kind 'Build' sorted by type + garden get actions build.a deploy.b # list actions 'build.a' and 'deploy.b' + garden get actions --include-state -o=json # get json output ` override arguments = getActionsArgs @@ -137,30 +138,40 @@ export class GetActionsCommand extends Command { args, opts, }: CommandParams): Promise> { - const { names: keys } = args const includeStateInOutput = opts["include-state"] const isOutputDetailed = opts["detail"] const router = await garden.getActionRouter() - const graph = await garden.getResolvedConfigGraph({ log, emit: false }) + + let actionsFilter: string[] | undefined = undefined + + if (args.names && opts.kind) { + actionsFilter = args.names.map((name) => `${opts.kind}.${name}`) + } else if (args.names) { + actionsFilter = args.names + } else if (opts.kind) { + actionsFilter = [opts.kind + ".*"] + } + + const graph = await garden.getResolvedConfigGraph({ log, emit: false, actionsFilter }) const kindOpt = opts["kind"]?.toLowerCase() let actions: ResolvedActionWithState[] = [] switch (kindOpt) { case "build": - actions = graph.getBuilds({ names: keys }) + actions = graph.getBuilds({ includeNames: args.names }) break case "deploy": - actions = graph.getDeploys({ names: keys }) + actions = graph.getDeploys({ includeNames: args.names }) break case "run": - actions = graph.getRuns({ names: keys }) + actions = graph.getRuns({ includeNames: args.names }) break case "test": - actions = graph.getTests({ names: keys }) + actions = graph.getTests({ includeNames: args.names }) break default: - actions = graph.getActions({ refs: keys }) + actions = graph.getActions({ refs: args.names }) break } diff --git a/core/src/commands/get/get-modules.ts b/core/src/commands/get/get-modules.ts index 1a8e818f8b..b26efc6250 100644 --- a/core/src/commands/get/get-modules.ts +++ b/core/src/commands/get/get-modules.ts @@ -80,7 +80,13 @@ export class GetModulesCommand extends Command { } async action({ garden, log, args, opts }: CommandParams) { - const graph = await garden.getConfigGraph({ log, emit: false }) + let actionsFilter: string[] | undefined = undefined + + if (args.modules) { + actionsFilter = args.modules.map((name) => `build.${name}`) + } + + const graph = await garden.getConfigGraph({ log, emit: false, actionsFilter }) const modules = sortBy( graph.getModules({ names: args.modules, includeDisabled: !opts["exclude-disabled"] }), diff --git a/core/src/commands/plugins.ts b/core/src/commands/plugins.ts index 58270cf571..f1a7d450a0 100644 --- a/core/src/commands/plugins.ts +++ b/core/src/commands/plugins.ts @@ -109,7 +109,7 @@ export class PluginsCommand extends Command { let graph = new ConfigGraph({ environmentName: garden.environmentName, actions: [], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), groups: [], }) diff --git a/core/src/commands/run.ts b/core/src/commands/run.ts index c37f1c28a4..3466eb76fe 100644 --- a/core/src/commands/run.ts +++ b/core/src/commands/run.ts @@ -132,7 +132,15 @@ export class RunCommand extends Command { await watchRemovedWarning(garden, log) } - const graph = await garden.getConfigGraph({ log, emit: true }) + let actionsFilter: string[] | undefined = undefined + + // TODO: Optimize partial module resolution further when --skip-dependencies=true + // TODO: Optimize partial resolution further with --skip flag + if (args.names) { + actionsFilter = args.names.map((name) => `run.${name}`) + } + + const graph = await garden.getConfigGraph({ log, emit: true, actionsFilter }) const force = opts.force const skipRuntimeDependencies = opts["skip-dependencies"] diff --git a/core/src/commands/test.ts b/core/src/commands/test.ts index 7094fd12bd..856dc6dd0d 100644 --- a/core/src/commands/test.ts +++ b/core/src/commands/test.ts @@ -149,13 +149,26 @@ export class TestCommand extends Command { ) } - const graph = await garden.getConfigGraph({ log, emit: true }) + let actionsFilter: string[] | undefined = undefined + + // TODO: Optimize partial resolution further when --skip-dependencies=true + // TODO: Optimize partial resolution further with --skip flag + if (args.names) { + actionsFilter = args.names.map((name) => `test.${name}`) + } + + if (opts.module) { + actionsFilter = [...(actionsFilter || []), `test.${opts.module}-*`] + } + + const graph = await garden.getConfigGraph({ log, emit: true, actionsFilter }) - let names: string[] | undefined = undefined - const nameArgs = [...(args.names || []), ...(opts.name || []).map((n) => `*-${n}`)] const force = opts.force const skipRuntimeDependencies = opts["skip-dependencies"] + let names: string[] | undefined = undefined + const nameArgs = [...(args.names || []), ...(opts.name || []).map((n) => `*-${n}`)] + if (nameArgs.length > 0) { names = nameArgs } diff --git a/core/src/config/template-contexts/module.ts b/core/src/config/template-contexts/module.ts index 18f695ccbf..e4f8275e39 100644 --- a/core/src/config/template-contexts/module.ts +++ b/core/src/config/template-contexts/module.ts @@ -214,7 +214,7 @@ export class OutputConfigContext extends ProviderConfigContext { .description("Retrieve information about modules that are defined in the project.") .meta({ keyPlaceholder: "" }) ) - public modules: Map + public modules: Map @schema( RuntimeConfigContext.getSchema().description( diff --git a/core/src/constants.ts b/core/src/constants.ts index 0909f3100f..fc7977cc53 100644 --- a/core/src/constants.ts +++ b/core/src/constants.ts @@ -105,4 +105,5 @@ export const gardenEnv = { // GARDEN_CLOUD_BUILDER will always override the config; That's why it doesn't have a default. // FIXME: If the environment variable is not set, asBool returns undefined, unlike the type suggests. That's why we cast to `boolean | undefined`. GARDEN_CLOUD_BUILDER: env.get("GARDEN_CLOUD_BUILDER").required(false).asBool() as boolean | undefined, + GARDEN_ENABLE_PARTIAL_RESOLUTION: env.get("GARDEN_ENABLE_PARTIAL_RESOLUTION").required(false).asBool(), } diff --git a/core/src/garden.ts b/core/src/garden.ts index 2ad3049512..5098ab6137 100644 --- a/core/src/garden.ts +++ b/core/src/garden.ts @@ -984,7 +984,22 @@ export class Garden { @OtelTraced({ name: "getConfigGraph", }) - async getConfigGraph({ log, graphResults, emit, actionModes = {} }: GetConfigGraphParams): Promise { + async getConfigGraph({ + log, + graphResults, + emit, + actionModes = {}, + /** + * If provided, this is used to perform partial module resolution. + * TODO: also limit action resolution (less important because it's faster and more done on-demand) + */ + actionsFilter, + }: GetConfigGraphParams): Promise { + // Feature-flagging this for now + if (!gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION) { + actionsFilter = undefined + } + // TODO: split this out of the Garden class await this.scanAndAddConfigs() @@ -1002,12 +1017,12 @@ export class Garden { graphResults, }) - const resolvedModules = await resolver.resolveAll() + const { resolvedModules, skipped } = await resolver.resolve({ actionsFilter }) // Validate the module dependency structure. This will throw on failure. const router = await this.getActionRouter() const moduleTypes = await this.getModuleTypes() - const moduleGraph = new ModuleGraph(resolvedModules, moduleTypes) + const moduleGraph = new ModuleGraph({ modules: resolvedModules, moduleTypes, skippedKeys: skipped }) // Require include/exclude on modules if their paths overlap const overlaps = detectModuleOverlap({ @@ -1068,8 +1083,6 @@ export class Garden { linkedSources, }) - // TODO-0.13.1: detect overlap on Build actions - // Walk through all plugins in dependency order, and allow them to augment the graph const plugins = keyBy(await this.getAllPlugins(), "name") @@ -2266,4 +2279,5 @@ export interface GetConfigGraphParams { graphResults?: GraphResults emit: boolean actionModes?: ActionModeMap + actionsFilter?: string[] } diff --git a/core/src/graph/common.ts b/core/src/graph/common.ts index db8a43d246..63d2b8646a 100644 --- a/core/src/graph/common.ts +++ b/core/src/graph/common.ts @@ -69,6 +69,25 @@ export class DependencyGraph extends DepGraph { } } + keys() { + return Object.keys(this["nodes"]) + } + + /** + * Returns a clone of the graph. + * Overriding base implementation to retain the same class type. + */ + override clone() { + const result = new DependencyGraph() + const keys = Object.keys(this["nodes"]) + for (const n of keys) { + result["nodes"][n] = this["nodes"][n] + result["outgoingEdges"][n] = [...this["outgoingEdges"][n]] + result["incomingEdges"][n] = [...this["incomingEdges"][n]] + } + return result + } + /** * Returns an error if cycles were found. */ diff --git a/core/src/graph/modules.ts b/core/src/graph/modules.ts index e19b3e46b5..7bb94537f7 100644 --- a/core/src/graph/modules.ts +++ b/core/src/graph/modules.ts @@ -91,7 +91,15 @@ export class ModuleGraph { [key: string]: EntityConfigEntry<"test", TestConfig> } - constructor(modules: GardenModule[], moduleTypes: ModuleTypeMap) { + constructor({ + modules, + moduleTypes, + skippedKeys, + }: { + modules: GardenModule[] + moduleTypes: ModuleTypeMap + skippedKeys?: Set + }) { this.dependencyGraph = {} this.modules = {} this.serviceConfigs = {} @@ -130,6 +138,10 @@ export class ModuleGraph { }) } + if (skippedKeys?.has(`deploy.${serviceName}`)) { + continue + } + this.serviceConfigs[serviceName] = { type: "service", moduleKey, config: serviceConfig } } @@ -151,11 +163,15 @@ export class ModuleGraph { }) } + if (skippedKeys?.has(`run.${taskName}`)) { + continue + } + this.taskConfigs[taskName] = { type: "task", moduleKey, config: taskConfig } } } - detectMissingDependencies(Object.values(this.modules)) + detectMissingDependencies(Object.values(this.modules), skippedKeys) // Add relations between nodes for (const module of modules) { @@ -183,6 +199,10 @@ export class ModuleGraph { // Service dependencies for (const serviceConfig of module.serviceConfigs) { + if (skippedKeys?.has(`deploy.${serviceConfig.name}`)) { + continue + } + const serviceNode = this.getNode( "deploy", serviceConfig.name, @@ -210,6 +230,10 @@ export class ModuleGraph { // Task dependencies for (const taskConfig of module.taskConfigs) { + if (skippedKeys?.has(`run.${taskConfig.name}`)) { + continue + } + const taskNode = this.getNode("run", taskConfig.name, moduleKey, module.disabled || taskConfig.disabled) if (needsBuild) { @@ -232,6 +256,10 @@ export class ModuleGraph { // Test dependencies for (const testConfig of module.testConfigs) { + if (skippedKeys?.has(`test.${module.name}-${testConfig.name}`)) { + continue + } + const testConfigName = module.name + "." + testConfig.name this.testConfigs[testConfigName] = { type: "test", moduleKey, config: testConfig } @@ -704,7 +732,7 @@ export class ModuleDependencyGraphNode { * Looks for dependencies on non-existent modules, services or tasks, and throws a ConfigurationError * if any were found. */ -export function detectMissingDependencies(moduleConfigs: ModuleConfig[]) { +export function detectMissingDependencies(moduleConfigs: ModuleConfig[], skippedKeys?: Set) { const moduleNames: Set = new Set(moduleConfigs.map((m) => m.name)) const serviceNames = moduleConfigs.flatMap((m) => m.serviceConfigs.map((s) => s.name)) const taskNames = moduleConfigs.flatMap((m) => m.taskConfigs.map((t) => t.name)) @@ -729,6 +757,10 @@ export function detectMissingDependencies(moduleConfigs: ModuleConfig[]) { for (const [configKey, entityName] of runtimeDepTypes) { for (const config of m[configKey]) { for (const missingRuntimeDep of config.dependencies.filter((d: string) => !runtimeNames.has(d))) { + if (skippedKeys?.has(`deploy.${missingRuntimeDep}`) || skippedKeys?.has(`run.${missingRuntimeDep}`)) { + // Don't flag missing dependencies that are explicitly skipped during resolution + continue + } missingDepDescriptions.push(deline` ${entityName} '${config.name}' (in module '${m.name}'): Unknown service or task '${missingRuntimeDep}' referenced in dependencies.`) diff --git a/core/src/resolve-module.ts b/core/src/resolve-module.ts index 183cb1c9b3..d552b13646 100644 --- a/core/src/resolve-module.ts +++ b/core/src/resolve-module.ts @@ -7,7 +7,7 @@ */ import cloneDeep from "fast-copy" -import { isArray, isString, keyBy, keys, partition, pick, union } from "lodash-es" +import { isArray, isString, keyBy, keys, partition, pick, union, uniq } from "lodash-es" import { validateWithPath } from "./config/validation.js" import { getModuleTemplateReferences, @@ -60,6 +60,8 @@ import type { ExecBuildConfig } from "./plugins/exec/build.js" import { pMemoizeDecorator } from "./lib/p-memoize.js" import { styles } from "./logger/styles.js" import { actionReferenceToString } from "./actions/base.js" +import type { DepGraph } from "dependency-graph" +import minimatch from "minimatch" // This limit is fairly arbitrary, but we need to have some cap on concurrent processing. export const moduleResolutionConcurrencyLimit = 50 @@ -101,32 +103,17 @@ export class ModuleResolver { this.bases = {} } - async resolveAll() { + async resolve({ actionsFilter }: { actionsFilter: string[] | undefined }) { // Collect template references for every raw config and work out module references in templates and explicit // dependency references. We use two graphs, one will be fully populated as we progress, the other we gradually // remove nodes from as we complete the processing. - const fullGraph = new DependencyGraph() - const processingGraph = new DependencyGraph() - const allPaths: string[] = [] + const fullGraph = new DependencyGraph() + const rawConfigs = Object.values(this.rawConfigsByKey) + const allPaths: string[] = rawConfigs.map((c) => c.path) - for (const key of Object.keys(this.rawConfigsByKey)) { - for (const graph of [fullGraph, processingGraph]) { - graph.addNode(key) - } - } + this.addModulesToGraph(fullGraph, rawConfigs) - for (const [key, rawConfig] of Object.entries(this.rawConfigsByKey)) { - const buildPath = this.garden.buildStaging.getBuildPath(rawConfig) - allPaths.push(rawConfig.path) - const deps = this.getModuleDependenciesFromConfig(rawConfig, buildPath) - for (const graph of [fullGraph, processingGraph]) { - for (const dep of deps) { - const depKey = dep.name - graph.addNode(depKey) - graph.addDependency(key, depKey) - } - } - } + const processingGraph = fullGraph.clone() const minimalRoots = await this.garden.vcs.getMinimalRoots(this.log, allPaths) @@ -136,7 +123,7 @@ export class ModuleResolver { const inFlight = new Set() - const processNode = async (moduleKey: string) => { + const processNode = async (moduleKey: string, forceResolve: boolean) => { if (inFlight.has(moduleKey)) { return } @@ -149,7 +136,7 @@ export class ModuleResolver { let foundNewDependency = false const dependencyNames = fullGraph.dependenciesOf(moduleKey) - const resolvedDependencies = dependencyNames.map((n) => resolvedModules[n]) + const resolvedDependencies = dependencyNames.map((n) => resolvedModules[n]).filter(Boolean) try { if (!resolvedConfig) { @@ -188,14 +175,21 @@ export class ModuleResolver { // it in the graph and move on to make sure we fully resolve the dependencies and don't run into circular // dependencies. if (!foundNewDependency) { - const buildPath = this.garden.buildStaging.getBuildPath(resolvedConfig) - resolvedModules[moduleKey] = await this.resolveModule({ - resolvedConfig, - buildPath, - dependencies: resolvedDependencies, - repoRoot: minimalRoots[resolvedConfig.path], - }) - this.log.silly(() => `ModuleResolver: Module ${moduleKey} resolved`) + const shouldResolve = + forceResolve || this.shouldResolveInline({ config: resolvedConfig, actionsFilter, fullGraph }) + + if (shouldResolve) { + const buildPath = this.garden.buildStaging.getBuildPath(resolvedConfig) + resolvedModules[moduleKey] = await this.resolveModule({ + resolvedConfig, + buildPath, + dependencies: resolvedDependencies, + repoRoot: minimalRoots[resolvedConfig.path], + }) + } else { + this.log.debug(() => `ModuleResolver: Module ${moduleKey} skipped`) + } + processingGraph.removeNode(moduleKey) } } catch (err) { @@ -204,10 +198,10 @@ export class ModuleResolver { } inFlight.delete(moduleKey) - return processLeaves() + return processLeaves(forceResolve) } - const processLeaves = async () => { + const processLeaves = async (forceResolve: boolean) => { if (Object.keys(errors).length > 0) { const errorStr = Object.entries(errors) .map(([name, err]) => `${styles.highlight.bold(name)}: ${err.message}`) @@ -247,7 +241,7 @@ export class ModuleResolver { } // Process each of the leaf node module configs. - await Promise.all(batch.map(processNode)) + await Promise.all(batch.map((m) => processNode(m, forceResolve))) } // Iterate through dependency graph, a batch of leaves at a time. While there are items remaining: @@ -255,10 +249,241 @@ export class ModuleResolver { while (processingGraph.size() > 0) { this.log.silly(() => `ModuleResolver: Loop ${++i}`) - await processLeaves() + await processLeaves(false) + } + + // Need to make sure we resolve modules that contain runtime dependencies of services, tasks and tests specified + // in actionsFilter (if any), including transitive dependencies. + let mayNeedAdditionalResolution = false + + for (const f of actionsFilter || []) { + // Build dependencies, i.e. module-to-module deps, will already be accounted for above. + if (!f.startsWith("build.")) { + mayNeedAdditionalResolution = true + } + } + + let runtimeGraph = new DependencyGraph() + + if (mayNeedAdditionalResolution) { + runtimeGraph = fullGraph.clone() + const serviceNames = new Set() + const taskNames = new Set() + + // Add runtime dependencies to the module dependency graph + for (const config of Object.values(resolvedConfigs)) { + for (const service of config.serviceConfigs) { + const key = `deploy.${service.name}` + runtimeGraph.addNode(key) + runtimeGraph.addDependency(key, config.name) + serviceNames.add(service.name) + } + for (const task of config.taskConfigs) { + const key = `run.${task.name}` + runtimeGraph.addNode(key) + runtimeGraph.addDependency(key, config.name) + taskNames.add(task.name) + } + for (const test of config.testConfigs) { + const key = `test.${config.name}-${test.name}` + runtimeGraph.addNode(key) + runtimeGraph.addDependency(key, config.name) + } + } + + const addRuntimeDep = (key: string, dep: string) => { + const depType = serviceNames.has(dep) ? "deploy" : taskNames.has(dep) ? "run" : null + if (depType) { + const depKey = `${depType}.${dep}` + runtimeGraph.addNode(depKey) + runtimeGraph.addDependency(key, depKey) + } + } + + for (const config of Object.values(resolvedConfigs)) { + for (const service of config.serviceConfigs) { + const key = `deploy.${service.name}` + for (const dep of service.dependencies || []) { + addRuntimeDep(key, dep) + } + } + for (const task of config.taskConfigs) { + const key = `run.${task.name}` + for (const dep of task.dependencies || []) { + addRuntimeDep(key, dep) + } + } + for (const test of config.testConfigs) { + const key = `test.${config.name}-${test.name}` + for (const dep of test.dependencies) { + addRuntimeDep(key, dep) + } + } + } + + // Collect all modules that still need to be resolved + const needResolve: { [key: string]: ModuleConfig } = {} + + for (const pattern of actionsFilter || []) { + const deps = this.dependenciesOfWildcard(runtimeGraph, pattern) + // Note: Module names in the graph don't have the build. prefix + const moduleDepNames = deps.filter((d) => !d.includes(".")) + for (const name of moduleDepNames) { + if (!resolvedModules[name] && resolvedConfigs[name]) { + needResolve[name] = resolvedConfigs[name] + } + } + } + + // Populate the processing graph and then resolve the remaining modules + this.addModulesToGraph(processingGraph, Object.values(needResolve)) + + while (processingGraph.size() > 0) { + this.log.silly(() => `ModuleResolver: Loop ${++i}`) + await processLeaves(true) + } } - return Object.values(resolvedModules) + const skipped = new Set() + + if (actionsFilter && mayNeedAdditionalResolution) { + for (const config of Object.values(resolvedConfigs)) { + if (!resolvedModules[config.name]) { + skipped.add(`build.${config.name}`) + for (const s of config.serviceConfigs) { + skipped.add(`deploy.${s.name}`) + } + for (const t of config.taskConfigs) { + skipped.add(`run.${t.name}`) + } + for (const t of config.testConfigs) { + skipped.add(`test.${config.name}-${t.name}`) + } + } + } + + const maybeSkip = (key: string) => { + // Don't skip anything that's requested in the filter + if (this.matchFilter(key, actionsFilter)) { + return + } + // Flag as skipped if the module is resolved but the action isn't requested, and it is not depended on by + // anything that is requested. + for (const f of actionsFilter) { + if (this.matchFilter(key, this.dependenciesOfWildcard(runtimeGraph, f))) { + return + } + } + skipped.add(key) + } + + for (const m of Object.values(resolvedModules)) { + for (const s of m.serviceConfigs) { + maybeSkip(`deploy.${s.name}`) + } + for (const t of m.taskConfigs) { + maybeSkip(`run.${t.name}`) + } + for (const t of m.testConfigs) { + maybeSkip(`test.${m.name}-${t.name}`) + } + } + } + + return { skipped, resolvedModules: Object.values(resolvedModules), resolvedConfigs: Object.values(resolvedConfigs) } + } + + private addModulesToGraph(graph: DepGraph, configs: ModuleConfig[]) { + for (const config of configs) { + graph.addNode(config.name) + } + + for (const config of configs) { + const buildPath = this.garden.buildStaging.getBuildPath(config) + const deps = this.getModuleDependenciesFromConfig(config, buildPath) + for (const dep of deps) { + const depKey = dep.name + graph.addNode(depKey) + graph.addDependency(config.name, depKey) + } + } + } + + /** + * Returns true if we know that the module should be resolved during the initial pass in config resolution. + * This is the case if no filter is set, the module itself is set in the actions filter, or if it's depended on + * by something set in the filter. + * + * After the first pass of config resolution, we do a separate check to see if an entity (service or task) is + * depended upon in any of the resolved modules in the first pass. + */ + private shouldResolveInline({ + config, + actionsFilter, + fullGraph, + }: { + config: ModuleConfig + actionsFilter: string[] | undefined + fullGraph: DependencyGraph + }) { + if (!actionsFilter) { + return true + } + + // Is the module itself set in the filter? + if (this.moduleMatchesFilter(config, actionsFilter)) { + return true + } + + // Is it depended on (at the module level) by something set in the filter? + const dependantKeys = fullGraph.dependantsOf(config.name) + for (const key of dependantKeys) { + if (this.matchFilter(`build.${key}`, actionsFilter)) { + return true + } + } + + return false + } + + private matchFilter(key: string, actionsFilter: string[] | undefined) { + if (!actionsFilter) { + return true + } + return actionsFilter.some((f: string) => minimatch(key, f)) + } + + private dependenciesOfWildcard(graph: DependencyGraph, pattern: string) { + const matchedKeys = graph.keys().filter((k) => minimatch(k, pattern)) + return uniq(matchedKeys.flatMap((k) => graph.dependenciesOf(k))) + } + + private moduleMatchesFilter(config: ModuleConfig, actionsFilter: string[] | undefined) { + if (!actionsFilter) { + return true + } + + const match = (n: string) => actionsFilter.some((f: string) => minimatch(n, f)) + + if (match(`build.${config.name}`)) { + return true + } + for (const s of config.serviceConfigs) { + if (match(`deploy.${s}`)) { + return true + } + } + for (const t of config.taskConfigs) { + if (match(`run.${t}`)) { + return true + } + } + for (const t of config.testConfigs) { + if (match(`test.${config.name}-${t}`)) { + return true + } + } + return false } /** @@ -532,7 +757,7 @@ export class ModuleResolver { dependencies: GardenModule[] repoRoot: string }) { - this.log.silly(() => `Resolving module ${resolvedConfig.name}`) + this.log.debug(() => `Resolving module ${resolvedConfig.name}`) // Write module files const configContext = new ModuleConfigContext({ @@ -652,6 +877,9 @@ export class ModuleResolver { }) } } + + this.log.debug(() => `ModuleResolver: Module ${resolvedConfig.name} resolved`) + return module } diff --git a/core/test/unit/src/actions/action-configs-to-graph.ts b/core/test/unit/src/actions/action-configs-to-graph.ts index bb065404ce..2d5e1eed81 100644 --- a/core/test/unit/src/actions/action-configs-to-graph.ts +++ b/core/test/unit/src/actions/action-configs-to-graph.ts @@ -51,7 +51,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -82,7 +82,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -113,7 +113,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -144,7 +144,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -182,7 +182,7 @@ describe("actionConfigsToGraph", () => { }, ], configs: [], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -224,7 +224,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -271,7 +271,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -319,7 +319,7 @@ describe("actionConfigsToGraph", () => { }, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -368,7 +368,7 @@ describe("actionConfigsToGraph", () => { }, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -417,7 +417,7 @@ describe("actionConfigsToGraph", () => { }, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -454,7 +454,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -482,7 +482,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -512,7 +512,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -546,7 +546,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -585,7 +585,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -640,7 +640,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -682,7 +682,7 @@ describe("actionConfigsToGraph", () => { }, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), linkedSources: {}, actionModes: { sync: ["deploy.foo"], @@ -712,7 +712,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), linkedSources: {}, actionModes: { local: ["deploy.foo"], @@ -747,7 +747,7 @@ describe("actionConfigsToGraph", () => { }, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), linkedSources: {}, actionModes: { local: ["deploy.foo"], @@ -778,7 +778,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), linkedSources: {}, actionModes: { local: ["*"], @@ -808,7 +808,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), linkedSources: {}, actionModes: { local: ["deploy.f*"], @@ -850,7 +850,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), linkedSources: {}, actionModes: { local: ["deploy.*"], @@ -883,7 +883,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }), @@ -920,7 +920,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }), @@ -958,7 +958,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -995,7 +995,7 @@ describe("actionConfigsToGraph", () => { spec: {}, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -1026,7 +1026,7 @@ describe("actionConfigsToGraph", () => { }, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) @@ -1062,7 +1062,7 @@ describe("actionConfigsToGraph", () => { exclude, }, ], - moduleGraph: new ModuleGraph([], {}), + moduleGraph: new ModuleGraph({ modules: [], moduleTypes: {} }), actionModes: {}, linkedSources: {}, }) diff --git a/core/test/unit/src/commands/build.ts b/core/test/unit/src/commands/build.ts index 8742d5cbfd..31efe07a86 100644 --- a/core/test/unit/src/commands/build.ts +++ b/core/test/unit/src/commands/build.ts @@ -9,6 +9,7 @@ import { BuildCommand } from "../../../../src/commands/build.js" import { expect } from "chai" import { + getAllProcessedTaskNames, makeModuleConfig, makeTestGarden, makeTestGardenA, @@ -25,6 +26,7 @@ const { writeFile } = fsExtra import { join } from "path" import type { ProcessCommandResult } from "../../../../src/commands/base.js" import { nodeKey } from "../../../../src/graph/modules.js" +import { gardenEnv } from "../../../../src/constants.js" describe("BuildCommand", () => { it("should build everything in a project and output the results", async () => { @@ -108,6 +110,68 @@ describe("BuildCommand", () => { expect(taskOutputResults["build.module-b"].state).to.equal("ready") }) + context("GARDEN_ENABLE_PARTIAL_RESOLUTION=true", () => { + const originalValue = gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION + + before(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = true + }) + + after(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = originalValue + }) + + it("should optionally build and deploy single service and its dependencies", async () => { + const garden = await makeTestGardenA([], { noCache: true }) + const log = garden.log + const command = new BuildCommand() + + const { result, errors } = await command.action({ + garden, + log, + args: { names: ["module-b"] }, + opts: withDefaultGlobalOpts({ "watch": false, "force": true, "with-dependants": false }), + }) + + if (errors) { + throw errors[0] + } + + const taskOutputResults = taskResultOutputs(result!) + expect(taskOutputResults["build.module-b"].state).to.equal("ready") + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.not.include("build.module-c") + expect(keys).to.not.include("resolve-action.build.module-c") + }) + + it("works with wildcard name", async () => { + const garden = await makeTestGardenA([], { noCache: true }) + const log = garden.log + const command = new BuildCommand() + + const { result, errors } = await command.action({ + garden, + log, + args: { names: ["*-b"] }, + opts: withDefaultGlobalOpts({ "watch": false, "force": true, "with-dependants": false }), + }) + + if (errors) { + throw errors[0] + } + + const taskOutputResults = taskResultOutputs(result!) + expect(taskOutputResults["build.module-b"].state).to.equal("ready") + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.not.include("build.module-c") + expect(keys).to.not.include("resolve-action.build.module-c") + }) + }) + it("should be protected", async () => { const command = new BuildCommand() expect(command.protected).to.be.true diff --git a/core/test/unit/src/commands/deploy.ts b/core/test/unit/src/commands/deploy.ts index c4e2d4b5b1..b86c4c233f 100644 --- a/core/test/unit/src/commands/deploy.ts +++ b/core/test/unit/src/commands/deploy.ts @@ -23,6 +23,7 @@ import type { ActionStatus } from "../../../../src/actions/types.js" import type { DeployStatus } from "../../../../src/plugin/handlers/Deploy/get-status.js" import { defaultServerPort } from "../../../../src/commands/serve.js" import { zodObjectToJoi } from "../../../../src/config/common.js" +import { gardenEnv } from "../../../../src/constants.js" // TODO-G2: rename test cases to match the new graph model semantics const placeholderTimestamp = new Date() @@ -224,6 +225,80 @@ describe("DeployCommand", () => { ]) }) + context("GARDEN_ENABLE_PARTIAL_RESOLUTION=true", () => { + const originalValue = gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION + + before(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = true + }) + + after(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = originalValue + }) + + it("should optionally build and deploy single service and its dependencies", async () => { + const garden = await makeTestGarden(projectRootA, { plugins: [testProvider()], noCache: true }) + const log = garden.log + + const { result, errors } = await command.action({ + garden, + log, + args: { + names: ["service-b"], + }, + opts: { ...defaultDeployOpts, force: true }, + }) + + if (errors) { + throw errors[0] + } + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.eql([ + "build.module-a", + "build.module-b", + "deploy.service-a", + "deploy.service-b", + "resolve-action.build.module-a", + "resolve-action.build.module-b", + "resolve-action.deploy.service-a", + "resolve-action.deploy.service-b", + ]) + }) + + it("works with wildcard name", async () => { + const garden = await makeTestGarden(projectRootA, { plugins: [testProvider()], noCache: true }) + const log = garden.log + + const { result, errors } = await command.action({ + garden, + log, + args: { + names: ["*-b"], + }, + opts: { ...defaultDeployOpts, force: true }, + }) + + if (errors) { + throw errors[0] + } + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.eql([ + "build.module-a", + "build.module-b", + "deploy.service-a", + "deploy.service-b", + "resolve-action.build.module-a", + "resolve-action.build.module-b", + "resolve-action.deploy.service-a", + "resolve-action.deploy.service-b", + ]) + }) + }) + context("when --skip-dependencies is passed", () => { it("should not process runtime dependencies for the requested services", async () => { const garden = await makeTestGarden(projectRootA, { plugins: [testProvider()] }) diff --git a/core/test/unit/src/commands/get/get-actions.ts b/core/test/unit/src/commands/get/get-actions.ts index 5e35ef1f3b..3253664e79 100644 --- a/core/test/unit/src/commands/get/get-actions.ts +++ b/core/test/unit/src/commands/get/get-actions.ts @@ -16,6 +16,7 @@ import type { ActionRouter } from "../../../../../src/router/router.js" import type { ResolvedConfigGraph } from "../../../../../src/graph/config-graph.js" import type { Log } from "../../../../../src/logger/log-entry.js" import { sortBy } from "lodash-es" +import { gardenEnv } from "../../../../../src/constants.js" export const getActionsToSimpleOutput = (d) => { return { name: d.name, kind: d.kind, type: d.type } @@ -132,6 +133,35 @@ describe("GetActionsCommand", () => { expect(result?.actions).to.eql(expected) }) + it("should return specific actions by reference in a project", async () => { + const garden = await makeTestGarden(projectRoot) + const log = garden.log + const command = new GetActionsCommand() + + const { result } = await command.action({ + garden, + log, + args: { names: ["run.task-a", "build.module-b"] }, + opts: withDefaultGlobalOpts({ "detail": false, "sort": "name", "include-state": false, "kind": "" }), + }) + + const expected = [ + { + kind: "Build", + name: "module-b", + type: "test", + }, + { + kind: "Run", + name: "task-a", + type: "test", + }, + ] + + expect(command.outputsSchema().validate(result).error).to.be.undefined + expect(result?.actions).to.eql(expected) + }) + it("should return all actions in a project with additional info when --detail is set", async () => { const garden = await makeTestGarden(projectRoot) const log = garden.log @@ -273,4 +303,81 @@ describe("GetActionsCommand", () => { expect(command.outputsSchema().validate(result).error).to.be.undefined expect(result).to.eql({ actions: expected }) }) + + context("GARDEN_ENABLE_PARTIAL_RESOLUTION=true", () => { + const originalValue = gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION + + before(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = true + }) + + after(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = originalValue + }) + + it("should return all actions in a project", async () => { + const garden = await makeTestGarden(projectRoot) + const log = garden.log + const command = new GetActionsCommand() + + const { result } = await command.action({ + garden, + log, + args: { names: undefined }, + opts: withDefaultGlobalOpts({ "detail": false, "sort": "name", "include-state": false, "kind": "" }), + }) + + const graph = await garden.getResolvedConfigGraph({ log, emit: false }) + const expected = sortBy(graph.getActions().map(getActionsToSimpleOutput), "name") + + expect(command.outputsSchema().validate(result).error).to.be.undefined + expect(result?.actions).to.eql(expected) + }) + + it("should return specific actions by reference in a project", async () => { + const garden = await makeTestGarden(projectRoot) + const log = garden.log + const command = new GetActionsCommand() + + const { result } = await command.action({ + garden, + log, + args: { names: ["run.task-a", "build.module-b"] }, + opts: withDefaultGlobalOpts({ "detail": false, "sort": "name", "include-state": false, "kind": "" }), + }) + + const expected = [ + { + kind: "Build", + name: "module-b", + type: "test", + }, + { + kind: "Run", + name: "task-a", + type: "test", + }, + ] + + expect(command.outputsSchema().validate(result).error).to.be.undefined + expect(result?.actions).to.eql(expected) + }) + + it("should return all actions of specific kind in a project when --kind is set", async () => { + const garden = await makeTestGarden(projectRoot) + const log = garden.log + const command = new GetActionsCommand() + + const { result } = await command.action({ + garden, + log, + args: { names: undefined }, + opts: withDefaultGlobalOpts({ "detail": false, "sort": "name", "include-state": false, "kind": "deploy" }), + }) + const graph = await garden.getResolvedConfigGraph({ log, emit: false }) + const expected = sortBy(graph.getDeploys().map(getActionsToSimpleOutput), "name") + expect(command.outputsSchema().validate(result).error).to.be.undefined + expect(result).to.eql({ actions: expected }) + }) + }) }) diff --git a/core/test/unit/src/commands/get/get-modules.ts b/core/test/unit/src/commands/get/get-modules.ts index c5b7ad516b..d0189d1a33 100644 --- a/core/test/unit/src/commands/get/get-modules.ts +++ b/core/test/unit/src/commands/get/get-modules.ts @@ -11,6 +11,7 @@ import { keyBy, mapValues } from "lodash-es" import { makeTestGardenA, withDefaultGlobalOpts } from "../../../../helpers.js" import { GetModulesCommand } from "../../../../../src/commands/get/get-modules.js" import { withoutInternalFields } from "../../../../../src/util/logging.js" +import { gardenEnv } from "../../../../../src/constants.js" describe("GetModulesCommand", () => { const command = new GetModulesCommand() @@ -73,4 +74,37 @@ describe("GetModulesCommand", () => { expect(res.result.modules["module-a"]["buildDependencies"]).to.be.undefined expect(res.result.modules["module-a"].version.dependencyVersions).to.be.undefined }) + + context("GARDEN_ENABLE_PARTIAL_RESOLUTION=true", () => { + const originalValue = gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION + + before(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = true + }) + + after(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = originalValue + }) + + it("returns specified module in a project", async () => { + const garden = await makeTestGardenA() + const log = garden.log + + const res = await command.action({ + garden, + log, + args: { modules: ["module-a"] }, + opts: withDefaultGlobalOpts({ "exclude-disabled": false, "full": false }), + }) + + expect(command.outputsSchema().validate(res.result).error).to.be.undefined + + const graph = await garden.getConfigGraph({ log, emit: false }) + const moduleA = graph.getModule("module-a") + + expect(res.result).to.eql({ modules: { "module-a": withoutInternalFields(moduleA) } }) + expect(res.result.modules["module-a"]["buildDependencies"]).to.be.undefined + expect(res.result.modules["module-a"].version.dependencyVersions).to.be.undefined + }) + }) }) diff --git a/core/test/unit/src/commands/run.ts b/core/test/unit/src/commands/run.ts index ac3b69305a..a1696fa524 100644 --- a/core/test/unit/src/commands/run.ts +++ b/core/test/unit/src/commands/run.ts @@ -12,7 +12,7 @@ import type { TestGarden } from "../../../helpers.js" import { expectError, getAllProcessedTaskNames, makeTestGarden, getDataDir, makeTestGardenA } from "../../../helpers.js" import { expectLogsContain, getLogMessages } from "../../../../src/util/testing.js" import { LogLevel } from "../../../../src/logger/logger.js" -import { DEFAULT_RUN_TIMEOUT_SEC } from "../../../../src/constants.js" +import { DEFAULT_RUN_TIMEOUT_SEC, gardenEnv } from "../../../../src/constants.js" // TODO-G2: fill in test implementations. use TestCommand tests for reference. @@ -42,6 +42,66 @@ describe("RunCommand", () => { expect(Object.keys(result!.graphResults).sort()).to.eql(["run.task-a"]) }) + context("GARDEN_ENABLE_PARTIAL_RESOLUTION=true", () => { + const originalValue = gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION + + before(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = true + }) + + after(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = originalValue + }) + + it("should optionally build and deploy single service and its dependencies", async () => { + const { result } = await garden.runCommand({ + command, + args: { names: ["task-a"] }, + opts: { + "force": true, + "force-build": true, + "watch": false, + "skip": [], + "skip-dependencies": false, + "module": undefined, + }, + }) + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.eql([ + "build.module-a", + "resolve-action.build.module-a", + "resolve-action.run.task-a", + "run.task-a", + ]) + }) + + it("works with wildcard name", async () => { + const { result } = await garden.runCommand({ + command, + args: { names: ["*-a"] }, + opts: { + "force": true, + "force-build": true, + "watch": false, + "skip": [], + "skip-dependencies": false, + "module": undefined, + }, + }) + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.eql([ + "build.module-a", + "resolve-action.build.module-a", + "resolve-action.run.task-a", + "run.task-a", + ]) + }) + }) + it("should optionally skip tests by name", async () => { const { result } = await garden.runCommand({ command, diff --git a/core/test/unit/src/commands/test.ts b/core/test/unit/src/commands/test.ts index 751a9e2cc0..46952ae3db 100644 --- a/core/test/unit/src/commands/test.ts +++ b/core/test/unit/src/commands/test.ts @@ -19,6 +19,7 @@ import { } from "../../../helpers.js" import type { ModuleConfig } from "../../../../src/config/module.js" import type { Log } from "../../../../src/logger/log-entry.js" +import { gardenEnv } from "../../../../src/constants.js" describe("TestCommand", () => { const command = new TestCommand() @@ -102,6 +103,110 @@ describe("TestCommand", () => { expect(Object.keys(result!.graphResults)).to.eql(["test.module-a-unit"]) }) + context("GARDEN_ENABLE_PARTIAL_RESOLUTION=true", () => { + const originalValue = gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION + + before(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = true + }) + + after(() => { + gardenEnv.GARDEN_ENABLE_PARTIAL_RESOLUTION = originalValue + }) + + it("should optionally test single module", async () => { + const { result } = await command.action({ + garden, + log, + args: { names: undefined }, + opts: withDefaultGlobalOpts({ + "name": undefined, + "force": true, + "force-build": true, + "watch": false, + "skip": [], + "skip-dependencies": false, + "skip-dependants": false, + "interactive": false, + "module": ["module-a"], // <--- + }), + }) + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.eql([ + "build.module-a", + "deploy.service-a", + "resolve-action.build.module-a", + "resolve-action.deploy.service-a", + "resolve-action.test.module-a-integration", + "resolve-action.test.module-a-unit", + "test.module-a-integration", + "test.module-a-unit", + ]) + }) + + it("should optionally run single test", async () => { + const { result } = await command.action({ + garden, + log, + args: { names: ["module-a-unit"] }, // <--- + opts: withDefaultGlobalOpts({ + "name": undefined, + "force": true, + "force-build": true, + "watch": false, + "skip": [], + "skip-dependencies": false, + "skip-dependants": false, + "interactive": false, + "module": undefined, + }), + }) + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.eql([ + "build.module-a", + "resolve-action.build.module-a", + "resolve-action.test.module-a-unit", + "test.module-a-unit", + ]) + }) + + it("works with wildcard name", async () => { + const { result } = await command.action({ + garden, + log, + args: { names: ["module-a-*"] }, // <--- + opts: withDefaultGlobalOpts({ + "name": undefined, + "force": true, + "force-build": true, + "watch": false, + "skip": [], + "skip-dependencies": false, + "skip-dependants": false, + "interactive": false, + "module": undefined, + }), + }) + + const keys = getAllProcessedTaskNames(result!.graphResults) + + expect(keys).to.eql([ + "build.module-a", + "deploy.service-a", + "resolve-action.build.module-a", + "resolve-action.deploy.service-a", + "resolve-action.test.module-a-integration", + "resolve-action.test.module-a-unit", + "test.module-a-integration", + "test.module-a-unit", + ]) + }) + }) + it("should optionally skip tests by name", async () => { const { result } = await command.action({ garden,