Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 57 additions & 25 deletions erlang/private/hermetic_erlang_repository.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,52 @@ _ARCH = {
def _sha256_key(os_key, arch):
return "{}_{}".format(os_key, arch)

def resolve_host(repository_ctx):
"""Determine the OS/arch/platform-constraint quadruple for a repository_ctx's host.

Pulled out of _hermetic_erlang_repository_impl so it can be unit-tested (test/unit/
hermetic_erlang_repository) against plain fake structs, without needing a real
repository_ctx or a network download -- the same trick host_repo.bzl's host_platform uses.

Args:
repository_ctx: a repository_ctx, or any struct exposing the same `.os.name`/`.os.arch`
fields (e.g. a fake used by tests).

Returns:
A struct with fields:
os_key: "linux" or "macos" (used to key the sha256 dict and pick a download URL shape)
arch: "arm64" or "amd64" (erlef's origin-naming convention, not necessarily
repository_ctx.os.arch's raw spelling)
os_constraint: a "@platforms//os:..." label for the toolchain() definition
cpu_constraint: a "@platforms//cpu:..." label for the toolchain() definition
"""
os_name = repository_ctx.os.name.lower()
arch = repository_ctx.os.arch

if "linux" in os_name:
os_key = "linux"
os_label = "Linux"
os_constraint = "@platforms//os:linux"
elif "mac os" in os_name:
os_key = "macos"
os_label = "macOS"
os_constraint = "@platforms//os:osx"
else:
fail("Hermetic Erlang/OTP toolchain does not support OS: {}".format(repository_ctx.os.name))

normalized_arch = _ARCH.get(arch)
if not normalized_arch:
fail("Unsupported CPU architecture for hermetic Erlang/OTP on {}: {}".format(os_label, arch))

cpu_constraint = "@platforms//cpu:arm64" if normalized_arch == "arm64" else "@platforms//cpu:x86_64"

return struct(
os_key = os_key,
arch = normalized_arch,
os_constraint = os_constraint,
cpu_constraint = cpu_constraint,
)

def _find_executable(repository_ctx, root, name):
result = repository_ctx.execute(["find", str(root), "-type", "f", "-name", name, "-path", "*/bin/" + name])
lines = [line for line in result.stdout.strip().split("\n") if line]
Expand All @@ -46,23 +92,19 @@ def _hermetic_erlang_repository_impl(repository_ctx):
otp_tag = "OTP-" + otp_version
sha256_map = repository_ctx.attr.sha256

os_name = repository_ctx.os.name.lower()
arch = repository_ctx.os.arch

if "linux" in os_name:
os_key = "linux"
linux_arch = _ARCH.get(arch)
if not linux_arch:
fail("Unsupported CPU architecture for hermetic Erlang/OTP on Linux: {}".format(arch))
os_constraint = "@platforms//os:linux"
cpu_constraint = "@platforms//cpu:arm64" if linux_arch == "arm64" else "@platforms//cpu:x86_64"
host = resolve_host(repository_ctx)
os_key = host.os_key
arch = host.arch
os_constraint = host.os_constraint
cpu_constraint = host.cpu_constraint

if os_key == "linux":
url = "https://builds.hex.pm/builds/otp/{arch}/{os_version}/{tag}.tar.gz".format(
arch = linux_arch,
arch = arch,
os_version = repository_ctx.attr.os_version,
tag = otp_tag,
)
checksum_key = _sha256_key(os_key, linux_arch)
checksum_key = _sha256_key(os_key, arch)
expected_sha256 = sha256_map.get(checksum_key, "")

download = repository_ctx.download(
Expand Down Expand Up @@ -97,19 +139,12 @@ def _hermetic_erlang_repository_impl(repository_ctx):
if install_result.return_code != 0:
fail("Erlang/OTP 'Install -minimal' script failed: " + install_result.stderr)

elif "mac os" in os_name:
os_key = "macos"
macos_arch = _ARCH.get(arch)
if not macos_arch:
fail("Unsupported CPU architecture for hermetic Erlang/OTP on macOS: {}".format(arch))
os_constraint = "@platforms//os:osx"
cpu_constraint = "@platforms//cpu:arm64" if macos_arch == "arm64" else "@platforms//cpu:x86_64"

else: # os_key == "macos"
url = "https://github.com/erlef/otp_builds/releases/download/{tag}/{tag}-macos-{arch}.tar.gz".format(
tag = otp_tag,
arch = macos_arch,
arch = arch,
)
checksum_key = _sha256_key(os_key, macos_arch)
checksum_key = _sha256_key(os_key, arch)
expected_sha256 = sha256_map.get(checksum_key, "")

download = repository_ctx.download(
Expand All @@ -133,9 +168,6 @@ def _hermetic_erlang_repository_impl(repository_ctx):

otp_root = repository_ctx.path("otp")

else:
fail("Hermetic Erlang/OTP toolchain does not support OS: {}".format(repository_ctx.os.name))

erl_path = _find_executable(repository_ctx, otp_root, "erl")
erlc_path = _find_executable(repository_ctx, otp_root, "erlc")
escript_path = _find_executable(repository_ctx, otp_root, "escript")
Expand Down
3 changes: 3 additions & 0 deletions test/unit/hermetic_erlang_repository/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
load(":hermetic_erlang_repository_test.bzl", "hermetic_erlang_repository_test_suite")

hermetic_erlang_repository_test_suite(name = "hermetic_erlang_repository_test_suite")
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
"""Unit tests for erlang/private/hermetic_erlang_repository.bzl's resolve_host.

resolve_host only ever reads `.os.name`/`.os.arch` off whatever it's given, so a plain struct
stands in for a real repository_ctx without needing any Bazel machinery or a network download --
same trick test/unit/host_repo/host_repo_test.bzl uses for gleam_host's own, separate
os/arch-mapping table.

This table is exactly where the original macOS hermetic-Erlang arch-naming bug lived (a real
production bug found via a real CI failure, not caught by any test, since none existed for this
file) -- these tests exist so a repeat of that class of mistake fails fast and locally instead.
"""

load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts", "unittest")
load("//erlang/private:hermetic_erlang_repository.bzl", "resolve_host") # buildifier: disable=bzl-visibility

def _fake_repository_ctx(os_name, os_arch):
return struct(os = struct(name = os_name, arch = os_arch))

def _resolves_linux_x86_64_test_impl(ctx):
env = unittest.begin(ctx)
host = resolve_host(_fake_repository_ctx("linux", "x86_64"))
asserts.equals(env, "linux", host.os_key)
asserts.equals(env, "amd64", host.arch)
asserts.equals(env, "@platforms//os:linux", host.os_constraint)
asserts.equals(env, "@platforms//cpu:x86_64", host.cpu_constraint)
return unittest.end(env)

resolves_linux_x86_64_test = unittest.make(_resolves_linux_x86_64_test_impl)

def _resolves_linux_amd64_spelling_test_impl(ctx):
env = unittest.begin(ctx)

# "amd64" is the same value os.arch reports as "x86_64" is normalized to -- confirm the
# already-normalized spelling is also accepted (idempotent), not just the raw one.
host = resolve_host(_fake_repository_ctx("linux", "amd64"))
asserts.equals(env, "amd64", host.arch)
asserts.equals(env, "@platforms//cpu:x86_64", host.cpu_constraint)
return unittest.end(env)

resolves_linux_amd64_spelling_test = unittest.make(_resolves_linux_amd64_spelling_test_impl)

def _resolves_linux_aarch64_test_impl(ctx):
env = unittest.begin(ctx)
host = resolve_host(_fake_repository_ctx("linux", "aarch64"))
asserts.equals(env, "linux", host.os_key)
asserts.equals(env, "arm64", host.arch)
asserts.equals(env, "@platforms//cpu:arm64", host.cpu_constraint)
return unittest.end(env)

resolves_linux_aarch64_test = unittest.make(_resolves_linux_aarch64_test_impl)

def _resolves_linux_arm64_spelling_test_impl(ctx):
env = unittest.begin(ctx)

# Real Bazel/OS combinations have been observed reporting "arm64" (not "aarch64") for
# repository_ctx.os.arch on Linux too -- confirm both spellings normalize the same way.
host = resolve_host(_fake_repository_ctx("linux", "arm64"))
asserts.equals(env, "arm64", host.arch)
asserts.equals(env, "@platforms//cpu:arm64", host.cpu_constraint)
return unittest.end(env)

resolves_linux_arm64_spelling_test = unittest.make(_resolves_linux_arm64_spelling_test_impl)

def _resolves_macos_x86_64_test_impl(ctx):
env = unittest.begin(ctx)
host = resolve_host(_fake_repository_ctx("mac os x", "x86_64"))
asserts.equals(env, "macos", host.os_key)
asserts.equals(env, "amd64", host.arch)
asserts.equals(env, "@platforms//os:osx", host.os_constraint)
asserts.equals(env, "@platforms//cpu:x86_64", host.cpu_constraint)
return unittest.end(env)

resolves_macos_x86_64_test = unittest.make(_resolves_macos_x86_64_test_impl)

def _resolves_macos_amd64_spelling_test_impl(ctx):
env = unittest.begin(ctx)
host = resolve_host(_fake_repository_ctx("mac os x", "amd64"))
asserts.equals(env, "amd64", host.arch)
asserts.equals(env, "@platforms//cpu:x86_64", host.cpu_constraint)
return unittest.end(env)

resolves_macos_amd64_spelling_test = unittest.make(_resolves_macos_amd64_spelling_test_impl)

def _resolves_macos_aarch64_test_impl(ctx):
env = unittest.begin(ctx)
host = resolve_host(_fake_repository_ctx("mac os x", "aarch64"))
asserts.equals(env, "macos", host.os_key)
asserts.equals(env, "arm64", host.arch)
asserts.equals(env, "@platforms//cpu:arm64", host.cpu_constraint)
return unittest.end(env)

resolves_macos_aarch64_test = unittest.make(_resolves_macos_aarch64_test_impl)

def _resolves_macos_arm64_spelling_test_impl(ctx):
env = unittest.begin(ctx)

# This is the exact spelling real macOS (Apple Silicon) Bazel runners report for
# repository_ctx.os.arch -- the original production bug this whole test file exists to
# prevent a repeat of was this normalization being missing/wrong for exactly this case.
host = resolve_host(_fake_repository_ctx("mac os x", "arm64"))
asserts.equals(env, "arm64", host.arch)
asserts.equals(env, "@platforms//cpu:arm64", host.cpu_constraint)
return unittest.end(env)

resolves_macos_arm64_spelling_test = unittest.make(_resolves_macos_arm64_spelling_test_impl)

def _fails_on_unsupported_arch_test_impl(ctx):
env = analysistest.begin(ctx)
asserts.expect_failure(env, "Unsupported CPU architecture")
return analysistest.end(env)

def _unsupported_arch_lookup_impl(_ctx):
resolve_host(_fake_repository_ctx("linux", "riscv64"))
return [DefaultInfo()]

_unsupported_arch_lookup_rule = rule(implementation = _unsupported_arch_lookup_impl)

_fails_on_unsupported_arch_test = analysistest.make(_fails_on_unsupported_arch_test_impl, expect_failure = True)

def _fails_on_unsupported_os_test_impl(ctx):
env = analysistest.begin(ctx)
asserts.expect_failure(env, "does not support OS")
return analysistest.end(env)

def _unsupported_os_lookup_impl(_ctx):
resolve_host(_fake_repository_ctx("plan9", "x86_64"))
return [DefaultInfo()]

_unsupported_os_lookup_rule = rule(implementation = _unsupported_os_lookup_impl)

_fails_on_unsupported_os_test = analysistest.make(_fails_on_unsupported_os_test_impl, expect_failure = True)

def hermetic_erlang_repository_test_suite(name):
"""Registers all hermetic_erlang_repository unit tests as a single test_suite target.

Args:
name: name of the generated test_suite.
"""
_unsupported_arch_lookup_rule(name = "unsupported_arch_lookup", tags = ["manual"])
_fails_on_unsupported_arch_test(
name = "fails_on_unsupported_arch_test",
target_under_test = ":unsupported_arch_lookup",
)
_unsupported_os_lookup_rule(name = "unsupported_os_lookup", tags = ["manual"])
_fails_on_unsupported_os_test(
name = "fails_on_unsupported_os_test",
target_under_test = ":unsupported_os_lookup",
)
unittest.suite(
"hermetic_erlang_repository_pure_tests",
resolves_linux_x86_64_test,
resolves_linux_amd64_spelling_test,
resolves_linux_aarch64_test,
resolves_linux_arm64_spelling_test,
resolves_macos_x86_64_test,
resolves_macos_amd64_spelling_test,
resolves_macos_aarch64_test,
resolves_macos_arm64_spelling_test,
)
native.test_suite(
name = name,
tests = [
":hermetic_erlang_repository_pure_tests",
":fails_on_unsupported_arch_test",
":fails_on_unsupported_os_test",
],
)
Loading