diff --git a/advisor/build.gradle.kts b/advisor/build.gradle.kts index 54531d6a897eb..6c75e86c9a4c2 100644 --- a/advisor/build.gradle.kts +++ b/advisor/build.gradle.kts @@ -24,6 +24,7 @@ plugins { dependencies { api(projects.model) + api(projects.plugins.api) api(projects.utils.commonUtils) implementation(projects.utils.ortUtils) diff --git a/advisor/src/main/kotlin/AdviceProvider.kt b/advisor/src/main/kotlin/AdviceProvider.kt index c0ffc14ef8ee1..9999c6bfaeb1c 100644 --- a/advisor/src/main/kotlin/AdviceProvider.kt +++ b/advisor/src/main/kotlin/AdviceProvider.kt @@ -22,20 +22,21 @@ package org.ossreviewtoolkit.advisor import org.ossreviewtoolkit.model.AdvisorDetails import org.ossreviewtoolkit.model.AdvisorResult import org.ossreviewtoolkit.model.Package +import org.ossreviewtoolkit.plugins.api.Plugin /** * An abstract class that represents a service that can retrieve any kind of advice information * for a list of given [Package]s. Examples of such information can be security vulnerabilities, known defects, * or code analysis results. */ -abstract class AdviceProvider(val providerName: String) { +interface AdviceProvider : Plugin { /** * For a given set of [Package]s, retrieve findings and return a map that associates packages with [AdvisorResult]s. */ - abstract suspend fun retrievePackageFindings(packages: Set): Map + suspend fun retrievePackageFindings(packages: Set): Map /** * An object with detail information about this [AdviceProvider]. */ - abstract val details: AdvisorDetails + val details: AdvisorDetails } diff --git a/advisor/src/main/kotlin/AdviceProviderFactory.kt b/advisor/src/main/kotlin/AdviceProviderFactory.kt index dee8b28a3472b..a0c1eb6fd3299 100644 --- a/advisor/src/main/kotlin/AdviceProviderFactory.kt +++ b/advisor/src/main/kotlin/AdviceProviderFactory.kt @@ -21,24 +21,16 @@ package org.ossreviewtoolkit.advisor import java.util.ServiceLoader -import org.ossreviewtoolkit.utils.common.Plugin -import org.ossreviewtoolkit.utils.common.TypedConfigurablePluginFactory +import org.ossreviewtoolkit.plugins.api.PluginFactory /** * A common abstract class for use with [ServiceLoader] that all [AdviceProviderFactory] classes need to implement. */ -abstract class AdviceProviderFactory(override val type: String) : - TypedConfigurablePluginFactory { +interface AdviceProviderFactory : PluginFactory { companion object { /** * All [advice provider factories][AdviceProviderFactory] available in the classpath, associated by their names. */ - val ALL by lazy { Plugin.getAll>() } + val ALL by lazy { PluginFactory.getAll() } } - - /** - * Return the provider's type here to allow Clikt to display something meaningful when listing the advisors which - * are enabled by default via their factories. - */ - override fun toString() = type } diff --git a/advisor/src/main/kotlin/Advisor.kt b/advisor/src/main/kotlin/Advisor.kt index c65d993c51778..24e0263e7c134 100644 --- a/advisor/src/main/kotlin/Advisor.kt +++ b/advisor/src/main/kotlin/Advisor.kt @@ -33,6 +33,7 @@ import org.ossreviewtoolkit.model.Identifier import org.ossreviewtoolkit.model.OrtResult import org.ossreviewtoolkit.model.Package import org.ossreviewtoolkit.model.config.AdvisorConfiguration +import org.ossreviewtoolkit.plugins.api.PluginConfig import org.ossreviewtoolkit.utils.ort.Environment /** @@ -40,7 +41,7 @@ import org.ossreviewtoolkit.utils.ort.Environment * [OrtResult]. */ class Advisor( - private val providerFactories: List>, + private val providerFactories: List, private val config: AdvisorConfiguration ) { /** @@ -75,8 +76,8 @@ class Advisor( logger.info { "There are no packages to give advice for." } } else { val providers = providerFactories.map { - val providerConfig = config.config?.get(it.type) - it.create(providerConfig?.options.orEmpty(), providerConfig?.secrets.orEmpty()) + val providerConfig = config.config?.get(it.descriptor.className) + it.create(PluginConfig(providerConfig?.options.orEmpty(), providerConfig?.secrets.orEmpty())) } providers.map { provider -> @@ -85,7 +86,7 @@ class Advisor( logger.info { "Found ${providerResults.values.flatMap { it.vulnerabilities }.distinct().size} distinct " + - "vulnerabilities via ${provider.providerName}. " + "vulnerabilities via ${provider.descriptor.name}. " } providerResults.keys.takeIf { it.isNotEmpty() }?.let { pkgs -> diff --git a/advisor/src/test/kotlin/AdvisorTest.kt b/advisor/src/test/kotlin/AdvisorTest.kt index a121c844eec3f..0340b8129c819 100644 --- a/advisor/src/test/kotlin/AdvisorTest.kt +++ b/advisor/src/test/kotlin/AdvisorTest.kt @@ -39,6 +39,8 @@ import org.ossreviewtoolkit.model.OrtResult import org.ossreviewtoolkit.model.Package import org.ossreviewtoolkit.model.Project import org.ossreviewtoolkit.model.config.AdvisorConfiguration +import org.ossreviewtoolkit.plugins.api.PluginConfig +import org.ossreviewtoolkit.plugins.api.PluginDescriptor class AdvisorTest : WordSpec({ "retrieveFindings" should { @@ -117,8 +119,8 @@ private fun createAdvisor(providers: List): Advisor { val advisorConfig = AdvisorConfiguration() val factories = providers.map { provider -> - val factory = mockk>() - every { factory.create(emptyMap(), emptyMap()) } returns provider + val factory = mockk() + every { factory.create(PluginConfig(emptyMap(), emptyMap())) } returns provider factory } @@ -146,7 +148,7 @@ private fun createPackage(index: Int): Package = private fun mockkAdviceProvider(): AdviceProvider = mockk().apply { - every { providerName } returns "provider" + every { descriptor } returns PluginDescriptor("provider", "Provider", "", emptyList()) } private fun mockkAdvisorResult(): AdvisorResult = diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index e5f5705fb1a32..79f8d95f809b5 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -40,5 +40,6 @@ dependencies { implementation(libs.plugin.dokkatoo) implementation(libs.plugin.graalVmNativeImage) implementation(libs.plugin.kotlin) + implementation(libs.plugin.ksp) implementation(libs.plugin.mavenPublish) } diff --git a/buildSrc/src/main/kotlin/ort-plugin-conventions.gradle.kts b/buildSrc/src/main/kotlin/ort-plugin-conventions.gradle.kts new file mode 100644 index 0000000000000..e432a5f3a4fc4 --- /dev/null +++ b/buildSrc/src/main/kotlin/ort-plugin-conventions.gradle.kts @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +plugins { + // Apply precompiled plugins. + id("ort-library-conventions") + id("ort-publication-conventions") + + // Apply third-party plugins. + id("com.google.devtools.ksp") +} + +dependencies { + ksp(project(":plugins:compiler")) +} diff --git a/buildSrc/src/main/kotlin/ort-plugins-conventions.gradle.kts b/buildSrc/src/main/kotlin/ort-plugin-parent-conventions.gradle.kts similarity index 100% rename from buildSrc/src/main/kotlin/ort-plugins-conventions.gradle.kts rename to buildSrc/src/main/kotlin/ort-plugin-parent-conventions.gradle.kts diff --git a/clients/osv/src/main/kotlin/OsvService.kt b/clients/osv/src/main/kotlin/OsvService.kt index ed2815d8653dd..a144a290f01f8 100644 --- a/clients/osv/src/main/kotlin/OsvService.kt +++ b/clients/osv/src/main/kotlin/OsvService.kt @@ -47,38 +47,26 @@ interface OsvService { companion object { const val BATCH_REQUEST_MAX_SIZE = 1000 - val JSON = Json { namingStrategy = JsonNamingStrategy.SnakeCase } + /** The URL of the production server. */ + const val PRODUCTION_SERVER_URL = "https://api.osv.dev" + + /** The URL of the staging server. */ + const val STAGING_SERVER_URL = "https://api-staging.osv.dev" - /** - * Create a service instance for communicating with the given [server], optionally using a pre-built OkHttp - * [client]. - */ - fun create(server: Server, client: OkHttpClient? = null): OsvService = create(server.url, client) + val JSON = Json { namingStrategy = JsonNamingStrategy.SnakeCase } fun create(serverUrl: String? = null, client: OkHttpClient? = null): OsvService { val converterFactory = JSON.asConverterFactory(contentType = "application/json".toMediaType()) return Retrofit.Builder() .apply { client(client ?: defaultHttpClient()) } - .baseUrl(serverUrl ?: Server.PRODUCTION.url) + .baseUrl(serverUrl ?: PRODUCTION_SERVER_URL) .addConverterFactory(converterFactory) .build() .create(OsvService::class.java) } } - enum class Server(val url: String) { - /** - * The production API server. - */ - PRODUCTION("https://api.osv.dev"), - - /** - * The staging API server. - */ - STAGING("https://api-staging.osv.dev") - } - /** * Get the vulnerabilities for the package matched by the given [request]. */ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e22600f32e28d..c0c662679b37c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,7 @@ gitSemverPlugin = "0.12.10" graalVmNativeImagePlugin = "0.10.2" ideaExtPlugin = "1.1.8" kotlinPlugin = "2.0.20" +ksp = "2.0.20-1.0.24" mavenPublishPlugin = "0.29.0" versionsPlugin = "0.51.0" @@ -33,6 +34,7 @@ jslt = "0.1.14" jsonSchemaValidator = "1.5.1" kaml = "0.61.0" kotest = "5.9.1" +kotlinPoet = "1.18.1" kotlinxCoroutines = "1.8.1" kotlinxHtml = "0.11.0" kotlinxSerialization = "1.7.1" @@ -78,6 +80,7 @@ plugin-detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-format plugin-dokkatoo = { module = "dev.adamko.dokkatoo:dokkatoo-plugin", version.ref = "dokkatooPlugin" } plugin-graalVmNativeImage = { module = "org.graalvm.buildtools:native-gradle-plugin", version.ref = "graalVmNativeImagePlugin" } plugin-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlinPlugin" } +plugin-ksp = { module = "com.google.devtools.ksp:com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } plugin-mavenPublish = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "mavenPublishPlugin" } asciidoctorj = { module = "org.asciidoctor:asciidoctorj", version.ref = "asciidoctorj" } @@ -124,6 +127,8 @@ kotest-framework-api = { module = "io.kotest:kotest-framework-api", version.ref kotest-framework-datatest = { module = "io.kotest:kotest-framework-datatest", version.ref = "kotest" } kotest-framework-engine = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" } +kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinPoet"} +kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinPoet"} kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } kotlinx-html = { module = "org.jetbrains.kotlinx:kotlinx-html-jvm", version.ref = "kotlinxHtml" } kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerialization" } @@ -133,6 +138,7 @@ kotlinx-serialization-xml = { module = "io.github.pdvrieze.xmlutil:serialization kotlinx-serialization-yaml = { module = "com.charleskorn.kaml:kaml", version.ref = "kaml" } ks3-jdk = { module = "io.ks3:ks3-jdk", version.ref = "ks3" } ks3-standard = { module = "io.ks3:ks3-standard", version.ref = "ks3" } +ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp"} ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-okHttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4jApi" } diff --git a/integrations/completions/ort-completion.fish b/integrations/completions/ort-completion.fish index e15657b5c4ced..14daa251760fe 100644 --- a/integrations/completions/ort-completion.fish +++ b/integrations/completions/ort-completion.fish @@ -25,7 +25,7 @@ complete -c ort -n "__fish_seen_subcommand_from advise" -l output-dir -s o -r -F complete -c ort -n "__fish_seen_subcommand_from advise" -l output-formats -s f -r -fa "JSON YAML" -d 'The list of output formats to be used for the ORT result file(s).' complete -c ort -n "__fish_seen_subcommand_from advise" -l label -s l -r -d 'Set a label in the ORT result, overwriting any existing label of the same name. Can be used multiple times. For example: --label distribution=external' complete -c ort -n "__fish_seen_subcommand_from advise" -l resolutions-file -r -F -d 'A file containing issue and rule violation resolutions.' -complete -c ort -n "__fish_seen_subcommand_from advise" -l advisors -s a -r -d 'The comma-separated advisors to use, any of [NexusIQ, OssIndex, OSV, VulnerableCode].' +complete -c ort -n "__fish_seen_subcommand_from advise" -l advisors -s a -r -d 'The comma-separated advisors to use, any of [NexusIq, OssIndex, Osv, VulnerableCode].' complete -c ort -n "__fish_seen_subcommand_from advise" -l skip-excluded -d 'Do not check excluded projects or packages.' complete -c ort -n "__fish_seen_subcommand_from advise" -s h -l help -d 'Show this message and exit' diff --git a/plugins/advisors/build.gradle.kts b/plugins/advisors/build.gradle.kts index bbfb2a35fcb17..367568bf2ac8f 100644 --- a/plugins/advisors/build.gradle.kts +++ b/plugins/advisors/build.gradle.kts @@ -19,5 +19,5 @@ plugins { // Apply precompiled plugins. - id("ort-plugins-conventions") + id("ort-plugin-parent-conventions") } diff --git a/plugins/advisors/nexus-iq/build.gradle.kts b/plugins/advisors/nexus-iq/build.gradle.kts index e05fe1c7d8f89..344b025515a5b 100644 --- a/plugins/advisors/nexus-iq/build.gradle.kts +++ b/plugins/advisors/nexus-iq/build.gradle.kts @@ -19,7 +19,7 @@ plugins { // Apply precompiled plugins. - id("ort-library-conventions") + id("ort-plugin-conventions") } dependencies { @@ -29,4 +29,6 @@ dependencies { implementation(projects.clients.nexusIqClient) implementation(projects.utils.commonUtils) implementation(projects.utils.ortUtils) + + ksp(projects.advisor) } diff --git a/plugins/advisors/nexus-iq/src/main/kotlin/NexusIq.kt b/plugins/advisors/nexus-iq/src/main/kotlin/NexusIq.kt index 05ecff5cc3639..2f121a22c8ef3 100644 --- a/plugins/advisors/nexus-iq/src/main/kotlin/NexusIq.kt +++ b/plugins/advisors/nexus-iq/src/main/kotlin/NexusIq.kt @@ -44,7 +44,8 @@ import org.ossreviewtoolkit.model.utils.PurlType import org.ossreviewtoolkit.model.utils.getPurlType import org.ossreviewtoolkit.model.vulnerabilities.Vulnerability import org.ossreviewtoolkit.model.vulnerabilities.VulnerabilityReference -import org.ossreviewtoolkit.utils.common.Options +import org.ossreviewtoolkit.plugins.api.OrtPlugin +import org.ossreviewtoolkit.plugins.api.PluginDescriptor import org.ossreviewtoolkit.utils.common.collectMessages import org.ossreviewtoolkit.utils.common.enumSetOf import org.ossreviewtoolkit.utils.ort.OkHttpClientHelper @@ -77,23 +78,13 @@ private val READ_TIMEOUT = Duration.ofSeconds(60) * * If not both `username` and `password` are provided, authentication is disabled. */ -class NexusIq(name: String, private val config: NexusIqConfiguration) : AdviceProvider(name) { - class Factory : AdviceProviderFactory("NexusIQ") { - override fun create(config: NexusIqConfiguration) = NexusIq(type, config) - - override fun parseConfig(options: Options, secrets: Options): NexusIqConfiguration { - val serverUrl = options.getValue("serverUrl") - - return NexusIqConfiguration( - serverUrl = serverUrl, - browseUrl = options["browseUrl"] ?: serverUrl, - username = secrets["username"], - password = secrets["password"] - ) - } - } - - override val details: AdvisorDetails = AdvisorDetails(providerName, enumSetOf(AdvisorCapability.VULNERABILITIES)) +@OrtPlugin( + name = "Nexus IQ", + description = "An advisor that uses Sonatype's Nexus IQ Server to determine vulnerabilities in dependencies.", + factory = AdviceProviderFactory::class +) +class NexusIq(override val descriptor: PluginDescriptor, private val config: NexusIqConfiguration) : AdviceProvider { + override val details = AdvisorDetails(descriptor.className, enumSetOf(AdvisorCapability.VULNERABILITIES)) private val service by lazy { NexusIqService.create( @@ -141,7 +132,7 @@ class NexusIq(name: String, private val config: NexusIqConfiguration) : AdvicePr component.packageUrl to ComponentDetails(component, SecurityData(emptyList())) } - issues += Issue(source = providerName, message = it.collectMessages()) + issues += Issue(source = descriptor.name, message = it.collectMessages()) } } diff --git a/plugins/advisors/nexus-iq/src/main/kotlin/NexusIqConfiguration.kt b/plugins/advisors/nexus-iq/src/main/kotlin/NexusIqConfiguration.kt index 79e29ebf24f2a..f7666503502c5 100644 --- a/plugins/advisors/nexus-iq/src/main/kotlin/NexusIqConfiguration.kt +++ b/plugins/advisors/nexus-iq/src/main/kotlin/NexusIqConfiguration.kt @@ -29,19 +29,19 @@ data class NexusIqConfiguration( val serverUrl: String, /** - * A URL to use as a base for browsing vulnerability details. Defaults to the server URL. + * A URL to use as a base for browsing vulnerability details. If not set, the [serverUrl] is used. */ - val browseUrl: String = serverUrl, + val browseUrl: String?, /** * The username to use for authentication. If not both [username] and [password] are provided, authentication is * disabled. */ - val username: String? = null, + val username: String?, /** * The password to use for authentication. If not both [username] and [password] are provided, authentication is * disabled. */ - val password: String? = null + val password: String? ) diff --git a/plugins/advisors/nexus-iq/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory b/plugins/advisors/nexus-iq/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory deleted file mode 100644 index 522db732f07b5..0000000000000 --- a/plugins/advisors/nexus-iq/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory +++ /dev/null @@ -1 +0,0 @@ -org.ossreviewtoolkit.plugins.advisors.nexusiq.NexusIq$Factory diff --git a/plugins/advisors/oss-index/build.gradle.kts b/plugins/advisors/oss-index/build.gradle.kts index 8dd14d51a93cc..63e83710d599d 100644 --- a/plugins/advisors/oss-index/build.gradle.kts +++ b/plugins/advisors/oss-index/build.gradle.kts @@ -19,7 +19,7 @@ plugins { // Apply precompiled plugins. - id("ort-library-conventions") + id("ort-plugin-conventions") } dependencies { @@ -30,5 +30,7 @@ dependencies { implementation(projects.utils.commonUtils) implementation(projects.utils.ortUtils) + ksp(projects.advisor) + testImplementation(libs.wiremock) } diff --git a/plugins/advisors/oss-index/src/main/kotlin/OssIndex.kt b/plugins/advisors/oss-index/src/main/kotlin/OssIndex.kt index 32ae886381fa8..c213381daed77 100644 --- a/plugins/advisors/oss-index/src/main/kotlin/OssIndex.kt +++ b/plugins/advisors/oss-index/src/main/kotlin/OssIndex.kt @@ -38,7 +38,8 @@ import org.ossreviewtoolkit.model.Package import org.ossreviewtoolkit.model.config.PluginConfiguration import org.ossreviewtoolkit.model.vulnerabilities.Vulnerability import org.ossreviewtoolkit.model.vulnerabilities.VulnerabilityReference -import org.ossreviewtoolkit.utils.common.Options +import org.ossreviewtoolkit.plugins.api.OrtPlugin +import org.ossreviewtoolkit.plugins.api.PluginDescriptor import org.ossreviewtoolkit.utils.common.collectMessages import org.ossreviewtoolkit.utils.common.enumSetOf import org.ossreviewtoolkit.utils.ort.OkHttpClientHelper @@ -65,25 +66,19 @@ private const val BULK_REQUEST_SIZE = 128 /** * A wrapper for Sonatype's [OSS Index](https://ossindex.sonatype.org/) security vulnerability data. */ -class OssIndex(name: String, config: OssIndexConfiguration) : AdviceProvider(name) { - class Factory : AdviceProviderFactory("OssIndex") { - override fun create(config: OssIndexConfiguration) = OssIndex(type, config) - - override fun parseConfig(options: Options, secrets: Options) = - OssIndexConfiguration( - serverUrl = options["serverUrl"], - username = secrets["username"], - password = secrets["password"] - ) - } - - override val details = AdvisorDetails(providerName, enumSetOf(AdvisorCapability.VULNERABILITIES)) +@OrtPlugin( + name = "OSS Index", + description = "An advisor that uses Sonatype's OSS Index to determine vulnerabilities in dependencies.", + factory = AdviceProviderFactory::class +) +class OssIndex(override val descriptor: PluginDescriptor, config: OssIndexConfiguration) : AdviceProvider { + override val details = AdvisorDetails(descriptor.className, enumSetOf(AdvisorCapability.VULNERABILITIES)) private val service by lazy { OssIndexService.create( config.serverUrl, - config.username, - config.password, + config.username?.value, + config.password?.value, OkHttpClientHelper.buildClient() ) } @@ -118,7 +113,7 @@ class OssIndex(name: String, config: OssIndexConfiguration) : AdviceProvider(nam ComponentReport(purl, reference = "", vulnerabilities = emptyList()) } - issues += Issue(source = providerName, message = it.collectMessages()) + issues += Issue(source = descriptor.name, message = it.collectMessages()) } } diff --git a/plugins/advisors/oss-index/src/main/kotlin/OssIndexConfiguration.kt b/plugins/advisors/oss-index/src/main/kotlin/OssIndexConfiguration.kt index fa9f42ccb99c0..4acb4e749f190 100644 --- a/plugins/advisors/oss-index/src/main/kotlin/OssIndexConfiguration.kt +++ b/plugins/advisors/oss-index/src/main/kotlin/OssIndexConfiguration.kt @@ -19,25 +19,29 @@ package org.ossreviewtoolkit.plugins.advisors.ossindex +import org.ossreviewtoolkit.clients.ossindex.OssIndexService +import org.ossreviewtoolkit.plugins.api.OrtPluginOption +import org.ossreviewtoolkit.plugins.api.Secret + /** * The configuration for the OSS Index provider. */ data class OssIndexConfiguration( /** - * The base URL of the OSS Index REST API. If undefined, default base URL for the REST API of the public OSS Index - * service. + * The base URL of the OSS Index REST API. */ - val serverUrl: String? = null, + @OrtPluginOption(defaultValue = OssIndexService.DEFAULT_BASE_URL) + val serverUrl: String, /** * The username to use for authentication. If not both [username] and [password] are provided, authentication is * disabled. */ - val username: String? = null, + val username: Secret?, /** * The password to use for authentication. If not both [username] and [password] are provided, authentication is * disabled. */ - val password: String? = null + val password: Secret? ) diff --git a/plugins/advisors/oss-index/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory b/plugins/advisors/oss-index/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory deleted file mode 100644 index 82f415eb99c1a..0000000000000 --- a/plugins/advisors/oss-index/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory +++ /dev/null @@ -1 +0,0 @@ -org.ossreviewtoolkit.plugins.advisors.ossindex.OssIndex$Factory diff --git a/plugins/advisors/oss-index/src/test/kotlin/OssIndexTest.kt b/plugins/advisors/oss-index/src/test/kotlin/OssIndexTest.kt index 44302d0ad5330..6e91483470697 100644 --- a/plugins/advisors/oss-index/src/test/kotlin/OssIndexTest.kt +++ b/plugins/advisors/oss-index/src/test/kotlin/OssIndexTest.kt @@ -70,7 +70,10 @@ class OssIndexTest : WordSpec({ "OssIndex" should { "return vulnerability information" { server.stubComponentsRequest("response_components.json") - val ossIndex = OssIndex(ADVISOR_NAME, OssIndexConfiguration("http://localhost:${server.port()}")) + val ossIndex = OssIndex( + OssIndexFactory().descriptor, + OssIndexConfiguration("http://localhost:${server.port()}", null, null) + ) val packages = COMPONENTS_REQUEST_IDS.mapTo(mutableSetOf()) { Package.EMPTY.copy(id = it, purl = it.toPurl()) } @@ -114,7 +117,10 @@ class OssIndexTest : WordSpec({ aResponse().withStatus(500) ) ) - val ossIndex = OssIndex(ADVISOR_NAME, OssIndexConfiguration("http://localhost:${server.port()}")) + val ossIndex = OssIndex( + OssIndexFactory().descriptor, + OssIndexConfiguration("http://localhost:${server.port()}", null, null) + ) val packages = COMPONENTS_REQUEST_IDS.mapTo(mutableSetOf()) { Package.EMPTY.copy(id = it, purl = it.toPurl()) } @@ -130,14 +136,17 @@ class OssIndexTest : WordSpec({ } "provide correct details" { - val ossIndex = OssIndex(ADVISOR_NAME, OssIndexConfiguration("http://localhost:${server.port()}")) + val ossIndex = OssIndex( + OssIndexFactory().descriptor, + OssIndexConfiguration("http://localhost:${server.port()}", null, null) + ) ossIndex.details shouldBe AdvisorDetails(ADVISOR_NAME, enumSetOf(AdvisorCapability.VULNERABILITIES)) } } }) -private const val ADVISOR_NAME = "OssIndexTest" +private const val ADVISOR_NAME = "OssIndex" private const val TEST_FILES_ROOT = "src/test/assets" diff --git a/plugins/advisors/osv/build.gradle.kts b/plugins/advisors/osv/build.gradle.kts index c8f75182850b4..6787f6091ed91 100644 --- a/plugins/advisors/osv/build.gradle.kts +++ b/plugins/advisors/osv/build.gradle.kts @@ -19,7 +19,7 @@ plugins { // Apply precompiled plugins. - id("ort-library-conventions") + id("ort-plugin-conventions") } dependencies { @@ -33,4 +33,6 @@ dependencies { implementation(libs.cvssCalculator) implementation(libs.kotlinx.serialization.core) implementation(libs.kotlinx.serialization.json) + + ksp(projects.advisor) } diff --git a/plugins/advisors/osv/src/funTest/assets/retrieve-package-findings-expected-result.json b/plugins/advisors/osv/src/funTest/assets/retrieve-package-findings-expected-result.json index 3dee8a2c40cd9..a57dd0f1b6508 100644 --- a/plugins/advisors/osv/src/funTest/assets/retrieve-package-findings-expected-result.json +++ b/plugins/advisors/osv/src/funTest/assets/retrieve-package-findings-expected-result.json @@ -1,7 +1,7 @@ { "NPM::find-my-way:3.0.0" : { "advisor" : { - "name" : "OSV", + "name" : "Osv", "capabilities" : [ "VULNERABILITIES" ] }, "summary" : { @@ -34,7 +34,7 @@ }, "NPM::discord-markdown:2.3.0" : { "advisor" : { - "name" : "OSV", + "name" : "Osv", "capabilities" : [ "VULNERABILITIES" ] }, "summary" : { @@ -63,7 +63,7 @@ }, "PyPI::donfig:0.2.0" : { "advisor" : { - "name" : "OSV", + "name" : "Osv", "capabilities" : [ "VULNERABILITIES" ] }, "summary" : { diff --git a/plugins/advisors/osv/src/funTest/kotlin/OsvFunTest.kt b/plugins/advisors/osv/src/funTest/kotlin/OsvFunTest.kt index 62a169c6a2d7a..ace5d0484a4da 100644 --- a/plugins/advisors/osv/src/funTest/kotlin/OsvFunTest.kt +++ b/plugins/advisors/osv/src/funTest/kotlin/OsvFunTest.kt @@ -33,6 +33,7 @@ import org.ossreviewtoolkit.model.Identifier import org.ossreviewtoolkit.model.Package import org.ossreviewtoolkit.model.readValue import org.ossreviewtoolkit.model.utils.toPurl +import org.ossreviewtoolkit.plugins.api.PluginConfig import org.ossreviewtoolkit.utils.test.getAssetFile class OsvFunTest : StringSpec({ @@ -88,7 +89,7 @@ class OsvFunTest : StringSpec({ private fun identifierToPackage(id: String): Package = Identifier(id).let { Package.EMPTY.copy(id = it, purl = it.toPurl()) } -private fun createOsv(): Osv = Osv("OSV", OsvConfiguration(serverUrl = null)) +private fun createOsv(): Osv = OsvFactory().create(PluginConfig()) private fun Map.patchTimes(): Map = mapValues { (_, advisorResult) -> diff --git a/plugins/advisors/osv/src/main/kotlin/Osv.kt b/plugins/advisors/osv/src/main/kotlin/Osv.kt index 8320d32155911..3b3b091c7f047 100644 --- a/plugins/advisors/osv/src/main/kotlin/Osv.kt +++ b/plugins/advisors/osv/src/main/kotlin/Osv.kt @@ -41,7 +41,8 @@ import org.ossreviewtoolkit.model.Identifier import org.ossreviewtoolkit.model.Package import org.ossreviewtoolkit.model.config.PluginConfiguration import org.ossreviewtoolkit.model.vulnerabilities.VulnerabilityReference -import org.ossreviewtoolkit.utils.common.Options +import org.ossreviewtoolkit.plugins.api.OrtPlugin +import org.ossreviewtoolkit.plugins.api.PluginDescriptor import org.ossreviewtoolkit.utils.common.collectMessages import org.ossreviewtoolkit.utils.common.enumSetOf import org.ossreviewtoolkit.utils.common.toUri @@ -59,15 +60,13 @@ import us.springett.cvss.Cvss * * **`serverUrl`:** The base URL of the OSV REST API. If undefined, default is the production endpoint of the official * OSV.dev API. */ -class Osv(name: String, config: OsvConfiguration) : AdviceProvider(name) { - class Factory : AdviceProviderFactory("OSV") { - override fun create(config: OsvConfiguration) = Osv(type, config) - - override fun parseConfig(options: Options, secrets: Options) = - OsvConfiguration(serverUrl = options["serverUrl"]) - } - - override val details: AdvisorDetails = AdvisorDetails(providerName, enumSetOf(AdvisorCapability.VULNERABILITIES)) +@OrtPlugin( + name = "OSV", + description = "An advisor that retrieves vulnerability information from the Open Source Vulnerabilities database.", + factory = AdviceProviderFactory::class +) +class Osv(override val descriptor: PluginDescriptor, config: OsvConfiguration) : AdviceProvider { + override val details = AdvisorDetails(descriptor.className, enumSetOf(AdvisorCapability.VULNERABILITIES)) private val service = OsvServiceWrapper( serverUrl = config.serverUrl, diff --git a/plugins/advisors/osv/src/main/kotlin/OsvConfiguration.kt b/plugins/advisors/osv/src/main/kotlin/OsvConfiguration.kt index 369f56c67558a..9fbd76e1c3585 100644 --- a/plugins/advisors/osv/src/main/kotlin/OsvConfiguration.kt +++ b/plugins/advisors/osv/src/main/kotlin/OsvConfiguration.kt @@ -19,6 +19,9 @@ package org.ossreviewtoolkit.plugins.advisors.osv +import org.ossreviewtoolkit.clients.osv.OsvService +import org.ossreviewtoolkit.plugins.api.OrtPluginOption + /** * The configuration for the Google OSV vulnerability provider. */ @@ -26,5 +29,6 @@ data class OsvConfiguration( /** * The base URL of the OSV REST API. If undefined, default is the production endpoint of the official OSV.dev API. */ - val serverUrl: String? = null + @OrtPluginOption(defaultValue = OsvService.PRODUCTION_SERVER_URL) + val serverUrl: String ) diff --git a/plugins/advisors/osv/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory b/plugins/advisors/osv/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory deleted file mode 100644 index 6785d516515d7..0000000000000 --- a/plugins/advisors/osv/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory +++ /dev/null @@ -1 +0,0 @@ -org.ossreviewtoolkit.plugins.advisors.osv.Osv$Factory diff --git a/plugins/advisors/vulnerable-code/build.gradle.kts b/plugins/advisors/vulnerable-code/build.gradle.kts index 10c30ab5307d0..24087e8c8bcf8 100644 --- a/plugins/advisors/vulnerable-code/build.gradle.kts +++ b/plugins/advisors/vulnerable-code/build.gradle.kts @@ -19,7 +19,7 @@ plugins { // Apply precompiled plugins. - id("ort-library-conventions") + id("ort-plugin-conventions") } dependencies { @@ -30,5 +30,7 @@ dependencies { implementation(projects.utils.commonUtils) implementation(projects.utils.ortUtils) + ksp(projects.advisor) + testImplementation(libs.wiremock) } diff --git a/plugins/advisors/vulnerable-code/src/funTest/kotlin/VulnerableCodeFunTest.kt b/plugins/advisors/vulnerable-code/src/funTest/kotlin/VulnerableCodeFunTest.kt index 9ac91db07ced4..41b38e0467f87 100644 --- a/plugins/advisors/vulnerable-code/src/funTest/kotlin/VulnerableCodeFunTest.kt +++ b/plugins/advisors/vulnerable-code/src/funTest/kotlin/VulnerableCodeFunTest.kt @@ -29,11 +29,12 @@ import io.kotest.matchers.shouldBe import org.ossreviewtoolkit.model.Identifier import org.ossreviewtoolkit.model.Package import org.ossreviewtoolkit.model.utils.toPurl +import org.ossreviewtoolkit.plugins.api.PluginConfig class VulnerableCodeFunTest : WordSpec({ "Vulnerable Maven packages" should { "return findings for Guava" { - val vc = VulnerableCode("VulnerableCode", VulnerableCodeConfiguration()) + val vc = VulnerableCodeFactory().create(PluginConfig()) val id = Identifier("Maven:com.google.guava:guava:19.0") val pkg = Package.EMPTY.copy(id, purl = id.toPurl()) @@ -57,7 +58,7 @@ class VulnerableCodeFunTest : WordSpec({ } "return findings for Commons-Compress" { - val vc = VulnerableCode("VulnerableCode", VulnerableCodeConfiguration()) + val vc = VulnerableCodeFactory().create(PluginConfig()) val id = Identifier("Maven:org.apache.commons:commons-compress:1.23.0") val pkg = Package.EMPTY.copy(id, purl = id.toPurl()) diff --git a/plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCode.kt b/plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCode.kt index 281976d72c2b4..51d3ca7d8a166 100644 --- a/plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCode.kt +++ b/plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCode.kt @@ -40,7 +40,8 @@ import org.ossreviewtoolkit.model.config.PluginConfiguration import org.ossreviewtoolkit.model.createAndLogIssue import org.ossreviewtoolkit.model.vulnerabilities.Vulnerability import org.ossreviewtoolkit.model.vulnerabilities.VulnerabilityReference -import org.ossreviewtoolkit.utils.common.Options +import org.ossreviewtoolkit.plugins.api.OrtPlugin +import org.ossreviewtoolkit.plugins.api.PluginDescriptor import org.ossreviewtoolkit.utils.common.collectMessages import org.ossreviewtoolkit.utils.common.enumSetOf import org.ossreviewtoolkit.utils.common.percentEncode @@ -69,23 +70,17 @@ private const val BULK_REQUEST_SIZE = 100 * * * **`apiKey`:** The optional API key to use. */ -class VulnerableCode(name: String, config: VulnerableCodeConfiguration) : AdviceProvider(name) { - class Factory : AdviceProviderFactory("VulnerableCode") { - override fun create(config: VulnerableCodeConfiguration) = VulnerableCode(type, config) - - override fun parseConfig(options: Options, secrets: Options) = - VulnerableCodeConfiguration( - serverUrl = options["serverUrl"], - apiKey = secrets["apiKey"], - readTimeout = options["readTimeout"]?.toLongOrNull() - ) - } - +@OrtPlugin( + name = "VulnerableCode", + description = "An advisor that uses a VulnerableCode instance to determine vulnerabilities in dependencies.", + factory = AdviceProviderFactory::class +) +class VulnerableCode(override val descriptor: PluginDescriptor, config: VulnerableCodeConfiguration) : AdviceProvider { /** * The details returned with each [AdvisorResult] produced by this instance. As this is constant, it can be * created once beforehand. */ - override val details = AdvisorDetails(providerName, enumSetOf(AdvisorCapability.VULNERABILITIES)) + override val details = AdvisorDetails(descriptor.className, enumSetOf(AdvisorCapability.VULNERABILITIES)) private val service by lazy { val client = OkHttpClientHelper.buildClient { @@ -116,7 +111,7 @@ class VulnerableCode(name: String, config: VulnerableCodeConfiguration) : Advice // issues that are not associated to any package. allVulnerabilities += chunk.associateWith { emptyList() } - issues += Issue(source = providerName, message = it.collectMessages()) + issues += Issue(source = descriptor.name, message = it.collectMessages()) logger.error { "The request of chunk ${index + 1} of ${chunks.size} failed for the following ${chunk.size} " + @@ -166,7 +161,7 @@ class VulnerableCode(name: String, config: VulnerableCodeConfiguration) : Advice VulnerabilityReference(sourceUri, it.scoringSystem, severity) } }.onFailure { - issues += createAndLogIssue(providerName, "Failed to map $this to ORT model due to $it.", Severity.HINT) + issues += createAndLogIssue(descriptor.name, "Failed to map $this to ORT model due to $it.", Severity.HINT) }.getOrElse { emptyList() } /** diff --git a/plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCodeConfiguration.kt b/plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCodeConfiguration.kt index 72a4bccaf6024..ef4cfb910d4fe 100644 --- a/plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCodeConfiguration.kt +++ b/plugins/advisors/vulnerable-code/src/main/kotlin/VulnerableCodeConfiguration.kt @@ -19,6 +19,9 @@ package org.ossreviewtoolkit.plugins.advisors.vulnerablecode +import org.ossreviewtoolkit.clients.vulnerablecode.VulnerableCodeService +import org.ossreviewtoolkit.plugins.api.OrtPluginOption + /** * The configuration for VulnerableCode as security vulnerability provider. */ @@ -26,15 +29,16 @@ data class VulnerableCodeConfiguration( /** * The base URL of the VulnerableCode REST API. By default, the public VulnerableCode instance is used. */ - val serverUrl: String? = null, + @OrtPluginOption(defaultValue = VulnerableCodeService.PUBLIC_SERVER_URL) + val serverUrl: String, /** * The optional API key to use. */ - val apiKey: String? = null, + val apiKey: String?, /** * The read timeout for the server connection in seconds. Defaults to whatever is the HTTP client's default value. */ - val readTimeout: Long? = null + val readTimeout: Long? ) diff --git a/plugins/advisors/vulnerable-code/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory b/plugins/advisors/vulnerable-code/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory deleted file mode 100644 index 394f359158afe..0000000000000 --- a/plugins/advisors/vulnerable-code/src/main/resources/META-INF/services/org.ossreviewtoolkit.advisor.AdviceProviderFactory +++ /dev/null @@ -1 +0,0 @@ -org.ossreviewtoolkit.plugins.advisors.vulnerablecode.VulnerableCode$Factory diff --git a/plugins/advisors/vulnerable-code/src/test/kotlin/VulnerableCodeTest.kt b/plugins/advisors/vulnerable-code/src/test/kotlin/VulnerableCodeTest.kt index c870d30006bba..932bf140b7b8b 100644 --- a/plugins/advisors/vulnerable-code/src/test/kotlin/VulnerableCodeTest.kt +++ b/plugins/advisors/vulnerable-code/src/test/kotlin/VulnerableCodeTest.kt @@ -281,7 +281,7 @@ class VulnerableCodeTest : WordSpec({ } }) -private const val ADVISOR_NAME = "VulnerableCodeTestAdvisor" +private const val ADVISOR_NAME = "VulnerableCode" private const val TEST_FILES_ROOT = "src/test/assets" private const val TEST_RESULT_NAME = "ort-analyzer-result.yml" @@ -329,14 +329,14 @@ private fun WireMockServer.stubPackagesRequest(responseFile: String, request: St */ private fun createConfig(server: WireMockServer): VulnerableCodeConfiguration { val url = "http://localhost:${server.port()}" - return VulnerableCodeConfiguration(url, "") + return VulnerableCodeConfiguration(url, null, null) } /** * Create a test instance of [VulnerableCode] that communicates with the local [server]. */ private fun createVulnerableCode(server: WireMockServer): VulnerableCode = - VulnerableCode(ADVISOR_NAME, createConfig(server)) + VulnerableCode(VulnerableCodeFactory().descriptor, createConfig(server)) /** * Return the test file with an analyzer result. diff --git a/plugins/api/README.md b/plugins/api/README.md new file mode 100644 index 0000000000000..56d3616655b9f --- /dev/null +++ b/plugins/api/README.md @@ -0,0 +1,139 @@ +# ORT Plugin API + +The ORT Plugin API defines the interfaces that ORT plugin extension points must use and that ORT plugins must implement. + +## Plugin Extension Points + +Plugin extension points must define the base class for the plugin and an interface for a factory that creates instances of the plugin. +For example, the extension point for advisor plugins is defined in the `AdviceProvider` interface and the `AdviceProviderFactory` interface. + +### Plugin Base Class + +The plugin base class defines the functions and properties that plugins must implement. +The base class can be either an interface or an abstract class and must extend the `Plugin` interface. +If it is an abstract class, it must not take any constructor arguments, as this would make it impossible to define a generic factory function for all plugins. + +For example, the `AdviceProvider` interface defines one function and one property that all advisor plugins must implement: + +```kotlin +interface AdviceProvider : Plugin { + val details: AdvisorDetails + suspend fun retrievePackageFindings(packages: Set): Map +} +``` + +In addition, the `Plugin` interface defines a `descriptor` property that contains metadata about the plugin: + +```kotlin +interface Plugin { + val descriptor: PluginDescriptor +} +``` + +### Plugin Factory Interface + +The plugin factory interface is a markup interface that extends the `PluginFactory` interface and defines the plugin base class as a type parameter. + +For example, the `AdviceProviderFactory` interface defines that the base class for advisor plugins is `AdviceProvider`: + +```kotlin +interface AdviceProviderFactory : PluginFactory +``` + +The `create` function defined by the `PluginFactory` interface takes has a `PluginConfig` parameter that contains the configuration for the plugin. +The `PluginFactory` provides a generic `getAll()` function that returns all available plugin factories of the given type. +It uses the service loader mechanism to find the available plugin factories. + +## Plugin Implementations + +Plugin implementations consist of a class that implements the plugin base class, a factory class that implements the factory interface, and a service loader configuration file. +If the plugin has configuration options, it must implement an additional data class as a holder for the configuration. + +To reduce the amount of boilerplate code, ORT provides a compiler plugin that can generate the factory class and the service loader file. +The compiler plugin uses the [Kotlin Symbol Processing (KSP) API](https://kotlinlang.org/docs/ksp-overview.html). +With this, the plugin implementation only needs to implement the plugin class and the configuration data class. + +### Plugin Class + +To be able to use the compiler plugin, the plugin class must follow certain conventions: + +* It must be annotated with the `@OrtPlugin` annotation which takes some metadata and the factory interface as arguments. +* It must have a single constructor that takes one or two arguments: + The first one must override the `descriptor` property of the `Plugin` interface. + Optionally, the second one must be called `config` and must be of the type of the configuration data class. + +For example, an advisor plugin implementation could look like this: + +```kotlin +@OrtPlugin( + name = "Example Advisor", + description = "An example advisor plugin.", + factory = AdviceProviderFactory::class +) +class ExampleAdvisor(override val descriptor: PluginDescriptor, val config: ExampleConfiguration) : AdviceProvider { + ... +} +``` + +### Plugin Configuration Class + +The configuration class must be a data class with a single constructor that takes all configuration options as arguments. +To be able to use the compiler plugin, the configuration class must follow certain conventions: + +* All constructor arguments must be `val`s. +* Constructor arguments must have one of the following types: `Boolean`, `Int`, `Long`, `Secret`, `String`, `List`. +* Constructor arguments must not have a default value. + Instead, the default value can be set by adding the `@OrtPluginOption` annotation to the property. + This is required for code generation because KSP does not provide any details about default values of constructor arguments. + Also, to be able to handle default values in the compiler plugin, they must be compile time constants which also applies to annotation arguments. +* Constructor arguments can be nullable if they are optional. +* If a constructor argument is not nullable and has no default value, the argument is required and the generated factory will throw an exception if it cannot be found in the `PluginConfig`. +* The compiler plugin will use the KDoc of a constructor argument as the description of the option when generating the `PluginDescriptor`. + +The generated factory class will take option values from the `PluginConfig.options` map and use them to create an instance of the configuration class. +The only exception are `Secret` properties which are taken from the `PluginConfig.secrets` map. + +For example, an advisor plugin configuration could look like this: + +```kotlin +data class ExampleConfiguration( + /** The REST API server URL. */ + @OrtPluginOption(defaultValue = "https://example.com") + val serverUrl: String, + + /** The timeout in seconds for REST API requests. */ + val timeout: Int, + + /** The API token to use for authentication. */ + val token: Secret? +) +``` + +Here, the `serverUrl` property has a default value, the `timeout` property is required, and the `token` property is optional. + +### Gradle Configuration + +A Gradle module that contains an ORT plugin implementation must apply the `com.google.devtools.ksp` Gradle plugin and add dependencies to the ORT compiler plugin and the API of the implemented extension point to the KSP configuration: + +```kotlin +plugins { + id("com.google.devtools.ksp:[version]") +} + +dependencies { + ksp("org.ossreviewtoolkit:advisor:[version]") + ksp("org.ossreviewtoolkit:compiler:[version]") +} +``` + +In the ORT codebase, the `ort-plugin-conventions` should be applied so that only a dependency on the extension point API is required: + +```kotlin +plugins { + id("ort-plugin-conventions") +} + +dependencies { + ksp(projects.advisor) +} +``` diff --git a/plugins/api/build.gradle.kts b/plugins/api/build.gradle.kts new file mode 100644 index 0000000000000..1d09686cdc8c6 --- /dev/null +++ b/plugins/api/build.gradle.kts @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +plugins { + // Apply precompiled plugins. + id("ort-library-conventions") +} + +dependencies { + api(projects.utils.commonUtils) +} diff --git a/plugins/api/src/main/kotlin/OrtPlugin.kt b/plugins/api/src/main/kotlin/OrtPlugin.kt new file mode 100644 index 0000000000000..12e92e849864b --- /dev/null +++ b/plugins/api/src/main/kotlin/OrtPlugin.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.api + +import kotlin.reflect.KClass + +/** + * An annotation to mark a class as an ORT plugin. + */ +@Target(AnnotationTarget.CLASS) +annotation class OrtPlugin( + val name: String, + val description: String, + val factory: KClass<*> +) diff --git a/plugins/api/src/main/kotlin/OrtPluginOption.kt b/plugins/api/src/main/kotlin/OrtPluginOption.kt new file mode 100644 index 0000000000000..0e83454b6bca3 --- /dev/null +++ b/plugins/api/src/main/kotlin/OrtPluginOption.kt @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.api + +/** + * An annotation to mark a property as an option for an ORT plugin. The annotation is only required to set a default + * value for the option. + */ +@Target(AnnotationTarget.PROPERTY) +annotation class OrtPluginOption( + /** + * The default value of the option. + */ + val defaultValue: String +) diff --git a/plugins/api/src/main/kotlin/PluginConfig.kt b/plugins/api/src/main/kotlin/PluginConfig.kt new file mode 100644 index 0000000000000..0be1ecdaf0536 --- /dev/null +++ b/plugins/api/src/main/kotlin/PluginConfig.kt @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.api + +import com.fasterxml.jackson.annotation.JsonIgnore + +import org.ossreviewtoolkit.utils.common.Options + +/** + * The configuration of a plugin, used as input for [PluginFactory.create]. + */ +data class PluginConfig( + /** + * The configuration options of the plugin. + */ + val options: Options = emptyMap(), + + /** + * The configuration secrets of the plugin. + * + * This property is not serialized to ensure that secrets do not appear in serialized output. + */ + @JsonIgnore + val secrets: Options = emptyMap() +) { + /** + * Return a string representation that does not contain the [secrets]. + */ + override fun toString() = "${this::class.simpleName}(options=$options, secrets=[***])" +} diff --git a/plugins/api/src/main/kotlin/PluginDescriptor.kt b/plugins/api/src/main/kotlin/PluginDescriptor.kt new file mode 100644 index 0000000000000..df148d839dbd6 --- /dev/null +++ b/plugins/api/src/main/kotlin/PluginDescriptor.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.api + +/** + * A descriptor holding the metadata of a plugin. + */ +data class PluginDescriptor( + /** + * The name of the plugin. Must be unique among all plugins for the same factory class. + */ + val name: String, + + /** + * The name of the plugin class. + */ + val className: String, + + /** + * The description of the plugin. + */ + val description: String, + + /** + * The configuration options supported by the plugin. + */ + val options: List +) + +/** + * The supported types of plugin options. + */ +enum class PluginOptionType { + /** A [Boolean] option. */ + BOOLEAN, + + /** An [Int] option. */ + INTEGER, + + /** A [Long] option. */ + LONG, + + /** A [Secret] option. */ + SECRET, + + /** A [String] option. */ + STRING, + + /** A [List]<[String]> option. */ + STRING_LIST +} + +/** + * A configuration option for a plugin. + */ +data class PluginOption( + /** + * The name of the option. Must be unique among all options of the same plugin. + */ + val name: String, + + /** + * The description of the option. + */ + val description: String, + + /** + * The [type][PluginOptionType] of the option. + */ + val type: PluginOptionType, + + /** + * The default value of the option, or `null` if the option is required. + */ + val defaultValue: String?, + + /** + * Whether the option is required. + */ + val isRequired: Boolean +) + +/** + * A secret value that should not be printed in logs. + */ +@JvmInline +value class Secret(val value: String) { + override fun toString() = "***" +} diff --git a/plugins/api/src/main/kotlin/PluginFactory.kt b/plugins/api/src/main/kotlin/PluginFactory.kt new file mode 100644 index 0000000000000..e38356491cb9a --- /dev/null +++ b/plugins/api/src/main/kotlin/PluginFactory.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.api + +import org.ossreviewtoolkit.utils.common.getLoaderFor + +/** + * A factory interface for creating plugins of type [PLUGIN]. The different plugin endpoints ORT provides must inherit + * from this interface. + */ +interface PluginFactory { + companion object { + /** + * Return all plugin factories of type [FACTORY]. + */ + inline fun , PLUGIN> getAll() = + getLoaderFor() + .iterator() + .asSequence() + .associateByTo(sortedMapOf(String.CASE_INSENSITIVE_ORDER)) { + it.descriptor.className + } + } + + /** + * The descriptor of the plugin + */ + val descriptor: PluginDescriptor + + /** + * Create a new instance of [PLUGIN] from [config]. + */ + fun create(config: PluginConfig): PLUGIN +} + +/** + * A plugin that ORT can use. Each plugin extension point of ORT must inherit from this interface. + */ +interface Plugin { + val descriptor: PluginDescriptor +} diff --git a/plugins/commands/build.gradle.kts b/plugins/commands/build.gradle.kts index bbfb2a35fcb17..367568bf2ac8f 100644 --- a/plugins/commands/build.gradle.kts +++ b/plugins/commands/build.gradle.kts @@ -19,5 +19,5 @@ plugins { // Apply precompiled plugins. - id("ort-plugins-conventions") + id("ort-plugin-parent-conventions") } diff --git a/plugins/compiler/build.gradle.kts b/plugins/compiler/build.gradle.kts new file mode 100644 index 0000000000000..87ddc1294bc5d --- /dev/null +++ b/plugins/compiler/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +plugins { + // Apply precompiled plugins. + id("ort-library-conventions") +} + +dependencies { + implementation(projects.plugins.api) + + implementation(libs.kotlinpoet) + implementation(libs.kotlinpoet.ksp) + implementation(libs.kotlinx.serialization.json) + implementation(libs.ksp) +} diff --git a/plugins/compiler/src/main/kotlin/JsonSpecGenerator.kt b/plugins/compiler/src/main/kotlin/JsonSpecGenerator.kt new file mode 100644 index 0000000000000..ce4c49939099a --- /dev/null +++ b/plugins/compiler/src/main/kotlin/JsonSpecGenerator.kt @@ -0,0 +1,72 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.compiler + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.addJsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToStream +import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonArray +import kotlinx.serialization.json.putJsonObject + +class JsonSpecGenerator(private val codeGenerator: CodeGenerator) { + private val json = Json { + prettyPrint = true + } + + @OptIn(ExperimentalSerializationApi::class) + fun generate(pluginSpec: PluginSpec) { + val jsonObject = buildJsonObject { + putJsonObject("descriptor") { + put("name", pluginSpec.descriptor.name) + put("className", pluginSpec.descriptor.className) + put("description", pluginSpec.descriptor.description) + + putJsonArray("options") { + pluginSpec.descriptor.options.forEach { + addJsonObject { + put("name", it.name) + put("type", it.type.name) + put("description", it.description) + put("default", it.defaultValue) + put("isRequired", it.isRequired) + } + } + } + } + + put("configClass", pluginSpec.configClass?.typeName.toString()) + put("factoryClass", pluginSpec.factory.qualifiedName) + } + + codeGenerator.createNewFileByPath( + dependencies = Dependencies(aggregating = true, *listOfNotNull(pluginSpec.containingFile).toTypedArray()), + path = "META-INF/plugin/${pluginSpec.packageName}.${pluginSpec.descriptor.className}", + extensionName = "json" + ).use { output -> + json.encodeToStream(jsonObject, output) + } + } +} diff --git a/plugins/compiler/src/main/kotlin/PluginFactoryGenerator.kt b/plugins/compiler/src/main/kotlin/PluginFactoryGenerator.kt new file mode 100644 index 0000000000000..e8c9785f38338 --- /dev/null +++ b/plugins/compiler/src/main/kotlin/PluginFactoryGenerator.kt @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.compiler + +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies + +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.FileSpec +import com.squareup.kotlinpoet.FunSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.ksp.writeTo + +import org.ossreviewtoolkit.plugins.api.PluginConfig +import org.ossreviewtoolkit.plugins.api.PluginDescriptor +import org.ossreviewtoolkit.plugins.api.PluginOption +import org.ossreviewtoolkit.plugins.api.PluginOptionType +import org.ossreviewtoolkit.plugins.api.Secret + +class PluginFactoryGenerator(private val codeGenerator: CodeGenerator) { + fun generate(pluginSpec: PluginSpec) { + val generatedFactory = generateFactoryClass(pluginSpec) + generateServiceLoaderFile(pluginSpec, generatedFactory) + } + + /** + * Generate a factory class for the [pluginSpec]. + */ + private fun generateFactoryClass(pluginSpec: PluginSpec): TypeSpec { + // Create the initializer for the plugin config object. + val configInitializer = pluginSpec.configClass?.let { + getConfigInitializer(it.typeName, pluginSpec.descriptor.options) + } + + // Create the plugin descriptor property. + val descriptorInitializer = getDescriptorInitializer(pluginSpec.descriptor) + val descriptorProperty = PropertySpec.builder("descriptor", PluginDescriptor::class, KModifier.OVERRIDE) + .initializer(descriptorInitializer) + .build() + + // Create the create function that initializes the plugin with the descriptor and the config object. + val createFunction = FunSpec.builder("create").apply { + addModifiers(KModifier.OVERRIDE) + addParameter("config", PluginConfig::class) + + if (configInitializer != null) { + addCode(configInitializer) + addCode("return %T(%N, configObject)", pluginSpec.typeName, descriptorProperty) + } else { + addCode("return %T(%N)", pluginSpec.typeName, descriptorProperty) + } + + returns(pluginSpec.typeName) + }.build() + + // Create the factory class. + val className = "${pluginSpec.descriptor.className}Factory" + val classSpec = TypeSpec.classBuilder(className) + .addSuperinterface(pluginSpec.factory.typeName) + .addProperty(descriptorProperty) + .addFunction(createFunction) + .build() + + // Write the factory class to a file. + FileSpec.builder(ClassName(pluginSpec.packageName, "${pluginSpec.descriptor.className}Factory")) + .addType(classSpec) + .build() + .writeTo(codeGenerator, aggregating = true, originatingKSFiles = listOfNotNull(pluginSpec.containingFile)) + + return classSpec + } + + /** + * Generate the code block to initialize the config object from the [PluginConfig]. + */ + private fun getConfigInitializer(configType: TypeName, pluginOptions: List) = + CodeBlock.builder().apply { + add("val configObject = %T(\n", configType) + + pluginOptions.forEach { option -> + add(" ${option.name} = ") + + // Add code to read the option from the options or secrets maps based on its type. + when (option.type) { + PluginOptionType.BOOLEAN -> add("config.options[%S]?.toBooleanStrict()", option.name) + PluginOptionType.INTEGER -> add("config.options[%S]?.toInt()", option.name) + PluginOptionType.LONG -> add("config.options[%S]?.toLong()", option.name) + PluginOptionType.SECRET -> add("config.secrets[%S]?.let { %T(it) }", option.name, Secret::class) + PluginOptionType.STRING -> add("config.options[%S]", option.name) + PluginOptionType.STRING_LIST -> add( + "config.options[%S]?.split(\",\")?.map { it.trim() }", + option.name + ) + } + + // Add the default value if present. + option.defaultValue?.let { defaultValue -> + when (option.type) { + PluginOptionType.BOOLEAN -> add(" ?: %L", defaultValue.toBoolean()) + PluginOptionType.INTEGER -> add(" ?: %L", defaultValue.toInt()) + PluginOptionType.LONG -> add(" ?: %LL", defaultValue.toLong()) + PluginOptionType.SECRET -> add(" ?: %T(%S)", Secret::class, defaultValue) + PluginOptionType.STRING -> add(" ?: %S", defaultValue) + PluginOptionType.STRING_LIST -> add(" ?: %S", defaultValue) + } + } + + // Throw exception if the option is required but not set. + if (option.isRequired) { + add(" ?: error(%S)", "Option ${option.name} is required but not set.") + } + + add(",\n") + } + + // TODO: Decide if an exception should be thrown if the options or secrets maps contain values that do not + // match any plugin option. This would be a safety net to catch typos in option names. + + add(")\n\n") + }.build() + + /** + * Generate the code block to initialize the [PluginDescriptor] for the plugin. + */ + private fun getDescriptorInitializer(descriptor: PluginDescriptor) = + CodeBlock.builder().apply { + add( + """ + PluginDescriptor( + name = %S, + className = %S, + description = %S, + options = listOf( + + """.trimIndent(), + descriptor.name, + descriptor.className, + descriptor.description + ) + + descriptor.options.forEach { + add( + """ + | %T( + | name = %S, + | description = %S, + | type = %T.%L, + | defaultValue = %S, + | isRequired = %L + | ), + | + """.trimMargin(), + PluginOption::class, + it.name, + it.description, + PluginOptionType::class, + it.type.name, + it.defaultValue, + it.isRequired + ) + } + + add( + """ + ) + ) + """.trimIndent() + ) + }.build() + + /** + * Generate a service loader file for the [generatedFactory]. + */ + private fun generateServiceLoaderFile(pluginSpec: PluginSpec, generatedFactory: TypeSpec) { + codeGenerator.createNewFileByPath( + dependencies = Dependencies(aggregating = true, *listOfNotNull(pluginSpec.containingFile).toTypedArray()), + path = "META-INF/services/${pluginSpec.factory.qualifiedName}", + extensionName = "" + ).use { output -> + output.writer().use { writer -> + writer.write("${pluginSpec.packageName}.${generatedFactory.name}\n") + } + } + } +} diff --git a/plugins/compiler/src/main/kotlin/PluginProcessor.kt b/plugins/compiler/src/main/kotlin/PluginProcessor.kt new file mode 100644 index 0000000000000..45bf63ac5043a --- /dev/null +++ b/plugins/compiler/src/main/kotlin/PluginProcessor.kt @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.compiler + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.getClassDeclarationByName +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration + +import org.ossreviewtoolkit.plugins.api.OrtPlugin + +class PluginProcessor(codeGenerator: CodeGenerator) : SymbolProcessor { + /** + * True, if the processor has been invoked in a previous run. + */ + private var invoked = false + + private val specFactory = PluginSpecFactory() + private val factoryGenerator = PluginFactoryGenerator(codeGenerator) + private val jsonGenerator = JsonSpecGenerator(codeGenerator) + + /** + * Process all classes annotated with [OrtPlugin] to generate plugin factories for them. + */ + @OptIn(KspExperimental::class) + override fun process(resolver: Resolver): List { + if (invoked) return emptyList() + + val ortPluginClassName = OrtPlugin::class.qualifiedName + + requireNotNull(ortPluginClassName) { + "Could not get qualified name of OrtPlugin annotation." + } + + resolver.getSymbolsWithAnnotation(ortPluginClassName).forEach { pluginClass -> + require(pluginClass is KSClassDeclaration) { + "Annotated element $pluginClass is not a class." + } + + val pluginAnnotation = pluginClass.getAnnotationsByType(OrtPlugin::class).single() + + val pluginFactoryClass = resolver.getPluginFactoryClass(pluginAnnotation) + checkExtendsPluginFactory(pluginFactoryClass) + + val pluginParentClass = getPluginParentClass(pluginFactoryClass) + checkExtendsPluginClass(pluginClass, pluginParentClass) + + val pluginSpec = specFactory.create(pluginAnnotation, pluginClass, pluginFactoryClass) + factoryGenerator.generate(pluginSpec) + jsonGenerator.generate(pluginSpec) + } + + invoked = true + + return emptyList() + } + + /** + * Get the declaration of the [plugin factory][OrtPlugin.factory]. + */ + private fun Resolver.getPluginFactoryClass(annotation: OrtPlugin): KSClassDeclaration { + val pluginFactoryName = annotation.factory.qualifiedName + + requireNotNull(pluginFactoryName) { + "Could not get qualified name of plugin factory." + } + + val pluginFactoryClass = getClassDeclarationByName(pluginFactoryName) + + requireNotNull(pluginFactoryClass) { + "Could not find class for plugin factory $pluginFactoryName." + } + + return pluginFactoryClass + } + + /** + * Get the declaration of the plugin class created by [factoryClass]. + */ + private fun getPluginParentClass(factoryClass: KSClassDeclaration): KSClassDeclaration { + val parentClass = factoryClass + .getAllFunctions() + .single { it.simpleName.asString() == "create" } + .returnType + ?.resolve() + ?.declaration + + checkNotNull(parentClass) + + require(parentClass is KSClassDeclaration) { + "Plugin class $parentClass is not a class." + } + + return parentClass + } + + /** + * Ensure that [factoryClass] extends [PluginFactory]. + */ + private fun checkExtendsPluginFactory(factoryClass: KSClassDeclaration) = + require( + factoryClass.getAllSuperTypes().any { + it.declaration.qualifiedName?.asString() == "org.ossreviewtoolkit.plugins.api.PluginFactory" + } + ) { + "Plugin factory $factoryClass does not extend the required super type PluginFactory." + } + + /** + * Ensure that [pluginClass] extends [pluginBaseClass]. + */ + private fun checkExtendsPluginClass(pluginClass: KSClassDeclaration, pluginBaseClass: KSClassDeclaration) { + require(pluginClass.getAllSuperTypes().any { it.declaration == pluginBaseClass }) { + "Plugin class $pluginClass does not extend the required super type $pluginBaseClass." + } + } +} diff --git a/plugins/compiler/src/main/kotlin/PluginProcessorProvider.kt b/plugins/compiler/src/main/kotlin/PluginProcessorProvider.kt new file mode 100644 index 0000000000000..a3467b32ded07 --- /dev/null +++ b/plugins/compiler/src/main/kotlin/PluginProcessorProvider.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.compiler + +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +/** + * A [SymbolProcessorProvider] that provides a [PluginProcessor]. + */ +class PluginProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment) = PluginProcessor(environment.codeGenerator) +} diff --git a/plugins/compiler/src/main/kotlin/PluginSpec.kt b/plugins/compiler/src/main/kotlin/PluginSpec.kt new file mode 100644 index 0000000000000..cf8b29c3a822e --- /dev/null +++ b/plugins/compiler/src/main/kotlin/PluginSpec.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.compiler + +import com.google.devtools.ksp.symbol.KSFile + +import com.squareup.kotlinpoet.TypeName + +import org.ossreviewtoolkit.plugins.api.PluginDescriptor + +/** + * A specification for a plugin. + */ +data class PluginSpec( + val containingFile: KSFile?, + val descriptor: PluginDescriptor, + val packageName: String, + val typeName: TypeName, + val configClass: PluginConfigClassSpec?, + val factory: PluginFactorySpec +) + +/** + * A specification for a plugin configuration class. + */ +data class PluginConfigClassSpec( + val typeName: TypeName +) + +/** + * A specification for a plugin factory. This describes the base factory class that the generated factory should + * implement. + */ +data class PluginFactorySpec( + val typeName: TypeName, + val qualifiedName: String +) diff --git a/plugins/compiler/src/main/kotlin/PluginSpecFactory.kt b/plugins/compiler/src/main/kotlin/PluginSpecFactory.kt new file mode 100644 index 0000000000000..a6b62525cf22a --- /dev/null +++ b/plugins/compiler/src/main/kotlin/PluginSpecFactory.kt @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2024 The ORT Project Authors (see ) + * + * Licensed 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 + * + * https://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. + * + * SPDX-License-Identifier: Apache-2.0 + * License-Filename: LICENSE + */ + +package org.ossreviewtoolkit.plugins.compiler + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getAnnotationsByType +import com.google.devtools.ksp.getConstructors +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.Modifier + +import com.squareup.kotlinpoet.ksp.toTypeName + +import org.ossreviewtoolkit.plugins.api.OrtPlugin +import org.ossreviewtoolkit.plugins.api.OrtPluginOption +import org.ossreviewtoolkit.plugins.api.PluginDescriptor +import org.ossreviewtoolkit.plugins.api.PluginOption +import org.ossreviewtoolkit.plugins.api.PluginOptionType + +/** + * A generator for [PluginSpec] instances. + */ +class PluginSpecFactory { + /** + * Create a [PluginSpec] for the given [ortPlugin] using the [pluginClass] and [pluginFactoryClass]. + */ + fun create( + ortPlugin: OrtPlugin, + pluginClass: KSClassDeclaration, + pluginFactoryClass: KSClassDeclaration + ): PluginSpec { + val pluginType = pluginClass.asType(emptyList()).toTypeName() + val pluginFactoryType = pluginFactoryClass.asType(emptyList()).toTypeName() + + val constructor = getPluginConstructor(pluginClass) + val (configClass, configType) = if (constructor.parameters.size == 2) { + val type = constructor.parameters[1].type + type.resolve().declaration as KSClassDeclaration to type.toTypeName() + } else { + null to null + } + + val pluginOptions = configClass?.getPluginOptions().orEmpty() + + return PluginSpec( + containingFile = pluginClass.containingFile, + descriptor = PluginDescriptor( + name = ortPlugin.name, + className = pluginClass.simpleName.asString(), + description = ortPlugin.description, + options = pluginOptions + ), + packageName = pluginClass.packageName.asString(), + typeName = pluginType, + configClass = configType?.let { PluginConfigClassSpec(it) }, + factory = PluginFactorySpec(pluginFactoryType, pluginFactoryClass.qualifiedName?.asString().orEmpty()) + ) + } + + /** + * Get the constructor of the plugin class that has a [PluginDescriptor] and a config argument. Throw an + * [IllegalArgumentException] if more than one or no such constructor exists. + */ + private fun getPluginConstructor(pluginClass: KSClassDeclaration): KSFunctionDeclaration { + // TODO: Consider adding an @OrtPluginConstructor annotation to mark the constructor to use. This could be + // useful if a plugin needs multiple constructors for different purposes like testing. + val constructors = pluginClass.getConstructors().filterTo(mutableListOf()) { + if (it.parameters.size < 1 || it.parameters.size > 2) { + return@filterTo false + } + + val firstArgumentIsDescriptor = it.parameters[0].name?.asString() == "descriptor" && + it.parameters[0].type.resolve().declaration.qualifiedName?.asString() == + "org.ossreviewtoolkit.plugins.api.PluginDescriptor" + + val optionalSecondArgumentIsCalledConfig = + it.parameters.size == 1 || it.parameters[1].name?.asString() == "config" + + firstArgumentIsDescriptor && optionalSecondArgumentIsCalledConfig + } + + return requireNotNull(constructors.singleOrNull()) { + "Plugin class $pluginClass must have exactly one constructor with a PluginDescriptor and an optional " + + "config argument." + } + } + + /** + * Get the plugin options from the config class by mapping its properties to [PluginOption] instances. + */ + @OptIn(KspExperimental::class) + private fun KSClassDeclaration.getPluginOptions(): List { + require(Modifier.DATA in modifiers) { + "Config class $this must be a data class." + } + + val constructor = requireNotNull(getConstructors().singleOrNull()) { + "Config class $this must have exactly one constructor." + } + + return constructor.parameters.map { param -> + val paramType = param.type.resolve() + val paramTypeString = getQualifiedNameWithTypeArguments(paramType) + val paramName = param.name?.asString() + + requireNotNull(paramName) { + "Config class constructor parameter has no name." + } + + require(param.isVal) { + "Config class constructor parameter $paramName must be a val." + } + + require(!param.hasDefault) { + "Config class constructor parameter $paramName must not have a default value. Default values must be " + + "set via the @OrtPluginOption annotation." + } + + val prop = getAllProperties().find { it.simpleName.asString() == paramName } + + requireNotNull(prop) { + "Config class must have a property with the name $paramName." + } + + val annotations = prop.getAnnotationsByType(OrtPluginOption::class).toList() + + require(annotations.size <= 1) { + "Config class constructor parameter $paramName must have at most one @OrtPluginOption annotation." + } + + val annotation = annotations.firstOrNull() + + val type = when (paramTypeString) { + "kotlin.Boolean" -> PluginOptionType.BOOLEAN + "kotlin.Int" -> PluginOptionType.INTEGER + "kotlin.Long" -> PluginOptionType.LONG + "org.ossreviewtoolkit.plugins.api.Secret" -> PluginOptionType.SECRET + "kotlin.String" -> PluginOptionType.STRING + "kotlin.collections.List" -> PluginOptionType.STRING_LIST + + else -> throw IllegalArgumentException( + "Config class constructor parameter ${param.name?.asString()} has unsupported type " + + "$paramTypeString." + ) + } + + val defaultValue = annotation?.defaultValue + + PluginOption( + name = param.name?.asString().orEmpty(), + description = prop.docString?.trim().orEmpty(), + type = type, + defaultValue = defaultValue, + isRequired = !paramType.isMarkedNullable && defaultValue == null + ) + } + } + + /** + * Get the qualified name of a [type] with its type arguments, for example, + * `kotlin.collections.List`. + */ + private fun getQualifiedNameWithTypeArguments(type: KSType): String = + buildString { + append(type.declaration.qualifiedName?.asString()) + if (type.arguments.isNotEmpty()) { + append("<") + append( + type.arguments.joinToString(", ") { argument -> + argument.type?.resolve()?.let { getQualifiedNameWithTypeArguments(it) } ?: "Unknown" + } + ) + append(">") + } + } +} diff --git a/plugins/compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/plugins/compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 0000000000000..3a53447f768e0 --- /dev/null +++ b/plugins/compiler/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +org.ossreviewtoolkit.plugins.compiler.PluginProcessorProvider diff --git a/plugins/package-configuration-providers/build.gradle.kts b/plugins/package-configuration-providers/build.gradle.kts index bbfb2a35fcb17..367568bf2ac8f 100644 --- a/plugins/package-configuration-providers/build.gradle.kts +++ b/plugins/package-configuration-providers/build.gradle.kts @@ -19,5 +19,5 @@ plugins { // Apply precompiled plugins. - id("ort-plugins-conventions") + id("ort-plugin-parent-conventions") } diff --git a/plugins/package-curation-providers/build.gradle.kts b/plugins/package-curation-providers/build.gradle.kts index bbfb2a35fcb17..367568bf2ac8f 100644 --- a/plugins/package-curation-providers/build.gradle.kts +++ b/plugins/package-curation-providers/build.gradle.kts @@ -19,5 +19,5 @@ plugins { // Apply precompiled plugins. - id("ort-plugins-conventions") + id("ort-plugin-parent-conventions") } diff --git a/plugins/package-managers/build.gradle.kts b/plugins/package-managers/build.gradle.kts index bbfb2a35fcb17..367568bf2ac8f 100644 --- a/plugins/package-managers/build.gradle.kts +++ b/plugins/package-managers/build.gradle.kts @@ -19,5 +19,5 @@ plugins { // Apply precompiled plugins. - id("ort-plugins-conventions") + id("ort-plugin-parent-conventions") } diff --git a/plugins/reporters/build.gradle.kts b/plugins/reporters/build.gradle.kts index bbfb2a35fcb17..367568bf2ac8f 100644 --- a/plugins/reporters/build.gradle.kts +++ b/plugins/reporters/build.gradle.kts @@ -19,5 +19,5 @@ plugins { // Apply precompiled plugins. - id("ort-plugins-conventions") + id("ort-plugin-parent-conventions") } diff --git a/plugins/scanners/build.gradle.kts b/plugins/scanners/build.gradle.kts index bbfb2a35fcb17..367568bf2ac8f 100644 --- a/plugins/scanners/build.gradle.kts +++ b/plugins/scanners/build.gradle.kts @@ -19,5 +19,5 @@ plugins { // Apply precompiled plugins. - id("ort-plugins-conventions") + id("ort-plugin-parent-conventions") } diff --git a/plugins/version-control-systems/build.gradle.kts b/plugins/version-control-systems/build.gradle.kts index bbfb2a35fcb17..367568bf2ac8f 100644 --- a/plugins/version-control-systems/build.gradle.kts +++ b/plugins/version-control-systems/build.gradle.kts @@ -19,5 +19,5 @@ plugins { // Apply precompiled plugins. - id("ort-plugins-conventions") + id("ort-plugin-parent-conventions") }