Administra dependencias externas con Bzlmod

Informar un problema Ver fuente Noche {/2/}}

Bzlmod es el nombre interno del nuevo sistema de dependencias externas que se introdujo en Bazel 5.0. Se introdujo para abordar varias dificultades del sistema anterior que no se podían corregir de forma incremental. Consulta la sección Planteamiento del problema del documento de diseño original para obtener más detalles.

En Bazel 5.0, Bzlmod no está activado de forma predeterminada, por lo que se debe especificar la marca --experimental_enable_bzlmod para que se aplique lo siguiente: Tal como lo sugiere el nombre de la marca, esta función es actualmente experimental; las APIs y los comportamientos pueden cambiar hasta que se lance oficialmente.

Para migrar tu proyecto a Bzlmod, sigue la guía de migración de Bzlmod. También puedes encontrar ejemplos de usos de Bzlmod en el repositorio de ejemplos.

Módulos de Bazel

El antiguo sistema de dependencias externas basado en WORKSPACE se centra en repositorios (o repositorios), creados a través de reglas de repositorio (o reglas de repositorio). Si bien los repositorios siguen siendo un concepto importante en el sistema nuevo, los módulos son las unidades principales de dependencia.

En esencia, un módulo es un proyecto de Bazel que puede tener varias versiones, cada una de las cuales publica metadatos sobre otros módulos de los que depende. Esto es análogo a conceptos conocidos en otros sistemas de administración de dependencias: un artefacto de Maven, un paquete de npm, un cajón de Cargo, un módulo de Go, etcétera.

Un módulo simplemente especifica sus dependencias con pares name y version, en lugar de URLs específicas en WORKSPACE. Luego, las dependencias se buscan en un registro de Bazel; de forma predeterminada, el registro central de Bazel. En tu espacio de trabajo, cada módulo se convierte en un repositorio.

MODULE.bazel

Cada versión de cada módulo tiene un archivo MODULE.bazel que declara sus dependencias y otros metadatos. A continuación, te mostramos un ejemplo básico:

module(
    name = "my-module",
    version = "1.0",
)

bazel_dep(name = "rules_cc", version = "0.0.1")
bazel_dep(name = "protobuf", version = "3.19.0")

El archivo MODULE.bazel debe estar ubicado en la raíz del directorio del lugar de trabajo (junto al archivo WORKSPACE). A diferencia del archivo WORKSPACE, no necesitas especificar tus dependencias transitivas; en cambio, solo debes especificar dependencias directas, y los archivos MODULE.bazel de tus dependencias se procesan para descubrir dependencias transitivas automáticamente.

El archivo MODULE.bazel es similar a los archivos BUILD, ya que no admite ninguna forma de flujo de control. Además, prohíbe las declaraciones load. Las directivas que admiten los archivos MODULE.bazel son las siguientes:

Formato de la versión

Bazel tiene un ecosistema diverso y los proyectos usan varios esquemas de control de versiones. El más popular por mucho es SemVer, pero también hay proyectos destacados que usan diferentes esquemas, como Abseil, cuyas versiones se basan en fechas, por ejemplo, 20210324.2.

Por este motivo, Bzlmod adopta una versión más relajada de la especificación de SemVer. Estas son algunas de las diferencias:

  • SemVer prescribe que la parte de "lanzamiento" de la versión debe constar de 3 segmentos: MAJOR.MINOR.PATCH. En Bazel, este requisito se flexibiliza para que se permita cualquier cantidad de segmentos.
  • En SemVer, cada uno de los segmentos en la parte “release” debe tener solo dígitos. En Bazel, esto se flexibiliza para permitir también letras, y la semántica de comparación coincide con los “identificadores” de la parte “lanzamiento previo”.
  • Además, no se aplica la semántica de los aumentos principales, secundarios y de versiones de parche. (Sin embargo, consulta el nivel de compatibilidad para obtener detalles sobre cómo denotamos la retrocompatibilidad).

Cualquier versión válida de SemVer es una versión válida del módulo de Bazel. Además, dos versiones de SemVer, a y b, comparan a < b con las mismas conservaciones cuando se comparan con las versiones de módulos de Bazel.

Resolución de la versión

El problema de la dependencia de diamante es un elemento básico en el espacio de administración de dependencias con control de versiones. Supongamos que tienes el siguiente gráfico de dependencias:

       A 1.0
      /     \
   B 1.0    C 1.1
     |        |
   D 1.0    D 1.1

¿Qué versión de D se debería usar? Para resolver esta pregunta, Bzlmod usa el algoritmo de selección de versión mínima (MVS) que se introdujo en el sistema de módulos de Go. MVS supone que todas las versiones nuevas de un módulo son retrocompatibles y, por lo tanto, simplemente elige la versión más alta especificada por cualquier dependiente (D 1.1 en nuestro ejemplo). Se llama "mínima" porque, aquí, D 1.1 es la versión mínima que podría satisfacer nuestros requisitos. No los seleccionamos incluso si existe D 1.2 o posterior. Esto tiene el beneficio adicional de que la selección de versión es de alta fidelidad y reproducible.

La resolución de la versión se realiza de forma local en tu máquina, no en el registro.

Nivel de compatibilidad

Ten en cuenta que la suposición de MVS sobre la retrocompatibilidad es factible porque simplemente trata las versiones incompatibles con versiones anteriores de un módulo como un módulo independiente. En términos de SemVer, eso significa que A 1.x y A 2.x se consideran módulos distintos y pueden coexistir en el gráfico de dependencia resuelto. Esto, a su vez, es posible porque la versión principal está codificada en la ruta del paquete en Go, por lo que no hay conflictos de tiempo de compilación ni de vinculación.

En Bazel, no tenemos esas garantías. Por lo tanto, necesitamos una manera de denotar el número de "versión principal" para detectar versiones incompatibles con versiones anteriores. Este número se denomina nivel de compatibilidad y se especifica en la directiva module() de cada versión de módulo. Con esta información a mano, podemos arrojar un error cuando detectemos que existen versiones del mismo módulo con diferentes niveles de compatibilidad en el gráfico de dependencia resuelto.

Nombres de repositorios

En Bazel, cada dependencia externa tiene un nombre de repositorio. A veces, se puede usar la misma dependencia a través de diferentes nombres de repositorio (por ejemplo, @io_bazel_skylib y @bazel_skylib significan Bazel skylib), o se puede usar el mismo nombre de repositorio para diferentes dependencias en diferentes proyectos.

En Bzlmod, los módulos y las extensiones de módulo de Bazel pueden generar repositorios. Para resolver conflictos de nombres de repositorios, adoptamos el mecanismo de asignación de repositorios en el nuevo sistema. Estos son dos conceptos importantes:

  • Nombre del repositorio canónico: Es el nombre del repositorio único a nivel global para cada repositorio. Este será el nombre del directorio donde reside el repositorio.
    Se construye de la siguiente manera (Advertencia: El formato de nombre canónico no es una API de la que debas confiar, ya que está sujeto a cambios en cualquier momento):

    • Para los repositorios de módulos de Bazel: module_name~version
      (ejemplo. @bazel_skylib~1.0.3)
    • Para los repositorios de extensiones de módulo: module_name~version~extension_name~repo_name
      (Ejemplo. @rules_cc~0.0.1~cc_configure~local_config_cc)
  • Nombre del repositorio aparente: Es el nombre del repositorio que se usará en los archivos BUILD y .bzl dentro de un repositorio. La misma dependencia podría tener nombres diferentes aparentes en diferentes repositorios.
    Se determina de la siguiente manera:

    • Para los repositorios de módulos de Bazel: module_name de forma predeterminada o el nombre especificado por el atributo repo_name en bazel_dep.
    • Para los repositorios de extensiones de módulo: el nombre del repositorio que se ingresa mediante use_repo.

Cada repositorio tiene un diccionario de asignación de repositorios de sus dependencias directas, que es un mapa desde el nombre aparente del repositorio hasta el nombre del repositorio canónico. Usamos la asignación del repositorio para resolver el nombre del repositorio cuando construimos una etiqueta. Ten en cuenta que no se generan conflictos de nombres de repositorios canónicos y que los usos de los nombres de repositorio aparentes se pueden descubrir analizando el archivo MODULE.bazel; por lo tanto, los conflictos se pueden detectar y resolver con facilidad sin afectar a otras dependencias.

Dependencias estrictas

El nuevo formato de especificación de dependencias nos permite realizar verificaciones más estrictas. En particular, ahora aplicamos que un módulo solo puede usar repositorios creados a partir de sus dependencias directas. Esto ayuda a evitar fallas accidentales y difíciles de depurar cuando cambia algo del gráfico de dependencia transitivo.

Las dependencias estrictas se implementan según la asignación de repositorio. Básicamente, la asignación de repositorios para cada repositorio contiene todas sus dependencias directas; los demás repositorios no son visibles. Las dependencias visibles para cada repositorio se determinan de la siguiente manera:

  • Un repositorio de módulos de Bazel puede ver todos los repositorios ingresados en el archivo MODULE.bazel a través de bazel_dep y use_repo.
  • Un repositorio de extensión de módulo puede ver todas las dependencias visibles del módulo que proporciona la extensión, además de todos los demás repositorios generados por la misma extensión de módulo.

Registros

Para descubrir dependencias, Bzlmod solicita su información a los registros de Bazel. Un registro de Bazel es simplemente una base de datos de módulos de Bazel. La única forma de registros admitida es un registro de índices, que es un directorio local o un servidor HTTP estático que sigue un formato específico. En el futuro, planeamos agregar compatibilidad con los registros de un solo módulo, que son simplemente repositorios de Git que contienen la fuente y el historial de un proyecto.

Registro de índices

Un registro de índices es un directorio local o un servidor HTTP estático que contiene información sobre una lista de módulos, incluida su página principal, los encargados de mantenimiento, el archivo MODULE.bazel de cada versión y cómo recuperar el código fuente de cada versión. En particular, no necesita entregar los archivos de origen en sí.

Un registro de índices debe seguir el siguiente formato:

  • /bazel_registry.json: Es un archivo JSON que contiene metadatos para el registro, como los siguientes:
    • mirrors, que especifica la lista de duplicaciones que se usarán para los archivos de origen.
    • module_base_path, que especifica la ruta base para los módulos con tipo local_repository en el archivo source.json.
  • /modules: Es un directorio que contiene un subdirectorio para cada módulo en este registro.
  • /modules/$MODULE: Es un directorio que contiene un subdirectorio para cada versión de este módulo, así como el siguiente archivo:
    • metadata.json: Es un archivo JSON que contiene información sobre el módulo, con los siguientes campos:
      • homepage: La URL de la página principal del proyecto.
      • maintainers: Es una lista de objetos JSON, cada uno de los cuales corresponde a la información de un encargado de mantenimiento del módulo en el registro. Ten en cuenta que no es necesariamente lo mismo que los autores del proyecto.
      • versions: Es una lista de todas las versiones de este módulo que se encontrarán en este registro.
      • yanked_versions: Es una lista de versiones ya realizadas de este módulo. Por el momento, esta es una no-op, pero en el futuro, las versiones no controladas se omitirán o generarán un error.
  • /modules/$MODULE/$VERSION: Un directorio que contiene los siguientes archivos:
    • MODULE.bazel: Es el archivo MODULE.bazel de la versión de este módulo.
    • source.json: Es un archivo JSON que contiene información para recuperar la fuente de esta versión del módulo.
      • El tipo predeterminado es "archivar" con los siguientes campos:
        • url: Es la URL del archivo de origen.
        • integrity: La suma de verificación de integridad de los subrecursos del archivo.
        • strip_prefix: Es un prefijo de directorio que se debe quitar cuando se extrae el archivo de origen.
        • patches: Una lista de cadenas, cada una de las cuales nombra un archivo de parche para aplicar al archivo extraído. Los archivos de parche se encuentran en el directorio /modules/$MODULE/$VERSION/patches.
        • patch_strip: Igual que el argumento --strip del parche de Unix.
      • Se puede cambiar el tipo para usar una ruta local con estos campos:
        • type: local_path
        • path: La ruta local al repositorio, que se calcula de la siguiente manera:
          • Si la ruta es una ruta de acceso absoluta, se usará tal como está.
          • Si la ruta de acceso es relativa y module_base_path es una ruta absoluta, la ruta se resuelve en <module_base_path>/<path>.
          • Si la ruta de acceso y module_base_path son rutas de acceso relativas, la ruta se resuelve en <registry_path>/<module_base_path>/<path>. El registro debe estar alojado de forma local, y --registry=file://<registry_path> debe usarlo. De lo contrario, Bazel mostrará un error.
    • patches/: Es un directorio opcional que contiene archivos de parche. Solo se usa cuando source.json tiene el tipo de "archivo".

Registro central de Bazel

Bazel Central Registry (BCR) es un registro de índices ubicado en bcr.bazel.build. Su contenido está respaldado por el repositorio de GitHub bazelbuild/bazel-central-registry.

La comunidad de Bazel mantiene el BCR. Los colaboradores pueden enviar solicitudes de extracción. Consulta Políticas y procedimientos del registro central de Bazel.

Además de seguir el formato de un registro de índices normal, el BCR requiere un archivo presubmit.yml para cada versión del módulo (/modules/$MODULE/$VERSION/presubmit.yml). Este archivo especifica algunos objetivos esenciales de compilación y prueba que se pueden usar para verificar la validez de esta versión del módulo, y las canalizaciones de CI de BCR lo usan para garantizar la interoperabilidad entre módulos en el BCR.

Selecciona registros

La marca repetible de Bazel --registry se puede usar para especificar la lista de registros a los que se deben solicitar módulos, de modo que puedas configurar tu proyecto para recuperar dependencias de un registro interno o de terceros. Los registros anteriores tienen prioridad. Para mayor comodidad, puedes incluir una lista de marcas --registry en el archivo .bazelrc de tu proyecto.

Extensiones de módulo

Las extensiones de módulo te permiten extender el sistema de módulos mediante la lectura de datos de entrada de los módulos en el gráfico de dependencias, la ejecución de la lógica necesaria para resolver dependencias y la creación de repositorios llamando a las reglas de repositorio. Son similares en función a las macros WORKSPACE actuales, pero son más adecuadas en el mundo de los módulos y las dependencias transitivas.

Las extensiones de módulo se definen en archivos .bzl, al igual que las reglas del repositorio o las macros WORKSPACE. No se invocan directamente. En su lugar, cada módulo puede especificar fragmentos de datos llamados etiquetas para que lean las extensiones. Una vez finalizada la resolución de la versión del módulo, se ejecutan las extensiones del módulo. Cada extensión se ejecuta una vez después de la resolución del módulo (aún antes de que se produzca cualquier compilación) y puede leer todas las etiquetas que le pertenecen en todo el gráfico de la dependencia.

          [ A 1.1                ]
          [   * maven.dep(X 2.1) ]
          [   * maven.pom(...)   ]
              /              \
   bazel_dep /                \ bazel_dep
            /                  \
[ B 1.2                ]     [ C 1.0                ]
[   * maven.dep(X 1.2) ]     [   * maven.dep(X 2.1) ]
[   * maven.dep(Y 1.3) ]     [   * cargo.dep(P 1.1) ]
            \                  /
   bazel_dep \                / bazel_dep
              \              /
          [ D 1.4                ]
          [   * maven.dep(Z 1.4) ]
          [   * cargo.dep(Q 1.1) ]

En el gráfico de dependencias de ejemplo anterior, A 1.1, B 1.2, etc., son módulos de Bazel. Puedes considerar cada uno como un archivo MODULE.bazel. Cada módulo puede especificar algunas etiquetas para las extensiones de módulo; aquí algunas se especifican para la extensión "maven" y otras para "cargo". Cuando se finaliza este gráfico de dependencia (por ejemplo, tal vez B 1.2 realmente tiene un bazel_dep en D 1.3, pero se actualizó a D 1.4 debido a C), se ejecuta la extensión "maven" y puede leer todas las etiquetas maven.*, con información allí para decidir qué repositorios crear. Del mismo modo que ocurre con la extensión "cargo".

Uso de extensiones

Las extensiones están alojadas en módulos de Bazel. Por lo tanto, para usarlas en tu módulo, primero debes agregar un bazel_dep a ese módulo y, luego, llamar a la función integrada use_extension para ponerla dentro del alcance. Considera el siguiente ejemplo un fragmento de un archivo MODULE.bazel para usar una extensión "maven" hipotética definida en el módulo rules_jvm_external:

bazel_dep(name = "rules_jvm_external", version = "1.0")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")

Una vez que la extensión esté dentro del alcance, puedes usar la sintaxis de puntos para especificar etiquetas. Ten en cuenta que las etiquetas deben seguir el esquema definido por las clases de etiquetas correspondientes (consulta la definición de extensión a continuación). A continuación, se muestra un ejemplo en el que se especifican algunas etiquetas maven.dep y maven.pom.

maven.dep(coord="org.junit:junit:3.0")
maven.dep(coord="com.google.guava:guava:1.2")
maven.pom(pom_xml="//:pom.xml")

Si la extensión genera repositorios que deseas usar en tu módulo, usa la directiva use_repo para declararlos. El objetivo de esto es satisfacer la condición de dependencias estrictas y evitar conflictos de nombres de repositorios locales.

use_repo(
    maven,
    "org_junit_junit",
    guava="com_google_guava_guava",
)

Los repositorios que genera una extensión forman parte de su API, por lo que, a partir de las etiquetas que especificaste, debes saber que la extensión “maven” generará un repositorio llamado “org_junit_junit” y otro llamado “com_google_guava_guava”. Con use_repo, tienes la opción de cambiarles el nombre dentro del alcance de tu módulo, como “guava” aquí.

Definición de la extensión

Las extensiones de módulo se definen de manera similar a las reglas del repositorio, con la función module_extension. Ambas tienen una función de implementación, pero, si bien las reglas de repositorio tienen varios atributos, las extensiones de módulo tienen varios tag_class, cada uno de los cuales tiene varios atributos. Las clases de etiquetas definen esquemas para las etiquetas que usa esta extensión. Continuando con nuestro ejemplo de la hipotética extensión de “maven” anterior:

# @rules_jvm_external//:extensions.bzl
maven_dep = tag_class(attrs = {"coord": attr.string()})
maven_pom = tag_class(attrs = {"pom_xml": attr.label()})
maven = module_extension(
    implementation=_maven_impl,
    tag_classes={"dep": maven_dep, "pom": maven_pom},
)

Estas declaraciones dejan en claro que se pueden especificar las etiquetas maven.dep y maven.pom mediante el esquema de atributos definido antes.

La función de implementación es similar a una macro WORKSPACE, excepto que obtiene un objeto module_ctx, que otorga acceso al gráfico de dependencia y a todas las etiquetas pertinentes. Luego, la función de implementación debería llamar a las reglas de repositorio para generar repositorios:

# @rules_jvm_external//:extensions.bzl
load("//:repo_rules.bzl", "maven_single_jar")
def _maven_impl(ctx):
  coords = []
  for mod in ctx.modules:
    coords += [dep.coord for dep in mod.tags.dep]
  output = ctx.execute(["coursier", "resolve", coords])  # hypothetical call
  repo_attrs = process_coursier(output)
  [maven_single_jar(**attrs) for attrs in repo_attrs]

En el ejemplo anterior, revisamos todos los módulos del gráfico de dependencias (ctx.modules), cada uno de los cuales es un objeto bazel_module cuyo campo tags expone todas las etiquetas maven.* en el módulo. Luego, invocamos la utilidad de la CLI, Coursier, para comunicarse con Maven y realizar la resolución. Por último, usamos el resultado de la resolución para crear varios repositorios con la regla hipotética maven_single_jar del repositorio.