main
ps1 1,287 lines 50.2 KB
Raw
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 }