Complete scratch iteration 004 setup and grouped route-action fixes

This commit is contained in:
master
2026-03-12 19:28:42 +02:00
parent d8d3133060
commit 317e55e623
26 changed files with 1124 additions and 304 deletions

View File

@@ -5,7 +5,9 @@
.DESCRIPTION
Discovers all *.sln files under src/ (excluding the root StellaOps.sln)
and runs dotnet build on each. Pass -Test to also run dotnet test.
and skips generated output trees such as node_modules, bin, obj, dist,
coverage, and output before running dotnet build on each. Pass -Test to
also run dotnet test.
.PARAMETER Test
Also run dotnet test on each solution after building.
@@ -147,10 +149,12 @@ if ($StopRepoHostProcesses) {
Stop-RepoHostProcesses -Root $repoRoot
}
$excludedSolutionRootPattern = '[\\/](node_modules|bin|obj|dist|coverage|output)[\\/]'
$solutions = Get-ChildItem -Path $srcDir -Filter '*.sln' -Recurse |
Where-Object {
$_.Name -ne 'StellaOps.sln' -and
$_.FullName -notmatch '[\\/](node_modules|bin|obj)[\\/]'
$_.FullName -notmatch $excludedSolutionRootPattern
} |
Sort-Object FullName

View File

@@ -62,10 +62,10 @@ if $STOP_REPO_HOST_PROCESSES; then
stop_repo_host_processes
fi
# Discover solutions (exclude root StellaOps.sln)
# Discover repo-owned solutions only; skip generated output trees.
mapfile -t SOLUTIONS < <(
find "$SRC_DIR" \
\( -path '*/node_modules/*' -o -path '*/bin/*' -o -path '*/obj/*' \) -prune -o \
\( -path '*/node_modules/*' -o -path '*/bin/*' -o -path '*/obj/*' -o -path '*/dist/*' -o -path '*/coverage/*' -o -path '*/output/*' \) -prune -o \
-name '*.sln' ! -name 'StellaOps.sln' -print | sort
)

View File

@@ -142,6 +142,166 @@ function Get-ServiceHttpProbeUrl([string]$serviceName, [int]$containerPort, [str
return "http://${probeHost}:$($Matches.port)$path"
}
function Get-ComposeServiceRecords([string[]]$composeFiles) {
$records = @()
$seenContainers = @{}
foreach ($composeFile in $composeFiles) {
$composePath = if ([System.IO.Path]::IsPathRooted($composeFile)) {
$composeFile
} else {
Join-Path $ComposeDir $composeFile
}
$expectedServices = Get-ComposeExpectedServices $composePath
$services = Get-ComposeServices $composePath
if ($expectedServices.Count -gt 0) {
$allowed = @{}
foreach ($name in $expectedServices) {
$allowed[$name.ToLowerInvariant()] = $true
}
$services = $services | Where-Object {
$service = "$($_.Service)".ToLowerInvariant()
$service -and $allowed.ContainsKey($service)
}
}
foreach ($svc in $services) {
$name = "$($svc.Name)"
if (-not $name -or $seenContainers.ContainsKey($name)) {
continue
}
$seenContainers[$name] = $true
$records += [pscustomobject]@{
ComposeFile = $composePath
Service = "$($svc.Service)"
Name = $name
State = "$($svc.State)".ToLowerInvariant()
Health = "$($svc.Health)".ToLowerInvariant()
}
}
}
return $records
}
function Wait-ForComposeConvergence(
[string[]]$composeFiles,
[string]$successMessage,
[int]$maxWaitSeconds = 180,
[int]$restartAfterSeconds = 45,
[int]$pollSeconds = 5,
[switch]$RestartStalledServices
) {
$restartedServices = @{}
$elapsed = 0
while ($elapsed -lt $maxWaitSeconds) {
$records = Get-ComposeServiceRecords $composeFiles
if ($records.Count -gt 0) {
$pending = @()
$blocking = @()
foreach ($record in $records) {
if ($record.State -ne 'running') {
$blocking += $record
continue
}
if (-not $record.Health -or $record.Health -eq 'healthy') {
continue
}
if ($record.Health -eq 'starting') {
$pending += $record
continue
}
$blocking += $record
}
if ($blocking.Count -eq 0 -and $pending.Count -eq 0 -and $elapsed -gt $pollSeconds) {
Write-Ok $successMessage
return $true
}
if ($RestartStalledServices -and $elapsed -ge $restartAfterSeconds -and $blocking.Count -gt 0) {
$restartGroups = @{}
foreach ($record in $blocking) {
$restartKey = "$($record.ComposeFile)|$($record.Service)"
if ($restartedServices.ContainsKey($restartKey)) {
continue
}
if (-not $restartGroups.ContainsKey($record.ComposeFile)) {
$restartGroups[$record.ComposeFile] = New-Object System.Collections.Generic.List[string]
}
$restartGroups[$record.ComposeFile].Add($record.Service)
$restartedServices[$restartKey] = $true
}
foreach ($group in $restartGroups.GetEnumerator()) {
$servicesToRestart = @($group.Value | Sort-Object -Unique)
if ($servicesToRestart.Count -eq 0) {
continue
}
Write-Warn "Restarting stalled services from $($group.Key): $($servicesToRestart -join ', ')"
Push-Location $ComposeDir
try {
docker compose -f $group.Key restart @servicesToRestart | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Ok "Restarted stalled services: $($servicesToRestart -join ', ')"
} else {
Write-Warn "Failed to restart stalled services: $($servicesToRestart -join ', ')"
}
}
finally {
Pop-Location
}
}
}
}
Start-Sleep -Seconds $pollSeconds
$elapsed += $pollSeconds
}
$finalRecords = Get-ComposeServiceRecords $composeFiles
$blockingSummary = @(
$finalRecords | ForEach-Object {
if ($_.State -ne 'running') {
"$($_.Name) (state=$($_.State))"
}
elseif ($_.Health -and $_.Health -ne 'healthy' -and $_.Health -ne 'starting') {
"$($_.Name) (health=$($_.Health))"
}
}
) | Where-Object { $_ }
$pendingSummary = @(
$finalRecords | Where-Object {
$_.State -eq 'running' -and $_.Health -eq 'starting'
} | ForEach-Object {
"$($_.Name) (health=starting)"
}
)
if ($blockingSummary.Count -gt 0) {
Write-Warn "Timed out waiting for compose convergence after ${maxWaitSeconds}s. Blocking services: $($blockingSummary -join ', ')"
} elseif ($pendingSummary.Count -gt 0) {
Write-Warn "Timed out waiting for compose convergence after ${maxWaitSeconds}s. Still starting: $($pendingSummary -join ', ')"
} else {
Write-Warn "Timed out waiting for compose convergence after ${maxWaitSeconds}s."
}
return $false
}
# ─── 1. Check prerequisites ────────────────────────────────────────────────
function Test-Prerequisites {
@@ -365,44 +525,7 @@ function Start-Infrastructure {
}
Write-Host ' Waiting for containers to become healthy...' -ForegroundColor Gray
$maxWait = 120
$elapsed = 0
while ($elapsed -lt $maxWait) {
$expectedServices = Get-ComposeExpectedServices 'docker-compose.dev.yml'
$services = Get-ComposeServices 'docker-compose.dev.yml'
if ($expectedServices.Count -gt 0) {
$allowed = @{}
foreach ($name in $expectedServices) {
$allowed[$name.ToLowerInvariant()] = $true
}
$services = $services | Where-Object {
$service = "$($_.Service)".ToLowerInvariant()
$service -and $allowed.ContainsKey($service)
}
}
if ($services.Count -gt 0) {
$allHealthy = $true
foreach ($svc in $services) {
$state = "$($svc.State)".ToLowerInvariant()
$health = "$($svc.Health)".ToLowerInvariant()
if ($state -ne 'running') {
$allHealthy = $false
continue
}
if ($health -and $health -ne 'healthy') {
$allHealthy = $false
}
}
if ($allHealthy -and $elapsed -gt 5) {
Write-Ok 'All infrastructure containers healthy'
return
}
}
Start-Sleep -Seconds 5
$elapsed += 5
}
Write-Warn "Timed out waiting for healthy status after ${maxWait}s. Check with: docker compose -f docker-compose.dev.yml ps"
[void](Wait-ForComposeConvergence -composeFiles @('docker-compose.dev.yml') -successMessage 'All infrastructure containers healthy' -maxWaitSeconds 120)
}
finally {
Pop-Location
@@ -465,6 +588,13 @@ function Start-Platform {
finally {
Pop-Location
}
[void](Wait-ForComposeConvergence `
-composeFiles @('docker-compose.stella-ops.yml') `
-successMessage 'Platform services converged from zero-state startup' `
-RestartStalledServices `
-maxWaitSeconds 180 `
-restartAfterSeconds 45)
}
function Test-ExpectedHttpStatus([string]$url, [int[]]$allowedStatusCodes, [int]$timeoutSeconds = 5, [int]$attempts = 6, [int]$retryDelaySeconds = 2) {

View File

@@ -70,6 +70,179 @@ service_http_probe_url() {
printf 'http://%s:%s%s' "$host" "$host_port" "$path"
}
get_compose_service_records() {
local seen_names=""
local compose_file compose_path expected_services services_json line service name state health
for compose_file in "$@"; do
if [[ "${compose_file}" = /* ]]; then
compose_path="${compose_file}"
else
compose_path="${COMPOSE_DIR}/${compose_file}"
fi
[[ -f "${compose_path}" ]] || continue
expected_services="$(docker compose -f "${compose_path}" config --services 2>/dev/null || true)"
services_json="$(docker compose -f "${compose_path}" ps --format json 2>/dev/null || true)"
[[ -n "${services_json}" ]] || continue
while IFS= read -r line; do
[[ -z "${line}" ]] && continue
service=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Service',''))" 2>/dev/null || true)
name=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Name',''))" 2>/dev/null || true)
state=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('State',''))" 2>/dev/null || true)
health=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Health',''))" 2>/dev/null || true)
if [[ -n "${expected_services}" ]] && ! printf '%s\n' "${expected_services}" | grep -Fxq "${service}"; then
continue
fi
[[ -n "${name}" ]] || continue
if printf '%s\n' "${seen_names}" | grep -Fxq "${name}"; then
continue
fi
seen_names="${seen_names}
${name}"
printf '%s|%s|%s|%s|%s\n' "${compose_path}" "${service}" "${name}" "${state,,}" "${health,,}"
done <<< "${services_json}"
done
}
wait_for_compose_convergence() {
local success_message="$1"
local restart_stalled="${2:-false}"
local max_wait="${3:-180}"
local restart_after="${4:-45}"
shift 4
local compose_files=("$@")
local elapsed=0
local poll_seconds=5
local restarted_services=""
while (( elapsed < max_wait )); do
local records
records="$(get_compose_service_records "${compose_files[@]}")"
if [[ -n "${records}" ]]; then
local pending=""
local blocking=""
local record compose_file service name state health
while IFS= read -r record; do
[[ -z "${record}" ]] && continue
IFS='|' read -r compose_file service name state health <<< "${record}"
if [[ "${state}" != "running" ]]; then
blocking="${blocking}
${compose_file}|${service}|${name}|state=${state}"
continue
fi
if [[ -z "${health}" || "${health}" == "healthy" ]]; then
continue
fi
if [[ "${health}" == "starting" ]]; then
pending="${pending}
${compose_file}|${service}|${name}|health=starting"
continue
fi
blocking="${blocking}
${compose_file}|${service}|${name}|health=${health}"
done <<< "${records}"
if [[ -z "${blocking//$'\n'/}" && -z "${pending//$'\n'/}" && ${elapsed} -gt ${poll_seconds} ]]; then
ok "${success_message}"
return 0
fi
if [[ "${restart_stalled}" == "true" && ${elapsed} -ge ${restart_after} && -n "${blocking//$'\n'/}" ]]; then
local restart_targets=""
while IFS= read -r record; do
[[ -z "${record}" ]] && continue
IFS='|' read -r compose_file service _ <<< "${record}"
local restart_key="${compose_file}|${service}"
if printf '%s\n' "${restarted_services}" | grep -Fxq "${restart_key}"; then
continue
fi
restarted_services="${restarted_services}
${restart_key}"
restart_targets="${restart_targets}
${compose_file}|${service}"
done <<< "${blocking}"
local compose_to_restart unique_compose_files service_to_restart
local -a services_for_compose
unique_compose_files=$(printf '%s\n' "${restart_targets}" | awk -F'|' 'NF { print $1 }' | sort -u)
while IFS= read -r compose_to_restart; do
[[ -z "${compose_to_restart}" ]] && continue
services_for_compose=()
while IFS= read -r service_to_restart; do
[[ -z "${service_to_restart}" ]] && continue
services_for_compose+=("${service_to_restart}")
done < <(printf '%s\n' "${restart_targets}" | awk -F'|' -v cf="${compose_to_restart}" 'NF && $1 == cf { print $2 }' | sort -u)
if (( ${#services_for_compose[@]} == 0 )); then
continue
fi
warn "Restarting stalled services from ${compose_to_restart}: ${services_for_compose[*]}"
(
cd "${COMPOSE_DIR}" &&
docker compose -f "${compose_to_restart}" restart "${services_for_compose[@]}" >/dev/null
) && ok "Restarted stalled services: ${services_for_compose[*]}" || \
warn "Failed to restart stalled services: ${services_for_compose[*]}"
done <<< "${unique_compose_files}"
fi
fi
sleep "${poll_seconds}"
elapsed=$((elapsed + poll_seconds))
done
local final_records
final_records="$(get_compose_service_records "${compose_files[@]}")"
local final_blocking=""
local final_pending=""
local record compose_file service name state health
while IFS= read -r record; do
[[ -z "${record}" ]] && continue
IFS='|' read -r compose_file service name state health <<< "${record}"
if [[ "${state}" != "running" ]]; then
final_blocking="${final_blocking}
${name} (state=${state})"
continue
fi
if [[ -n "${health}" && "${health}" != "healthy" ]]; then
if [[ "${health}" == "starting" ]]; then
final_pending="${final_pending}
${name} (health=starting)"
else
final_blocking="${final_blocking}
${name} (health=${health})"
fi
fi
done <<< "${final_records}"
if [[ -n "${final_blocking//$'\n'/}" ]]; then
warn "Timed out waiting for compose convergence after ${max_wait}s. Blocking services: $(printf '%s\n' "${final_blocking}" | awk 'NF { print }' | paste -sd ', ' -)"
elif [[ -n "${final_pending//$'\n'/}" ]]; then
warn "Timed out waiting for compose convergence after ${max_wait}s. Still starting: $(printf '%s\n' "${final_pending}" | awk 'NF { print }' | paste -sd ', ' -)"
else
warn "Timed out waiting for compose convergence after ${max_wait}s."
fi
return 1
}
# ─── 1. Check prerequisites ────────────────────────────────────────────────
check_prerequisites() {
@@ -264,27 +437,7 @@ start_infra() {
docker compose -f docker-compose.dev.yml up -d
echo ' Waiting for containers to become healthy...'
local max_wait=120
local elapsed=0
while (( elapsed < max_wait )); do
local all_healthy=true
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local health; health=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin).get('Health',''))" 2>/dev/null || true)
if [[ -n "$health" && "$health" != "healthy" ]]; then
all_healthy=false
fi
done < <(docker compose -f docker-compose.dev.yml ps --format json 2>/dev/null)
if [[ "$all_healthy" == "true" && $elapsed -gt 5 ]]; then
ok 'All infrastructure containers healthy'
cd "$ROOT"
return
fi
sleep 5
elapsed=$((elapsed + 5))
done
warn "Timed out waiting for healthy status after ${max_wait}s."
wait_for_compose_convergence 'All infrastructure containers healthy' false 120 45 docker-compose.dev.yml || true
cd "$ROOT"
}
@@ -330,6 +483,7 @@ start_platform() {
docker compose -f docker-compose.stella-ops.yml up -d
ok 'Platform services started'
cd "$ROOT"
wait_for_compose_convergence 'Platform services converged from zero-state startup' true 180 45 docker-compose.stella-ops.yml || true
}
http_status() {