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..0c1b017 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, awk, +`sha256sum` or `shasum`, 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..efd0a57 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、awk、 +`sha256sum` 或 `shasum`、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..0daaf1f --- /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}" clean 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");