| 1 | param( |
| 2 | [ValidateSet("up", "deploy", "down", "clean", "status", "url", "components")] |
| 3 | [string]$Command = "up", |
| 4 | |
| 5 | # For `deploy`, choose individual components. Example: |
| 6 | # .\platform.ps1 deploy -Component frontend,research-engine |
| 7 | [string[]]$Component, |
| 8 | |
| 9 | # Optional named deployment profile. Supported values are printed by |
| 10 | # `.\platform.ps1 components`. |
| 11 | [string]$Profile, |
| 12 | |
| 13 | [switch]$SkipBuild, |
| 14 | [switch]$NoCache, |
| 15 | [switch]$KeepOldImages, |
| 16 | [switch]$ForceClean, |
| 17 | [switch]$NoBrowser, |
| 18 | [switch]$EnableIbkr |
| 19 | ) |
| 20 | |
| 21 | $ErrorActionPreference = "Stop" |
| 22 | $ProgressPreference = "SilentlyContinue" |
| 23 | |
| 24 | # ----------------------------------------------------------------------------- |
| 25 | # AI Investment Platform - LOCAL full-stack deployment helper |
| 26 | # |
| 27 | # Usage from repository root: |
| 28 | # .\platform.ps1 |
| 29 | # .\platform.ps1 up |
| 30 | # .\platform.ps1 up -SkipBuild |
| 31 | # .\platform.ps1 up -EnableIbkr |
| 32 | # .\platform.ps1 deploy -Component frontend,research-engine |
| 33 | # .\platform.ps1 deploy -Profile research-ui |
| 34 | # .\platform.ps1 components |
| 35 | # .\platform.ps1 status |
| 36 | # .\platform.ps1 url |
| 37 | # .\platform.ps1 down |
| 38 | # .\platform.ps1 clean |
| 39 | # |
| 40 | # LOCAL behavior: |
| 41 | # - Creates/starts the k3d cluster. |
| 42 | # - Creates required external DEV secrets interactively when missing. |
| 43 | # - `up` builds the complete stack; `deploy` rebuilds only selected components/profiles. |
| 44 | # - Every build uses a new immutable timestamp tag. After a successful rollout, |
| 45 | # older images for the deployed component(s) are removed unless -KeepOldImages is used. |
| 46 | # - Imports the immutable images directly into k3d (no local-registry push required). |
| 47 | # - Installs/upgrades the Helm release without blocking on startup, then reconciles |
| 48 | # application Deployments to the exact immutable release tag. |
| 49 | # - Applies LOCAL runtime normalization before waiting for readiness. |
| 50 | # - IBKR runtime is disabled by default because broker authentication is parked. |
| 51 | # Pass -EnableIbkr only when actively testing IBKR. |
| 52 | # - Waits for the complete enabled stack, prints diagnostics on failure, |
| 53 | # verifies the gateway application URL, and opens it unless -NoBrowser is supplied. |
| 54 | # - Never falls back to a direct frontend port-forward because the frontend uses |
| 55 | # same-origin /api routes through the API Gateway. |
| 56 | # - "down" stops the k3d cluster and preserves PostgreSQL/PVC state. |
| 57 | # - "clean" is intentionally destructive: it deletes the local k3d cluster, |
| 58 | # its Kubernetes/Helm/PVC runtime state, and all host Docker application image |
| 59 | # tags for this platform. Use -ForceClean to skip the confirmation prompt. |
| 60 | # ----------------------------------------------------------------------------- |
| 61 | |
| 62 | $ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path |
| 63 | $HelmChart = Join-Path $ProjectRoot "infrastructure\helm\ai-investment-platform" |
| 64 | $HelmValues = Join-Path $HelmChart "values-dev.yaml" |
| 65 | $K3dConfig = Join-Path $ProjectRoot "infrastructure\k3d\cluster-dev.yaml" |
| 66 | |
| 67 | $ClusterName = "ai-investment-dev" |
| 68 | $Namespace = "ai-investment" |
| 69 | $ReleaseName = "ai-investment-platform" |
| 70 | $ImageRegistry = "localhost:5001" |
| 71 | $ImageTag = $null |
| 72 | $IngressApplicationUrl = "http://localhost:18080" |
| 73 | $RuntimeDir = Join-Path $ProjectRoot ".tmp" |
| 74 | $ApplicationUrlFile = Join-Path $RuntimeDir "application-url.txt" |
| 75 | $ImageTagFile = Join-Path $RuntimeDir "last-image-tag.txt" |
| 76 | $LegacyPortForwardPidFile = Join-Path $RuntimeDir "frontend-port-forward.pid" |
| 77 | |
| 78 | $JavaServices = @( |
| 79 | "api-gateway", |
| 80 | "auth-service", |
| 81 | "portfolio-service", |
| 82 | "broker-service", |
| 83 | "company-service", |
| 84 | "research-service", |
| 85 | "recommendation-service", |
| 86 | "risk-service", |
| 87 | "notification-service" |
| 88 | ) |
| 89 | |
| 90 | $DatabaseJavaServices = @( |
| 91 | "auth-service", |
| 92 | "portfolio-service", |
| 93 | "broker-service", |
| 94 | "research-service" |
| 95 | ) |
| 96 | |
| 97 | $AiServices = @( |
| 98 | "research-engine", |
| 99 | "valuation-engine", |
| 100 | "ranking-engine", |
| 101 | "portfolio-optimizer", |
| 102 | "mcp-gateway" |
| 103 | ) |
| 104 | |
| 105 | $PythonServices = @("ibkr-connector") |
| 106 | |
| 107 | $DeploymentProfiles = [ordered]@{ |
| 108 | "frontend" = @("frontend") |
| 109 | "research" = @("research-engine") |
| 110 | "research-ui" = @("frontend", "research-engine", "portfolio-service") |
| 111 | "portfolio" = @("portfolio-service") |
| 112 | "gateway" = @("api-gateway") |
| 113 | "mcp" = @("mcp-gateway", "yahoo-finance-mcp") |
| 114 | } |
| 115 | |
| 116 | function Write-Step { |
| 117 | param([string]$Message) |
| 118 | Write-Host "" |
| 119 | Write-Host "==> $Message" -ForegroundColor Cyan |
| 120 | } |
| 121 | |
| 122 | function Require-Command { |
| 123 | param([string]$Name) |
| 124 | if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { |
| 125 | throw "Required command '$Name' was not found on PATH." |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | function Invoke-Checked { |
| 130 | param( |
| 131 | [Parameter(Mandatory = $true)][string]$Description, |
| 132 | [Parameter(Mandatory = $true)][scriptblock]$Script |
| 133 | ) |
| 134 | |
| 135 | Write-Step $Description |
| 136 | & $Script |
| 137 | if ($LASTEXITCODE -ne 0) { |
| 138 | throw "$Description failed with exit code $LASTEXITCODE." |
| 139 | } |
| 140 | } |
| 141 | |
| 142 | function Assert-FileExists { |
| 143 | param([string]$Path, [string]$Description) |
| 144 | if (-not (Test-Path $Path)) { |
| 145 | throw "$Description was not found: $Path" |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | function Assert-LocalPrerequisites { |
| 150 | Require-Command "docker" |
| 151 | Require-Command "k3d" |
| 152 | Require-Command "kubectl" |
| 153 | Require-Command "helm" |
| 154 | |
| 155 | Assert-FileExists $HelmChart "Helm chart" |
| 156 | Assert-FileExists $HelmValues "DEV Helm values" |
| 157 | Assert-FileExists $K3dConfig "k3d cluster configuration" |
| 158 | |
| 159 | docker info *> $null |
| 160 | if ($LASTEXITCODE -ne 0) { |
| 161 | throw "Docker Desktop is not running or is not reachable. Start Docker Desktop and retry." |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | function Assert-BuildPrerequisites { |
| 166 | Require-Command "mvn" |
| 167 | } |
| 168 | |
| 169 | function Ensure-RuntimeDirectory { |
| 170 | if (-not (Test-Path $RuntimeDir)) { |
| 171 | New-Item -ItemType Directory -Path $RuntimeDir -Force | Out-Null |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | function Initialize-ImageTag { |
| 176 | Ensure-RuntimeDirectory |
| 177 | |
| 178 | if ($SkipBuild) { |
| 179 | if (-not (Test-Path $ImageTagFile)) { |
| 180 | throw "-SkipBuild requires a previously successful immutable build. '$ImageTagFile' does not exist." |
| 181 | } |
| 182 | |
| 183 | $script:ImageTag = (Get-Content -Raw $ImageTagFile).Trim() |
| 184 | if ([string]::IsNullOrWhiteSpace($script:ImageTag)) { |
| 185 | throw "-SkipBuild could not resolve a previous immutable image tag from '$ImageTagFile'." |
| 186 | } |
| 187 | |
| 188 | Write-Host "Reusing immutable image tag: $script:ImageTag" -ForegroundColor DarkGray |
| 189 | return |
| 190 | } |
| 191 | |
| 192 | $script:ImageTag = "dev-" + (Get-Date -Format "yyyyMMdd-HHmmss") |
| 193 | Write-Host "Immutable image tag: $script:ImageTag" -ForegroundColor DarkGray |
| 194 | } |
| 195 | |
| 196 | function Save-SuccessfulFullImageTag { |
| 197 | Ensure-RuntimeDirectory |
| 198 | Set-Content -Path $ImageTagFile -Value $script:ImageTag -Encoding ASCII |
| 199 | } |
| 200 | |
| 201 | function Test-ClusterExists { |
| 202 | try { |
| 203 | $json = k3d cluster list -o json |
| 204 | if ($LASTEXITCODE -ne 0 -or -not $json) { return $false } |
| 205 | $clusters = @($json | ConvertFrom-Json) |
| 206 | return [bool]($clusters | Where-Object { $_.name -eq $ClusterName } | Select-Object -First 1) |
| 207 | } |
| 208 | catch { |
| 209 | return $false |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | function Select-ClusterContext { |
| 214 | Invoke-Checked "Selecting kube context for '$ClusterName'" { |
| 215 | k3d kubeconfig merge $ClusterName --kubeconfig-switch-context |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | function Ensure-Cluster { |
| 220 | $existing = @() |
| 221 | try { |
| 222 | $json = k3d cluster list -o json |
| 223 | if ($LASTEXITCODE -eq 0 -and $json) { |
| 224 | $existing = @($json | ConvertFrom-Json) |
| 225 | } |
| 226 | } |
| 227 | catch { |
| 228 | $existing = @() |
| 229 | } |
| 230 | |
| 231 | $cluster = $existing | Where-Object { $_.name -eq $ClusterName } | Select-Object -First 1 |
| 232 | if ($cluster) { |
| 233 | Invoke-Checked "Starting existing k3d cluster '$ClusterName'" { |
| 234 | k3d cluster start $ClusterName |
| 235 | } |
| 236 | } |
| 237 | else { |
| 238 | Invoke-Checked "Creating k3d cluster '$ClusterName'" { |
| 239 | k3d cluster create --config $K3dConfig |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | Select-ClusterContext |
| 244 | } |
| 245 | |
| 246 | function Ensure-Namespace { |
| 247 | # Clean clusters legitimately do not have this namespace yet. Avoid a |
| 248 | # failing kubectl existence check because ErrorActionPreference=Stop can |
| 249 | # promote native stderr to a terminating NativeCommandError. |
| 250 | $existingNamespace = kubectl get namespace $Namespace --ignore-not-found -o name 2>$null |
| 251 | if ($LASTEXITCODE -ne 0) { |
| 252 | throw "Unable to query namespace '$Namespace'." |
| 253 | } |
| 254 | |
| 255 | if ([string]::IsNullOrWhiteSpace(($existingNamespace | Out-String).Trim())) { |
| 256 | Invoke-Checked "Creating namespace '$Namespace'" { |
| 257 | kubectl create namespace $Namespace |
| 258 | } |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | function Test-SecretExists { |
| 263 | param([string]$Name) |
| 264 | $existingSecret = kubectl get secret $Name -n $Namespace --ignore-not-found -o name 2>$null |
| 265 | if ($LASTEXITCODE -ne 0) { |
| 266 | throw "Unable to query secret '$Name' in namespace '$Namespace'." |
| 267 | } |
| 268 | return (-not [string]::IsNullOrWhiteSpace(($existingSecret | Out-String).Trim())) |
| 269 | } |
| 270 | |
| 271 | function Convert-SecureStringToPlainText { |
| 272 | param([Security.SecureString]$SecureValue) |
| 273 | $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureValue) |
| 274 | try { |
| 275 | return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr) |
| 276 | } |
| 277 | finally { |
| 278 | [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | function New-OrReplaceLiteralSecret { |
| 283 | param( |
| 284 | [Parameter(Mandatory = $true)][string]$Name, |
| 285 | [Parameter(Mandatory = $true)][hashtable]$Literals |
| 286 | ) |
| 287 | |
| 288 | $args = @("create", "secret", "generic", $Name, "-n", $Namespace) |
| 289 | foreach ($key in $Literals.Keys) { |
| 290 | $args += "--from-literal=$key=$($Literals[$key])" |
| 291 | } |
| 292 | $args += @("--dry-run=client", "-o", "json") |
| 293 | |
| 294 | $manifest = & kubectl @args |
| 295 | if ($LASTEXITCODE -ne 0 -or -not $manifest) { |
| 296 | throw "Unable to render Kubernetes secret '$Name'." |
| 297 | } |
| 298 | |
| 299 | $tempFile = Join-Path $RuntimeDir "$Name.secret.json" |
| 300 | try { |
| 301 | Set-Content -Path $tempFile -Value $manifest -Encoding UTF8 |
| 302 | kubectl apply -f $tempFile | Out-Host |
| 303 | if ($LASTEXITCODE -ne 0) { |
| 304 | throw "Unable to apply Kubernetes secret '$Name'." |
| 305 | } |
| 306 | } |
| 307 | finally { |
| 308 | Remove-Item $tempFile -Force -ErrorAction SilentlyContinue |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | function Ensure-DevSecrets { |
| 313 | Ensure-RuntimeDirectory |
| 314 | Ensure-Namespace |
| 315 | |
| 316 | if (-not (Test-SecretExists "auth-smtp-credentials")) { |
| 317 | Write-Step "Configuring LOCAL SMTP credentials" |
| 318 | Write-Host "auth-service requires SMTP credentials in LOCAL mode." |
| 319 | Write-Host "For Gmail, use an App Password rather than your normal account password." |
| 320 | |
| 321 | $defaultUser = $env:AIP_SMTP_USERNAME |
| 322 | if (-not $defaultUser) { $defaultUser = "prakhar.unique@gmail.com" } |
| 323 | $enteredUser = if ($env:AIP_SMTP_USERNAME) { $env:AIP_SMTP_USERNAME } else { Read-Host "SMTP username [$defaultUser]" } |
| 324 | if ([string]::IsNullOrWhiteSpace($enteredUser)) { $enteredUser = $defaultUser } |
| 325 | |
| 326 | $smtpPasswordPlain = $env:AIP_SMTP_PASSWORD |
| 327 | if (-not $smtpPasswordPlain) { |
| 328 | $securePassword = Read-Host "SMTP/App password" -AsSecureString |
| 329 | $smtpPasswordPlain = Convert-SecureStringToPlainText $securePassword |
| 330 | } |
| 331 | if ([string]::IsNullOrWhiteSpace($smtpPasswordPlain)) { |
| 332 | throw "SMTP password cannot be empty." |
| 333 | } |
| 334 | |
| 335 | New-OrReplaceLiteralSecret "auth-smtp-credentials" @{ |
| 336 | SMTP_USERNAME = $enteredUser |
| 337 | SMTP_PASSWORD = $smtpPasswordPlain |
| 338 | } |
| 339 | $smtpPasswordPlain = $null |
| 340 | } |
| 341 | else { |
| 342 | Write-Host "Secret auth-smtp-credentials already exists; keeping it." -ForegroundColor DarkGray |
| 343 | } |
| 344 | |
| 345 | # LOCAL research/news search uses the in-cluster SearXNG service. |
| 346 | # The current Helm template still references the legacy Google search |
| 347 | # secret keys as mandatory env sources even though Google search is not |
| 348 | # part of the active LOCAL acquisition path. Create a non-sensitive |
| 349 | # compatibility secret automatically so a clean deployment does not ask |
| 350 | # the user for paid Google credentials. |
| 351 | if (-not (Test-SecretExists "research-search-secret")) { |
| 352 | Write-Step "Creating LOCAL search compatibility secret (SearXNG is the active search provider)" |
| 353 | New-OrReplaceLiteralSecret "research-search-secret" @{ |
| 354 | "google-engine-id" = "disabled-local-searxng" |
| 355 | "google-api-key" = "disabled-local-searxng" |
| 356 | } |
| 357 | } |
| 358 | else { |
| 359 | Write-Host "Secret research-search-secret already exists; keeping it." -ForegroundColor DarkGray |
| 360 | } |
| 361 | |
| 362 | if (-not (Test-SecretExists "ibkr-runtime-internal-token")) { |
| 363 | Write-Step "Creating LOCAL IBKR internal service token" |
| 364 | $token = ([guid]::NewGuid().ToString("N") + [guid]::NewGuid().ToString("N")) |
| 365 | New-OrReplaceLiteralSecret "ibkr-runtime-internal-token" @{ |
| 366 | AIP_INTERNAL_TOKEN = $token |
| 367 | } |
| 368 | $token = $null |
| 369 | } |
| 370 | else { |
| 371 | Write-Host "Secret ibkr-runtime-internal-token already exists; keeping it." -ForegroundColor DarkGray |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | function Get-ImageDefinitions { |
| 376 | param([string[]]$Names) |
| 377 | |
| 378 | $images = @() |
| 379 | |
| 380 | $images += [pscustomobject]@{ |
| 381 | Name = "frontend" |
| 382 | Context = Join-Path $ProjectRoot "frontend" |
| 383 | Dockerfile = $null |
| 384 | Repo = "$ImageRegistry/ai-investment/frontend" |
| 385 | Image = "$ImageRegistry/ai-investment/frontend:$ImageTag" |
| 386 | BootstrapImage = "$ImageRegistry/ai-investment/frontend:dev" |
| 387 | } |
| 388 | |
| 389 | foreach ($service in $JavaServices) { |
| 390 | $context = Join-Path $ProjectRoot "services\$service" |
| 391 | if (Test-Path (Join-Path $context "Dockerfile")) { |
| 392 | $images += [pscustomobject]@{ |
| 393 | Name = $service |
| 394 | Context = $context |
| 395 | Dockerfile = $null |
| 396 | Repo = "$ImageRegistry/ai-investment/$service" |
| 397 | Image = "$ImageRegistry/ai-investment/$($service):$ImageTag" |
| 398 | BootstrapImage = "$ImageRegistry/ai-investment/$($service):dev" |
| 399 | } |
| 400 | } |
| 401 | else { |
| 402 | Write-Warning "Skipping '$service' because no Dockerfile exists at '$context'." |
| 403 | } |
| 404 | } |
| 405 | |
| 406 | foreach ($service in $AiServices) { |
| 407 | $context = Join-Path $ProjectRoot "ai\$service" |
| 408 | if (Test-Path (Join-Path $context "Dockerfile")) { |
| 409 | $images += [pscustomobject]@{ |
| 410 | Name = $service |
| 411 | Context = $context |
| 412 | Dockerfile = $null |
| 413 | Repo = "$ImageRegistry/ai-investment/$service" |
| 414 | Image = "$ImageRegistry/ai-investment/$($service):$ImageTag" |
| 415 | BootstrapImage = "$ImageRegistry/ai-investment/$($service):dev" |
| 416 | } |
| 417 | } |
| 418 | else { |
| 419 | Write-Warning "Skipping '$service' because no Dockerfile exists at '$context'." |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | foreach ($service in $PythonServices) { |
| 424 | $dockerfile = Join-Path $ProjectRoot "services\$service\Dockerfile" |
| 425 | if (Test-Path $dockerfile) { |
| 426 | $images += [pscustomobject]@{ |
| 427 | Name = $service |
| 428 | Context = $ProjectRoot |
| 429 | Dockerfile = $dockerfile |
| 430 | Repo = "$ImageRegistry/ai-investment/$service" |
| 431 | Image = "$ImageRegistry/ai-investment/$($service):$ImageTag" |
| 432 | BootstrapImage = "$ImageRegistry/ai-investment/$($service):dev" |
| 433 | } |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | $yahooDockerfile = Join-Path $ProjectRoot "ai\yahoo-finance-mcp\Dockerfile" |
| 438 | if (-not (Test-Path $yahooDockerfile)) { |
| 439 | throw "First-party Yahoo Finance MCP Dockerfile was not found: $yahooDockerfile" |
| 440 | } |
| 441 | $images += [pscustomobject]@{ |
| 442 | Name = "yahoo-finance-mcp" |
| 443 | Context = $ProjectRoot |
| 444 | Dockerfile = $yahooDockerfile |
| 445 | Repo = "$ImageRegistry/ai-investment/yahoo-finance-mcp" |
| 446 | Image = "$ImageRegistry/ai-investment/yahoo-finance-mcp:$ImageTag" |
| 447 | BootstrapImage = "$ImageRegistry/ai-investment/yahoo-finance-mcp:dev" |
| 448 | } |
| 449 | |
| 450 | if ($Names -and $Names.Count -gt 0) { |
| 451 | $unknown = @($Names | Where-Object { $_ -notin @($images.Name) }) |
| 452 | if ($unknown.Count -gt 0) { |
| 453 | throw "Unknown component(s): $($unknown -join ', '). Run '.\\platform.ps1 components' for valid names." |
| 454 | } |
| 455 | return @($images | Where-Object { $_.Name -in $Names }) |
| 456 | } |
| 457 | |
| 458 | return $images |
| 459 | } |
| 460 | |
| 461 | function Resolve-SelectedComponents { |
| 462 | param([switch]$RequireSelection) |
| 463 | |
| 464 | $all = @((Get-ImageDefinitions).Name) |
| 465 | $selected = @() |
| 466 | |
| 467 | if ($Profile) { |
| 468 | $profileKey = $Profile.Trim().ToLowerInvariant() |
| 469 | if ($profileKey -eq "all") { |
| 470 | $selected += $all |
| 471 | } |
| 472 | elseif ($DeploymentProfiles.Contains($profileKey)) { |
| 473 | $selected += @($DeploymentProfiles[$profileKey]) |
| 474 | } |
| 475 | else { |
| 476 | throw "Unknown profile '$Profile'. Run '.\\platform.ps1 components' for supported profiles." |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | if ($Component) { |
| 481 | foreach ($item in $Component) { |
| 482 | if ([string]::IsNullOrWhiteSpace($item)) { continue } |
| 483 | $selected += @($item -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | $selected = @($selected | Select-Object -Unique) |
| 488 | if ($RequireSelection -and $selected.Count -eq 0) { |
| 489 | throw "The deploy command requires -Component or -Profile. Example: .\\platform.ps1 deploy -Profile research-ui" |
| 490 | } |
| 491 | if ($selected.Count -eq 0) { return $all } |
| 492 | |
| 493 | $unknown = @($selected | Where-Object { $_ -notin $all }) |
| 494 | if ($unknown.Count -gt 0) { |
| 495 | throw "Unknown component(s): $($unknown -join ', '). Run '.\\platform.ps1 components' for valid names." |
| 496 | } |
| 497 | |
| 498 | return $selected |
| 499 | } |
| 500 | |
| 501 | function Show-Components { |
| 502 | $previousTag = $script:ImageTag |
| 503 | if (-not $script:ImageTag) { $script:ImageTag = "preview" } |
| 504 | try { |
| 505 | Write-Host "" |
| 506 | Write-Host "Available deployable components:" -ForegroundColor Cyan |
| 507 | foreach ($name in @((Get-ImageDefinitions).Name)) { |
| 508 | Write-Host " - $name" |
| 509 | } |
| 510 | Write-Host "" |
| 511 | Write-Host "Profiles:" -ForegroundColor Cyan |
| 512 | Write-Host " - all : complete application image set" |
| 513 | foreach ($entry in $DeploymentProfiles.GetEnumerator()) { |
| 514 | Write-Host (" - {0,-11} : {1}" -f $entry.Key, ($entry.Value -join ', ')) |
| 515 | } |
| 516 | Write-Host "" |
| 517 | Write-Host "Examples:" -ForegroundColor Cyan |
| 518 | Write-Host " .\\platform.ps1 deploy -Component frontend" |
| 519 | Write-Host " .\\platform.ps1 deploy -Component frontend,research-engine" |
| 520 | Write-Host " .\\platform.ps1 deploy -Profile research-ui" |
| 521 | Write-Host " .\\platform.ps1 up -NoCache" |
| 522 | } |
| 523 | finally { |
| 524 | $script:ImageTag = $previousTag |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | function Build-Images { |
| 529 | param([Parameter(Mandatory = $true)][string[]]$Names) |
| 530 | |
| 531 | $definitions = @(Get-ImageDefinitions -Names $Names) |
| 532 | $selectedJava = @($definitions | Where-Object { $_.Name -in $JavaServices }) |
| 533 | |
| 534 | if ($selectedJava.Count -gt 0) { |
| 535 | Invoke-Checked "Building Java artifacts required by selected services" { |
| 536 | mvn -f (Join-Path $ProjectRoot "services\pom.xml") package -DskipTests |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | foreach ($image in $definitions) { |
| 541 | Invoke-Checked "Building fresh $($image.Name) -> $($image.Image)" { |
| 542 | $args = @("build", "--pull") |
| 543 | if ($NoCache) { $args += "--no-cache" } |
| 544 | if ($image.Dockerfile) { $args += @("-f", $image.Dockerfile) } |
| 545 | $args += @("-t", $image.Image, "-t", $image.BootstrapImage, $image.Context) |
| 546 | & docker @args |
| 547 | } |
| 548 | } |
| 549 | } |
| 550 | |
| 551 | function Prepare-BootstrapImageAliases { |
| 552 | param([Parameter(Mandatory = $true)][string[]]$Names) |
| 553 | |
| 554 | foreach ($image in Get-ImageDefinitions -Names $Names) { |
| 555 | docker image inspect $image.Image *> $null |
| 556 | if ($LASTEXITCODE -ne 0) { |
| 557 | throw "Immutable image '$($image.Image)' is not available locally. Re-run without -SkipBuild." |
| 558 | } |
| 559 | |
| 560 | docker tag $image.Image $image.BootstrapImage |
| 561 | if ($LASTEXITCODE -ne 0) { |
| 562 | throw "Unable to create temporary bootstrap alias '$($image.BootstrapImage)'." |
| 563 | } |
| 564 | } |
| 565 | } |
| 566 | |
| 567 | function Import-ImagesIntoK3d { |
| 568 | param([Parameter(Mandatory = $true)][string[]]$Names) |
| 569 | |
| 570 | Prepare-BootstrapImageAliases -Names $Names |
| 571 | |
| 572 | $images = @( |
| 573 | Get-ImageDefinitions -Names $Names | |
| 574 | ForEach-Object { @($_.Image, $_.BootstrapImage) } | |
| 575 | ForEach-Object { $_ } |
| 576 | ) | Select-Object -Unique |
| 577 | |
| 578 | if (-not $images) { throw "No application images were discovered to import." } |
| 579 | |
| 580 | Invoke-Checked "Importing selected immutable application images into k3d" { |
| 581 | k3d image import --cluster $ClusterName @images |
| 582 | } |
| 583 | } |
| 584 | |
| 585 | function Validate-Helm { |
| 586 | Invoke-Checked "Linting Helm chart" { |
| 587 | helm lint $HelmChart -f $HelmValues |
| 588 | } |
| 589 | |
| 590 | Invoke-Checked "Rendering Helm chart" { |
| 591 | helm template $ReleaseName $HelmChart -n $Namespace -f $HelmValues *> $null |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | function Deploy-Helm { |
| 596 | # Intentionally do NOT use --wait here. We must normalize LOCAL env values |
| 597 | # and optional broker runtime settings before Kubernetes readiness is judged. |
| 598 | Invoke-Checked "Installing/upgrading complete DEV stack" { |
| 599 | helm upgrade --install $ReleaseName $HelmChart ` |
| 600 | --namespace $Namespace ` |
| 601 | --create-namespace ` |
| 602 | --values $HelmValues ` |
| 603 | --force-conflicts ` |
| 604 | --timeout 10m |
| 605 | } |
| 606 | } |
| 607 | |
| 608 | function Set-ImmutableDeploymentImages { |
| 609 | param([Parameter(Mandatory = $true)][string[]]$Names) |
| 610 | |
| 611 | Write-Step "Reconciling selected Deployments to immutable image tag '$ImageTag'" |
| 612 | |
| 613 | foreach ($image in Get-ImageDefinitions -Names $Names) { |
| 614 | $deploymentName = $image.Name |
| 615 | $deploymentJsonRaw = kubectl get deployment $deploymentName -n $Namespace --ignore-not-found -o json 2>$null |
| 616 | if ($LASTEXITCODE -ne 0) { throw "Unable to query deployment '$deploymentName'." } |
| 617 | if ([string]::IsNullOrWhiteSpace(($deploymentJsonRaw | Out-String).Trim())) { |
| 618 | Write-Host "Deployment '$deploymentName' is not present; skipping immutable image reconciliation." -ForegroundColor DarkGray |
| 619 | continue |
| 620 | } |
| 621 | |
| 622 | $deployment = $deploymentJsonRaw | ConvertFrom-Json |
| 623 | $containerPatch = @() |
| 624 | $initContainerPatch = @() |
| 625 | |
| 626 | foreach ($container in @($deployment.spec.template.spec.containers)) { |
| 627 | if ($container.name -eq $deploymentName -or $container.image -like "$($image.Repo):*") { |
| 628 | $containerPatch += @{ name = $container.name; image = $image.Image } |
| 629 | } |
| 630 | } |
| 631 | foreach ($container in @($deployment.spec.template.spec.initContainers)) { |
| 632 | if ($null -ne $container -and ($container.name -eq $deploymentName -or $container.image -like "$($image.Repo):*")) { |
| 633 | $initContainerPatch += @{ name = $container.name; image = $image.Image } |
| 634 | } |
| 635 | } |
| 636 | |
| 637 | if ($containerPatch.Count -eq 0 -and $initContainerPatch.Count -eq 0) { |
| 638 | throw "Deployment '$deploymentName' exists, but no container uses repository '$($image.Repo)'. Refusing to guess." |
| 639 | } |
| 640 | |
| 641 | $templateSpec = @{} |
| 642 | if ($containerPatch.Count -gt 0) { $templateSpec["containers"] = $containerPatch } |
| 643 | if ($initContainerPatch.Count -gt 0) { $templateSpec["initContainers"] = $initContainerPatch } |
| 644 | |
| 645 | $patch = @{ |
| 646 | spec = @{ |
| 647 | template = @{ |
| 648 | metadata = @{ annotations = @{ "aip.openai.com/local-image-tag" = $ImageTag } } |
| 649 | spec = $templateSpec |
| 650 | } |
| 651 | } |
| 652 | } |
| 653 | |
| 654 | $patchFile = Join-Path $RuntimeDir ("immutable-image-" + $deploymentName + ".json") |
| 655 | try { |
| 656 | $patch | ConvertTo-Json -Depth 12 | Set-Content -Path $patchFile -Encoding UTF8 |
| 657 | kubectl patch deployment $deploymentName -n $Namespace --field-manager=helm --type=strategic --patch-file $patchFile | Out-Host |
| 658 | if ($LASTEXITCODE -ne 0) { throw "Unable to set immutable image for deployment '$deploymentName'." } |
| 659 | } |
| 660 | finally { |
| 661 | Remove-Item $patchFile -Force -ErrorAction SilentlyContinue |
| 662 | } |
| 663 | } |
| 664 | } |
| 665 | |
| 666 | function Assert-ImmutableDeploymentImages { |
| 667 | param([Parameter(Mandatory = $true)][string[]]$Names) |
| 668 | |
| 669 | Write-Step "Verifying selected immutable deployment images" |
| 670 | $failures = @() |
| 671 | |
| 672 | foreach ($image in Get-ImageDefinitions -Names $Names) { |
| 673 | $deploymentJsonRaw = kubectl get deployment $image.Name -n $Namespace --ignore-not-found -o json 2>$null |
| 674 | if ($LASTEXITCODE -ne 0) { $failures += "$($image.Name): unable to query deployment"; continue } |
| 675 | if ([string]::IsNullOrWhiteSpace(($deploymentJsonRaw | Out-String).Trim())) { continue } |
| 676 | |
| 677 | $deployment = $deploymentJsonRaw | ConvertFrom-Json |
| 678 | $matchingImages = @() |
| 679 | foreach ($container in @($deployment.spec.template.spec.containers)) { |
| 680 | if ($container.name -eq $image.Name -or $container.image -like "$($image.Repo):*") { $matchingImages += $container.image } |
| 681 | } |
| 682 | foreach ($container in @($deployment.spec.template.spec.initContainers)) { |
| 683 | if ($null -ne $container -and ($container.name -eq $image.Name -or $container.image -like "$($image.Repo):*")) { $matchingImages += $container.image } |
| 684 | } |
| 685 | |
| 686 | $wrong = @($matchingImages | Where-Object { $_ -ne $image.Image }) |
| 687 | if ($matchingImages.Count -eq 0) { $failures += "$($image.Name): no matching container image found" } |
| 688 | elseif ($wrong.Count -gt 0) { $failures += "$($image.Name): expected '$($image.Image)', found '$($wrong -join ', ')'" } |
| 689 | else { Write-Host "$($image.Name) -> $($image.Image)" -ForegroundColor DarkGray } |
| 690 | } |
| 691 | |
| 692 | if ($failures.Count -gt 0) { |
| 693 | throw "Immutable deployment image verification failed:`n - $($failures -join "`n - ")" |
| 694 | } |
| 695 | } |
| 696 | |
| 697 | function Set-DeploymentEnvIfExists { |
| 698 | param( |
| 699 | [Parameter(Mandatory = $true)][string]$Deployment, |
| 700 | [Parameter(Mandatory = $true)][string[]]$Assignments |
| 701 | ) |
| 702 | |
| 703 | $existingDeployment = kubectl get deployment $Deployment -n $Namespace --ignore-not-found -o name 2>$null |
| 704 | if ($LASTEXITCODE -ne 0) { throw "Unable to query deployment '$Deployment'." } |
| 705 | if ([string]::IsNullOrWhiteSpace(($existingDeployment | Out-String).Trim())) { return } |
| 706 | |
| 707 | # Use Helm's field manager for LOCAL runtime normalization so rerunning |
| 708 | # `platform.ps1 up` does not create kubectl-set ownership conflicts with |
| 709 | # Helm's server-side apply on the next upgrade. |
| 710 | kubectl set env deployment/$Deployment -n $Namespace --field-manager=helm @Assignments | Out-Host |
| 711 | if ($LASTEXITCODE -ne 0) { |
| 712 | throw "Unable to normalize environment for deployment '$Deployment'." |
| 713 | } |
| 714 | } |
| 715 | |
| 716 | function Apply-LocalRuntimeNormalization { |
| 717 | Write-Step "Applying LOCAL runtime normalization" |
| 718 | |
| 719 | # PowerShell/Helm/YAML may render 1800000 as 1.8e+06. Spring Hikari binds |
| 720 | # max-lifetime as a Java long and rejects scientific notation. Force exact |
| 721 | # integer strings on all DB-backed Java services before readiness checks. |
| 722 | foreach ($service in $DatabaseJavaServices) { |
| 723 | Set-DeploymentEnvIfExists $service @( |
| 724 | "DB_POOL_MAXIMUM_SIZE=8", |
| 725 | "DB_POOL_MINIMUM_IDLE=1", |
| 726 | "DB_POOL_CONNECTION_TIMEOUT_MS=10000", |
| 727 | "DB_POOL_IDLE_TIMEOUT_MS=600000", |
| 728 | "DB_POOL_MAX_LIFETIME_MS=1800000" |
| 729 | ) |
| 730 | } |
| 731 | |
| 732 | # Kafka KRaft can take several minutes on a brand-new k3d cluster while the |
| 733 | # controller/broker catches up. The chart's current startup probe restarts |
| 734 | # Kafka too early, which interrupts KRaft startup and produces: |
| 735 | # "Received a fatal error while waiting for the controller to acknowledge |
| 736 | # that we are caught up". Give Kafka a bounded 10-minute startup window. |
| 737 | $kafkaDeployment = kubectl get deployment kafka -n $Namespace --ignore-not-found -o name 2>$null |
| 738 | if ($LASTEXITCODE -ne 0) { throw "Unable to query Kafka deployment." } |
| 739 | if (-not [string]::IsNullOrWhiteSpace(($kafkaDeployment | Out-String).Trim())) { |
| 740 | Write-Step "Applying LOCAL Kafka startup grace period" |
| 741 | |
| 742 | # Use --patch-file rather than passing JSON directly on the command line. |
| 743 | # This avoids Windows PowerShell/native-command quoting differences that |
| 744 | # can make a syntactically successful kubectl call leave the probe unchanged. |
| 745 | $kafkaPatchObject = @{ |
| 746 | spec = @{ |
| 747 | template = @{ |
| 748 | spec = @{ |
| 749 | containers = @( |
| 750 | @{ |
| 751 | name = "kafka" |
| 752 | startupProbe = @{ |
| 753 | tcpSocket = @{ port = "broker" } |
| 754 | initialDelaySeconds = 10 |
| 755 | periodSeconds = 5 |
| 756 | timeoutSeconds = 3 |
| 757 | successThreshold = 1 |
| 758 | failureThreshold = 120 |
| 759 | } |
| 760 | } |
| 761 | ) |
| 762 | } |
| 763 | } |
| 764 | } |
| 765 | } |
| 766 | |
| 767 | $kafkaPatchFile = Join-Path ([System.IO.Path]::GetTempPath()) ("aip-kafka-startup-probe-" + [guid]::NewGuid().ToString("N") + ".json") |
| 768 | try { |
| 769 | $kafkaPatchObject | ConvertTo-Json -Depth 10 | Set-Content -Path $kafkaPatchFile -Encoding UTF8 |
| 770 | kubectl patch deployment kafka -n $Namespace --field-manager=helm --type=strategic --patch-file $kafkaPatchFile | Out-Host |
| 771 | if ($LASTEXITCODE -ne 0) { |
| 772 | throw "Unable to apply LOCAL Kafka startup probe normalization." |
| 773 | } |
| 774 | } |
| 775 | finally { |
| 776 | Remove-Item $kafkaPatchFile -Force -ErrorAction SilentlyContinue |
| 777 | } |
| 778 | |
| 779 | # Fail immediately if the live Deployment did not receive the intended |
| 780 | # startup probe. Do not wait 12 minutes with an ineffective configuration. |
| 781 | $liveKafkaProbe = kubectl get deployment kafka -n $Namespace -o jsonpath="{.spec.template.spec.containers[?(@.name=='kafka')].startupProbe.failureThreshold}{'|'}{.spec.template.spec.containers[?(@.name=='kafka')].startupProbe.periodSeconds}{'|'}{.spec.template.spec.containers[?(@.name=='kafka')].startupProbe.timeoutSeconds}{'|'}{.spec.template.spec.containers[?(@.name=='kafka')].startupProbe.initialDelaySeconds}" |
| 782 | if ($LASTEXITCODE -ne 0) { |
| 783 | throw "Unable to verify Kafka startup probe normalization." |
| 784 | } |
| 785 | |
| 786 | if (($liveKafkaProbe | Out-String).Trim() -ne "120|5|3|10") { |
| 787 | throw "Kafka startup probe normalization was not applied. Live values: $liveKafkaProbe" |
| 788 | } |
| 789 | |
| 790 | Write-Host "Kafka startup probe verified: failureThreshold=120, periodSeconds=5, timeoutSeconds=3, initialDelaySeconds=10" -ForegroundColor DarkGray |
| 791 | } |
| 792 | |
| 793 | if ($EnableIbkr) { |
| 794 | Write-Host "IBKR LOCAL runtime explicitly enabled." -ForegroundColor Yellow |
| 795 | Set-DeploymentEnvIfExists "broker-service" @("IBKR_ENABLED=true") |
| 796 | } |
| 797 | else { |
| 798 | # IBKR authentication/runtime is currently parked. Keeping its standalone |
| 799 | # connector enabled makes a fresh local install depend on its PVC/package. |
| 800 | # Disable the integration and scale the connector to zero for normal DEV. |
| 801 | Set-DeploymentEnvIfExists "broker-service" @("IBKR_ENABLED=false") |
| 802 | $ibkrDeployment = kubectl get deployment ibkr-connector -n $Namespace --ignore-not-found -o name 2>$null |
| 803 | if ($LASTEXITCODE -ne 0) { throw "Unable to query LOCAL ibkr-connector deployment." } |
| 804 | if (-not [string]::IsNullOrWhiteSpace(($ibkrDeployment | Out-String).Trim())) { |
| 805 | kubectl scale deployment/ibkr-connector -n $Namespace --replicas=0 | Out-Host |
| 806 | if ($LASTEXITCODE -ne 0) { throw "Unable to disable LOCAL ibkr-connector deployment." } |
| 807 | } |
| 808 | } |
| 809 | } |
| 810 | |
| 811 | function Write-FailureDiagnostics { |
| 812 | # Diagnostics must never hide the original deployment failure. A pod may |
| 813 | # legitimately have no previous logs when it failed before container start. |
| 814 | $previousErrorActionPreference = $ErrorActionPreference |
| 815 | $ErrorActionPreference = "Continue" |
| 816 | try { |
| 817 | Write-Host "" |
| 818 | Write-Host "================ DEPLOYMENT DIAGNOSTICS ================" -ForegroundColor Yellow |
| 819 | try { kubectl get pods -n $Namespace -o wide 2>$null | Out-Host } catch {} |
| 820 | |
| 821 | $badPods = @() |
| 822 | try { |
| 823 | $badPods = kubectl get pods -n $Namespace --no-headers 2>$null | ForEach-Object { |
| 824 | $parts = ($_ -split '\s+') |
| 825 | if ($parts.Count -ge 3 -and ($parts[1] -notmatch '^([1-9][0-9]*)/\1$' -or $parts[2] -ne "Running")) { |
| 826 | $parts[0] |
| 827 | } |
| 828 | } |
| 829 | } catch {} |
| 830 | |
| 831 | foreach ($pod in @($badPods | Select-Object -Unique)) { |
| 832 | if (-not $pod) { continue } |
| 833 | |
| 834 | Write-Host "" |
| 835 | Write-Host "--- $pod : events ---" -ForegroundColor Yellow |
| 836 | try { |
| 837 | kubectl describe pod $pod -n $Namespace 2>$null | |
| 838 | Select-String -Pattern "Events:","Warning","Failed","BackOff","Error","Unhealthy","Mount","secret","configmap","scheduling" -Context 0,2 | |
| 839 | ForEach-Object { $_.ToString() } | |
| 840 | Out-Host |
| 841 | } catch {} |
| 842 | |
| 843 | Write-Host "--- $pod : previous/current logs ---" -ForegroundColor Yellow |
| 844 | $previousLogs = $null |
| 845 | try { |
| 846 | $previousLogs = kubectl logs $pod -n $Namespace --all-containers --previous --tail=80 2>$null |
| 847 | } catch {} |
| 848 | |
| 849 | if ($previousLogs) { |
| 850 | $previousLogs | Out-Host |
| 851 | } |
| 852 | else { |
| 853 | try { |
| 854 | kubectl logs $pod -n $Namespace --all-containers --tail=80 2>$null | Out-Host |
| 855 | } catch {} |
| 856 | } |
| 857 | } |
| 858 | |
| 859 | Write-Host "========================================================" -ForegroundColor Yellow |
| 860 | } |
| 861 | finally { |
| 862 | $ErrorActionPreference = $previousErrorActionPreference |
| 863 | } |
| 864 | } |
| 865 | function Wait-ForDeployments { |
| 866 | param([string[]]$Names, [switch]$AllEnabled) |
| 867 | |
| 868 | Write-Step "Waiting for deployments to become ready" |
| 869 | |
| 870 | if ($AllEnabled) { |
| 871 | $deploymentNames = kubectl get deployment -n $Namespace -o jsonpath="{.items[*].metadata.name}" |
| 872 | if ($LASTEXITCODE -ne 0) { throw "Unable to list deployments in namespace '$Namespace'." } |
| 873 | $deployments = @($deploymentNames -split " " | Where-Object { $_ }) |
| 874 | } |
| 875 | else { |
| 876 | $deployments = @($Names | Select-Object -Unique) |
| 877 | } |
| 878 | |
| 879 | if (-not $deployments) { throw "No deployments were selected for readiness validation." } |
| 880 | |
| 881 | foreach ($deployment in $deployments) { |
| 882 | if (-not $EnableIbkr -and $deployment -eq "ibkr-connector") { continue } |
| 883 | $exists = kubectl get deployment $deployment -n $Namespace --ignore-not-found -o name 2>$null |
| 884 | if ($LASTEXITCODE -ne 0) { throw "Unable to query deployment '$deployment'." } |
| 885 | if ([string]::IsNullOrWhiteSpace(($exists | Out-String).Trim())) { continue } |
| 886 | |
| 887 | Write-Step "Waiting for deployment '$deployment'" |
| 888 | $rolloutTimeout = if ($deployment -eq "kafka") { "720s" } else { "420s" } |
| 889 | kubectl rollout status deployment/$deployment -n $Namespace --timeout=$rolloutTimeout |
| 890 | if ($LASTEXITCODE -ne 0) { |
| 891 | Write-FailureDiagnostics |
| 892 | throw "Deployment '$deployment' did not become ready. See diagnostics above." |
| 893 | } |
| 894 | } |
| 895 | |
| 896 | if ($AllEnabled) { |
| 897 | $postgresStatefulSet = kubectl get statefulset postgres -n $Namespace --ignore-not-found -o name 2>$null |
| 898 | if ($LASTEXITCODE -ne 0) { throw "Unable to query PostgreSQL StatefulSet." } |
| 899 | if (-not [string]::IsNullOrWhiteSpace(($postgresStatefulSet | Out-String).Trim())) { |
| 900 | Write-Step "Waiting for PostgreSQL" |
| 901 | kubectl rollout status statefulset/postgres -n $Namespace --timeout=300s |
| 902 | if ($LASTEXITCODE -ne 0) { Write-FailureDiagnostics; throw "PostgreSQL did not become ready." } |
| 903 | } |
| 904 | } |
| 905 | } |
| 906 | |
| 907 | function Remove-OldApplicationImages { |
| 908 | param([Parameter(Mandatory = $true)][string[]]$Names) |
| 909 | |
| 910 | if ($KeepOldImages) { |
| 911 | Write-Step "Keeping previous component images by request" |
| 912 | return |
| 913 | } |
| 914 | |
| 915 | Write-Step "Removing older images for successfully deployed component(s)" |
| 916 | $selected = @(Get-ImageDefinitions -Names $Names) |
| 917 | |
| 918 | # Host Docker cleanup. Keep only the current immutable tag for each selected |
| 919 | # application repository. The temporary :dev bootstrap alias is removed too. |
| 920 | $hostRows = docker images --format "{{.Repository}}|{{.Tag}}|{{.ID}}" 2>$null |
| 921 | if ($LASTEXITCODE -ne 0) { throw "Unable to enumerate local Docker images for cleanup." } |
| 922 | |
| 923 | foreach ($image in $selected) { |
| 924 | $suffix = "/ai-investment/$($image.Name)" |
| 925 | $rows = @($hostRows | ForEach-Object { |
| 926 | $parts = $_ -split '\|' |
| 927 | if ($parts.Count -lt 3) { return } |
| 928 | [pscustomobject]@{ Repository=$parts[0]; Tag=$parts[1]; Id=$parts[2] } |
| 929 | } | Where-Object { |
| 930 | ($_.Repository -eq "ai-investment/$($image.Name)" -or $_.Repository.EndsWith($suffix)) -and |
| 931 | -not ($_.Repository -eq $image.Repo -and $_.Tag -eq $ImageTag) |
| 932 | }) |
| 933 | |
| 934 | foreach ($row in $rows) { |
| 935 | $ref = "$($row.Repository):$($row.Tag)" |
| 936 | Write-Host "Removing old host image tag $ref" -ForegroundColor DarkGray |
| 937 | docker image rm -f $ref *> $null |
| 938 | if ($LASTEXITCODE -ne 0) { Write-Warning "Unable to remove old host image '$ref'." } |
| 939 | } |
| 940 | } |
| 941 | |
| 942 | # k3d node/containerd cleanup. This runs only after successful rollout, so |
| 943 | # deleting stale image references cannot affect the new running pods. |
| 944 | $nodeNames = @(docker ps --format "{{.Names}}" | Where-Object { |
| 945 | $_ -like "k3d-$ClusterName-server-*" -or $_ -like "k3d-$ClusterName-agent-*" |
| 946 | }) |
| 947 | |
| 948 | foreach ($node in $nodeNames) { |
| 949 | $refs = @(docker exec $node ctr -n k8s.io images ls -q 2>$null) |
| 950 | if ($LASTEXITCODE -ne 0) { |
| 951 | Write-Warning "Unable to enumerate containerd images on '$node'; host cleanup still completed." |
| 952 | continue |
| 953 | } |
| 954 | |
| 955 | foreach ($image in $selected) { |
| 956 | $nameToken = "/ai-investment/$($image.Name):" |
| 957 | $currentTagPattern = "*$nameToken$ImageTag" |
| 958 | $oldRefs = @($refs | Where-Object { |
| 959 | $_ -like "*$nameToken*" -and $_ -notlike $currentTagPattern |
| 960 | } | Select-Object -Unique) |
| 961 | |
| 962 | foreach ($ref in $oldRefs) { |
| 963 | Write-Host "Removing old k3d image reference on $node -> $ref" -ForegroundColor DarkGray |
| 964 | docker exec $node ctr -n k8s.io images rm $ref *> $null |
| 965 | if ($LASTEXITCODE -ne 0) { Write-Warning "Unable to remove '$ref' from node '$node'." } |
| 966 | } |
| 967 | } |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | function Stop-LegacyFrontendPortForward { |
| 972 | # Cleanup only. New platform runs never create a direct frontend port-forward |
| 973 | # because it bypasses the API Gateway and breaks same-origin /api requests. |
| 974 | if (Test-Path $LegacyPortForwardPidFile) { |
| 975 | $rawPid = (Get-Content -Raw $LegacyPortForwardPidFile).Trim() |
| 976 | if ($rawPid -match '^\d+$') { |
| 977 | $process = Get-Process -Id ([int]$rawPid) -ErrorAction SilentlyContinue |
| 978 | if ($process) { |
| 979 | Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue |
| 980 | } |
| 981 | } |
| 982 | Remove-Item $LegacyPortForwardPidFile -Force -ErrorAction SilentlyContinue |
| 983 | } |
| 984 | } |
| 985 | |
| 986 | function Test-HttpUrl { |
| 987 | param([string]$Url, [int]$TimeoutSeconds = 5) |
| 988 | try { |
| 989 | $response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec $TimeoutSeconds |
| 990 | return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) |
| 991 | } |
| 992 | catch { |
| 993 | # 401/403/404 are still proof that the HTTP endpoint is reachable, but |
| 994 | # Resolve-ApplicationUrl tests the root UI path and expects a normal 2xx. |
| 995 | return $false |
| 996 | } |
| 997 | } |
| 998 | |
| 999 | function Resolve-ApplicationUrl { |
| 1000 | Write-Step "Verifying gateway application URL" |
| 1001 | |
| 1002 | Stop-LegacyFrontendPortForward |
| 1003 | |
| 1004 | $deadline = (Get-Date).AddSeconds(120) |
| 1005 | do { |
| 1006 | if (Test-HttpUrl $IngressApplicationUrl) { |
| 1007 | Set-Content -Path $ApplicationUrlFile -Value $IngressApplicationUrl -Encoding ASCII |
| 1008 | return $IngressApplicationUrl |
| 1009 | } |
| 1010 | Start-Sleep -Seconds 2 |
| 1011 | } while ((Get-Date) -lt $deadline) |
| 1012 | |
| 1013 | Write-FailureDiagnostics |
| 1014 | throw "Gateway application URL '$IngressApplicationUrl' did not become reachable. Direct frontend port-forward fallback is intentionally disabled because it bypasses API Gateway same-origin routing." |
| 1015 | } |
| 1016 | |
| 1017 | function Show-ApplicationUrl { |
| 1018 | if (Test-Path $ApplicationUrlFile) { |
| 1019 | $url = (Get-Content -Raw $ApplicationUrlFile).Trim() |
| 1020 | if ($url) { |
| 1021 | Write-Host "" |
| 1022 | Write-Host "Application URL: $url" -ForegroundColor Green |
| 1023 | return $url |
| 1024 | } |
| 1025 | } |
| 1026 | Write-Host "No saved application URL exists. Run '.\platform.ps1 up' first." -ForegroundColor Yellow |
| 1027 | return $null |
| 1028 | } |
| 1029 | |
| 1030 | function Show-Status { |
| 1031 | Require-Command "k3d"; Require-Command "kubectl"; Require-Command "helm" |
| 1032 | if (-not (Test-ClusterExists)) { |
| 1033 | Write-Host "Local cluster '$ClusterName' does not exist. Run '.\platform.ps1 up'." -ForegroundColor Yellow |
| 1034 | return |
| 1035 | } |
| 1036 | |
| 1037 | Select-ClusterContext |
| 1038 | Write-Step "Cluster nodes"; kubectl get nodes -o wide |
| 1039 | Write-Step "Application pods"; kubectl get pods -n $Namespace -o wide |
| 1040 | Write-Step "Application services"; kubectl get svc -n $Namespace |
| 1041 | Write-Step "Helm release"; helm list -n $Namespace |
| 1042 | Show-ApplicationUrl | Out-Null |
| 1043 | } |
| 1044 | |
| 1045 | function Start-Platform { |
| 1046 | Assert-LocalPrerequisites |
| 1047 | Ensure-RuntimeDirectory |
| 1048 | Initialize-ImageTag |
| 1049 | Ensure-Cluster |
| 1050 | Ensure-DevSecrets |
| 1051 | Validate-Helm |
| 1052 | |
| 1053 | if ($Component -or $Profile) { |
| 1054 | throw "-Component/-Profile are only valid with the 'deploy' command. Use '.\platform.ps1 deploy -Profile research-ui'." |
| 1055 | } |
| 1056 | $selected = @((Get-ImageDefinitions).Name) |
| 1057 | |
| 1058 | if (-not $SkipBuild) { |
| 1059 | Assert-BuildPrerequisites |
| 1060 | Build-Images -Names $selected |
| 1061 | } |
| 1062 | else { |
| 1063 | Write-Step "Skipping image build by request" |
| 1064 | } |
| 1065 | |
| 1066 | Import-ImagesIntoK3d -Names $selected |
| 1067 | Deploy-Helm |
| 1068 | Set-ImmutableDeploymentImages -Names $selected |
| 1069 | Apply-LocalRuntimeNormalization |
| 1070 | Wait-ForDeployments -AllEnabled |
| 1071 | Assert-ImmutableDeploymentImages -Names $selected |
| 1072 | |
| 1073 | $url = Resolve-ApplicationUrl |
| 1074 | Save-SuccessfulFullImageTag |
| 1075 | Remove-OldApplicationImages -Names $selected |
| 1076 | |
| 1077 | Write-Host "" |
| 1078 | Write-Host "============================================================" -ForegroundColor Green |
| 1079 | Write-Host " AI Investment Platform is ready" -ForegroundColor Green |
| 1080 | Write-Host "============================================================" -ForegroundColor Green |
| 1081 | Write-Host "Application URL : $url" -ForegroundColor Green |
| 1082 | Write-Host "Namespace : $Namespace" |
| 1083 | Write-Host "Cluster : $ClusterName" |
| 1084 | Write-Host "Image tag : $ImageTag" |
| 1085 | Write-Host "Build cache : $(if ($NoCache) { 'disabled' } else { 'enabled (source changes still rebuild)' })" |
| 1086 | Write-Host "Old images : $(if ($KeepOldImages) { 'kept' } else { 'cleaned after successful rollout' })" |
| 1087 | Write-Host "Yahoo MCP : enabled by DEV Helm values" |
| 1088 | Write-Host "IBKR runtime : $(if ($EnableIbkr) { 'enabled' } else { 'disabled for normal LOCAL development' })" |
| 1089 | Write-Host "" |
| 1090 | Write-Host "Component deploy: .\\platform.ps1 deploy -Profile research-ui" |
| 1091 | Write-Host "Status command : .\\platform.ps1 status" |
| 1092 | Write-Host "Stop command : .\\platform.ps1 down" |
| 1093 | Write-Host "" |
| 1094 | |
| 1095 | if (-not $NoBrowser) { |
| 1096 | try { Start-Process $url | Out-Null } |
| 1097 | catch { Write-Warning "Unable to open the browser automatically. Open $url manually." } |
| 1098 | } |
| 1099 | } |
| 1100 | |
| 1101 | function Deploy-SelectedComponents { |
| 1102 | Assert-LocalPrerequisites |
| 1103 | Ensure-RuntimeDirectory |
| 1104 | |
| 1105 | if (-not (Test-ClusterExists)) { |
| 1106 | throw "Component deployment requires an existing local cluster. Run '.\\platform.ps1 up' once to create the full stack." |
| 1107 | } |
| 1108 | |
| 1109 | Ensure-Cluster |
| 1110 | Ensure-Namespace |
| 1111 | |
| 1112 | $release = helm status $ReleaseName -n $Namespace -o json 2>$null |
| 1113 | if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace(($release | Out-String).Trim())) { |
| 1114 | throw "Component deployment requires an existing Helm release. Run '.\\platform.ps1 up' once first." |
| 1115 | } |
| 1116 | |
| 1117 | if ($SkipBuild) { |
| 1118 | throw "-SkipBuild is intentionally disabled for component deploys. Component deploys always build a fresh image from the current working tree." |
| 1119 | } |
| 1120 | |
| 1121 | Initialize-ImageTag |
| 1122 | $selected = @(Resolve-SelectedComponents -RequireSelection) |
| 1123 | |
| 1124 | Write-Host "Selected components: $($selected -join ', ')" -ForegroundColor Cyan |
| 1125 | |
| 1126 | if (@($selected | Where-Object { $_ -in $JavaServices }).Count -gt 0) { Assert-BuildPrerequisites } |
| 1127 | Build-Images -Names $selected |
| 1128 | |
| 1129 | Import-ImagesIntoK3d -Names $selected |
| 1130 | Set-ImmutableDeploymentImages -Names $selected |
| 1131 | |
| 1132 | # Only normalize selected DB-backed Java services. Avoid touching/restarting |
| 1133 | # unrelated Deployments during a component-only rollout. |
| 1134 | foreach ($service in @($selected | Where-Object { $_ -in $DatabaseJavaServices })) { |
| 1135 | Set-DeploymentEnvIfExists $service @( |
| 1136 | "DB_POOL_MAXIMUM_SIZE=8", |
| 1137 | "DB_POOL_MINIMUM_IDLE=1", |
| 1138 | "DB_POOL_CONNECTION_TIMEOUT_MS=10000", |
| 1139 | "DB_POOL_IDLE_TIMEOUT_MS=600000", |
| 1140 | "DB_POOL_MAX_LIFETIME_MS=1800000" |
| 1141 | ) |
| 1142 | } |
| 1143 | if ($selected -contains "broker-service") { |
| 1144 | Set-DeploymentEnvIfExists "broker-service" @("IBKR_ENABLED=$(if ($EnableIbkr) { 'true' } else { 'false' })") |
| 1145 | } |
| 1146 | |
| 1147 | Wait-ForDeployments -Names $selected |
| 1148 | Assert-ImmutableDeploymentImages -Names $selected |
| 1149 | $url = Resolve-ApplicationUrl |
| 1150 | Remove-OldApplicationImages -Names $selected |
| 1151 | |
| 1152 | Write-Host "" |
| 1153 | Write-Host "============================================================" -ForegroundColor Green |
| 1154 | Write-Host " Component deployment completed" -ForegroundColor Green |
| 1155 | Write-Host "============================================================" -ForegroundColor Green |
| 1156 | Write-Host "Components : $($selected -join ', ')" |
| 1157 | Write-Host "Image tag : $ImageTag" |
| 1158 | Write-Host "Application URL : $url" |
| 1159 | Write-Host "Old images : $(if ($KeepOldImages) { 'kept' } else { 'cleaned for selected components' })" |
| 1160 | Write-Host "" |
| 1161 | |
| 1162 | if (-not $NoBrowser) { |
| 1163 | try { Start-Process $url | Out-Null } catch {} |
| 1164 | } |
| 1165 | } |
| 1166 | |
| 1167 | function Stop-Platform { |
| 1168 | Require-Command "k3d" |
| 1169 | Stop-LegacyFrontendPortForward |
| 1170 | |
| 1171 | if (Test-ClusterExists) { |
| 1172 | Write-Step "Stopping local k3d cluster '$ClusterName' (preserving PostgreSQL/PVC state)" |
| 1173 | k3d cluster stop $ClusterName |
| 1174 | if ($LASTEXITCODE -ne 0) { throw "Failed to stop k3d cluster '$ClusterName'." } |
| 1175 | Write-Host "Local platform stopped. Cluster and persistent state were preserved." -ForegroundColor Green |
| 1176 | } |
| 1177 | else { |
| 1178 | Write-Host "Local cluster '$ClusterName' is already absent." -ForegroundColor Yellow |
| 1179 | } |
| 1180 | |
| 1181 | Remove-Item $ApplicationUrlFile -Force -ErrorAction SilentlyContinue |
| 1182 | } |
| 1183 | |
| 1184 | function Remove-AllHostApplicationImages { |
| 1185 | Require-Command "docker" |
| 1186 | |
| 1187 | Write-Step "Removing all local Docker application image tags for AI Investment Platform" |
| 1188 | |
| 1189 | $previousTag = $script:ImageTag |
| 1190 | if (-not $script:ImageTag) { $script:ImageTag = "clean-preview" } |
| 1191 | try { |
| 1192 | $componentNames = @((Get-ImageDefinitions).Name) |
| 1193 | $rows = @(docker images --format "{{.Repository}}|{{.Tag}}" 2>$null) |
| 1194 | if ($LASTEXITCODE -ne 0) { |
| 1195 | throw "Unable to enumerate local Docker images during clean." |
| 1196 | } |
| 1197 | |
| 1198 | foreach ($row in $rows) { |
| 1199 | $parts = $row -split '\|', 2 |
| 1200 | if ($parts.Count -ne 2) { continue } |
| 1201 | $repository = $parts[0] |
| 1202 | $tag = $parts[1] |
| 1203 | |
| 1204 | $matchesPlatform = $false |
| 1205 | foreach ($name in $componentNames) { |
| 1206 | if ($repository -eq "ai-investment/$name" -or |
| 1207 | $repository -eq "$ImageRegistry/ai-investment/$name" -or |
| 1208 | $repository.EndsWith("/ai-investment/$name")) { |
| 1209 | $matchesPlatform = $true |
| 1210 | break |
| 1211 | } |
| 1212 | } |
| 1213 | |
| 1214 | if (-not $matchesPlatform) { continue } |
| 1215 | if ($tag -eq "<none>") { continue } |
| 1216 | |
| 1217 | $ref = "$repository`:$tag" |
| 1218 | Write-Host "Removing platform image $ref" -ForegroundColor DarkGray |
| 1219 | docker image rm -f $ref *> $null |
| 1220 | if ($LASTEXITCODE -ne 0) { |
| 1221 | Write-Warning "Unable to remove platform image '$ref'." |
| 1222 | } |
| 1223 | } |
| 1224 | } |
| 1225 | finally { |
| 1226 | $script:ImageTag = $previousTag |
| 1227 | } |
| 1228 | } |
| 1229 | |
| 1230 | function Clean-Platform { |
| 1231 | Assert-LocalPrerequisites |
| 1232 | Stop-LegacyFrontendPortForward |
| 1233 | |
| 1234 | if (-not $ForceClean) { |
| 1235 | Write-Host "" |
| 1236 | Write-Host "WARNING: CLEAN IS DESTRUCTIVE" -ForegroundColor Red |
| 1237 | Write-Host "This will delete k3d cluster '$ClusterName' and its LOCAL Kubernetes/Helm/PVC state." -ForegroundColor Yellow |
| 1238 | Write-Host "It will also remove all local Docker image tags belonging to this platform." -ForegroundColor Yellow |
| 1239 | Write-Host "PostgreSQL data stored in the local cluster/PVC will be deleted." -ForegroundColor Yellow |
| 1240 | Write-Host "Source code is NOT deleted." -ForegroundColor Green |
| 1241 | Write-Host "" |
| 1242 | $confirmation = Read-Host "Type CLEAN to continue" |
| 1243 | if ($confirmation -cne "CLEAN") { |
| 1244 | Write-Host "Clean cancelled; no destructive action was taken." -ForegroundColor Yellow |
| 1245 | return |
| 1246 | } |
| 1247 | } |
| 1248 | |
| 1249 | if (Test-ClusterExists) { |
| 1250 | Write-Step "Deleting local k3d cluster '$ClusterName'" |
| 1251 | k3d cluster delete $ClusterName |
| 1252 | if ($LASTEXITCODE -ne 0) { |
| 1253 | throw "Failed to delete k3d cluster '$ClusterName'." |
| 1254 | } |
| 1255 | } |
| 1256 | else { |
| 1257 | Write-Host "Local cluster '$ClusterName' is already absent." -ForegroundColor Yellow |
| 1258 | } |
| 1259 | |
| 1260 | Remove-AllHostApplicationImages |
| 1261 | |
| 1262 | Write-Step "Removing local deployment runtime markers" |
| 1263 | Remove-Item $ApplicationUrlFile -Force -ErrorAction SilentlyContinue |
| 1264 | Remove-Item $ImageTagFile -Force -ErrorAction SilentlyContinue |
| 1265 | Remove-Item $LegacyPortForwardPidFile -Force -ErrorAction SilentlyContinue |
| 1266 | |
| 1267 | Write-Host "" |
| 1268 | Write-Host "============================================================" -ForegroundColor Green |
| 1269 | Write-Host " AI Investment Platform local clean completed" -ForegroundColor Green |
| 1270 | Write-Host "============================================================" -ForegroundColor Green |
| 1271 | Write-Host "Cluster/runtime state : deleted" |
| 1272 | Write-Host "Platform image tags : deleted from host Docker" |
| 1273 | Write-Host "Source code : preserved" |
| 1274 | Write-Host "" |
| 1275 | Write-Host "Fresh install command : .\platform.ps1 up -NoCache -NoBrowser" -ForegroundColor Cyan |
| 1276 | Write-Host "" |
| 1277 | } |
| 1278 | |
| 1279 | switch ($Command) { |
| 1280 | "up" { Start-Platform } |
| 1281 | "deploy" { Deploy-SelectedComponents } |
| 1282 | "components" { Show-Components } |
| 1283 | "down" { Stop-Platform } |
| 1284 | "clean" { Clean-Platform } |
| 1285 | "status" { Show-Status } |
| 1286 | "url" { Show-ApplicationUrl | Out-Null } |
| 1287 | } |