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.

- `--no-image-pull` — Don't pull `--image` before building; use the copy already present locally.

Lets you build against a locally-built (never pushed) image or a digest-pinned image already on disk, and to work offline — e.g. air-gapped verification against a pinned digest. Fails if the image isn't present.

- `-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
71 changes: 69 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 All @@ -212,6 +228,18 @@ impl Args {
cmd
}

/// Inspect a locally-present image, used to assert an image exists without
/// contacting the registry. Both engines group this under `image inspect` and
/// exit non-zero when the image is absent locally. Neither `docker run` nor
/// `container run` offers a portable "never pull" policy (Apple's CLI has no
/// `--pull` flag), so `--no-image-pull` is enforced with this precheck
/// instead of relying on the engine's implicit pull behavior.
pub(crate) fn image_inspect_command(&self, image: &str) -> Command {
let mut cmd = self.base_command();
cmd.args(["image", "inspect", image]);
cmd
}

pub(crate) fn run_command(&self, name: &str, ports: &[String]) -> Command {
let mut cmd = self.base_command();
cmd.args(["run", "-d", "--rm", "--name", name]);
Expand All @@ -227,6 +255,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 @@ -378,6 +415,21 @@ mod test {
assert_eq!(args_of(&cmd), ["image", "pull", "img:tag"]);
}

#[test]
fn docker_image_inspect_uses_image_inspect() {
let cmd = args(None, None).image_inspect_command("img:tag");
assert_eq!(program_of(&cmd), "docker");
assert_eq!(args_of(&cmd), ["image", "inspect", "img:tag"]);
}

#[test]
fn apple_image_inspect_uses_image_inspect_and_ignores_host() {
let cmd =
args(Some("ssh://host"), Some(Engine::AppleContainer)).image_inspect_command("img:tag");
assert_eq!(program_of(&cmd), "container");
assert_eq!(args_of(&cmd), ["image", "inspect", "img:tag"]);
}

#[test]
fn docker_run_passes_host_as_h_flag() {
let cmd =
Expand Down Expand Up @@ -440,6 +492,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
Loading
Loading