You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

438 lines
24 KiB

<#
.SYNOPSIS
Deploy the NEW Web Configurator to a device, or roll back the last deploy.
.DESCRIPTION
Builds a clean archive of the committed src/ tree, transfers it over SSH,
backs up the device's current src/, swaps in the new tree, stamps the
deployed version, and restarts the web-configurator systemd service.
Every deploy must sit on a version tag (vX.Y.Z); use -AllowUntagged for a
flagged dev build. See docs/DEPLOY.md.
.EXAMPLE
.\scripts\deploy.ps1 192.168.55.56
.EXAMPLE
.\scripts\deploy.ps1 192.168.55.56 -AllowUntagged
.EXAMPLE
.\scripts\deploy.ps1 192.168.55.56 -Rollback
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$DeviceIp,
[switch]$AllowUntagged,
[switch]$Rollback
)
$ErrorActionPreference = 'Stop'
# --- Fixed device facts ---------------------------------------------------
# v1.12.0: 앱 코드 디렉토리 /opt/web-configurator → /usr/lib/web-configurator (협력사 BSP
# 베이킹 정합). 이 장비에서 /usr/lib 는 /lib/systemd/system 과 동일한 영속 RW overlay 라
# in-place src swap·backups/·DEPLOYED_VERSION 기록이 그대로 동작한다(쓰기 산출물은 $AppDir 하 유지).
$AppDir = '/usr/lib/web-configurator'
$Service = 'web-configurator'
$Port = 9090
$SshTarget = "root@$DeviceIp"
$SshOpts = @('-o', 'StrictHostKeyChecking=no', '-o', 'BatchMode=yes')
# --- Helpers --------------------------------------------------------------
function Write-Step([string]$Message) {
Write-Host "==> $Message" -ForegroundColor Cyan
}
# Run a command on the device; throw on non-zero exit.
function Invoke-Ssh([string]$Command) {
$output = & ssh @SshOpts $SshTarget $Command
if ($LASTEXITCODE -ne 0) {
throw "SSH command failed (exit $LASTEXITCODE): $Command"
}
return $output
}
# Run a git command allowed to fail; return trimmed stdout, or $null.
function Invoke-GitSafe([string]$GitArgs) {
$out = cmd /c "git $GitArgs 2>nul"
if ($LASTEXITCODE -eq 0 -and $out) { return ($out | Out-String).Trim() }
return $null
}
function Test-ServiceHealthy {
$active = (Invoke-Ssh "systemctl is-active $Service" | Out-String).Trim()
if ($active -ne 'active') { return $false }
try {
$resp = Invoke-WebRequest -Uri "http://${DeviceIp}:$Port/" `
-TimeoutSec 5 -UseBasicParsing
return ($resp.StatusCode -eq 200)
} catch {
return $false
}
}
function Confirm-Health {
Write-Step "Verifying service health"
for ($i = 1; $i -le 5; $i++) {
Start-Sleep -Seconds 3
if (Test-ServiceHealthy) {
Write-Host " service active, HTTP 200 on :$Port" -ForegroundColor Green
return
}
}
throw "Verification failed: $Service not healthy on $DeviceIp after restart"
}
# --- Rollback -------------------------------------------------------------
if ($Rollback) {
Write-Step "Rollback on $DeviceIp"
$raw = Invoke-Ssh "ls -1dt $AppDir/backups/src-* 2>/dev/null || true"
$backups = @($raw | Where-Object { $_ -and $_.Trim() })
if ($backups.Count -eq 0) {
throw "No backups found on $DeviceIp under $AppDir/backups"
}
$latest = $backups[0].Trim()
Write-Host " restoring $latest" -ForegroundColor Yellow
Invoke-Ssh "cd $AppDir && rm -rf src && cp -r '$latest' src" | Out-Null
$now = (Get-Date).ToString('o')
$deployedBy = "$env:USERNAME@$env:COMPUTERNAME"
Invoke-Ssh ("printf 'version=rolled-back\nrestored_from=%s\ndeployed_at=%s\ndeployed_by=%s\n'" +
" '$latest' '$now' '$deployedBy' > $AppDir/DEPLOYED_VERSION") | Out-Null
Invoke-Ssh ("printf '%s %s %s %s\n' '$now' 'rolled-back' '$latest' '$deployedBy'" +
" >> $AppDir/deploy-history.log") | Out-Null
Invoke-Ssh "systemctl restart $Service" | Out-Null
Confirm-Health
Write-Host "Rollback complete on $DeviceIp ($latest)" -ForegroundColor Green
return
}
# --- Pre-flight -----------------------------------------------------------
Write-Step "Pre-flight checks"
try { Invoke-Ssh "echo ok" | Out-Null }
catch { throw "Cannot reach $DeviceIp over SSH. Check the IP and that your key is authorized." }
$dirtyRaw = & git status --porcelain
if ($LASTEXITCODE -ne 0) { throw "git status failed — not a git repo?" }
# Skip untracked-only lines (??) — only staged/modified tracked files block deploy
$dirty = @($dirtyRaw | Where-Object { $_ -and $_ -notmatch '^\?\?' })
if ($dirty.Count -gt 0) { throw "Local working tree is not clean. Commit or stash before deploying." }
$commit = (& git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0) { throw "git rev-parse HEAD failed" }
$short = (& git rev-parse --short HEAD).Trim()
if ($LASTEXITCODE -ne 0) { throw "git rev-parse --short HEAD failed" }
$tag = Invoke-GitSafe 'describe --exact-match --tags HEAD'
if ($tag) {
$version = $tag
Write-Host " deploying release $version" -ForegroundColor Green
} elseif ($AllowUntagged) {
$nearest = Invoke-GitSafe 'describe --tags --abbrev=0'
if (-not $nearest) { $nearest = 'v0.0.0' }
$version = "$nearest-dev+$short"
Write-Host " WARNING: HEAD is untagged - dev build $version" -ForegroundColor Yellow
} else {
throw "HEAD is not at a version tag. Tag a release, or pass -AllowUntagged for a dev build."
}
# --- Deploy ---------------------------------------------------------------
$localTar = Join-Path $env:TEMP "webcfg-deploy-$short.tar"
try {
Write-Step "Building archive of committed src/"
& git archive --format=tar --output="$localTar" HEAD src
if ($LASTEXITCODE -ne 0) { throw "git archive failed" }
Write-Step "Transferring to $DeviceIp"
# v1.5.0: first-deploy guard — ensure $AppDir exists before scp (otherwise
# scp fails with "dest open: No such file or directory" on a fresh device).
# v1.5.2 C1: pre-create firmware OTA dirs (ProtectSystem=strict requires they exist
# AND be in ReadWritePaths). systemd unit lists them; we ensure presence.
Invoke-Ssh "mkdir -p $AppDir /opt/fw_staging /opt/fw_upload /opt/config_backups" | Out-Null
& scp @SshOpts "$localTar" "${SshTarget}:$AppDir/_deploy.tar"
if ($LASTEXITCODE -ne 0) { throw "scp failed" }
# v1.4.6.6: GNU tar의 timestamp warning이 stderr로 출력되면 PowerShell 5.1
# NativeCommandError로 wrap되어 ErrorActionPreference=Stop과 결합 시 terminating
# 에러 발생. --warning=no-timestamp로 시간 차 warning만 끄고 진짜 에러는 보존.
Invoke-Ssh ("rm -rf $AppDir/_deploy_tmp && mkdir -p $AppDir/_deploy_tmp" +
" && tar --warning=no-timestamp -xf $AppDir/_deploy.tar -C $AppDir/_deploy_tmp") | Out-Null
Write-Step "Backing up current src/ (keep last 3)"
$ts = Get-Date -Format 'yyyyMMdd-HHmmss'
Invoke-Ssh "cd $AppDir && mkdir -p backups && if [ -d src ]; then cp -r src backups/src-$ts; fi" | Out-Null
$raw = Invoke-Ssh "ls -1dt $AppDir/backups/src-* 2>/dev/null || true"
$backups = @($raw | Where-Object { $_ -and $_.Trim() })
if ($backups.Count -gt 3) {
foreach ($old in $backups[3..($backups.Count - 1)]) {
Invoke-Ssh "rm -rf '$($old.Trim())'" | Out-Null
}
}
# v1.5.2 D1 (H17): install unit to /lib/systemd/system/ — persistent across reboots on
# Telechips/Poky 4.0.17. /etc/systemd/system/ is a tmpfs overlay that is lost on every
# reboot; all firmware-native services (dpworldapp, app-runner, nginx) live in
# /lib/systemd/system/ which is on the read-write overlay (persistent). Installing here
# ensures web-configurator survives power cycles without manual re-registration.
# Enable symlink is created in /etc/systemd/system/multi-user.target.wants/ (which also
# survives because it is backed by the same persistent overlay).
# #8 review fix: unit/dir installs are idempotent and independent of the src tree — done
# BEFORE the src swap so a scp failure aborts while the OLD src is still active on disk.
$unitPath = "/lib/systemd/system/web-configurator.service"
$unitCheck = (Invoke-Ssh "test -f $unitPath && echo yes || echo no").Trim()
$localUnit = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\web-configurator.service")).Path
if ($unitCheck -ne "yes") {
Write-Host "[deploy] first deploy on $DeviceIp — installing systemd unit to persistent /lib/systemd/system/" -ForegroundColor Yellow
& scp @SshOpts "$localUnit" "${SshTarget}:${unitPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of unit file failed" }
Invoke-Ssh "systemctl daemon-reload && systemctl enable web-configurator" | Out-Null
} else {
$remoteHash = (Invoke-Ssh "sha256sum $unitPath | cut -d' ' -f1").Trim()
$localHash = (Get-FileHash $localUnit -Algorithm SHA256).Hash.ToLower()
if ($remoteHash -ne $localHash) {
Write-Host "[deploy] unit file changed — reinstalling to /lib/systemd/system/ and daemon-reload" -ForegroundColor Yellow
& scp @SshOpts "$localUnit" "${SshTarget}:${unitPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of updated unit file failed" }
Invoke-Ssh "systemctl daemon-reload" | Out-Null
}
}
# v1.6.0: 네트워크 적용 엔진 디렉토리 생성 (ReadWritePaths - prefix 와 쌍 — src swap 전 보장)
# #5/#8 review fix: /opt/config_backups/network 는 plaintext PSK 저장 — chmod 700 으로 world-read 차단.
# /home/root/network 는 dpworldapp 공유 디렉토리로 기본 권한 유지.
Invoke-Ssh "mkdir -p /home/root/network /opt/config_backups/network && chmod 700 /opt/config_backups/network" | Out-Null
# v1.6.0: dpworld-net-recover.service 설치 (oneshot — enable 불필요, 파일만)
# C2 review fix: /etc/systemd/system 은 tmpfs overlay 로 재부팅 시 소실 (v1.5.2 D1 참조)
# → web-configurator.service 와 동일한 hash-compare 패턴으로 영속 /lib/systemd/system 에 설치.
# 구버전이 설치한 휘발성 /etc 사본은 /lib 유닛을 가리므로(systemd 우선순위) 제거.
$recoverPath = "/lib/systemd/system/dpworld-net-recover.service"
$recoverUnit = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-net-recover.service")).Path
$recoverCheck = (Invoke-Ssh "test -f $recoverPath && echo yes || echo no").Trim()
if ($recoverCheck -ne "yes") {
Write-Host "[deploy] installing dpworld-net-recover.service to persistent /lib/systemd/system/" -ForegroundColor Yellow
& scp @SshOpts $recoverUnit "${SshTarget}:${recoverPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-net-recover.service failed" }
Invoke-Ssh "rm -f /etc/systemd/system/dpworld-net-recover.service && systemctl daemon-reload" | Out-Null
} else {
$recoverRemoteHash = (Invoke-Ssh "sha256sum $recoverPath | cut -d' ' -f1").Trim()
$recoverLocalHash = (Get-FileHash $recoverUnit -Algorithm SHA256).Hash.ToLower()
if ($recoverRemoteHash -ne $recoverLocalHash) {
Write-Host "[deploy] dpworld-net-recover.service changed — reinstalling to /lib/systemd/system/ and daemon-reload" -ForegroundColor Yellow
& scp @SshOpts $recoverUnit "${SshTarget}:${recoverPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-net-recover.service failed" }
Invoke-Ssh "rm -f /etc/systemd/system/dpworld-net-recover.service && systemctl daemon-reload" | Out-Null
}
}
# v1.6.0: dpworld-network-apply.service 온디맨드 drop-in 설치
# firmware 소유 apply.service 의 Requires=dpworld-network-seed.service (boot-only oneshot) 을
# 런타임에 빈 값으로 override — 재부팅 후 systemctl start 가 "Dependency failed" rc=1 을 내던
# 결함 해소 (.56 실측, DEF-2b). 드롭인 디렉토리는 /lib/systemd/system/ 하 (persistent overlay —
# /etc 는 tmpfs 로 재부팅 시 소실, v1.5.2 D1 참조).
$ondemandConfPath = "/lib/systemd/system/dpworld-network-apply.service.d/10-ondemand.conf"
$ondemandConfLocal = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-network-apply-ondemand.conf")).Path
$ondemandCheck = (Invoke-Ssh "test -f $ondemandConfPath && echo yes || echo no").Trim()
if ($ondemandCheck -ne "yes") {
Write-Host "[deploy] installing dpworld-network-apply ondemand drop-in to persistent /lib/systemd/system/" -ForegroundColor Yellow
Invoke-Ssh "mkdir -p /lib/systemd/system/dpworld-network-apply.service.d" | Out-Null
& scp @SshOpts $ondemandConfLocal "${SshTarget}:${ondemandConfPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-network-apply-ondemand.conf failed" }
Invoke-Ssh "systemctl daemon-reload" | Out-Null
} else {
$ondemandRemoteHash = (Invoke-Ssh "sha256sum $ondemandConfPath | cut -d' ' -f1").Trim()
$ondemandLocalHash = (Get-FileHash $ondemandConfLocal -Algorithm SHA256).Hash.ToLower()
if ($ondemandRemoteHash -ne $ondemandLocalHash) {
Write-Host "[deploy] dpworld-network-apply ondemand drop-in changed — reinstalling and daemon-reload" -ForegroundColor Yellow
& scp @SshOpts $ondemandConfLocal "${SshTarget}:${ondemandConfPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-network-apply-ondemand.conf failed" }
Invoke-Ssh "systemctl daemon-reload" | Out-Null
}
}
# v1.7.1: firmware boot hardening — dpworld-network-apply-hardened.sh + 2 drop-ins.
# 배경: 펌웨어 dpworld-network-seed.service 가 --boot 에서 modprobe -r wlan 을 호출 →
# QCA6490(cnss_pci) 모듈 hang → Telechips PMU 하드웨어 watchdog(20 s) 리셋 → 부팅 루프.
# 조치: 두 drop-in(20-hardened.conf) 이 seed/apply 양쪽 ExecStart 를 하드닝본으로 교체.
# 하드닝본은 --boot 경로에서 modprobe -r 을 절대 호출하지 않음. country 는 modprobe.d +
# reboot-deferred 로 처리. 펌웨어 원본 /usr/bin/dpworld-network-apply.sh 은 무수정 보존.
# 설치 경로 모두 /lib/systemd/system (persistent overlay — /etc 는 tmpfs, v1.5.2 D1 참조).
$needsDaemonReload = $false
$hardenedScript = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-network-apply-hardened.sh")).Path
$hardenedScriptPath = "/usr/bin/dpworld-network-apply-hardened.sh"
$hardenedScriptCheck = (Invoke-Ssh "test -f $hardenedScriptPath && echo yes || echo no").Trim()
if ($hardenedScriptCheck -ne "yes") {
Write-Host "[deploy] installing dpworld-network-apply-hardened.sh to $hardenedScriptPath" -ForegroundColor Yellow
& scp @SshOpts $hardenedScript "${SshTarget}:${hardenedScriptPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-network-apply-hardened.sh failed" }
Invoke-Ssh "chmod +x $hardenedScriptPath" | Out-Null
$needsDaemonReload = $true
} else {
$hardenedRemoteHash = (Invoke-Ssh "sha256sum $hardenedScriptPath | cut -d' ' -f1").Trim()
$hardenedLocalHash = (Get-FileHash $hardenedScript -Algorithm SHA256).Hash.ToLower()
if ($hardenedRemoteHash -ne $hardenedLocalHash) {
Write-Host "[deploy] dpworld-network-apply-hardened.sh changed — reinstalling" -ForegroundColor Yellow
& scp @SshOpts $hardenedScript "${SshTarget}:${hardenedScriptPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-network-apply-hardened.sh failed" }
Invoke-Ssh "chmod +x $hardenedScriptPath" | Out-Null
$needsDaemonReload = $true
}
}
# drop-in: dpworld-network-apply.service.d/20-hardened.conf
$applyDropinLocal = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-network-apply.service.d\20-hardened.conf")).Path
$applyDropinPath = "/lib/systemd/system/dpworld-network-apply.service.d/20-hardened.conf"
$applyDropinCheck = (Invoke-Ssh "test -f $applyDropinPath && echo yes || echo no").Trim()
if ($applyDropinCheck -ne "yes") {
Write-Host "[deploy] installing dpworld-network-apply drop-in 20-hardened.conf" -ForegroundColor Yellow
Invoke-Ssh "mkdir -p /lib/systemd/system/dpworld-network-apply.service.d" | Out-Null
& scp @SshOpts $applyDropinLocal "${SshTarget}:${applyDropinPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-network-apply 20-hardened.conf failed" }
$needsDaemonReload = $true
} else {
$applyDropinRemoteHash = (Invoke-Ssh "sha256sum $applyDropinPath | cut -d' ' -f1").Trim()
$applyDropinLocalHash = (Get-FileHash $applyDropinLocal -Algorithm SHA256).Hash.ToLower()
if ($applyDropinRemoteHash -ne $applyDropinLocalHash) {
Write-Host "[deploy] dpworld-network-apply 20-hardened.conf changed — reinstalling" -ForegroundColor Yellow
& scp @SshOpts $applyDropinLocal "${SshTarget}:${applyDropinPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-network-apply 20-hardened.conf failed" }
$needsDaemonReload = $true
}
}
# drop-in: dpworld-network-seed.service.d/20-hardened.conf
$seedDropinLocal = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-network-seed.service.d\20-hardened.conf")).Path
$seedDropinPath = "/lib/systemd/system/dpworld-network-seed.service.d/20-hardened.conf"
$seedDropinCheck = (Invoke-Ssh "test -f $seedDropinPath && echo yes || echo no").Trim()
if ($seedDropinCheck -ne "yes") {
Write-Host "[deploy] installing dpworld-network-seed drop-in 20-hardened.conf" -ForegroundColor Yellow
Invoke-Ssh "mkdir -p /lib/systemd/system/dpworld-network-seed.service.d" | Out-Null
& scp @SshOpts $seedDropinLocal "${SshTarget}:${seedDropinPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-network-seed 20-hardened.conf failed" }
$needsDaemonReload = $true
} else {
$seedDropinRemoteHash = (Invoke-Ssh "sha256sum $seedDropinPath | cut -d' ' -f1").Trim()
$seedDropinLocalHash = (Get-FileHash $seedDropinLocal -Algorithm SHA256).Hash.ToLower()
if ($seedDropinRemoteHash -ne $seedDropinLocalHash) {
Write-Host "[deploy] dpworld-network-seed 20-hardened.conf changed — reinstalling" -ForegroundColor Yellow
& scp @SshOpts $seedDropinLocal "${SshTarget}:${seedDropinPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-network-seed 20-hardened.conf failed" }
$needsDaemonReload = $true
}
}
if ($needsDaemonReload) {
Invoke-Ssh "systemctl daemon-reload" | Out-Null
}
# AP: install — begin
# WiFi AP 엔진: applier 스크립트(/usr/bin) + 4 유닛(/lib/systemd/system) 설치.
# seed 만 enable(부팅 reconcile). apply/hostapd/udhcpd 는 enable 안 함 — apply 스크립트가 기동.
# 설치 경로 /lib/systemd/system (persistent overlay — /etc 는 tmpfs, v1.5.2 D1 참조).
$apNeedsReload = $false
# 1) applier 스크립트
$apScript = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\dpworld-ap-apply.sh")).Path
$apScriptPath = "/usr/bin/dpworld-ap-apply.sh"
$apScriptCheck = (Invoke-Ssh "test -f $apScriptPath && echo yes || echo no").Trim()
if ($apScriptCheck -ne "yes") {
Write-Host "[deploy] installing dpworld-ap-apply.sh to $apScriptPath" -ForegroundColor Yellow
& scp @SshOpts $apScript "${SshTarget}:${apScriptPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of dpworld-ap-apply.sh failed" }
Invoke-Ssh "chmod 0755 $apScriptPath" | Out-Null
$apNeedsReload = $true
} else {
$apScriptRemoteHash = (Invoke-Ssh "sha256sum $apScriptPath | cut -d' ' -f1").Trim()
$apScriptLocalHash = (Get-FileHash $apScript -Algorithm SHA256).Hash.ToLower()
if ($apScriptRemoteHash -ne $apScriptLocalHash) {
Write-Host "[deploy] dpworld-ap-apply.sh changed — reinstalling" -ForegroundColor Yellow
& scp @SshOpts $apScript "${SshTarget}:${apScriptPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of updated dpworld-ap-apply.sh failed" }
Invoke-Ssh "chmod 0755 $apScriptPath" | Out-Null
$apNeedsReload = $true
}
}
# 2) systemd 유닛 4종 (영속 /lib/systemd/system, 휘발성 /etc 사본 제거)
$apUnits = @("dpworld-ap-seed.service", "dpworld-ap-apply.service",
"dpworld-hostapd-ap0.service", "dpworld-udhcpd-ap0.service")
foreach ($u in $apUnits) {
$uLocal = (Resolve-Path (Join-Path $PSScriptRoot "..\deploy\$u")).Path
$uPath = "/lib/systemd/system/$u"
$uCheck = (Invoke-Ssh "test -f $uPath && echo yes || echo no").Trim()
if ($uCheck -ne "yes") {
Write-Host "[deploy] installing $u to /lib/systemd/system/" -ForegroundColor Yellow
& scp @SshOpts $uLocal "${SshTarget}:${uPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of $u failed" }
Invoke-Ssh "rm -f /etc/systemd/system/$u" | Out-Null
$apNeedsReload = $true
} else {
$uRemoteHash = (Invoke-Ssh "sha256sum $uPath | cut -d' ' -f1").Trim()
$uLocalHash = (Get-FileHash $uLocal -Algorithm SHA256).Hash.ToLower()
if ($uRemoteHash -ne $uLocalHash) {
Write-Host "[deploy] $u changed — reinstalling" -ForegroundColor Yellow
& scp @SshOpts $uLocal "${SshTarget}:${uPath}"
if ($LASTEXITCODE -ne 0) { throw "scp of updated $u failed" }
Invoke-Ssh "rm -f /etc/systemd/system/$u" | Out-Null
$apNeedsReload = $true
}
}
}
if ($apNeedsReload) { Invoke-Ssh "systemctl daemon-reload" | Out-Null }
# seed 만 enable — 부팅 시 마커 기준 reconcile (apply/데몬 유닛은 apply 스크립트가 기동)
Invoke-Ssh "systemctl enable dpworld-ap-seed.service" | Out-Null
# AP: install — end
Write-Step "Swapping in new src/"
Invoke-Ssh ("cd $AppDir && rm -rf src && mv _deploy_tmp/src src" +
" && rm -rf _deploy_tmp _deploy.tar") | Out-Null
Write-Step "Stamping version $version"
$deployedBy = "$env:USERNAME@$env:COMPUTERNAME"
$now = (Get-Date).ToString('o')
$tagField = if ($tag) { $tag } else { '(untagged)' }
Invoke-Ssh ("printf 'version=%s\ntag=%s\ncommit=%s\ndeployed_at=%s\ndeployed_by=%s\n'" +
" '$version' '$tagField' '$commit' '$now' '$deployedBy' > $AppDir/DEPLOYED_VERSION") | Out-Null
Invoke-Ssh ("printf '%s %s %s %s\n' '$now' '$version' '$short' '$deployedBy'" +
" >> $AppDir/deploy-history.log") | Out-Null
# v1.5.2 D2 (H18): wrap swap + unit install + restart in try/catch for auto-rollback.
# If Confirm-Health throws (service not healthy after restart), restore the backed-up
# src/ from backups/src-$ts and restart — leaving the device in a known-good state.
Write-Step "Restarting $Service"
try {
Invoke-Ssh "systemctl restart $Service" | Out-Null
Confirm-Health
}
catch {
Write-Host "[deploy] FAILED — auto-rolling back to backups/src-$ts" -ForegroundColor Red
try {
Invoke-Ssh "cd $AppDir && rm -rf src && cp -r backups/src-$ts src" | Out-Null
Invoke-Ssh "systemctl restart $Service" | Out-Null
Start-Sleep -Seconds 3
if (Test-ServiceHealthy) {
Write-Host " rollback succeeded — device is back on previous version" -ForegroundColor Yellow
} else {
Write-Host " WARNING: rollback restart may still be starting up" -ForegroundColor Yellow
}
$rollbackBy = "$env:USERNAME@$env:COMPUTERNAME"
$rollbackAt = (Get-Date).ToString('o')
Invoke-Ssh ("printf 'version=rolled-back\nfrom=failed-$version\nrestored_from=backups/src-$ts\nrolled_back_at=$rollbackAt\nrolled_back_by=$rollbackBy\n'" +
" > $AppDir/DEPLOYED_VERSION") | Out-Null
}
catch {
Write-Host "[deploy] CRITICAL: rollback also failed — device may need manual recovery" -ForegroundColor Red
}
throw # re-throw original error so the caller sees failure
}
}
finally {
Remove-Item -Path $localTar -ErrorAction SilentlyContinue
}
Write-Host ""
Write-Host "Deployed $version to $DeviceIp" -ForegroundColor Green
Write-Host " commit: $commit"
Write-Host " verify: ssh $SshTarget cat $AppDir/DEPLOYED_VERSION"