diff --git a/Cargo.lock b/Cargo.lock index 0d717e1be..e55e8acf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -353,9 +353,9 @@ dependencies = [ [[package]] name = "astral-tokio-tar" -version = "0.6.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ce73b17c62717c4b6a9af10b43e87c578b0cac27e00666d48304d3b7d2c0693" +checksum = "3c23f3af104b40a3430ccb90ed5f7bd877a8dc5c26fc92fde51a22b40890dcf9" dependencies = [ "filetime", "futures-core", @@ -1194,7 +1194,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1297,6 +1297,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "git+https://github.com/stellar/crate-git-revision?branch=dirty-untracked-files#43cb856d8f136c1cb5b9ab232e441b9fd776e849" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1978,7 +1988,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2752,7 +2762,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.0", "system-configuration", "tokio", "tower-service", @@ -4286,7 +4296,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.0", "thiserror 2.0.18", "tokio", "tracing", @@ -4323,9 +4333,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.0", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -4686,7 +4696,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4699,7 +4709,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -4757,7 +4767,7 @@ dependencies = [ "security-framework 3.4.0", "security-framework-sys", "webpki-root-certs 0.26.11", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5382,7 +5392,7 @@ dependencies = [ "chrono", "clap", "clap_complete", - "crate-git-revision", + "crate-git-revision 0.0.6 (git+https://github.com/stellar/crate-git-revision?branch=dirty-untracked-files)", "csv", "directories", "dotenvy", @@ -5400,6 +5410,7 @@ dependencies = [ "indexmap 2.11.0", "itertools 0.10.5", "keyring", + "libc", "mockito", "num-bigint", "open", @@ -5465,7 +5476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08582c2c21bd3f7b737bcb76db9d4ca473f8349d65f8952a50eeed8823f44aef" dependencies = [ "arbitrary", - "crate-git-revision", + "crate-git-revision 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "ethnum", "num-derive", "num-traits", @@ -5565,7 +5576,7 @@ checksum = "bdc156a0183eb584e57d45f63f3bd7023165980131d6eecc939fe5cda2490c63" dependencies = [ "arbitrary", "bytes-lit", - "crate-git-revision", + "crate-git-revision 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "ctor", "derive_arbitrary", "ed25519-dalek", @@ -5859,7 +5870,7 @@ version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee1832fb50c651ad10f734aaf5d31ca5acdfb197a6ecda64d93fcdb8885af913" dependencies = [ - "crate-git-revision", + "crate-git-revision 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "data-encoding", ] @@ -5870,7 +5881,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97e1a364048067bfcd24e6f1a93bba43eeb79c16b854596c841c3e8bab0bfa0c" dependencies = [ "clap", - "crate-git-revision", + "crate-git-revision 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "data-encoding", "serde", "serde_json", @@ -5884,7 +5895,7 @@ version = "0.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" dependencies = [ - "crate-git-revision", + "crate-git-revision 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "data-encoding", "heapless", ] @@ -5899,7 +5910,7 @@ dependencies = [ "base64 0.22.1", "cfg_eval", "clap", - "crate-git-revision", + "crate-git-revision 0.0.6 (registry+https://github.com/rust-lang/crates.io-index)", "escape-bytes", "ethnum", "hex", @@ -6101,7 +6112,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix 1.0.8", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] diff --git a/cmd/crates/soroban-test/tests/it/build.rs b/cmd/crates/soroban-test/tests/it/build.rs index 1863af976..aaa749e15 100644 --- a/cmd/crates/soroban-test/tests/it/build.rs +++ b/cmd/crates/soroban-test/tests/it/build.rs @@ -594,12 +594,24 @@ fn get_entries(fixture_path: &Path, outdir: &Path) -> Vec { Limits::none(), )) .filter(|entry| match entry { - // Ignore the meta entries that the SDK embeds that capture the SDK and - // Rust version, since these will change often and are not really - // relevant to this test. + // Ignore SDK-embedded keys (rsver, rssdkver) and stellar-cli-embedded + // build-record keys (bldbkd, bldimg, bldopt_*, source_*) — these + // change often and aren't what these tests are asserting on. Ok(ScMetaEntry::ScMetaV0(ScMetaV0 { key, .. })) => { let key = key.to_string(); - !matches!(key.as_str(), "rsver" | "rssdkver") + !matches!( + key.as_str(), + "rsver" + | "rssdkver" + | "bldbkd" + | "bldimg" + | "bldopt_manifest_path" + | "bldopt_package" + | "bldopt_profile" + | "bldopt_optimize" + | "source_repo" + | "source_rev" + ) } _ => true, }) diff --git a/cmd/soroban-cli/Cargo.toml b/cmd/soroban-cli/Cargo.toml index f44337204..0dd6933e1 100644 --- a/cmd/soroban-cli/Cargo.toml +++ b/cmd/soroban-cli/Cargo.toml @@ -127,8 +127,11 @@ whoami = "1.5.2" serde_with = "3.11.0" rustc_version = "0.4.1" +[target.'cfg(unix)'.dependencies] +libc = "0.2" + [build-dependencies] -crate-git-revision = "0.0.6" +crate-git-revision = { git = "https://github.com/stellar/crate-git-revision", branch = "dirty-untracked-files" } serde.workspace = true thiserror.workspace = true diff --git a/cmd/soroban-cli/src/commands/container/mod.rs b/cmd/soroban-cli/src/commands/container/mod.rs index 08203095d..6d025cac4 100644 --- a/cmd/soroban-cli/src/commands/container/mod.rs +++ b/cmd/soroban-cli/src/commands/container/mod.rs @@ -1,7 +1,7 @@ use crate::commands::global; pub(crate) mod logs; -mod shared; +pub(crate) mod shared; pub(crate) mod start; pub(crate) mod stop; diff --git a/cmd/soroban-cli/src/commands/contract/build.rs b/cmd/soroban-cli/src/commands/contract/build.rs index ad0012e8a..ddfe5fcf5 100644 --- a/cmd/soroban-cli/src/commands/contract/build.rs +++ b/cmd/soroban-cli/src/commands/contract/build.rs @@ -19,7 +19,7 @@ use stellar_xdr::curr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXd #[cfg(feature = "additional-libs")] use crate::commands::contract::optimize; use crate::{ - commands::{global, version}, + commands::{container::shared::Args as ContainerArgs, contract::build_docker, global, version}, print::Print, wasm, }; @@ -95,8 +95,102 @@ pub struct Cmd { #[arg(long, conflicts_with = "out_dir", help_heading = "Other")] pub print_commands_only: bool, + /// Build backend. + /// + /// - `local` (default): build using the host's rust toolchain. + /// - `docker`: build inside `docker.io/stellar/stellar-cli:latest` + /// (linux/amd64) using the local docker daemon. The entire build + /// pipeline runs inside the container; the host orchestrates only. + /// The resolved image digest is recorded in contract metadata. + /// - `docker=`: build inside the specified image (must have + /// `stellar` as its entrypoint). Pin via + /// `--backend docker=@sha256:...` for fully-reproducible builds. + /// + /// Aborted docker builds may leave a stopped container; clean with `docker container prune`. + #[arg( + long, + value_name = "BACKEND", + default_value = "local", + value_parser = parse_backend, + help_heading = "Build Backends", + )] + pub backend: Backend, + + #[command(flatten)] + pub container_args: ContainerArgs, + + /// Run cargo via `cargo +` to pin the rust toolchain. Set by + /// `verify` from the wasm's `rsver` meta entry; not user-facing. + #[arg(skip)] + pub rustup_toolchain: Option, + #[command(flatten)] pub build_args: BuildArgs, + + #[command(subcommand)] + pub action: Option, +} + +/// Subcommands of `stellar contract build`. +#[derive(clap::Subcommand, Debug, Clone)] +pub enum Action { + /// Verify a wasm by rebuilding it inside the Docker image recorded in its metadata. + Verify(super::verify::Cmd), +} + +/// Build backend selector for `--backend`. +#[derive(Clone, Debug, Default)] +pub enum Backend { + /// Build with the host's rust toolchain. + #[default] + Local, + /// Build inside a Docker container whose entrypoint is `stellar`. + Docker { image: String }, +} + +impl Backend { + /// Returns the docker image if the backend uses one, else `None`. + pub fn docker_image(&self) -> Option<&str> { + match self { + Self::Docker { image } => Some(image), + Self::Local => None, + } + } +} + +// Pinned by digest rather than `:latest` so that: +// - the digest is recorded in `bldimg` immediately (no post-pull resolution +// that can fail on Apple Silicon docker, where `RepoDigests` is often left +// empty after a cross-platform pull), +// - builds with the default backend are reproducible day-one without the +// user having to specify `--backend docker=...@sha256:...` themselves. +// +// To bump: `docker pull --platform linux/amd64 stellar/stellar-cli:`, +// then read the `Digest:` line. +const DEFAULT_DOCKER_IMAGE: &str = + "docker.io/stellar/stellar-cli@sha256:cb2fc3116a6ace37a77ca6bb88afb4bee57fc746cd556a4373f2c3ee95d4e917"; + +pub fn parse_backend(s: &str) -> Result { + match s { + "local" => Ok(Backend::Local), + "docker" => Ok(Backend::Docker { + image: DEFAULT_DOCKER_IMAGE.to_string(), + }), + _ => { + if let Some(image) = s.strip_prefix("docker=") { + if image.is_empty() { + return Err("docker image cannot be empty; use `--backend docker` for the default image".to_string()); + } + Ok(Backend::Docker { + image: image.to_string(), + }) + } else { + Err(format!( + "unknown backend {s:?}; expected `local`, `docker`, or `docker=`" + )) + } + } + } } /// Shared build options for meta and optimization, reused by deploy and upload. @@ -190,6 +284,13 @@ pub enum Error { #[error("wasm parsing error: {0}")] WasmParsing(String), + + #[error(transparent)] + Container(#[from] build_docker::Error), + + // Boxed to break the cycle between verify::Error::Build and build::Error::Verify. + #[error(transparent)] + Verify(Box), } const WASM_TARGET: &str = "wasm32v1-none"; @@ -208,7 +309,11 @@ impl Default for Cmd { out_dir: None, locked: false, print_commands_only: false, + backend: Backend::Local, + container_args: ContainerArgs { docker_host: None }, + rustup_toolchain: None, build_args: BuildArgs::default(), + action: None, } } } @@ -216,16 +321,25 @@ impl Default for Cmd { impl Cmd { /// Builds the project and returns the built WASM artifacts. #[allow(clippy::too_many_lines)] - pub fn run(&self, global_args: &global::Args) -> Result, Error> { + pub async fn run(&self, global_args: &global::Args) -> Result, Error> { + if let Some(Action::Verify(verify)) = &self.action { + // Box::pin breaks the recursion: verify.run() calls build::Cmd::run() + // for the rebuild, so the future would otherwise have infinite size. + Box::pin(verify.run(global_args)) + .await + .map_err(|e| Error::Verify(Box::new(e)))?; + return Ok(Vec::new()); + } let print = Print::new(global_args.quiet); let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?; let metadata = self.metadata()?; let packages = self.packages(&metadata)?; let target_dir = &metadata.target_directory; + let workspace_root = metadata.workspace_root.as_std_path(); // Run build configuration checks (only when actually building) if !self.print_commands_only { - run_checks(metadata.workspace_root.as_std_path(), &self.profile)?; + run_checks(workspace_root, &self.profile)?; } if let Some(package) = &self.package { @@ -239,9 +353,58 @@ impl Cmd { let wasm_target = get_wasm_target()?; let mut built_contracts = Vec::new(); - for p in packages { + // Detect git state once for the build. Embed source_repo/source_rev + // (and per-package source_path) when the workspace is a clean git + // checkout with an origin remote; warn (but proceed) otherwise so + // users know the wasm won't be reproducible against a public source. + let (source_repo, source_rev, git_root) = match detect_git_state(workspace_root) { + GitState::Clean { repo, rev, root } => (Some(repo), Some(rev), Some(root)), + GitState::Dirty => { + print.warnln( + "git working tree has uncommitted changes; source_repo/source_rev/bldopt_* not embedded in contract metadata. Commit changes for a reproducible build.", + ); + (None, None, None) + } + GitState::NotARepo => (None, None, None), + }; + // Mount the git repo root when available (so cross-workspace path + // dependencies and shared repo files are visible inside the + // container). Fall back to the cargo workspace root otherwise. + let mount_root = git_root.as_deref().unwrap_or(workspace_root); + + // `--backend docker` runs the entire build pipeline (cargo + meta + // injection + spec filtering + optional wasm-opt) inside a container + // whose entrypoint is `stellar`. The host orchestrates only. + if let Backend::Docker { image } = &self.backend { + return self + .run_docker( + image, + &print, + &packages, + target_dir.as_std_path(), + workspace_root, + mount_root, + git_root.as_deref(), + source_repo.as_deref(), + source_rev.as_deref(), + &wasm_target, + ) + .await; + } + + for (i, p) in packages.iter().enumerate() { + if i > 0 { + // Blank line separating successive contract builds in a workspace. + eprintln!(); + } let mut cmd = Command::new("cargo"); cmd.stdout(Stdio::piped()); + // `+` is rustup's explicit toolchain selector and overrides + // any `rust-toolchain.toml` in the workspace. Set by `verify` from + // the wasm's `rsver` meta entry. + if let Some(toolchain) = &self.rustup_toolchain { + cmd.arg(format!("+{toolchain}")); + } cmd.arg("rustc"); if self.locked { cmd.arg("--locked"); @@ -300,7 +463,22 @@ impl Cmd { .join(&self.profile) .join(&file); - self.inject_meta(&target_file_path)?; + let bldopt_manifest_path = git_root.as_deref().and_then(|gr| { + pathdiff::diff_paths(&p.manifest_path, gr) + .map(|p| p.to_string_lossy().into_owned()) + }); + self.inject_meta( + &target_file_path, + &ExtraMeta { + bldimg: None, + source_repo: source_repo.clone(), + source_rev: source_rev.clone(), + bldopt_manifest_path, + bldopt_package: Some(p.name.clone()), + bldopt_profile: Some(self.profile.clone()), + bldopt_optimize: self.build_args.optimize, + }, + )?; Self::filter_spec(&target_file_path)?; let final_path = if let Some(out_dir) = &self.out_dir { @@ -350,6 +528,129 @@ impl Cmd { Ok(built_contracts) } + /// Orchestrate a `--backend docker` build: pull the requested + /// stellar-cli image and run `stellar contract build` inside it + /// against the bind-mounted source. The in-container cli does cargo + + /// meta injection + spec filtering + optional wasm-opt itself; the + /// host only orchestrates and copies outputs to `--out-dir` if + /// requested. + #[allow(clippy::too_many_arguments)] + async fn run_docker( + &self, + image: &str, + print: &Print, + packages: &[Package], + target_dir: &Path, + workspace_root: &Path, + mount_root: &Path, + git_root: Option<&Path>, + source_repo: Option<&str>, + source_rev: Option<&str>, + wasm_target: &str, + ) -> Result, Error> { + // The user's --manifest-path (if any) is a host path; translate to + // the in-container `/source/...` form. If absent, fall back to the + // workspace root's Cargo.toml. + let host_manifest = self + .manifest_path + .clone() + .unwrap_or_else(|| workspace_root.join("Cargo.toml")); + let rel = pathdiff::diff_paths(&host_manifest, mount_root) + .unwrap_or_else(|| host_manifest.clone()); + let in_container_manifest = Path::new(build_docker::SOURCE_DIR) + .join(rel) + .to_string_lossy() + .into_owned(); + + // TODO(transitional): forward host-detected `source_*` and + // `bldopt_*` reproducibility meta as `--meta` entries to the + // in-container cli. Released `stellar/stellar-cli` images today + // don't auto-inject these on build, so without this pass-through + // the resulting wasm would be missing them. + // + // Once a `stellar/stellar-cli` image carrying this PR's auto- + // injection logic is published and adopted as the default, the + // in-container cli will set these itself (and its values, being + // first in the meta section, take precedence over the user-meta + // entries we add here per `find_meta`'s first-match semantics). + // At that point, remove this block and let the in-container cli + // be the source of truth. + let mut meta = self.build_args.meta.clone(); + if let Some(s) = source_repo { + meta.push(("source_repo".to_string(), s.to_string())); + } + if let Some(s) = source_rev { + meta.push(("source_rev".to_string(), s.to_string())); + } + meta.push(("bldopt_profile".to_string(), self.profile.clone())); + if self.build_args.optimize { + meta.push(("bldopt_optimize".to_string(), "true".to_string())); + } + // Per-package fields are only safe to forward when exactly one + // package will be built — passing a single value to a multi-package + // workspace build would attach the same `bldopt_package` / + // `bldopt_manifest_path` to every wasm. For multi-package builds we + // skip them and let the in-container cli supply them per-package + // (newer images will; older won't). + if packages.len() == 1 { + let p = &packages[0]; + meta.push(("bldopt_package".to_string(), p.name.clone())); + if let Some(rel) = git_root.and_then(|gr| { + pathdiff::diff_paths(&p.manifest_path, gr) + .map(|p| p.to_string_lossy().into_owned()) + }) { + meta.push(("bldopt_manifest_path".to_string(), rel)); + } + } + + let inner = build_docker::InnerBuildArgs { + manifest_path: in_container_manifest, + package: self.package.as_deref(), + profile: &self.profile, + features: self.features.as_deref(), + all_features: self.all_features, + no_default_features: self.no_default_features, + optimize: self.build_args.optimize, + meta, + }; + + build_docker::run_in_docker( + image, + None, + self.rustup_toolchain.as_deref(), + mount_root, + &inner, + &self.container_args, + print, + ) + .await?; + + // The in-container build wrote its outputs to the bind-mounted + // target dir; copy to --out-dir if requested. + let mut built = Vec::with_capacity(packages.len()); + for p in packages { + let wasm_name = p.name.replace('-', "_"); + let file = format!("{wasm_name}.wasm"); + let target_file_path = target_dir.join(wasm_target).join(&self.profile).join(&file); + + let final_path = if let Some(out_dir) = &self.out_dir { + fs::create_dir_all(out_dir).map_err(Error::CreatingOutDir)?; + let out_file_path = out_dir.join(&file); + fs::copy(&target_file_path, &out_file_path) + .map_err(Error::CopyingWasmFile)?; + out_file_path + } else { + target_file_path + }; + + built.push(BuiltContract { + name: p.name.clone(), + path: final_path, + }); + } + Ok(built) + } + fn features(&self) -> Option> { self.features .as_ref() @@ -417,9 +718,9 @@ impl Cmd { cmd.exec() } - fn inject_meta(&self, target_file_path: &PathBuf) -> Result<(), Error> { + fn inject_meta(&self, target_file_path: &PathBuf, extra: &ExtraMeta) -> Result<(), Error> { let mut wasm_bytes = fs::read(target_file_path).map_err(Error::ReadingWasmFile)?; - let xdr = self.encoded_new_meta()?; + let xdr = self.encoded_new_meta(extra)?; wasm_gen::write_custom_section(&mut wasm_bytes, META_CUSTOM_SECTION_NAME, &xdr); // Deleting .wasm file effectively unlinking it from /release/deps/.wasm preventing from overwrite @@ -468,7 +769,7 @@ impl Cmd { fs::write(target_file_path, new_wasm).map_err(Error::WritingWasmFile) } - fn encoded_new_meta(&self) -> Result, Error> { + fn encoded_new_meta(&self, extra: &ExtraMeta) -> Result, Error> { let mut new_meta: Vec = Vec::new(); // Always inject CLI version @@ -478,6 +779,42 @@ impl Cmd { }); new_meta.push(cli_meta_entry); + // Reproducible-build meta. `rsver` (rustc version) is recorded by + // soroban-sdk itself; here we add `bldimg` when --backend docker + // was used, and source_repo/source_rev when the workspace was a + // clean git checkout. + let kvs = [ + ("bldimg", extra.bldimg.as_deref()), + ("source_repo", extra.source_repo.as_deref()), + ("source_rev", extra.source_rev.as_deref()), + ( + "bldopt_manifest_path", + extra.bldopt_manifest_path.as_deref(), + ), + ("bldopt_package", extra.bldopt_package.as_deref()), + ("bldopt_profile", extra.bldopt_profile.as_deref()), + ( + "bldopt_optimize", + if extra.bldopt_optimize { + Some("true") + } else { + None + }, + ), + ]; + for (k, v) in kvs { + let Some(v) = v else { continue }; + let key: StringM = k + .to_string() + .try_into() + .map_err(|e| Error::MetaArg(format!("{k} is an invalid metadata key: {e}")))?; + let val: StringM = v + .to_string() + .try_into() + .map_err(|e| Error::MetaArg(format!("{v} is an invalid metadata value: {e}")))?; + new_meta.push(ScMetaEntry::ScMetaV0(ScMetaV0 { key, val })); + } + // Add args provided meta for (k, v) in self.build_args.meta.clone() { let key: StringM = k @@ -570,8 +907,120 @@ impl Cmd { } } - print.checkln("Build Complete\n"); + print.checkln("Build Complete"); + } +} + +/// Extra meta entries to embed in the wasm's `contractmetav0` custom section. +/// `cliver` is always embedded (separately). `rsver` is embedded by soroban-sdk. +#[derive(Default, Debug, Clone)] +struct ExtraMeta { + /// `bldimg`: fully-qualified container image used to build (e.g. + /// `docker.io/stellar/stellar-cli@sha256:...`). Set when `--backend + /// docker`; injected by the in-container cli via `--meta bldimg=...`, + /// not by this struct (which is only used on the local code path). + bldimg: Option, + /// `source_repo`: HTTPS URL of the workspace's git origin remote. + /// Set only when the workspace is a clean git checkout. + source_repo: Option, + /// `source_rev`: full SHA of the workspace's git HEAD commit. + /// Set only when the workspace is a clean git checkout. + source_rev: Option, + /// `bldopt_manifest_path`: package's `Cargo.toml` path relative to the + /// git repo root. Set only when the workspace is a clean git checkout. + bldopt_manifest_path: Option, + /// `bldopt_package`: cargo package name being built. + bldopt_package: Option, + /// `bldopt_profile`: cargo profile (e.g. `release`). + bldopt_profile: Option, + /// `bldopt_optimize`: present (with value `true`) iff `--optimize` was used. + bldopt_optimize: bool, +} + +enum GitState { + NotARepo, + Dirty, + Clean { + repo: String, + rev: String, + root: PathBuf, + }, +} + +fn detect_git_state(workspace_root: &Path) -> GitState { + let in_repo = Command::new("git") + .arg("-C") + .arg(workspace_root) + .args(["rev-parse", "--git-dir"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !in_repo { + return GitState::NotARepo; + } + let dirty = Command::new("git") + .arg("-C") + .arg(workspace_root) + .args(["status", "--porcelain"]) + .output() + .map(|o| !o.stdout.is_empty()) + .unwrap_or(true); + if dirty { + return GitState::Dirty; } + let Some(repo) = git_output(workspace_root, &["remote", "get-url", "origin"]) + .as_deref() + .and_then(remote_to_https) + else { + return GitState::Dirty; + }; + let Some(rev) = git_output(workspace_root, &["rev-parse", "HEAD"]) else { + return GitState::Dirty; + }; + let Some(root) = + git_output(workspace_root, &["rev-parse", "--show-toplevel"]).map(PathBuf::from) + else { + return GitState::Dirty; + }; + GitState::Clean { repo, rev, root } +} + +fn git_output(workspace_root: &Path, args: &[&str]) -> Option { + let out = Command::new("git") + .arg("-C") + .arg(workspace_root) + .args(args) + .output() + .ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Convert a git remote URL to a canonical `https://...` form. +fn remote_to_https(url: &str) -> Option { + let url = url.trim(); + let canonical = if url.starts_with("https://") || url.starts_with("http://") { + url.to_string() + } else if let Some(rest) = url.strip_prefix("git@") { + let (host, path) = rest.split_once(':')?; + format!("https://{host}/{path}") + } else if let Some(rest) = url.strip_prefix("ssh://git@") { + format!("https://{rest}") + } else if let Some(rest) = url.strip_prefix("ssh://") { + format!("https://{rest}") + } else if let Some(rest) = url.strip_prefix("git://") { + format!("https://{rest}") + } else { + return None; + }; + Some( + canonical + .strip_suffix(".git") + .unwrap_or(&canonical) + .to_string(), + ) } fn serialize_command(cmd: &Command) -> String { @@ -845,4 +1294,43 @@ mod tests { "shlex round-trip failed: {raw_arg:?} not found as a single token in {tokens:?}" ); } + + #[test] + fn remote_to_https_normalizes_common_forms() { + let cases = [ + ("https://github.com/x/y", "https://github.com/x/y"), + ("https://github.com/x/y.git", "https://github.com/x/y"), + ("http://example.com/x/y.git", "http://example.com/x/y"), + ("git@github.com:x/y.git", "https://github.com/x/y"), + ("ssh://git@github.com/x/y.git", "https://github.com/x/y"), + ("ssh://git.example.com/x/y", "https://git.example.com/x/y"), + ("git://github.com/x/y.git", "https://github.com/x/y"), + ]; + for (input, want) in cases { + assert_eq!( + remote_to_https(input).as_deref(), + Some(want), + "input: {input}" + ); + } + assert_eq!(remote_to_https("notaurl"), None); + } + + #[test] + fn parse_backend_cases() { + assert!(matches!(parse_backend("local"), Ok(Backend::Local))); + let parsed = parse_backend("docker").unwrap(); + match parsed { + Backend::Docker { image } => assert_eq!(image, DEFAULT_DOCKER_IMAGE), + other @ Backend::Local => panic!("expected Docker, got {other:?}"), + } + let parsed = parse_backend("docker=quay.io/foo/bar:tag").unwrap(); + match parsed { + Backend::Docker { image } => assert_eq!(image, "quay.io/foo/bar:tag"), + other @ Backend::Local => panic!("expected Docker, got {other:?}"), + } + assert!(parse_backend("docker=").is_err()); + assert!(parse_backend("docker-all").is_err()); + assert!(parse_backend("nonsense").is_err()); + } } diff --git a/cmd/soroban-cli/src/commands/contract/build_docker.rs b/cmd/soroban-cli/src/commands/contract/build_docker.rs new file mode 100644 index 000000000..9b03a9b00 --- /dev/null +++ b/cmd/soroban-cli/src/commands/contract/build_docker.rs @@ -0,0 +1,500 @@ +//! `--backend docker` build backend. +//! +//! Runs the entire `stellar contract build` pipeline inside a container +//! whose entrypoint is `stellar` (the official `stellar/stellar-cli` +//! image, or any user-supplied image with the same shape). The host +//! orchestrates only — pull, set up bind mounts, run, stream logs. +//! +//! The host CLI's version is irrelevant: whatever cli is in the image is +//! what builds the wasm and what records `cliver` / `rsver` / source meta +//! into the wasm. The host injects `bldimg` (the pulled image's resolved +//! digest) via the inner cli's `--meta` mechanism — no new flags. +//! +//! For `verify`, the recorded `bldimg` is pulled (so the same cli runs) +//! and `RUSTUP_TOOLCHAIN` is set from the wasm's `rsver` so the rust +//! toolchain matches whatever the original build used. +//! +//! User-supplied images must: +//! - Have `stellar` as their entrypoint +//! - Have `rustup` available with the `wasm32v1-none` target installed +//! (preflight-checked before the build runs) + +use std::path::Path; + +use bollard::{ + models::ContainerCreateBody, + query_parameters::{ + CreateContainerOptions, CreateImageOptions, LogsOptions, RemoveContainerOptions, + StartContainerOptions, WaitContainerOptions, + }, + service::HostConfig, + Docker, +}; +use futures_util::{StreamExt, TryStreamExt}; + +use crate::{ + commands::container::shared::{Args as ContainerArgs, Error as ContainerError}, + print::Print, +}; + +const PLATFORM: &str = "linux/amd64"; +/// Where the workspace gets bind-mounted inside the container. Matches the +/// official `stellar/stellar-cli` image's `WORKDIR`. Cargo writes its +/// target directory under this path, so the host reads the wasm via the +/// same bind mount — no separate `/target` mount needed. +pub const SOURCE_DIR: &str = "/source"; +const REGISTRY_DIR: &str = "/usr/local/cargo/registry"; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("cannot connect to docker daemon; is it running? ({0})")] + RuntimeNotRunning(ContainerError), + + #[error("pulling docker image {image}: {source}")] + ImagePull { + image: String, + source: bollard::errors::Error, + }, + + #[error("inspecting docker image {image}: {source}")] + ImageInspect { + image: String, + source: bollard::errors::Error, + }, + + #[error("docker image {image} has no repository digest. Either pin via --backend docker=/@sha256:..., or remove any locally-built image at this tag (`docker rmi {image}`) and let the default re-pull")] + NoDigest { image: String }, + + #[error("pulling docker image {image}: daemon reported error: {message}")] + PullDaemonError { image: String, message: String }, + + #[error("build failed inside docker container (exit {0})")] + BuildExit(i64), + + #[error("docker run: {0}")] + Runtime(#[from] bollard::errors::Error), + + #[error("resolving CARGO_HOME: {0}")] + CargoHome(std::io::Error), +} + +/// Forwarded host build args used to construct the inner +/// `stellar contract build` invocation. `manifest_path` is expected to +/// already be in container-relative form (`/source/...`). `meta` holds both +/// the user's `--meta` entries and any host-detected entries (e.g. +/// `source_repo`, `source_rev`, `bldopt_*`) that are forwarded as +/// transitional pass-throughs while the published cli image catches up. +pub struct InnerBuildArgs<'a> { + pub manifest_path: String, + pub package: Option<&'a str>, + pub profile: &'a str, + pub features: Option<&'a str>, + pub all_features: bool, + pub no_default_features: bool, + pub optimize: bool, + pub meta: Vec<(String, String)>, +} + +/// Pull the image (if needed), then run the in-container +/// `stellar contract build --backend local --meta bldimg=` against +/// the bind-mounted source. Returns the resolved image digest for the host +/// to record. +/// +/// `rsver` is `None` for fresh builds and `Some()` for verify; +/// when set, `RUSTUP_TOOLCHAIN` inside the container is pinned to that +/// toolchain so rustup-managed cargo uses the matching rust version. +#[allow(clippy::too_many_arguments)] +pub async fn run_in_docker( + image: &str, + pre_resolved: Option<&str>, + rsver: Option<&str>, + mount_root: &Path, + inner: &InnerBuildArgs<'_>, + container_args: &ContainerArgs, + print: &Print, +) -> Result { + let docker: Docker = container_args + .connect_to_docker(print) + .await + .map_err(Error::RuntimeNotRunning)?; + + let resolved = if let Some(r) = pre_resolved { + r.to_string() + } else { + // Skip the pull when the image is already local. For digest-pinned + // references the digest is immutable, so a present image is the + // image. This also sidesteps a bollard quirk where pulling an + // already-present digest-pinned image surfaces the daemon's + // "cannot overwrite digest" event as a stream error. + if docker.inspect_image(image).await.is_err() { + pull_image(&docker, image, print).await?; + } + resolve_image_digest(&docker, image).await? + }; + + print.infoln(format_inner_cmd(inner, &resolved)); + run_inner_build(&docker, &resolved, inner, rsver, mount_root).await?; + + Ok(resolved) +} + +async fn run_inner_build( + docker: &Docker, + image: &str, + inner: &InnerBuildArgs<'_>, + rsver: Option<&str>, + mount_root: &Path, +) -> Result<(), Error> { + let cargo_home = home::cargo_home().map_err(Error::CargoHome)?; + let binds = vec![ + format!("{}:{}", mount_root.display(), SOURCE_DIR), + format!("{}:{}", cargo_home.join("registry").display(), REGISTRY_DIR), + ]; + + let mut env = vec![ + format!("SOURCE_DATE_EPOCH={}", source_date_epoch(mount_root)), + "CARGO_TERM_COLOR=always".to_string(), + ]; + if let Some(t) = rsver { + env.push(format!("RUSTUP_TOOLCHAIN={t}")); + } + + // Override the image's entrypoint with a small shim that ensures the + // wasm target is installed for the active rust toolchain, then exec's + // `stellar`. Two reasons: + // + // - When `RUSTUP_TOOLCHAIN=` selects a toolchain other than the + // image's default (typical at verify time), the image's pre-installed + // `wasm32v1-none` target is associated with the *other* toolchain, + // not the selected one — `cargo build --target=wasm32v1-none` would + // fail. `rustup target add` is idempotent (and quick, when the target + // is already present) so always running it is safe. + // - The official `stellar/stellar-cli` image's stock entrypoint is a + // wrapper script that launches dbus + gnome-keyring before exec-ing + // `stellar`; that setup is irrelevant for `contract build` and dbus + // refuses to start when the container runs as a host UID with no + // `/etc/passwd` entry. Skipping it keeps the host UID mapping intact. + // + // TODO: remove this entrypoint override once + // https://github.com/stellar/stellar-cli/issues/2545 is implemented and + // the published image's entrypoint installs the wasm target itself + // (and doesn't drag dbus/gnome-keyring into the contract-build path). + let entrypoint = vec![ + "sh".to_string(), + "-c".to_string(), + "rustup --quiet target add wasm32v1-none && exec stellar \"$@\"".to_string(), + ]; + let argv = build_inner_argv(inner, image); + + let config = ContainerCreateBody { + image: Some(image.to_string()), + entrypoint: Some(entrypoint), + cmd: Some(argv), + env: Some(env), + working_dir: Some(SOURCE_DIR.to_string()), + user: current_uid_gid(), + attach_stdout: Some(true), + attach_stderr: Some(true), + host_config: Some(HostConfig { + binds: Some(binds), + auto_remove: Some(false), + ..Default::default() + }), + ..Default::default() + }; + + let container_id = docker + .create_container(None::, config) + .await? + .id; + + let result = stream_and_wait(docker, &container_id).await; + + let _ = docker + .remove_container( + &container_id, + Some(RemoveContainerOptions { + force: true, + ..Default::default() + }), + ) + .await; + + result +} + +async fn stream_and_wait(docker: &Docker, container_id: &str) -> Result<(), Error> { + docker + .start_container(container_id, None::) + .await?; + + let mut log_stream = docker.logs( + container_id, + Some(LogsOptions { + follow: true, + stdout: true, + stderr: true, + ..Default::default() + }), + ); + while let Some(item) = log_stream.next().await { + let s = item?.to_string(); + let s = s.trim_end_matches('\n'); + if !s.is_empty() { + eprintln!("{s}"); + } + } + + let mut wait_stream = docker.wait_container(container_id, None::); + let mut exit_code: i64 = 0; + while let Some(res) = wait_stream.next().await { + match res { + Ok(r) => exit_code = r.status_code, + Err(bollard::errors::Error::DockerContainerWaitError { code, .. }) => exit_code = code, + Err(e) => return Err(Error::Runtime(e)), + } + } + if exit_code != 0 { + return Err(Error::BuildExit(exit_code)); + } + Ok(()) +} + +/// Build the argv passed via `cmd`. The image's entrypoint is overridden +/// to `sh -c '