Skip to content

Commit 2652b05

Browse files
committed
[rules score] make sphinx build fully hermetic
- include graphviz - rework plantuml integration - include graphbiz system deps as rootfs
1 parent bf82dcd commit 2652b05

19 files changed

Lines changed: 924 additions & 127 deletions

File tree

MODULE.bazel

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,16 @@ deb(
260260
)
261261

262262
###############################################################################
263-
# Graphviz deb package (cmake release; bundles all graphviz .so files so
264-
# dot_builtins runs without system graphviz installation)
265-
# Uses download_deb from @download_utils at a commit that includes
266-
# data.tar.gz support in download/deb/repository.bzl.
263+
# Graphviz (relocatable cmake-release deb)
264+
#
265+
# A relocatable graphviz build (RUNPATH $ORIGIN/../lib, ships a basename-based
266+
# config6 plugin manifest), so its dynamic `dot` runs from the Bazel execroot
267+
# without a system install. We use this build (not apt's `graphviz`) because
268+
# apt's is built for a fixed `/` install and relies on a postinst `dot -c` that
269+
# rules_distroless never runs, so it fails to load plugins when relocated.
270+
# The cmake deb bundles graphviz's own libs + plugins + config6; the external
271+
# system libs it needs (libexpat/libltdl/libz) come from the docs_runtime sysroot
272+
# below. See //third_party/docs_runtime/README.md.
267273
###############################################################################
268274
deb(
269275
name = "graphviz_deb",
@@ -272,6 +278,33 @@ deb(
272278
urls = ["https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/12.2.1/ubuntu_24.04_graphviz-12.2.1-cmake.deb"],
273279
)
274280

281+
###############################################################################
282+
# Hermetic doc-tool sysroot (docs_runtime)
283+
#
284+
# A small distroless rootfs supplying ONLY the external system shared libraries
285+
# the relocatable graphviz `dot` links against but does not bundle
286+
# (libexpat.so.1, libltdl.so.7, libz.so.1). Together with @graphviz_deb this
287+
# makes the doc build's dot fully hermetic (no host graphviz/system-lib needed).
288+
# glibc (libc/libm/ld-linux) deliberately stays on the host loader.
289+
#
290+
# rules_distroless `apt.install` resolves the closure from a committed lock; the
291+
# flattened rootfs is extracted to a directory by //third_party/docs_runtime and
292+
# merged into LD_LIBRARY_PATH alongside the graphviz_deb libs.
293+
###############################################################################
294+
bazel_dep(name = "rules_distroless", version = "0.6.2")
295+
296+
# bsdtar (used by //third_party/docs_runtime to extract the flattened rootfs tar).
297+
bazel_dep(name = "tar.bzl", version = "0.6.0")
298+
299+
apt = use_extension("@rules_distroless//apt:extensions.bzl", "apt")
300+
apt.install(
301+
name = "docs_runtime",
302+
lock = "//third_party/docs_runtime:docs_runtime.lock.json",
303+
manifest = "//third_party/docs_runtime:docs_runtime.yaml",
304+
mergedusr = True,
305+
)
306+
use_repo(apt, "docs_runtime")
307+
275308
register_toolchains(
276309
"//bazel/rules/rules_score:sphinx_default_toolchain",
277310
)

bazel/rules/rules_score/BUILD

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,6 @@ sphinx_module(
185185
py_binary(
186186
name = "raw_build",
187187
srcs = ["src/sphinx_wrapper.py"],
188-
data = [
189-
"//tools/sphinx:plantuml",
190-
],
191188
env = {
192189
"SOURCE_DIRECTORY": "",
193190
"DATA": "",
@@ -198,7 +195,6 @@ py_binary(
198195
deps = [
199196
":sphinx_module_ext",
200197
"@lobster//sphinx_lobster:sphinx_lobster_builder",
201-
"@rules_python//python/runfiles",
202198
"@score_tooling//plantuml/sphinx/clickable_plantuml",
203199
"@trlc//tools/sphinx/extensions/trlc",
204200
requirement("sphinx"),

bazel/rules/rules_score/docs/integration_guide.rst

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,143 @@ Design Rationale
175175
6. **Build System Integration** — Bazel ensures reproducible, cacheable documentation builds
176176

177177
Reference implementation: `examples/seooc <https://github.com/eclipse-score/score-tooling/tree/main/bazel/rules/rules_score/examples/seooc>`_ in the score-tooling repository.
178+
179+
---
180+
181+
.. _sphinx-hermetic-tool-setup:
182+
183+
Hermetic Diagram Tools (Graphviz and PlantUML)
184+
----------------------------------------------
185+
186+
The Sphinx HTML action shells out to two diagram tools at **runtime** (inside
187+
Bazel actions): ``dot`` from Graphviz and PlantUML. Both are hermetic —
188+
i.e.\ no host installation required. The two tools use different
189+
delivery mechanisms, described below.
190+
191+
Graphviz / ``dot``
192+
~~~~~~~~~~~~~~~~~~
193+
194+
**Source and packaging**
195+
196+
Graphviz is pulled from the **cmake-release** ``.deb`` published on the
197+
Graphviz GitLab CI at
198+
``https://gitlab.com/graphviz/graphviz/-/packages``. This ``dot`` binary is
199+
*relocatable*: ``RUNPATH=$ORIGIN/../lib:$ORIGIN/../lib/graphviz``
200+
and the plugin manifest (``config6``) references plugins by basename — so it
201+
works from any directory without a system install. The standard Ubuntu
202+
``graphviz`` package would not work here.
203+
204+
``@graphviz_deb`` is declared in ``MODULE.bazel`` via a ``download_deb`` rule.
205+
Three external system libraries that ``dot`` does **not** bundle (``libexpat``,
206+
``libltdl``, ``libz``) are provided by a small distroless sysroot declared in
207+
``//third_party/docs_runtime`` (see its ``README.md`` for details).
208+
209+
**Where the files land (execroot-relative paths)**
210+
211+
.. code-block:: text
212+
213+
external/+_repo_rules+graphviz_deb/usr/
214+
bin/dot ← GRAPHVIZ_DOT env var
215+
lib/
216+
libgvc.so.6 / libcgraph.so.6 /… ← core libs (RUNPATH $ORIGIN/../lib)
217+
graphviz/
218+
config6 ← plugin manifest (GVBINDIR)
219+
libgvplugin_core.so.6 ← SVG renderer (LTDL_LIBRARY_PATH)
220+
libgvplugin_dot_layout.so.6 ← dot layout engine
221+
222+
bazel-bin/third_party/docs_runtime/tree_root/usr/lib/x86_64-linux-gnu/
223+
libexpat.so.1 / libltdl.so.7 / libz.so.1 ← LD_LIBRARY_PATH
224+
225+
**Wiring into the Sphinx action**
226+
227+
``dot_action_env()`` (from ``//third_party/docs_runtime:defs.bzl``) converts
228+
the file list into an environment dict with four execroot-relative paths:
229+
230+
.. list-table::
231+
:widths: 30 70
232+
:header-rows: 1
233+
234+
* - Env var
235+
- Content
236+
* - ``GRAPHVIZ_DOT``
237+
- Path to the ``dot`` binary
238+
* - ``LD_LIBRARY_PATH``
239+
- Graphviz core libs + sysroot libs (colon-separated)
240+
* - ``GVBINDIR``
241+
- Plugin directory (``config6`` + ``.so`` files)
242+
* - ``LTDL_LIBRARY_PATH``
243+
- Same as ``GVBINDIR``; searched by ``libltdl`` to ``dlopen`` plugins
244+
245+
Both targets are added as **action inputs** and the env dict is set on the
246+
Sphinx HTML ``ctx.actions.run`` call.
247+
248+
**Resolving paths in conf.py**
249+
250+
All four env vars are set as *execroot-relative* paths (e.g.
251+
``external/+_repo_rules+graphviz_deb/usr/bin/dot``). Because Sphinx changes
252+
the process working directory during the build, these paths would break if
253+
used as-is. ``conf.template.py`` therefore:
254+
255+
1. Captures ``_EXECROOT = Path.cwd()`` at **module import time** (cwd is still
256+
the execroot at that point).
257+
2. Calls ``_resolve_execroot_path()`` on each value to prepend ``_EXECROOT``
258+
and produce absolute paths.
259+
3. Mutates ``os.environ`` for ``LD_LIBRARY_PATH``, ``GVBINDIR`` and
260+
``LTDL_LIBRARY_PATH`` so that the ``dot`` *child process* (spawned by
261+
``sphinx.ext.graphviz`` and PlantUML) inherits them.
262+
263+
PlantUML
264+
~~~~~~~~
265+
266+
**Source and packaging**
267+
268+
PlantUML is fetched from **Maven Central** via ``rules_jvm_external``
269+
(declared in ``MODULE.bazel``). It is wrapped as a ``java_binary`` at
270+
``//tools/sphinx:plantuml`` in ``tools/sphinx/BUILD``.
271+
272+
The PlantUML target is added to the sphinx-build binary (``raw_build``) as a
273+
``data`` dependency, making it a **runfile** of that binary — not an
274+
independent action input.
275+
276+
**Where the file lands (runfiles-relative path)**
277+
278+
.. code-block:: text
279+
280+
{sphinx_build_binary}.runfiles/
281+
{repo_name}/tools/sphinx/plantuml ← wrapper script (absolute path via Runfiles API)
282+
283+
The ``{repo_name}`` prefix depends on the Bzlmod configuration:
284+
285+
- ``_main`` — when score_tooling is the root module (e.g.\ building within
286+
the score-tooling repo itself)
287+
- ``score_tooling`` / ``score_tooling+`` / ``score_tooling~`` — when
288+
score_tooling is an external dependency of another project
289+
290+
**Discovering the binary in conf.py**
291+
292+
Because the repo name varies, ``conf.template.py`` uses two-stage discovery:
293+
294+
1. **Manifest scan (primary):** Read ``RUNFILES_MANIFEST_FILE`` and search
295+
for any entry whose runfiles path ends in ``/tools/sphinx/plantuml``. This
296+
requires no knowledge of the repo name prefix.
297+
2. **Runfiles API fallback:** If no manifest file is available (directory-based
298+
runfiles trees on some platforms), fall back to
299+
``Runfiles.Create().Rlocation()`` with a list of known repo-name candidates
300+
(``_main``, ``score_tooling``, ``score_tooling+``, …).
301+
302+
The Runfiles API returns an **absolute path** directly, so no
303+
``_resolve_execroot_path()`` is required for PlantUML.
304+
305+
**Connecting PlantUML to Graphviz**
306+
307+
Once both paths are resolved, ``conf.template.py`` assembles the PlantUML
308+
command:
309+
310+
.. code-block:: python
311+
312+
plantuml = f"{plantuml_path} -graphvizdot {graphviz_dot}"
313+
314+
The ``-graphvizdot`` flag makes PlantUML use the hermetic ``dot`` binary for
315+
diagram layout instead of its bundled Java port (Smetana). This ensures that
316+
the graphviz version is identical for both ``sphinx.ext.graphviz`` directives
317+
and PlantUML diagrams.

bazel/rules/rules_score/private/sphinx_module.bzl

Lines changed: 25 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ load("@rules_python//sphinxdocs:sphinx_docs_library.bzl", "sphinx_docs_library")
1818
load("@rules_python//sphinxdocs/private:sphinx_docs_library_info.bzl", "SphinxDocsLibraryInfo")
1919
load("//bazel/rules/rules_score:providers.bzl", "FilteredExecpathInfo", "SphinxIndexFileInfo", "SphinxModuleInfo", "SphinxNeedsInfo")
2020
load("//bazel/rules/rules_score/private:verbosity.bzl", "VERBOSITY_ATTR", "get_log_level")
21+
load("//third_party/docs_runtime:defs.bzl", "DOCS_RUNTIME_TREE", "GRAPHVIZ_DEB", "dot_action_env")
2122

2223
def _get_index_file(ctx):
2324
"""Extract the index file from the index attribute.
@@ -69,6 +70,11 @@ sphinx_rule_attrs = dict(
6970
"deps": attr.label_list(
7071
doc = "List of other sphinx_module targets this module depends on for intersphinx.",
7172
),
73+
"_plantuml": attr.label(
74+
default = Label("//third_party/plantuml:plantuml"),
75+
executable = True,
76+
cfg = "exec",
77+
),
7278
},
7379
**VERBOSITY_ATTR
7480
)
@@ -110,10 +116,12 @@ def _score_needs_impl(ctx):
110116
inputs = needs_inputs,
111117
outputs = [needs_output],
112118
arguments = needs_args,
119+
env = {"PLANTUML_BIN": ctx.executable._plantuml.path},
113120
progress_message = "Generating needs.json for: %s" % ctx.label.name,
114121
executable = sphinx_toolchain.sphinx.files_to_run.executable,
115122
tools = [
116123
sphinx_toolchain.sphinx.files_to_run,
124+
ctx.attr._plantuml.files_to_run,
117125
],
118126
)
119127
transitive_needs = [dep[SphinxNeedsInfo].needs_json_files for dep in ctx.attr.deps if SphinxNeedsInfo in dep]
@@ -238,39 +246,26 @@ def _score_html_impl(ctx):
238246
get_log_level(ctx),
239247
]
240248

241-
# Wire in the hermetic graphviz deb (dot_builtins + bundled shared libs) if provided.
242-
# conf.template.py resolves all three env vars (GRAPHVIZ_DOT,
243-
# LD_LIBRARY_PATH, LTDL_LIBRARY_PATH) from execroot-relative to absolute
244-
# paths so dot_builtins can load its plugins without a system installation.
245-
graphviz_env = {}
246-
graphviz_files = ctx.files.graphviz
247-
if graphviz_files:
248-
_dot_suffix = "/usr/bin/dot_builtins"
249-
dot_binary = None
250-
for f in graphviz_files:
251-
if f.path.endswith(_dot_suffix):
252-
dot_binary = f
253-
break
254-
if not dot_binary:
255-
fail("graphviz target {} must provide usr/bin/dot_builtins".format(ctx.attr.graphviz))
256-
257-
graphviz_prefix = dot_binary.path[:-len(_dot_suffix)]
258-
graphviz_env = {
259-
"GRAPHVIZ_DOT": dot_binary.path,
260-
"LD_LIBRARY_PATH": graphviz_prefix + "/usr/lib",
261-
"LTDL_LIBRARY_PATH": graphviz_prefix + "/usr/lib/graphviz",
262-
}
263-
html_inputs = html_inputs + graphviz_files
249+
# Wire in the hermetic graphviz `dot` (relocatable cmake deb @graphviz_deb)
250+
# plus the docs_runtime sysroot (libexpat/libltdl/libz). conf.template.py
251+
# reads GRAPHVIZ_DOT for both sphinx.ext.graphviz and PlantUML, and resolves
252+
# LD_LIBRARY_PATH / GVBINDIR / LTDL_LIBRARY_PATH to absolute paths so dot can
253+
# load its plugins without a system graphviz installation.
254+
action_env = {"PLANTUML_BIN": ctx.executable._plantuml.path}
255+
if ctx.files._graphviz and ctx.file._docs_runtime:
256+
action_env.update(dot_action_env(ctx.files._graphviz, ctx.file._docs_runtime))
257+
html_inputs = html_inputs + ctx.files._graphviz + [ctx.file._docs_runtime]
264258

265259
ctx.actions.run(
266260
inputs = html_inputs,
267261
outputs = [sphinx_html_output],
268262
arguments = html_args + [args],
269-
env = graphviz_env,
263+
env = action_env,
270264
progress_message = "Building HTML: %s" % ctx.label.name,
271265
executable = sphinx_toolchain.sphinx.files_to_run.executable,
272266
tools = [
273267
sphinx_toolchain.sphinx.files_to_run,
268+
ctx.attr._plantuml.files_to_run,
274269
],
275270
)
276271

@@ -358,12 +353,13 @@ _score_html = rule(
358353
"destination paths relative to the Sphinx source root. Exactly one " +
359354
"file per label. Mirrors sphinx_docs.renamed_srcs from rules_python.",
360355
),
361-
graphviz = attr.label(
362-
default = None,
356+
_graphviz = attr.label(
357+
default = GRAPHVIZ_DEB,
363358
allow_files = True,
364-
doc = "Graphviz cmake-release deb files (dot_builtins binary + bundled libs). " +
365-
"Only available on Linux x86_64; provides a hermetic 'dot' binary without requiring a system graphviz installation. " +
366-
"Defaults to @graphviz_deb//:all on Linux x86_64.",
359+
),
360+
_docs_runtime = attr.label(
361+
default = DOCS_RUNTIME_TREE,
362+
allow_single_file = True,
367363
),
368364
),
369365
toolchains = ["//bazel/rules/rules_score:toolchain_type"],
@@ -383,7 +379,6 @@ def sphinx_module(
383379
strip_prefix = "",
384380
extra_opts = [],
385381
extra_opts_targets = [],
386-
graphviz = None,
387382
testonly = False,
388383
**kwargs):
389384
"""Build a Sphinx module with transitive HTML dependencies.
@@ -408,10 +403,6 @@ def sphinx_module(
408403
extra_opts_targets: {type}`list[label]` Label targets that resolve to extra Sphinx
409404
arguments at analysis time. Each target must provide FilteredExecpathInfo
410405
(e.g. filter_execpath targets).
411-
graphviz: Graphviz cmake-release deb files (dot_builtins + bundled libs). On Linux x86_64,
412-
defaults to @graphviz_deb//:all for hermetic graphviz support. On other platforms
413-
or if explicitly set to None, no graphviz support is provided (the sphinx.ext.graphviz
414-
extension will not be available).
415406
visibility: Bazel visibility
416407
"""
417408
_score_needs(
@@ -432,7 +423,6 @@ def sphinx_module(
432423
needs = [d + "_needs" for d in deps],
433424
extra_opts = extra_opts,
434425
extra_opts_targets = extra_opts_targets,
435-
graphviz = graphviz,
436426
testonly = testonly,
437427
**kwargs
438428
)

0 commit comments

Comments
 (0)