Skip to content

Commit 5cbee31

Browse files
SONARIAC-2199 Create go license file generation (#83)
1 parent af01862 commit 5cbee31

9 files changed

+342
-103
lines changed

gradle-modules/src/main/kotlin/org.sonarsource.cloud-native.go-binary-builder.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
import org.gradle.kotlin.dsl.registering
1818
import org.sonarsource.cloudnative.gradle.GO_BINARY_OUTPUT_DIR
19+
import org.sonarsource.cloudnative.gradle.GO_LICENSES_OUTPUT_DIR
1920
import org.sonarsource.cloudnative.gradle.GoBuild
2021
import org.sonarsource.cloudnative.gradle.allGoSourcesAndMakeScripts
2122
import org.sonarsource.cloudnative.gradle.callMake
@@ -64,6 +65,7 @@ if (isCi()) {
6465
inputs.files(allGoSourcesAndMakeScripts())
6566

6667
outputs.dir(GO_BINARY_OUTPUT_DIR)
68+
outputs.dir(GO_LICENSES_OUTPUT_DIR)
6769
outputs.files(goBuildExtension.additionalOutputFiles)
6870
outputs.cacheIf { true }
6971

gradle-modules/src/main/kotlin/org.sonarsource.cloud-native.go-docker-environment.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
*/
1717
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
1818
import org.sonarsource.cloudnative.gradle.GO_BINARY_OUTPUT_DIR
19+
import org.sonarsource.cloudnative.gradle.GO_LICENSES_OUTPUT_DIR
1920
import org.sonarsource.cloudnative.gradle.GoBuild
2021
import org.sonarsource.cloudnative.gradle.allGoSourcesAndMakeScripts
2122
import org.sonarsource.cloudnative.gradle.goLangCiLintVersion
@@ -96,6 +97,7 @@ val dockerTasks = goBuildExtension.dockerCommands.map { tasksToCommands ->
9697
inputs.property("goCrossCompile", isCrossCompile)
9798
outputs.files(goBuildExtension.additionalOutputFiles)
9899
outputs.dir(GO_BINARY_OUTPUT_DIR)
100+
outputs.dir(GO_LICENSES_OUTPUT_DIR)
99101
outputs.cacheIf { true }
100102

101103
val workDir = goBuildExtension.dockerWorkDir.get()
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
/*
2+
* SonarSource Cloud Native Gradle Modules
3+
* Copyright (C) 2024-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
import java.io.File
18+
import java.nio.file.Files
19+
import java.nio.file.StandardCopyOption
20+
import org.gradle.api.GradleException
21+
import org.gradle.api.logging.Logger
22+
import org.gradle.kotlin.dsl.create
23+
import org.gradle.kotlin.dsl.findByType
24+
import org.sonarsource.cloudnative.gradle.GoLicenseGenerationConfig
25+
import org.sonarsource.cloudnative.gradle.areDirectoriesEqual
26+
import org.sonarsource.cloudnative.gradle.copyDirectory
27+
28+
/**
29+
* This plugin is used for generating license files for third-party GO runtime-dependencies into the resources folder.
30+
* It provides a validation task to ensure that the license files in the resource folder are up-to-date.
31+
* It provides a task to regenerate the license files into the resources folder.
32+
* These tasks expect the licenses to be generated by go-licenses by another task first, which is configurable.
33+
*/
34+
35+
val goLicenseGenerationConfig =
36+
extensions.findByType<GoLicenseGenerationConfig>() ?: extensions.create<GoLicenseGenerationConfig>("goLicenseGenerationConfig")
37+
goLicenseGenerationConfig.buildGoLicenseFilesDir.convention(project.layout.buildDirectory.dir("go-licenses").get().asFile)
38+
goLicenseGenerationConfig.packagedBinaries.convention(emptySet())
39+
goLicenseGenerationConfig.binaryLicenseFile.convention(project.rootDir.resolve("LICENSE.txt"))
40+
41+
var resourceLicenseDir = project.layout.projectDirectory.dir("src/main/resources/licenses")
42+
43+
val validateGoLicenses = tasks.register("validateGoLicenseFiles") {
44+
description = "Validate that generated go license files match the committed ones"
45+
group = "validation"
46+
dependsOn(generateGoLicenses)
47+
48+
doLast {
49+
val generatedLicenses = goLicenseGenerationConfig.buildGoLicenseFilesDir.get().resolve("resources")
50+
goLicenseGenerationConfig.packagedBinaries.get().forEach {
51+
val licenseResourcesForBinary = resourceLicenseDir.dir("$it-licenses").asFile
52+
if (!areDirectoriesEqual(generatedLicenses, licenseResourcesForBinary, logger)) {
53+
val message = """
54+
[FAILURE] License file validation failed!
55+
Generated license files differ from committed files at $resourceLicenseDir.
56+
To update the committed license files, run './gradlew generateLicenseResources' and commit the changes.
57+
58+
Note: This will completely regenerate all license files under $resourceLicenseDir and remove any stale ones.
59+
"""
60+
throw GradleException(message)
61+
} else {
62+
logger.lifecycle("Go license file validation succeeded: Generated go license files match the committed ones.")
63+
}
64+
}
65+
}
66+
}
67+
68+
tasks.named("validateLicenseFiles") {
69+
dependsOn(validateGoLicenses)
70+
}
71+
72+
/**
73+
* This tasks requires that the go-license generation is already done
74+
* during 'goLicenseGenerationConfig.generatingGoLicensesGradleTask.' task.
75+
*/
76+
val generateGoLicenses = tasks.register("generateGoLicenses") {
77+
// Generating the licenses with go-license tool is done during 'dockerCompileGo' task
78+
dependsOn(goLicenseGenerationConfig.generatingGoLicensesGradleTask.get())
79+
doLast {
80+
transformGoLicenseOutputToDesiredLicenseStructure(goLicenseGenerationConfig.buildGoLicenseFilesDir.get(), logger)
81+
82+
// copy included license
83+
val newIncludedLicensePath = goLicenseGenerationConfig.buildGoLicenseFilesDir.get().resolve("resources/LICENSE.txt").toPath()
84+
Files.copy(goLicenseGenerationConfig.binaryLicenseFile.get().toPath(), newIncludedLicensePath, StandardCopyOption.REPLACE_EXISTING)
85+
}
86+
}
87+
88+
val generateGoLicenseResources = tasks.register("generateGoLicenseResources") {
89+
description = "Copies generated license files to the resources directory"
90+
dependsOn(generateGoLicenses)
91+
92+
doLast {
93+
val generatedLicenses = goLicenseGenerationConfig.buildGoLicenseFilesDir.get().resolve("resources")
94+
goLicenseGenerationConfig.packagedBinaries.get().forEach {
95+
val licenseResourcesForBinary = resourceLicenseDir.dir("$it-licenses").asFile
96+
97+
Files.createDirectories(licenseResourcesForBinary.toPath())
98+
copyDirectory(generatedLicenses, licenseResourcesForBinary, logger)
99+
}
100+
}
101+
}
102+
103+
tasks.named("generateLicenseResources") {
104+
dependsOn(generateGoLicenseResources)
105+
}
106+
107+
fun transformGoLicenseOutputToDesiredLicenseStructure(
108+
licenseFolder: File,
109+
logger: Logger,
110+
) {
111+
val generatedDirectory = licenseFolder.resolve("generated").toPath()
112+
val transformedDirectory = licenseFolder.resolve("resources/THIRD_PARTY_LICENSES").toPath()
113+
114+
// 1. Validation: Ensure input exists
115+
if (!Files.isDirectory(generatedDirectory)) {
116+
throw GradleException("Error: The directory '$generatedDirectory' does not exist.")
117+
}
118+
119+
// Create output directory if it doesn't exist
120+
if (!Files.exists(transformedDirectory)) {
121+
Files.createDirectories(transformedDirectory)
122+
}
123+
124+
logger.lifecycle("Processing files from: $generatedDirectory")
125+
126+
try {
127+
// 2. Walk the tree and collect all regular files
128+
val allGeneratedFiles = Files.walk(generatedDirectory)
129+
.filter { Files.isRegularFile(it) }
130+
.toList()
131+
// 3. Group files by their parent directory
132+
val filesByDirectory = allGeneratedFiles.groupBy { it.parent }
133+
134+
// 4. Iterate through each directory group
135+
filesByDirectory.forEach { (parentDir, filesInDir) ->
136+
137+
// Find "LICENSE", otherwise take the first existing file
138+
val selectedFile = filesInDir.find { it.toFile().name.startsWith("LICENSE") }
139+
?: filesInDir.first()
140+
141+
// 5. Create the new filename
142+
143+
// Get path relative to "licenses" (e.g., "my/directory/structure/LICENSE")
144+
val relativePath = generatedDirectory.relativize(parentDir)
145+
val newFileName = relativePath.toString().replace(File.separator, ".") + "-LICENSE.txt"
146+
147+
// 6. Copy to output
148+
val destinationPath = transformedDirectory.resolve(newFileName)
149+
150+
Files.copy(selectedFile, destinationPath, StandardCopyOption.REPLACE_EXISTING)
151+
}
152+
} catch (e: Exception) {
153+
logger.warn("An error occurred: ${e.message}", e)
154+
throw e
155+
}
156+
}

gradle-modules/src/main/kotlin/org.sonarsource.cloud-native.license-file-generator.gradle.kts

Lines changed: 5 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,18 @@
1515
* along with this program; if not, see https://sonarsource.com/license/ssal/
1616
*/
1717
import com.github.jk1.license.render.ReportRenderer
18-
import java.io.IOException
1918
import java.nio.file.Files
2019
import java.nio.file.StandardCopyOption
21-
import java.security.MessageDigest
2220
import org.sonarsource.cloudnative.gradle.AnalyzerLicensingPackagingRenderer
21+
import org.sonarsource.cloudnative.gradle.areDirectoriesEqual
22+
import org.sonarsource.cloudnative.gradle.copyDirectory
2323

2424
plugins {
2525
id("com.github.jk1.dependency-license-report")
2626
}
2727

2828
/**
29-
* This plugin is used for generating license files for third-party dependencies into the resources folder.
29+
* This plugin is used for generating license files for third-party runtime-dependencies into the resources folder.
3030
* It provides a validation task to ensure that the license files in the resource folder are up-to-date.
3131
* It provides a task to regenerate the license files into the resources folder.
3232
* This tasks expects the license of the analyzer to be present one level above the (project-)plugin directory.
@@ -52,7 +52,7 @@ tasks.register("validateLicenseFiles") {
5252
dependsOn("generateLicenseReport")
5353

5454
doLast {
55-
if (!areDirectoriesEqual(buildLicenseOutputToCopyDir.asFile, resourceThirdPartyDir.asFile)) {
55+
if (!areDirectoriesEqual(buildLicenseOutputToCopyDir.asFile, resourceThirdPartyDir.asFile, logger)) {
5656
val message = """
5757
[FAILURE] License file validation failed!
5858
Generated license files differ from committed files at $resourceThirdPartyDir.
@@ -109,104 +109,6 @@ tasks.register("generateLicenseResources") {
109109
resourceLicenseDir.file("LICENSE.txt").asFile.toPath(),
110110
StandardCopyOption.REPLACE_EXISTING
111111
)
112-
copyDirectory(buildLicenseReportDirectory.get().dir("licenses").asFile, resourceThirdPartyDir.asFile)
113-
}
114-
}
115-
116-
/**
117-
* Compares two directories recursively to check for equality.
118-
* * Two directories are considered equal if they have the exact same
119-
* directory structure and all corresponding files have identical content.
120-
*
121-
* @param dir1 The first directory.
122-
* @param dir2 The second directory.
123-
* @return `true` if the directories are equal, `false` otherwise.
124-
*/
125-
fun areDirectoriesEqual(
126-
dir1: File,
127-
dir2: File,
128-
): Boolean {
129-
if (!dir1.isDirectory || !dir2.isDirectory) {
130-
logger.warn("One or both paths are not directories.")
131-
return false
132-
}
133-
logger.lifecycle("Comparing directories: ${dir1.name} and ${dir2.name}")
134-
135-
try {
136-
// 1. Walk both directory trees and map files by their relative path
137-
val files1 = dir1.walk()
138-
.filter { it.isFile }
139-
.associateBy { it.relativeTo(dir1) }
140-
141-
val files2 = dir2.walk()
142-
.filter { it.isFile }
143-
.associateBy { it.relativeTo(dir2) }
144-
145-
// 2. Compare the directory structure (based on file paths)
146-
if (files1.keys != files2.keys) {
147-
logger.warn("Directory structures do not match.")
148-
logger.warn("Files only in ${dir1.name}: ${files1.keys - files2.keys}")
149-
logger.warn("Files only in ${dir2.name}: ${files2.keys - files1.keys}")
150-
return false
151-
}
152-
153-
// 3. Compare the content of each matching file
154-
for (relativePath in files1.keys) {
155-
val file1 = files1[relativePath]!!
156-
val file2 = files2[relativePath]!!
157-
158-
// Quick check: compare file sizes first
159-
if (file1.length() != file2.length()) {
160-
logger.warn("File size mismatch: $relativePath")
161-
return false
162-
}
163-
164-
// Full check: compare byte content
165-
val checksum1 = getFileChecksum(file1)
166-
val checksum2 = getFileChecksum(file2)
167-
if (checksum1 != checksum2) {
168-
logger.warn("File content mismatch: $relativePath")
169-
return false
170-
}
171-
}
172-
173-
// If all checks pass, the directories are equal
174-
return true
175-
} catch (e: IOException) {
176-
logger.error("An error occurred during comparison: ${e.message}")
177-
return false
178-
}
179-
}
180-
181-
fun getFileChecksum(file: File): String {
182-
val md = MessageDigest.getInstance("SHA-256")
183-
val digestBytes = md.digest(file.readBytes())
184-
185-
// 4. Convert the byte array to a hexadecimal string
186-
// "%02x" formats each byte as two lowercase hex digits
187-
return digestBytes.joinToString("") { "%02x".format(it) }
188-
}
189-
190-
fun copyDirectory(
191-
sourceDir: File,
192-
destinationDir: File,
193-
) {
194-
val errors = mutableListOf<String>()
195-
196-
destinationDir.deleteRecursively()
197-
sourceDir.copyRecursively(
198-
target = destinationDir,
199-
overwrite = true,
200-
onError = { file, exception ->
201-
logger.warn("Failed to copy $file: ${exception.message}")
202-
errors.add(file.name)
203-
OnErrorAction.SKIP // Skip this file and continue
204-
}
205-
)
206-
207-
if (errors.isEmpty()) {
208-
logger.lifecycle("Directory ${sourceDir.name} copied successfully to ${destinationDir.name}")
209-
} else {
210-
throw GradleException("Failed to copy ${errors.size} files.")
112+
copyDirectory(buildLicenseReportDirectory.get().dir("licenses").asFile, resourceThirdPartyDir.asFile, logger)
211113
}
212114
}

gradle-modules/src/main/kotlin/org/sonarsource/cloudnative/gradle/AnalyzerLicensingPackagingRenderer.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ class AnalyzerLicensingPackagingRenderer(
4444
put("BSD", "BSD-2.txt")
4545
put("GNU LGPL 3", "GNU-LGPL-3.txt")
4646
put("Go License", "Go.txt")
47+
put("MIT License", "MIT.txt")
48+
put("MIT", "MIT.txt")
4749
}
4850
private val dependenciesWithUnusableLicenseFileInside: Set<String> = setOf(
4951
"com.fasterxml.jackson.dataformat.jackson-dataformat-smile",

gradle-modules/src/main/kotlin/org/sonarsource/cloudnative/gradle/GoBuild.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import org.gradle.api.provider.Provider
2626
import org.gradle.api.provider.SetProperty
2727

2828
const val GO_BINARY_OUTPUT_DIR = "build/executable"
29+
const val GO_LICENSES_OUTPUT_DIR = "build/go-licenses"
2930

3031
interface GoBuild {
3132
val dockerfile: RegularFileProperty
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/*
2+
* SonarSource Cloud Native Gradle Modules
3+
* Copyright (C) 2024-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the Sonar Source-Available License Version 1, as published by SonarSource Sàrl.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
12+
* See the Sonar Source-Available License for more details.
13+
*
14+
* You should have received a copy of the Sonar Source-Available License
15+
* along with this program; if not, see https://sonarsource.com/license/ssal/
16+
*/
17+
package org.sonarsource.cloudnative.gradle
18+
19+
import java.io.File
20+
import org.gradle.api.provider.Property
21+
import org.gradle.api.provider.SetProperty
22+
23+
interface GoLicenseGenerationConfig {
24+
/**
25+
* Name of the binaries files for which licenses should be packaged.
26+
*/
27+
val packagedBinaries: SetProperty<String>
28+
29+
/**
30+
* License file of the binary itself to be included in the generated license files.
31+
* Normally it is the LICENSE.txt file present in the root of the repository.
32+
*/
33+
val binaryLicenseFile: Property<File>
34+
35+
/**
36+
* Directory where the generated Go license files will be placed.
37+
*/
38+
val buildGoLicenseFilesDir: Property<File>
39+
40+
/**
41+
* Name of the Gradle task that generates the Go license files.
42+
*/
43+
val generatingGoLicensesGradleTask: Property<String>
44+
}

0 commit comments

Comments
 (0)