feat: add native Windows local orchestration
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,6 +6,7 @@
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
/.local/
|
||||||
/backups/
|
/backups/
|
||||||
/certbot/conf/
|
/certbot/conf/
|
||||||
/certbot/www/
|
/certbot/www/
|
||||||
@@ -16,4 +17,3 @@
|
|||||||
.vscode/
|
.vscode/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ This repository is the operational control plane for the hackathon project. It w
|
|||||||
|
|
||||||
Current state: specifications, operations plan, adaptation contract, runbooks, and task list are approved. Phase 4 deployment implementation is authorized.
|
Current state: specifications, operations plan, adaptation contract, runbooks, and task list are approved. Phase 4 deployment implementation is authorized.
|
||||||
|
|
||||||
|
For direct Windows development without Docker Compose, see [`docs/LOCAL-DEVELOPMENT.md`](docs/LOCAL-DEVELOPMENT.md). This helper is deliberately separate from the production Compose contract.
|
||||||
|
|
||||||
## Repository topology
|
## Repository topology
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
38
docs/LOCAL-DEVELOPMENT.md
Normal file
38
docs/LOCAL-DEVELOPMENT.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Local development without Docker Compose
|
||||||
|
|
||||||
|
This Windows-only helper runs the API, analytics worker, and frontend directly from the three sibling repositories. PostgreSQL and Redis must already be reachable locally. It does not create databases, run migrations/imports, generate credentials, edit service-owned environment files, or start Docker.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go and npm are on `PATH`.
|
||||||
|
- PostgreSQL is reachable at `127.0.0.1:5432`.
|
||||||
|
- Redis is reachable at `127.0.0.1:6379`.
|
||||||
|
- The backend has `cmd/api` and `cmd/worker`, plus a protected backend-owned `.env`.
|
||||||
|
- The frontend has a `package.json` `dev` script and its frontend-owned local environment file.
|
||||||
|
- Dependencies, migrations, the one-time importer, and demo merchant creation have already completed through their owning repositories.
|
||||||
|
|
||||||
|
The helper only reports variable names or file paths when validation fails. It never prints resolved environment values. Runtime process metadata and logs are written under ignored `.local/`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Run these from the deployment repository in PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\local-dev.ps1 check
|
||||||
|
.\scripts\local-dev.ps1 start
|
||||||
|
.\scripts\local-dev.ps1 status
|
||||||
|
.\scripts\local-dev.ps1 stop
|
||||||
|
```
|
||||||
|
|
||||||
|
The default sibling locations are `../zarinpal-analytics-backend` and `../zarinpal-analytics-frontend`. Override them with `-BackendPath` and `-FrontendPath` when necessary.
|
||||||
|
|
||||||
|
`stop` terminates only the process trees whose PID and start time match the ignored state file. A stale or reused PID is not terminated.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File .\tests\local-dev.tests.ps1
|
||||||
|
pwsh -NoProfile -File .\scripts\local-dev.ps1 check
|
||||||
|
```
|
||||||
|
|
||||||
|
After `start`, open the frontend URL declared by the frontend development server. Inspect `.local/logs/*.err.log` if a process exits during or after startup.
|
||||||
206
scripts/LocalDev.psm1
Normal file
206
scripts/LocalDev.psm1
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
Set-StrictMode -Version Latest
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
function Read-DotEnvFile {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([Parameter(Mandatory)][string]$Path)
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||||
|
throw "Environment file not found: $Path"
|
||||||
|
}
|
||||||
|
|
||||||
|
$values = @{}
|
||||||
|
$lineNumber = 0
|
||||||
|
foreach ($rawLine in Get-Content -LiteralPath $Path) {
|
||||||
|
$lineNumber++
|
||||||
|
$line = $rawLine.Trim()
|
||||||
|
if (-not $line -or $line.StartsWith('#')) { continue }
|
||||||
|
if ($line -notmatch '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') {
|
||||||
|
throw "invalid dotenv entry at ${Path}:$lineNumber"
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $Matches[1]
|
||||||
|
$value = $Matches[2]
|
||||||
|
if ($values.ContainsKey($name)) {
|
||||||
|
throw "duplicate variable '$name' in $Path"
|
||||||
|
}
|
||||||
|
if ($value.Length -ge 2 -and (($value[0] -eq '"' -and $value[-1] -eq '"') -or ($value[0] -eq "'" -and $value[-1] -eq "'"))) {
|
||||||
|
$value = $value.Substring(1, $value.Length - 2)
|
||||||
|
}
|
||||||
|
$values[$name] = $value
|
||||||
|
}
|
||||||
|
return $values
|
||||||
|
}
|
||||||
|
|
||||||
|
function Resolve-LocalDevCommands {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$BackendPath,
|
||||||
|
[Parameter(Mandatory)][string]$FrontendPath
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($entrypoint in 'api', 'worker') {
|
||||||
|
$path = Join-Path $BackendPath "cmd\$entrypoint"
|
||||||
|
if (-not (Test-Path -LiteralPath $path -PathType Container)) {
|
||||||
|
throw "Backend entrypoint cmd/$entrypoint is missing under $BackendPath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$packageJsonPath = Join-Path $FrontendPath 'package.json'
|
||||||
|
if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) {
|
||||||
|
throw "Frontend package.json is missing under $FrontendPath"
|
||||||
|
}
|
||||||
|
$packageJson = Get-Content -Raw -LiteralPath $packageJsonPath | ConvertFrom-Json
|
||||||
|
if (-not $packageJson.scripts -or -not $packageJson.scripts.dev) {
|
||||||
|
throw "Frontend package.json does not define the required 'dev' script"
|
||||||
|
}
|
||||||
|
|
||||||
|
return @{
|
||||||
|
Api = @{ FilePath = 'go'; Arguments = @('run', './cmd/api'); WorkingDirectory = $BackendPath }
|
||||||
|
Worker = @{ FilePath = 'go'; Arguments = @('run', './cmd/worker'); WorkingDirectory = $BackendPath }
|
||||||
|
Frontend = @{ FilePath = 'npm.cmd'; Arguments = @('run', 'dev', '--', '--host', '127.0.0.1'); WorkingDirectory = $FrontendPath }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-TcpEndpoint {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][int]$Port, [int]$TimeoutMilliseconds = 1000)
|
||||||
|
|
||||||
|
$client = [System.Net.Sockets.TcpClient]::new()
|
||||||
|
try {
|
||||||
|
$task = $client.ConnectAsync($HostName, $Port)
|
||||||
|
return $task.Wait($TimeoutMilliseconds) -and $client.Connected
|
||||||
|
}
|
||||||
|
catch { return $false }
|
||||||
|
finally { $client.Dispose() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-StatePath {
|
||||||
|
param([string]$DeploymentPath)
|
||||||
|
return Join-Path $DeploymentPath '.local\processes.json'
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-RecordedProcesses {
|
||||||
|
param([string]$DeploymentPath)
|
||||||
|
$statePath = Get-StatePath -DeploymentPath $DeploymentPath
|
||||||
|
if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) { return @() }
|
||||||
|
$items = @(Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json)
|
||||||
|
return $items
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-RecordedProcess {
|
||||||
|
param($Record)
|
||||||
|
$process = Get-Process -Id $Record.Pid -ErrorAction SilentlyContinue
|
||||||
|
if (-not $process) { return $false }
|
||||||
|
try {
|
||||||
|
$actual = $process.StartTime.ToUniversalTime().ToString('o')
|
||||||
|
return $actual -eq $Record.StartTimeUtc
|
||||||
|
}
|
||||||
|
catch { return $false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-LocalDevCheck {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory)][string]$DeploymentPath,
|
||||||
|
[Parameter(Mandatory)][string]$BackendPath,
|
||||||
|
[Parameter(Mandatory)][string]$FrontendPath,
|
||||||
|
[string]$PostgresHost = '127.0.0.1',
|
||||||
|
[int]$PostgresPort = 5432,
|
||||||
|
[string]$RedisHost = '127.0.0.1',
|
||||||
|
[int]$RedisPort = 6379
|
||||||
|
)
|
||||||
|
|
||||||
|
$null = Resolve-LocalDevCommands -BackendPath $BackendPath -FrontendPath $FrontendPath
|
||||||
|
foreach ($tool in 'go', 'npm.cmd') {
|
||||||
|
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "Required command is not installed or on PATH: $tool" }
|
||||||
|
}
|
||||||
|
$backendEnv = Join-Path $BackendPath '.env'
|
||||||
|
if (-not (Test-Path -LiteralPath $backendEnv -PathType Leaf)) { throw "Backend .env is missing: $backendEnv" }
|
||||||
|
$null = Read-DotEnvFile -Path $backendEnv
|
||||||
|
if (-not (Test-TcpEndpoint -HostName $PostgresHost -Port $PostgresPort)) { throw "PostgreSQL is not reachable at ${PostgresHost}:$PostgresPort" }
|
||||||
|
if (-not (Test-TcpEndpoint -HostName $RedisHost -Port $RedisPort)) { throw "Redis is not reachable at ${RedisHost}:$RedisPort" }
|
||||||
|
|
||||||
|
[pscustomobject]@{ Go = 'ok'; Node = 'ok'; PostgreSQL = 'reachable'; Redis = 'reachable'; Contracts = 'ok' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-LocalProcess {
|
||||||
|
param([string]$Name, [hashtable]$Command, [hashtable]$Environment, [string]$LogDirectory)
|
||||||
|
|
||||||
|
$resolved = Get-Command $Command.FilePath -ErrorAction Stop
|
||||||
|
$previous = @{}
|
||||||
|
foreach ($entry in $Environment.GetEnumerator()) {
|
||||||
|
$previous[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key, 'Process')
|
||||||
|
[Environment]::SetEnvironmentVariable($entry.Key, [string]$entry.Value, 'Process')
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$stdout = Join-Path $LogDirectory "$Name.out.log"
|
||||||
|
$stderr = Join-Path $LogDirectory "$Name.err.log"
|
||||||
|
$process = Start-Process -FilePath $resolved.Source -ArgumentList $Command.Arguments -WorkingDirectory $Command.WorkingDirectory -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
foreach ($entry in $previous.GetEnumerator()) {
|
||||||
|
[Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 750
|
||||||
|
if ($process.HasExited) { throw "$Name exited during startup; inspect $stderr" }
|
||||||
|
$process.Refresh()
|
||||||
|
return [pscustomobject]@{ Name = $Name; Pid = $process.Id; StartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o') }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-RecordedProcess {
|
||||||
|
param($Record)
|
||||||
|
if (-not (Test-RecordedProcess $Record)) { return }
|
||||||
|
& taskkill.exe /PID $Record.Pid /T /F | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Start-LocalDev {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([string]$DeploymentPath, [string]$BackendPath, [string]$FrontendPath)
|
||||||
|
|
||||||
|
$existing = @(Get-RecordedProcesses -DeploymentPath $DeploymentPath | Where-Object { Test-RecordedProcess $_ })
|
||||||
|
if ($existing.Count -gt 0) { throw 'Local services are already recorded as running; use status or stop first' }
|
||||||
|
|
||||||
|
$null = Invoke-LocalDevCheck -DeploymentPath $DeploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath
|
||||||
|
$commands = Resolve-LocalDevCommands -BackendPath $BackendPath -FrontendPath $FrontendPath
|
||||||
|
$backendEnvironment = Read-DotEnvFile -Path (Join-Path $BackendPath '.env')
|
||||||
|
$runtimeDirectory = Join-Path $DeploymentPath '.local'
|
||||||
|
$logDirectory = Join-Path $runtimeDirectory 'logs'
|
||||||
|
New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null
|
||||||
|
$started = @()
|
||||||
|
try {
|
||||||
|
$started += Start-LocalProcess -Name 'api' -Command $commands.Api -Environment $backendEnvironment -LogDirectory $logDirectory
|
||||||
|
$started += Start-LocalProcess -Name 'worker' -Command $commands.Worker -Environment $backendEnvironment -LogDirectory $logDirectory
|
||||||
|
$started += Start-LocalProcess -Name 'frontend' -Command $commands.Frontend -Environment @{} -LogDirectory $logDirectory
|
||||||
|
$started | ConvertTo-Json | Set-Content -LiteralPath (Get-StatePath -DeploymentPath $DeploymentPath) -Encoding utf8
|
||||||
|
return $started
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
foreach ($record in $started) {
|
||||||
|
Stop-RecordedProcess $record
|
||||||
|
}
|
||||||
|
throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stop-LocalDev {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([string]$DeploymentPath)
|
||||||
|
|
||||||
|
foreach ($record in @(Get-RecordedProcesses -DeploymentPath $DeploymentPath)) {
|
||||||
|
Stop-RecordedProcess $record
|
||||||
|
}
|
||||||
|
Remove-Item -LiteralPath (Get-StatePath -DeploymentPath $DeploymentPath) -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-LocalDevStatus {
|
||||||
|
[CmdletBinding()]
|
||||||
|
param([string]$DeploymentPath)
|
||||||
|
|
||||||
|
return @(Get-RecordedProcesses -DeploymentPath $DeploymentPath | ForEach-Object {
|
||||||
|
[pscustomobject]@{ Name = $_.Name; Pid = $_.Pid; Running = [bool](Test-RecordedProcess $_) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function Read-DotEnvFile, Resolve-LocalDevCommands, Test-TcpEndpoint, Invoke-LocalDevCheck, Start-LocalDev, Stop-LocalDev, Get-LocalDevStatus
|
||||||
26
scripts/local-dev.ps1
Normal file
26
scripts/local-dev.ps1
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Position = 0)][ValidateSet('check', 'start', 'stop', 'status')][string]$Action = 'status',
|
||||||
|
[string]$BackendPath,
|
||||||
|
[string]$FrontendPath
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$deploymentPath = Split-Path $PSScriptRoot -Parent
|
||||||
|
if (-not $BackendPath) { $BackendPath = Join-Path (Split-Path $deploymentPath -Parent) 'zarinpal-analytics-backend' }
|
||||||
|
if (-not $FrontendPath) { $FrontendPath = Join-Path (Split-Path $deploymentPath -Parent) 'zarinpal-analytics-frontend' }
|
||||||
|
|
||||||
|
Import-Module (Join-Path $PSScriptRoot 'LocalDev.psm1') -Force
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch ($Action) {
|
||||||
|
'check' { Invoke-LocalDevCheck -DeploymentPath $deploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath | Format-List }
|
||||||
|
'start' { Start-LocalDev -DeploymentPath $deploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath | Format-Table -AutoSize }
|
||||||
|
'stop' { Stop-LocalDev -DeploymentPath $deploymentPath; Write-Output 'Local services stopped.' }
|
||||||
|
'status' { Get-LocalDevStatus -DeploymentPath $deploymentPath | Format-Table -AutoSize }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Error $_
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
73
tests/local-dev.tests.ps1
Normal file
73
tests/local-dev.tests.ps1
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$modulePath = Join-Path $PSScriptRoot '..\scripts\LocalDev.psm1'
|
||||||
|
Import-Module $modulePath -Force
|
||||||
|
|
||||||
|
function Assert-Equal {
|
||||||
|
param($Expected, $Actual, [string]$Message)
|
||||||
|
|
||||||
|
if ($Expected -ne $Actual) {
|
||||||
|
throw "$Message (expected '$Expected', got '$Actual')"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Throws {
|
||||||
|
param([scriptblock]$Action, [string]$Pattern, [string]$Message)
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $Action
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
if ($_.Exception.Message -notmatch $Pattern) {
|
||||||
|
throw "$Message (unexpected error: $($_.Exception.Message))"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw "$Message (no error was thrown)"
|
||||||
|
}
|
||||||
|
|
||||||
|
$fixture = Join-Path ([System.IO.Path]::GetTempPath()) ("zarinpal-local-dev-" + [guid]::NewGuid())
|
||||||
|
New-Item -ItemType Directory -Path $fixture | Out-Null
|
||||||
|
|
||||||
|
try {
|
||||||
|
$validEnv = Join-Path $fixture '.env'
|
||||||
|
@(
|
||||||
|
'# ignored comment'
|
||||||
|
'PLAIN=value'
|
||||||
|
'QUOTED="value with spaces"'
|
||||||
|
'EMPTY='
|
||||||
|
) | Set-Content -LiteralPath $validEnv -Encoding utf8
|
||||||
|
|
||||||
|
$values = Read-DotEnvFile -Path $validEnv
|
||||||
|
Assert-Equal 'value' $values.PLAIN 'Plain dotenv values should parse'
|
||||||
|
Assert-Equal 'value with spaces' $values.QUOTED 'Quoted dotenv values should parse'
|
||||||
|
Assert-Equal '' $values.EMPTY 'Blank dotenv values should parse'
|
||||||
|
|
||||||
|
$duplicateEnv = Join-Path $fixture 'duplicate.env'
|
||||||
|
@('KEY=one', 'KEY=two') | Set-Content -LiteralPath $duplicateEnv -Encoding utf8
|
||||||
|
Assert-Throws { Read-DotEnvFile -Path $duplicateEnv } 'duplicate variable' 'Duplicate variables must fail closed'
|
||||||
|
|
||||||
|
$invalidEnv = Join-Path $fixture 'invalid.env'
|
||||||
|
'NOT A KEY=value' | Set-Content -LiteralPath $invalidEnv -Encoding utf8
|
||||||
|
Assert-Throws { Read-DotEnvFile -Path $invalidEnv } 'invalid dotenv entry' 'Malformed variables must fail closed'
|
||||||
|
|
||||||
|
$backend = Join-Path $fixture 'backend'
|
||||||
|
$frontend = Join-Path $fixture 'frontend'
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $backend 'cmd\api') -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $backend 'cmd\worker') -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path $frontend -Force | Out-Null
|
||||||
|
'{"scripts":{"dev":"vite"}}' | Set-Content -LiteralPath (Join-Path $frontend 'package.json') -Encoding utf8
|
||||||
|
|
||||||
|
$commands = Resolve-LocalDevCommands -BackendPath $backend -FrontendPath $frontend
|
||||||
|
Assert-Equal './cmd/api' $commands.Api.Arguments[1] 'API command should follow the backend contract'
|
||||||
|
Assert-Equal './cmd/worker' $commands.Worker.Arguments[1] 'Worker command should follow the backend contract'
|
||||||
|
Assert-Equal 'run' $commands.Frontend.Arguments[0] 'Frontend should use its checked-in dev script'
|
||||||
|
|
||||||
|
Remove-Item -LiteralPath (Join-Path $backend 'cmd\worker') -Recurse
|
||||||
|
Assert-Throws { Resolve-LocalDevCommands -BackendPath $backend -FrontendPath $frontend } 'cmd/worker' 'A missing worker entrypoint must be actionable'
|
||||||
|
|
||||||
|
Write-Output 'local-dev tests passed'
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user