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.
434 lines
24 KiB
434 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 ---------------------------------------------------
|
|
$AppDir = '/opt/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"
|
|
|