From 48a82c63ea30d42561dc528a7fd3eee5f34bdda0 Mon Sep 17 00:00:00 2001 From: Sahin Yort Date: Fri, 22 May 2026 20:47:47 -0700 Subject: [PATCH] feat: implement run task --- .aspect/config.axl | 7 + .buildkite/pipeline.yaml | 13 ++ .../src/builtins/aspect/MODULE.aspect | 1 + .../src/builtins/aspect/lib/runnable.axl | 57 ++++++- .../src/builtins/aspect/lib/runnable_test.axl | 80 ++++++++++ crates/aspect-cli/src/builtins/aspect/run.axl | 144 ++++++++++++++++++ crates/aspect-cli/src/cmd.rs | 3 +- 7 files changed, 298 insertions(+), 7 deletions(-) create mode 100644 crates/aspect-cli/src/builtins/aspect/lib/runnable_test.axl create mode 100644 crates/aspect-cli/src/builtins/aspect/run.axl diff --git a/.aspect/config.axl b/.aspect/config.axl index 2a6a1b5b1..e03e6a796 100644 --- a/.aspect/config.axl +++ b/.aspect/config.axl @@ -12,6 +12,7 @@ load("@aspect//lib/gitlab_detect_test.axl", "gitlab_detect_tests") load("@aspect//lib/gitlab_test.axl", "gitlab_client_tests") load("@aspect//lib/lint_results_test.axl", "lint_annotation_plan_tests", "lint_template_snapshot_tests") load("@aspect//lib/rate_limit_test.axl", "rate_limit_tests") +load("@aspect//lib/runnable_test.axl", "runnable_tests") load("@aspect//lib/runner_job_history_test.axl", "runner_job_history_tests") load("@aspect//lib/tips_test.axl", "tips_tests") load("@aspect//traits.axl", "BazelTrait", "LintTrait") @@ -120,6 +121,12 @@ def config(ctx: ConfigContext): # Run with: aspect dev test-runner-job-history ctx.tasks.add(runner_job_history_tests) + # Label canonicalizer — `canonical_label` across absolute, @repo, + # `:name`, bare, and `pkg:name` forms with and without a current + # package context. + # Run with: aspect dev test-runnable + ctx.tasks.add(runnable_tests) + # Delivery template snapshot tests — renders delivery_results templates. # Run with: aspect dev test-delivery-template-snapshots ctx.tasks.add(delivery_template_snapshot_tests) diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml index c16970a89..c69aa7145 100644 --- a/.buildkite/pipeline.yaml +++ b/.buildkite/pipeline.yaml @@ -93,6 +93,17 @@ steps: cd crates/aspect-cli $$LAUNCHER user user-task-subdir ) + echo "--- :aspect: aspect run smoke (in examples/deliverable)" + # Exercises the run task end-to-end across the three label forms the + # canonicalizer resolves (:name, //:name, bare name) and verifies + # trailing args are forwarded to the spawned binary. + ( + cd examples/deliverable + $$LAUNCHER run :py_deliverable + $$LAUNCHER run //:py_deliverable + $$LAUNCHER run py_deliverable + $$LAUNCHER run :deliverable -- forwarded-arg + ) echo "--- :aspect: aspect help" $$LAUNCHER help echo "--- :aspect: aspect-launcher --version" @@ -133,6 +144,8 @@ steps: $$LAUNCHER dev test-rate-limit echo "--- :aspect: aspect dev test-runner-job-history" $$LAUNCHER dev test-runner-job-history + echo "--- :aspect: aspect dev test-runnable" + $$LAUNCHER dev test-runnable echo "--- :aspect: aspect tests axl" # Run AXL tests last — cancel tests may kill the Bazel server, # which invalidates bazel-out paths used by $$LAUNCHER above. diff --git a/crates/aspect-cli/src/builtins/aspect/MODULE.aspect b/crates/aspect-cli/src/builtins/aspect/MODULE.aspect index 134acba17..34d6953aa 100644 --- a/crates/aspect-cli/src/builtins/aspect/MODULE.aspect +++ b/crates/aspect-cli/src/builtins/aspect/MODULE.aspect @@ -3,6 +3,7 @@ use_task("auth.axl", "logout") use_task("auth.axl", "whoami") use_task("build.axl", "build") use_task("github.axl", "token") +use_task("run.axl", "run") use_task("test.axl", "test") use_task("axl_add.axl", "add") use_task("delivery.axl", "delivery") diff --git a/crates/aspect-cli/src/builtins/aspect/lib/runnable.axl b/crates/aspect-cli/src/builtins/aspect/lib/runnable.axl index 73ea67198..84e601744 100644 --- a/crates/aspect-cli/src/builtins/aspect/lib/runnable.axl +++ b/crates/aspect-cli/src/builtins/aspect/lib/runnable.axl @@ -1,9 +1,52 @@ _DEFAULT_WORKSPACE_NAME = "_main" -def canonical_label(label): - if ":" not in label: - return label + ":" + label.split("/")[-1] - return label +def canonical_label(label: str, current_package: str = "") -> str: + """Normalize a Bazel label to `//:` (or `@//:`) form. + + `current_package` is the workspace-relative path of the user's working + package (e.g. "foo/bar", or "" for the root package). Required to resolve + relative labels (`:foo`, `foo`, `pkg:foo`). Absolute labels (`//...`, + `@repo//...`) ignore it. + + canonical_label("//foo/bar") -> "//foo/bar:bar" + canonical_label("//foo/bar:baz") -> "//foo/bar:baz" + canonical_label("@repo//foo") -> "@repo//foo:foo" + canonical_label(":baz", "foo/bar") -> "//foo/bar:baz" + canonical_label("baz", "foo/bar") -> "//foo/bar:baz" + canonical_label("sub:baz", "foo") -> "//foo/sub:baz" + canonical_label("baz", "") -> "//:baz" + """ + # Absolute: //pkg[:name] or @repo//pkg[:name]. + if label.startswith("//") or label.startswith("@"): + pkg_part = label.split("//", 1)[-1] + if ":" in pkg_part: + return label + if pkg_part: + return label + ":" + pkg_part.rsplit("/", 1)[-1] + return label + + # `:name` -> //:name + if label.startswith(":"): + return "//" + current_package + label + + # `pkg:name` -> ///:name (or //:name when at root) + if ":" in label: + pkg, _, name = label.partition(":") + joined = current_package + "/" + pkg if current_package else pkg + return "//" + joined + ":" + name + + # Bare `name` -> //:name + return "//" + current_package + ":" + label + +def _current_package(ctx) -> str: + """Workspace-relative path of cwd, used as the package for relative labels.""" + root = ctx.std.env.root_dir().rstrip("/") + cwd = ctx.std.env.current_dir().rstrip("/") + if cwd == root: + return "" + if cwd.startswith(root + "/"): + return cwd[len(root) + 1:] + return "" def _process_bes(state: struct, event): if event.kind == "named_set_of_files": @@ -35,7 +78,7 @@ def _determine_entrypoint(state: struct, target: str) -> str | None: caller pairs this with `is_runnable` to distinguish "not in BES" from "in BES but no runfiles tree". """ - target = canonical_label(target) + target = canonical_label(target, state.current_package) if target not in state.target_fileset: return None @@ -98,9 +141,11 @@ def _spawn(ctx: TaskContext, state: struct, entrypoint: str, args: list[str], ca return cmd.spawn() def runnable(ctx, targets: list[str]) -> struct: + current_package = _current_package(ctx) state = struct( ctx = ctx, - targets = {canonical_label(t): t for t in targets}, + current_package = current_package, + targets = {canonical_label(t, current_package): t for t in targets}, filesets = {}, target_fileset = {}, # Single-element list so `_process_bes` can mutate it (struct fields are diff --git a/crates/aspect-cli/src/builtins/aspect/lib/runnable_test.axl b/crates/aspect-cli/src/builtins/aspect/lib/runnable_test.axl new file mode 100644 index 000000000..bebe4c722 --- /dev/null +++ b/crates/aspect-cli/src/builtins/aspect/lib/runnable_test.axl @@ -0,0 +1,80 @@ +"""Tests for `lib/runnable.axl`'s label canonicalizer. + +Covers `canonical_label` across: + - Absolute labels: `//pkg:name`, `//pkg` (shorthand), `//:name`, `@repo//pkg:name`, `@repo//pkg` (shorthand). + - Relative labels: `:name`, bare `name`, `pkg:name` — both at the workspace root and inside a nested package. + +`_current_package` derives the package from cwd vs workspace root; that path +needs a real cwd/root pair and is exercised by the live `aspect run` smoke +in CI rather than mocked here. + +Run with: + aspect dev test-runnable +""" + +load("./runnable.axl", "canonical_label") + +def _eq(label, got, want): + if got != want: + fail("%s: got %r, want %r" % (label, got, want)) + +def _test_absolute_unchanged_when_target_present(): + _eq("//foo/bar:baz", canonical_label("//foo/bar:baz"), "//foo/bar:baz") + _eq("//:foo", canonical_label("//:foo"), "//:foo") + _eq("//foo/bar:baz with pkg context", canonical_label("//foo/bar:baz", "ignored"), "//foo/bar:baz") + +def _test_absolute_shorthand_expands_basename(): + _eq("//foo/bar -> //foo/bar:bar", canonical_label("//foo/bar"), "//foo/bar:bar") + _eq("//foo -> //foo:foo", canonical_label("//foo"), "//foo:foo") + +def _test_external_repo_labels(): + _eq("@repo//foo:bar", canonical_label("@repo//foo:bar"), "@repo//foo:bar") + _eq("@repo//foo -> @repo//foo:foo", canonical_label("@repo//foo"), "@repo//foo:foo") + _eq("@@_main//foo:bar (bzlmod canonical)", canonical_label("@@_main//foo:bar"), "@@_main//foo:bar") + +def _test_relative_colon_name_at_root(): + _eq(":foo at root -> //:foo", canonical_label(":foo", ""), "//:foo") + +def _test_relative_colon_name_in_nested_package(): + _eq(":foo in examples/deliverable", canonical_label(":foo", "examples/deliverable"), "//examples/deliverable:foo") + +def _test_bare_name_at_root(): + _eq("foo at root -> //:foo", canonical_label("foo", ""), "//:foo") + +def _test_bare_name_in_nested_package(): + _eq("foo in examples -> //examples:foo", canonical_label("foo", "examples"), "//examples:foo") + +def _test_relative_pkg_colon_name_at_root(): + _eq("sub:foo at root -> //sub:foo", canonical_label("sub:foo", ""), "//sub:foo") + +def _test_relative_pkg_colon_name_in_nested_package(): + _eq("sub:foo in foo -> //foo/sub:foo", canonical_label("sub:foo", "foo"), "//foo/sub:foo") + +def _test_default_current_package_is_root(): + """Callers that omit `current_package` (e.g. `_process_bes` on BES events) + get root-package resolution, which is correct because BES always emits + absolute labels — so the default never actually engages on a relative + input in practice.""" + _eq(":foo with default pkg", canonical_label(":foo"), "//:foo") + _eq("foo with default pkg", canonical_label("foo"), "//:foo") + +def _test_impl(ctx): + _test_absolute_unchanged_when_target_present() + _test_absolute_shorthand_expands_basename() + _test_external_repo_labels() + _test_relative_colon_name_at_root() + _test_relative_colon_name_in_nested_package() + _test_bare_name_at_root() + _test_bare_name_in_nested_package() + _test_relative_pkg_colon_name_at_root() + _test_relative_pkg_colon_name_in_nested_package() + _test_default_current_package_is_root() + print("runnable.axl: OK (10 sections)") + return 0 + +runnable_tests = task( + name = "test-runnable", + group = ["dev"], + implementation = _test_impl, + args = {}, +) diff --git a/crates/aspect-cli/src/builtins/aspect/run.axl b/crates/aspect-cli/src/builtins/aspect/run.axl new file mode 100644 index 000000000..48295e369 --- /dev/null +++ b/crates/aspect-cli/src/builtins/aspect/run.axl @@ -0,0 +1,144 @@ +"""A default 'run' task that builds a target with `bazel build` and runs the resulting binary.""" + +load("@std//time.axl", "sleep_iter") +load("./bazel.axl", "BazelTrait") +load("./lib/bazel_results.axl", "BES_DRAIN_TICK_MS", "init_data", "process_event") +load("./lib/environment.axl", "error") +load("./lib/health_check.axl", "HealthCheckTrait") +load("./lib/lifecycle.axl", "Phase", "ProgressStyles", "TaskLifecycleTrait", "preflight_phase", "setup_phase", "task_update") +load("./lib/runnable.axl", "runnable") +load("./lib/tips.axl", "tips") + +def _impl(ctx: TaskContext) -> int: + bazel_trait = ctx.traits[BazelTrait] + hc_trait = ctx.traits[HealthCheckTrait] + lifecycle = ctx.traits[TaskLifecycleTrait] + + target = ctx.args.target[0] + forward_args = list(ctx.args.run_args) + + data = init_data() + setup_phase(ctx, lifecycle) + for handler in lifecycle.task_started: + handler(ctx, target) + + preflight_phase(ctx, lifecycle, hc_trait) + + for hook in hc_trait.post_health_check: + result = hook(ctx) + if result != None: + fail(result) + + flags = [] + flags.extend(bazel_trait.extra_flags) + for hook in bazel_trait.task_flags: + flags.extend(hook(ctx)) + if bazel_trait.flags: + flags = bazel_trait.flags(flags) + + startup_flags = list(bazel_trait.extra_startup_flags) + if bazel_trait.startup_flags: + startup_flags = bazel_trait.startup_flags(startup_flags) + + rc = ctx.bazel.parse_rc( + root = ctx.std.env.root_dir(), + flags = flags, + startup_flags = startup_flags, + ) + + # `run` inherits the `build` section in Bazel's rc hierarchy, so this picks + # up `common`, `build`, and `run` flags. Forwarded as-is to ctx.bazel.build. + rc_startup_flags, flags = rc.expand_all(command = "run") + ctx.bazel.startup_flags.extend(startup_flags + rc_startup_flags + ["--ignore_all_rc_files"]) + + # Required for the spawn to succeed: keep BES artifact references + # local, materialize the binary on disk, and lay down the runfiles + # tree. Appended LAST so they win over any --config=X expansion + # introduced by the rc. + flags.append("--experimental_build_event_upload_strategy=local") + flags.append("--remote_download_outputs=toplevel") + flags.append("--build_runfile_links") + + r = runnable(ctx, [target]) + events = bazel.build_events.iterator() + build_events = [events] + list(bazel_trait.build_event_sinks) + + for hook in bazel_trait.build_start: + hook(ctx) + + task_update( + ctx, + lifecycle, + "running", + ProgressStyles( + body = "Building %s..." % target, + stream = "Building %s" % target, + ), + kind = "bazel_results", + data = data, + phase = Phase(name = "build", description = "Build run target", emoji = "🔨"), + ) + + build = ctx.bazel.build(target, build_events = build_events, flags = flags) + + for _tick in sleep_iter(BES_DRAIN_TICK_MS): + for _ in range(10000): + event = events.try_pop() + if event == None: + break + r.event(event) + for handler in bazel_trait.build_event: + handler(ctx, event) + process_event(data, event) + if events.done: + break + + build_status = build.wait() + for sink in bazel_trait.build_event_sinks: + sink.wait() + for hook in bazel_trait.build_end: + hook(ctx, build_status.code) + + if not build_status.success: + return build_status.code or 1 + + entrypoint = r.determine_entrypoint(target) + if not entrypoint: + error(ctx.std, "Failed to determine the run entrypoint for %s." % target) + return 1 + if not r.is_runnable(entrypoint): + error(ctx.std, "%s is not runnable (no runfiles tree at %s.runfiles)." % (target, entrypoint)) + return 1 + + task_update( + ctx, + lifecycle, + "running", + ProgressStyles( + body = "Running %s..." % target, + stream = "Running %s" % target, + ), + phase = Phase(name = "run", description = "Run target", emoji = "🚀"), + ) + + return r.spawn(entrypoint, forward_args).wait().code + +run = task( + summary = "Build a target with bazel and run the resulting binary.", + args = { + "target": args.positional( + minimum = 1, + maximum = 1, + description = "Bazel target to build and run.", + ), + "run_args": args.trailing_var_args( + description = "Arguments forwarded to the target binary. Separate from `aspect run`'s own flags with `--`, e.g. `aspect run //foo:bar -- --binary-arg value`.", + ), + }, + traits = [ + BazelTrait, + HealthCheckTrait, + TaskLifecycleTrait, + ] + tips.TRAITS, + implementation = _impl, +) diff --git a/crates/aspect-cli/src/cmd.rs b/crates/aspect-cli/src/cmd.rs index b03548468..8ded1f18b 100644 --- a/crates/aspect-cli/src/cmd.rs +++ b/crates/aspect-cli/src/cmd.rs @@ -303,7 +303,8 @@ fn arg_to_clap(scope: Scope<'_>, name: &str, arg: &Arg) -> ClapArg { .value_parser(value_parser!(String)) .value_name(id) .help(help_text(description)) - .num_args(*minimum as usize..=*maximum as usize); + .num_args(*minimum as usize..=*maximum as usize) + .required(*minimum > 0 && default.is_none()); if let Some(default) = default { it = it.default_values(default); }