Files
git.stella-ops.org/scripts/setup.ps1

1023 lines
34 KiB
PowerShell

#!/usr/bin/env pwsh
<#
.SYNOPSIS
Automated developer environment setup for Stella Ops (Windows).
.DESCRIPTION
Validates prerequisites, starts infrastructure, builds solutions and Docker images,
and launches the full platform.
.PARAMETER SkipBuild
Skip .NET solution builds.
.PARAMETER InfraOnly
Only start infrastructure containers (PostgreSQL, Valkey, SeaweedFS, Rekor, Zot).
.PARAMETER ImagesOnly
Only build Docker images (skip infra start and .NET build).
.PARAMETER SkipImages
Skip Docker image builds.
.PARAMETER QaIntegrationFixtures
Start the optional Harbor and GitHub App QA fixtures used for successful Integrations Hub onboarding checks.
#>
[CmdletBinding()]
param(
[switch]$SkipBuild,
[switch]$InfraOnly,
[switch]$ImagesOnly,
[switch]$SkipImages,
[switch]$QaIntegrationFixtures
)
$ErrorActionPreference = 'Stop'
$Root = git rev-parse --show-toplevel 2>$null
if (-not $Root) {
Write-Error 'Not inside a git repository. Run this script from within the Stella Ops repo.'
exit 1
}
$Root = $Root.Trim()
$ComposeDir = Join-Path $Root 'devops/compose'
# ─── Helpers ────────────────────────────────────────────────────────────────
function Write-Step([string]$msg) {
Write-Host "`n>> $msg" -ForegroundColor Cyan
}
function Write-Ok([string]$msg) {
Write-Host " [OK] $msg" -ForegroundColor Green
}
function Write-Warn([string]$msg) {
Write-Host " [WARN] $msg" -ForegroundColor Yellow
}
function Write-Fail([string]$msg) {
Write-Host " [FAIL] $msg" -ForegroundColor Red
}
function Test-Command([string]$cmd) {
return [bool](Get-Command $cmd -ErrorAction SilentlyContinue)
}
function Get-ComposeServices([string]$composeFile) {
$services = @()
if (-not (Test-Path $composeFile)) {
return $services
}
$ps = docker compose -f $composeFile ps --format json 2>$null
if (-not $ps) {
return $services
}
foreach ($line in $ps -split "`n") {
$line = $line.Trim()
if (-not $line) { continue }
try {
$services += ($line | ConvertFrom-Json)
} catch {}
}
return $services
}
function Get-ComposeExpectedServices([string]$composeFile) {
$services = @()
if (-not (Test-Path $composeFile)) {
return $services
}
$configured = docker compose -f $composeFile config --services 2>$null
if (-not $configured) {
return $services
}
foreach ($line in ($configured -split "`n")) {
$name = $line.Trim()
if ($name) {
$services += $name
}
}
return $services
}
function Get-RunningContainerByService([string]$serviceName) {
$names = docker ps --filter "label=com.docker.compose.service=$serviceName" --format "{{.Names}}" 2>$null
if (-not $names) {
return $null
}
foreach ($name in ($names -split "`n")) {
$trimmed = $name.Trim()
if ($trimmed) {
return $trimmed
}
}
return $null
}
function Get-ServiceHttpProbeUrl([string]$serviceName, [int]$containerPort, [string]$path = '/') {
$containerName = Get-RunningContainerByService $serviceName
if (-not $containerName) {
return $null
}
$portMapping = docker port $containerName "${containerPort}/tcp" 2>$null | Select-Object -First 1
if (-not $portMapping) {
return $null
}
$portMapping = $portMapping.Trim()
if ($portMapping -notmatch '^(?<host>.+):(?<port>\d+)$') {
return $null
}
$probeHost = $Matches.host
if ($probeHost -eq '0.0.0.0' -or $probeHost -eq '::') {
$probeHost = '127.0.0.1'
}
if (-not $path.StartsWith('/')) {
$path = "/$path"
}
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 {
Write-Step 'Checking prerequisites'
$allGood = $true
# dotnet
if (Test-Command 'dotnet') {
$v = (dotnet --version 2>$null)
if ($v -match '^10\.') {
Write-Ok "dotnet $v"
} else {
Write-Fail "dotnet $v found, but 10.x is required"
$allGood = $false
}
} else {
Write-Fail 'dotnet SDK not found. Install .NET 10 SDK.'
$allGood = $false
}
# node
if (Test-Command 'node') {
$v = (node --version 2>$null).TrimStart('v')
$major = [int]($v -split '\.')[0]
if ($major -ge 20) {
Write-Ok "node $v"
} else {
Write-Fail "node $v found, but 20+ is required"
$allGood = $false
}
} else {
Write-Fail 'node not found. Install Node.js 20+.'
$allGood = $false
}
# npm
if (Test-Command 'npm') {
$v = (npm --version 2>$null)
$major = [int]($v -split '\.')[0]
if ($major -ge 10) {
Write-Ok "npm $v"
} else {
Write-Fail "npm $v found, but 10+ is required"
$allGood = $false
}
} else {
Write-Fail 'npm not found.'
$allGood = $false
}
# docker
if (Test-Command 'docker') {
$v = (docker --version 2>$null)
Write-Ok "docker: $v"
} else {
Write-Fail 'docker not found. Install Docker Desktop.'
$allGood = $false
}
# docker compose
$composeOk = $false
try {
$null = docker compose version 2>$null
if ($LASTEXITCODE -eq 0) { $composeOk = $true }
} catch {}
if ($composeOk) {
Write-Ok 'docker compose available'
} else {
Write-Fail 'docker compose not available. Ensure Docker Desktop includes Compose V2.'
$allGood = $false
}
# git
if (Test-Command 'git') {
Write-Ok "git $(git --version 2>$null)"
} else {
Write-Fail 'git not found.'
$allGood = $false
}
if (-not $allGood) {
Write-Error 'Prerequisites not met. Install missing tools and re-run.'
exit 1
}
}
# ─── 2. Check and install hosts file ─────────────────────────────────────
function Test-HostsFile {
Write-Step 'Checking hosts file for required Stella Ops entries'
$hostsPath = 'C:\Windows\System32\drivers\etc\hosts'
$hostsSource = Join-Path $Root 'devops/compose/hosts.stellaops.local'
if (-not (Test-Path $hostsPath)) {
Write-Warn "Cannot read hosts file at $hostsPath"
return
}
if (-not (Test-Path $hostsSource)) {
Write-Warn "Hosts source file not found at $hostsSource"
Write-Host ' Add the hosts block from docs/dev/DEV_ENVIRONMENT_SETUP.md section 2' -ForegroundColor Yellow
return
}
$content = Get-Content $hostsPath -Raw
$sourceLines = Get-Content $hostsSource | Where-Object {
$trimmed = $_.Trim()
$trimmed -and -not $trimmed.StartsWith('#')
}
$missingLines = @()
$missingHosts = @()
foreach ($line in $sourceLines) {
$tokens = $line -split '\s+' | Where-Object { $_ }
if ($tokens.Count -lt 2) {
continue
}
$hostnames = $tokens | Select-Object -Skip 1
$lineMissing = $false
foreach ($hostname in $hostnames) {
$escapedHostname = [regex]::Escape($hostname)
if ($content -notmatch "(?m)(^|\s)$escapedHostname($|\s)") {
$missingHosts += $hostname
$lineMissing = $true
}
}
if ($lineMissing) {
$missingLines += $line
}
}
if ($missingHosts.Count -eq 0) {
Write-Ok 'All required Stella Ops host entries are present in the hosts file'
return
}
$missingHosts = $missingHosts | Sort-Object -Unique
Write-Warn "Missing Stella Ops host aliases: $([string]::Join(', ', $missingHosts))"
# Check if running as Administrator
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if ($isAdmin) {
Write-Host ''
Write-Host ' Stella Ops needs ~50 hosts file entries for local development.' -ForegroundColor Yellow
Write-Host ' Source: devops/compose/hosts.stellaops.local' -ForegroundColor Yellow
Write-Host ''
$answer = Read-Host ' Add entries to hosts file now? (Y/n)'
if ($answer -eq '' -or $answer -match '^[Yy]') {
Add-Content -Path $hostsPath -Value ''
Add-Content -Path $hostsPath -Value ($missingLines -join [Environment]::NewLine)
Write-Ok "Added $($missingLines.Count) missing host entry line(s) successfully"
} else {
Write-Warn 'Skipped. Add them manually before accessing the platform.'
Write-Host " Copy from: $hostsSource" -ForegroundColor Yellow
}
} else {
Write-Host ''
Write-Host ' Stella Ops needs ~50 hosts file entries for local development.' -ForegroundColor Yellow
Write-Host ' To install them, run this command in an elevated (Administrator) PowerShell:' -ForegroundColor Yellow
Write-Host ''
Write-Host " Get-Content '$hostsSource' | Add-Content '$hostsPath'" -ForegroundColor White
Write-Host ''
Write-Host ' Or re-run this script as Administrator to install them automatically.' -ForegroundColor Yellow
}
}
# ─── 3. Ensure .env ────────────────────────────────────────────────────────
function Initialize-EnvFile {
Write-Step 'Ensuring .env file exists'
$envFile = Join-Path $ComposeDir '.env'
$envExample = Join-Path $ComposeDir 'env/stellaops.env.example'
if (Test-Path $envFile) {
Write-Ok ".env already exists at $envFile"
} elseif (Test-Path $envExample) {
Copy-Item $envExample $envFile
Write-Ok "Copied $envExample -> $envFile"
Write-Warn 'For production, change POSTGRES_PASSWORD in .env.'
} else {
Write-Fail "Neither .env nor env/stellaops.env.example found in $ComposeDir"
exit 1
}
}
function Get-ComposeEnvValue([string]$key) {
$envFile = Join-Path $ComposeDir '.env'
if (-not (Test-Path $envFile)) {
return $null
}
foreach ($line in Get-Content $envFile) {
if ($line -match "^\s*$key=(.+)$") {
return $matches[1].Trim()
}
}
return $null
}
function Get-FrontdoorNetworkName {
if (-not [string]::IsNullOrWhiteSpace($env:FRONTDOOR_NETWORK)) {
return $env:FRONTDOOR_NETWORK.Trim()
}
$configured = Get-ComposeEnvValue 'FRONTDOOR_NETWORK'
if (-not [string]::IsNullOrWhiteSpace($configured)) {
return $configured
}
return 'stellaops_frontdoor'
}
function Ensure-FrontdoorNetwork {
$networkName = Get-FrontdoorNetworkName
if ([string]::IsNullOrWhiteSpace($networkName)) {
Write-Fail 'Unable to resolve the frontdoor Docker network name.'
exit 1
}
$existingNetworks = @(docker network ls --format '{{.Name}}' 2>$null)
if ($existingNetworks -contains $networkName) {
Write-Ok "Frontdoor network available ($networkName)"
return
}
Write-Warn "Frontdoor network missing ($networkName); creating it now."
docker network create $networkName | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Fail "Failed to create frontdoor network ($networkName)."
exit 1
}
Write-Ok "Created frontdoor network ($networkName)"
}
# ─── 4. Start infrastructure ───────────────────────────────────────────────
function Start-Infrastructure {
Write-Step 'Starting infrastructure containers (docker-compose.dev.yml)'
Push-Location $ComposeDir
try {
docker compose -f docker-compose.dev.yml up -d
if ($LASTEXITCODE -ne 0) {
Write-Fail 'Failed to start infrastructure containers.'
exit 1
}
Write-Host ' Waiting for containers to become healthy...' -ForegroundColor Gray
[void](Wait-ForComposeConvergence -composeFiles @('docker-compose.dev.yml') -successMessage 'All infrastructure containers healthy' -maxWaitSeconds 120)
}
finally {
Pop-Location
}
}
# ─── 5. Build .NET solutions ───────────────────────────────────────────────
function Build-Solutions {
Write-Step 'Building all .NET solutions'
$buildScript = Join-Path $Root 'scripts/build-all-solutions.ps1'
if (Test-Path $buildScript) {
& $buildScript -StopRepoHostProcesses
if ($LASTEXITCODE -ne 0) {
Write-Fail '.NET solution build failed.'
exit 1
}
Write-Ok '.NET solutions built successfully'
} else {
Write-Warn "Build script not found at $buildScript. Skipping .NET build."
}
}
# ─── 6. Build Docker images ────────────────────────────────────────────────
function Build-Images([switch]$PublishNoRestore) {
Write-Step 'Building Docker images'
$buildScript = Join-Path $Root 'devops/docker/build-all.ps1'
if (Test-Path $buildScript) {
$buildParameters = @{}
if ($PublishNoRestore) {
$buildParameters['PublishNoRestore'] = $true
}
& $buildScript @buildParameters
if ($LASTEXITCODE -ne 0) {
Write-Fail 'Docker image build failed.'
exit 1
}
Write-Ok 'Docker images built successfully'
} else {
Write-Warn "Build script not found at $buildScript. Skipping image build."
}
}
# ─── 7. Start full platform ────────────────────────────────────────────────
function Start-Platform {
Write-Step 'Starting full Stella Ops platform'
Ensure-FrontdoorNetwork
Push-Location $ComposeDir
try {
docker compose -f docker-compose.stella-ops.yml up -d
if ($LASTEXITCODE -ne 0) {
Write-Fail 'Failed to start platform services.'
exit 1
}
Write-Ok 'Platform services started'
}
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 Start-QaIntegrationFixtures {
Write-Step 'Starting QA integration fixtures'
Push-Location $ComposeDir
try {
docker compose -f docker-compose.integration-fixtures.yml up -d
if ($LASTEXITCODE -ne 0) {
Write-Fail 'Failed to start QA integration fixtures.'
exit 1
}
[void](Wait-ForComposeConvergence `
-composeFiles @('docker-compose.integration-fixtures.yml') `
-successMessage 'QA integration fixtures are healthy' `
-maxWaitSeconds 90 `
-restartAfterSeconds 30)
}
finally {
Pop-Location
}
}
function Test-ExpectedHttpStatus([string]$url, [int[]]$allowedStatusCodes, [int]$timeoutSeconds = 5, [int]$attempts = 6, [int]$retryDelaySeconds = 2) {
for ($attempt = 1; $attempt -le $attempts; $attempt++) {
$statusCode = $null
$previousCertificateCallback = $null
$hasCertificateCallbackOverride = $false
try {
$request = [System.Net.WebRequest]::Create($url)
$request.Method = 'GET'
$request.Timeout = $timeoutSeconds * 1000
if ($request -is [System.Net.HttpWebRequest]) {
$request.AllowAutoRedirect = $false
}
if ($url.StartsWith('https://', [System.StringComparison]::OrdinalIgnoreCase)) {
$previousCertificateCallback = [System.Net.ServicePointManager]::ServerCertificateValidationCallback
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
$hasCertificateCallbackOverride = $true
}
$response = [System.Net.HttpWebResponse]$request.GetResponse()
try {
$statusCode = [int]$response.StatusCode
} finally {
$response.Dispose()
}
} catch [System.Net.WebException] {
$webResponse = $_.Exception.Response -as [System.Net.HttpWebResponse]
if ($null -ne $webResponse) {
try {
$statusCode = [int]$webResponse.StatusCode
} finally {
$webResponse.Dispose()
}
}
} catch {
} finally {
if ($hasCertificateCallbackOverride) {
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = $previousCertificateCallback
}
}
if ($null -ne $statusCode -and $allowedStatusCodes -contains $statusCode) {
return $statusCode
}
if ($attempt -lt $attempts) {
Start-Sleep -Seconds $retryDelaySeconds
}
}
return $null
}
function Test-FrontdoorBootstrap {
$baseUrl = 'https://stella-ops.local'
$probes = @(
@{
Name = 'Frontdoor readiness'
Url = "$baseUrl/health/ready"
AllowedStatusCodes = @(200)
},
@{
Name = 'Frontdoor welcome page'
Url = "$baseUrl/welcome"
AllowedStatusCodes = @(200)
},
@{
Name = 'Frontdoor environment settings'
Url = "$baseUrl/envsettings.json"
AllowedStatusCodes = @(200)
},
@{
Name = 'Authority discovery'
Url = "$baseUrl/.well-known/openid-configuration"
AllowedStatusCodes = @(200)
},
@{
Name = 'Authority authorize bootstrap'
Url = "$baseUrl/connect/authorize?client_id=stella-ops-ui&redirect_uri=https%3A%2F%2Fstella-ops.local%2Fauth%2Fcallback&response_type=code&scope=openid%20profile%20email&state=setup-smoke&nonce=setup-smoke&code_challenge=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&code_challenge_method=S256"
AllowedStatusCodes = @(200, 302, 303)
}
)
Write-Step 'Waiting for frontdoor bootstrap readiness'
foreach ($probe in $probes) {
$statusCode = Test-ExpectedHttpStatus $probe.Url $probe.AllowedStatusCodes -timeoutSeconds 5 -attempts 24 -retryDelaySeconds 5
if ($null -ne $statusCode) {
Write-Ok "$($probe.Name) (HTTP $statusCode)"
continue
}
Write-Fail "$($probe.Name) did not reach an expected status ($($probe.AllowedStatusCodes -join '/'))"
return $false
}
return $true
}
function Test-AuthenticatedFrontdoorReadiness {
Write-Step 'Waiting for authenticated frontdoor route readiness'
Push-Location $Root
try {
& node 'src/Web/StellaOps.Web/scripts/live-frontdoor-authenticated-readiness.mjs'
if ($LASTEXITCODE -eq 0) {
Write-Ok 'Authenticated topology, notifications admin, and promotion flows are ready for first-user QA'
return $true
}
}
catch {
}
finally {
Pop-Location
}
Write-Fail 'Authenticated frontdoor route readiness did not converge'
return $false
}
# ─── 8. Smoke test ─────────────────────────────────────────────────────────
function Test-Smoke {
Write-Step 'Running smoke tests'
$hasBlockingFailures = $false
# Infrastructure checks
$postgresContainer = Get-RunningContainerByService 'postgres'
if ($postgresContainer) {
docker exec $postgresContainer pg_isready -U stellaops 2>$null | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Ok "PostgreSQL ($postgresContainer)"
} else {
Write-Fail "PostgreSQL not responding ($postgresContainer)"
$hasBlockingFailures = $true
}
} else {
Write-Fail 'PostgreSQL container not found'
$hasBlockingFailures = $true
}
$valkeyContainer = Get-RunningContainerByService 'valkey'
if ($valkeyContainer) {
$valkeyResponse = (docker exec $valkeyContainer valkey-cli ping 2>$null)
if ($valkeyResponse -and $valkeyResponse.Trim() -eq 'PONG') {
Write-Ok "Valkey ($valkeyContainer)"
} else {
Write-Fail "Valkey not responding ($valkeyContainer)"
$hasBlockingFailures = $true
}
} else {
Write-Fail 'Valkey container not found'
$hasBlockingFailures = $true
}
$rustFsUrl = Get-ServiceHttpProbeUrl 'rustfs' 8333 '/'
$rustFsStatus = if ($rustFsUrl) { Test-ExpectedHttpStatus $rustFsUrl @(200, 403) } else { $null }
if ($null -ne $rustFsStatus) {
Write-Ok "RustFS S3 endpoint (HTTP $rustFsStatus)"
} else {
Write-Fail 'RustFS S3 endpoint did not respond with an expected status (wanted 200/403)'
$hasBlockingFailures = $true
}
$registryUrl = Get-ServiceHttpProbeUrl 'registry' 5000 '/v2/'
$registryStatus = if ($registryUrl) { Test-ExpectedHttpStatus $registryUrl @(200, 401) } else { $null }
if ($null -ne $registryStatus) {
Write-Ok "Zot registry endpoint (HTTP $registryStatus)"
} else {
Write-Fail 'Zot registry endpoint did not respond with an expected status (wanted 200/401)'
$hasBlockingFailures = $true
}
if ($QaIntegrationFixtures) {
$harborFixtureStatus = Test-ExpectedHttpStatus 'http://127.1.1.6/api/v2.0/health' @(200)
if ($null -ne $harborFixtureStatus) {
Write-Ok "Harbor QA fixture (HTTP $harborFixtureStatus)"
} else {
Write-Fail 'Harbor QA fixture did not respond with HTTP 200 on /api/v2.0/health'
$hasBlockingFailures = $true
}
$githubFixtureStatus = Test-ExpectedHttpStatus 'http://127.1.1.7/api/v3/app' @(200)
if ($null -ne $githubFixtureStatus) {
Write-Ok "GitHub App QA fixture (HTTP $githubFixtureStatus)"
} else {
Write-Fail 'GitHub App QA fixture did not respond with HTTP 200 on /api/v3/app'
$hasBlockingFailures = $true
}
}
if (-not $InfraOnly) {
if (Test-FrontdoorBootstrap) {
Write-Ok 'Frontdoor bootstrap path is ready for first-user sign-in'
} else {
$hasBlockingFailures = $true
}
if (Test-AuthenticatedFrontdoorReadiness) {
Write-Ok 'Authenticated frontdoor route readiness converged'
} else {
$hasBlockingFailures = $true
}
}
# Platform container health summary
Write-Step 'Container health summary'
Push-Location $ComposeDir
try {
$composeFiles = if ($InfraOnly) {
@('docker-compose.dev.yml')
} else {
@('docker-compose.stella-ops.yml')
}
if ($QaIntegrationFixtures) {
$composeFiles += 'docker-compose.integration-fixtures.yml'
}
if (-not ($composeFiles | Where-Object { Test-Path $_ })) {
$composeFiles = @('docker-compose.dev.yml', 'docker-compose.stella-ops.yml')
if ($QaIntegrationFixtures) {
$composeFiles += 'docker-compose.integration-fixtures.yml'
}
}
$totalContainers = 0
$healthyContainers = 0
$warningNames = @()
$blockingNames = @()
$seenContainers = @{}
foreach ($cf in $composeFiles) {
$expectedServices = Get-ComposeExpectedServices $cf
$services = Get-ComposeServices $cf
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
$totalContainers++
$state = "$($svc.State)".ToLowerInvariant()
$health = "$($svc.Health)".ToLowerInvariant()
if ($state -ne 'running') {
$blockingNames += "$name (state=$state)"
continue
}
if (-not $health -or $health -eq 'healthy') {
$healthyContainers++
} elseif ($health -eq 'starting') {
$warningNames += "$name (health=starting)"
} else {
$blockingNames += "$name (health=$health)"
}
}
}
if ($totalContainers -gt 0) {
if ($blockingNames.Count -eq 0 -and $warningNames.Count -eq 0) {
Write-Ok "$healthyContainers/$totalContainers containers healthy"
} elseif ($blockingNames.Count -eq 0) {
Write-Warn "$healthyContainers/$totalContainers containers healthy ($($warningNames.Count) still starting)"
foreach ($name in $warningNames) {
Write-Warn " Advisory: $name"
}
} else {
Write-Fail "$healthyContainers/$totalContainers containers healthy ($($blockingNames.Count) blocking issue(s))"
foreach ($name in $blockingNames) {
Write-Fail " Blocking: $name"
}
foreach ($name in $warningNames) {
Write-Warn " Advisory: $name"
}
$hasBlockingFailures = $true
}
}
# Platform endpoint check
try {
$tcp = New-Object System.Net.Sockets.TcpClient
$tcp.Connect('stella-ops.local', 443)
$tcp.Close()
Write-Ok 'Platform accessible at https://stella-ops.local'
} catch {
Write-Warn 'Platform not yet accessible at https://stella-ops.local (may still be starting)'
}
}
finally {
Pop-Location
}
return $hasBlockingFailures
}
# ─── Main ───────────────────────────────────────────────────────────────────
Write-Host '=============================================' -ForegroundColor Cyan
Write-Host ' Stella Ops Developer Environment Setup' -ForegroundColor Cyan
Write-Host '=============================================' -ForegroundColor Cyan
Test-Prerequisites
Test-HostsFile
if ($ImagesOnly) {
Build-Images
Write-Host "`nDone (images only)." -ForegroundColor Green
exit 0
}
Initialize-EnvFile
if ($InfraOnly) {
Start-Infrastructure
if ($QaIntegrationFixtures) {
Start-QaIntegrationFixtures
}
$infraSmokeFailed = Test-Smoke
if ($infraSmokeFailed) {
Write-Fail 'Infrastructure setup did not pass blocking smoke tests. Review output and docker compose logs.'
exit 1
}
Write-Host "`nDone (infra only). Infrastructure is running." -ForegroundColor Green
exit 0
}
if (-not $SkipBuild) {
Build-Solutions
}
if (-not $SkipImages) {
Build-Images -PublishNoRestore:(-not $SkipBuild)
}
Start-Platform
if ($QaIntegrationFixtures) {
Start-QaIntegrationFixtures
}
$platformSmokeFailed = Test-Smoke
if ($platformSmokeFailed) {
Write-Fail 'Setup did not pass blocking smoke tests. Review output and docker compose logs.'
exit 1
}
Write-Host "`n=============================================" -ForegroundColor Green
Write-Host ' Setup complete!' -ForegroundColor Green
Write-Host ' Platform: https://stella-ops.local' -ForegroundColor Green
if ($QaIntegrationFixtures) {
Write-Host ' Harbor QA fixture: http://harbor-fixture.stella-ops.local/api/v2.0/health' -ForegroundColor Green
Write-Host ' GitHub App QA fixture: http://github-app-fixture.stella-ops.local/api/v3/app' -ForegroundColor Green
}
Write-Host ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md' -ForegroundColor Green
Write-Host '=============================================' -ForegroundColor Green
exit 0