#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) REPO_ROOT=$(cd "$SCRIPT_DIR/.." && pwd) SOLUTION="$REPO_ROOT/BuildFlow.slnx" COMPOSE_FILE="$SCRIPT_DIR/backend-optimization-compose.yml" RUN_TIMESTAMP=$(date -u +%Y%m%dT%H%M%SZ) RUN_ID="${RUN_TIMESTAMP}-$$" ARTIFACT_ROOT=${BACKEND_SOP_ARTIFACT_ROOT:-"$REPO_ROOT/artifacts/backend-optimization-sop"} ARTIFACT_DIR="$ARTIFACT_ROOT/$RUN_ID" LOG_DIR="$ARTIFACT_DIR/logs" TRX_DIR="$ARTIFACT_DIR/trx" SUMMARY_FILE="$ARTIFACT_DIR/summary.md" STAGE_FILE="$ARTIFACT_DIR/stages.tsv" ENV_FILE="$ARTIFACT_DIR/infrastructure.env" SUCCESS_FILE="$ARTIFACT_DIR/SUCCESS" COMPOSE_PROJECT="buildflow-sop-$(date -u +%Y%m%d%H%M%S)-$$" CURRENT_STAGE="not-started" CURRENT_LOG="" INFRA_STARTED=0 RUN_COMPLETED=0 mkdir -p "$LOG_DIR" "$TRX_DIR" printf 'stage\tstarted_utc\tended_utc\tduration_seconds\texit_status\tlog\tcommand\n' > "$STAGE_FILE" run_logged() { printf '+ ' printf '%q ' "$@" printf '\n' "$@" } run_stage() { local stage=$1 shift local log="$LOG_DIR/$stage.log" local started_at local ended_at local started_epoch local ended_epoch local status CURRENT_STAGE=$stage CURRENT_LOG=$log started_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) started_epoch=$(date +%s) { printf 'stage=%s\n' "$stage" printf 'started_utc=%s\n' "$started_at" printf 'command=' printf '%q ' "$@" printf '\n' } | tee "$log" set +e ( set -e cd "$REPO_ROOT" "$@" ) 2>&1 | tee -a "$log" status=${PIPESTATUS[0]} set -e ended_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) ended_epoch=$(date +%s) printf 'ended_utc=%s\nduration_seconds=%s\nexit_status=%s\n' \ "$ended_at" "$((ended_epoch - started_epoch))" "$status" | tee -a "$log" printf '%s\t%s\t%s\t%s\t%s\t%s\t' \ "$stage" "$started_at" "$ended_at" "$((ended_epoch - started_epoch))" \ "$status" "$log" >> "$STAGE_FILE" printf '%q ' "$@" >> "$STAGE_FILE" printf '\n' >> "$STAGE_FILE" return "$status" } require_command() { local name=$1 command -v "$name" >/dev/null 2>&1 || { printf 'Required command is missing: %s\n' "$name" >&2 return 1 } } assert_clean_repository() { local status status=$(git status --porcelain --untracked-files=normal) if [[ -n "$status" ]]; then printf 'Repository must be clean before running the closure SOP:\n%s\n' "$status" >&2 return 1 fi } assert_no_matches() { local description=$1 local pattern=$2 shift 2 local status printf 'scan=%s pattern=%s\n' "$description" "$pattern" set +e rg -n --pcre2 -U "$pattern" "$@" status=$? set -e if [[ $status -eq 0 ]]; then printf 'Forbidden source debt found: %s\n' "$description" >&2 return 1 fi if [[ $status -ne 1 ]]; then printf 'Static scan failed: %s (rg exit %s)\n' "$description" "$status" >&2 return "$status" fi } extract_counter() { local trx=$1 local counter=$2 local value value=$(rg -o "${counter}=\"[0-9]+\"" "$trx" | head -n 1 | tr -dc '0-9' || true) printf '%s' "${value:-0}" } collect_sop_metrics() { rg -o --no-filename \ -g '*.log' \ -g '*.trx' \ 'SOP_METRIC [A-Za-z0-9_]+=[0-9]+' \ "$LOG_DIR" "$TRX_DIR" 2>/dev/null | sort -u } assert_no_skipped_tests() { local target=$1 local found=0 local skipped=0 local trx while IFS= read -r trx; do found=1 skipped=$((skipped + $(extract_counter "$trx" notExecuted))) done < <(find "$target" -type f -name '*.trx' -print | sort) if [[ $found -eq 0 ]]; then printf 'No TRX result was produced under %s\n' "$target" >&2 return 1 fi if [[ $skipped -gt 0 ]]; then printf 'Skipped tests are forbidden: skipped=%s target=%s\n' "$skipped" "$target" >&2 return 1 fi printf 'skipped=0 target=%s\n' "$target" } run_test() { local label=$1 local project=$2 local filter=${3:-} local args args=( dotnet test "$project" --no-restore --no-build --logger "trx;LogFileName=${label}.trx" --results-directory "$TRX_DIR" --logger "console;verbosity=normal" ) if [[ -n "$filter" ]]; then args+=(--filter "$filter") fi run_logged "${args[@]}" assert_no_skipped_tests "$TRX_DIR/${label}.trx" } load_infrastructure_env() { if [[ ! -f "$ENV_FILE" ]]; then printf 'Infrastructure environment file is missing: %s\n' "$ENV_FILE" >&2 return 1 fi # shellcheck disable=SC1090 source "$ENV_FILE" } compose_port() { local service=$1 local container_port=$2 local endpoint printf '+ docker compose -p %q -f %q port %q %q\n' \ "$COMPOSE_PROJECT" "$COMPOSE_FILE" "$service" "$container_port" >&2 endpoint=$(docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" port "$service" "$container_port") printf 'endpoint=%s\n' "$endpoint" >&2 printf '%s' "${endpoint##*:}" } stage_preflight() { local command for command in dotnet docker git rg curl; do require_command "$command" done run_logged dotnet --version [[ $(dotnet --version) == 10.* ]] || { printf '.NET SDK 10 is required.\n' >&2 return 1 } run_logged docker --version run_logged docker compose version run_logged docker version --format '{{.Server.Version}}' run_logged git --version run_logged rg --version run_logged curl --version run_logged git rev-parse --show-toplevel assert_clean_repository } stage_static_guards() { local fixed_password_pattern='Password\s*=\s*(?:(?!REPLACE_IN_SECRETS|YOUR_[A-Z_]+)[A-Za-z0-9_-]{3,}(?:;|")|config\[[^]]+\]\s*\?\?\s*"[^"]+")' local one_argument_exception_pattern='new\s+(?:[A-Za-z_][A-Za-z0-9_:]*\.)*(?:BusinessException|NotFoundException|UnauthorizedException)\s*\(\s*"' local endpoint_dbcontext_pattern='class\s+[A-Za-z0-9_]+Endpoint\s*\([^)]*[A-Za-z0-9_]+DbContext\b' # 通配排除 *Secrets*.json:本地机密文件已 gitignore、绝不提交; # 本机部分 rg 构建不尊重 vcs ignore(连 --ignore-file 也不生效),需 glob 硬排除 assert_no_matches 'fixed password' "$fixed_password_pattern" src -g '*.cs' -g '*.json' -g '!*.Secrets.json' assert_no_matches 'one-argument business exception' "$one_argument_exception_pattern" src -g '*.cs' assert_no_matches 'Endpoint DbContext dependency' "$endpoint_dbcontext_pattern" src/BuildFlow.Host/Endpoints -g '*.cs' assert_no_matches 'InProcess bridge' 'InProcess(UserInfoService|FileStorageClient|AvatarUrlResolver)' src -g '*.cs' assert_no_matches 'local wall-clock usage' 'DateTime\.Now' src -g '*.cs' assert_no_matches 'detached workflow work' 'Task\.Run' src/modules/workflow src/BuildFlow.Host -g '*.cs' } stage_restore_build() { run_logged dotnet restore "$SOLUTION" run_logged dotnet build "$SOLUTION" --no-restore -warnaserror } stage_unit_tests() { local source_guard_filter='FullyQualifiedName~EndpointConstructorsShouldNotDependOnDbContexts|FullyQualifiedName~HostShouldNotContainInProcessBridges|FullyQualifiedName~BusinessExceptionsShouldSpecifySemanticErrorCodes|FullyQualifiedName~SourceFilesShouldContainAtMostOneTopLevelClassRecordOrEnum' local workflow_filter='FullyQualifiedName!~BuildFlow.Workflow.Tests.Reliability&FullyQualifiedName!~OpenXmlExportBoundaryTests&FullyQualifiedName!~FormAggregateQueryCountTests&FullyQualifiedName!~ImportJobBatchingTests' run_test 04-source-architecture tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj "$source_guard_filter" run_test 04-host-architecture tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj 'FullyQualifiedName~HostArchitectureTests' run_test 04-sop-contract tests/BuildFlow.Architecture.Tests/BuildFlow.Architecture.Tests.csproj 'FullyQualifiedName~BackendOptimizationSopTests' run_test 04-host tests/BuildFlow.Host.Tests/BuildFlow.Host.Tests.csproj run_test 04-platform tests/BuildFlow.Platform.Tests/BuildFlow.Platform.Tests.csproj run_test 04-scripting tests/BuildFlow.Scripting.Tests/BuildFlow.Scripting.Tests.csproj run_test 04-file tests/BuildFlow.File.Tests/BuildFlow.File.Tests.csproj 'Category!=External' run_test 04-rag tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj 'FullyQualifiedName!~RedisChatMessageCacheTests' run_test 04-im tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj 'FullyQualifiedName!~SendMessageCommandHandlerTests' run_test 04-workflow tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj "$workflow_filter" } stage_start_infrastructure() { local postgres_port local redis_port local mongo_port local minio_port run_logged docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" up -d --wait postgres_port=$(compose_port postgres 5432) redis_port=$(compose_port redis 6379) mongo_port=$(compose_port mongo 27017) minio_port=$(compose_port minio 9000) { printf 'export BUILDFLOW_WORKFLOW_TEST_CONNECTION=%q\n' "Host=127.0.0.1;Port=$postgres_port;Username=buildflow;Password=buildflow;Database=postgres" printf 'export BUILDFLOW_MONITORING_TEST_CONNECTION=%q\n' "Host=127.0.0.1;Port=$postgres_port;Username=buildflow;Password=buildflow;Database=postgres" printf 'export BUILDFLOW_RAG_TEST_POSTGRES_CONNECTION=%q\n' "Host=127.0.0.1;Port=$postgres_port;Username=buildflow;Password=buildflow;Database=postgres" printf 'export BUILDFLOW_RAG_REDIS_TEST_CONNECTION=%q\n' "127.0.0.1:$redis_port,abortConnect=false" printf 'export BUILDFLOW_IM_TEST_POSTGRES_CONNECTION=%q\n' "Host=127.0.0.1;Port=$postgres_port;Username=buildflow;Password=buildflow;Database=im_system_test" printf 'export BUILDFLOW_IM_TEST_MONGO_CONNECTION=%q\n' "mongodb://buildflow:buildflow@127.0.0.1:$mongo_port/im_system_test?authSource=admin" printf 'export BUILDFLOW_IM_TEST_REDIS_CONNECTION=%q\n' "127.0.0.1:$redis_port,abortConnect=false" printf 'export BUILDFLOW_FILE_S3_TEST_ENDPOINT=%q\n' "http://127.0.0.1:$minio_port" printf 'export BUILDFLOW_FILE_S3_TEST_ACCESS_KEY=%q\n' buildflowminio printf 'export BUILDFLOW_FILE_S3_TEST_SECRET_KEY=%q\n' buildflowminiosecret } > "$ENV_FILE" sed -E 's/(SECRET_KEY=).*/\1/' "$ENV_FILE" } stage_database_migrations() { load_infrastructure_env run_test 06-workflow-migrations tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj 'FullyQualifiedName~WorkflowJobMigrationTests' } stage_integration_tests() { load_infrastructure_env run_test 07-monitoring-upsert tests/BuildFlow.Monitoring.Tests/BuildFlow.Monitoring.Tests.csproj run_test 07-rag-redis tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj 'FullyQualifiedName~RedisChatMessageCacheTests' BUILDFLOW_IM_TEST_MONGO_CONNECTION=${BUILDFLOW_IM_TEST_MONGO_CONNECTION/im_system_test/im_unit_test} \ run_test 07-im-mongo tests/BuildFlow.IM.Tests/BuildFlow.IM.Tests.csproj 'FullyQualifiedName~SendMessageCommandHandlerTests' run_test 07-im-signalr tests/BuildFlow.IM.Integration.Tests/BuildFlow.IM.Integration.Tests.csproj run_test 07-file-s3 tests/BuildFlow.File.Tests/BuildFlow.File.Tests.csproj 'FullyQualifiedName~S3StorageServiceIntegrationTests' run_test 07-rag-stream tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj 'FullyQualifiedName~RagStreamQueryHandlerTests' run_test 07-rag-pgvector tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj 'FullyQualifiedName~RagPgVectorIntegrationTests' run_test 07-host-sse tests/BuildFlow.Host.Tests/BuildFlow.Host.Tests.csproj 'FullyQualifiedName~RagStreamEndpointTests' } stage_recovery_tests() { load_infrastructure_env run_test 08-workflow-recovery tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj 'FullyQualifiedName~WorkflowTimerProcessorTests|FullyQualifiedName~ImportJobClaimTests|FullyQualifiedName~ImportJobRecoveryTests' } stage_performance_tests() { load_infrastructure_env run_test 09-workflow-performance tests/BuildFlow.Workflow.Tests/BuildFlow.Workflow.Tests.csproj 'FullyQualifiedName~FormAggregateQueryCountTests|FullyQualifiedName~ImportJobBatchingTests|FullyQualifiedName~OpenXmlMemoryProfileTests|FullyQualifiedName~OpenXmlExportBoundaryTests' run_test 09-rag-performance tests/BuildFlow.Rag.Tests/BuildFlow.Rag.Tests.csproj 'FullyQualifiedName~ChatContextWindowBuilderTests|FullyQualifiedName~RedisChatMessageCacheTests' run_test 09-file-performance tests/BuildFlow.File.Tests/BuildFlow.File.Tests.csproj 'FullyQualifiedName~FileSizeVerificationProcessorTests' } stage_full_solution() { load_infrastructure_env run_logged dotnet test "$SOLUTION" \ --no-restore \ --no-build \ --logger 'trx;LogFilePrefix=10-full-solution' \ --results-directory "$TRX_DIR/10-full-solution" \ --logger 'console;verbosity=minimal' assert_no_skipped_tests "$TRX_DIR/10-full-solution" } write_summary() { local status=$1 local total=0 local passed=0 local failed=0 local skipped=0 local trx local failure_log='none' while IFS= read -r trx; do total=$((total + $(extract_counter "$trx" total))) passed=$((passed + $(extract_counter "$trx" passed))) failed=$((failed + $(extract_counter "$trx" failed))) skipped=$((skipped + $(extract_counter "$trx" notExecuted))) done < <(find "$TRX_DIR" -type f -name '*.trx' -print 2>/dev/null | sort) if [[ "$status" != success ]]; then failure_log=${CURRENT_LOG:-none} fi { printf '# Backend Optimization SOP Report\n\n' printf -- '- Run: `%s`\n' "$RUN_ID" printf -- '- Status: `%s`\n' "$status" printf -- '- Compose project: `%s`\n' "$COMPOSE_PROJECT" printf -- '- Tests: total `%s`, passed `%s`, failed `%s`, skipped `%s`\n' "$total" "$passed" "$failed" "$skipped" printf -- '- Failure log: `%s`\n\n' "$failure_log" printf '## Stages\n\n' printf '| Stage | Started UTC | Ended UTC | Seconds | Exit | Log | Command |\n' printf '|---|---|---|---:|---:|---|---|\n' tail -n +2 "$STAGE_FILE" | while IFS=$'\t' read -r stage started ended duration exit_code log command; do printf '| `%s` | `%s` | `%s` | %s | %s | `%s` | `%s` |\n' \ "$stage" "$started" "$ended" "$duration" "$exit_code" "$log" "$command" done printf '\n## Metrics\n\n' if ! collect_sop_metrics; then printf 'No SOP_METRIC lines were produced.\n' fi printf '\n## Evidence\n\n' printf -- '- Stage ledger: `%s`\n' "$STAGE_FILE" printf -- '- Logs: `%s`\n' "$LOG_DIR" printf -- '- TRX: `%s`\n' "$TRX_DIR" } > "$SUMMARY_FILE" } cleanup() { local status=$? local cleanup_status=0 trap - EXIT set +e if [[ $INFRA_STARTED -eq 1 ]]; then docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE" down -v \ >> "$ARTIFACT_DIR/cleanup.log" 2>&1 cleanup_status=$? fi if [[ $status -eq 0 && $cleanup_status -ne 0 ]]; then status=$cleanup_status CURRENT_STAGE=cleanup CURRENT_LOG="$ARTIFACT_DIR/cleanup.log" fi if [[ $status -ne 0 || $RUN_COMPLETED -ne 1 ]]; then rm -f "$SUCCESS_FILE" write_summary failure printf 'Backend optimization SOP failed in stage %s. Report: %s\n' \ "$CURRENT_STAGE" "$SUMMARY_FILE" >&2 fi exit "$status" } trap cleanup EXIT run_stage '01-preflight' stage_preflight run_stage '02-static-guards' stage_static_guards run_stage '03-restore-build' stage_restore_build run_stage '04-unit-tests' stage_unit_tests INFRA_STARTED=1 run_stage '05-start-infrastructure' stage_start_infrastructure run_stage '06-database-migrations' stage_database_migrations run_stage '07-integration-tests' stage_integration_tests run_stage '08-recovery-tests' stage_recovery_tests run_stage '09-performance-tests' stage_performance_tests run_stage '10-full-solution' stage_full_solution assert_no_skipped_tests "$TRX_DIR" write_summary success printf 'success\n' > "$SUCCESS_FILE" RUN_COMPLETED=1 printf 'Backend optimization SOP completed. Report: %s\n' "$SUMMARY_FILE"