First-class Bazel support for Quarkus #54762
Replies: 8 comments 12 replies
|
Very interesting, thanks. @aloubyansky, @gsmet, @geoand what do you think? Let's be frank: my only experience with Bazel is building true distroless containers... (so, very limited) |
|
This is an amazing text! I have no answers for you, as I don't really work on the build system, but I know one thing that is probably interesting for you: Quarkus builds are currently not reproducible. We're working on fixing that (CC @reaver585), but it's gonna take a while. |
|
hi @clementguillot - i think we met at Devoxx France? good write and up looks awesome. I'm +1 on the idea behind this and on first read it looks to overlap with similar needs jbang (another non-first-class-but-working "builder") has. unfortunately i'm on PTO the next week so I'll be delayed in following up in more detail. btw. I see #11305 has some work too - not sure if you have already compared @kinhluan's approach? |
|
Thanks for sharing and all the efforts on the Bazel front. That's definitely great to know.
Sure. I think this kind of refactoring will be for everyone's benefit.
I'd hope we'd collaborate but you guys would keep driving it on the Bazel side. A few other remarks. Conceptually, the Gradle integration is closer to what you describe. For example, most of the dependency resolutions happen in project configuration phase and not during a task execution. See https://quarkus.io/guides/gradle-tooling#gradle-configuration-cache for the configuration cache compatible tasks, it covers all launch modes: prod, test and dev. The idea of delegating application rebuilding to Bazel in dev mode, for example, is a good one. We were hoping for the same in case of Gradle, with its support for incremental builds and even an API to trigger those but, unfortunately, in practice, it appeared to be significantly slower than the current Quarkus impl, so it hasn't happened yet. The general idea makes sense though for both Gradle and Bazel. I haven't looked at your implementation, but dependency resolution is tricky subject and it's something that needs to be done right. I have some concerns based on the description. In any case, let's continue the discussion. If that makes sense, perhaps it could also help, if you could present the topic on one the community meetings? WDYT @cescoffier ? |
|
Thank you, everyone, for your valuable feedback! @cescoffier Funny coincidence: @Ladicek Interesting! Is there an issue or another place where I can follow your work? @maxandersen Yes, we met at Devoxx France 😃 So sad, I missed your talk. It took me some time to get from our conversation to this discussion, but at the time, some core features were still missing. I think @kinhluan and I use a similar approach: we both introduce a new JVM-based CLI application, managed by Bazel, that calls My implementation may differ in terms of the Starlark code and the Bazel API exposed to end users (DX closer to Maven/Gradle). @aloubyansky Thank you very much for your detailed answer! There are many points I’ll dig into and incorporate into upcoming developments. Maven was my “north star” because Quarkus itself is built with Maven. That bias may have been a mistake, so I’ll take a closer look at the Gradle plugin sources, which I had deliberately omitted until now. Regarding dependency resolution, TBH, I also have concerns about my current implementation. I think I lack experience and knowledge in this area, and there is room for improvement and stabilization. That is also one of the motivations behind this discussion. I’d be very happy to present the topic at a community meeting and continue the discussion if everyone agrees that it would be useful. |
|
A quick update on the project:
Next step is to add way more e2e/smoke tests to prevent from regressions. |
|
@clementguillot, would you be interested in discussing your project during one of the community calls (September-ish)? If so, ping me on zulip or email. |
|
I'm currently working on the Quarkus Gradle plugin and during that work that touched quarkus-dev/remove-dev/continuous-testing it became obvious that build-tool agnostic helper functionality was needed. I already have some changes to quarkus-core-deployment for this scenario (although the quarkus-continuous testing work isn't finished yet - but quarkus-dev + quarkus-remote-dev already work). |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Discussion: First-class Bazel support for Quarkus
TL;DR
rules_quarkusis an out-of-tree, third-party project that brings Quarkus to Bazel. It currently ships:quarkus_apprule that produces a Fast-Jar (production), with optionalnative = True(host GraalVM or Mandrel container).quarkus_devtarget (auto-generated by thequarkus_appmacro) that boots Dev UI, Dev Services, hot-reload, and Continuous Testing on top of Bazel-driven incremental compilation.quarkus_testrule that runs@QuarkusTestJUnit 5 tests insidebazel test.Under the hood, every entry point goes through a single Java tool (the Quarkifier (
com.clementguillot.quarkifier)) that invokes Quarkus' internal build API (io.quarkus.deployment,io.quarkus.bootstrap.*) directly, bypassing Maven and Gradle resolvers entirely. The Quarkifier builds theApplicationModelfrom a flat classpath, drivesQuarkusBootstrap→CuratedApplication→AugmentAction, and emits the same Fast-Jar layout Quarkus produces today.We follow Maven's
DevMojo/BootstrapMavenContextpatterns as closely as possible. Where Quarkus internals assume a Maven (or Gradle) project model, we either reconstruct the missing pieces by hand or work around them with reflection. The result works, but the gap between "what's possible" and "what's stable across patch versions" is wider than it should be, and the goal of this discussion is to start a conversation about narrowing it.The two questions we'd like to put to the Quarkus team:
BootstrapMavenContextandDevMojo?rules_quarkus(or at least co-maintaining a Bazel integration) once such an SPI exists?The rest of this post explains the context, design choices, and concrete pain points that motivated these questions.
NB: This was AI-generated and reviewed by me before posting.
1. Why a Bazel integration matters
Bazel is the dominant build system at large polyglot shops (Google, Stripe, Uber, Pinterest, Snowflake, Twitter/X, LinkedIn, Toyota Connected, …). For these teams:
rules_jvm_external-managed jars are the norm.pom.xml,build.gradle,mvn/gradleinvocation) is foreign to Bazel.Until now, Quarkus has had no first-class story for these users. The closest alternatives are:
mvn quarkus:devandmvn packageinside agenrule— gives up Bazel's caching, sandboxing, and incremental compilation, defeats the purpose of using Bazel in the first place.rules_quarkusdoes today.We picked the second path. This document explains the result and the trade-offs.
2. What does "Bazel-native" mean here?
For context: in Bazel, a "rule" is a pure Starlark function that takes inputs (sources, dependencies, attributes) and declares outputs and actions. Each action is a sandboxed subprocess (
ctx.actions.run) with a fully declared input set, run by Bazel inside a hermetic sandbox, fingerprinted, and cached locally and remotely. There is no "build lifecycle" the way Maven has one — there is a build graph.A Bazel-native Quarkus integration therefore must:
ctx.actions.runaction with a fully declared classpath input (every jar listed, every source file listed, no implicit lookups).~/.m2, no~/.gradle/caches, no walking parent directories looking for apom.xml. The action runs inside/tmp/bazel-sandbox/....rules_java,rules_jvm_external,rules_kotlin, etc., without imposing its own Java compilation rule on the user. Users keep theirjava_librarytargets and theirmaven_installrepos;quarkus_appjust consumes them.The reference implementation is in
quarkifier/(Java tool) andquarkus/(Starlark rules). Seedocs/architecture.mdanddocs/quarkifier.mdfor the full pipeline.3. The three big differences from Maven & Gradle
3.1 Dependency resolution happens outside the build action
In Maven, when
quarkus:devorquarkus:buildruns, the plugin has full access to a liveMavenProjectand can call into the Maven resolver any time it wants (and it does —BootstrapMavenContextre-resolves the project model, walks the reactor, talks to the local repository). Gradle is similar: the plugin has aProjecthandle and can resolve configurations on demand.In Bazel, this is forbidden by design. The augmentation action runs in a sandbox with only the inputs Bazel declared. There is no live project model, no reactor, no resolver to call. Dependencies have already been resolved at workspace-load time (when
rules_jvm_external'smaven_installextension ran), captured in amaven_install.jsonlock file, and exposed as a flat set of jar files.The Quarkifier therefore:
META-INF/quarkus-extension.propertiesto discover extensions.ApplicationModelmanually by walking the classpath, parsing GAV from filenames (MavenCoordinateParser), and feedingApplicationModelBuilder.handleExtensionProperties()for each extension.-deploymentjars from a separate, pre-fetched classpath that the module extension downloads at workspace-load time via Coursier (seedocs/architecture.md→ "Module Extension System").There is no live network call during build. There is no
mvn dependency:tree. TheApplicationModelis built from path strings andJarFilereads, nothing else.3.2 The "project model" doesn't exist in the Maven sense
Quarkus internals carry a strong assumption that somewhere there is a
WorkspaceModule, aMavenContext, a parent POM, a reactor. Many code paths reach for it. A few examples we've hit:BootstrapAppModelFactory.resolveAppModel()in test mode falls through toloadWorkspace()→createBootstrapMavenContext()when thequarkus-internal-test.serialized-app-model.pathsystem property is unset. In Bazel, those resolver classes are not (and must not be) on the classpath.ApplicationModel.asMap()(the JSON serializer used in 3.31+) callsgetPlatforms().asMap()without a null check. Bypassing Maven/Gradle meansplatformImportsis never populated, so we set an emptyPlatformImportsImpl()purely to dodge an NPE.handleExtensionProperties()buildsGACTkeys with emptytype, but our model entries havetype = "jar". The result is that theCLASSLOADER_RUNNER_PARENT_FIRSTflag is never set, so we have to monkey-patch each artifact byartifactIdafter the fact.RemoteRepository(org.eclipse.aether.repository.RemoteRepository) instance that the Maven resolver supplies. OurApplicationModeldoesn't have one, and the panel currently throwsClassCastExceptionacross classloaders. We don't have a clean fix.Every one of these is a small leak of "this code assumes Maven (or Gradle) is what called it" into what should be a build-tool-agnostic build API.
3.3 Augmentation runs in a Bazel action, not as a build lifecycle
In Maven, augmentation is a phase. The plugin has time to set things up, resolve, augment, package, repeat. In Bazel, augmentation is a single subprocess invocation that produces a Fast-Jar directory in its declared output path and exits. The orchestration (caching, sandboxing, retries, remote execution, etc.) is Bazel's job, not Quarkus'.
This is a feature, not a bug — it's the whole reason to do this work. But it does mean:
QuarkifierConfig.parse()with property-based round-trip tests).Map.keySet()is unordered in Java.FastJarAssembler.assemble()) is non-trivial because Quarkus places jars underlib/main/using the original classpath filenames (including Bazel'sprocessed_prefixes), and we have to rename, classify boot vs main, dedupe, and regeneratequarkus-application.dat.4. Following the Maven implementation
When in doubt, we copy
quarkus-maven-plugin. Both because Quarkus was originally Maven-first and because the Maven sources are the only thing we can stare at to understand the intended classloader / lifecycle / serialization contracts.Concrete examples:
DevMojo.createDevJar()Class-Pathoffile:///URIs that points to the core deployment infrastructure and parent-first artifacts; everything else flows through the augment classloader from theApplicationModel.DevModeLauncher.createDevJar()DevMojosubprocess launchjava -jar dev.jar) so the parent classloader stays minimal.IsolatedDevModeMainruns inside a clean augment classloader.DevModeLauncher.launch()BootstrapMavenContext.loadWorkspace()ApplicationModelfrom the classpath and serialize it to the path Quarkus reads fromquarkus-internal.serialized-app-model.path(andquarkus-internal-test.serialized-app-model.pathfor continuous testing).QuarkusAppModelBuilder,DevModeLauncherBootstrapAppModelFactorytest-mode short-circuit (-Dquarkus-internal-test.serialized-app-model.path)ApplicationModeland pass that property to the dev mode child JVM so that clicking "Start Continuous Testing" doesn't fall through tocreateBootstrapMavenContext().DevModeLauncher(see §6.4)conditional-dev-dependenciesquarkus-devui,quarkus-devui-deployment,quarkus-devui-spi,{extension}-dev, etc.) because nothing on the Bazel side will discover them transitively.quarkus/extensions.bzl, seedocs/dev-mode.md→ "Conditional Dev Dependencies"application rootsemanticsPathList-with-multiple-entries toQuarkusBootstrap.setApplicationRoot()), because Bazel's depset gives no guaranteed first jar. Without this, OpenAPI /@Pathdiscovery breaks for multi-module projects.AugmentationExecutor(see §6.5)QuarkusAppModelBuilder— same parent-first list (bootstrap, core, logging, Jakarta APIs), same SmallRye Config exception (must not be parent-first because CDI beans inside it wouldVerifyError), same "mark-devand-spiruntime jars" rule.dev-mode.md→ "Parent-First Artifacts"The takeaway: if Maven does it, we do the same thing. Where we differ, it's because we have to (no Maven context available), not because we wanted to.
5. The core difficulty: Quarkifier ↔ Quarkus version coupling
This is the single biggest design problem and the reason this discussion exists.
5.1 The problem
The Quarkifier links against Quarkus' deployment APIs:
io.quarkus.deployment.*(AugmentAction,IsolatedDevModeMain,DevModeMain, ...)io.quarkus.bootstrap.*(QuarkusBootstrap,CuratedApplication,ApplicationModelBuilder,BootstrapUtils, ...)io.quarkus.bootstrap.app.ApplicationModel,ApplicationModelSerializerio.quarkus.bootstrap.devmode.DevModeContextNone of these are documented public APIs. They are deployment-internal. Their signatures, method bodies, and bytecode-level expectations change between Quarkus releases — sometimes between minor versions, sometimes between patches.
Three concrete examples we've already hit:
ApplicationModelSerializerformat flip (3.31) — In 3.27,BootstrapUtils.serializeAppModel()used Java Object Serialization. From 3.31+,ApplicationModelSerializer.serialize()emits JSON. The wire format is incompatible; the Quarkifier needs version-specific code to pick the right serializer. We dispatch via a smallAppModelSerializerStrategySPI with one implementation per minor (src/main/java_3_27/AppModelSerializerImpl.java,src/main/java_3_33/AppModelSerializerImpl.java).PlatformImports.asMap()NPE (3.31+) — JSON serialization started callinggetPlatforms().asMap(). Because we bypass Maven,platformImportsis null. We patched around it with an emptyPlatformImportsImpl(). This is a workaround that wouldn't be needed if the build API tolerated a missing platform model.GACT key mismatch in
handleExtensionProperties()— This was true in 3.20 and was still true in 3.33. It silently breaks theCLASSLOADER_RUNNER_PARENT_FIRSTflag and we have to monkey-patch.The implication: a Quarkifier compiled against
quarkus-core-deployment:3.33.2is not guaranteed to produce bytecode that runs against3.33.1runtime jars at the user's site. The augmentation step may call methods that exist in 3.33.2 but not 3.33.1, or rely on a field initializer that changed. Quarkus generates bytecode during augmentation — that bytecode references symbols, and symbols move.5.2 What we do today
We initially keyed on minor versions, on the assumption that "API differences happen at the minor boundary". This turned out to be too optimistic — patches do break things — so the current implementation keys on the full patch version:
SUPPORTED_VERSIONS = { "3.27.4": ..., "3.33.2": ... }— a finite, enumerated set inquarkus/private/versions.bzl.maven.install(name = "maven_3_33_2", ...)per supported patch in our rootMODULE.bazel, with its own lock file (maven_install_3_33_2.json).quarkifier_targets("3_33_2", "@maven_3_33_2", minor = "3_33")call inquarkifier/BUILD.bazel.quarkifier-3.33.2-v0.6.0.jar).quarkus_versionwith exact string equality againstSUPPORTED_VERSIONS. No fallback. If the user says3.33.3and we only ship3.33.2, the build fails loudly with a clear error.The version-specific source directory (
src/main/java_3_33/) is still keyed by minor, because that's where the SPI-level differences live; the patch granularity exists purely to guarantee bytecode compatibility.5.3 What this costs
rules_quarkusrelease with an added entry inSUPPORTED_VERSIONSand a new pre-built deploy jar. There is no way for a user to use a Quarkus version we haven't explicitly enumerated.3.33.2to3.33.3cannot do it until we cut a release. Maven and Gradle users just change the version string and go.5.4 What would help, from the Quarkus side
Any of the following, in roughly increasing order of ambition, would meaningfully reduce the version-coupling pain. We're not asking for all of them — we're asking to discuss which (if any) are feasible:
@Experimentalwith explicit breaking-change notes per release. Today the deployment API is implicitly internal; in practice every alternative build tool integration has to either pin to a version or vendor the API.ApplicationModelfrom a flat list of(GAV, jarPath, parentFirst?, runtime?, ...)tuples without invoking any resolver.ApplicationModelwith a stable wire format.AugmentAction.createInitialRuntimeApplication()andIsolatedDevModeMainwith an externally-providedApplicationModel, never falling through toBootstrapMavenContext.RuntimeUpdatesProcessorwith a writableclassesPathand trust the caller to keep it fresh; no internalQuarkusCompiler.javacviaCompilationProviderServiceLoader.asMap()never NPEs whenplatformImportsisn't set, and the JSON serializer tolerates a minimal model.We are happy to do the implementation work; what we don't have is upstream-level alignment on the SPI shape.
6. Why we think this is worth a conversation now
The project is at a point where:
Continuous Testing,@QuarkusTest, native image (host + container). E2E smoke tests pass on Bazel 7 / 8 / 9.rules_quarkuspatch release. Every Quarkus internal refactor potentially breaks us. We have no early warning system — we find out at release time.@Experimentalone, would let us drop the patch-version pinning and ride minor versions; an inversion-of-control hook for hot-reload compilation would let us deleteBazelFileWatcher; explicitconditional-dev-dependenciesindexing would let us delete a hand-maintained allowlist.We are not asking the Quarkus team to take ownership of Bazel. We are asking whether there's appetite for the small, focused changes that would make any non-Maven, non-Gradle build tool a less second-class citizen — and whether
rules_quarkuscould be the test case that drives those changes.If yes:
rules_quarkusas the integration test consumer.rules_quarkusshould eventually live underquarkusio/.If the answer is "Quarkus is Maven-and-Gradle-first and intends to stay that way" — that's also valuable to know. It tells us we should keep the project as a community-maintained third-party module and stop hoping for upstream alignment.
Either way, we'd appreciate a conversation.
All reactions