From f94391ee6dd20f1f26b0a6e38541c23b45bdd023 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 30 Aug 2026 20:39:35 +0800 Subject: [PATCH 1/2] test: add Portal mutation smoke Add a pinned Apollo Portal and H2 mutation workflow covering config merge, confirmation and permission failures, release creation, and rollback. Isolate CLI state and redact mutation response values so smoke diagnostics remain secret-safe.\n\nRefs apolloconfig/apollo#5669 --- .github/workflows/ci.yml | 2 + .github/workflows/mutation-smoke.yml | 48 +++ README.md | 32 ++ README.zh.md | 27 ++ scripts/mutation-smoke.sh | 615 +++++++++++++++++++++++++++ src/command.rs | 6 +- src/config.rs | 43 ++ tests/openapi.rs | 13 +- 8 files changed, 780 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/mutation-smoke.yml create mode 100755 scripts/mutation-smoke.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e923f11..e922be8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: with: toolchain: stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + - name: bash syntax + run: bash -n scripts/mutation-smoke.sh - name: cargo fmt run: cargo fmt --check - name: cargo clippy diff --git a/.github/workflows/mutation-smoke.yml b/.github/workflows/mutation-smoke.yml new file mode 100644 index 0000000..fa838b9 --- /dev/null +++ b/.github/workflows/mutation-smoke.yml @@ -0,0 +1,48 @@ +name: Apollo mutation smoke + +on: + workflow_dispatch: + schedule: + - cron: "37 3 * * 1" + +concurrency: + group: apollo-cli-mutation-smoke + cancel-in-progress: false + +permissions: + contents: read + +jobs: + mutation-smoke: + name: Portal + H2 mutation smoke + runs-on: ubuntu-24.04 + timeout-minutes: 120 + steps: + - name: Check out Apollo CLI + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up JDK 17 + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4 + with: + distribution: temurin + java-version: 17 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: stable + + - name: Cache Rust build data + uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 + with: + shared-key: mutation-smoke + + - name: Validate smoke script syntax + run: bash -n scripts/mutation-smoke.sh + + - name: Run Apollo CLI mutation smoke + env: + CARGO_TERM_COLOR: always + run: ./scripts/mutation-smoke.sh diff --git a/README.md b/README.md index d7133da..c3bf8eb 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,10 @@ The CLI stores non-secret profile metadata in `config.toml` under the OS config - Linux: `$XDG_CONFIG_HOME/apollo/config.toml` or `~/.config/apollo/config.toml` - Windows: `%APPDATA%\apollo\config.toml` +Set `APOLLO_CLI_HOME` to an absolute directory to place `config.toml` and file-backed credentials +there instead. This is intended for isolated CI and smoke runs; normal interactive use should keep +the platform default. + The config file stores: - `active_profile` @@ -431,6 +435,13 @@ those checks pass does it create the `v` tag at the exact workflow comm notes, verify the complete draft asset set, and publish the Release. Versions containing a SemVer prerelease suffix are published as prereleases. +Before starting a release, maintainers must also run **Actions → Apollo mutation smoke → Run +workflow** on the intended default-branch commit and require a successful result. The workflow is +also scheduled weekly. It builds a pinned Apollo revision with Portal, ConfigService, AdminService, +and disposable H2 databases, then exercises the real mutation contract described below. The Apollo +pin is deliberately stored in `scripts/mutation-smoke.sh`; update it only as a reviewed source +change. + ## Local development Build the CLI: @@ -457,6 +468,26 @@ Run focused OpenAPI command integration tests with the local mock HTTP server: cargo test --test openapi ``` +Run the repeatable real-Portal mutation smoke from a clean checkout (requires Git, curl, jq, JDK 17, +and the stable Rust toolchain): + +```bash +./scripts/mutation-smoke.sh +``` + +The command fetches the pinned Apollo revision, builds its single-process assembly, starts Portal + +H2, builds the CLI, creates an isolated `APOLLO_CLI_HOME` profile and disposable app/namespaces, and +validates config diff/apply, confirmation rejection, permission failure, release creation/listing, +and rollback against real server state. Target-only keys are verified as preserved under the merge +contract, and no-op state is compared before and after the command. User tokens and config values +stay in a mode-`0700` temporary directory, failure diagnostics are dynamically redacted, and the +directory and assembly process are always removed. To reuse an already checked-out clean Apollo +tree at the same pinned commit: + +```bash +APOLLO_SMOKE_APOLLO_SOURCE=/absolute/path/to/apollo ./scripts/mutation-smoke.sh +``` + If you have a local Apollo Portal running, you can also smoke-test against it: ```bash @@ -492,3 +523,4 @@ cargo clippy --all-targets --all-features -- -D warnings - `tests/openapi.rs`: integration coverage for OpenAPI paths, auth headers, and confirmation guards - `tests/profile.rs`: integration coverage for profile commands and context resolution - `tests/redaction.rs`: integration coverage for redaction behavior +- `scripts/mutation-smoke.sh`: pinned Apollo Portal + H2 mutation smoke and state assertions diff --git a/README.zh.md b/README.zh.md index 4635016..fb8d59e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -120,6 +120,9 @@ CLI 将非敏感 profile 元数据存储在操作系统配置目录下的 `confi - Linux:`$XDG_CONFIG_HOME/apollo/config.toml` 或 `~/.config/apollo/config.toml` - Windows:`%APPDATA%\apollo\config.toml` +如需在隔离的 CI 或 smoke 环境中保存 `config.toml` 和文件型凭据,可将 +`APOLLO_CLI_HOME` 设置为绝对目录。日常交互式使用仍建议保留平台默认路径。 + 配置文件会存储: - `active_profile` @@ -357,6 +360,11 @@ checksum 与构建来源证明。全部检查通过后,才会在本次 workflo `v` tag,自动生成 release notes,核对草稿 Release 的完整附件集合并公开发布。版本中含有 SemVer 预发布后缀时,会发布为 prerelease。 +发版前,维护者还必须在目标默认分支 commit 上运行 **Actions → Apollo mutation smoke → Run +workflow**,并确认执行成功;该 workflow 也会每周定时运行。它使用固定 Apollo revision 构建 +Portal、ConfigService、AdminService 和一次性 H2 数据库,再执行下文所述的真实变更约定。Apollo +固定 revision 保存在 `scripts/mutation-smoke.sh` 中,更新它必须作为代码变更接受 review。 + ## 本地开发 构建 CLI: @@ -383,6 +391,24 @@ cargo test cargo test --test openapi ``` +从干净 checkout 运行可重复的真实 Portal 变更 smoke(需要 Git、curl、jq、JDK 17 和稳定版 Rust +工具链): + +```bash +./scripts/mutation-smoke.sh +``` + +该命令会拉取固定 Apollo revision、构建并启动单进程 Portal + H2 assembly、构建 CLI、创建隔离的 +`APOLLO_CLI_HOME` profile 和一次性 app/namespace,并基于真实服务端状态验证 config diff/apply、 +缺少确认时拒绝、权限失败、release 创建/列表以及回滚。测试会确认保守合并保留目标端独有 key, +并比较 no-op 前后的完整目标状态。User token 和配置值只保存在权限为 `0700` 的临时目录中,失败 +诊断会动态脱敏,assembly 进程与临时目录始终会被清理。若本地已有同一固定 commit 的干净 Apollo +checkout,可以复用: + +```bash +APOLLO_SMOKE_APOLLO_SOURCE=/absolute/path/to/apollo ./scripts/mutation-smoke.sh +``` + 如果本地运行了 Apollo Portal,也可以直接对它做 smoke test: ```bash @@ -418,3 +444,4 @@ cargo clippy --all-targets --all-features -- -D warnings - `tests/openapi.rs`:OpenAPI path、鉴权 header 和确认保护的集成覆盖 - `tests/profile.rs`:profile 命令和上下文解析的集成覆盖 - `tests/redaction.rs`:脱敏行为的集成覆盖 +- `scripts/mutation-smoke.sh`:固定 Apollo Portal + H2 变更 smoke 与状态断言 diff --git a/scripts/mutation-smoke.sh b/scripts/mutation-smoke.sh new file mode 100755 index 0000000..5c93f1a --- /dev/null +++ b/scripts/mutation-smoke.sh @@ -0,0 +1,615 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail +umask 077 + +readonly APOLLO_REVISION_DEFAULT="6ca0319decfacf886403d1eb4122ae29a0476003" +readonly APOLLO_REPOSITORY="https://github.com/apolloconfig/apollo.git" +readonly APOLLO_OPENAPI_SPEC_URL="https://raw.githubusercontent.com/apolloconfig/apollo-openapi/v0.3.10/apollo-openapi.yaml" +readonly APOLLO_OPENAPI_SPEC_SHA256="c0cbd94952618c5e56f4948c2707bac8f7907dba913c7184b55d23cfdf39896b" +readonly PORTAL_URL="${APOLLO_SMOKE_PORTAL_URL:-http://127.0.0.1:8070}" +readonly CONFIG_URL="${APOLLO_SMOKE_CONFIG_URL:-http://127.0.0.1:8080}" +readonly ADMIN_URL="${APOLLO_SMOKE_ADMIN_URL:-http://127.0.0.1:8090}" +readonly SMOKE_ENV="LOCAL" +readonly APOLLO_REVISION="${APOLLO_SMOKE_APOLLO_REVISION:-${APOLLO_REVISION_DEFAULT}}" +readonly PORTAL_USERNAME="${APOLLO_SMOKE_PORTAL_USERNAME:-apollo}" +readonly PORTAL_PASSWORD="${APOLLO_SMOKE_PORTAL_PASSWORD:-admin}" +readonly WAIT_TIMEOUT_SECONDS="${APOLLO_SMOKE_WAIT_TIMEOUT_SECONDS:-360}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +REPOSITORY_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd -P)" +TEMP_BASE="${TMPDIR:-/tmp}" +TEMP_BASE="${TEMP_BASE%/}" +SMOKE_WORK_DIR="$(mktemp -d "${TEMP_BASE}/apollo-cli-smoke.XXXXXX")" +RESULTS_DIR="${SMOKE_WORK_DIR}/results" +PORTAL_LOG="${SMOKE_WORK_DIR}/apollo-assembly.log" +APOLLO_PROCESS_ID="" +CURRENT_STEP="initialization" +LAST_STDOUT="" +LAST_STDERR="" +TOKEN_RESULT="" +CAPTURE_INDEX=0 +SENSITIVE_VALUES=() + +mkdir -p "${RESULTS_DIR}" + +sanitize_stream() { + local line secret + while IFS= read -r line || [[ -n "${line}" ]]; do + for secret in "${SENSITIVE_VALUES[@]-}"; do + if [[ -n "${secret}" ]]; then + line="${line//${secret}/[REDACTED]}" + fi + done + printf '%s\n' "${line}" + done +} + +cleanup() { + local status=$? + local diagnostic_excerpt="" + set +e + + if [[ ${status} -ne 0 ]]; then + printf 'Mutation smoke failed during: %s\n' "${CURRENT_STEP}" >&2 + if [[ -s "${LAST_STDOUT}" ]]; then + printf '%s\n' 'Sanitized CLI stdout:' >&2 + sanitize_stream < "${LAST_STDOUT}" >&2 + fi + if [[ -s "${LAST_STDERR}" ]]; then + printf '%s\n' 'Sanitized CLI stderr:' >&2 + sanitize_stream < "${LAST_STDERR}" >&2 + fi + if [[ -s "${PORTAL_LOG}" ]]; then + diagnostic_excerpt="$(grep -E -B 5 -A 40 \ + 'Create app failed|No available admin server' "${PORTAL_LOG}" 2>/dev/null \ + | tail -n 120 || true)" + if [[ -n "${diagnostic_excerpt}" ]]; then + printf '%s\n' 'Sanitized Apollo failure context:' >&2 + printf '%s\n' "${diagnostic_excerpt}" | sanitize_stream >&2 + fi + printf '%s\n' 'Sanitized Apollo assembly log tail:' >&2 + tail -n 80 "${PORTAL_LOG}" | sanitize_stream >&2 + fi + fi + + if [[ -n "${APOLLO_PROCESS_ID}" ]]; then + kill "${APOLLO_PROCESS_ID}" >/dev/null 2>&1 || true + wait "${APOLLO_PROCESS_ID}" >/dev/null 2>&1 || true + fi + + if [[ -d "${SMOKE_WORK_DIR}" && "${SMOKE_WORK_DIR}" == "${TEMP_BASE}/apollo-cli-smoke."* ]]; then + rm -rf -- "${SMOKE_WORK_DIR}" + fi +} +trap cleanup EXIT + +fail() { + printf '%s\n' "$1" >&2 + return 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "Required command is not available: $1" +} + +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +assert_files_do_not_contain_sensitive_data() { + local label=$1 + shift + local file secret + for file in "$@"; do + [[ -f "${file}" ]] || continue + for secret in "${SENSITIVE_VALUES[@]-}"; do + [[ -n "${secret}" ]] || continue + if LC_ALL=C grep -F -- "${secret}" "${file}" >/dev/null 2>&1; then + fail "${label} exposed a credential or config value" + fi + done + done +} + +next_capture_paths() { + CAPTURE_INDEX=$((CAPTURE_INDEX + 1)) + LAST_STDOUT="${RESULTS_DIR}/$(printf '%03d' "${CAPTURE_INDEX}").stdout.json" + LAST_STDERR="${RESULTS_DIR}/$(printf '%03d' "${CAPTURE_INDEX}").stderr.log" +} + +run_cli_with_token() { + local token=$1 + local label=$2 + shift 2 + local status + + CURRENT_STEP="${label}" + next_capture_paths + set +e + APOLLO_TOKEN="${token}" "${CLI_BINARY}" \ + --profile "${PROFILE_NAME}" --output json "$@" \ + >"${LAST_STDOUT}" 2>"${LAST_STDERR}" + status=$? + set -e + + if [[ ${status} -ne 0 ]]; then + fail "${label} returned exit status ${status}" + fi + jq -e . "${LAST_STDOUT}" >/dev/null || fail "${label} did not return valid JSON" + assert_files_do_not_contain_sensitive_data "${label}" "${LAST_STDOUT}" "${LAST_STDERR}" + printf 'ok - %s\n' "${label}" +} + +run_cli() { + local label=$1 + shift + run_cli_with_token "${PRIMARY_TOKEN}" "${label}" "$@" +} + +run_cli_expect_failure() { + local token=$1 + local label=$2 + local expected_category=$3 + shift 3 + local status + + CURRENT_STEP="${label}" + next_capture_paths + set +e + APOLLO_TOKEN="${token}" "${CLI_BINARY}" \ + --profile "${PROFILE_NAME}" --output json "$@" \ + >"${LAST_STDOUT}" 2>"${LAST_STDERR}" + status=$? + set -e + + if [[ ${status} -eq 0 ]]; then + fail "${label} unexpectedly succeeded" + fi + jq -e --arg category "${expected_category}" \ + '.error.category == $category' "${LAST_STDERR}" >/dev/null \ + || fail "${label} did not return error category ${expected_category}" + assert_files_do_not_contain_sensitive_data "${label}" "${LAST_STDOUT}" "${LAST_STDERR}" + printf 'ok - %s\n' "${label}" +} + +assert_jq() { + local label=$1 + local file=$2 + shift 2 + CURRENT_STEP="${label}" + jq -e "$@" "${file}" >/dev/null || fail "JSON assertion failed: ${label}" +} + +server_get() { + local token=$1 + local path=$2 + local output_file=$3 + local expected_status=$4 + local status + + status="$(curl --connect-timeout 3 --max-time 20 -sS \ + -o "${output_file}" -w '%{http_code}' \ + -H "Authorization: Bearer ${token}" \ + "${PORTAL_URL}${path}")" + if [[ "${status}" != "${expected_status}" ]]; then + fail "Server state read returned HTTP ${status}; expected ${expected_status}" + fi + if [[ "${expected_status}" == "200" ]]; then + jq -e . "${output_file}" >/dev/null || fail "Server state read did not return valid JSON" + fi +} + +assert_item_value() { + local cluster=$1 + local namespace=$2 + local key=$3 + local expected_value=$4 + local output_file="${RESULTS_DIR}/server-item-${cluster}-${namespace}-${key}.json" + + CURRENT_STEP="verify ${cluster}/${namespace}/${key} server state" + server_get "${PRIMARY_TOKEN}" \ + "/openapi/v1/envs/${SMOKE_ENV}/apps/${APP_ID}/clusters/${cluster}/namespaces/${namespace}/items/${key}" \ + "${output_file}" 200 + jq -e --arg key "${key}" --arg value "${expected_value}" \ + '.key == $key and .value == $value' "${output_file}" >/dev/null \ + || fail "Server item state did not match the expected key and value" +} + +assert_item_absent() { + local cluster=$1 + local namespace=$2 + local key=$3 + local output_file="${RESULTS_DIR}/server-item-absent-${cluster}-${namespace}-${key}.json" + + CURRENT_STEP="verify ${cluster}/${namespace}/${key} is absent" + server_get "${PRIMARY_TOKEN}" \ + "/openapi/v1/envs/${SMOKE_ENV}/apps/${APP_ID}/clusters/${cluster}/namespaces/${namespace}/items/${key}" \ + "${output_file}" 404 +} + +create_user_token() { + local label=$1 + local payload=$2 + local response_file="${RESULTS_DIR}/token-${label}.json" + local status + + CURRENT_STEP="create ${label} user token" + status="$(curl --connect-timeout 3 --max-time 20 -sS \ + -o "${response_file}" -w '%{http_code}' \ + --user "${PORTAL_USERNAME}:${PORTAL_PASSWORD}" \ + -H 'Content-Type: application/json' \ + -X POST "${PORTAL_URL}/openapi/v1/user-tokens" \ + --data "${payload}")" + if [[ "${status}" != "200" ]]; then + fail "Creating ${label} user token returned HTTP ${status}" + fi + TOKEN_RESULT="$(jq -er '.tokenValue | select(startswith("apollo_pat_"))' "${response_file}")" \ + || fail "Creating ${label} user token returned an invalid response" + : > "${response_file}" +} + +wait_for_apollo() { + local deadline=$((SECONDS + WAIT_TIMEOUT_SECONDS)) + local services + + CURRENT_STEP="wait for Apollo Portal and AdminService readiness" + while (( SECONDS < deadline )); do + if ! kill -0 "${APOLLO_PROCESS_ID}" >/dev/null 2>&1; then + fail "Apollo assembly exited before its services became ready" + fi + if curl --connect-timeout 2 --max-time 5 -fsS "${CONFIG_URL}/health" >/dev/null 2>&1 \ + && curl --connect-timeout 2 --max-time 5 -fsS "${ADMIN_URL}/health" >/dev/null 2>&1 \ + && curl --connect-timeout 2 --max-time 5 -fsS "${PORTAL_URL}/signin" >/dev/null 2>&1; then + services="$(curl --connect-timeout 2 --max-time 5 -fsS \ + "${CONFIG_URL}/services/admin" 2>/dev/null || true)" + if [[ "${services}" == *"apollo-adminservice"* ]]; then + printf '%s\n' 'Apollo Portal, ConfigService, and AdminService are ready' + return 0 + fi + fi + sleep 3 + done + fail "Timed out after ${WAIT_TIMEOUT_SECONDS}s waiting for Apollo readiness" +} + +wait_for_portal_admin_cache() { + local deadline=$((SECONDS + WAIT_TIMEOUT_SECONDS)) + local readiness_body="${RESULTS_DIR}/portal-admin-readiness.json" + local status + + CURRENT_STEP="wait for Portal AdminService cache readiness" + while (( SECONDS < deadline )); do + if ! kill -0 "${APOLLO_PROCESS_ID}" >/dev/null 2>&1; then + fail "Apollo assembly exited before Portal's AdminService cache became ready" + fi + status="$(curl --connect-timeout 2 --max-time 5 -sS \ + -o "${readiness_body}" -w '%{http_code}' \ + -H "Authorization: Bearer ${PRIMARY_TOKEN}" \ + "${PORTAL_URL}/openapi/v1/envs/${SMOKE_ENV}/apps/${APP_ID}/clusters/default/namespaces/application" \ + 2>/dev/null || true)" + if [[ "${status}" == "404" ]]; then + : > "${readiness_body}" + printf '%s\n' "Portal's AdminService cache is ready" + return 0 + fi + sleep 3 + done + fail "Timed out after ${WAIT_TIMEOUT_SECONDS}s waiting for Portal's AdminService cache" +} + +for command_name in awk cargo curl git java jq; do + require_command "${command_name}" +done +if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then + fail "Required SHA-256 command is not available: sha256sum or shasum" +fi + +CURRENT_STEP="prepare pinned Apollo source" +if [[ -n "${APOLLO_SMOKE_APOLLO_SOURCE:-}" ]]; then + APOLLO_SOURCE_DIR="$(cd "${APOLLO_SMOKE_APOLLO_SOURCE}" && pwd -P)" + actual_revision="$(git -C "${APOLLO_SOURCE_DIR}" rev-parse HEAD)" + if [[ "${actual_revision}" != "${APOLLO_REVISION}" ]]; then + fail "Local Apollo source is at ${actual_revision}, expected pinned revision ${APOLLO_REVISION}" + fi + if ! git -C "${APOLLO_SOURCE_DIR}" diff --quiet \ + || ! git -C "${APOLLO_SOURCE_DIR}" diff --cached --quiet; then + fail "Local Apollo source has tracked changes; use a clean checkout of ${APOLLO_REVISION}" + fi +else + APOLLO_SOURCE_DIR="${SMOKE_WORK_DIR}/apollo" + git init -q "${APOLLO_SOURCE_DIR}" + git -C "${APOLLO_SOURCE_DIR}" remote add origin "${APOLLO_REPOSITORY}" + git -C "${APOLLO_SOURCE_DIR}" fetch --depth=1 origin "${APOLLO_REVISION}" + git -C "${APOLLO_SOURCE_DIR}" checkout -q --detach FETCH_HEAD +fi +printf 'Using Apollo revision %s\n' "${APOLLO_REVISION}" + +CURRENT_STEP="build Apollo CLI" +(cd "${REPOSITORY_ROOT}" && cargo build --release --locked) +CLI_BINARY="${REPOSITORY_ROOT}/target/release/apollo" + +CURRENT_STEP="build Apollo assembly" +OPENAPI_SPEC="${SMOKE_WORK_DIR}/apollo-openapi-v0.3.10.yaml" +curl -fL --retry 5 --retry-all-errors --connect-timeout 10 --max-time 60 -sS \ + -o "${OPENAPI_SPEC}" "${APOLLO_OPENAPI_SPEC_URL}" +actual_openapi_sha256="$(sha256_file "${OPENAPI_SPEC}")" +if [[ "${actual_openapi_sha256}" != "${APOLLO_OPENAPI_SPEC_SHA256}" ]]; then + fail "Apollo OpenAPI specification checksum did not match the pinned digest" +fi +(cd "${APOLLO_SOURCE_DIR}" && ./mvnw -B -ntp -q -pl apollo-assembly -am \ + -DskipTests -Dapollo.openapi.spec.url="${OPENAPI_SPEC}" package) + +ASSEMBLY_JAR="" +while IFS= read -r candidate; do + ASSEMBLY_JAR="${candidate}" + break +done < <(find "${APOLLO_SOURCE_DIR}/apollo-assembly/target" -maxdepth 1 -type f \ + -name 'apollo-assembly-*.jar' ! -name '*-sources.jar' ! -name '*-javadoc.jar' | sort) +[[ -n "${ASSEMBLY_JAR}" ]] || fail "No runnable Apollo assembly jar was produced" + +CURRENT_STEP="start Apollo assembly" +SPRING_PROFILES_ACTIVE="github,database-discovery,auth" \ +SPRING_SQL_CONFIG_INIT_MODE="always" \ +SPRING_SQL_PORTAL_INIT_MODE="always" \ +SPRING_CONFIG_DATASOURCE_URL="jdbc:h2:mem:apollo-config-db;mode=mysql;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1;BUILTIN_ALIAS_OVERRIDE=TRUE;DATABASE_TO_UPPER=FALSE" \ +SPRING_PORTAL_DATASOURCE_URL="jdbc:h2:mem:apollo-portal-db;mode=mysql;DB_CLOSE_ON_EXIT=FALSE;DB_CLOSE_DELAY=-1;BUILTIN_ALIAS_OVERRIDE=TRUE;DATABASE_TO_UPPER=FALSE" \ +LOGGING_FILE_NAME="${SMOKE_WORK_DIR}/apollo-assembly-file.log" \ +java -jar "${ASSEMBLY_JAR}" >"${PORTAL_LOG}" 2>&1 & +APOLLO_PROCESS_ID=$! +wait_for_apollo + +nonce="${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}-$(date +%s)-${RANDOM}" +nonce="${nonce//[^a-zA-Z0-9-]/-}" +readonly APP_ID="cli-smoke-${nonce}" +readonly PROFILE_NAME="smoke-${nonce}" +readonly SOURCE_CLUSTER="default" +readonly TARGET_CLUSTER="smoke-target" +readonly CONFIG_NAMESPACE="smoke-config" +readonly SOURCE_ONLY_KEY="source-only" +readonly SHARED_KEY="shared-key" +readonly TARGET_ONLY_KEY="target-only" +readonly CONFIRMATION_KEY="confirmation-guard" +readonly DENIED_KEY="permission-guard" +readonly SOURCE_ONLY_VALUE="source-${nonce}" +readonly SOURCE_V1_VALUE="source-v1-${nonce}" +readonly SOURCE_V2_VALUE="source-v2-${nonce}" +readonly TARGET_OLD_VALUE="target-old-${nonce}" +readonly TARGET_ONLY_VALUE="target-only-${nonce}" +readonly CONFIRMATION_VALUE="confirmation-${nonce}" +readonly DENIED_VALUE="denied-${nonce}" + +full_token_payload="$(jq -cn --arg name "cli-smoke-full-${nonce}" --arg env "${SMOKE_ENV}" '{ + name: $name, + operations: [ + "config:read", "config:modify", "config:release", "namespace:create", + "namespace:delete", "cluster:create", "app:create", "app:manage-role", "system:admin" + ], + envs: [$env], + rateLimit: 0 +}')" +create_user_token "full" "${full_token_payload}" +readonly PRIMARY_TOKEN="${TOKEN_RESULT}" +SENSITIVE_VALUES+=( + "${PRIMARY_TOKEN}" + "${SOURCE_ONLY_VALUE}" + "${SOURCE_V1_VALUE}" + "${SOURCE_V2_VALUE}" + "${TARGET_OLD_VALUE}" + "${TARGET_ONLY_VALUE}" + "${CONFIRMATION_VALUE}" + "${DENIED_VALUE}" +) +wait_for_portal_admin_cache + +export APOLLO_CLI_HOME="${SMOKE_WORK_DIR}/cli-home" +CURRENT_STEP="configure isolated Apollo CLI profile" +next_capture_paths +"${CLI_BINARY}" --output json profile add "${PROFILE_NAME}" \ + --server "${PORTAL_URL}" --auth-mode user-token --use \ + >"${LAST_STDOUT}" 2>"${LAST_STDERR}" +jq -e --arg profile "${PROFILE_NAME}" --arg home "${APOLLO_CLI_HOME}" \ + '.profile == $profile and .activeProfile == $profile + and (.configPath | startswith($home + "/"))' "${LAST_STDOUT}" >/dev/null \ + || fail "The isolated CLI profile was not configured as expected" +assert_files_do_not_contain_sensitive_data "isolated profile setup" "${LAST_STDOUT}" "${LAST_STDERR}" +printf '%s\n' 'ok - configure isolated Apollo CLI profile' + +app_body="$(jq -cn --arg app "${APP_ID}" '{ + assignAppRoleToSelf: true, + admins: ["apollo"], + app: { + appId: $app, + name: $app, + orgId: "TEST1", + orgName: "Sample Department 1", + ownerName: "apollo", + ownerEmail: "apollo@localhost" + } +}')" +run_cli "create isolated test app" --yes api post /openapi/v1/apps --body "${app_body}" +assert_jq "app creation returns a redacted raw API operation" "${LAST_STDOUT}" \ + --arg path "/openapi/v1/apps" \ + '.operation.operation == "api.post" and .operation.request.method == "POST" + and .operation.request.path == $path and (.operation.request | has("body") | not)' + +cluster_body="$(jq -cn --arg app "${APP_ID}" --arg cluster "${TARGET_CLUSTER}" \ + '{appId: $app, name: $cluster}')" +run_cli "create target cluster" --yes api post \ + "/openapi/v1/envs/${SMOKE_ENV}/apps/${APP_ID}/clusters" --body "${cluster_body}" +run_cli "create source namespace" --yes namespace create \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" "${CONFIG_NAMESPACE}" +run_cli "verify target namespace" namespace get \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" "${CONFIG_NAMESPACE}" + +run_cli "set source-only config" --yes config set \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" --namespace "${CONFIG_NAMESPACE}" \ + "${SOURCE_ONLY_KEY}" "${SOURCE_ONLY_VALUE}" +assert_jq "--yes retains config target summary" "${LAST_STDOUT}" \ + --arg app "${APP_ID}" --arg cluster "${SOURCE_CLUSTER}" --arg namespace "${CONFIG_NAMESPACE}" --arg key "${SOURCE_ONLY_KEY}" \ + '.operation.operation == "config.set" and .operation.target.app == $app + and .operation.target.cluster == $cluster and .operation.target.namespace == $namespace + and .operation.key == $key' + +run_cli "set source shared config" --yes config set \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" --namespace "${CONFIG_NAMESPACE}" \ + "${SHARED_KEY}" "${SOURCE_V1_VALUE}" +run_cli "set target shared config" --yes config set \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" --namespace "${CONFIG_NAMESPACE}" \ + "${SHARED_KEY}" "${TARGET_OLD_VALUE}" +run_cli "set target-only config" --yes config set \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" --namespace "${CONFIG_NAMESPACE}" \ + "${TARGET_ONLY_KEY}" "${TARGET_ONLY_VALUE}" + +run_cli_expect_failure "${PRIMARY_TOKEN}" "reject mutation without --yes" \ + confirmation_required config set \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" --namespace "${CONFIG_NAMESPACE}" \ + "${CONFIRMATION_KEY}" "${CONFIRMATION_VALUE}" +assert_jq "confirmation error retains a redacted operation" "${LAST_STDERR}" \ + --arg key "${CONFIRMATION_KEY}" \ + '.error.operation.operation == "config.set" and .error.operation.key == $key' +assert_item_absent "${TARGET_CLUSTER}" "${CONFIG_NAMESPACE}" "${CONFIRMATION_KEY}" + +readonly_token_payload="$(jq -cn --arg name "cli-smoke-readonly-${nonce}" --arg app "${APP_ID}" --arg env "${SMOKE_ENV}" '{ + name: $name, + operations: ["config:read"], + appIds: [$app], + envs: [$env], + rateLimit: 0 +}')" +create_user_token "readonly" "${readonly_token_payload}" +readonly READONLY_TOKEN="${TOKEN_RESULT}" +SENSITIVE_VALUES+=("${READONLY_TOKEN}") +run_cli_expect_failure "${READONLY_TOKEN}" "reject mutation without permission" \ + permission_denied --yes config set \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" --namespace "${CONFIG_NAMESPACE}" \ + "${DENIED_KEY}" "${DENIED_VALUE}" +assert_item_absent "${SOURCE_CLUSTER}" "${CONFIG_NAMESPACE}" "${DENIED_KEY}" + +run_cli "preview initial config merge" config diff \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" --namespace "${CONFIG_NAMESPACE}" \ + --target-env "${SMOKE_ENV}" --target-cluster "${TARGET_CLUSTER}" +assert_jq "initial diff reports add/update/preserve contract" "${LAST_STDOUT}" \ + '.data.result == "preview" and .data.strategy == "merge" + and .data.targetOnlyBehavior == "preserve" + and .data.changes == {"create":1,"update":1,"delete":0,"unchanged":0}' + +run_cli "apply initial config merge" --yes config apply \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" --namespace "${CONFIG_NAMESPACE}" \ + --target-env "${SMOKE_ENV}" --target-cluster "${TARGET_CLUSTER}" +assert_jq "initial apply reports the approved change set" "${LAST_STDOUT}" \ + '.data.result == "applied" + and .data.changes == {"create":1,"update":1,"delete":0,"unchanged":0} + and .operation.changes == .data.changes + and .operation.strategy == "merge" and .operation.targetOnlyBehavior == "preserve"' +assert_item_value "${TARGET_CLUSTER}" "${CONFIG_NAMESPACE}" "${SOURCE_ONLY_KEY}" "${SOURCE_ONLY_VALUE}" +assert_item_value "${TARGET_CLUSTER}" "${CONFIG_NAMESPACE}" "${SHARED_KEY}" "${SOURCE_V1_VALUE}" +assert_item_value "${TARGET_CLUSTER}" "${CONFIG_NAMESPACE}" "${TARGET_ONLY_KEY}" "${TARGET_ONLY_VALUE}" + +run_cli "read target config through CLI" config list \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" \ + --namespace "${CONFIG_NAMESPACE}" --size 500 +assert_jq "CLI config read keeps all values redacted" "${LAST_STDOUT}" \ + '.data.content | length == 3 and all(.value == "[REDACTED]")' + +run_cli "create first release" --yes release create \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" \ + --namespace "${CONFIG_NAMESPACE}" \ + --title "smoke-v1-${nonce}" +RELEASE_V1_ID="$(jq -er '.data.id' "${LAST_STDOUT}")" + +run_cli "update source shared config" --yes config set \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" \ + --namespace "${CONFIG_NAMESPACE}" \ + "${SHARED_KEY}" "${SOURCE_V2_VALUE}" +run_cli "apply config update" --yes config apply \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" \ + --namespace "${CONFIG_NAMESPACE}" --target-env "${SMOKE_ENV}" \ + --target-cluster "${TARGET_CLUSTER}" +assert_jq "update apply reports one update" "${LAST_STDOUT}" \ + '.data.result == "applied" + and .data.changes == {"create":0,"update":1,"delete":0,"unchanged":1}' +assert_item_value "${TARGET_CLUSTER}" "${CONFIG_NAMESPACE}" "${SHARED_KEY}" "${SOURCE_V2_VALUE}" +assert_item_value "${TARGET_CLUSTER}" "${CONFIG_NAMESPACE}" "${TARGET_ONLY_KEY}" "${TARGET_ONLY_VALUE}" + +target_state_before_noop="${RESULTS_DIR}/target-before-noop.json" +server_get "${PRIMARY_TOKEN}" \ + "/openapi/v1/envs/${SMOKE_ENV}/apps/${APP_ID}/clusters/${TARGET_CLUSTER}/namespaces/${CONFIG_NAMESPACE}/items?page=0&size=500" \ + "${target_state_before_noop}" 200 +run_cli "apply no-op config merge" --yes config apply \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${SOURCE_CLUSTER}" \ + --namespace "${CONFIG_NAMESPACE}" --target-env "${SMOKE_ENV}" \ + --target-cluster "${TARGET_CLUSTER}" +assert_jq "no-op apply is deterministic" "${LAST_STDOUT}" \ + '.data.result == "no-op" + and .data.changes == {"create":0,"update":0,"delete":0,"unchanged":2}' +target_state_after_noop="${RESULTS_DIR}/target-after-noop.json" +server_get "${PRIMARY_TOKEN}" \ + "/openapi/v1/envs/${SMOKE_ENV}/apps/${APP_ID}/clusters/${TARGET_CLUSTER}/namespaces/${CONFIG_NAMESPACE}/items?page=0&size=500" \ + "${target_state_after_noop}" 200 +jq -S '.content | sort_by(.key)' "${target_state_before_noop}" > "${RESULTS_DIR}/target-before-noop.normalized.json" +jq -S '.content | sort_by(.key)' "${target_state_after_noop}" > "${RESULTS_DIR}/target-after-noop.normalized.json" +cmp -s "${RESULTS_DIR}/target-before-noop.normalized.json" \ + "${RESULTS_DIR}/target-after-noop.normalized.json" \ + || fail "No-op apply changed target server state" + +run_cli "create second release" --yes release create \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" \ + --namespace "${CONFIG_NAMESPACE}" \ + --title "smoke-v2-${nonce}" +RELEASE_V2_ID="$(jq -er '.data.id' "${LAST_STDOUT}")" +[[ "${RELEASE_V1_ID}" != "${RELEASE_V2_ID}" ]] || fail "Release IDs were not unique" + +run_cli "list active releases" release list \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" \ + --namespace "${CONFIG_NAMESPACE}" --size 20 +assert_jq "release list contains both releases and redacts configurations" "${LAST_STDOUT}" \ + --argjson v1 "${RELEASE_V1_ID}" --argjson v2 "${RELEASE_V2_ID}" \ + '(.data | map(.id)) as $ids + | ($ids | index($v1)) != null and ($ids | index($v2)) != null + and all(.data[]; .configurations == "[REDACTED]")' + +active_releases_before_rollback="${RESULTS_DIR}/active-releases-before-rollback.json" +server_get "${PRIMARY_TOKEN}" \ + "/openapi/v1/envs/${SMOKE_ENV}/apps/${APP_ID}/clusters/${TARGET_CLUSTER}/namespaces/${CONFIG_NAMESPACE}/releases/active?page=0&size=20" \ + "${active_releases_before_rollback}" 200 +jq -e --argjson id "${RELEASE_V2_ID}" --arg key "${SHARED_KEY}" --arg value "${SOURCE_V2_VALUE}" \ + 'any(.[]; .id == $id and .configurations[$key] == $value)' \ + "${active_releases_before_rollback}" >/dev/null \ + || fail "Second release did not contain the applied server state" + +run_cli "roll back to first release" --yes release rollback \ + --env "${SMOKE_ENV}" "${RELEASE_V2_ID}" --to-release-id "${RELEASE_V1_ID}" +assert_jq "rollback reports both release IDs" "${LAST_STDOUT}" \ + --argjson v1 "${RELEASE_V1_ID}" --argjson v2 "${RELEASE_V2_ID}" \ + '.operation.operation == "release.rollback" + and .operation.releaseId == $v2 and .operation.toReleaseId == $v1' + +run_cli "list releases after rollback" release list \ + --env "${SMOKE_ENV}" --app "${APP_ID}" --cluster "${TARGET_CLUSTER}" \ + --namespace "${CONFIG_NAMESPACE}" --size 20 +assert_jq "rolled-back release is no longer active" "${LAST_STDOUT}" \ + --argjson v1 "${RELEASE_V1_ID}" --argjson v2 "${RELEASE_V2_ID}" \ + '(.data | map(.id)) as $ids + | ($ids | index($v1)) != null and ($ids | index($v2)) == null' + +active_releases_after_rollback="${RESULTS_DIR}/active-releases-after-rollback.json" +server_get "${PRIMARY_TOKEN}" \ + "/openapi/v1/envs/${SMOKE_ENV}/apps/${APP_ID}/clusters/${TARGET_CLUSTER}/namespaces/${CONFIG_NAMESPACE}/releases/active?page=0&size=20" \ + "${active_releases_after_rollback}" 200 +jq -e --argjson v1 "${RELEASE_V1_ID}" --argjson v2 "${RELEASE_V2_ID}" \ + --arg shared "${SHARED_KEY}" --arg shared_value "${SOURCE_V1_VALUE}" \ + --arg kept "${TARGET_ONLY_KEY}" --arg kept_value "${TARGET_ONLY_VALUE}" \ + 'any(.[]; .id == $v1 and .configurations[$shared] == $shared_value + and .configurations[$kept] == $kept_value) + and (any(.[]; .id == $v2) | not)' \ + "${active_releases_after_rollback}" >/dev/null \ + || fail "Rollback did not restore the first release as the active server state" + +assert_files_do_not_contain_sensitive_data "Apollo assembly log" "${PORTAL_LOG}" +CURRENT_STEP="completed" +printf '%s\n' 'Apollo CLI mutation smoke completed successfully' diff --git a/src/command.rs b/src/command.rs index 219dce7..e1aaf86 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1118,17 +1118,19 @@ fn execute_config( .client .request("PUT", &update_path, Some(body.clone())) { - Ok(response) => Ok(render_mutation_response( + Ok(response) => Ok(render_mutation_response_with_data( &openapi.writer, mutation_plan, &response, + redact_config_item_values(response.data.clone()), )), Err(error) if error.http_status_code() == Some(404) => { let response = openapi.client.request("POST", &create_path, Some(body))?; - Ok(render_mutation_response( + Ok(render_mutation_response_with_data( &openapi.writer, mutation_plan, &response, + redact_config_item_values(response.data.clone()), )) } Err(error) => Err(error), diff --git a/src/config.rs b/src/config.rs index 45811c5..e32a9b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -165,6 +165,18 @@ fn config_path_for_platform( platform: Platform, vars: &HashMap, ) -> Result { + if let Some(cli_home) = vars + .get("APOLLO_CLI_HOME") + .filter(|value| !value.is_empty()) + .cloned() + .map(PathBuf::from) + { + if !cli_home.is_absolute() { + return Err("APOLLO_CLI_HOME must be an absolute path".to_owned()); + } + return Ok(cli_home.join("config.toml")); + } + let home = || { vars.get("HOME") .filter(|value| !value.is_empty()) @@ -292,6 +304,37 @@ mod tests { ); } + #[test] + fn apollo_cli_home_overrides_platform_config_path() { + let vars = HashMap::from([ + (String::from("HOME"), OsString::from("/Users/tester")), + ( + String::from("APOLLO_CLI_HOME"), + OsString::from("/tmp/apollo-cli-smoke"), + ), + ]); + + for platform in [Platform::MacOs, Platform::Linux, Platform::Windows] { + assert_eq!( + config_path_for_platform(platform, &vars).unwrap(), + PathBuf::from("/tmp/apollo-cli-smoke/config.toml") + ); + } + } + + #[test] + fn apollo_cli_home_must_be_absolute() { + let vars = HashMap::from([( + String::from("APOLLO_CLI_HOME"), + OsString::from("relative/apollo-cli"), + )]); + + assert_eq!( + config_path_for_platform(Platform::Linux, &vars).unwrap_err(), + "APOLLO_CLI_HOME must be an absolute path" + ); + } + #[test] fn config_path_uses_linux_xdg_when_present() { let vars = HashMap::from([( diff --git a/tests/openapi.rs b/tests/openapi.rs index 2ac07ec..54923b4 100644 --- a/tests/openapi.rs +++ b/tests/openapi.rs @@ -992,7 +992,8 @@ fn user_token_config_set_does_not_require_or_send_operator() { assert_eq!(json["operation"]["operation"], "config.set"); assert_eq!(json["operation"]["target"]["namespace"], "application"); assert_eq!(json["operation"]["key"], "timeout"); - assert_eq!(json["data"]["value"], "3000"); + assert_eq!(json["data"]["value"], "[REDACTED]"); + assert!(!stdout.contains("3000")); let request = server.request(); assert_eq!( @@ -1384,15 +1385,19 @@ fn config_set_falls_back_to_create_when_update_reports_missing_item() { &profile_config_with_operator(&server.url(), "apollo-bot"), ); - base_command(&home) + let assert = base_command(&home) .env("APOLLO_TOKEN", "consumer-token") .args([ "--yes", "--output", "json", "config", "set", "--env", "DEV", "--app", "demo", "timeout", "3000", ]) .assert() - .success() - .stdout(predicate::str::contains(r#""key": "timeout""#)); + .success(); + let stdout = String::from_utf8(assert.get_output().stdout.clone()).expect("utf8 stdout"); + let json: Value = serde_json::from_str(&stdout).expect("json stdout"); + assert_eq!(json["data"]["key"], "timeout"); + assert_eq!(json["data"]["value"], "[REDACTED]"); + assert!(!stdout.contains("3000")); let requests = server.requests(2); assert_eq!(requests[0].method, "PUT"); From 8e7b0df575b910fb9e4aaee8776e4500375e0953 Mon Sep 17 00:00:00 2001 From: Jason Song Date: Sun, 30 Aug 2026 20:53:21 +0800 Subject: [PATCH 2/2] fix: make mutation smoke build deterministic --- README.md | 4 ++-- README.zh.md | 4 ++-- scripts/mutation-smoke.sh | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c3bf8eb..0c1b017 100644 --- a/README.md +++ b/README.md @@ -468,8 +468,8 @@ Run focused OpenAPI command integration tests with the local mock HTTP server: cargo test --test openapi ``` -Run the repeatable real-Portal mutation smoke from a clean checkout (requires Git, curl, jq, JDK 17, -and the stable Rust toolchain): +Run the repeatable real-Portal mutation smoke from a clean checkout (requires Git, curl, jq, awk, +`sha256sum` or `shasum`, JDK 17, and the stable Rust toolchain): ```bash ./scripts/mutation-smoke.sh diff --git a/README.zh.md b/README.zh.md index fb8d59e..efd0a57 100644 --- a/README.zh.md +++ b/README.zh.md @@ -391,8 +391,8 @@ cargo test cargo test --test openapi ``` -从干净 checkout 运行可重复的真实 Portal 变更 smoke(需要 Git、curl、jq、JDK 17 和稳定版 Rust -工具链): +从干净 checkout 运行可重复的真实 Portal 变更 smoke(需要 Git、curl、jq、awk、 +`sha256sum` 或 `shasum`、JDK 17 和稳定版 Rust 工具链): ```bash ./scripts/mutation-smoke.sh diff --git a/scripts/mutation-smoke.sh b/scripts/mutation-smoke.sh index 5c93f1a..0daaf1f 100755 --- a/scripts/mutation-smoke.sh +++ b/scripts/mutation-smoke.sh @@ -342,7 +342,7 @@ if [[ "${actual_openapi_sha256}" != "${APOLLO_OPENAPI_SPEC_SHA256}" ]]; then fail "Apollo OpenAPI specification checksum did not match the pinned digest" fi (cd "${APOLLO_SOURCE_DIR}" && ./mvnw -B -ntp -q -pl apollo-assembly -am \ - -DskipTests -Dapollo.openapi.spec.url="${OPENAPI_SPEC}" package) + -DskipTests -Dapollo.openapi.spec.url="${OPENAPI_SPEC}" clean package) ASSEMBLY_JAR="" while IFS= read -r candidate; do