diff --git a/crates/plugin-go/src/plugingo/addr_util.rs b/crates/plugin-go/src/plugingo/addr_util.rs index 2d386e93..0c9657a5 100644 --- a/crates/plugin-go/src/plugingo/addr_util.rs +++ b/crates/plugin-go/src/plugingo/addr_util.rs @@ -299,30 +299,34 @@ pub fn dep_group_env_var(group: &str) -> String { /// stage the SDK files into the sandbox. pub const GO_SDK_DEP_GROUP: &str = "gosdk"; -/// `(group, value)` dep entry staging the hermetic Go SDK for `go_version` into -/// the sandbox at [`toolchain::staged_goroot`]. A single SDK output (the full -/// tree, incl. `GOROOT/src`) serves every consumer: it is staged read-only and -/// exposed via a directory symlink, so its size costs nothing per consumer. -/// Pair with [`go_sdk_read_only_config`] on `sh`/exec targets. +/// `(group, value)` dep entry staging the Go toolchain for `go_version` into the +/// sandbox via the `gosdk` group. The dep address depends on the toolchain kind: +/// a hermetic version deps the synthesized `//@heph/go/toolchain/:go` +/// download (staged at the deterministic [`toolchain::staged_goroot`]); a target +/// ref (`//pkg:go`) deps that target verbatim (host `go` via hostbin, a nix-built +/// `go`, …), staged at a path discovered from `$SRC_GOSDK` at runtime. Pair with +/// [`go_sdk_read_only_config`] on `sh`/exec targets. /// /// Returns `None` for the host toolchain ([`toolchain::HOST`]) — the host `go` /// is read from the sandbox's `PATH`/`GOROOT`, not staged as a dep. pub fn go_sdk_dep(go_version: &str) -> Option<(String, Value)> { - if crate::plugingo::toolchain::is_host(go_version) { - return None; - } + use crate::plugingo::toolchain::{self, Toolchain}; + let addr = match toolchain::classify(go_version) { + Toolchain::Host => return None, + Toolchain::Target(t) => t.to_string(), + Toolchain::Hermetic(v) => toolchain::toolchain_addr(v).format(), + }; Some(( GO_SDK_DEP_GROUP.to_string(), - Value::List(vec![Value::String( - crate::plugingo::toolchain::toolchain_addr(go_version).format(), - )]), + Value::List(vec![Value::String(addr)]), )) } /// `(key, value)` config entry marking the `gosdk` dep group for read-only -/// staging on the `sh`/exec driver: the SDK is materialized once into the -/// shared stage and exposed to each sandbox via a directory symlink instead of -/// byte-copied per consumer. `None` for the host toolchain (no SDK dep to mark). +/// staging on the `sh`/exec driver: the toolchain is materialized once into the +/// shared stage and exposed to each sandbox via a symlink instead of byte-copied +/// per consumer. Applies to both the hermetic SDK and a target-ref toolchain. +/// `None` for the host toolchain (no SDK dep to mark). pub fn go_sdk_read_only_config(go_version: &str) -> Option<(String, Value)> { if crate::plugingo::toolchain::is_host(go_version) { return None; @@ -333,12 +337,16 @@ pub fn go_sdk_read_only_config(go_version: &str) -> Option<(String, Value)> { )) } -/// Host env vars to pass through (at runtime, unhashed) so the host toolchain -/// works inside the sandbox: `PATH` to find `go`, plus the Go/module cache and -/// proxy knobs `go` consults. Empty for a hermetic toolchain (reads nothing from -/// the host). Insert the names under the exec `runtime_pass_env` config key. +/// Env vars to pass through (at runtime, unhashed) so a non-hermetic toolchain +/// works inside the sandbox: `PATH`, plus the Go/module cache and proxy knobs +/// `go` consults. Passed for both the host toolchain and a target-ref toolchain +/// (`//pkg:go`): the staged `go` still resolves thirdparty modules through the +/// host module cache/proxy, and a hostbin/nix wrapper may consult `PATH`/`HOME`. +/// Empty for a hermetic toolchain (reads nothing from the host). Insert the +/// names under the exec `runtime_pass_env` config key. pub fn go_host_runtime_pass_env(go_version: &str) -> Vec { - if crate::plugingo::toolchain::is_host(go_version) { + use crate::plugingo::toolchain::{is_host, is_target_ref}; + if is_host(go_version) || is_target_ref(go_version) { [ "PATH", "HOME", @@ -375,28 +383,42 @@ pub fn go_host_pass_env_config(go_version: &str) -> Option<(String, Value)> { /// Prelude pointing `GOROOT` at the Go toolchain for `go_version` and exposing /// its `go` binary on `PATH` and as `$GO`. /// -/// Hermetic: `GOROOT` is the staged SDK ([`toolchain::staged_goroot`]) — reads -/// nothing from the host; pair with [`go_sdk_dep`]. Host ([`toolchain::HOST`]): -/// `GOROOT` is resolved in-shell via the host `go env GOROOT` (so `go` must be -/// on the passed-through `PATH`, see [`go_host_runtime_pass_env`]). +/// - Hermetic: `GOROOT` is the staged SDK ([`toolchain::staged_goroot`]) — reads +/// nothing from the host; pair with [`go_sdk_dep`]. +/// - Host ([`toolchain::HOST`]): `GOROOT` is resolved in-shell via the host +/// `go env GOROOT` (so `go` must be on the passed-through `PATH`, see +/// [`go_host_runtime_pass_env`]). +/// - Target (`//pkg:go`): the toolchain target's single output is staged via the +/// `gosdk` dep and surfaced as `$SRC_GOSDK`. Auto-detect a full GOROOT tree (a +/// directory, whose `bin/go` we use) vs. a bare `go` binary (a file), then take +/// `GOROOT` from whatever that `go` reports — a relocated tree, a host install, +/// or a `/nix/store` path alike. pub fn go_goroot_prelude(go_version: &str) -> Vec { - if crate::plugingo::toolchain::is_host(go_version) { - return vec![ + use crate::plugingo::toolchain::{self, Toolchain}; + match toolchain::classify(go_version) { + Toolchain::Host => vec![ // Host `go` from PATH; pin GOROOT to its own report so `go tool` // invocations resolve the compiler/linker consistently. "export GOROOT=\"$(go env GOROOT)\"".to_string(), "export PATH=\"$GOROOT/bin:$PATH\"".to_string(), "GO=\"$GOROOT/bin/go\"".to_string(), - ]; + ], + Toolchain::Target(_) => vec![ + "GO=\"$SRC_GOSDK\"".to_string(), + // Directory output → a GOROOT tree; file output → the `go` binary. + "if [ -d \"$GO\" ]; then GO=\"$GO/bin/go\"; fi".to_string(), + "export GOROOT=\"$(\"$GO\" env GOROOT)\"".to_string(), + "export PATH=\"$GOROOT/bin:$PATH\"".to_string(), + ], + Toolchain::Hermetic(v) => vec![ + format!( + "export GOROOT=\"$WORKSPACE_ROOT/{}\"", + toolchain::staged_goroot(v) + ), + "export PATH=\"$GOROOT/bin:$PATH\"".to_string(), + "GO=\"$GOROOT/bin/go\"".to_string(), + ], } - vec![ - format!( - "export GOROOT=\"$WORKSPACE_ROOT/{}\"", - crate::plugingo::toolchain::staged_goroot(go_version) - ), - "export PATH=\"$GOROOT/bin:$PATH\"".to_string(), - "GO=\"$GOROOT/bin/go\"".to_string(), - ] } /// Shell prelude every Go *compile/link/list* script runs first: @@ -638,6 +660,56 @@ mod tests { ); } + #[test] + fn test_go_sdk_dep_per_toolchain_kind() { + // Host: no SDK dep. + assert!(go_sdk_dep("host").is_none()); + // Hermetic: deps the synthesized toolchain download target. + let (group, val) = go_sdk_dep("1.26.4").expect("hermetic deps SDK"); + assert_eq!(group, GO_SDK_DEP_GROUP); + match val { + Value::List(v) => match &v[0] { + Value::String(s) => assert!(s.contains("@heph/go/toolchain/1.26.4")), + _ => panic!("addr must be a string"), + }, + _ => panic!("dep value must be a list"), + } + // Target ref: deps that target verbatim. + let (group, val) = go_sdk_dep("//@heph/bin:go").expect("target deps SDK"); + assert_eq!(group, GO_SDK_DEP_GROUP); + match val { + Value::List(v) => assert_eq!(v, vec![Value::String("//@heph/bin:go".to_string())]), + _ => panic!("dep value must be a list"), + } + } + + #[test] + fn test_target_ref_passes_host_env_and_marks_read_only() { + // A target-ref toolchain still consults the host module cache/proxy and + // is staged read-only like the hermetic SDK. + assert!(go_host_runtime_pass_env("//@heph/bin:go").contains(&"PATH".to_string())); + assert!(go_host_runtime_pass_env("//@heph/bin:go").contains(&"GOMODCACHE".to_string())); + assert!(go_sdk_read_only_config("//@heph/bin:go").is_some()); + // Hermetic passes no host env; host passes the full set. + assert!(go_host_runtime_pass_env("1.26.4").is_empty()); + assert!(go_host_runtime_pass_env("host").contains(&"PATH".to_string())); + } + + #[test] + fn test_goroot_prelude_target_auto_detects_via_src_gosdk() { + let p = go_goroot_prelude("//some/pkg:go").join("\n"); + // Resolves the toolchain from the staged `gosdk` dep, auto-detecting a + // directory (GOROOT tree) vs. a bare `go` binary, and derives GOROOT from + // the binary itself. + assert!(p.contains("$SRC_GOSDK"), "must read the staged dep: {p}"); + assert!(p.contains("if [ -d"), "must auto-detect dir vs file: {p}"); + assert!(p.contains("env GOROOT"), "must derive GOROOT: {p}"); + // Hermetic instead points at the deterministic staged path. + let h = go_goroot_prelude("1.26.4").join("\n"); + assert!(h.contains("$WORKSPACE_ROOT/@heph/go/toolchain/1.26.4/go")); + assert!(!h.contains("$SRC_GOSDK")); + } + #[test] fn test_encode_stdlib() { let factors = Factors { diff --git a/crates/plugin-go/src/plugingo/driver_compile.rs b/crates/plugin-go/src/plugingo/driver_compile.rs index 03c46522..77b217e7 100644 --- a/crates/plugin-go/src/plugingo/driver_compile.rs +++ b/crates/plugin-go/src/plugingo/driver_compile.rs @@ -321,23 +321,34 @@ impl ManagedDriver for GoCompileDriver { let pkg_dir = &req.sandbox_pkg_dir; let ws_root = req.sandbox_ws_dir.to_string_lossy().into_owned(); - // GOROOT / go binary: hermetic staged SDK or host (mirrors go_golist). - let host = crate::plugingo::toolchain::is_host(&def.go_version); - let (goroot, go_bin) = if host { - let go_bin = crate::plugingo::toolchain::resolve_host_go()?; - let goroot = crate::plugingo::toolchain::host_goroot(&go_bin)?; - (goroot, go_bin) - } else { - let goroot = req - .sandbox_ws_dir - .join(crate::plugingo::toolchain::staged_goroot(&def.go_version)); - let go_bin = goroot.join("bin").join("go"); - if !go_bin.exists() { - anyhow::bail!( - "go_compile: hermetic go binary missing at {go_bin:?} (gosdk dep not staged?)" - ); + // GOROOT / go binary by toolchain kind (mirrors go_golist): + // - Hermetic: SDK staged into this sandbox by the `gosdk` dep. + // - Host (`gotool = "host"`): host `go` from PATH; GOROOT it reports. + // - Target (`gotool = "//pkg:go"`): `go` staged by the `gosdk` dep at a + // path discovered from the dep's output; GOROOT it reports. + use crate::plugingo::toolchain::{self, Toolchain}; + let toolchain = toolchain::classify(&def.go_version); + let (goroot, go_bin) = match toolchain { + Toolchain::Host => { + let go_bin = toolchain::resolve_host_go()?; + let goroot = toolchain::go_env_goroot(&go_bin)?; + (goroot, go_bin) + } + Toolchain::Target(_) => { + let go_bin = crate::plugingo::driver_golist::resolve_target_go(&req.inputs)?; + let goroot = toolchain::go_env_goroot(&go_bin)?; + (goroot, go_bin) + } + Toolchain::Hermetic(v) => { + let goroot = req.sandbox_ws_dir.join(toolchain::staged_goroot(v)); + let go_bin = goroot.join("bin").join("go"); + if !go_bin.exists() { + anyhow::bail!( + "go_compile: hermetic go binary missing at {go_bin:?} (gosdk dep not staged?)" + ); + } + (goroot, go_bin) } - (goroot, go_bin) }; let gocache = pkg_dir.join(".heph-gocache"); @@ -355,7 +366,11 @@ impl ManagedDriver for GoCompileDriver { env.insert("GOTOOLCHAIN".to_string(), "local".to_string()); env.insert("GOWORK".to_string(), "off".to_string()); env.insert("CGO_ENABLED".to_string(), "0".to_string()); - if host && let Ok(v) = std::env::var("PATH") { + // Non-hermetic toolchains (host or a hostbin/nix target wrapper) may need + // PATH; hermetic mode omits it to stay PATH-independent. + if !matches!(toolchain, Toolchain::Hermetic(_)) + && let Ok(v) = std::env::var("PATH") + { env.insert("PATH".to_string(), v); } diff --git a/crates/plugin-go/src/plugingo/driver_golist.rs b/crates/plugin-go/src/plugingo/driver_golist.rs index c2265c65..5db85d77 100644 --- a/crates/plugin-go/src/plugingo/driver_golist.rs +++ b/crates/plugin-go/src/plugingo/driver_golist.rs @@ -240,25 +240,39 @@ impl ManagedDriver for GoGolistDriver { ) -> anyhow::Result { let def = req.request.target.def_de::(); - // GOROOT and the `go` binary come from either the hermetic SDK staged - // into this sandbox by the `gosdk` dep, or — when the provider selects - // `gotool = "host"` — the host `go` resolved from this process's PATH. - let host = crate::plugingo::toolchain::is_host(&def.go_version); - let (goroot, go_bin) = if host { - let go_bin = crate::plugingo::toolchain::resolve_host_go()?; - let goroot = crate::plugingo::toolchain::host_goroot(&go_bin)?; - (goroot, go_bin) - } else { - let goroot = req - .sandbox_ws_dir - .join(crate::plugingo::toolchain::staged_goroot(&def.go_version)); - let go_bin = goroot.join("bin").join("go"); - if !go_bin.exists() { - anyhow::bail!( - "go_golist: hermetic go binary missing at {go_bin:?} (gosdk dep not staged?)" - ); + // GOROOT and the `go` binary depend on the selected toolchain: + // - Hermetic: the SDK staged into this sandbox by the `gosdk` dep, at the + // deterministic staged path. + // - Host (`gotool = "host"`): the host `go` resolved from this process's + // PATH; GOROOT is whatever it reports. + // - Target (`gotool = "//pkg:go"`): the `go` staged by the `gosdk` dep at + // a path discovered from the dep's output; GOROOT is whatever it reports + // (auto-detecting a GOROOT tree vs. a bare `go` binary). + use crate::plugingo::toolchain::Toolchain; + let toolchain = crate::plugingo::toolchain::classify(&def.go_version); + let (goroot, go_bin) = match toolchain { + Toolchain::Host => { + let go_bin = crate::plugingo::toolchain::resolve_host_go()?; + let goroot = crate::plugingo::toolchain::go_env_goroot(&go_bin)?; + (goroot, go_bin) + } + Toolchain::Target(_) => { + let go_bin = resolve_target_go(&req.inputs)?; + let goroot = crate::plugingo::toolchain::go_env_goroot(&go_bin)?; + (goroot, go_bin) + } + Toolchain::Hermetic(v) => { + let goroot = req + .sandbox_ws_dir + .join(crate::plugingo::toolchain::staged_goroot(v)); + let go_bin = goroot.join("bin").join("go"); + if !go_bin.exists() { + anyhow::bail!( + "go_golist: hermetic go binary missing at {go_bin:?} (gosdk dep not staged?)" + ); + } + (goroot, go_bin) } - (goroot, go_bin) }; // Sandbox-local build cache so `go list` neither reads nor writes the // host GOCACHE. @@ -301,10 +315,12 @@ impl ManagedDriver for GoGolistDriver { env.insert(name.to_string(), v); } } - // Host toolchain: the `go` subprocess may need PATH (e.g. to locate - // ancillary tools). Hermetic mode deliberately omits it to stay - // PATH-independent. - if host && let Ok(v) = std::env::var("PATH") { + // Non-hermetic toolchains: the `go` subprocess (or a hostbin/nix wrapper) + // may need PATH — to locate ancillary tools, or for the wrapper itself. + // Hermetic mode deliberately omits it to stay PATH-independent. + if !matches!(toolchain, Toolchain::Hermetic(_)) + && let Ok(v) = std::env::var("PATH") + { env.insert("PATH".to_string(), v); } @@ -473,6 +489,54 @@ fn normalize_dir(dir: &str, ws_prefix: &str) -> String { } } +/// Resolve the `go` binary from a target-ref toolchain (`gotool = "//pkg:go"`) +/// staged into this sandbox via the `gosdk` dep. The dep's output is either a +/// full GOROOT tree (we pick its `bin/go`) or a single `go` binary (a hostbin or +/// nix wrapper); when both shapes appear, prefer a `.../bin/go`. Shared with the +/// `go_compile` driver, which resolves the same target toolchain in-process. +pub(crate) fn resolve_target_go( + inputs: &[hdriver_support::driver_managed::ManagedRunInput], +) -> anyhow::Result { + let prefix = format!("dep|{}|", crate::plugingo::addr_util::GO_SDK_DEP_GROUP); + let gosdk = inputs + .iter() + .find(|m| m.input.origin_id.starts_with(&prefix)) + .context("go_golist: target toolchain `gosdk` dep not staged")?; + + let mut candidates: Vec = Vec::new(); + for entry in gosdk + .input + .artifact + .content + .as_ref() + .walk() + .context("walk target toolchain output")? + { + let entry = entry.context("read target toolchain entry")?; + if entry.path.file_name().and_then(|n| n.to_str()) == Some("go") { + candidates.push(gosdk.unpack_root.join(&entry.path)); + } + } + + pick_go_binary(candidates).context("go_golist: no `go` binary in target toolchain output") +} + +/// Pick the `go` binary among the toolchain output's `go`-named entries, +/// preferring a `.../bin/go` (a full GOROOT tree) over a bare `go` wrapper +/// (hostbin/nix). Returns `None` when no candidate is present. +pub(crate) fn pick_go_binary( + mut candidates: Vec, +) -> Option { + // `false` (parent is `bin`) sorts before `true`. Stable so ties keep input order. + candidates.sort_by_key(|p| { + p.parent() + .and_then(|d| d.file_name()) + .and_then(|n| n.to_str()) + != Some("bin") + }); + candidates.into_iter().next() +} + #[cfg(test)] mod tests { use super::*; @@ -483,6 +547,26 @@ mod tests { GoGolistDriver::new() } + #[test] + fn test_pick_go_binary_prefers_goroot_tree_bin_go() { + use std::path::PathBuf; + // hostbin wrapper only → use it. + assert_eq!( + pick_go_binary(vec![PathBuf::from("__heph/hostbin/go")]), + Some(PathBuf::from("__heph/hostbin/go")) + ); + // Both a wrapper and a full tree → prefer the tree's bin/go. + assert_eq!( + pick_go_binary(vec![ + PathBuf::from("ws/wrap/go"), + PathBuf::from("ws/sdk/go/bin/go"), + ]), + Some(PathBuf::from("ws/sdk/go/bin/go")) + ); + // No candidates → None. + assert_eq!(pick_go_binary(vec![]), None); + } + fn make_parse_request( pkg: &str, import_path: &str, diff --git a/crates/plugin-go/src/plugingo/provider.rs b/crates/plugin-go/src/plugingo/provider.rs index 47a4862c..aee8e562 100644 --- a/crates/plugin-go/src/plugingo/provider.rs +++ b/crates/plugin-go/src/plugingo/provider.rs @@ -216,15 +216,19 @@ impl Provider { // `gotool` selects the Go toolchain and is REQUIRED — there is no // implicit default. Set it to: // - `"host"` → use the host `go` (read from PATH / `go env GOROOT` - // inside the sandbox; non-hermetic, see [`toolchain::HOST`]), or + // inside the sandbox; non-hermetic, see [`toolchain::HOST`]), // - a pinned version like `"1.26.4"` → download + manage that SDK - // hermetically (`//@heph/go/toolchain/:go`). + // hermetically (`//@heph/go/toolchain/:go`), or + // - a target address like `"//@heph/bin:go"` (host `go` via the hostbin + // provider) or `"//some/pkg:go"` (e.g. a nix-built `go`) → use the + // `go` produced by that target (see [`toolchain::is_target_ref`]). hplugin::config::deny_unknown("go provider", opts, &["gotool", "skip", "checksums"])?; let go_version: String = hplugin::config::decode_opt(opts, "go provider", "gotool")? .ok_or_else(|| { anyhow::anyhow!( - "go provider: `gotool` is required — set it to \"host\" (use the host go) \ - or a pinned version like \"{}\" (hermetic SDK download)", + "go provider: `gotool` is required — set it to \"host\" (use the host go), \ + a pinned version like \"{}\" (hermetic SDK download), or a target address \ + like \"//@heph/bin:go\" (a target that produces the go toolchain)", toolchain::DEFAULT_GO_VERSION ) })?; diff --git a/crates/plugin-go/src/plugingo/toolchain.rs b/crates/plugin-go/src/plugingo/toolchain.rs index 9cb2ca8b..4bd28026 100644 --- a/crates/plugin-go/src/plugingo/toolchain.rs +++ b/crates/plugin-go/src/plugingo/toolchain.rs @@ -13,6 +13,15 @@ //! (resolved from `PATH` / `go env GOROOT` inside the sandbox): no SDK target, //! no `gosdk` dep, host env passed through. Non-hermetic by construction. //! +//! With `gotool = "//pkg:go"` ([`is_target_ref`]) the toolchain comes from +//! another *target* — e.g. `//@heph/bin:go` (host `go` exposed by the hostbin +//! provider) or a `//some/pkg:go` built by the nix driver. The build deps that +//! target in the `gosdk` group exactly like the hermetic SDK, but its staged +//! path is not known ahead of time, so `go`/`GOROOT` are resolved from the +//! staged output at runtime (auto-detecting a GOROOT directory vs. a bare `go` +//! binary; see [`addr_util::go_goroot_prelude`] and the golist driver). How +//! hermetic the result is then depends entirely on what that target produces. +//! //! The SDK is one cacheable output (the full tree: `go` + `pkg/tool` + `lib` + //! `src` + version/env metadata; `api/test/doc/misc` excluded — nothing reads //! them). Consumers don't copy it: it is staged read-only once and exposed to @@ -68,6 +77,43 @@ pub fn is_host(spec: &str) -> bool { spec == HOST } +/// Whether `spec` selects a **target** toolchain: an explicit target address +/// providing the `go` toolchain, distinguished by a leading `//`. Examples: +/// `//@heph/bin:go` (host `go` exposed by the hostbin provider) or +/// `//some/pkg:go` (a `go` built by the nix driver). The build deps that target +/// in the `gosdk` group, stages its single output, and resolves `go`/`GOROOT` +/// from it at runtime — auto-detecting a full GOROOT tree (a directory whose +/// `bin/go` is used) vs. a bare `go` binary (a file), with `GOROOT` taken from +/// whatever that `go` reports. +pub fn is_target_ref(spec: &str) -> bool { + spec.starts_with("//") +} + +/// The three ways the required `gotool` provider option selects the toolchain. +/// Threaded everywhere as the `go_version` string; classify with [`classify`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Toolchain<'a> { + /// `gotool = "host"` — host `go` resolved from `PATH` / `go env GOROOT`. + Host, + /// `gotool = "//pkg:go"` — a target producing the toolchain (hostbin, nix, …). + Target(&'a str), + /// `gotool = "1.26.4"` — a pinned hermetic SDK downloaded from go.dev. + Hermetic(&'a str), +} + +/// Classify a `gotool` value into the toolchain it selects. `"host"` → +/// [`Toolchain::Host`]; anything starting with `//` → [`Toolchain::Target`]; +/// everything else is taken as a pinned hermetic version. +pub fn classify(spec: &str) -> Toolchain<'_> { + if is_host(spec) { + Toolchain::Host + } else if is_target_ref(spec) { + Toolchain::Target(spec) + } else { + Toolchain::Hermetic(spec) + } +} + /// Base provider package for the hermetic toolchain. The concrete target lives /// at `{TOOLCHAIN_PKG_PREFIX}/` (e.g. `@heph/go/toolchain/1.26.4`). pub const TOOLCHAIN_PKG_PREFIX: &str = "@heph/go/toolchain"; @@ -175,8 +221,11 @@ pub(crate) fn resolve_host_go() -> anyhow::Result { anyhow::bail!("go host toolchain: `go` not found on PATH") } -/// Query `GOROOT` from the host `go` binary. -pub(crate) fn host_goroot(go_bin: &std::path::Path) -> anyhow::Result { +/// Query `GOROOT` from a `go` binary (`go env GOROOT`). Shared by the host +/// toolchain and a target-ref toolchain: works for the host `go`, a relocated +/// SDK tree, and hostbin/nix wrappers alike — each reports the root matching its +/// own location. +pub(crate) fn go_env_goroot(go_bin: &std::path::Path) -> anyhow::Result { use anyhow::Context; let out = std::process::Command::new(go_bin) .args(["env", "GOROOT"]) @@ -521,6 +570,24 @@ mod tests { assert_eq!(version_from_pkg("@heph/go/toolchain/1.26.4/extra"), None); } + #[test] + fn test_classify_distinguishes_host_target_hermetic() { + assert_eq!(classify("host"), Toolchain::Host); + assert_eq!(classify("1.26.4"), Toolchain::Hermetic("1.26.4")); + assert_eq!( + classify("//@heph/bin:go"), + Toolchain::Target("//@heph/bin:go") + ); + assert_eq!( + classify("//some/pkg:go"), + Toolchain::Target("//some/pkg:go") + ); + // A bare version is never mistaken for a target ref. + assert!(!is_target_ref("1.26.4")); + assert!(!is_target_ref("host")); + assert!(is_target_ref("//@heph/bin:go")); + } + #[test] fn test_staged_goroot_is_versioned() { assert_eq!(staged_goroot("1.26.4"), "@heph/go/toolchain/1.26.4/go"); diff --git a/crates/plugingo-e2e/tests/build.rs b/crates/plugingo-e2e/tests/build.rs index 50fdb80f..8fc485df 100644 --- a/crates/plugingo-e2e/tests/build.rs +++ b/crates/plugingo-e2e/tests/build.rs @@ -1,6 +1,6 @@ mod common; -use common::{artifact_paths, fixture, make_workspace, make_workspace_host, require_go}; +use common::{artifact_paths, fixture, make_workspace, make_workspace_hermetic, require_go}; #[tokio::test] async fn test_simple_lib_build_lib() -> anyhow::Result<()> { @@ -187,18 +187,37 @@ async fn test_thirdparty_asm_build_lib_compiles() -> anyhow::Result<()> { Ok(()) } -/// Same build, but with `gotool = "host"`: the provider uses the host `go` -/// (resolved from PATH / `go env GOROOT` in-sandbox) instead of a hermetic SDK. +/// Same build, but with the **hermetic** Go SDK (downloaded + staged) instead of +/// the host `go` the rest of the suite uses. This is the dedicated coverage of +/// the hermetic install/download path. It hits the network, so a download +/// failure (offline CI) skips; any *other* failure fails loudly. #[tokio::test] -async fn test_with_dep_cmd_build_host_toolchain() -> anyhow::Result<()> { - require_go!(); +async fn test_with_dep_cmd_build_hermetic_toolchain() -> anyhow::Result<()> { let dir = fixture("with_dep")?; - let ws = make_workspace_host(dir)?; - let result = ws.run("//cmd:build").await?; - assert!( - !artifact_paths(&result).is_empty(), - "host-toolchain cmd build should produce at least one artifact" - ); + let ws = make_workspace_hermetic(dir)?; + match ws.run("//cmd:build").await { + Ok(result) => { + assert!( + !artifact_paths(&result).is_empty(), + "hermetic-toolchain cmd build should produce at least one artifact" + ); + } + Err(e) => { + let msg = format!("{e:?}").to_lowercase(); + let networky = msg.contains("download") + || msg.contains("dial") + || msg.contains("lookup") + || msg.contains("connection") + || msg.contains("proxy") + || msg.contains("timeout") + || msg.contains("go.dev"); + if networky { + eprintln!("skipping hermetic install e2e (SDK download unavailable): {e:?}"); + } else { + return Err(e); + } + } + } Ok(()) } diff --git a/crates/plugingo-e2e/tests/common/mod.rs b/crates/plugingo-e2e/tests/common/mod.rs index e4f18537..23a73aee 100644 --- a/crates/plugingo-e2e/tests/common/mod.rs +++ b/crates/plugingo-e2e/tests/common/mod.rs @@ -42,16 +42,22 @@ pub fn fixture(name: &str) -> anyhow::Result { copy_dir_to_tempdir(&testdata(name)) } +/// Path to the host `go` binary, feeding the `//@heph/bin:go` static target. +/// That target is only queried by target-ref toolchain tests (which require +/// `go`); host and hermetic builds never reference it. So when no host `go` is +/// on PATH (e.g. a hermetic-only CI runner) fall back to the bare name instead +/// of panicking — the value is then never used. fn go_bin_path() -> String { - let output = std::process::Command::new("go") + match std::process::Command::new("go") .args(["env", "GOROOT"]) .output() - .expect("go env GOROOT"); - let goroot = String::from_utf8(output.stdout) - .expect("utf8 goroot") - .trim() - .to_string(); - format!("{goroot}/bin/go") + { + Ok(output) if output.status.success() => { + let goroot = String::from_utf8_lossy(&output.stdout).trim().to_string(); + format!("{goroot}/bin/go") + } + _ => "go".to_string(), + } } /// Pinned hermetic Go toolchain version the e2e suite builds against. Mirrors @@ -96,21 +102,29 @@ fn sdk_checksums_for(gotool: &str) -> std::collections::HashMap .collect() } +/// Build the workspace using the **host** `go` (gotool = "host") resolved from +/// PATH / `go env GOROOT` in-sandbox. This is the default for the e2e suite: it +/// needs no network (no SDK download), only `go` on PATH — guard call sites with +/// `require_go!`. Use [`make_workspace_hermetic`] for the few tests that must +/// exercise the hermetic-install (download) route. pub fn make_workspace(dir: TempDir) -> anyhow::Result { - make_workspace_ordered(dir, false, true, &[], HERMETIC_GO) + make_workspace_ordered(dir, false, true, &[], HOST_GO) } -/// Build the workspace using the **host** `go` (gotool = "host") instead of a -/// hermetic SDK. Requires `go` on PATH (guard call sites with `require_go!`). -pub fn make_workspace_host(dir: TempDir) -> anyhow::Result { - make_workspace_ordered(dir, false, true, &[], HOST_GO) +/// Build the workspace using the **hermetic** Go SDK ([`HERMETIC_GO`]), +/// downloaded and staged by the provider. Reserved for tests that specifically +/// exercise the hermetic install/download path — it hits the network, so guard +/// call sites for offline tolerance. +pub fn make_workspace_hermetic(dir: TempDir) -> anyhow::Result { + make_workspace_ordered(dir, false, true, &[], HERMETIC_GO) } -/// Like [`make_workspace`] but with `fs.skip` entries, mirroring a config file's -/// `fs: { skip: [...] }`. Used to reproduce a codegen target whose generated Go -/// package lives under a skipped subtree (e.g. a generated `gen/**` tree). +/// Like [`make_workspace`] (host `go`) but with `fs.skip` entries, mirroring a +/// config file's `fs: { skip: [...] }`. Used to reproduce a codegen target whose +/// generated Go package lives under a skipped subtree (e.g. a generated `gen/**` +/// tree). pub fn make_workspace_fs_skip(dir: TempDir, skip: &[&str]) -> anyhow::Result { - make_workspace_ordered(dir, false, true, skip, HERMETIC_GO) + make_workspace_ordered(dir, false, true, skip, HOST_GO) } /// Same as [`make_workspace`] but registers the **go provider before** the @@ -125,7 +139,7 @@ pub fn make_workspace_go_first( dir: TempDir, foreign_name_guard: bool, ) -> anyhow::Result { - make_workspace_ordered(dir, true, foreign_name_guard, &[], HERMETIC_GO) + make_workspace_ordered(dir, true, foreign_name_guard, &[], HOST_GO) } fn make_workspace_ordered(