#!/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. #> [CmdletBinding()] param( [switch]$SkipBuild, [switch]$InfraOnly, [switch]$ImagesOnly, [switch]$SkipImages ) $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 '^(?.+):(?\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" } # ─── 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 stella-ops.local 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 } $content = Get-Content $hostsPath -Raw if ($content -match 'stella-ops\.local') { Write-Ok 'stella-ops.local entries found in hosts file' return } Write-Warn 'stella-ops.local entries NOT found in hosts file.' 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 } # 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]') { $hostsBlock = Get-Content $hostsSource -Raw Add-Content -Path $hostsPath -Value "`n$hostsBlock" Write-Ok 'Hosts entries added 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 $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" } 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 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 } } 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 try { $request = [System.Net.WebRequest]::Create($url) $request.Method = 'GET' $request.Timeout = $timeoutSeconds * 1000 $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 { } if ($null -ne $statusCode -and $allowedStatusCodes -contains $statusCode) { return $statusCode } if ($attempt -lt $attempts) { Start-Sleep -Seconds $retryDelaySeconds } } return $null } # ─── 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 } # 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 (-not ($composeFiles | Where-Object { Test-Path $_ })) { $composeFiles = @('docker-compose.dev.yml', 'docker-compose.stella-ops.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 $infraSmokeFailed = Test-Smoke if ($infraSmokeFailed) { Write-Warn 'Infrastructure started with blocking smoke failures. Review output and docker compose logs.' } 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 $platformSmokeFailed = Test-Smoke if ($platformSmokeFailed) { Write-Warn 'Setup completed with blocking smoke failures. Review output and docker compose logs.' } Write-Host "`n=============================================" -ForegroundColor Green Write-Host ' Setup complete!' -ForegroundColor Green Write-Host ' Platform: https://stella-ops.local' -ForegroundColor Green Write-Host ' Docs: docs/dev/DEV_ENVIRONMENT_SETUP.md' -ForegroundColor Green Write-Host '=============================================' -ForegroundColor Green