-
Notifications
You must be signed in to change notification settings - Fork 312
Adding Gradle Plugins for Config Inversion #9565
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 17 commits
fd07055
caeabd9
1f3e635
f2ae15c
3728184
0dddffe
044ea43
7034d38
a934971
833207d
1c780b3
bba22a3
a1d4188
e163b60
ec931f4
c74f883
3b92a69
d30ad8f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,128 @@ | ||
package datadog.gradle.plugin.config | ||
|
||
import org.gradle.api.Plugin | ||
import org.gradle.api.Project | ||
import org.gradle.api.GradleException | ||
import org.gradle.api.tasks.SourceSet | ||
import org.gradle.api.tasks.SourceSetContainer | ||
import org.gradle.internal.impldep.kotlinx.metadata.impl.extensions.KmExtension | ||
import org.gradle.kotlin.dsl.accessors.runtime.externalModuleDependencyFor | ||
import org.gradle.kotlin.dsl.getByType | ||
import java.net.URLClassLoader | ||
import java.nio.file.Path | ||
|
||
class ConfigInversionLinter : Plugin<Project> { | ||
override fun apply(target: Project) { | ||
val extension = target.extensions.create("supportedTracerConfigurations", SupportedTracerConfigurations::class.java) | ||
registerLogEnvVarUsages(target, extension) | ||
registerCheckEnvironmentVariablesUsage(target) | ||
} | ||
} | ||
|
||
/** Registers `logEnvVarUsages` (scan for DD_/OTEL_ tokens and fail if unsupported). */ | ||
private fun registerLogEnvVarUsages(target: Project, extension: SupportedTracerConfigurations) { | ||
val ownerPath = extension.configOwnerPath | ||
val generatedFile = extension.className | ||
|
||
// token check that uses the generated class instead of JSON | ||
target.tasks.register("logEnvVarUsages") { | ||
group = "verification" | ||
description = "Scan Java files for DD_/OTEL_ tokens and fail if unsupported (using generated constants)" | ||
|
||
val mainSourceSetOutput = ownerPath.map { | ||
target.project(it) | ||
.extensions.getByType<SourceSetContainer>() | ||
.named(SourceSet.MAIN_SOURCE_SET_NAME) | ||
.map { main -> main.output } | ||
} | ||
inputs.files(mainSourceSetOutput) | ||
|
||
// inputs for incrementality (your own source files, not the owner’s) | ||
val javaFiles = target.fileTree(target.projectDir) { | ||
include("**/src/main/java/**/*.java") | ||
exclude("**/build/**", "**/dd-smoke-tests/**") | ||
} | ||
inputs.files(javaFiles) | ||
outputs.upToDateWhen { true } | ||
doLast { | ||
// 1) Build classloader from the owner project’s runtime classpath | ||
val urls = mainSourceSetOutput.get().get().files.map { it.toURI().toURL() }.toTypedArray() | ||
val supported: Set<String> = URLClassLoader(urls, javaClass.classLoader).use { cl -> | ||
// 2) Load the generated class + read static field | ||
val clazz = Class.forName(generatedFile.get(), true, cl) | ||
@Suppress("UNCHECKED_CAST") | ||
clazz.getField("SUPPORTED").get(null) as Set<String> | ||
} | ||
|
||
// 3) Scan our sources and compare | ||
val repoRoot = target.projectDir.toPath() | ||
val tokenRegex = Regex("\"(?:DD_|OTEL_)[A-Za-z0-9_]+\"") | ||
|
||
val violations = buildList { | ||
javaFiles.files.forEach { f -> | ||
val rel = repoRoot.relativize(f.toPath()).toString() | ||
var inBlock = false | ||
f.readLines().forEachIndexed { i, raw -> | ||
val trimmed = raw.trim() | ||
if (trimmed.startsWith("//")) return@forEachIndexed | ||
if (!inBlock && trimmed.contains("/*")) inBlock = true | ||
if (inBlock) { | ||
if (trimmed.contains("*/")) inBlock = false | ||
return@forEachIndexed | ||
} | ||
tokenRegex.findAll(raw).forEach { m -> | ||
val token = m.value.trim('"') | ||
if (token !in supported) add("$rel:${i + 1} -> Unsupported token'$token'") | ||
} | ||
} | ||
} | ||
} | ||
|
||
if (violations.isNotEmpty()) { | ||
violations.forEach { target.logger.error(it) } | ||
throw GradleException("Unsupported DD_/OTEL_ tokens found! See errors above.") | ||
} else { | ||
target.logger.info("All DD_/OTEL_ tokens are supported.") | ||
} | ||
} | ||
} | ||
} | ||
|
||
/** Registers `checkEnvironmentVariablesUsage` (forbid EnvironmentVariables.get(...)). */ | ||
private fun registerCheckEnvironmentVariablesUsage(project: Project) { | ||
project.tasks.register("checkEnvironmentVariablesUsage") { | ||
group = "verification" | ||
description = "Scans src/main/java for direct usages of EnvironmentVariables.get(...)" | ||
|
||
doLast { | ||
val repoRoot: Path = project.projectDir.toPath() | ||
val javaFiles = project.fileTree(project.projectDir) { | ||
include("**/src/main/java/**/*.java") | ||
exclude("**/build/**") | ||
exclude("internal-api/src/main/java/datadog/trace/api/ConfigHelper.java") | ||
exclude("dd-java-agent/agent-bootstrap/**") | ||
exclude("dd-java-agent/src/main/java/datadog/trace/bootstrap/BootstrapInitializationTelemetry.java") | ||
} | ||
|
||
val pattern = Regex("""EnvironmentVariables\.get\s*\(""") | ||
val matches = buildList { | ||
javaFiles.forEach { f -> | ||
val relative = repoRoot.relativize(f.toPath()) | ||
f.readLines().forEachIndexed { idx, line -> | ||
if (pattern.containsMatchIn(line)) { | ||
add("$relative:${idx + 1} -> ${line.trim()}") | ||
} | ||
} | ||
} | ||
} | ||
|
||
if (matches.isNotEmpty()) { | ||
project.logger.lifecycle("\nFound forbidden usages of EnvironmentVariables.get(...):") | ||
matches.forEach { project.logger.lifecycle(it) } | ||
throw GradleException("Forbidden usage of EnvironmentVariables.get(...) found in Java files.") | ||
} else { | ||
project.logger.info("No forbidden EnvironmentVariables.get(...) usages found in src/main/java.") | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
package datadog.gradle.plugin.config | ||
|
||
import org.gradle.api.DefaultTask | ||
import org.gradle.api.model.ObjectFactory | ||
import org.gradle.api.tasks.Input | ||
import org.gradle.api.tasks.InputFile | ||
import org.gradle.api.tasks.OutputDirectory | ||
import org.gradle.api.tasks.TaskAction | ||
import com.fasterxml.jackson.core.type.TypeReference | ||
import com.fasterxml.jackson.databind.ObjectMapper | ||
import org.gradle.api.tasks.CacheableTask | ||
import org.gradle.api.tasks.PathSensitive | ||
import org.gradle.api.tasks.PathSensitivity | ||
import java.io.File | ||
import java.io.FileInputStream | ||
import java.io.PrintWriter | ||
import javax.inject.Inject | ||
|
||
@CacheableTask | ||
abstract class ParseSupportedConfigurationsTask @Inject constructor( | ||
private val objects: ObjectFactory | ||
) : DefaultTask() { | ||
@InputFile | ||
@PathSensitive(PathSensitivity.NONE) | ||
val jsonFile = objects.fileProperty() | ||
|
||
@get:OutputDirectory | ||
val destinationDirectory = objects.directoryProperty() | ||
|
||
@Input | ||
val className = objects.property(String::class.java) | ||
|
||
@TaskAction | ||
fun generate() { | ||
val input = jsonFile.get().asFile | ||
val outputDir = destinationDirectory.get().asFile | ||
val finalClassName = className.get() | ||
outputDir.mkdirs() | ||
|
||
// Read JSON (directly from the file, not classpath) | ||
val mapper = ObjectMapper() | ||
val fileData: Map<String, Any?> = FileInputStream(input).use { inStream -> | ||
mapper.readValue(inStream, object : TypeReference<Map<String, Any?>>() {}) | ||
} | ||
|
||
@Suppress("UNCHECKED_CAST") | ||
val supported = fileData["supportedConfigurations"] as Map<String, List<String>> | ||
@Suppress("UNCHECKED_CAST") | ||
val aliases = fileData["aliases"] as Map<String, List<String>> | ||
@Suppress("UNCHECKED_CAST") | ||
val deprecated = (fileData["deprecations"] as? Map<String, String>) ?: emptyMap() | ||
|
||
val aliasMapping = mutableMapOf<String, String>() | ||
for ((canonical, alist) in aliases) { | ||
for (alias in alist) aliasMapping[alias] = canonical | ||
} | ||
|
||
// Build the output .java path from the fully-qualified class name | ||
val pkgName = finalClassName.substringBeforeLast('.', "") | ||
val pkgPath = pkgName.replace('.', File.separatorChar) | ||
val simpleName = finalClassName.substringAfterLast('.') | ||
val pkgDir = if (pkgPath.isEmpty()) outputDir else File(outputDir, pkgPath).also { it.mkdirs() } | ||
val generatedFile = File(pkgDir, "$simpleName.java").absolutePath | ||
|
||
// Call your existing generator (same signature as in your Java code) | ||
generateJavaFile( | ||
generatedFile, | ||
simpleName, | ||
pkgName, | ||
supported.keys, | ||
aliases, | ||
aliasMapping, | ||
deprecated | ||
) | ||
} | ||
|
||
private fun generateJavaFile( | ||
outputPath: String, | ||
className: String, | ||
packageName: String, | ||
supportedKeys: Set<String>, | ||
aliases: Map<String, List<String>>, | ||
aliasMapping: Map<String, String>, | ||
deprecated: Map<String, String> | ||
) { | ||
val outFile = File(outputPath) | ||
outFile.parentFile?.mkdirs() | ||
|
||
PrintWriter(outFile).use { out -> | ||
// NOTE: adjust these if you want to match task's className | ||
out.println("package $packageName;") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 suggestion: I would recommend using Kotlin multiline strings for readability. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. polish: There's also the |
||
out.println() | ||
out.println("import java.util.*;") | ||
out.println() | ||
out.println("public final class $className {") | ||
out.println() | ||
out.println(" public static final Set<String> SUPPORTED;") | ||
out.println() | ||
out.println(" public static final Map<String, List<String>> ALIASES;") | ||
out.println() | ||
out.println(" public static final Map<String, String> ALIAS_MAPPING;") | ||
out.println() | ||
out.println(" public static final Map<String, String> DEPRECATED;") | ||
out.println() | ||
out.println(" static {") | ||
out.println() | ||
|
||
// SUPPORTED | ||
out.print(" Set<String> supportedSet = new HashSet<>(Arrays.asList(") | ||
val supportedIter = supportedKeys.toSortedSet().iterator() | ||
while (supportedIter.hasNext()) { | ||
val key = supportedIter.next() | ||
out.print("\"${esc(key)}\"") | ||
if (supportedIter.hasNext()) out.print(", ") | ||
} | ||
out.println("));") | ||
out.println(" SUPPORTED = Collections.unmodifiableSet(supportedSet);") | ||
out.println() | ||
|
||
// ALIASES | ||
out.println(" Map<String, List<String>> aliasesMap = new HashMap<>();") | ||
for ((canonical, list) in aliases.toSortedMap()) { | ||
out.printf( | ||
" aliasesMap.put(\"%s\", Collections.unmodifiableList(Arrays.asList(%s)));\n", | ||
esc(canonical), | ||
quoteList(list) | ||
) | ||
} | ||
out.println(" ALIASES = Collections.unmodifiableMap(aliasesMap);") | ||
out.println() | ||
|
||
// ALIAS_MAPPING | ||
out.println(" Map<String, String> aliasMappingMap = new HashMap<>();") | ||
for ((alias, target) in aliasMapping.toSortedMap()) { | ||
out.printf(" aliasMappingMap.put(\"%s\", \"%s\");\n", esc(alias), esc(target)) | ||
} | ||
out.println(" ALIAS_MAPPING = Collections.unmodifiableMap(aliasMappingMap);") | ||
out.println() | ||
|
||
// DEPRECATED | ||
out.println(" Map<String, String> deprecatedMap = new HashMap<>();") | ||
for ((oldKey, note) in deprecated.toSortedMap()) { | ||
out.printf(" deprecatedMap.put(\"%s\", \"%s\");\n", esc(oldKey), esc(note)) | ||
} | ||
out.println(" DEPRECATED = Collections.unmodifiableMap(deprecatedMap);") | ||
out.println() | ||
out.println(" }") | ||
out.println("}") | ||
} | ||
} | ||
|
||
private fun quoteList(list: List<String>): String = | ||
list.joinToString(", ") { "\"${esc(it)}\"" } | ||
|
||
private fun esc(s: String): String = | ||
s.replace("\\", "\\\\").replace("\"", "\\\"") | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
package datadog.gradle.plugin.config | ||
|
||
import org.gradle.api.Plugin | ||
import org.gradle.api.Project | ||
import org.gradle.api.tasks.SourceSet | ||
import org.gradle.api.tasks.SourceSetContainer | ||
|
||
class SupportedConfigPlugin : Plugin<Project> { | ||
override fun apply(targetProject: Project) { | ||
val extension = targetProject.extensions.create("supportedTracerConfigurations", SupportedTracerConfigurations::class.java) | ||
generateSupportedConfigurations(targetProject, extension) | ||
} | ||
|
||
private fun generateSupportedConfigurations(targetProject: Project, extension: SupportedTracerConfigurations) { | ||
val generateTask = | ||
targetProject.tasks.register("generateSupportedConfigurations", ParseSupportedConfigurationsTask::class.java) { | ||
jsonFile.set(extension.jsonFile) | ||
destinationDirectory.set(extension.destinationDirectory) | ||
className.set(extension.className) | ||
} | ||
|
||
val sourceset = targetProject.extensions.getByType(SourceSetContainer::class.java).named(SourceSet.MAIN_SOURCE_SET_NAME) | ||
sourceset.configure { | ||
java.srcDir(generateTask) | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package datadog.gradle.plugin.config | ||
|
||
import org.gradle.api.Project | ||
import org.gradle.api.file.ProjectLayout | ||
import org.gradle.api.model.ObjectFactory | ||
import javax.inject.Inject | ||
|
||
open class SupportedTracerConfigurations @Inject constructor(objects: ObjectFactory, layout: ProjectLayout, project: Project) { | ||
val configOwnerPath = objects.property<String>(String::class.java).convention(":utils:config-utils") | ||
val className = objects.property<String>(String::class.java).convention("datadog.trace.config.inversion.GeneratedSupportedConfigurations") | ||
|
||
val jsonFile = objects.fileProperty().convention(project.rootProject.layout.projectDirectory.file("metadata/supported-configurations.json")) | ||
|
||
val destinationDirectory = objects.directoryProperty().convention(layout.buildDirectory.dir("generated/supportedConfigurations")) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
plugins { | ||
`java-library` | ||
id("supported-config-generator") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. note: Just some tip about kotlin-dsl, it also possible to refer to that plugin with the generated accessor Note this code is fine and you can close the comment. |
||
} | ||
|
||
apply(from = "$rootDir/gradle/java.gradle") | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thought: I believe this might be more efficient to parse the json file rather than wait for
generatedFile
(actually ageneratedClass
) to be compiled.