Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions FULL_HELP_DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,26 @@ To view the commands that will be executed, without executing them, use the --pr

**Usage:** `stellar contract build [OPTIONS]`

###### **Container Options:**

- `--image <IMAGE>` — Build inside this container image (e.g. `docker.io/stellar/stellar-cli:latest`). When set, the build runs in the container against the bind-mounted working tree instead of locally. Any tag or digest ref is accepted.

On Linux the container runs as your uid:gid so built wasm isn't root-owned; this assumes the image keeps CARGO_HOME/RUSTUP_HOME writable by non-root users, as the official image does.

- `--pull` — Pull `--image` before building to refresh a moving tag.

By default the build uses the image already present locally and doesn't pull (matching `docker run`), so a locally-built or digest-pinned image is used as-is. Pass `--pull` to fetch the newest image for the tag first.

- `-d`, `--docker-host <DOCKER_HOST>` — Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock
- `--engine <ENGINE>` — Container engine to use [default: docker]

Possible values:
- `docker`: Docker, or any Docker-compatible CLI
- `apple-container`: Apple's `container` CLI (macOS 26+, Apple silicon)

- `--cpus <CPUS>` — Limit the number of CPUs available to the container, e.g. `2`. A whole number: Apple's `container` engine does not accept fractional CPUs
- `--memory <MEMORY>` — Limit the memory available to the container, e.g. `2g` or `512m`

###### **Features:**

- `--features <FEATURES>` — Build with the list of features activated, space or comma separated
Expand Down
87 changes: 87 additions & 0 deletions cmd/crates/soroban-test/tests/it/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,93 @@ cargo rustc {} --crate-type=cdylib --target=wasm32v1-none --release",
.stdout(predicate::eq(with_flags(expected.as_str())));
}

#[test]
fn build_with_image_print_commands_only_multi_package() {
// With `--image`, `--print-commands-only` prints the container run command
// instead of the local cargo commands, without touching the engine. The
// workspace has several default-member cdylibs, so they chain through
// `/bin/sh -c`.
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/");
sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--image")
.arg("docker.io/stellar/stellar-cli:latest")
.arg("--print-commands-only")
.assert()
.success()
.stdout(
predicate::str::starts_with("docker run --rm")
.and(predicate::str::contains("-w /source"))
.and(predicate::str::contains("--entrypoint /bin/sh"))
.and(predicate::str::contains(
"docker.io/stellar/stellar-cli:latest",
))
.and(predicate::str::contains(
"stellar contract build --package=add",
))
.and(predicate::str::contains("&&"))
.and(predicate::str::contains("cargo rustc").not()),
);
}

#[test]
fn build_with_image_print_commands_only_single_package() {
// A single package runs the image's default entrypoint directly — no shell
// wrapper.
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/");
sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--image")
.arg("docker.io/stellar/stellar-cli:latest")
.arg("--package=add")
.arg("--print-commands-only")
.assert()
.success()
.stdout(
predicate::str::contains(
"'docker.io/stellar/stellar-cli:latest' contract build --package=add --optimize",
)
.and(predicate::str::contains("--entrypoint").not())
.and(predicate::str::contains("cargo rustc").not()),
);
}

#[test]
fn build_with_image_selects_package_by_manifest_path() {
// With `--image` and a `--manifest-path` pointing at a single member, only
// that package is built — mirroring the local build's package selection —
// instead of chaining every default-member cdylib. So it takes the
// single-package form (image's default entrypoint, no `/bin/sh` chain).
let sandbox = TestEnv::default();
let cargo_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let fixture_path = cargo_dir.join("tests/fixtures/workspace/");
sandbox
.new_assert_cmd("contract")
.current_dir(fixture_path)
.arg("build")
.arg("--image")
.arg("docker.io/stellar/stellar-cli:latest")
.arg(manifest_path_arg(&add_path()))
.arg("--print-commands-only")
.assert()
.success()
.stdout(
predicate::str::contains("--package=add")
.and(predicate::str::contains("--package=call").not())
.and(predicate::str::contains("--package=add2").not())
.and(predicate::str::contains("&&").not())
.and(predicate::str::contains("--entrypoint").not()),
);
}

#[test]
fn build_package_by_name() {
let sandbox = TestEnv::default();
Expand Down
5 changes: 5 additions & 0 deletions cmd/soroban-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ whoami = "1.5.2"
serde_with = "3.11.0"
rustc_version = "0.4.1"

# Used to read the current uid/gid so container builds don't leave root-owned
# artifacts on Linux bind mounts.
[target.'cfg(target_os = "linux")'.dependencies]
rustix = { version = "1", features = ["process"] }

[build-dependencies]
crate-git-revision = "0.0.9"
serde.workspace = true
Expand Down
44 changes: 42 additions & 2 deletions cmd/soroban-cli/src/commands/container/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ impl fmt::Display for Engine {
}
}

#[derive(Debug, clap::Parser, Clone)]
#[derive(Debug, clap::Parser, Clone, Default)]
pub struct Args {
/// Optional argument to override the default docker host. This is useful when you are using a non-standard docker host path for your Docker-compatible container runtime, e.g. Docker Desktop defaults to $HOME/.docker/run/docker.sock instead of /var/run/docker.sock
#[arg(short = 'd', long, help = DOCKER_HOST_HELP, env = "DOCKER_HOST")]
Expand Down Expand Up @@ -187,11 +187,27 @@ impl Args {
self.engine().is_container_not_found(stderr)
}

/// The engine invocation prefix for copy-pasteable reproduce/print lines,
/// mirroring [`base_command`](Self::base_command): the binary name plus
/// `-H <host>` when the docker engine honors a configured
/// `--docker-host`/`DOCKER_HOST`. Shell-escaped so it round-trips.
pub(crate) fn command_prefix(&self) -> String {
let engine = self.engine();
let mut prefix = engine.program().to_string();
if engine.supports_docker_host() {
if let Some(host) = &self.docker_host {
prefix.push_str(" -H ");
prefix.push_str(&shell_escape::escape(host.into()));
}
}
prefix
}

/// Builds the base command for the selected engine. For docker, a
/// `--docker-host` (or `DOCKER_HOST` env) value is passed as `-H <host>`; the
/// `-H` flag outranks `DOCKER_CONTEXT`, so the override is honored even when a
/// docker context is active. Host resolution is otherwise left to the CLI.
fn base_command(&self) -> Command {
pub(crate) fn base_command(&self) -> Command {
let engine = self.engine();
let mut cmd = Command::new(engine.program());
if engine.supports_docker_host() {
Expand Down Expand Up @@ -227,6 +243,15 @@ impl Args {
cmd
}

/// Immediately kill (SIGKILL) a running container by name. Used to tear down
/// a build container when the CLI is interrupted, where `stop`'s grace
/// period would let the build keep running while we block waiting.
pub(crate) fn kill_command(&self, name: &str) -> Command {
let mut cmd = self.base_command();
cmd.args(["kill", name]);
cmd
}

pub(crate) fn logs_command(&self, name: &str) -> Command {
let mut cmd = self.base_command();
match self.engine() {
Expand Down Expand Up @@ -440,6 +465,21 @@ mod test {
);
}

#[test]
fn command_prefix_reflects_docker_host_and_apple_ignores_it() {
assert_eq!(args(None, None).command_prefix(), "docker");
// The host is shell-escaped so the reproduce line round-trips.
assert_eq!(
args(Some("ssh://host"), None).command_prefix(),
"docker -H 'ssh://host'"
);
// Apple ignores the host and uses its own binary name.
assert_eq!(
args(Some("ssh://host"), Some(Engine::AppleContainer)).command_prefix(),
"container"
);
}

#[test]
fn host_ignored_warning_only_for_non_docker_engines() {
assert!(args(Some("ssh://host"), Some(Engine::AppleContainer))
Expand Down
105 changes: 100 additions & 5 deletions cmd/soroban-cli/src/commands/contract/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ use stellar_xdr::{Limited, Limits, ScMetaEntry, ScMetaV0, StringM, WriteXdr};
use crate::commands::contract::optimize;
use crate::utils::XDR_DEPTH_LIMIT;
use crate::{
commands::{global, version},
commands::{
container::shared::{Args as ContainerArgs, RunArgs as ContainerRunArgs},
global, version, HEADING_CONTAINER,
},
print::Print,
wasm,
};

pub mod container;

/// A built WASM artifact with its package name and file path.
#[derive(Debug, Clone)]
pub struct BuiltContract {
Expand Down Expand Up @@ -97,8 +102,39 @@ pub struct Cmd {
#[arg(long, conflicts_with = "out_dir", help_heading = "Other")]
pub print_commands_only: bool,

/// Build inside this container image (e.g.
/// `docker.io/stellar/stellar-cli:latest`). When set, the build runs in the
/// container against the bind-mounted working tree instead of locally. Any
/// tag or digest ref is accepted.
///
/// On Linux the container runs as your uid:gid so built wasm isn't
/// root-owned; this assumes the image keeps CARGO_HOME/RUSTUP_HOME writable
/// by non-root users, as the official image does.
#[arg(long, help_heading = HEADING_CONTAINER)]
pub image: Option<String>,
Comment thread
fnando marked this conversation as resolved.

/// Pull `--image` before building to refresh a moving tag.
///
/// By default the build uses the image already present locally and doesn't
/// pull (matching `docker run`), so a locally-built or digest-pinned image is
/// used as-is. Pass `--pull` to fetch the newest image for the tag first.
#[arg(long, requires = "image", help_heading = HEADING_CONTAINER)]
pub pull: bool,

#[command(flatten)]
pub build_args: BuildArgs,

// Declared after `build_args` so their `next_help_heading` groups them under
// the Container heading without leaking it onto the ungrouped flags above.
/// Container connection options (`--engine`, `--docker-host`) used when
/// `--image` is set. `--docker-host` is honored only by the docker engine.
#[command(flatten, next_help_heading = HEADING_CONTAINER)]
pub container_args: ContainerArgs,
Comment thread
fnando marked this conversation as resolved.

/// Container resource limits (`--cpus`, `--memory`) applied to the
/// `--image` build container.
#[command(flatten, next_help_heading = HEADING_CONTAINER)]
pub run_args: ContainerRunArgs,
Comment thread
fnando marked this conversation as resolved.
}

/// Shared build options for meta and optimization, reused by deploy and upload.
Expand Down Expand Up @@ -205,10 +241,13 @@ pub enum Error {

#[error("wasm parsing error: {0}")]
WasmParsing(String),

#[error(transparent)]
Container(#[from] container::Error),
}

const WASM_TARGET: &str = "wasm32v1-none";
const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
pub(crate) const WASM_TARGET: &str = "wasm32v1-none";
pub(crate) const WASM_TARGET_OLD: &str = "wasm32-unknown-unknown";
const META_CUSTOM_SECTION_NAME: &str = "contractmetav0";

impl Default for Cmd {
Expand All @@ -223,16 +262,26 @@ impl Default for Cmd {
out_dir: None,
locked: false,
print_commands_only: false,
image: None,
pull: false,
build_args: BuildArgs::default(),
container_args: ContainerArgs::default(),
run_args: ContainerRunArgs::default(),
}
}
}

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<Vec<BuiltContract>, Error> {
pub async fn run(&self, global_args: &global::Args) -> Result<Vec<BuiltContract>, Error> {
let print = Print::new(global_args.quiet);

// When an image is given, build inside that container instead of locally.
if self.image.is_some() {
return container::run(self, global_args, &print).await;
}

let working_dir = env::current_dir().map_err(Error::GettingCurrentDir)?;
let metadata = self.metadata()?;
let packages = self.packages(&metadata)?;
Expand Down Expand Up @@ -716,7 +765,7 @@ fn get_rustflags() -> Option<Vec<String>> {
None
}

fn get_wasm_target() -> Result<String, Error> {
pub(crate) fn get_wasm_target() -> Result<String, Error> {
let Ok(current_version) = version() else {
return Ok(WASM_TARGET.into());
};
Expand Down Expand Up @@ -841,6 +890,52 @@ pub fn filter_and_dedup_spec(
mod tests {
use super::*;

#[test]
fn image_flag_parses_with_tag_and_container_options() {
let cmd = Cmd::try_parse_from([
"build",
"--image",
"docker.io/stellar/stellar-cli:latest",
"--meta",
"field=value",
"--engine",
"docker",
"--cpus",
"2",
])
.expect("--image with a tag ref and container options must parse");
assert_eq!(
cmd.image.as_deref(),
Some("docker.io/stellar/stellar-cli:latest")
);
assert_eq!(
cmd.build_args.meta,
vec![("field".to_string(), "value".to_string())]
);
assert_eq!(cmd.run_args.cpus, Some(2));
}

#[test]
fn image_defaults_to_none() {
let cmd = Cmd::try_parse_from(["build"]).unwrap();
assert!(cmd.image.is_none());
}

#[test]
fn pull_requires_image() {
let cmd = Cmd::try_parse_from([
"build",
"--image",
"docker.io/stellar/stellar-cli:latest",
"--pull",
])
.expect("--pull with --image must parse");
assert!(cmd.pull);

// Without --image the flag is rejected rather than silently ignored.
assert!(Cmd::try_parse_from(["build", "--pull"]).is_err());
}

#[test]
fn serialize_command_shell_escapes_args_with_metacharacters() {
let raw_arg = "--manifest-path=/path/to/contract;touch PWNED;#/Cargo.toml";
Expand Down
Loading
Loading