Skip to content
Merged
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
105 changes: 105 additions & 0 deletions .github/workflows/smoke-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: Compose smoke tests

# Manual only: each scenario stands up a full docker-compose cluster (N nodes +
# relay + prometheus) and observes it for 2 minutes. The resource-heavy
# very_large scenario remains local-only. Too heavy to attach to push or
# pull_request.
on:
workflow_dispatch:
inputs:
scenarios:
description: "Scenario filter (go test -run regex). Empty runs the CI matrix; very_large is excluded."
type: string
default: ""
go_timeout:
description: "go test -timeout. Must exceed the sum of the selected scenarios."
type: string
default: "50m"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
actions: read

jobs:
smoke:
name: Compose smoke tests
runs-on: ubuntu-24.04
# Covers the pluto image build (release build of pluto-cli inside docker,
# uncached on a fresh runner) plus the scenario matrix.
timeout-minutes: 90

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: test-infra/compose/go.mod
cache-dependency-path: test-infra/compose/go.sum

- name: Build pluto image
# Built here rather than letting the harness do it inside `go test`, so
# the release compile does not consume the -timeout budget (which should
# bound observation, not compilation) and a build break fails in its own
# step. The harness still calls `docker build` during its define step;
# replicating the tag and build-arg exactly makes that a cache hit.
#
# Deliberately plain `docker build`, not buildx: setup-buildx-action's
# docker-container driver keeps a separate cache that the harness's
# `docker build` would not see, so the image would be compiled twice.
timeout-minutes: 40
run: |
docker build -t pluto:local \
--build-arg "GIT_COMMIT_HASH_SHORT=$(git rev-parse --short=7 HEAD)" .

- name: Run smoke tests
working-directory: test-infra/compose
# Inputs are passed as env vars, never interpolated into the script:
# `${{ inputs.* }}` inside `run:` is substituted before the shell sees
# it, so a crafted value would execute as shell.
env:
# The pluto image is built from this checkout during the define step.
PLUTO_REPO: ${{ github.workspace }}
SCENARIOS: ${{ inputs.scenarios }}
GO_TIMEOUT: ${{ inputs.go_timeout }}
LOG_DIR: ${{ runner.temp }}/smoke-logs
run: |
mkdir -p "$LOG_DIR"

args=(
./smoke -v -integration
"-timeout=$GO_TIMEOUT"
"-log-dir=$LOG_DIR"
# Requires more CPU than a GitHub-hosted runner provides reliably.
"-skip=^TestSmoke/very_large$"
# Containers run as root, so the artefacts they leave in the compose
# dir are root-owned; without this the runner cannot clean them up.
-sudo-perms
)
if [ -n "$SCENARIOS" ]; then
args+=(-run "$SCENARIOS")
fi

go test "${args[@]}"

- name: Upload scenario logs
# Always: a passing run's logs are the baseline for triaging the next
# failure, and these clusters are expensive to reproduce.
if: always()
uses: actions/upload-artifact@v4
with:
name: smoke-logs-${{ github.run_id }}
path: ${{ runner.temp }}/smoke-logs
if-no-files-found: warn
retention-days: 7

- name: Collect container state on failure
if: failure()
run: |
docker ps -a || true
docker images || true
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ test-infra/sszfixtures/sszfixtures

.claude/worktrees/
.claude/scheduled_tasks.lock
test-cluster
test-cluster

# Smoke-test docker-compose logs (go test -log-dir)
test-infra/compose/**/*.log
43 changes: 43 additions & 0 deletions crates/cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,46 @@ pub enum CreateCommands {
)]
Cluster(Box<CreateClusterArgs>),
}

/// Builds the fully-configured root command.
///
/// Use this instead of [`Cli::command`] anywhere the command is rendered or
/// parsed, so every entrypoint gets the same hardening.
pub fn build_command() -> clap::Command {
let cmd =
crate::commands::test::update_test_cases_help(<Cli as clap::CommandFactory>::command());

hide_env_values(ignore_empty_env(cmd))
}

/// Treats a `CHARON_*` variable that is set but empty as unset.
///
/// Charon resolves env vars through Viper, which reports an empty value as
/// absent (it does not enable `AllowEmptyEnv`). clap instead binds the literal
/// `""`, so a common empty placeholder would fail numeric parsing, turn a
/// comma-delimited list into one blank element, or flip an `Option` flag from
/// `None` to `Some("")`.
fn ignore_empty_env(cmd: clap::Command) -> clap::Command {
cmd.mut_args(|arg| {
let is_empty = arg
.get_env()
.is_some_and(|name| std::env::var_os(name).is_some_and(|value| value.is_empty()));

if is_empty {
arg.env(clap::builder::Resettable::Reset)
} else {
arg
}
})
.mut_subcommands(ignore_empty_env)
}

/// Suppresses environment variable *values* in `--help`.
///
/// clap renders `[env: VAR=value]` by default, printing the caller's actual
/// value — including secrets such as `CHARON_KEYMANAGER_AUTH_TOKEN(S)` — into
/// terminals, CI logs and support captures. The variable names stay documented.
fn hide_env_values(cmd: clap::Command) -> clap::Command {
cmd.mut_args(|arg| arg.hide_env_values(true))
.mut_subcommands(hide_env_values)
}
Loading
Loading