diff --git a/MODULE.bazel b/MODULE.bazel index 9c08d4aa..8acf6568 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -254,6 +254,21 @@ deb( urls = ["https://archive.ubuntu.com/ubuntu/pool/universe/l/lcov/lcov_2.0-4ubuntu2_all.deb"], ) +############################################################################### +# Graphviz deb package (cmake release; bundles all graphviz .so files so +# dot_builtins runs without system graphviz installation) +# Uses custom repository rule because the cmake deb uses data.tar.gz +# which download_utils doesn't support (only .xz and .zst) +############################################################################### +graphviz_deb_rule = use_repo_rule("//third_party/graphviz:defs.bzl", "graphviz_deb") + +graphviz_deb_rule( + name = "graphviz_deb", + build = "//third_party/graphviz:graphviz.BUILD", + integrity = "sha256-Jk5gSqo8l0INoY+kr1ZAsi2WhZY8LlAFlEag54H3Q2Q=", + urls = ["https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/12.2.1/ubuntu_24.04_graphviz-12.2.1-cmake.deb"], +) + register_toolchains( "//bazel/rules/rules_score:sphinx_default_toolchain", ) diff --git a/bazel/rules/rules_score/private/sphinx_module.bzl b/bazel/rules/rules_score/private/sphinx_module.bzl index 15b4527d..bcca5feb 100644 --- a/bazel/rules/rules_score/private/sphinx_module.bzl +++ b/bazel/rules/rules_score/private/sphinx_module.bzl @@ -228,10 +228,35 @@ def _score_html_impl(ctx): "--log-level", get_log_level(ctx), ] + + # Wire in the hermetic graphviz deb (dot_builtins + bundled shared libs). + # conf.template.py resolves all three env vars (GRAPHVIZ_DOT, + # LD_LIBRARY_PATH, LTDL_LIBRARY_PATH) from execroot-relative to absolute + # paths so dot_builtins can load its plugins without a system installation. + _dot_suffix = "/usr/bin/dot_builtins" + graphviz_files = ctx.files.graphviz + dot_binary = None + for f in graphviz_files: + if f.path.endswith(_dot_suffix): + dot_binary = f + break + if not dot_binary: + fail("graphviz target {} must provide usr/bin/dot_builtins".format(ctx.attr.graphviz.label)) + + graphviz_prefix = dot_binary.path[:-len(_dot_suffix)] + graphviz_env = { + "GRAPHVIZ_DOT": dot_binary.path, + "LD_LIBRARY_PATH": graphviz_prefix + "/usr/lib", + "LTDL_LIBRARY_PATH": graphviz_prefix + "/usr/lib/graphviz", + } + html_inputs = html_inputs + graphviz_files + ctx.actions.run( inputs = html_inputs, outputs = [sphinx_html_output], arguments = html_args + [args], + env = graphviz_env, + use_default_shell_env = True, progress_message = "Building HTML: %s" % ctx.label.name, executable = sphinx_toolchain.sphinx.files_to_run.executable, tools = [ @@ -323,6 +348,12 @@ _score_html = rule( "destination paths relative to the Sphinx source root. Exactly one " + "file per label. Mirrors sphinx_docs.renamed_srcs from rules_python.", ), + graphviz = attr.label( + default = Label("@graphviz_deb//:all"), + allow_files = True, + doc = "Graphviz cmake-release deb files (dot_builtins binary + bundled libs). " + + "Provides a hermetic 'dot' binary without requiring a system graphviz installation.", + ), ), toolchains = ["//bazel/rules/rules_score:toolchain_type"], ) diff --git a/bazel/rules/rules_score/templates/conf.template.py b/bazel/rules/rules_score/templates/conf.template.py index 0646a342..93ba9fd1 100644 --- a/bazel/rules/rules_score/templates/conf.template.py +++ b/bazel/rules/rules_score/templates/conf.template.py @@ -20,6 +20,7 @@ import json import os +import shutil as _shutil import sys from pathlib import Path from typing import Any, Dict, List @@ -30,6 +31,48 @@ # Create a logger with the Sphinx namespace logger = logging.getLogger(__name__) +# --------------------------------------------------------------------------- +# Helpers: Bazel execroot path resolution +# --------------------------------------------------------------------------- + + +def _bazel_execroot() -> Path: + """Return the Bazel execroot directory inferred from this config file's path. + + conf.py is generated into ``bazel-out/…/bin/…/conf.py``, so splitting on + ``/bazel-out/`` gives us the execroot prefix reliably. Falls back to the + current working directory when the path pattern is not recognised (e.g. + during unit tests or IDE runs outside Bazel). + """ + parts = str(Path(__file__).resolve()).split("/bazel-out/", 1) + return Path(parts[0]) if len(parts) == 2 else Path.cwd() + + +# Computed once at import time so _resolve_execroot_path() doesn't repeat the +# filesystem resolution on every call. +_EXECROOT = _bazel_execroot() + + +def _resolve_execroot_path(path_value: str) -> str: + """Resolve an execroot-relative path to an absolute filesystem path. + + Bazel passes action inputs as paths relative to the execroot (e.g. + ``external/+_repo_rules2+graphviz_deb/usr/bin/dot_builtins``). Those + paths are only valid when the process' cwd is the execroot — which is + not guaranteed once Sphinx changes directories during the build. + + This function makes them absolute so they work regardless of cwd. + Absolute paths and plain command names (e.g. ``dot``) are returned + unchanged. + """ + p = Path(path_value) + if p.is_absolute(): + return str(p) + if path_value.startswith("external/") or path_value.startswith("bazel-out/"): + return str((_EXECROOT / p).resolve()) + return path_value + + logger.debug("#" * 80) logger.debug("# READING CONF.PY") logger.debug("SYSPATH:" + str(sys.path)) @@ -55,6 +98,7 @@ "sphinxcontrib.plantuml", "trlc", "clickable_plantuml", + "sphinx.ext.graphviz", ] # MyST parser extensions @@ -153,9 +197,29 @@ plantuml = f"{plantuml_path} -Playout=smetana" plantuml_output_format = "svg_obj" -import shutil as _shutil +# --------------------------------------------------------------------------- +# Graphviz (sphinx.ext.graphviz) +# --------------------------------------------------------------------------- +# GRAPHVIZ_DOT is set by the Bazel sphinx_module rule to point at the hermetic +# dot_builtins binary from @graphviz_deb. The path is execroot-relative, so +# we resolve it to an absolute path here so it remains valid after any cwd +# change that Sphinx may perform during the build. +graphviz_dot = _resolve_execroot_path( + os.environ.get("GRAPHVIZ_DOT") or _shutil.which("dot") or "dot" +) -graphviz_dot = os.environ.get("GRAPHVIZ_DOT") or _shutil.which("dot") or "dot" +# LD_LIBRARY_PATH and LTDL_LIBRARY_PATH are set by the Bazel rule as +# execroot-relative paths. We mutate os.environ (not just a local) because +# sphinx.ext.graphviz spawns `dot` as a child process that inherits these +# variables to locate the bundled shared libraries and plugins. Each +# component is resolved to absolute so it stays valid if Sphinx changes cwd +# before spawning the dot subprocess. +for _env_var in ("LD_LIBRARY_PATH", "LTDL_LIBRARY_PATH"): + _env_val = os.environ.get(_env_var, "") + if _env_val: + os.environ[_env_var] = ":".join( + _resolve_execroot_path(p) for p in _env_val.split(":") + ) # HTML theme html_theme = "sphinx_rtd_theme" diff --git a/third_party/graphviz/BUILD b/third_party/graphviz/BUILD new file mode 100644 index 00000000..40fc23fe --- /dev/null +++ b/third_party/graphviz/BUILD @@ -0,0 +1,16 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# This package hosts the BUILD file used by the @graphviz_deb external repository. +# The graphviz_deb rule (defined in MODULE.bazel) extracts the Graphviz cmake +# release .deb and uses graphviz.BUILD as its top-level BUILD file. diff --git a/third_party/graphviz/defs.bzl b/third_party/graphviz/defs.bzl new file mode 100644 index 00000000..d3b11d14 --- /dev/null +++ b/third_party/graphviz/defs.bzl @@ -0,0 +1,78 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +"""Repository rule that downloads and extracts a graphviz cmake-release .deb. + +The upstream cmake deb uses data.tar.gz compression, which download_utils does +not support (it only handles .xz and .zst). This rule uses the standard +`ar` + `tar` toolchain that is present on every Debian/Ubuntu host. + +Note: because extraction shells out to host `ar` and `tar`, this rule is not +fully hermetic and assumes a Debian/Ubuntu-style builder. It will fail on +hosts where those tools are unavailable. +""" + +def _graphviz_deb_impl(ctx): + """Download and extract a graphviz .deb package into an external repository.""" + + # Step 1: download the .deb archive. + deb_path = ctx.path("graphviz.deb") + ctx.download( + url = ctx.attr.urls, + integrity = ctx.attr.integrity, + output = deb_path, + ) + + work_dir = str(ctx.path(".")) + + # Step 2: unpack data.tar.gz from the ar archive. + # A Debian .deb is an ar archive containing control.tar.* and data.tar.*. + result = ctx.execute( + ["ar", "x", str(deb_path), "data.tar.gz"], + working_directory = work_dir, + ) + if result.return_code != 0: + fail("Failed to extract data.tar.gz from deb: {}".format(result.stderr)) + + # Step 3: extract data.tar.gz contents into the repository root. + result = ctx.execute( + ["tar", "-xzf", "data.tar.gz"], + working_directory = work_dir, + ) + if result.return_code != 0: + fail("Failed to extract data.tar.gz: {}".format(result.stderr)) + + # Clean up only the files we explicitly created. + ctx.execute(["rm", "-f", str(deb_path), "data.tar.gz"], working_directory = work_dir) + + # Step 4: inject the BUILD file that exposes graphviz targets. + ctx.file("BUILD", ctx.read(ctx.attr.build)) + +graphviz_deb = repository_rule( + doc = "Downloads and extracts a graphviz cmake-release .deb into an external repository.", + implementation = _graphviz_deb_impl, + attrs = { + "urls": attr.string_list( + mandatory = True, + doc = "List of mirror URLs for the graphviz .deb package.", + ), + "integrity": attr.string( + mandatory = True, + doc = "Subresource Integrity (SRI) checksum of the .deb archive.", + ), + "build": attr.label( + mandatory = True, + doc = "Label of the BUILD file template to inject into the extracted repository.", + ), + }, +) diff --git a/third_party/graphviz/graphviz.BUILD b/third_party/graphviz/graphviz.BUILD new file mode 100644 index 00000000..b65a14a7 --- /dev/null +++ b/third_party/graphviz/graphviz.BUILD @@ -0,0 +1,101 @@ +# ******************************************************************************* +# Copyright (c) 2025 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* + +# This BUILD file is injected into the @graphviz_deb external repository by the +# graphviz_deb rule. It exposes the cmake-built graphviz binaries and their +# bundled shared libraries. +# +# == Use case == +# We use graphviz exclusively to render the LOBSTER tracing-policy diagram as +# SVG inside Sphinx (sphinx.ext.graphviz, -Tsvg, dot layout algorithm). +# +# == Plugins activated for our use case == +# Only two of the bundled plugins are activated at runtime for -Tsvg + dot layout: +# libgvplugin_core.so.6 — SVG/PS/JSON renderer ("render: svg:core") +# libgvplugin_dot_layout.so.6 — Hierarchical "dot" layout algorithm +# +# All other plugins in usr/lib/graphviz/ (pango, gd, neato_layout, vt, …) are +# registered at startup from the config6 file and then loaded on demand. +# For our -Tsvg + dot-layout use case they are never invoked; if their system +# dependencies are absent, graphviz emits a warning but SVG output is unaffected. +# +# == System library dependencies == +# The cmake deb bundles all graphviz-specific .so files so that `dot_builtins` +# finds them via RUNPATH=$ORIGIN/../lib without a system graphviz installation. +# The remaining system libraries are split by whether they are required for our +# specific use case or only pulled in by unused plugins: +# +# Required by libgvplugin_core + libgvplugin_dot_layout (our actual use case): +# libc.so.6 — C standard library (always present) +# libm.so.6 — math library (always present) +# libz.so.1 — zlib compression (always present: zlib1g) +# libexpat.so.1 — XML/SVG parsing (always present: libexpat1) +# libltdl.so.7 — plugin dynamic loader (libtool) (always present: libltdl7) +# +# Only required by unused plugins (pango/gd/neato_layout) — NOT needed for SVG: +# libcairo.so.2 + libpixman-1.so.0 + libxcb*.so — raster/PDF rendering +# (pango+gd plugins; pre-installed on Ubuntu 24.04) +# libpango*.so + libfontconfig.so.1 + libfreetype.so.6 +# + libharfbuzz.so.0 + libfribidi.so.0 + libthai.so.0 + libdatrie.so.1 +# + libgraphite2.so.3 — font layout for PNG/PDF output +# (pango plugin; pre-installed on Ubuntu 24.04) +# libgd.so.3 + libjpeg.so.8 + libpng16.so.16 + libtiff.so.6 + libwebp.so.7 +# + libheif.so.1 + libLerc.so.4 + libjbig.so.0 + libdeflate.so.0 +# + libbrotli*.so + libzstd.so.1 + liblzma.so.5 + libsharpyuv.so.0 +# — image-format decoders for PNG/GIF/JPEG output +# (gd plugin; pre-installed on Ubuntu 24.04) +# libgts-0.7.so.5 — graph triangulation +# (neato_layout only; NOT needed for dot layout) +# libglib-2.0.so.0 + libgio-2.0.so.0 + libgobject-2.0.so.0 +# + libgmodule-2.0.so.0 + libffi.so.8 + libpcre2-8.so.0 +# + libblkid.so.1 + libmount.so.1 + libselinux.so.1 +# + libbsd.so.0 + libmd.so.0 — GLib/GIO stack (pango plugin transitive deps) +# (pre-installed on Ubuntu 24.04) +# libX11.so.6 + libXext.so.6 + libXrender.so.1 + libXpm.so.4 +# + libXau.so.6 + libXdmcp.so.6 — X11 display (xlib/x11 output only) +# (pre-installed on Ubuntu 24.04) +# libstdc++.so.6 + libgcc_s.so.1 — C++ runtime (gd plugin) +# (always present) + +package(default_visibility = ["//visibility:public"]) + +# The actual graphviz rendering binary (not the dot wrapper/launcher). +# Uses RUNPATH $ORIGIN/../lib to find bundled shared libraries. +filegroup( + name = "dot_binary", + srcs = ["usr/bin/dot_builtins"], +) + +# Bundled graphviz shared libraries (libgvc, libcgraph, libcdt, libpathplan, libxdot). +# These are found automatically by dot_builtins via RUNPATH $ORIGIN/../lib. +filegroup( + name = "core_libs", + srcs = glob(["usr/lib/*.so*"]), +) + +# Graphviz plugin shared libraries (libgvplugin_core, libgvplugin_dot_layout, etc.). +# Loaded at runtime via libltdl; requires LTDL_LIBRARY_PATH=usr/lib/graphviz. +filegroup( + name = "plugin_libs", + srcs = glob(["usr/lib/graphviz/*.so*"]), +) + +# All graphviz files needed to run dot_builtins. +filegroup( + name = "all", + srcs = [ + ":core_libs", + ":dot_binary", + ":plugin_libs", + ], +) diff --git a/tools/sphinx/BUILD b/tools/sphinx/BUILD index e0168a64..d1a64d03 100644 --- a/tools/sphinx/BUILD +++ b/tools/sphinx/BUILD @@ -12,7 +12,6 @@ # ******************************************************************************* load("@pip_rules_score//:requirements.bzl", "requirement") -load("@pip_tooling//:requirements.bzl", "requirement") load("@rules_java//java:defs.bzl", "java_binary") load("@rules_python//sphinxdocs:sphinx.bzl", "sphinx_build_binary")