297 lines
14 KiB
Bash
Executable File
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
|