diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 939a9025109..2bfeb85049d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -402,7 +402,7 @@ config-inversion-linter: needs: [] script: - ./gradlew --version - - ./gradlew logEnvVarUsages checkEnvironmentVariablesUsage checkConfigStrings + - ./gradlew logEnvVarUsages checkEnvironmentVariablesUsage checkConfigStrings checkInstrumenterModuleConfigurations checkDecoratorAnalyticsConfigurations test_published_artifacts: extends: .gradle_build diff --git a/buildSrc/src/main/kotlin/datadog/gradle/plugin/config/ConfigInversionLinter.kt b/buildSrc/src/main/kotlin/datadog/gradle/plugin/config/ConfigInversionLinter.kt index 74c184a47ae..d2afd581957 100644 --- a/buildSrc/src/main/kotlin/datadog/gradle/plugin/config/ConfigInversionLinter.kt +++ b/buildSrc/src/main/kotlin/datadog/gradle/plugin/config/ConfigInversionLinter.kt @@ -4,15 +4,25 @@ import com.github.javaparser.ParserConfiguration import com.github.javaparser.StaticJavaParser import com.github.javaparser.ast.CompilationUnit import com.github.javaparser.ast.Modifier +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration import com.github.javaparser.ast.body.FieldDeclaration +import com.github.javaparser.ast.body.MethodDeclaration import com.github.javaparser.ast.body.VariableDeclarator import com.github.javaparser.ast.expr.StringLiteralExpr import com.github.javaparser.ast.nodeTypes.NodeWithModifiers +import com.github.javaparser.ast.stmt.ExplicitConstructorInvocationStmt +import com.github.javaparser.ast.stmt.ReturnStmt +import org.gradle.api.DefaultTask import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.SourceSet import org.gradle.api.tasks.SourceSetContainer +import org.gradle.api.tasks.TaskAction import org.gradle.kotlin.dsl.getByType import java.net.URLClassLoader import java.nio.file.Path @@ -23,13 +33,16 @@ class ConfigInversionLinter : Plugin { registerLogEnvVarUsages(target, extension) registerCheckEnvironmentVariablesUsage(target) registerCheckConfigStringsTask(target, extension) + registerCheckInstrumenterModuleConfigurations(target, extension) + registerCheckDecoratorAnalyticsConfigurations(target, extension) } } // Data class for fields from generated class -private data class LoadedConfigFields( +data class LoadedConfigFields( val supported: Set, - val aliasMapping: Map = emptyMap() + val aliasMapping: Map = emptyMap(), + val aliases: Map> = emptyMap() ) // Cache for fields from generated class @@ -55,7 +68,9 @@ private fun loadConfigFields( @Suppress("UNCHECKED_CAST") val aliasMappingMap = clazz.getField("ALIAS_MAPPING").get(null) as Map - LoadedConfigFields(supportedSet, aliasMappingMap) + @Suppress("UNCHECKED_CAST") + val aliasesMap = clazz.getField("ALIASES").get(null) as Map> + LoadedConfigFields(supportedSet, aliasMappingMap, aliasesMap) }.also { cachedConfigFields = it } } } @@ -248,3 +263,206 @@ private fun registerCheckConfigStringsTask(project: Project, extension: Supporte } } } + +/** Collects violations for [key] against [supported] and [aliases], checking that all [expectedAliases] are values of that alias entry. */ +private fun collectMissingKeysAndAliases( + key: String, + expectedAliases: List, + supported: Set, + aliases: Map>, + location: String, + context: String +): List = buildList { + if (key !in supported) { + add("$location -> $context: '$key' is missing from SUPPORTED") + } + if (key !in aliases) { + add("$location -> $context: '$key' is missing from ALIASES") + } else { + val aliasValues = aliases[key] ?: emptyList() + for (expected in expectedAliases) { + if (expected !in aliasValues) { + add("$location -> $context: '$expected' is missing from ALIASES['$key']") + } + } + } +} + +/** Abstract base for tasks that scan instrumentation source files against the generated config class. */ +abstract class InstrumentationConfigCheckTask : DefaultTask() { + @get:InputFiles + abstract val mainSourceSetOutput: ConfigurableFileCollection + + @get:InputFiles + abstract val instrumentationFiles: ConfigurableFileCollection + + @get:Input + abstract val generatedClassName: Property + + @get:Input + abstract val errorHeader: Property + + @get:Input + abstract val errorMessage: Property + + @get:Input + abstract val successMessage: Property + + @TaskAction + fun execute() { + val configFields = loadConfigFields(mainSourceSetOutput, generatedClassName.get()) + + val parserConfig = ParserConfiguration() + parserConfig.setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_8) + StaticJavaParser.setConfiguration(parserConfig) + + val repoRoot = project.rootProject.projectDir.toPath() + val violations = instrumentationFiles.files.flatMap { file -> + val rel = repoRoot.relativize(file.toPath()).toString() + val cu: CompilationUnit = try { + StaticJavaParser.parse(file) + } catch (_: Exception) { + return@flatMap emptyList() + } + collectPropertyViolations(configFields, rel, cu) + } + + if (violations.isNotEmpty()) { + logger.error(errorHeader.get()) + violations.forEach { logger.lifecycle(it) } + throw GradleException(errorMessage.get()) + } else { + logger.info(successMessage.get()) + } + } + + protected abstract fun collectPropertyViolations( + configFields: LoadedConfigFields, relativePath: String, cu: CompilationUnit + ): List +} + +/** Checks that InstrumenterModule integration names have proper entries in SUPPORTED and ALIASES. */ +abstract class CheckInstrumenterModuleConfigTask : InstrumentationConfigCheckTask() { + override fun collectPropertyViolations( + configFields: LoadedConfigFields, relativePath: String, cu: CompilationUnit + ): List { + val violations = mutableListOf() + + cu.findAll(ClassOrInterfaceDeclaration::class.java).forEach classLoop@{ classDecl -> + val extendsModule = classDecl.extendedTypes.any { it.toString().startsWith("InstrumenterModule") } + if (!extendsModule) return@classLoop + + classDecl.findAll(ExplicitConstructorInvocationStmt::class.java) + .filter { !it.isThis } + .forEach { superCall -> + val names = superCall.arguments + .filterIsInstance() + .map { it.value } + val line = superCall.range.map { it.begin.line }.orElse(1) + + for (name in names) { + val normalized = name.uppercase().replace("-", "_").replace(".", "_") + val enabledKey = "DD_TRACE_${normalized}_ENABLED" + val context = "Integration '$name' (super arg)" + val location = "$relativePath:$line" + + violations.addAll(collectMissingKeysAndAliases( + enabledKey, + listOf("DD_TRACE_INTEGRATION_${normalized}_ENABLED", "DD_INTEGRATION_${normalized}_ENABLED"), + configFields.supported, configFields.aliases, location, context + )) + } + } + } + + return violations + } +} + +/** Checks that Decorator instrumentationNames have proper analytics entries in SUPPORTED and ALIASES. */ +abstract class CheckDecoratorAnalyticsConfigTask : InstrumentationConfigCheckTask() { + override fun collectPropertyViolations( + configFields: LoadedConfigFields, relativePath: String, cu: CompilationUnit + ): List { + val violations = mutableListOf() + + cu.findAll(MethodDeclaration::class.java) + .filter { it.nameAsString == "instrumentationNames" && it.parameters.isEmpty() } + .forEach { method -> + val names = method.findAll(ReturnStmt::class.java).flatMap { ret -> + ret.expression.map { it.findAll(StringLiteralExpr::class.java).map { s -> s.value } } + .orElse(emptyList()) + } + val line = method.range.map { it.begin.line }.orElse(1) + + for (name in names) { + val normalized = name.uppercase().replace("-", "_").replace(".", "_") + val context = "Decorator instrumentationName '$name'" + val location = "$relativePath:$line" + + violations.addAll(collectMissingKeysAndAliases( + "DD_TRACE_${normalized}_ANALYTICS_ENABLED", + listOf("DD_${normalized}_ANALYTICS_ENABLED"), + configFields.supported, configFields.aliases, location, context + )) + violations.addAll(collectMissingKeysAndAliases( + "DD_TRACE_${normalized}_ANALYTICS_SAMPLE_RATE", + listOf("DD_${normalized}_ANALYTICS_SAMPLE_RATE"), + configFields.supported, configFields.aliases, location, context + )) + } + } + + return violations + } +} + +/** Registers `checkInstrumenterModuleConfigurations` to verify each InstrumenterModule's integration name has proper entries in SUPPORTED and ALIASES. */ +private fun registerCheckInstrumenterModuleConfigurations(project: Project, extension: SupportedTracerConfigurations) { + val ownerPath = extension.configOwnerPath + val generatedFile = extension.className + + project.tasks.register("checkInstrumenterModuleConfigurations", CheckInstrumenterModuleConfigTask::class.java) { + group = "verification" + description = "Validates that InstrumenterModule integration names have corresponding entries in SUPPORTED and ALIASES" + + mainSourceSetOutput.from(ownerPath.map { + project.project(it) + .extensions.getByType() + .named(SourceSet.MAIN_SOURCE_SET_NAME) + .map { main -> main.output } + }) + instrumentationFiles.from(project.fileTree(project.rootProject.projectDir) { + include("dd-java-agent/instrumentation/**/src/main/java/**/*.java") + }) + generatedClassName.set(generatedFile) + errorHeader.set("\nFound InstrumenterModule integration names with missing SUPPORTED/ALIASES entries:") + errorMessage.set("InstrumenterModule integration names are missing from SUPPORTED or ALIASES in '${extension.jsonFile.get()}'.") + successMessage.set("All InstrumenterModule integration names have proper SUPPORTED and ALIASES entries.") + } +} + +/** Registers `checkDecoratorAnalyticsConfigurations` to verify each BaseDecorator subclass's instrumentationNames have proper analytics entries in SUPPORTED and ALIASES. */ +private fun registerCheckDecoratorAnalyticsConfigurations(project: Project, extension: SupportedTracerConfigurations) { + val ownerPath = extension.configOwnerPath + val generatedFile = extension.className + + project.tasks.register("checkDecoratorAnalyticsConfigurations", CheckDecoratorAnalyticsConfigTask::class.java) { + group = "verification" + description = "Validates that Decorator instrumentationNames have corresponding analytics entries in SUPPORTED and ALIASES" + + mainSourceSetOutput.from(ownerPath.map { + project.project(it) + .extensions.getByType() + .named(SourceSet.MAIN_SOURCE_SET_NAME) + .map { main -> main.output } + }) + instrumentationFiles.from(project.fileTree(project.rootProject.projectDir) { + include("dd-java-agent/instrumentation/**/src/main/java/**/*.java") + }) + generatedClassName.set(generatedFile) + errorHeader.set("\nFound Decorator instrumentationNames with missing analytics SUPPORTED/ALIASES entries:") + errorMessage.set("Decorator instrumentationNames are missing analytics entries from SUPPORTED or ALIASES in '${extension.jsonFile.get()}'.") + successMessage.set("All Decorator instrumentationNames have proper analytics SUPPORTED and ALIASES entries.") + } +}