diff --git a/AGENTS.md b/AGENTS.md index 072774e7..e301badf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -224,6 +224,55 @@ buildpacks (`bootBuildImage -Pnative`). Key configuration: - **CI:** Separate `native.yml` workflow; native failures do not block JVM-path merges. - **Spec:** [docs/specs/graalvm-native-image.md](docs/specs/graalvm-native-image.md) +## Release LICENSE / NOTICE + +ASF policy requires distinct LICENSE/NOTICE for the *source* form and the *binary* +form, because the binary (Spring Boot fat `bootJar`) bundles third-party bytecode. +See [infra.apache.org/licensing-howto](https://infra.apache.org/licensing-howto.html). + +- **Source form** (thin `jar`, `-sources`, `-javadoc`): the base `LICENSE` (Apache-2.0) + and `NOTICE` at the repo root, bundled into `META-INF/` as-is. +- **Binary form** (`bootJar`): generated at build time and bundled into its `META-INF/`: + - `generateBinaryLicense` → `LICENSE` = base Apache-2.0 + an appendix listing every + bundled `productionRuntimeClasspath` dependency and a link to its license. Licenses + are read from the **CycloneDX SBOM** (`cyclonedxBom`, the same SBOM embedded at + `META-INF/sbom/application.cdx.json`), filtered to the shipped classpath. The SBOM + resolves a license for every bundled component — including Gradle-module-metadata + -only ASF artifacts such as `solr-solrj`/`solr-api` that POM-only scanners miss — so + no per-dependency list is hand-maintained. + - `generateBinaryNotice` → `NOTICE` = base NOTICE + the `META-INF/NOTICE` files lifted + verbatim (de-duplicated) from the bundled jars (Maven-Shade + `ApacheNoticeResourceTransformer` approach). +- **Where / when they appear:** both binary files are regenerated on every build — the + two tasks run ahead of `bootJar` (and in `check`), so any `./gradlew build` / `bootJar` + produces them. They live at `META-INF/LICENSE` and `META-INF/NOTICE` inside the fat jar + (`build/libs/solr-mcp-.jar`), and therefore inside every published **Docker image** + too, since the Jib JVM image and the Paketo native images both package the bootJar + contents. Inspect a built artifact with + `unzip -p build/libs/solr-mcp-.jar META-INF/LICENSE` (or `META-INF/NOTICE`); the + generator also writes them to `build/generated/license/` for local viewing. The + source-form jars (thin `jar`, `-sources`, `-javadoc`) instead carry the repo-root base + files unchanged. +- **Licenses are disclosed as the SBOM reports them** (SPDX ids where available). The + appendix is a disclosure, not a license policy: there is **no allow-list and no + corrections**, so a few imprecise-but-permissive upstream labels appear as-is (e.g. + `mcp-server-security` shows `Apache-1.0`, ANTLR shows `BSD-4-Clause`/`BSD licence`); the + appendix preamble says so and links each license. All bundled deps are ASF Category A/B. +- **Completeness gate** (`generateBinaryLicense`, run as part of `check`/`build`): the + *only* gate — fails if a bundled dependency is missing from the SBOM, so a dependency + can never be silently omitted from the LICENSE. It makes no judgement about which + licenses are acceptable. (Unlike apache/solr's `solr/licenses/` folder, which JanHoy + said not to replicate, there is no per-dependency license/checksum store here.) +- This builds on the SBOM generation (see **SBOM Architecture**); the SBOM remains the + machine-readable bill of materials, and LICENSE/NOTICE are the human-readable legal + artifacts derived from it. +- **Implementation:** the `org.apache.solr.mcp.license-notice` convention plugin in + `buildSrc/` (typed `GenerateBinaryLicense` / `GenerateBinaryNotice` tasks). The root + `build.gradle.kts` only applies the plugin. The tasks are unit-tested in + `buildSrc/src/test/kotlin/.../LicenseNoticeTasksTest.kt` (appendix listing, SBOM + name/URL handling, the completeness gate, and NOTICE de-duplication); `buildSrc`'s + `test` runs as part of `./gradlew build`. + ## Testing Structure - **Unit tests** (`*Test.java`): Mocked dependencies, fast execution. Mockito-based diff --git a/build.gradle.kts b/build.gradle.kts index 075fafa9..572bc760 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,10 @@ plugins { alias(libs.plugins.jib) alias(libs.plugins.graalvm.native) apply false alias(libs.plugins.cyclonedx) + // Generates ASF source/binary LICENSE + NOTICE (buildSrc convention plugin). + // Listed after spring-boot + cyclonedx so productionRuntimeClasspath and + // cyclonedxBom exist when it wires its tasks. See buildSrc/. + id("org.apache.solr.mcp.license-notice") } // GraalVM Native Image (Opt-In) @@ -77,15 +81,12 @@ java { } // ASF release policy requires every distributed artifact to carry the project's -// LICENSE and NOTICE files. Bundle them into META-INF of every JAR produced by -// this build (main jar, bootJar, sources, javadoc). +// LICENSE and NOTICE files. This is handled by the `org.apache.solr.mcp.license-notice` +// convention plugin (buildSrc/): the source-form jars (thin jar, -sources, -javadoc) +// get the base Apache-2.0 LICENSE/NOTICE, while the binary fat bootJar gets generated +// files with an SBOM-derived third-party appendix. The plugin must own this wiring for +// the bootJar — bundling the base files here too would duplicate META-INF/LICENSE. // See https://www.apache.org/legal/release-policy.html#licensing-documentation -tasks.withType().configureEach { - metaInf { - from(rootProject.file("LICENSE")) - from(rootProject.file("NOTICE")) - } -} // Maven Publishing Configuration // ============================== diff --git a/buildSrc/README.md b/buildSrc/README.md new file mode 100644 index 00000000..4d9bdff9 --- /dev/null +++ b/buildSrc/README.md @@ -0,0 +1,90 @@ + + +# buildSrc — generating the binary LICENSE & NOTICE + +This directory holds the build logic that assembles the **binary-release `LICENSE` +and `NOTICE`** files (the ones bundled inside the executable JAR). It is written in +Kotlin. If you don't work with Gradle day-to-day, this README explains what each piece +is and how they fit together; the end-user view of *what* these files contain lives on +the [Licensing & Notices](https://solr.apache.org/mcp/licensing.html) docs page. + +## What is `buildSrc`? + +`buildSrc` is a Gradle convention: **any code you put under `buildSrc/` is compiled +automatically before the main build and made available to `build.gradle.kts`.** You +don't declare a dependency on it or publish it anywhere — Gradle just picks it up. It is +the standard place to keep custom build logic so the root `build.gradle.kts` stays +small. (Think of it as a tiny library that only this project's build uses.) + +## What's in here + +| File | Role | +|------|------| +| `src/main/kotlin/.../GenerateBinaryLicense.kt` | A custom Gradle **task** that writes the binary `LICENSE` (Apache-2.0 text + a generated third-party dependency appendix). | +| `src/main/kotlin/.../GenerateBinaryNotice.kt` | A custom Gradle **task** that writes the binary `NOTICE` (our `NOTICE` + the `NOTICE` files of bundled dependencies). | +| `src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts` | A **convention plugin** that creates the two tasks above and wires them into the build. | +| `src/test/kotlin/.../LicenseNoticeTasksTest.kt` | Unit tests for the two tasks. | +| `build.gradle.kts` | Builds `buildSrc` itself (enables Kotlin + the test dependencies). | + +## Gradle concepts, for Java developers + +A handful of Gradle terms show up in the code. Here is the minimum to read it: + +- **Task** — a single unit of build work with declared *inputs* and *outputs*, a bit + like one rule in a `Makefile`. Gradle decides whether a task needs to run by comparing + its inputs/outputs to the last run. We write a task by subclassing `DefaultTask`. +- **`@TaskAction`** — the method Gradle calls to actually do the work when the task runs. + It's effectively the task's "main". +- **Input / output annotations** (`@InputFile`, `@InputFiles`, `@Input`, `@OutputFile`) — + these declare what a task reads and writes. They are not decoration: Gradle uses them + to (1) **skip** the task when nothing changed (incremental builds), and (2) **order** + tasks so a producer runs before whoever consumes its output. `@InputFile`/`@InputFiles` + are file inputs; `@Input` is a plain value (a string, list, map); `@OutputFile` is a + produced file. +- **`Property` / `Provider` types** (`RegularFileProperty`, `ListProperty`, + `MapProperty`, `ConfigurableFileCollection`) — Gradle's "lazy" typed holders for a + value. The convention plugin `.set(...)`s them while the build is being *configured*; + the task `.get()`s them later when it actually *runs*. This lazy split is why the task + declares `abstract val foo: …Property` instead of a plain field. +- **Convention plugin** — a `.gradle.kts` file under `buildSrc` that Gradle compiles into + a plugin you can apply by id. Applying it (one line in the root build) registers our + tasks and connects them to the rest of the build, so the conventions live here instead + of being copy-pasted into `build.gradle.kts`. +- **`productionRuntimeClasspath`** — the set of dependency jars that actually end up + inside the Spring Boot fat jar. It excludes test-only, compile-only, and + `developmentOnly` dependencies. "What ships" is exactly what the binary LICENSE/NOTICE + must describe, which is why both tasks are driven by it. + +## How it runs + +1. The root `build.gradle.kts` applies the plugin: `id("org.apache.solr.mcp.license-notice")`. +2. The plugin registers `generateBinaryLicense` and `generateBinaryNotice`, and makes the + `bootJar` task depend on them (and the `check` task depend on `generateBinaryLicense`). +3. On a build, the CycloneDX `cyclonedxBom` task produces the SBOM, then: + - `generateBinaryLicense` reads the SBOM + the list of shipped dependencies and writes + `build/generated/license/LICENSE`. It **fails the build** if a shipped dependency is + missing from the SBOM (so nothing can ship unlisted). + - `generateBinaryNotice` scans the shipped jars for their `META-INF/NOTICE` files and + writes `build/generated/license/NOTICE`. +4. `bootJar` copies those two files into the JAR's `META-INF/`. The source-form jars + (thin `jar`, `-sources`, `-javadoc`) instead carry the plain repo-root `LICENSE` / + `NOTICE`. + +See the `## Release LICENSE / NOTICE` section in the repository's `AGENTS.md` for the +policy rationale, and the [Licensing & Notices](https://solr.apache.org/mcp/licensing.html) +docs page for the consumer-facing explanation. diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..40fb7c8b --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This file builds buildSrc itself (not the main project). See buildSrc/README.md. +plugins { + // Lets us write Gradle build logic — tasks and the convention plugin — in Kotlin, + // and turns the `*.gradle.kts` files under src/main/kotlin into apply-by-id plugins. + `kotlin-dsl` +} + +repositories { + mavenCentral() +} + +dependencies { + // Only used by the task unit tests under src/test (the main code needs no extra deps; + // the Gradle API is provided by the kotlin-dsl plugin). + testImplementation("org.junit.jupiter:junit-jupiter:5.12.2") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.withType().configureEach { + useJUnitPlatform() +} diff --git a/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts new file mode 100644 index 00000000..72400666 --- /dev/null +++ b/buildSrc/src/main/kotlin/org.apache.solr.mcp.license-notice.gradle.kts @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Convention plugin: ASF-compliant LICENSE / NOTICE for the source and binary forms. +// +// For readers new to Gradle: this `.gradle.kts` file under buildSrc is a "precompiled +// script plugin". Gradle compiles it into a plugin whose id is the file name +// (`org.apache.solr.mcp.license-notice`); the root build applies it with one line, +// `id("org.apache.solr.mcp.license-notice")`. The body below runs at *configuration* +// time: it creates the two generator tasks (defined in this same buildSrc as +// GenerateBinaryLicense / GenerateBinaryNotice), wires their inputs, and connects their +// outputs to the `bootJar` and `check` tasks. See buildSrc/README.md for the primer. +// +// ASF policy requires distinct LICENSE/NOTICE for the source form and the binary form, +// because the binary (the Spring Boot fat `bootJar`) bundles third-party bytecode. See +// https://infra.apache.org/licensing-howto.html. This plugin: +// +// - bundles the base Apache-2.0 LICENSE + NOTICE into the source-form jars as-is; +// - generates, for the bootJar, a LICENSE with a third-party appendix derived from the +// CycloneDX SBOM and a NOTICE that lifts bundled dependencies' notices, with a +// completeness gate that fails the build if a bundled dependency is missing from the +// SBOM. Licenses are disclosed as the SBOM reports them; there is no license policy. +// +// Apply this AFTER the Spring Boot and CycloneDX plugins so `productionRuntimeClasspath` +// and the `cyclonedxBom` task exist. + +import org.apache.solr.mcp.build.GenerateBinaryLicense +import org.apache.solr.mcp.build.GenerateBinaryNotice +import org.gradle.api.artifacts.component.ModuleComponentIdentifier + +// The project's source-form LICENSE/NOTICE at the repo root (the plain Apache-2.0 text +// and the base NOTICE). They are bundled as-is into the non-fat jars, and are also the +// base that the generated binary files are built on top of. +val licenseFile = layout.projectDirectory.file("LICENSE") +val noticeFile = layout.projectDirectory.file("NOTICE") + +// A Gradle "configuration" is a named set of dependencies. `productionRuntimeClasspath` +// is the one that actually ends up inside the fat jar — it excludes test/compile-only and +// developmentOnly deps. So this is exactly "what ships", which is what the binary +// LICENSE/NOTICE must describe. +val shippedClasspath = configurations.named("productionRuntimeClasspath") + +// Resolve that configuration to its actual artifacts — each is a jar file plus the module +// identity it came from. `flatMap` keeps everything lazy: nothing is resolved here while +// the build is being configured; it is computed later, when a task that needs it runs. +// The result is a Provider>. +val shippedArtifacts = shippedClasspath.flatMap { it.incoming.artifacts.resolvedArtifacts } + +// Derive the shipped dependencies as sorted, de-duplicated "group:name:version" strings. +// `mapNotNull { it... as? ModuleComponentIdentifier }` keeps only normal external modules +// and drops anything that isn't one (e.g. file dependencies). This feeds the LICENSE +// task's `bundledCoordinates` input. +val shippedCoordinates = + shippedArtifacts.map { set -> + set.mapNotNull { it.id.componentIdentifier as? ModuleComponentIdentifier } + .map { "${it.group}:${it.module}:${it.version}" } + .distinct() + .sorted() + } + +// Map each shipped jar's *file name* to its "group:name:version". The NOTICE task opens +// the jar files and uses this map to label each lifted notice with the module it came +// from (at that point the file is all it has to go on). +val jarNameToCoordinate = + shippedArtifacts.map { set -> + set.mapNotNull { artifact -> + (artifact.id.componentIdentifier as? ModuleComponentIdentifier)?.let { id -> + artifact.file.name to "${id.group}:${id.module}:${id.version}" + } + }.toMap() + } + +// Create (register) the LICENSE task and wire its inputs/output. `register` is lazy — the +// task is configured/run only if the build needs it. `dependsOn("cyclonedxBom")` ensures +// the SBOM exists before this runs; each `.set(...)` connects one declared input. +val generateBinaryLicense = + tasks.register("generateBinaryLicense") { + description = "Assembles the binary-release LICENSE (Apache-2.0 + SBOM-derived appendix)." + group = "documentation" + dependsOn("cyclonedxBom") + baseLicense.set(licenseFile) + sbom.set(layout.buildDirectory.file("reports/application.cdx.json")) + bundledCoordinates.set(shippedCoordinates) + outputFile.set(layout.buildDirectory.file("generated/license/LICENSE")) + } + +// Same for the NOTICE task. `jars.from(shippedClasspath)` hands it the shipped jar files +// to scan for their `META-INF/NOTICE` entries. +val generateBinaryNotice = + tasks.register("generateBinaryNotice") { + description = "Assembles the binary-release NOTICE (project NOTICE + bundled dependency notices)." + group = "documentation" + jars.from(shippedClasspath) + coordinateByJarName.set(jarNameToCoordinate) + baseNotice.set(noticeFile) + outputFile.set(layout.buildDirectory.file("generated/license/NOTICE")) + } + +// `metaInf { from(file) }` adds files to a jar's `META-INF/` directory. The source-form +// artifacts — the thin `jar`, `-sources`, `-javadoc` (everything except `bootJar`) — get +// the base LICENSE/NOTICE unchanged. `configureEach` applies this to each matching jar +// task lazily. +tasks.withType().matching { it.name != "bootJar" }.configureEach { + metaInf { + from(licenseFile) + from(noticeFile) + } +} + +// The binary artifact (the Spring Boot fat `bootJar`) instead gets the *generated* files. +// `dependsOn(...)` makes the generators run first; `from(task.flatMap { it.outputFile })` +// bundles each task's output into `META-INF/` (the lazy flatMap also wires the task +// dependency automatically). +tasks.named("bootJar") { + dependsOn(generateBinaryLicense, generateBinaryNotice) + metaInf { + from(generateBinaryLicense.flatMap { it.outputFile }) + from(generateBinaryNotice.flatMap { it.outputFile }) + } +} + +// Run the LICENSE task — and therefore its completeness gate — as part of `check`, so a +// plain `./gradlew build` fails if a bundled dependency is missing from the SBOM. +tasks.named("check") { dependsOn(generateBinaryLicense) } diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt new file mode 100644 index 00000000..077bbb37 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryLicense.kt @@ -0,0 +1,190 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.build + +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.ListProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the binary-release `LICENSE`: the base Apache-2.0 text plus an appendix + * listing every bundled dependency and the license the CycloneDX SBOM reports for it. + * + * License data is read from the SBOM (the same SBOM embedded in the bootJar), keyed to + * the [bundledCoordinates] that actually ship. The SBOM resolves a license for every + * component — including Gradle-module-metadata-only artifacts (e.g. SolrJ) that POM-only + * scanners miss — so no per-dependency list is hand-maintained. + * + * Licenses are reported **as the SBOM declares them**; the appendix is a disclosure, not + * a license policy, so it carries no allow-list and applies no corrections (a few + * upstream POMs report imprecise but still-permissive identifiers). The task's only gate + * is completeness: it fails if a bundled coordinate is absent from the SBOM, so a + * dependency can never be silently omitted from the LICENSE. + * + * For readers new to Gradle: this is a custom build *task* (a unit of build work). It is + * created and configured by the `org.apache.solr.mcp.license-notice` convention plugin, + * and runs as part of `./gradlew build` / `bootJar`. The annotated `abstract val` + * properties below are its declared inputs and output — Gradle reads those annotations + * to skip the task when nothing changed and to run it before whatever consumes its + * output (here, the `bootJar`). See `buildSrc/README.md` for a fuller primer. + */ +abstract class GenerateBinaryLicense : DefaultTask() { + + /** + * The repo-root Apache-2.0 `LICENSE` that the third-party appendix is appended to. + * `@InputFile` marks it a file input, so the task re-runs if it changes. The path is + * not part of the cache key (`PathSensitivity.NONE`) — only the contents matter. + */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val baseLicense: RegularFileProperty + + /** + * The generated CycloneDX SBOM (`application.cdx.json`), read to find each bundled + * dependency's license. `@InputFile`, so the task re-runs when the SBOM changes. + */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val sbom: RegularFileProperty + + /** + * The dependencies that actually ship, as `"group:name:version"` strings — the source + * of truth for what to list. `@Input` marks it a plain *value* input (not a file), so + * the task re-runs whenever the shipped dependency set changes. + */ + @get:Input + abstract val bundledCoordinates: ListProperty + + /** + * Where the assembled binary `LICENSE` is written. `@OutputFile` lets Gradle skip the + * task when the output is already up to date, and lets the `bootJar` task depend on it. + */ + @get:OutputFile + abstract val outputFile: RegularFileProperty + + /** One license entry in the appendix: a display label and an optional link to its text. */ + private data class License(val label: String, val url: String?) + + /** Gradle runs this method when the task executes (`@TaskAction`). */ + @TaskAction + fun generate() { + val slurper = JsonSlurper() + + // 1. Index every SBOM component's licenses by "group:name" and "group:name:version". + // The version-keyed map is preferred so the exact shipped version wins; the + // coarser key is the fallback when versions differ between SBOM and classpath. + @Suppress("UNCHECKED_CAST") + val sbomJson = slurper.parse(sbom.get().asFile) as Map + + @Suppress("UNCHECKED_CAST") + val components = (sbomJson["components"] as? List>).orEmpty() + val byGroupArtifact = HashMap>() + val byGroupArtifactVersion = HashMap>() + for (component in components) { + val group = component["group"] as? String ?: continue + val name = component["name"] as? String ?: continue + val licenses = licensesOf(component) + byGroupArtifact["$group:$name"] = licenses + (component["version"] as? String)?.let { byGroupArtifactVersion["$group:$name:$it"] = licenses } + } + + // 2. For each dependency that actually ships, look up its license(s) in the SBOM and + // append a row. Collect any coordinate the SBOM does not cover for the gate below. + val notInSbom = mutableListOf() + val rows = StringBuilder() + for (coordinate in bundledCoordinates.get()) { + val groupArtifact = coordinate.substringBeforeLast(':') + val licenses = + byGroupArtifactVersion[coordinate] + ?: byGroupArtifact[groupArtifact] + ?: emptyList() + if (licenses.isEmpty()) { + notInSbom += coordinate + continue + } + rows.append("- ").append(coordinate).append('\n') + for (license in licenses) { + rows.append(" License: ").append(license.label) + if (!license.url.isNullOrBlank()) rows.append(" — ").append(license.url) + rows.append('\n') + } + } + + // 3. Completeness gate: a shipped dependency missing from the SBOM would be silently + // omitted from the LICENSE, so fail loudly. This is the "verify bundled deps are + // accounted for" check; it makes no judgement about which licenses are acceptable. + if (notInSbom.isNotEmpty()) { + throw GradleException( + "Bundled dependencies absent from the CycloneDX SBOM:\n" + + notInSbom.joinToString("\n") { " - $it" } + + "\nEnsure cyclonedxBom covers the runtime classpath.", + ) + } + + // 4. Write the binary LICENSE: the base Apache-2.0 text, then the generated + // third-party appendix. + val out = outputFile.get().asFile + out.parentFile.mkdirs() + out.writeText(buildString { + append(baseLicense.get().asFile.readText().trimEnd()).append("\n\n\n") + append("=".repeat(78)).append('\n') + append("APACHE SOLR MCP SERVER — THIRD-PARTY DEPENDENCY LICENSES\n") + append("=".repeat(78)).append("\n\n") + append( + "The binary distribution (the Spring Boot executable JAR) bundles the\n" + + "third-party dependencies listed below, derived from the bundled CycloneDX\n" + + "SBOM. License identifiers are reported as the SBOM declares them (SPDX ids\n" + + "where available) and a few may be imprecise; consult each dependency's own\n" + + "license for the authoritative terms via the link shown. A machine-readable\n" + + "bill of materials (component versions, hashes, and licenses) is also bundled\n" + + "at META-INF/sbom/application.cdx.json.\n\n", + ) + append(rows) + }) + } + + /** Distinct (label, url?) licenses of an SBOM component; prefers SPDX id, else name/expression. */ + private fun licensesOf(component: Map): List { + val out = LinkedHashMap() + + @Suppress("UNCHECKED_CAST") + val nodes = component["licenses"] as? List> ?: return emptyList() + for (node in nodes) { + @Suppress("UNCHECKED_CAST") + val license = node["license"] as? Map + if (license != null) { + val id = license["id"] as? String + val label = id ?: (license["name"] as? String) ?: "Unspecified" + val url = (license["url"] as? String) ?: id?.let { spdxUrl(it) } + out.putIfAbsent(label, url) + } else { + (node["expression"] as? String)?.let { out.putIfAbsent(it, null) } + } + } + return out.map { License(it.key, it.value) } + } + + private fun spdxUrl(spdxId: String): String = "https://spdx.org/licenses/$spdxId.html" +} diff --git a/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt new file mode 100644 index 00000000..405d7195 --- /dev/null +++ b/buildSrc/src/main/kotlin/org/apache/solr/mcp/build/GenerateBinaryNotice.kt @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.build + +import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.MapProperty +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import java.util.zip.ZipFile + +/** + * Generates the binary-release `NOTICE`: this project's base NOTICE followed by the + * `META-INF/NOTICE` files lifted verbatim (and de-duplicated) from the bundled jars — + * the same approach as Maven Shade's `ApacheNoticeResourceTransformer`, so notices + * required by bundled (notably ASF) dependencies are carried and stay current. + * + * For readers new to Gradle: this is a custom build *task*, created and configured by the + * `org.apache.solr.mcp.license-notice` convention plugin and run as part of + * `./gradlew build` / `bootJar`. The annotated `abstract val` properties are its declared + * inputs and output (used for up-to-date checking and task ordering). See + * `buildSrc/README.md` for a primer. + */ +abstract class GenerateBinaryNotice : DefaultTask() { + + /** + * The bundled dependency jars to scan for `META-INF/NOTICE` entries. `@InputFiles` + * marks the whole collection a file input, so the task re-runs when the set of jars + * (or their contents) changes. + */ + @get:InputFiles + abstract val jars: ConfigurableFileCollection + + /** + * Maps a jar's file name to its `"group:name:version"`, used to attribute each lifted + * notice to the dependency it came from. `@Input` — a plain value input (string map). + */ + @get:Input + abstract val coordinateByJarName: MapProperty + + /** This project's own repo-root `NOTICE`, written first. `@InputFile` (contents only). */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val baseNotice: RegularFileProperty + + /** + * Where the assembled binary `NOTICE` is written. `@OutputFile` enables up-to-date + * skipping and lets the `bootJar` task depend on it. + */ + @get:OutputFile + abstract val outputFile: RegularFileProperty + + /** Gradle runs this method when the task executes (`@TaskAction`). */ + @TaskAction + fun generate() { + val coordinates = coordinateByJarName.get() + // Match the conventional notice file names (NOTICE, NOTICE.txt, NOTICE.md) at the + // root of META-INF, case-insensitively. + val noticeEntry = Regex("(^|/)META-INF/NOTICE(\\.txt|\\.md)?$", RegexOption.IGNORE_CASE) + // Tracks notice bodies already emitted so identical notices (common across related + // modules, e.g. a multi-module library) appear once. + val seen = LinkedHashSet() + val sections = StringBuilder() + + // Walk the bundled jars in a stable order (by module coordinate) so the output is + // reproducible, and lift each jar's NOTICE entry verbatim. + jars.files + .filter { it.name.endsWith(".jar") } + .sortedBy { coordinates[it.name] ?: it.name } + .forEach { jar -> + val label = coordinates[jar.name] ?: jar.name + ZipFile(jar).use { zip -> + zip.entries().asSequence() + .filter { !it.isDirectory && noticeEntry.containsMatchIn(it.name) } + .forEach { entry -> + val text = + zip.getInputStream(entry).bufferedReader(Charsets.UTF_8).readText().trim() + // Only the first occurrence of a given notice body is kept, + // attributed to the module it came from. + if (text.isNotEmpty() && seen.add(text)) { + sections.append('\n').append("-".repeat(78)).append('\n') + sections.append("From ").append(label).append(":\n\n") + sections.append(text).append('\n') + } + } + } + } + + // Write the binary NOTICE: this project's NOTICE, then the aggregated dependency + // notices under a header (omitted entirely if no dependency ships a NOTICE). + val out = outputFile.get().asFile + out.parentFile.mkdirs() + out.writeText(buildString { + append(baseNotice.get().asFile.readText().trimEnd()).append('\n') + if (sections.isNotEmpty()) { + append("\n\n").append("=".repeat(78)).append('\n') + append("NOTICES FROM BUNDLED THIRD-PARTY DEPENDENCIES (binary distribution)\n") + append("=".repeat(78)).append('\n') + append(sections) + } + }) + } +} diff --git a/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt b/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt new file mode 100644 index 00000000..457ccd6d --- /dev/null +++ b/buildSrc/src/test/kotlin/org/apache/solr/mcp/build/LicenseNoticeTasksTest.kt @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.mcp.build + +import org.gradle.api.GradleException +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +class LicenseNoticeTasksTest { + + @TempDir + lateinit var tempDir: File + + // ---- GenerateBinaryLicense ---------------------------------------------------- + + @Test + fun `license appendix lists bundled deps with the SBOM-reported licenses and keeps the base text`() { + val task = licenseTask() + write("LICENSE", "APACHE-2.0 BASE TEXT").let(task.baseLicense::set) + // Licenses are disclosed exactly as the SBOM reports them (no allow-list, no + // corrections) — including ST4's imprecise-but-permissive BSD-4-Clause. + write( + "sbom.json", + """{"components":[ + {"group":"org.apache.solr","name":"solr-solrj","version":"10.0.0", + "licenses":[{"license":{"id":"Apache-2.0"}}]}, + {"group":"org.antlr","name":"ST4","version":"4.3.4", + "licenses":[{"license":{"id":"BSD-4-Clause"}}]}]}""", + ).let(task.sbom::set) + task.bundledCoordinates.set(listOf("org.apache.solr:solr-solrj:10.0.0", "org.antlr:ST4:4.3.4")) + val out = File(tempDir, "out/LICENSE") + task.outputFile.set(out) + + task.generate() + + val text = out.readText() + assertTrue(text.startsWith("APACHE-2.0 BASE TEXT"), "base license text must be preserved") + assertTrue(text.contains("- org.apache.solr:solr-solrj:10.0.0"), "SolrJ must be listed") + assertTrue(text.contains("Apache-2.0 — https://spdx.org/licenses/Apache-2.0.html")) + assertTrue(text.contains("BSD-4-Clause"), "SBOM license must be reported verbatim") + } + + @Test + fun `license uses a name and URL from the SBOM when there is no SPDX id`() { + val task = licenseTask() + write("LICENSE", "BASE").let(task.baseLicense::set) + write( + "sbom.json", + """{"components":[{"group":"org.antlr","name":"antlr-runtime","version":"3.5.3", + "licenses":[{"license":{"name":"BSD licence","url":"http://antlr.org/license.html"}}]}]}""", + ).let(task.sbom::set) + task.bundledCoordinates.set(listOf("org.antlr:antlr-runtime:3.5.3")) + val out = File(tempDir, "out/LICENSE") + task.outputFile.set(out) + + task.generate() + + val text = out.readText() + assertTrue(text.contains("License: BSD licence — http://antlr.org/license.html")) + } + + @Test + fun `license gate fails when a bundled dependency is absent from the SBOM`() { + val task = licenseTask() + write("LICENSE", "BASE").let(task.baseLicense::set) + write("sbom.json", """{"components":[]}""").let(task.sbom::set) + task.bundledCoordinates.set(listOf("missing:dep:1.0")) + task.outputFile.set(File(tempDir, "out/LICENSE")) + + val ex = assertThrows(GradleException::class.java) { task.generate() } + assertTrue(ex.message!!.contains("absent from the CycloneDX SBOM")) + assertTrue(ex.message!!.contains("missing:dep:1.0")) + } + + // ---- GenerateBinaryNotice ----------------------------------------------------- + + @Test + fun `notice aggregates bundled notices verbatim, de-duplicated and labelled`() { + val task = noticeTask() + write("NOTICE", "PROJECT NOTICE").let(task.baseNotice::set) + val jarA = jarWithNotice("a.jar", "Shared notice text") + val jarB = jarWithNotice("b.jar", "Shared notice text") // duplicate -> collapsed + val jarC = jarWithNotice("c.jar", "Unique C notice") + task.jars.from(jarA, jarB, jarC) + task.coordinateByJarName.set( + mapOf("a.jar" to "g:a:1", "b.jar" to "g:b:1", "c.jar" to "g:c:1"), + ) + val out = File(tempDir, "out/NOTICE") + task.outputFile.set(out) + + task.generate() + + val text = out.readText() + assertTrue(text.startsWith("PROJECT NOTICE"), "project NOTICE must lead") + assertEquals(1, occurrences(text, "Shared notice text"), "duplicate notices must collapse to one") + assertTrue(text.contains("Unique C notice")) + assertTrue(text.contains("From g:c:1:"), "each lifted notice must be attributed to its module") + } + + @Test + fun `notice with no dependency notices is just the project notice`() { + val task = noticeTask() + write("NOTICE", "PROJECT NOTICE").let(task.baseNotice::set) + task.jars.from(jarWithoutNotice("plain.jar")) + task.coordinateByJarName.set(mapOf("plain.jar" to "g:p:1")) + val out = File(tempDir, "out/NOTICE") + task.outputFile.set(out) + + task.generate() + + val text = out.readText() + assertTrue(text.startsWith("PROJECT NOTICE")) + assertFalse(text.contains("NOTICES FROM BUNDLED"), "no section header when there are no lifted notices") + } + + // ---- helpers ------------------------------------------------------------------ + + private fun project() = ProjectBuilder.builder().withProjectDir(tempDir).build() + + private fun licenseTask() = + project().tasks.register("generateBinaryLicense", GenerateBinaryLicense::class.java).get() + + private fun noticeTask() = + project().tasks.register("generateBinaryNotice", GenerateBinaryNotice::class.java).get() + + private fun write(name: String, content: String): File = + File(tempDir, name).apply { parentFile.mkdirs(); writeText(content.trimIndent()) } + + private fun jarWithNotice(name: String, notice: String): File = + File(tempDir, name).also { jar -> + ZipOutputStream(jar.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("META-INF/NOTICE")) + zip.write(notice.toByteArray()) + zip.closeEntry() + } + } + + private fun jarWithoutNotice(name: String): File = + File(tempDir, name).also { jar -> + ZipOutputStream(jar.outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("META-INF/MANIFEST.MF")) + zip.write("Manifest-Version: 1.0\n".toByteArray()) + zip.closeEntry() + } + } + + private fun occurrences(haystack: String, needle: String): Int = + haystack.split(needle).size - 1 +}