Files
ss-tools/scripts/e2e-dashboard-testing-loop.sh

297 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# #region Tooling.DashboardTestingE2E.Loop [C:4] [TYPE Script] [SEMANTICS docker,e2e,private,cleanup]
# @PURPOSE Build and run the isolated dashboard-testing UI/API smoke against synthetic fixtures.
# @PRE Docker Compose is available; run from any directory inside this checkout.
# @POST A sanitized test summary is retained; the private Compose project and all volumes are removed.
# @INVARIANT Credentials and unredacted Docker logs remain in a mode-0700 private lab-config directory and are deleted.
# @REJECTED Running the broad legacy Playwright project because it includes external/obsolete capture paths.
set -Eeuo pipefail
umask 077
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
PROJECT="ss-tools-dashboard-testing-e2e-${STAMP,,}-$$"
PRIVATE_ROOT="${HOME}/ss-tools-lab/config/e2e-transient"
[[ ! -L "${PRIVATE_ROOT}" ]] || { printf 'Private config path must not be a symlink\n' >&2; exit 2; }
mkdir -m 0700 -p "${PRIVATE_ROOT}"
[[ "$(stat -c '%u' "${PRIVATE_ROOT}")" == "$(id -u)" ]] || {
printf 'Private config path must be owned by the current user\n' >&2
exit 2
}
chmod 0700 "${PRIVATE_ROOT}"
PRIVATE_DIR="$(mktemp -d "${PRIVATE_ROOT}/${PROJECT}.XXXXXX")"
ARTIFACT_DIR="${ROOT}/artifacts/e2e-loop/runs/${STAMP}-$$"
IDENTITY_DIR="${PRIVATE_DIR}/source-identity"
ENV_FILE="${PRIVATE_DIR}/full-flow.env"
CURRENT_PHASE="private_setup"
EVIDENCE_COLLECTION_EXIT=0
mkdir -m 0700 -p "${ARTIFACT_DIR}" "${IDENTITY_DIR}"
export E2E_PRIVATE_ENV_FILE="${ENV_FILE}"
export E2E_PRIVATE_ENV_DIR="${PRIVATE_DIR}"
export E2E_PRIVATE_ENV_UID="$(id -u)"
export E2E_PRIVATE_ENV_GID="$(id -g)"
export E2E_SOURCE_IDENTITY_DIR="${IDENTITY_DIR}"
export E2E_ARTIFACT_DIR="${ARTIFACT_DIR}"
compose() {
docker compose -p "${PROJECT}" \
--env-file "${ENV_FILE}" \
-f "${ROOT}/docker-compose.e2e.yml" \
-f "${ROOT}/docker-compose.full-flow.yml" \
-f "${ROOT}/docker-compose.full-flow.finance.yml" \
-f "${ROOT}/docker-compose.e2e.dashboard-testing.yml" \
"$@"
}
collect_backend_events() {
local raw_log="${PRIVATE_DIR}/backend-compose.log"
if compose logs --no-color backend >"${raw_log}" 2>&1; then
python3 - "${ENV_FILE}" "${raw_log}" "${ARTIFACT_DIR}/backend-redacted.log" <<'PY'
import os
import base64
from pathlib import Path
from urllib.parse import quote
import sys
env_path, log_path, output_path = map(Path, sys.argv[1:])
secrets = []
for line in env_path.read_text().splitlines():
key, separator, value = line.partition('=')
if separator and key in {
'AUTH_SECRET_KEY', 'ENCRYPTION_KEY', 'SERVICE_JWT', 'POSTGRES_PASSWORD',
'SUPERSET_SECRET_KEY', 'FIXTURE_PASSWORD', 'SUPERSET_PASSWORD', 'CLICKHOUSE_PASSWORD',
'PUBLISHED_CATALOG_GITEA_TOKEN',
} and value:
secrets.extend((value, quote(value, safe=''), base64.b64encode(value.encode()).decode()))
content = log_path.read_text(errors='replace')
for secret in sorted(set(secrets), key=len, reverse=True):
content = content.replace(secret, '[REDACTED]')
fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as output:
output.write(content)
PY
if python3 "${ROOT}/scripts/pretty_cot.py" "${ARTIFACT_DIR}/backend-redacted.log" --evidence \
>"${ARTIFACT_DIR}/backend-molecular-evidence.json" 2>/dev/null; then
EVIDENCE_COLLECTION_EXIT=0
else
EVIDENCE_COLLECTION_EXIT=$?
fi
else
EVIDENCE_COLLECTION_EXIT=$?
printf '{"complete":false,"collector_error":"backend_log_unavailable","counts":{"read_errors":1}}\n' >"${ARTIFACT_DIR}/backend-molecular-evidence.json"
fi
}
retain_private_diagnostic() {
local source_file="$1" destination="$2"
[[ -f "${source_file}" ]] || return 0
python3 - "${ENV_FILE}" "${source_file}" "${destination}" <<'PY'
import os
import base64
from pathlib import Path
from urllib.parse import quote
import sys
env_path, input_path, output_path = map(Path, sys.argv[1:])
secrets = []
for line in env_path.read_text().splitlines():
key, separator, value = line.partition('=')
if separator and key in {
'AUTH_SECRET_KEY', 'ENCRYPTION_KEY', 'SERVICE_JWT', 'POSTGRES_PASSWORD',
'SUPERSET_SECRET_KEY', 'FIXTURE_PASSWORD', 'SUPERSET_PASSWORD', 'CLICKHOUSE_PASSWORD',
'PUBLISHED_CATALOG_GITEA_TOKEN',
} and value:
secrets.extend((value, quote(value, safe=''), base64.b64encode(value.encode()).decode()))
content = input_path.read_text(errors='replace')
for secret in sorted(set(secrets), key=len, reverse=True):
content = content.replace(secret, '[REDACTED]')
fd = os.open(output_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as output:
output.write(content)
PY
}
on_error() {
local result=$?
trap - ERR
printf 'failed_phase=%s\nactual_exit=%s\n' "${CURRENT_PHASE}" "${result}" >>"${ARTIFACT_DIR}/outcome.txt"
exit "${result}"
}
cleanup() {
local result=$?
local cleanup_succeeded=0
trap - EXIT ERR INT TERM
collect_backend_events || true
printf 'evidence_collection_exit=%s\n' "${EVIDENCE_COLLECTION_EXIT}" >>"${ARTIFACT_DIR}/outcome.txt"
if (( EVIDENCE_COLLECTION_EXIT != 0 && result == 0 )); then
printf 'failed_phase=backend_log_evidence\nactual_exit=%s\n' \
"${EVIDENCE_COLLECTION_EXIT}" >>"${ARTIFACT_DIR}/outcome.txt"
result=1
fi
retain_private_diagnostic "${PRIVATE_DIR}/backend-ready.out" "${ARTIFACT_DIR}/backend-ready.txt" || true
retain_private_diagnostic "${PRIVATE_DIR}/native-hold-init.out" "${ARTIFACT_DIR}/native-hold-init.txt" || true
retain_private_diagnostic "${PRIVATE_DIR}/gitea-init.out" "${ARTIFACT_DIR}/gitea-init.txt" || true
retain_private_diagnostic "${PRIVATE_DIR}/gitea-bootstrap.out" "${ARTIFACT_DIR}/gitea-bootstrap.txt" || true
retain_private_diagnostic "${PRIVATE_DIR}/compose-up.out" "${ARTIFACT_DIR}/compose-up.txt" || true
retain_private_diagnostic "${PRIVATE_DIR}/script-preflight.out" "${ARTIFACT_DIR}/script-preflight.txt" || true
retain_private_diagnostic "${PRIVATE_DIR}/playwright.out" "${ARTIFACT_DIR}/playwright.txt" || true
retain_private_diagnostic "${PRIVATE_DIR}/official-release.out" "${ARTIFACT_DIR}/official-release.txt" || true
retain_private_diagnostic "${PRIVATE_DIR}/native-setup.out" "${ARTIFACT_DIR}/native-setup.txt" || true
if compose down -v --remove-orphans --rmi local >"${PRIVATE_DIR}/compose-down.txt" 2>&1; then
retain_private_diagnostic "${PRIVATE_DIR}/compose-down.txt" "${ARTIFACT_DIR}/compose-down.txt" || true
printf 'compose_down=0\n' >>"${ARTIFACT_DIR}/outcome.txt"
cleanup_succeeded=1
else
retain_private_diagnostic "${PRIVATE_DIR}/compose-down.txt" "${ARTIFACT_DIR}/compose-down.txt" || true
printf 'compose_down=failed\n' >>"${ARTIFACT_DIR}/outcome.txt"
if (( result == 0 )); then result=1; fi
printf 'private_cleanup_retained=1\nprivate_cleanup_path=%s\n' "${PRIVATE_DIR}" >>"${ARTIFACT_DIR}/outcome.txt"
fi
if (( cleanup_succeeded == 1 )) && ! grep -q '^compose_down=failed$' "${ARTIFACT_DIR}/outcome.txt"; then
rm -rf -- "${PRIVATE_DIR}"
fi
printf 'exit=%s\n' "${result}" >>"${ARTIFACT_DIR}/outcome.txt"
printf 'E2E artifacts: %s\n' "${ARTIFACT_DIR}"
exit "${result}"
}
trap cleanup EXIT
trap on_error ERR
CURRENT_PHASE="private_setup"
python3 "${ROOT}/scripts/full-flow-env.py" "${ENV_FILE}" >/dev/null
python3 "${ROOT}/scripts/full-flow-finance-env.py" "${ENV_FILE}" >/dev/null
source "${ROOT}/scripts/source_repository_identity_env.sh"
source_repository_identity_for_run "${ROOT}" "${ROOT}/scripts/write_source_repository_identity.py" "${IDENTITY_DIR}"
if [[ -z "${SOURCE_REPOSITORY_IDENTITY_FILE:-}" || ! -f "${SOURCE_REPOSITORY_IDENTITY_FILE}" ]]; then
printf 'source_identity=unavailable\n' >"${ARTIFACT_DIR}/outcome.txt"
exit 1
fi
mv -- "${SOURCE_REPOSITORY_IDENTITY_FILE}" "${IDENTITY_DIR}/identity.json"
chmod 0400 "${IDENTITY_DIR}/identity.json"
CURRENT_PHASE="compose_config"
compose config --quiet
CURRENT_PHASE="compose_build"
compose build >"${PRIVATE_DIR}/compose-up.out" 2>&1
# Verify the read-only script bind mount before starting the fixture stack.
CURRENT_PHASE="compose_script_preflight"
compose run --rm --no-deps --entrypoint python backend -c \
'import sys; sys.path[0:0]=["/run/e2e-scripts/full_flow", "/run/e2e-scripts/full_flow_finance"]; import verify, common, release; from pathlib import Path; path=Path("/run/e2e-scripts/e2e-scripts/native_chart8_prepare.py"); compile(path.read_text(), str(path), "exec"); print("e2e_script_imports=3; native_prepare_readable=true")' \
>"${PRIVATE_DIR}/script-preflight.out" 2>&1
# Start only the private fixture dependencies. Backend waits until its Gitea PAT
# is atomically added to the host-owned private env file.
CURRENT_PHASE="fixture_services_start"
compose up -d --wait --wait-timeout 900 \
db clickhouse gitea superset-dev superset-preprod superset-prod >>"${PRIVATE_DIR}/compose-up.out" 2>&1
# Establish a private Gitea destination using the existing local bootstrap owner.
CURRENT_PHASE="gitea_user_bootstrap"
compose exec -T gitea sh /fixture/gitea-init.sh >"${PRIVATE_DIR}/gitea-init.out" 2>&1
CURRENT_PHASE="gitea_repository_bootstrap"
compose run --rm --no-deps --entrypoint python backend /run/full-flow-git.py /run/private-env/full-flow.env \
>"${PRIVATE_DIR}/gitea-bootstrap.out" 2>&1
CURRENT_PHASE="backend_frontend_start"
compose up -d --wait --wait-timeout 600 backend frontend >>"${PRIVATE_DIR}/compose-up.out" 2>&1
CURRENT_PHASE="backend_api_readiness_smoke"
compose exec -T backend python -c \
'import urllib.request; response = urllib.request.urlopen("http://127.0.0.1:8000/api/ready", timeout=10); assert response.status == 200; print("backend_ready=200")' \
>"${PRIVATE_DIR}/backend-ready.out" 2>&1
# Native-result retention is an explicit deployment/test initialization, never an API fallback.
CURRENT_PHASE="native_storage_hold_init"
compose exec -T backend python -c \
'from src.services.storage_holds import initialize_native_holds; initialize_native_holds("/app/storage"); print("native_hold_state=initialized")' \
>"${PRIVATE_DIR}/native-hold-init.out" 2>&1
# Materialize only the existing synthetic finance fixture and Superset dashboard.
CURRENT_PHASE="finance_clickhouse_seed"
compose exec -T superset-dev python /fixture/seed_finance_clickhouse.py \
>"${ARTIFACT_DIR}/fixture-seed.json"
CURRENT_PHASE="finance_superset_seed"
compose exec -T superset-dev python /fixture/seed_finance_superset.py \
>"${ARTIFACT_DIR}/superset-seed.json"
CURRENT_PHASE="finance_stage_binding_seed"
for stage in superset-preprod superset-prod; do
compose exec -T "${stage}" python /fixture/seed_finance_superset.py --binding-only \
>>"${PRIVATE_DIR}/compose-up.out" 2>&1
done
# Run the owned public Git release flow against this isolated three-stage fixture.
CURRENT_PHASE="official_preprod_release"
mkdir -m 0700 -p "${ARTIFACT_DIR}/git-release"
compose exec -T --user "${E2E_PRIVATE_ENV_UID}:${E2E_PRIVATE_ENV_GID}" backend sh -c \
"umask 077; python /run/e2e-scripts/full_flow_finance/release.py --env-file /run/private-env/full-flow.env --assets /tmp/e2e-artifacts/superset-seed.json --output /tmp/e2e-artifacts/git-release" \
>"${PRIVATE_DIR}/official-release.out" 2>&1
# The shared native setup consumes an explicit target descriptor for either
# this disposable fixture or the long-lived lab. Clean E2E records only the
# values created by its owned release/fixture steps here.
CURRENT_PHASE="native_target_descriptor"
BACKEND_CONTAINER_ID="$(compose ps -q backend)"
[[ -n "${BACKEND_CONTAINER_ID}" ]] || { echo "Backend container identity is unavailable" >&2; exit 2; }
python3 - "${ARTIFACT_DIR}/superset-seed.json" \
"${ARTIFACT_DIR}/git-release/published-release.json" \
"${PRIVATE_DIR}/native-target.json" "${BACKEND_CONTAINER_ID}" <<'PY'
import json
import sys
from pathlib import Path
seed_path, release_path, target_path = map(Path, sys.argv[1:4])
backend_container_id = sys.argv[4]
seed = json.loads(seed_path.read_text())
release = json.loads(release_path.read_text())
target = {
"target_mode": "clean_fixture",
"backend_url": "http://127.0.0.1:8000",
"backend_username": "admin",
"backend_container": backend_container_id,
"source_environment_id": "full-flow-dev",
"target_environment_id": "full-flow-preprod",
"target_stage": "preprod",
"superset_url": "http://superset-preprod:8088",
"superset_username": "admin",
"source_dashboard_id": seed["dashboard_id"],
"source_dashboard_uuid": seed["dashboard_uuid"],
"target_dashboard_uuid": seed["dashboard_uuid"],
"chart_uuid": seed["acceptance_chart"]["chart_uuid"],
"reference_url": "http://superset-preprod:8088/superset/dashboard/finance-million/",
"expected_filter_context": [],
"capture_scenario": {"mode": "compile_structural", "selected_case_ids": ["B01"]},
"chart_contract": {
"dataset_uuid": seed["acceptance_chart"]["dataset_uuid"],
"viz_type": seed["acceptance_chart"]["viz_type"],
"group_by_columns": seed["acceptance_chart"]["groupby"],
"key_columns": seed["acceptance_chart"]["groupby"],
"row_key_column": seed["acceptance_chart"]["groupby"][0],
"metric_names": seed["acceptance_chart"]["metrics"],
"comparison_policy_intent": seed["acceptance_chart"]["comparison_policy_intent"],
},
"release_identity": {key: release[key] for key in ("id", "commit_hash", "content_hash")},
"catalog_mode": "seed_empty",
}
target_path.write_text(json.dumps(target, indent=2) + "\n")
PY
# Register the ordinary owned scenario and seed one empty current catalog head
# through public APIs. Native result capture will consume this exact revision.
CURRENT_PHASE="native_chart8_scenario_catalog_prepare"
compose exec -T --user "${E2E_PRIVATE_ENV_UID}:${E2E_PRIVATE_ENV_GID}" backend sh -c \
"umask 077; python /run/e2e-scripts/e2e-scripts/native_chart8_prepare.py --private-env-file /run/private-env/full-flow.env --seed /tmp/e2e-artifacts/superset-seed.json --published-release /tmp/e2e-artifacts/git-release/published-release.json --target-config /run/private-env/native-target.json --output /tmp/e2e-artifacts/native-setup" \
>"${PRIVATE_DIR}/native-setup.out" 2>&1
# The dedicated config runs two dashboard-testing UI/API tests with retries and browser artifacts off.
CURRENT_PHASE="dashboard_testing_playwright"
if compose run --rm dashboard-testing-runner >"${PRIVATE_DIR}/playwright.out" 2>&1; then
printf 'playwright=0\n' >"${ARTIFACT_DIR}/outcome.txt"
else
result=$?
printf 'failed_phase=dashboard_testing_playwright\nactual_exit=%s\nplaywright=%s\n' \
"${result}" "${result}" >>"${ARTIFACT_DIR}/outcome.txt"
exit "${result}"
fi
# #endregion Tooling.DashboardTestingE2E.Loop