后端: 1. LLM 独立服务与统一计费出口落地:新增 `cmd/llm`、`client/llm` 与 `services/llm/rpc`,补齐 BillingContext、CreditBalanceGuard、价格规则解析、stream usage 归集与 `credit.charge.requested` outbox 发布,active-scheduler / agent / course / memory / gateway fallback 全部改走 llm zrpc,不再各自本地初始化模型。 2. TokenStore 收口为 Credit 权威账本:新增 credit account / ledger / product / order / price-rule / reward-rule 能力与 Redis 快照缓存,扩展 tokenstore rpc/client 支撑余额快照、消耗看板、商品、订单、流水、价格规则和奖励规则,并接入 LLM charge 事件消费完成 Credit 扣费落账。 3. 计费旧链路下线与网关切口切换:`/token-store` 语义整体切到 `/credit-store`,agent chat 移除旧 TokenQuotaGuard,userauth 的 CheckTokenQuota / AdjustTokenUsage 改为废弃,聊天历史落库不再同步旧 token 额度账本,course 图片解析请求补 user_id 进入新计费口径。 前端: 4. 计划广场从 mock 数据切到真实接口:新增 forum api/types,首页支持真实列表、标签、搜索、防抖、点赞、导入和发布计划,详情页补齐帖子详情、评论树、回复和删除评论链路,同时补上“至少一个标签”的前后端约束与默认标签兜底。 5. 商店页切到 Credit 体系并重做展示:顶部改为余额 + Credit/Token 消耗看板,支持 24h/7d/30d/all 周期切换;套餐区展示原价与当前价;历史区改为当前用户 Credit 流水并支持查看更多,整体视觉和交互同步收口。 仓库: 6. 配置与本地启动体系补齐 llm / outbox 编排:`config.example.yaml` 增加 llm rpc 和统一 outbox service 配置,`dev-common.ps1` 把 llm 纳入多服务依赖并自动建 Kafka topic,`docker-compose.yml` 同步初始化 agent/task/memory/active-scheduler/notification/taskclass-forum/llm/token-store 全量 outbox topic。
971 lines
28 KiB
PowerShell
971 lines
28 KiB
PowerShell
$BackendRoot = Split-Path -Parent $PSScriptRoot
|
||
$RepoRoot = Split-Path -Parent $BackendRoot
|
||
$ComposeFile = Join-Path $RepoRoot "docker-compose.yml"
|
||
$StateRoot = Join-Path $BackendRoot ".dev"
|
||
$PidRoot = Join-Path $StateRoot "pids"
|
||
$LogRoot = Join-Path $StateRoot "logs"
|
||
$BinRoot = Join-Path $StateRoot "bin"
|
||
$DockerConfigRoot = Join-Path $StateRoot "docker-config"
|
||
$GoCacheRoot = Join-Path $StateRoot "gocache"
|
||
|
||
function Initialize-DevState {
|
||
foreach ($path in @($StateRoot, $PidRoot, $LogRoot, $BinRoot, $DockerConfigRoot, $GoCacheRoot)) {
|
||
if (-not (Test-Path -LiteralPath $path)) {
|
||
New-Item -ItemType Directory -Path $path -Force | Out-Null
|
||
}
|
||
}
|
||
|
||
# 1. 当前桌面环境里可能同时注入 Path / PATH 两个大小写不同的环境变量。
|
||
# 2. Windows PowerShell 的 Env: 提供器与 Start-Process 在这种情况下会报“重复键”错误。
|
||
# 3. 这里统一只保留一个进程级 Path,避免启动脚本因为环境脏数据直接失败。
|
||
$effectivePath = ""
|
||
try {
|
||
$pathValue = [System.Environment]::GetEnvironmentVariable("Path", "Process")
|
||
if ($null -ne $pathValue) {
|
||
$effectivePath = [string]$pathValue
|
||
}
|
||
}
|
||
catch {
|
||
$effectivePath = ""
|
||
}
|
||
[System.Environment]::SetEnvironmentVariable("PATH", $null, "Process")
|
||
[System.Environment]::SetEnvironmentVariable("Path", $null, "Process")
|
||
[System.Environment]::SetEnvironmentVariable("Path", $effectivePath, "Process")
|
||
|
||
$env:DOCKER_CONFIG = $DockerConfigRoot
|
||
$env:GOCACHE = $GoCacheRoot
|
||
}
|
||
|
||
function Assert-ToolExists {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$Name
|
||
)
|
||
|
||
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
|
||
throw "Command not found: $Name"
|
||
}
|
||
}
|
||
|
||
function Invoke-ExternalCommand {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$FilePath,
|
||
|
||
[Parameter(Mandatory = $true)]
|
||
[string[]]$Arguments,
|
||
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$WorkingDirectory
|
||
)
|
||
|
||
Push-Location $WorkingDirectory
|
||
try {
|
||
& $FilePath @Arguments
|
||
if ($LASTEXITCODE -ne 0) {
|
||
throw "Command failed: $FilePath $($Arguments -join ' ')"
|
||
}
|
||
}
|
||
finally {
|
||
Pop-Location
|
||
}
|
||
}
|
||
|
||
function Get-InfrastructureDefinitions {
|
||
return @(
|
||
[pscustomobject]@{
|
||
Name = "mysql"
|
||
Container = "smartflow-mysql"
|
||
TimeoutSec = 120
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "redis"
|
||
Container = "smartflow-redis"
|
||
TimeoutSec = 90
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "kafka"
|
||
Container = "smartflow-kafka"
|
||
TimeoutSec = 180
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "etcd"
|
||
Container = "smartflow-etcd"
|
||
TimeoutSec = 120
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "minio"
|
||
Container = "smartflow-minio"
|
||
TimeoutSec = 120
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "milvus-standalone"
|
||
Container = "smartflow-milvus"
|
||
TimeoutSec = 240
|
||
}
|
||
)
|
||
}
|
||
|
||
function Get-InfrastructureComposeServices {
|
||
return @(
|
||
"mysql",
|
||
"redis",
|
||
"kafka",
|
||
"etcd",
|
||
"minio",
|
||
"milvus-standalone",
|
||
"kafka-init"
|
||
)
|
||
}
|
||
|
||
function Get-KafkaTopicDefinitions {
|
||
return @(
|
||
"smartflow.agent.outbox",
|
||
"smartflow.task.outbox",
|
||
"smartflow.memory.outbox",
|
||
"smartflow.active-scheduler.outbox",
|
||
"smartflow.notification.outbox",
|
||
"smartflow.taskclass-forum.outbox",
|
||
"smartflow.llm.outbox",
|
||
"smartflow.token-store.outbox"
|
||
)
|
||
}
|
||
|
||
function Get-BackendServiceDefinitions {
|
||
return @(
|
||
[pscustomobject]@{
|
||
Name = "userauth"
|
||
Package = "./cmd/userauth"
|
||
BinaryPath = (Join-Path $BinRoot "userauth.exe")
|
||
Port = 9081
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 60
|
||
Dependencies = @()
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "task"
|
||
Package = "./cmd/task"
|
||
BinaryPath = (Join-Path $BinRoot "task.exe")
|
||
Port = 9085
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 90
|
||
Dependencies = @()
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "schedule"
|
||
Package = "./cmd/schedule"
|
||
BinaryPath = (Join-Path $BinRoot "schedule.exe")
|
||
Port = 9084
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 90
|
||
Dependencies = @()
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "task-class"
|
||
Package = "./cmd/task-class"
|
||
BinaryPath = (Join-Path $BinRoot "task-class.exe")
|
||
Port = 9086
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 90
|
||
Dependencies = @()
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "llm"
|
||
Package = "./cmd/llm"
|
||
BinaryPath = (Join-Path $BinRoot "llm.exe")
|
||
Port = 9096
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 120
|
||
Dependencies = @()
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "course"
|
||
Package = "./cmd/course"
|
||
BinaryPath = (Join-Path $BinRoot "course.exe")
|
||
Port = 9087
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 120
|
||
Dependencies = @("llm")
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "tokenstore"
|
||
Package = "./cmd/tokenstore"
|
||
BinaryPath = (Join-Path $BinRoot "tokenstore.exe")
|
||
Port = 9095
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 90
|
||
Dependencies = @()
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "notification"
|
||
Package = "./cmd/notification"
|
||
BinaryPath = (Join-Path $BinRoot "notification.exe")
|
||
Port = 9082
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 90
|
||
Dependencies = @()
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "memory"
|
||
Package = "./cmd/memory"
|
||
BinaryPath = (Join-Path $BinRoot "memory.exe")
|
||
Port = 9088
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 150
|
||
Dependencies = @("llm")
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "taskclassforum"
|
||
Aliases = @("forum")
|
||
Package = "./cmd/taskclassforum"
|
||
BinaryPath = (Join-Path $BinRoot "taskclassforum.exe")
|
||
Port = 9090
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 90
|
||
Dependencies = @("task-class")
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "active-scheduler"
|
||
Package = "./cmd/active-scheduler"
|
||
BinaryPath = (Join-Path $BinRoot "active-scheduler.exe")
|
||
Port = 9083
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 120
|
||
Dependencies = @("task", "schedule", "llm")
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "agent"
|
||
Package = "./cmd/agent"
|
||
BinaryPath = (Join-Path $BinRoot "agent.exe")
|
||
Port = 9089
|
||
ProbeType = "tcp"
|
||
ProbeTarget = $null
|
||
StartTimeoutSec = 180
|
||
Dependencies = @("task", "schedule", "task-class", "memory", "llm")
|
||
},
|
||
[pscustomobject]@{
|
||
Name = "api"
|
||
Package = "./cmd/api"
|
||
BinaryPath = (Join-Path $BinRoot "api.exe")
|
||
Port = 8080
|
||
ProbeType = "http"
|
||
ProbeTarget = "http://127.0.0.1:8080/api/v1/health"
|
||
StartTimeoutSec = 90
|
||
Dependencies = @(
|
||
"userauth",
|
||
"task",
|
||
"schedule",
|
||
"task-class",
|
||
"course",
|
||
"llm",
|
||
"tokenstore",
|
||
"notification",
|
||
"memory",
|
||
"taskclassforum",
|
||
"active-scheduler",
|
||
"agent"
|
||
)
|
||
}
|
||
)
|
||
}
|
||
|
||
function Get-BackendServiceDefinition {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$Name
|
||
)
|
||
|
||
$serviceDefinitions = @(Get-BackendServiceDefinitions)
|
||
foreach ($service in $serviceDefinitions) {
|
||
$aliases = @()
|
||
if ($service.PSObject.Properties.Name -contains "Aliases" -and $null -ne $service.Aliases) {
|
||
$aliases = @($service.Aliases)
|
||
}
|
||
|
||
if ($service.Name -eq $Name -or $aliases -contains $Name) {
|
||
return $service
|
||
}
|
||
}
|
||
|
||
$availableNames = foreach ($service in $serviceDefinitions) {
|
||
$aliases = @()
|
||
if ($service.PSObject.Properties.Name -contains "Aliases" -and $null -ne $service.Aliases) {
|
||
$aliases = @($service.Aliases)
|
||
}
|
||
|
||
if ($aliases.Count -gt 0) {
|
||
"{0} ({1})" -f $service.Name, ($aliases -join ", ")
|
||
}
|
||
else {
|
||
$service.Name
|
||
}
|
||
}
|
||
|
||
throw "Service definition not found: $Name. Available names: $($availableNames -join '; ')"
|
||
}
|
||
|
||
function Get-ServicePidFilePath {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
return (Join-Path $PidRoot "$($Service.Name).json")
|
||
}
|
||
|
||
function Get-ServiceStdoutLogPath {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
return (Join-Path $LogRoot "$($Service.Name).log")
|
||
}
|
||
|
||
function Get-ServiceStderrLogPath {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
return (Join-Path $LogRoot "$($Service.Name).err.log")
|
||
}
|
||
|
||
function New-ServiceLogPaths {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||
return [pscustomobject]@{
|
||
Stdout = (Join-Path $LogRoot "$($Service.Name)-$stamp.log")
|
||
Stderr = (Join-Path $LogRoot "$($Service.Name)-$stamp.err.log")
|
||
}
|
||
}
|
||
|
||
function Read-ServicePidInfo {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
$path = Get-ServicePidFilePath -Service $Service
|
||
if (-not (Test-Path -LiteralPath $path)) {
|
||
return $null
|
||
}
|
||
|
||
return (Get-Content -LiteralPath $path -Encoding UTF8 | ConvertFrom-Json)
|
||
}
|
||
|
||
function Get-LatestServiceLogPath {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service,
|
||
|
||
[Parameter(Mandatory = $true)]
|
||
[ValidateSet("stdout", "stderr")]
|
||
[string]$Stream
|
||
)
|
||
|
||
$pidInfo = Read-ServicePidInfo -Service $Service
|
||
if ($null -ne $pidInfo) {
|
||
$managedPath = if ($Stream -eq "stdout") { [string]$pidInfo.StdoutLogPath } else { [string]$pidInfo.StderrLogPath }
|
||
if (-not [string]::IsNullOrWhiteSpace($managedPath) -and (Test-Path -LiteralPath $managedPath)) {
|
||
return $managedPath
|
||
}
|
||
}
|
||
|
||
if ($Stream -eq "stdout") {
|
||
$candidates = @(Get-ChildItem -LiteralPath $LogRoot -Filter "$($Service.Name)-*.log" -ErrorAction SilentlyContinue |
|
||
Where-Object { $_.Name -notlike "*.err.log" } |
|
||
Sort-Object LastWriteTime -Descending)
|
||
}
|
||
else {
|
||
$candidates = @(Get-ChildItem -LiteralPath $LogRoot -Filter "$($Service.Name)-*.err.log" -ErrorAction SilentlyContinue |
|
||
Sort-Object LastWriteTime -Descending)
|
||
}
|
||
|
||
if ($null -eq $candidates -or $candidates.Count -eq 0) {
|
||
return $null
|
||
}
|
||
|
||
return $candidates[0].FullName
|
||
}
|
||
|
||
function Write-ServicePidInfo {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service,
|
||
|
||
[Parameter(Mandatory = $true)]
|
||
[System.Diagnostics.Process]$Process,
|
||
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$StdoutLogPath,
|
||
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$StderrLogPath
|
||
)
|
||
|
||
$payload = [pscustomobject]@{
|
||
Name = $Service.Name
|
||
Pid = [int]$Process.Id
|
||
Port = [int]$Service.Port
|
||
StartedAt = (Get-Date).ToString("o")
|
||
BinaryPath = $Service.BinaryPath
|
||
StdoutLogPath = $StdoutLogPath
|
||
StderrLogPath = $StderrLogPath
|
||
}
|
||
|
||
$path = Get-ServicePidFilePath -Service $Service
|
||
$payload | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding UTF8
|
||
}
|
||
|
||
function Remove-ServicePidInfo {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
$path = Get-ServicePidFilePath -Service $Service
|
||
if (Test-Path -LiteralPath $path) {
|
||
Remove-Item -LiteralPath $path -Force
|
||
}
|
||
}
|
||
|
||
function Test-ProcessAlive {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[int]$ProcessId
|
||
)
|
||
|
||
return ($null -ne (Get-Process -Id $ProcessId -ErrorAction SilentlyContinue))
|
||
}
|
||
|
||
function Get-ListeningProcessId {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[int]$Port
|
||
)
|
||
|
||
try {
|
||
$connection = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop |
|
||
Sort-Object -Property OwningProcess |
|
||
Select-Object -First 1
|
||
if ($null -ne $connection) {
|
||
return [int]$connection.OwningProcess
|
||
}
|
||
}
|
||
catch {
|
||
}
|
||
|
||
$pattern = "^\s*TCP\s+\S+:$Port\s+\S+\s+LISTENING\s+(\d+)\s*$"
|
||
foreach ($line in (netstat -ano -p tcp)) {
|
||
if ($line -match $pattern) {
|
||
return [int]$matches[1]
|
||
}
|
||
}
|
||
|
||
return $null
|
||
}
|
||
|
||
function Test-TcpPort {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[int]$Port,
|
||
|
||
[string]$HostName = "127.0.0.1",
|
||
|
||
[int]$TimeoutMs = 500
|
||
)
|
||
|
||
$client = New-Object System.Net.Sockets.TcpClient
|
||
try {
|
||
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
|
||
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
|
||
return $false
|
||
}
|
||
|
||
$client.EndConnect($asyncResult)
|
||
return $true
|
||
}
|
||
catch {
|
||
return $false
|
||
}
|
||
finally {
|
||
$client.Close()
|
||
}
|
||
}
|
||
|
||
function Test-HttpEndpoint {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$Url,
|
||
|
||
[int]$TimeoutSec = 2
|
||
)
|
||
|
||
try {
|
||
$response = Invoke-WebRequest -Uri $Url -Method Get -TimeoutSec $TimeoutSec -UseBasicParsing
|
||
return ($response.StatusCode -ge 200 -and $response.StatusCode -lt 300)
|
||
}
|
||
catch {
|
||
return $false
|
||
}
|
||
}
|
||
|
||
function Test-ServiceHealthy {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
switch ($Service.ProbeType) {
|
||
"http" {
|
||
return (Test-HttpEndpoint -Url $Service.ProbeTarget)
|
||
}
|
||
default {
|
||
return (Test-TcpPort -Port $Service.Port)
|
||
}
|
||
}
|
||
}
|
||
|
||
function Wait-ServiceReady {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service,
|
||
|
||
[int]$TimeoutSec,
|
||
|
||
[int]$ProcessId
|
||
)
|
||
|
||
$effectiveTimeout = $TimeoutSec
|
||
if ($effectiveTimeout -le 0) {
|
||
$effectiveTimeout = [int]$Service.StartTimeoutSec
|
||
}
|
||
|
||
$deadline = (Get-Date).AddSeconds($effectiveTimeout)
|
||
while ((Get-Date) -lt $deadline) {
|
||
if ($ProcessId -gt 0 -and -not (Test-ProcessAlive -ProcessId $ProcessId)) {
|
||
return $false
|
||
}
|
||
|
||
if (Test-ServiceHealthy -Service $Service) {
|
||
return $true
|
||
}
|
||
|
||
Start-Sleep -Seconds 1
|
||
}
|
||
|
||
return $false
|
||
}
|
||
|
||
function Get-ServiceStatus {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
$pidInfo = Read-ServicePidInfo -Service $Service
|
||
$managedPid = $null
|
||
$hasStalePid = $false
|
||
$stdoutLogPath = Get-ServiceStdoutLogPath -Service $Service
|
||
$stderrLogPath = Get-ServiceStderrLogPath -Service $Service
|
||
if ($null -ne $pidInfo) {
|
||
$managedPid = [int]$pidInfo.Pid
|
||
if (-not [string]::IsNullOrWhiteSpace($pidInfo.StdoutLogPath)) {
|
||
$stdoutLogPath = [string]$pidInfo.StdoutLogPath
|
||
}
|
||
if (-not [string]::IsNullOrWhiteSpace($pidInfo.StderrLogPath)) {
|
||
$stderrLogPath = [string]$pidInfo.StderrLogPath
|
||
}
|
||
if (-not (Test-ProcessAlive -ProcessId $managedPid)) {
|
||
$hasStalePid = $true
|
||
$managedPid = $null
|
||
}
|
||
}
|
||
|
||
$healthy = Test-ServiceHealthy -Service $Service
|
||
$listenerPid = Get-ListeningProcessId -Port $Service.Port
|
||
|
||
if ($null -ne $managedPid) {
|
||
if ($healthy) {
|
||
return [pscustomobject]@{
|
||
State = "managed"
|
||
Summary = "managed-running"
|
||
Pid = $managedPid
|
||
Port = $Service.Port
|
||
HasStalePid = $false
|
||
StdoutLog = $stdoutLogPath
|
||
StderrLog = $stderrLogPath
|
||
}
|
||
}
|
||
|
||
return [pscustomobject]@{
|
||
State = "managed_unhealthy"
|
||
Summary = "managed-not-ready"
|
||
Pid = $managedPid
|
||
Port = $Service.Port
|
||
HasStalePid = $false
|
||
StdoutLog = $stdoutLogPath
|
||
StderrLog = $stderrLogPath
|
||
}
|
||
}
|
||
|
||
if ($healthy) {
|
||
return [pscustomobject]@{
|
||
State = "external"
|
||
Summary = "external-running"
|
||
Pid = $listenerPid
|
||
Port = $Service.Port
|
||
HasStalePid = $hasStalePid
|
||
StdoutLog = $null
|
||
StderrLog = $null
|
||
}
|
||
}
|
||
|
||
if ($null -ne $listenerPid) {
|
||
return [pscustomobject]@{
|
||
State = "conflict"
|
||
Summary = "port-conflict"
|
||
Pid = $listenerPid
|
||
Port = $Service.Port
|
||
HasStalePid = $hasStalePid
|
||
StdoutLog = $null
|
||
StderrLog = $null
|
||
}
|
||
}
|
||
|
||
if ($hasStalePid) {
|
||
return [pscustomobject]@{
|
||
State = "stale"
|
||
Summary = "stale-pid-file"
|
||
Pid = $null
|
||
Port = $Service.Port
|
||
HasStalePid = $true
|
||
StdoutLog = $stdoutLogPath
|
||
StderrLog = $stderrLogPath
|
||
}
|
||
}
|
||
|
||
return [pscustomobject]@{
|
||
State = "stopped"
|
||
Summary = "stopped"
|
||
Pid = $null
|
||
Port = $Service.Port
|
||
HasStalePid = $false
|
||
StdoutLog = $stdoutLogPath
|
||
StderrLog = $stderrLogPath
|
||
}
|
||
}
|
||
|
||
function Build-ServiceBinary {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
Initialize-DevState
|
||
Invoke-ExternalCommand -FilePath "go" -Arguments @(
|
||
"build",
|
||
"-o",
|
||
$Service.BinaryPath,
|
||
$Service.Package
|
||
) -WorkingDirectory $BackendRoot
|
||
}
|
||
|
||
function Start-ServiceProcess {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
$logPaths = New-ServiceLogPaths -Service $Service
|
||
$stdoutLog = $logPaths.Stdout
|
||
$stderrLog = $logPaths.Stderr
|
||
|
||
$process = Start-Process -FilePath $Service.BinaryPath `
|
||
-WorkingDirectory $BackendRoot `
|
||
-WindowStyle Hidden `
|
||
-RedirectStandardOutput $stdoutLog `
|
||
-RedirectStandardError $stderrLog `
|
||
-PassThru
|
||
|
||
Write-ServicePidInfo -Service $Service -Process $process -StdoutLogPath $stdoutLog -StderrLogPath $stderrLog
|
||
if (-not (Wait-ServiceReady -Service $Service -TimeoutSec $Service.StartTimeoutSec -ProcessId $process.Id)) {
|
||
if (Test-ProcessAlive -ProcessId $process.Id) {
|
||
Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
|
||
}
|
||
Remove-ServicePidInfo -Service $Service
|
||
throw "Service start failed: $($Service.Name). Check logs: $stdoutLog and $stderrLog"
|
||
}
|
||
|
||
return $process
|
||
}
|
||
|
||
function Stop-ServiceProcess {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
$pidInfo = Read-ServicePidInfo -Service $Service
|
||
if ($null -eq $pidInfo) {
|
||
return [pscustomobject]@{
|
||
Name = $Service.Name
|
||
Action = "skip"
|
||
Message = "no managed process"
|
||
}
|
||
}
|
||
|
||
$managedProcessId = [int]$pidInfo.Pid
|
||
if (Test-ProcessAlive -ProcessId $managedProcessId) {
|
||
try {
|
||
Stop-Process -Id $managedProcessId -ErrorAction Stop
|
||
}
|
||
catch {
|
||
Stop-Process -Id $managedProcessId -Force -ErrorAction SilentlyContinue
|
||
}
|
||
|
||
$deadline = (Get-Date).AddSeconds(15)
|
||
while ((Get-Date) -lt $deadline -and (Test-ProcessAlive -ProcessId $managedProcessId)) {
|
||
Start-Sleep -Milliseconds 500
|
||
}
|
||
|
||
if (Test-ProcessAlive -ProcessId $managedProcessId) {
|
||
Stop-Process -Id $managedProcessId -Force -ErrorAction SilentlyContinue
|
||
}
|
||
}
|
||
|
||
Remove-ServicePidInfo -Service $Service
|
||
return [pscustomobject]@{
|
||
Name = $Service.Name
|
||
Action = "stopped"
|
||
Message = "managed process stopped"
|
||
}
|
||
}
|
||
|
||
function Ensure-ServiceStarted {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
$status = Get-ServiceStatus -Service $Service
|
||
if ($status.HasStalePid) {
|
||
Remove-ServicePidInfo -Service $Service
|
||
$status = Get-ServiceStatus -Service $Service
|
||
}
|
||
|
||
switch ($status.State) {
|
||
"managed" {
|
||
return [pscustomobject]@{
|
||
Name = $Service.Name
|
||
Port = $Service.Port
|
||
Action = "skip"
|
||
Detail = "managed and healthy"
|
||
}
|
||
}
|
||
"external" {
|
||
return [pscustomobject]@{
|
||
Name = $Service.Name
|
||
Port = $Service.Port
|
||
Action = "skip"
|
||
Detail = "external process already healthy"
|
||
}
|
||
}
|
||
"managed_unhealthy" {
|
||
if (Wait-ServiceReady -Service $Service -TimeoutSec 15 -ProcessId $status.Pid) {
|
||
return [pscustomobject]@{
|
||
Name = $Service.Name
|
||
Port = $Service.Port
|
||
Action = "skip"
|
||
Detail = "existing managed process became healthy"
|
||
}
|
||
}
|
||
|
||
throw "Service $($Service.Name) already has a managed process, but it is still not healthy after waiting. Check log: $($status.StdoutLog)"
|
||
}
|
||
"conflict" {
|
||
throw "Service $($Service.Name) port $($Service.Port) is occupied by process $($status.Pid), but the health check failed. Resolve the conflict first."
|
||
}
|
||
}
|
||
|
||
Assert-ServiceDependenciesReady -Service $Service
|
||
Build-ServiceBinary -Service $Service
|
||
$process = Start-ServiceProcess -Service $Service
|
||
return [pscustomobject]@{
|
||
Name = $Service.Name
|
||
Port = $Service.Port
|
||
Action = "start"
|
||
Detail = "started, PID=$($process.Id)"
|
||
}
|
||
}
|
||
|
||
function Restart-BackendService {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
$status = Get-ServiceStatus -Service $Service
|
||
if ($status.HasStalePid) {
|
||
Remove-ServicePidInfo -Service $Service
|
||
$status = Get-ServiceStatus -Service $Service
|
||
}
|
||
|
||
switch ($status.State) {
|
||
"external" {
|
||
throw "Service $($Service.Name) is managed by an external process. Refuse to restart it automatically."
|
||
}
|
||
"conflict" {
|
||
throw "Service $($Service.Name) port $($Service.Port) is occupied by process $($status.Pid), but the health check failed. Resolve the conflict first."
|
||
}
|
||
"managed" {
|
||
Stop-ServiceProcess -Service $Service | Out-Null
|
||
}
|
||
"managed_unhealthy" {
|
||
Stop-ServiceProcess -Service $Service | Out-Null
|
||
}
|
||
}
|
||
|
||
Assert-ServiceDependenciesReady -Service $Service
|
||
Build-ServiceBinary -Service $Service
|
||
$process = Start-ServiceProcess -Service $Service
|
||
return [pscustomobject]@{
|
||
Name = $Service.Name
|
||
Port = $Service.Port
|
||
Action = "restart"
|
||
Detail = "restarted, PID=$($process.Id)"
|
||
}
|
||
}
|
||
|
||
function Get-ContainerStatus {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[string]$ContainerName
|
||
)
|
||
|
||
$status = cmd.exe /d /c "docker inspect --format ""{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}"" $ContainerName 2>nul"
|
||
if ($LASTEXITCODE -ne 0) {
|
||
return "unavailable"
|
||
}
|
||
|
||
return (($status | Select-Object -First 1).Trim())
|
||
}
|
||
|
||
function Wait-ContainerStatus {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$ContainerDefinition
|
||
)
|
||
|
||
$deadline = (Get-Date).AddSeconds([int]$ContainerDefinition.TimeoutSec)
|
||
while ((Get-Date) -lt $deadline) {
|
||
$status = Get-ContainerStatus -ContainerName $ContainerDefinition.Container
|
||
if ($status -eq "healthy") {
|
||
return
|
||
}
|
||
|
||
Start-Sleep -Seconds 2
|
||
}
|
||
|
||
throw "Container did not become healthy in time: $($ContainerDefinition.Name)"
|
||
}
|
||
|
||
function Start-BackendInfrastructure {
|
||
Assert-ToolExists -Name "docker"
|
||
if (-not (Test-Path -LiteralPath $ComposeFile)) {
|
||
throw "docker-compose.yml not found: $ComposeFile"
|
||
}
|
||
|
||
$composeServices = Get-InfrastructureComposeServices
|
||
Invoke-ExternalCommand -FilePath "docker" -Arguments (@(
|
||
"compose",
|
||
"-f",
|
||
$ComposeFile,
|
||
"up",
|
||
"-d"
|
||
) + $composeServices) -WorkingDirectory $RepoRoot
|
||
|
||
foreach ($definition in (Get-InfrastructureDefinitions)) {
|
||
Wait-ContainerStatus -ContainerDefinition $definition
|
||
}
|
||
|
||
Ensure-KafkaTopics
|
||
}
|
||
|
||
function Ensure-KafkaTopics {
|
||
Assert-ToolExists -Name "docker"
|
||
|
||
foreach ($topic in (Get-KafkaTopicDefinitions)) {
|
||
$arguments = @(
|
||
"exec",
|
||
"smartflow-kafka",
|
||
"/opt/kafka/bin/kafka-topics.sh",
|
||
"--bootstrap-server",
|
||
"localhost:9092",
|
||
"--create",
|
||
"--if-not-exists",
|
||
"--topic",
|
||
$topic,
|
||
"--partitions",
|
||
"3",
|
||
"--replication-factor",
|
||
"1"
|
||
)
|
||
Invoke-ExternalCommand -FilePath "docker" -Arguments $arguments -WorkingDirectory $RepoRoot
|
||
}
|
||
}
|
||
|
||
function Stop-BackendInfrastructure {
|
||
Assert-ToolExists -Name "docker"
|
||
$composeServices = Get-InfrastructureComposeServices
|
||
Invoke-ExternalCommand -FilePath "docker" -Arguments (@(
|
||
"compose",
|
||
"-f",
|
||
$ComposeFile,
|
||
"stop"
|
||
) + $composeServices) -WorkingDirectory $RepoRoot
|
||
}
|
||
|
||
function Get-InfrastructureStatus {
|
||
$rows = @()
|
||
foreach ($definition in (Get-InfrastructureDefinitions)) {
|
||
$rows += [pscustomobject]@{
|
||
Name = $definition.Name
|
||
Container = $definition.Container
|
||
Status = (Get-ContainerStatus -ContainerName $definition.Container)
|
||
}
|
||
}
|
||
return $rows
|
||
}
|
||
|
||
function Assert-ServiceDependenciesReady {
|
||
param(
|
||
[Parameter(Mandatory = $true)]
|
||
[pscustomobject]$Service
|
||
)
|
||
|
||
foreach ($dependencyName in $Service.Dependencies) {
|
||
$dependency = Get-BackendServiceDefinition -Name $dependencyName
|
||
$status = Get-ServiceStatus -Service $dependency
|
||
if ($status.State -notin @("managed", "external")) {
|
||
throw "Dependency not ready for service $($Service.Name): $dependencyName (current state: $($status.Summary))"
|
||
}
|
||
}
|
||
}
|