<# .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"