bazelbazel-rulesstarlark

How do I pass user-defined build settings to existing Bazel rules?


I'd like to migrate away from --define flags and to build settings per: https://docs.bazel.build/versions/5.0.0/skylark/config.html

Here's the rule to which I'd like to pass command line values.

I'm somewhat new to Bazel and still figuring out the right way to conceptualize this stuff. Any guidance is appreciated!

Current Approach

backend/BUILD.bazel:

load("@io_bazel_rules_docker//container:container.bzl", "container_image", "container_push")

# container_image :run_server definition

container_push(
    name = "push_server",
    format = "Docker",
    image = ":run_server",
    registry = "gcr.io",
    repository = "$(PROJECT_ID)/chat/server",
    tag = "$(CONTAINER_TAG)",
)

Then I run:

bazel run \
  --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 \
  --define PROJECT_ID=$(gcloud config get-value project) \
  --define CONTAINER_TAG=some_feature_branch \
  -- //backend:push_server

What I've Tried

A few variations of:

load("//backend:rules.bzl", "gcr_container_push")
load("@bazel_skylib//rules:common_settings.bzl", "string_flag")
load("@io_bazel_rules_docker//container:container.bzl", "container_image")

string_flag(
    name = "container_tag",
    build_setting_default = "latest",
    visibility = ["//visibility:public"],
)

string_flag(
    name = "project_id",
    build_setting_default = "",
    visibility = ["//visibility:public"],
)

# container_image :run_server definition

gcr_container_push(
    name = "push_server",
    image = ":run_server", 
    path = "chat/server",
)

backend/rules.bzl:

load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo")
load("@bazel_skylib//lib:paths.bzl", "paths")
load("@io_bazel_rules_docker//container:container.bzl", "container_push")

def _gcr_container_push_impl(ctx):
    project_id = ctx.attr._project_id[BuildSettingInfo].value
    if len(project_id) == 0:
        fail("Please provide a GCP project ID via --//backend:project_id=<PROJECT ID>.")
    container_push(
        name = ctx.label.name,
        format = "Docker",
        image = ctx.attr.image,
        registry = "gcr.io",
        repository = paths.join(project_id, ctx.attr.path),
        tag = ctx.attr._container_tag[BuildSettingInfo].value,
    )

_gcr_container_push_attrs = {
    "image": attr.label(
        allow_single_file = [".tar"],
        mandatory = True,
        doc = "The label of the image to push.",
    ),
    "path": attr.string(
        mandatory = True,
        doc = "The name of the image within the repository. Ex. gcr.io/project_id/<PATH>:tag.",
    ),
    "_container_tag": attr.label(default = Label("//backend:container_tag")),
    "_project_id": attr.label(default = Label("//backend:project_id")),
}

gcr_container_push = rule(
    implementation = _gcr_container_push_impl,
    attrs = _gcr_container_push_attrs,
    executable = True,
)

Then I run:

bazel run \
  --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 \
  --//backend:project_id=ggx-prototype \
  -- //backend:push_server  

Which returns:

Error in container_push_: 'container_push_' can only be called during the loading phase

Solution

  • rules_docker has attrs like repository_file and tag_file for exactly this kind of thing. You can generate those files however you want, including a custom rule that uses your user-defined flags. I'd do it like this:

    def gcr_container_push(name, image, path, **kwargs):
      if 'tag' in kwargs or 'repository' in kwargs:
        fail('Not allowed to set these')
      _gcr_container_repository(
        name = name + '_repository',
        visibility = ['//visibility:private'],
        path = path,
      )
      _gcr_container_tag(
        name = name + '_tag',
        visibility = ['//visibility:private'],
        path = path,
      )
      container_push(
        name = name,
        format = 'Docker',
        image = image,
        registry = 'gcr.io',
        repository = '',
        repository_file = ':%s_repository' % name,
        tag_file = ':%s_tag' % name,
        **kwargs
      )
    
    def _gcr_container_repository_impl(ctx):
        project_id = ctx.attr._project_id[BuildSettingInfo].value
        if len(project_id) == 0:
            fail("Please provide a GCP project ID via --//backend:project_id=<PROJECT ID>.")
        output = ctx.actions.declare_file(ctx.label.name + '_file')
        ctx.actions.write(output, paths.join(project_id, ctx.attr.path))
        return [DefaultInfo(files = depset([output]))]
    
    _gcr_container_repository = rule(
      impl = _gcr_container_repository_impl,
      attrs = {
        "path": attr.string(mandatory = True),
        "_project_id": attr.label(default = Label("//backend:project_id")),
      },
    )
    
    def _gcr_container_tag_impl(ctx):
        output = ctx.actions.declare_file(ctx.label.name + '_file')
        ctx.actions.write(output, ctx.attr._container_tag[BuildSettingInfo].value)
        return [DefaultInfo(files = depset([output]))]
    
    _gcr_container_tag = rule(
      impl = _gcr_container_tag_impl,
      attrs = {
        "path": attr.string(mandatory = True),
        "_container_tag": attr.label(default = Label("//backend:container_tag")),
      },
    )
    

    Your attempt is mixing a rule and a macro. Rules have attrs and an _impl vs macros can use other rules. My approach uses custom rules to generate the files, and a macro to tie those rules to container_push.

    The general answer to your question is that this requires modifying the rule to perform new kinds of substitutions based on a user-defined flag. I can see some kind of --@rules_docker//flags:docker_info=MY_PROJECT=foo configured with allow_multiple = True that would get substituted, but it definitely requires rule modifications. Wrapping the _impl is going to be tricky, because you have to reach in and change some of the actions.