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
84 changes: 82 additions & 2 deletions bzlmod/extensions.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,27 @@ def _elixir_config(ctx):
sha256 = elixir.sha256,
strip_prefix = elixir.strip_prefix,
build_file_content = """
load("@rules_elixir//private:elixir_build.bzl", "elixir_build")
load("@rules_elixir//private:elixir_build.bzl", "elixir_build", "elixir_prebuilt_tarball")

elixir_build(
name = "elixir_build",
srcs = glob(["**/*"]),
otp = "@@{erlang_config_repo}//{otp}:otp-{otp}",
visibility = ["//visibility:public"],
)

# Package the built runtime as a hostable tarball -- bazel build
# @elixir_source_{name}//:elixir_tarball -- for use with
# internal_elixir_from_prebuilt elsewhere, avoiding recompilation.
elixir_prebuilt_tarball(
name = "elixir_tarball",
elixir = ":elixir_build",
visibility = ["//visibility:public"],
)
""".format(
erlang_config_repo = erlang_config_repo,
otp = elixir.otp,
name = elixir.name,
),
)

Expand Down Expand Up @@ -101,14 +111,58 @@ elixir_build(
sha256 = elixir.sha256,
strip_prefix = strip_prefix,
build_file_content = """
load("@rules_elixir//private:elixir_build.bzl", "elixir_build")
load("@rules_elixir//private:elixir_build.bzl", "elixir_build", "elixir_prebuilt_tarball")

elixir_build(
name = "elixir_build",
srcs = glob(["**/*"]),
otp = "@@{erlang_config_repo}//{otp}:otp-{otp}",
visibility = ["//visibility:public"],
)

# Package the built runtime as a hostable tarball -- bazel build
# @elixir_source_{name}//:elixir_tarball -- for use with
# internal_elixir_from_prebuilt elsewhere, avoiding recompilation.
elixir_prebuilt_tarball(
name = "elixir_tarball",
elixir = ":elixir_build",
visibility = ["//visibility:public"],
)
""".format(
erlang_config_repo = erlang_config_repo,
otp = elixir.otp,
name = elixir.name,
),
)

for elixir in mod.tags.internal_elixir_from_prebuilt:
types[elixir.name] = INSTALLATION_TYPE_INTERNAL
versions[elixir.name] = elixir.version
urls[elixir.name] = elixir.url
strip_prefixs[elixir.name] = elixir.strip_prefix
sha256s[elixir.name] = elixir.sha256
target_compatible_withs[elixir.name] = [str(l) for l in elixir.target_compatible_with]
exec_compatible_withs[elixir.name] = [str(l) for l in elixir.exec_compatible_with]
if elixir.erlang_version:
erlang_versions[elixir.name] = elixir.erlang_version

# Fetch a *precompiled* Elixir release and stage it (no source build)
# via elixir_prebuilt. The target stays named "elixir_build" so the
# generated internal toolchain BUILD (BUILD_internal.tpl) resolves.
http_archive(
name = "elixir_source_{}".format(elixir.name),
url = elixir.url,
sha256 = elixir.sha256,
strip_prefix = elixir.strip_prefix,
build_file_content = """
load("@rules_elixir//private:elixir_build.bzl", "elixir_prebuilt")

elixir_prebuilt(
name = "elixir_build",
srcs = glob(["**/*"]),
otp = "@@{erlang_config_repo}//{otp}:otp-{otp}",
visibility = ["//visibility:public"],
)
""".format(
erlang_config_repo = erlang_config_repo,
otp = elixir.otp,
Expand Down Expand Up @@ -179,12 +233,38 @@ internal_elixir_from_github_release = tag_class(attrs = {
"target_compatible_with": attr.label_list(default = []),
})

internal_elixir_from_prebuilt = tag_class(attrs = {
"name": attr.string(),
"version": attr.string(),
"url": attr.string(
doc = "URL of a precompiled Elixir release archive.",
),
"strip_prefix": attr.string(),
"sha256": attr.string(),
# NOTE: we currently depend on both of these so that we can populate our
# templated BUILD file correctly, and don't have access to OtpInfo here
# (unless we accept a label, and give up lazy loading).
# TODO(denbeigh): i believe i can remove this with a more coherent
# toolchain aliasing strategy in rules_erlang
"otp": attr.string(
mandatory = True,
doc = "Name of an erlang_config installation to validate the prebuilt Elixir against.",
),
"erlang_version": attr.string(
doc = "OTP version string (e.g. '26.3') for combined platform constraints. " +
"Must match the version of the OTP installation named by the 'otp' attr.",
),
"exec_compatible_with": attr.label_list(default = []),
"target_compatible_with": attr.label_list(default = []),
})

elixir_config = module_extension(
implementation = _elixir_config,
tag_classes = {
"external_elixir_from_path": external_elixir_from_path,
"internal_elixir_from_http_archive": internal_elixir_from_http_archive,
"internal_elixir_from_github_release": internal_elixir_from_github_release,
"internal_elixir_from_prebuilt": internal_elixir_from_prebuilt,
},
)

Expand Down
14 changes: 14 additions & 0 deletions defs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ load(
"//private:elixir_build.bzl",
_elixir_build = "elixir_build",
_elixir_external = "elixir_external",
_elixir_prebuilt = "elixir_prebuilt",
_elixir_prebuilt_tarball = "elixir_prebuilt_tarball",
)
load("//private:elixir_release.bzl", _elixir_release = "elixir_release")
load("//private:elixir_release_bundle.bzl", _elixir_release_bundle = "elixir_release_bundle")
Expand Down Expand Up @@ -135,6 +137,18 @@ def ex_unit_test(**kwargs):
def elixir_external(**kwargs):
return _elixir_external(**kwargs)

def elixir_prebuilt(**kwargs):
return _elixir_prebuilt(**kwargs)

def elixir_prebuilt_tarball(**kwargs):
"""Packages a built Elixir runtime (bin/ + lib/) into a hostable .tar.gz.

Consume the result with elixir_config's internal_elixir_from_prebuilt tag,
which fetches it via http_archive and stages it through elixir_prebuilt --
closing the produce/consume loop for pre-built Elixir runtimes.
"""
return _elixir_prebuilt_tarball(**kwargs)

def elixir_toolchain(**kwargs):
return _elixir_toolchain(**kwargs)

Expand Down
198 changes: 161 additions & 37 deletions private/elixir_build.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,32 @@ ElixirInfo = provider(
],
)

def elixir_version_action(ctx, otp_info, elixir_home, version_file, inputs, mnemonic = "ELIXIRVERSION", progress_message = "Validating elixir"):
"""Run `iex --version` to validate an Elixir install and capture its version.

Shared by elixir_build / elixir_external / elixir_prebuilt / elixir_source_build:
the command is identical; callers vary only in inputs, elixir_home, and labels.
"""
ctx.actions.run_shell(
inputs = inputs,
outputs = [version_file],
command = """set -euo pipefail

{erl_rootdir_setup}

export PATH="{erlang_home}"/bin:${{PATH}}

"{elixir_home}"/bin/iex --version > {version_file}
""".format(
erl_rootdir_setup = otp_rootdir_setup(otp_info),
erlang_home = erlang_home(otp_info),
elixir_home = elixir_home,
version_file = version_file.path,
),
mnemonic = mnemonic,
progress_message = progress_message,
)

def _elixir_build_impl(ctx):
otp_info = ctx.attr.otp[OtpInfo]
release_dir = ctx.actions.declare_directory("elixir_release")
Expand Down Expand Up @@ -80,27 +106,12 @@ cp -r lib $ABS_RELEASE_DIR/
progress_message = "Building Elixir from source",
)

ctx.actions.run_shell(
inputs = depset(
direct = [release_dir],
transitive = [runfiles.files],
),
outputs = [version_file],
command = """set -euo pipefail

{erl_rootdir_setup}

export PATH="{erlang_home}"/bin:${{PATH}}

"{elixir_home}"/bin/iex --version > {version_file}
""".format(
erl_rootdir_setup = otp_rootdir_setup(otp_info),
erlang_home = erlang_home(otp_info),
elixir_home = release_dir.path,
version_file = version_file.path,
),
mnemonic = "ELIXIRVERSION",
progress_message = "Validating elixir",
elixir_version_action(
ctx,
otp_info,
release_dir.path,
version_file,
depset(direct = [release_dir], transitive = [runfiles.files]),
)

return [
Expand Down Expand Up @@ -136,22 +147,12 @@ def _elixir_external_impl(ctx):

runfiles = otp_runfiles(ctx, otp_info)

ctx.actions.run_shell(
inputs = runfiles.files,
outputs = [version_file],
command = """set -euo pipefail

{erl_rootdir_setup}

export PATH="{erlang_home}"/bin:${{PATH}}

"{elixir_home}"/bin/iex --version > {version_file}
""".format(
erl_rootdir_setup = otp_rootdir_setup(otp_info),
erlang_home = erlang_home(otp_info),
elixir_home = elixir_home,
version_file = version_file.path,
),
elixir_version_action(
ctx,
otp_info,
elixir_home,
version_file,
runfiles.files,
mnemonic = "ELIXIR",
progress_message = "Validating elixir at {}".format(elixir_home),
)
Expand Down Expand Up @@ -180,3 +181,126 @@ elixir_external = rule(
),
},
)

def _archive_root(files):
"""Longest common directory prefix of all paths -- the extracted archive root.

Stripping it makes a prebuilt Elixir's bin/ and lib/ land at the release_dir
root. Robust regardless of glob ordering (unlike using the first file's dir).
"""

# NOTE: Get the dirname of the first file to calculate the lowest common
# path prefix between all files. Chopping off the last element ensures we
# don't return the file itself, if we only have one file.
# We always provide `files.srcs` here, so we should never be called with a directory.
segs = files[0].path.split("/")[:-1]
for f in files[1:]:
other = f.path.split("/")
n = 0
for i in range(min(len(segs), len(other))):
if segs[i] != other[i]:
break
n += 1
segs = segs[:n]
return "/".join(segs)

def _elixir_prebuilt_impl(ctx):
otp_info = ctx.attr.otp[OtpInfo]
release_dir = ctx.actions.declare_directory("elixir_release")
version_file = ctx.actions.declare_file("elixir_version")

runfiles = otp_runfiles(ctx, otp_info)

# Stage the prebuilt Elixir release (bin/ + lib/) into a relocatable tree
# artifact -- like elixir_build, but extract instead of `make`.
ctx.actions.run_shell(
inputs = ctx.files.srcs,
outputs = [release_dir],
command = """set -euo pipefail

ABS_RELEASE_DIR=$PWD/{release_path}
# cp -rp, not a tar -h pipe (cf. erlang_release_archive): compiled Elixir has no
# symlinks that need resolving. archive_root holds bin/ + lib/; /. copies contents.
cp -rp "{archive_root}/." "$ABS_RELEASE_DIR/"
""".format(
release_path = release_dir.path,
archive_root = _archive_root(ctx.files.srcs) if ctx.files.srcs else ".",
),
mnemonic = "ELIXIRPREBUILT",
progress_message = "Staging prebuilt Elixir",
)

elixir_version_action(
ctx,
otp_info,
release_dir.path,
version_file,
depset(direct = [release_dir], transitive = [runfiles.files]),
progress_message = "Validating prebuilt elixir",
)

return [
DefaultInfo(files = depset([release_dir, version_file])),
otp_info,
ElixirInfo(
release_dir = release_dir,
elixir_home = None,
version_file = version_file,
),
]

elixir_prebuilt = rule(
implementation = _elixir_prebuilt_impl,
attrs = {
"srcs": attr.label_list(
allow_files = True,
doc = "Extracted prebuilt Elixir release tree (bin/, lib/).",
),
"otp": attr.label(
mandatory = True,
providers = [OtpInfo],
doc = "An OTP target providing the installation to validate against.",
),
},
)

def _elixir_prebuilt_tarball_impl(ctx):
info = ctx.attr.elixir[ElixirInfo]
if info.release_dir == None:
fail("elixir_prebuilt_tarball requires a relocatable Elixir (release_dir set); " +
"external installs (elixir_home) cannot be packaged.")

tarball = ctx.actions.declare_file(ctx.label.name + ".tar.gz")

ctx.actions.run_shell(
inputs = [info.release_dir],
outputs = [tarball],
command = """set -euo pipefail

# -h dereferences symlinks so the archive has none (RBE-robust, matches
# rules_erlang's erlang_build). -C <dir> . puts bin/ + lib/ at the tar root,
# which is what internal_elixir_from_prebuilt / elixir_prebuilt expect.
tar -czhf "$PWD/{out}" -C "{release_dir}" .
""".format(
out = tarball.path,
release_dir = info.release_dir.path,
),
use_default_shell_env = True,
mnemonic = "ELIXIRTARBALL",
progress_message = "Packaging prebuilt Elixir tarball",
)

return [DefaultInfo(files = depset([tarball]))]

elixir_prebuilt_tarball = rule(
implementation = _elixir_prebuilt_tarball_impl,
attrs = {
"elixir": attr.label(
mandatory = True,
providers = [ElixirInfo],
doc = "A target providing ElixirInfo (e.g. an elixir_build/elixir_prebuilt " +
"target such as @elixir_source_<name>//:elixir_build). Its release_dir " +
"(bin/ + lib/) is packaged into a .tar.gz for internal_elixir_from_prebuilt.",
),
},
)
Loading