build-flow.api/scripts/backend-optimization-sop.sh
2026-07-24 13:29:29 +08:00

407 lines
16 KiB
Bash
Executable File

#!/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'
assert_no_matches 'fixed password' "$fixed_password_pattern" src -g '*.cs' -g '*.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_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<redacted>/' "$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-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"