Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
17 changes: 17 additions & 0 deletions bazel/rules/macos/pkg/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""Macos packaging rules."""

load("@rules_pkg//pkg:mappings.bzl", "pkg_filegroup", "pkg_files", "pkg_mklink")
load("@rules_python//python:py_binary.bzl", "py_binary")
load(":pkg_mac_pkg.bzl", "pkg_mac_pkg")
load(":pkg_mac_pkg_test.bzl", "pkg_mac_pkg_test_suite")

package(default_visibility = ["//visibility:private"])

pkg_mac_pkg_test_suite(name = "pkg_mac_pkg_tests")

py_binary(
name = "build_mac_pkg",
srcs = ["build_mac_pkg.py"],
tags = ["manual"],
visibility = ["//visibility:public"],
)
85 changes: 85 additions & 0 deletions bazel/rules/macos/pkg/build_mac_pkg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Wraps macOS `pkgbuild` so it can be invoked as a Bazel action.

Bazel presents action inputs (including declare_directory outputs like
--root) as a symlink farm inside the sandbox, wrapping every payload entry in
a symlink pointing at its real, ephemeral sandbox/exec-root/cache location.
`pkgbuild` faithfully packages whatever it finds under --root, including
symlink-ness, so a naive copy either ships broken symlinks (plain
`cp -R`) or, if symlinks are blindly dereferenced (`cp -RL`), loses two things
pkg_install's NativeInstaller deliberately set: exact permission bits (a plain
`cp` applies umask to the new file's mode instead of copying the source mode)
and intentional payload symlinks (e.g. pkg_mklink entries), which get
flattened into duplicate regular files.

materialize_root.py resolves exactly the sandbox's own indirection layer
(dereferencing until it hits either real content or the payload's own
intended symlink) and preserves source file modes explicitly, so it is not
subject to the umask under which this action runs.
"""

import argparse
import os
import shutil
import subprocess
import sys
import tempfile


def _install_script(src, dst):
shutil.copyfile(src, dst)
os.chmod(dst, 0o755)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--materialize-root-py", required=True, help="Path to materialize_root.py.")
parser.add_argument("--root", required=True, help="pkg_install-materialized root directory (symlink farm).")
parser.add_argument("--pkgbuild", required=True, help="Path to the pkgbuild binary.")
parser.add_argument("--identifier", required=True, help="pkgbuild --identifier.")
parser.add_argument("--version", required=True, help="pkgbuild --version.")
parser.add_argument("--install-location", required=True, help="pkgbuild --install-location.")
parser.add_argument("--output", required=True, help="Path to write the built .pkg to.")
parser.add_argument("--preinstall", default="", help="Optional path to a preinstall script.")
parser.add_argument("--postinstall", default="", help="Optional path to a postinstall script.")
parser.add_argument("--signing-identity", default="", help="Optional pkgbuild --sign identity name.")

args = parser.parse_args()

real_root_dir = tempfile.mkdtemp()
scripts_dir = tempfile.mkdtemp()
try:
subprocess.run(
[sys.executable, args.materialize_root_py, args.root, real_root_dir],
check=True,
)

# fmt: off
pkgbuild_args = [
args.pkgbuild,
"--root", real_root_dir,
"--identifier", args.identifier,
"--version", args.version,
"--install-location", args.install_location,
]

if args.preinstall or args.postinstall:
if args.preinstall:
_install_script(args.preinstall, os.path.join(scripts_dir, "preinstall"))
if args.postinstall:
_install_script(args.postinstall, os.path.join(scripts_dir, "postinstall"))
pkgbuild_args += ["--scripts", scripts_dir]

if args.signing_identity:
pkgbuild_args += ["--sign", args.signing_identity]

os.makedirs(os.path.dirname(args.output), exist_ok=True)
pkgbuild_args.append(args.output)
subprocess.run(pkgbuild_args, check=True)
finally:
shutil.rmtree(real_root_dir, ignore_errors=True)
shutil.rmtree(scripts_dir, ignore_errors=True)


if __name__ == "__main__":
main()
60 changes: 60 additions & 0 deletions bazel/rules/macos/pkg/materialize_root.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Copies a pkg_install-materialized tree out of Bazel's sandbox symlink farm.

Usage: materialize_root.py <src_dir> <dst_dir>

Bazel presents a declare_directory() action input as a symlink farm: every
entry is (transitively) a symlink to its real, ephemeral location under the
sandbox/exec-root/cache. This walks that farm and, for each entry, resolves
symlinks one hop at a time until it finds either real content (a regular
file/directory) or another symlink -- the latter case means the payload
itself (via pkg_install's NativeInstaller) intentionally created that
symlink, e.g. for a pkg_mklink() entry, so its target text is preserved
verbatim rather than being flattened into a duplicate file.

File/directory modes are copied explicitly via shutil.copymode()/copystat()
so the result isn't affected by this process's umask.
"""

import os
import shutil
import sys


def _resolve_one_hop(path):
"""If path is a symlink, returns (resolved_path, target_is_symlink)."""
if not os.path.islink(path):
return path, False
target = os.readlink(path)
if os.path.isabs(target):
resolved = target
else:
resolved = os.path.join(os.path.dirname(path), target)
resolved = os.path.normpath(resolved)
return resolved, os.path.islink(resolved)


def materialize(src, dst):
real_src, is_intentional_symlink = _resolve_one_hop(src)
if is_intentional_symlink:
os.symlink(os.readlink(real_src), dst)
return

if os.path.isdir(real_src):
os.makedirs(dst, exist_ok=True)
for entry in os.listdir(real_src):
materialize(os.path.join(real_src, entry), os.path.join(dst, entry))
shutil.copystat(real_src, dst)
else:
shutil.copyfile(real_src, dst)
shutil.copymode(real_src, dst)


def main():
src_dir, dst_dir = sys.argv[1], sys.argv[2]
for entry in os.listdir(src_dir):
materialize(os.path.join(src_dir, entry), os.path.join(dst_dir, entry))


if __name__ == "__main__":
main()
159 changes: 159 additions & 0 deletions bazel/rules/macos/pkg/pkg_mac_pkg.bzl
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
"""pkg_mac_pkg - build a macOS .pkg installer from rules_pkg-style file mappings.

Consumes the same `srcs` shape as `pkg_deb`/`pkg_rpm` (pkg_files/pkg_filegroup
targets) and calls the macOS `pkgbuild` tool to produce a single component
package. See ABLD-395 (~/mac_package.md has the design writeup).

v1 scope, deliberately: no `productbuild`/Distribution.xml composition -- a
single flat component package is directly installable via `installer(8)`,
Installer.app, or MDM. v1 also materializes `srcs` to a real directory on disk
before invoking `pkgbuild --root`, reusing rules_pkg's own `pkg_install`
installer (and its codesign-safe atomic file copy) rather than writing a
bespoke manifest interpreter.
"""

load("@rules_pkg//pkg:install.bzl", "pkg_install")

def _pkg_mac_pkg_impl(ctx):
# `pkg_install` produces a `bazel run`-able installer whose CLI insists on
# an absolute --destdir (or BUILD_WORKSPACE_DIRECTORY for resolving a
# relative one), since it's normally invoked interactively. Inside a
# Bazel action the process cwd is already the exec root, so a relative
# destdir resolves correctly on disk -- we just need to satisfy that
# check. Setting BUILD_WORKSPACE_DIRECTORY to "." (a literal we choose,
# not an inherited env var) does that without needing our own installer.
root_dir = ctx.actions.declare_directory(ctx.label.name + "_root")
ctx.actions.run(
mnemonic = "MacPkgRoot",
progress_message = "Materializing pkg root for %s" % ctx.label,
executable = ctx.attr.installer[DefaultInfo].files_to_run,
arguments = ["--destdir", root_dir.path, "--wipe_destdir"],
env = {"BUILD_WORKSPACE_DIRECTORY": "."},
outputs = [root_dir],
)

pkgbuild_toolchain = ctx.toolchains["@macos_pkgbuild//:pkgbuild_toolchain_type"].pkgbuild
if not pkgbuild_toolchain.valid:
fail("No pkgbuild available on this machine.")

output = ctx.outputs.out
inputs = [root_dir, ctx.file._materialize_root_py]

args = ctx.actions.args()
args.add("--materialize-root-py", ctx.file._materialize_root_py)
args.add("--root", root_dir.path)

# pkgbuild_toolchain.path is a plain string pointing at a system binary
# found via `which` (see bazel/toolchains/common/defs.bzl) -- it isn't a
# Bazel-tracked File, so it must not be added to `inputs`.
args.add("--pkgbuild", pkgbuild_toolchain.path)

args.add("--identifier", ctx.attr.identifier)
args.add("--version", ctx.attr.version)
args.add("--install-location", ctx.attr.install_location)
args.add("--output", output)

if ctx.file.preinstall:
args.add("--preinstall", ctx.file.preinstall)
inputs.append(ctx.file.preinstall)

if ctx.file.postinstall:
args.add("--postinstall", ctx.file.postinstall)
inputs.append(ctx.file.postinstall)

if ctx.attr.signing_identity:
args.add("--signing-identity", ctx.attr.signing_identity)

ctx.actions.run(
mnemonic = "MacPkgBuild",
progress_message = "Building macOS pkg %s" % ctx.label,
executable = ctx.executable._pkgbuild_wrapper,
arguments = [args],
inputs = inputs,
outputs = [output],
)

return [
DefaultInfo(files = depset([output])),
OutputGroupInfo(pkg = depset([output])),
]

_pkg_mac_pkg = rule(
implementation = _pkg_mac_pkg_impl,
attrs = {
"installer": attr.label(
mandatory = True,
executable = True,
cfg = "target",
providers = [DefaultInfo],
doc = "pkg_install target used to materialize srcs onto disk.",
),
"identifier": attr.string(mandatory = True, doc = "pkgbuild --identifier"),
"version": attr.string(mandatory = True, doc = "pkgbuild --version"),
"install_location": attr.string(default = "/", doc = "pkgbuild --install-location"),
"preinstall": attr.label(allow_single_file = True, doc = "Script run as `preinstall`."),
"postinstall": attr.label(allow_single_file = True, doc = "Script run as `postinstall`."),
"signing_identity": attr.string(doc = "pkgbuild --sign identity name."),
"out": attr.output(mandatory = True),
"_pkgbuild_wrapper": attr.label(
default = Label("//bazel/rules/macos/pkg:build_mac_pkg"),
executable = True,
cfg = "exec",
),
"_materialize_root_py": attr.label(
default = Label("//bazel/rules/macos/pkg:materialize_root.py"),
allow_single_file = True,
),
},
toolchains = ["@macos_pkgbuild//:pkgbuild_toolchain_type"],
)

def pkg_mac_pkg(
name,
srcs,
identifier,
version,
install_location = "/",
preinstall = None,
postinstall = None,
signing_identity = "",
out = None,
**kwargs):
"""Builds a macOS .pkg installer from pkg_filegroup/pkg_files srcs.

Args:
name: rule name.
srcs: pkg_filegroup framework mapping/grouping targets (same shape
accepted by pkg_tar/pkg_install).
identifier: the package identifier, e.g. "com.datadoghq.agent".
version: the package version string.
install_location: root install path baked into the package
(pkgbuild --install-location). Defaults to "/".
preinstall: optional label of a script to run as `preinstall`.
postinstall: optional label of a script to run as `postinstall`.
signing_identity: optional codesigning identity for `pkgbuild --sign`.
Leave unset for unsigned builds; callers that need env-gated
signing (e.g. Omnibus's SIGN_MAC) should select() this at the
call site rather than reading the env from within the rule.
out: output file name. Defaults to "{name}.pkg".
**kwargs: standard attributes.
"""
pkg_install(
name = name + "_installer",
srcs = srcs,
visibility = ["//visibility:private"],
)
Comment thread
aiuto marked this conversation as resolved.

_pkg_mac_pkg(
name = name,
installer = ":" + name + "_installer",
identifier = identifier,
version = version,
install_location = install_location,
preinstall = preinstall,
postinstall = postinstall,
signing_identity = signing_identity,
out = out or (name + ".pkg"),
target_compatible_with = ["@platforms//os:macos"],
**kwargs
)
Loading
Loading