blob: 90499c361d3542f6b0f120b0dcb2aa4442ad25e3 [file]
# Copyright 2023 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
load("@io_bazel_rules_go_bazel_features//:features.bzl", "bazel_features")
load("//go/private:go_mod.bzl", "version_from_go_mod", "version_from_go_work")
load("//go/private:nogo.bzl", "DEFAULT_NOGO", "NOGO_DEFAULT_EXCLUDES", "NOGO_DEFAULT_INCLUDES", "go_register_nogo")
load("//go/private:sdk.bzl", "detect_host_platform", "fetch_sdks_by_version", "go_download_sdk_rule", "go_host_sdk_rule", "go_multiple_toolchains", "go_wrap_sdk_rule")
def host_compatible_toolchain_impl(ctx):
ctx.file("BUILD.bazel")
ctx.file("defs.bzl", content = """
HOST_COMPATIBLE_SDK = Label({})
""".format(repr(ctx.attr.toolchain)))
host_compatible_toolchain = repository_rule(
implementation = host_compatible_toolchain_impl,
attrs = {
# We cannot use attr.label for the `toolchain` attribute since the module extension cannot
# refer to the repositories it creates by their apparent repository names.
"toolchain": attr.string(
doc = "The apparent label of a `ROOT` file in the repository of a host compatible toolchain created by the `go_sdk` extension",
mandatory = True,
),
},
doc = "An external repository to expose the first host compatible toolchain",
)
_COMMON_TAG_ATTRS = {
"name": attr.string(),
"goos": attr.string(),
"goarch": attr.string(),
"sdks": attr.string_list_dict(),
"experiments": attr.string_list(
doc = "Go experiments to enable via GOEXPERIMENT",
),
"urls": attr.string_list(default = ["https://dl.google.com/go/{}"]),
"patches": attr.label_list(
doc = "A list of patches to apply to the SDK after downloading it",
),
"patch_strip": attr.int(
default = 0,
doc = "The number of leading path segments to be stripped from the file name in the patches.",
),
"strip_prefix": attr.string(default = "go"),
}
_download_tag = tag_class(
doc = """Download a specific Go SDK at the optional GOOS, GOARCH, and version, from a customisable URL. Optionally apply local customisations to the SDK by applying patches and setting experiments.""",
attrs = _COMMON_TAG_ATTRS | {
"version": attr.string(),
},
)
_host_tag = tag_class(
attrs = {
"name": attr.string(),
"version": attr.string(),
"experiments": attr.string_list(
doc = "Go experiments to enable via GOEXPERIMENT",
),
},
)
_nogo_tag = tag_class(
attrs = {
"nogo": attr.label(
doc = "The nogo target to use when this module is the root module.",
),
"includes": attr.label_list(
default = NOGO_DEFAULT_INCLUDES,
# The special include "all" is undocumented on purpose: With it, adding a new transitive
# dependency to a Go module can cause a build failure if the new dependency has lint
# issues.
doc = """
A Go target is checked with nogo if its package matches at least one of the entries in 'includes'
and none of the entries in 'excludes'. By default, nogo is applied to all targets in the main
repository.
Uses the same format as 'visibility', i.e., every entry must be a label that ends with ':__pkg__' or
':__subpackages__'.
""",
),
"excludes": attr.label_list(
default = NOGO_DEFAULT_EXCLUDES,
doc = "See 'includes'.",
),
},
)
# string_keyed_label_dict was added in 8.0.0
_maybe_string_keyed_label_dict = getattr(
attr,
"string_keyed_label_dict",
attr.string_dict,
)
_wrap_tag = tag_class(
attrs = {
"root_file": attr.label(
mandatory = False,
doc = "A file in the SDK root directory. Use to determine GOROOT.",
),
"root_files": _maybe_string_keyed_label_dict(
mandatory = False,
doc = "A set of mappings from the host platform to a file in the SDK's root directory.",
),
"version": attr.string(),
"experiments": attr.string_list(
doc = "Go experiments to enable via GOEXPERIMENT.",
),
"goos": attr.string(),
"goarch": attr.string(),
},
)
_from_file_tag = tag_class(
doc = """Use a specific Go SDK version described by a `go.mod` or `go.work` file. Optionally supply GOOS, GOARCH, and download from a customisable URL, and apply local patches or set experiments.""",
attrs = _COMMON_TAG_ATTRS | {
"go_mod": attr.label(
doc = "The go.mod file to read the SDK version from.",
),
"go_work": attr.label(
doc = "The go.work file to read the SDK version from.",
),
},
)
# A list of (goos, goarch) pairs that are commonly used for remote executors in cross-platform
# builds (where host != exec platform). By default, we register toolchains for all of these
# platforms in addition to the host platform.
_COMMON_EXEC_PLATFORMS = [
("darwin", "amd64"),
("darwin", "arm64"),
("linux", "amd64"),
("linux", "arm64"),
("windows", "amd64"),
("windows", "arm64"),
]
# This limit can be increased essentially arbitrarily, but doing so will cause a rebuild of all
# targets using any of these toolchains due to the changed repository name.
_MAX_NUM_TOOLCHAINS = 9999
_TOOLCHAIN_INDEX_PAD_LENGTH = len(str(_MAX_NUM_TOOLCHAINS))
def _go_sdk_impl(ctx):
nogo_tag = struct(
nogo = DEFAULT_NOGO,
includes = NOGO_DEFAULT_INCLUDES,
excludes = NOGO_DEFAULT_EXCLUDES,
)
for module in ctx.modules:
if not module.is_root or not module.tags.nogo:
continue
if len(module.tags.nogo) > 1:
# Make use of the special formatting applied to tags by fail.
fail(
"go_sdk.nogo: only one tag can be specified per module, got:\n",
*[t for p in zip(module.tags.nogo, len(module.tags.nogo) * ["\n"]) for t in p]
)
nogo_tag = module.tags.nogo[0]
for scope in nogo_tag.includes + nogo_tag.excludes:
# Validate that the scope references a valid, visible repository.
# buildifier: disable=no-effect
scope.repo_name
if scope.name != "__pkg__" and scope.name != "__subpackages__":
fail(
"go_sdk.nogo: all entries in includes and excludes must end with ':__pkg__' or ':__subpackages__', got '{}' in".format(scope.name),
nogo_tag,
)
go_register_nogo(
name = "io_bazel_rules_nogo",
nogo = str(nogo_tag.nogo),
# Go through canonical label literals to avoid a dependency edge on the packages in the
# scope.
includes = [str(l) for l in nogo_tag.includes],
excludes = [str(l) for l in nogo_tag.excludes],
)
multi_version_module = {}
for module in ctx.modules:
if module.name in multi_version_module:
multi_version_module[module.name] = True
else:
multi_version_module[module.name] = False
# We remember the first host compatible toolchain declared by the download, host, and from_file tags.
# The order follows bazel's iteration over modules (the toolchains declared by the root module are considered first).
# We know that at least `go_default_sdk` (which is declared by the `rules_go` module itself) is host compatible.
first_host_compatible_toolchain = None
host_detected_goos, host_detected_goarch = detect_host_platform(ctx)
toolchains = []
all_sdks_by_version = {}
used_sdks_by_version = {}
facts = getattr(ctx, "facts", {})
def get_sdks_by_version_cached(version):
# Avoid a download without a known digest in the SDK repo rule by fetching the SDKs filename
# and digest here. When using a version of Bazel that supports module extension facts, this
# info will be persisted in the lockfile, allowing for truly airgapped builds with an
# up-to-date lockfile and download (formerly repository) cache.
sdks = facts.get(version)
if sdks == None:
# Lazily fetch the information about all SDKs so that we avoid the download if the facts
# already contain all the versions we care about. We take care to only do this once and
# also accept failures to support airgapped builds: the user may have set sdk hashes on
# all SDK repos they actually intend to use, but others (e.g., the default SDK added by
# rules_go) trigger this path even if they would never be selected by toolchain
# resolution. We must not break those builds.
if not all_sdks_by_version:
all_sdks_by_version.clear()
all_sdks_by_version.update(fetch_sdks_by_version(ctx, allow_fail = True) or {
"fetch_failed_but_should_not_fetch_again_sentinel": [],
})
sdks = all_sdks_by_version.get(version)
if sdks == None:
# This is either caused by an invalid version or because we are in an airgapped build
# and the version wasn't present in facts. Since we don't want to fail in the latter
# case, we leave it to the repository rule to report a useful error message.
return None
used_sdks_by_version[version] = sdks
return sdks
for module in ctx.modules:
# Apply wrapped toolchains first to override specific platforms from the
# default toolchain or any downloads.
for index, wrap_tag in enumerate(module.tags.wrap):
name = _default_go_sdk_name(
module = module,
multi_version = multi_version_module[module.name],
tag_type = "wrap",
index = index,
)
go_wrap_sdk_rule(
name = name,
root_file = wrap_tag.root_file,
root_files = wrap_tag.root_files,
version = wrap_tag.version,
experiments = wrap_tag.experiments,
)
toolchains.append(struct(
goos = wrap_tag.goos,
goarch = wrap_tag.goarch,
sdk_repo = name,
sdk_type = "remote",
sdk_version = wrap_tag.version,
))
if (not wrap_tag.goos or wrap_tag.goos == host_detected_goos) and (not wrap_tag.goarch or wrap_tag.goarch == host_detected_goarch):
first_host_compatible_toolchain = first_host_compatible_toolchain or "@{}//:ROOT".format(name)
additional_download_tags = []
# If the module suggests to read the toolchain version from a `go.mod` or `go.work` file, use that.
for index, from_file_tag in enumerate(module.tags.from_file):
if from_file_tag.go_mod and from_file_tag.go_work:
fail("go_sdk.from_file: either go_mod or go_work must be specified, but not both")
elif from_file_tag.go_mod:
version = version_from_go_mod(ctx, from_file_tag.go_mod)
elif from_file_tag.go_work:
version = version_from_go_work(ctx, from_file_tag.go_work)
else:
fail("go_sdk.from_file: either go_mod or go_work must be specified")
# Synthesize a `download` tag so we can reuse the selection logic below.
download_tag = {
key: getattr(from_file_tag, key)
for key in dir(from_file_tag)
if key not in ["go_mod", "go_work"]
}
download_tag["version"] = version
additional_download_tags.append(struct(**download_tag))
# We handle the `additional_download_tags` first so that `from_file` takes precedence
# over extra SDKs specified with `download`. That way the `from_file` toolchains are registered
# with higher precedence and become default, while `download`'ed toolchains can still be
# requested explicitly.
# TODO(zbarsky/fmeum): This is still not the ideal ordering. We should respect the order that tags are
# specified in, but Bzlmod currently doesn't provide this information across tag classes.
for index, download_tag in enumerate(additional_download_tags + module.tags.download):
# SDKs without an explicit version are fetched even when not selected by toolchain
# resolution. This is acceptable if brought in by the root module, but transitive
# dependencies should not slow down the build in this way.
if not module.is_root and not download_tag.version:
fail("go_sdk.download: version must be specified in non-root module " + module.name)
# SDKs with an explicit name are at risk of colliding with those from other modules.
# This is acceptable if brought in by the root module as the user is responsible for any
# conflicts that arise. rules_go itself provides "go_default_sdk".
# TODO: Now that Gazelle relies on the go_host_compatible_sdk_label repo, remove the
# special case for "go_default_sdk". Users should migrate to @rules_go//go.
if (not module.is_root and not module.name == "rules_go") and download_tag.name:
fail("go_sdk.download: name must not be specified in non-root module " + module.name)
name = download_tag.name or _default_go_sdk_name(
module = module,
multi_version = multi_version_module[module.name],
tag_type = "download",
index = index,
)
_download_sdk(
get_sdks_by_version = get_sdks_by_version_cached,
name = name,
goos = download_tag.goos,
goarch = download_tag.goarch,
download_tag = download_tag,
)
if (not download_tag.goos or download_tag.goos == host_detected_goos) and (not download_tag.goarch or download_tag.goarch == host_detected_goarch):
first_host_compatible_toolchain = first_host_compatible_toolchain or "@{}//:ROOT".format(name)
toolchains.append(struct(
goos = download_tag.goos,
goarch = download_tag.goarch,
sdk_repo = name,
sdk_type = "remote",
sdk_version = download_tag.version,
))
# Additionally register SDKs for all common execution platforms, but only if the user
# specified a version to prevent eager fetches.
if download_tag.version and not download_tag.goos and not download_tag.goarch:
for goos, goarch in _COMMON_EXEC_PLATFORMS:
if goos == host_detected_goos and goarch == host_detected_goarch:
# We already added the host-compatible toolchain above.
continue
if download_tag.sdks and not "{}_{}".format(goos, goarch) in download_tag.sdks:
# The user supplied custom download links, but not for this tuple.
continue
default_name = _default_go_sdk_name(
module = module,
multi_version = multi_version_module[module.name],
tag_type = "download",
index = index,
suffix = "_{}_{}".format(goos, goarch),
)
_download_sdk(
get_sdks_by_version = get_sdks_by_version_cached,
name = default_name,
goos = goos,
goarch = goarch,
download_tag = download_tag,
)
toolchains.append(struct(
goos = goos,
goarch = goarch,
sdk_repo = default_name,
sdk_type = "remote",
sdk_version = download_tag.version,
))
for index, host_tag in enumerate(module.tags.host):
# Dependencies can rely on rules_go providing a default remote SDK. They can also
# configure a specific version of the SDK to use. However, they should not add a
# dependency on the host's Go SDK.
if not module.is_root:
fail("go_sdk.host: cannot be used in non-root module {}, consider using use_extension(..., dev_dependency = True)".format(module.name))
name = host_tag.name or _default_go_sdk_name(
module = module,
multi_version = multi_version_module[module.name],
tag_type = "host",
index = index,
)
go_host_sdk_rule(
name = name,
version = host_tag.version,
experiments = host_tag.experiments,
)
toolchains.append(struct(
goos = "",
goarch = "",
sdk_repo = name,
sdk_type = "host",
sdk_version = host_tag.version,
))
first_host_compatible_toolchain = first_host_compatible_toolchain or "@{}//:ROOT".format(name)
host_compatible_toolchain(name = "go_host_compatible_sdk_label", toolchain = first_host_compatible_toolchain)
if len(toolchains) > _MAX_NUM_TOOLCHAINS:
fail("more than {} go_sdk tags are not supported".format(_MAX_NUM_TOOLCHAINS))
# Toolchains in a BUILD file are registered in the order given by name, not in the order they
# are declared:
# https://cs.opensource.google/bazel/bazel/+/master:src/main/java/com/google/devtools/build/lib/packages/Package.java;drc=8e41dce65b97a3d466d6b1e65005abc52a07b90b;l=156
# We pad with an index that lexicographically sorts in the same order as if these toolchains
# were registered using register_toolchains in their MODULE.bazel files.
go_multiple_toolchains(
name = "go_toolchains",
prefixes = [
_toolchain_prefix(index, toolchain.sdk_repo)
for index, toolchain in enumerate(toolchains)
],
geese = [toolchain.goos for toolchain in toolchains],
goarchs = [toolchain.goarch for toolchain in toolchains],
sdk_repos = [toolchain.sdk_repo for toolchain in toolchains],
sdk_types = [toolchain.sdk_type for toolchain in toolchains],
sdk_versions = [toolchain.sdk_version for toolchain in toolchains],
)
if bazel_features.external_deps.extension_metadata_has_reproducible:
kwargs = {
"reproducible": True,
}
# See get_sdks_by_version_cached above for details on these facts.
if hasattr(ctx, "facts"):
kwargs["facts"] = used_sdks_by_version
return ctx.extension_metadata(**kwargs)
else:
return None
def _default_go_sdk_name(*, module, multi_version, tag_type, index, suffix = ""):
# Keep the version and name of the root module out of the repository name if possible to
# prevent unnecessary rebuilds when it changes.
return "{name}_{version}_{tag_type}_{index}{suffix}".format(
# "main_" is not a valid module name and thus can't collide.
name = "main_" if module.is_root else module.name,
version = module.version if multi_version else "",
tag_type = tag_type,
index = index,
suffix = suffix,
)
def _toolchain_prefix(index, name):
"""Prefixes the given name with the index, padded with zeros to ensure lexicographic sorting.
Examples:
_toolchain_prefix( 2, "foo") == "_0002_foo_"
_toolchain_prefix(2000, "foo") == "_2000_foo_"
"""
return "_{}_{}_".format(_left_pad_zero(index, _TOOLCHAIN_INDEX_PAD_LENGTH), name)
def _left_pad_zero(index, length):
if index < 0:
fail("index must be non-negative")
return ("0" * length + str(index))[-length:]
def _download_sdk(*, get_sdks_by_version, name, goos, goarch, download_tag):
version = download_tag.version
sdks = download_tag.sdks
if version and not sdks:
sdks = get_sdks_by_version(version)
go_download_sdk_rule(
name = name,
goos = goos,
goarch = goarch,
sdks = sdks,
experiments = download_tag.experiments,
patches = download_tag.patches,
patch_strip = download_tag.patch_strip,
urls = download_tag.urls,
version = download_tag.version,
strip_prefix = download_tag.strip_prefix,
)
go_sdk_extra_kwargs = {
# The choice of a host-compatible SDK is expressed in repository rule attribute values and
# depends on host OS and architecture.
"os_dependent": True,
"arch_dependent": True,
} if bazel_features.external_deps.module_extension_has_os_arch_dependent else {}
go_sdk = module_extension(
implementation = _go_sdk_impl,
tag_classes = {
"download": _download_tag,
"host": _host_tag,
"nogo": _nogo_tag,
"wrap": _wrap_tag,
"from_file": _from_file_tag,
},
**go_sdk_extra_kwargs
)