정식 승격 상태판과 증거를 동기화
This commit is contained in:
parent
34aff65cb0
commit
be08c0b573
16 changed files with 1160 additions and 63 deletions
|
|
@ -24,6 +24,20 @@ watchdog은 이 증거를 health probe와 failcount 기록보다 먼저 다시
|
|||
API secret은 release root의 apps/api/.env에 두되 task 인자에는 넣지 않는다. 이 파일과 web node_modules,
|
||||
runtime log는 Git ignore 대상이다. Cloudflared와 Claude CLI credential은 현재 Windows 사용자 profile에 둔다.
|
||||
|
||||
사용자 업로드는 release root와 분리한 영속 절대 경로만 사용한다. 권장 기본값은
|
||||
`%LOCALAPPDATA%\Vignette\public-runtime\uploads`다. 이 경로는 같은 Windows 호스트의 release 교체와 재부팅에는
|
||||
유지되지만 호스트 장애를 견디는 외부 백업은 아니다. consumer(start/boot/watchdog/registrar)는 빈 경로를 만들지
|
||||
않는다. 별도 initializer가 현재 DB의 정확한 `/uploads/profile-avatars/` 참조를 copy-only·no-overwrite·SHA-256으로
|
||||
검증해 만든 뒤에만 두 task action에 `-UserUploadDir`로 고정한다. 상대 경로, Git root와 겹치거나 이를 포함하는 경로, 기존
|
||||
symlink/junction/reparse point를 통과하는 경로, 디렉터리가 아니거나 쓸 수 없는 경로는 프로세스 변경 전에
|
||||
fail-closed한다. watchdog `-CheckOnly`는 실행 중 API 프로세스의 `USER_UPLOAD_DIR`까지 비교하므로, health가
|
||||
정상이더라도 값이 없거나 다른 release-local 경로면 실패한다.
|
||||
|
||||
공개 static mount는 `USER_UPLOAD_DIR/profile-avatars` 하나뿐이다. 같은 legacy root의 `multimodal-audio`는 private
|
||||
storage이며 공개 migration 대상도 static 서빙 대상도 아니다. 보존 중인 private audio DB 참조가 하나라도 있으면
|
||||
initializer는 별도 private migration 없이는 중단한다. migration manifest와 write-freeze sentinel은 공개 upload root와
|
||||
Git root 밖의 절대 private state directory에만 둔다.
|
||||
|
||||
## Stable Release 준비
|
||||
|
||||
아래 작업은 승인된 clean commit이 생긴 뒤 단일 public mutation owner가 수행한다. 기존 release root를
|
||||
|
|
@ -34,6 +48,7 @@ runtime log는 Git ignore 대상이다. Cloudflared와 Claude CLI credential은
|
|||
$commit = (& git.exe -C $repoRoot rev-parse --verify HEAD).Trim()
|
||||
if ($LASTEXITCODE -ne 0) { throw 'HEAD 조회 실패' }
|
||||
$releaseRoot = "D:\workspace\vignette-public-runtime-$($commit.Substring(0, 12))"
|
||||
$userUploadDir = Join-Path $env:LOCALAPPDATA 'Vignette\public-runtime\uploads'
|
||||
if (Test-Path -LiteralPath $releaseRoot) { throw "release root already exists: $releaseRoot" }
|
||||
|
||||
& git.exe -C $repoRoot worktree add --detach $releaseRoot $commit
|
||||
|
|
@ -54,6 +69,48 @@ runtime log는 Git ignore 대상이다. Cloudflared와 Claude CLI credential은
|
|||
apps/api/.env의 내용을 console이나 evidence에 출력하지 않는다. 새 root에 node_modules와 .env를 준비한 뒤에도
|
||||
위 Git status 결과는 빈 값이어야 한다.
|
||||
|
||||
## 공개 아바타 저장소 초기화와 fresh cutover
|
||||
|
||||
이 단계가 task 설치보다 먼저다. 기존 API도 `upload_write_freeze` health 계약을 지원해야 한다. initializer는 freeze를
|
||||
`CreateNew`로 게시하고 기존 API의 write lease가 0이 될 때까지 기다린 뒤, caller가 명시한 3개 source root의 flat
|
||||
`profile-avatars` regular file 전체 union을 보존한다. 현재 승인 기준은 preserved 93개와 DB 참조 8개이며, 같은 URL을
|
||||
여러 행이 참조하면 파일은 한 번 복사하고 reference count는 보존한다. 호출자는 사전 계산한 preserved object count와
|
||||
privacy-safe path/content/size inventory SHA256을 함께 고정해야 한다. worker는 copy 전후 재스캔과 manifest v2 proof까지
|
||||
그 pin을 재검증한다. 원본은 삭제·이동하지 않으며 대상 충돌, source 간 hash 충돌, 누락 1건, 경로 인코딩/중첩,
|
||||
active private audio가 있으면 중단한다.
|
||||
|
||||
$userUploadDir = Join-Path $env:LOCALAPPDATA 'Vignette\public-runtime\uploads'
|
||||
$uploadStateDir = Join-Path $env:LOCALAPPDATA 'Vignette\public-runtime\private-state'
|
||||
$uploadFreezePath = Join-Path $uploadStateDir 'avatar-cutover.freeze.json'
|
||||
$legacyUploadRoots = @(
|
||||
'D:\exact-approved-upload-root-1'
|
||||
'D:\exact-approved-upload-root-2'
|
||||
'D:\exact-approved-upload-root-3'
|
||||
)
|
||||
$expectedPreservedInventorySha256 = '<approved lowercase SHA256>'
|
||||
$initializer = Join-Path $releaseRoot 'scripts\initialize-public-runtime-upload-root.ps1'
|
||||
$initJson = & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $initializer `
|
||||
-StableSourceRoot $releaseRoot `
|
||||
-UserUploadDir $userUploadDir `
|
||||
-ManifestStateDir $uploadStateDir `
|
||||
-UserUploadWriteFreezePath $uploadFreezePath `
|
||||
-ExpectedReferenceCount 8 `
|
||||
-ExpectedPreservedObjectCount 93 `
|
||||
-ExpectedPreservedInventorySha256 $expectedPreservedInventorySha256 `
|
||||
-SourceUploadDir $legacyUploadRoots
|
||||
if ($LASTEXITCODE -ne 0) { throw 'avatar storage initialization failed' }
|
||||
$init = $initJson | ConvertFrom-Json
|
||||
$uploadManifestSha = [string]$init.manifest_sha256
|
||||
$uploadManifestPath = Join-Path $uploadStateDir "public-avatar-upload-$uploadManifestSha.json"
|
||||
if ((Get-FileHash -LiteralPath $uploadManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() -ne $uploadManifestSha) {
|
||||
throw 'private migration manifest hash mismatch'
|
||||
}
|
||||
|
||||
initializer 성공 시 freeze는 의도적으로 남는다. 이어지는 `-RequireFreshPublicProvenance` cutover는 old API가
|
||||
active+valid+drained freeze를 증명한 뒤 tunnel을 먼저 닫고 API를 교체한다. frozen 새 API와 새 tunnel의 local/public GET,
|
||||
listener PID/cwd/env, receipt를 검증한 뒤에만 소유 token과 일치하는 sentinel을 지우고 쓰기를 재개한다. 쓰기 재개 전 실패는
|
||||
prior API/tunnel과 write availability를 복원한다. 쓰기 재개 뒤에는 old upload root로 자동 rollback하지 않는다.
|
||||
|
||||
## Task 설치 또는 승격
|
||||
|
||||
두 registrar 자체도 동일 stable release root에서 실행해야 한다. 다른 worktree의 registrar로 target만
|
||||
|
|
@ -61,12 +118,20 @@ apps/api/.env의 내용을 console이나 evidence에 출력하지 않는다. 새
|
|||
|
||||
$bootRegistrar = Join-Path $releaseRoot 'scripts\register-boot-task.ps1'
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $bootRegistrar `
|
||||
-StableSourceRoot $releaseRoot
|
||||
-StableSourceRoot $releaseRoot `
|
||||
-UserUploadDir $userUploadDir `
|
||||
-UserUploadManifestPath $uploadManifestPath `
|
||||
-ExpectedUserUploadManifestSha256 $uploadManifestSha `
|
||||
-UserUploadWriteFreezePath $uploadFreezePath
|
||||
if ($LASTEXITCODE -ne 0) { throw 'boot task 등록 실패' }
|
||||
|
||||
$watchdogInstaller = Join-Path $releaseRoot 'scripts\install-public-runtime-task.ps1'
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $watchdogInstaller `
|
||||
-StableSourceRoot $releaseRoot `
|
||||
-UserUploadDir $userUploadDir `
|
||||
-UserUploadManifestPath $uploadManifestPath `
|
||||
-ExpectedUserUploadManifestSha256 $uploadManifestSha `
|
||||
-UserUploadWriteFreezePath $uploadFreezePath `
|
||||
-IntervalMinutes 5
|
||||
if ($LASTEXITCODE -ne 0) { throw 'watchdog task 등록 실패' }
|
||||
|
||||
|
|
@ -88,14 +153,22 @@ RunNow 전에 action을 읽어 두 task가 같은 release root와 commit을 가
|
|||
'-ExpectedSourceCommit',
|
||||
'-ExpectedSourceTree',
|
||||
'-ExpectedBootScriptSha256',
|
||||
'-ExpectedStartScriptSha256'
|
||||
'-ExpectedStartScriptSha256',
|
||||
'-UserUploadDir',
|
||||
'-UserUploadManifestPath',
|
||||
'-ExpectedUserUploadManifestSha256',
|
||||
'-UserUploadWriteFreezePath'
|
||||
)
|
||||
VignettePublicRuntimeWatchdog = @(
|
||||
'-StableSourceRoot',
|
||||
'-ExpectedSourceCommit',
|
||||
'-ExpectedSourceTree',
|
||||
'-ExpectedWatchdogSha256',
|
||||
'-ExpectedStartScriptSha256'
|
||||
'-ExpectedStartScriptSha256',
|
||||
'-UserUploadDir',
|
||||
'-UserUploadManifestPath',
|
||||
'-ExpectedUserUploadManifestSha256',
|
||||
'-UserUploadWriteFreezePath'
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -111,6 +184,9 @@ RunNow 전에 action을 읽어 두 task가 같은 release root와 commit을 가
|
|||
if ($action.Arguments.IndexOf($commit, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
||||
throw "$taskName commit pin 누락"
|
||||
}
|
||||
if ($action.Arguments.IndexOf($userUploadDir, [StringComparison]::OrdinalIgnoreCase) -lt 0) {
|
||||
throw "$taskName user upload root pin 누락"
|
||||
}
|
||||
foreach ($marker in $requirements[$taskName]) {
|
||||
if ($action.Arguments.IndexOf($marker, [StringComparison]::Ordinal) -lt 0) {
|
||||
throw "$taskName action pin 누락: $marker"
|
||||
|
|
@ -127,6 +203,21 @@ LastTaskResult=0과 stable release root의 public-runtime-watchdog.failcount=0
|
|||
새 source 배포가 완료된 것은 아니다. 공개 API process cwd, Git commit, OpenAPI, auth, voice provider/model,
|
||||
실제 session smoke까지 별도 배포 gate에서 확인한다.
|
||||
|
||||
### PowerShell 5.1 web build 종료코드 경계
|
||||
|
||||
Windows PowerShell 5.1의 `Start-Process -PassThru`가 반환한 `System.Diagnostics.Process`는 process handle을
|
||||
열기 전에 timed `WaitForExit(milliseconds)`를 호출하면 성공한 자식 프로세스도 `ExitCode=$null`로 남을 수 있다.
|
||||
`start-public-runtime.ps1`은 web build 직후 `$null = $build.Handle`로 handle을 먼저 확보하고, bounded wait 뒤
|
||||
`Refresh()`·null guard·nonzero guard 순서로 판정한다. null을 0으로 간주하거나 build를 무조건 재시도하지 않는다.
|
||||
|
||||
계약 검증은 실제 Windows PowerShell 5.1에서 성공 프로세스의 종료코드를 읽는 probe를 포함한다.
|
||||
|
||||
py -3.11 -B -X utf8 -m pytest -p no:cacheprovider scripts\test_start_public_runtime_contract.py -q
|
||||
|
||||
2026-08-29 기준 32 passed이며, detached-clean `44b7835c…`·tree `06133249…`에 boot/watchdog을 같은 핀으로
|
||||
재등록한 뒤 5174 자동복구·HTTP 200, `LastTaskResult=0`, failcount 0을 확인했다. 실제 Windows 재부팅 smoke는
|
||||
별도 운영 gate다.
|
||||
|
||||
## 숨김 수동 Trigger
|
||||
|
||||
watch-public-runtime-hidden.vbs는 source script를 직접 실행하지 않는다. 등록된 watchdog task action이
|
||||
|
|
@ -144,6 +235,39 @@ install-public-runtime-task.ps1은 액션을 `wscript.exe "<root>\scripts\watch-
|
|||
번쩍이고, 5분 주기 watchdog에서는 그것이 곧 "5분마다 화면에 뜨는 콘솔 창"이 된다(2026-08-08/09/12 세 번 재발).
|
||||
런처는 pin 인자를 해석하지 않고 그대로 전달만 하며, provenance 검증은 watch-public-runtime.ps1이 수행한다.
|
||||
|
||||
## Docker Desktop ERROR 1920 stale AF_UNIX socket 복구
|
||||
|
||||
Docker Desktop 백엔드 로그 또는 `%LOCALAPPDATA%\Docker\backend.error.json`에 아래 경로의 `remove ...
|
||||
The file cannot be accessed by the system`(ERROR 1920)이 보이면 `com.docker.service`나 PostgreSQL volume 문제가
|
||||
아니다. 비정상 종료 뒤 남은 0바이트 AF_UNIX reparse socket 때문에 백엔드가 startup crash-loop한 것이다.
|
||||
|
||||
- `%LOCALAPPDATA%\Docker\run\dockerInference`
|
||||
- `%LOCALAPPDATA%\docker-secrets-engine\engine.sock`
|
||||
|
||||
Docker upstream의 [desktop-feedback #531](https://github.com/docker/desktop-feedback/issues/531)과
|
||||
[#536](https://github.com/docker/desktop-feedback/issues/536)에 같은 결함과 workaround가 기록돼 있다. 개별 socket은
|
||||
`Remove-Item`, `fsutil`, 파일 rename으로도 ERROR 1920이 날 수 있으므로 **삭제·factory reset·WSL unregister를 하지
|
||||
않는다.** Docker Desktop과 Docker CLI만 완전히 종료한 뒤 두 부모 디렉터리를 복구 가능한 timestamp 백업명으로
|
||||
옮긴다.
|
||||
|
||||
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$run = 'C:\Users\encep\AppData\Local\Docker\run'
|
||||
$secrets = 'C:\Users\encep\AppData\Local\docker-secrets-engine'
|
||||
if ((Resolve-Path -LiteralPath $run).Path -ne $run) { throw 'run 경로 불일치' }
|
||||
if ((Resolve-Path -LiteralPath $secrets).Path -ne $secrets) { throw 'secrets 경로 불일치' }
|
||||
$runBackup = Join-Path (Split-Path -Parent $run) ("run.stale-$stamp")
|
||||
$secretsBackup = Join-Path (Split-Path -Parent $secrets) ("docker-secrets-engine.stale-$stamp")
|
||||
if (Test-Path -LiteralPath $runBackup) { throw 'run 백업명 충돌' }
|
||||
if (Test-Path -LiteralPath $secretsBackup) { throw 'secrets 백업명 충돌' }
|
||||
Move-Item -LiteralPath $run -Destination $runBackup
|
||||
Move-Item -LiteralPath $secrets -Destination $secretsBackup
|
||||
|
||||
Docker Desktop 일반 사용자 재기동 뒤 `docker version`의 Linux server 응답을 확인한다. DB는
|
||||
`docker inspect vignette-dev-db`로 exact container와 recovered named volume이 존재함을 먼저 확인한 경우에만
|
||||
`docker start vignette-dev-db`를 실행한다. 새 container/volume 생성, 기존 volume 교체, `compose down -v`는 금지다.
|
||||
DB healthy와 55432 listener가 닫힌 뒤에만 아래 stable-root API-only 복구로 이어간다. WSL2 Linux engine에서
|
||||
`AlwaysRunService=false`이면 `com.docker.service`가 stopped인 사실만으로 장애 원인이나 복구 완료를 판정하지 않는다.
|
||||
|
||||
## Manual Source Recovery
|
||||
|
||||
운영 code를 강제로 교체해야 할 때도 shared worktree의 start-public-runtime.ps1을 실행하지 않는다.
|
||||
|
|
@ -152,6 +276,10 @@ install-public-runtime-task.ps1은 액션을 `wscript.exe "<root>\scripts\watch-
|
|||
$startScript = Join-Path $releaseRoot 'scripts\start-public-runtime.ps1'
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startScript `
|
||||
-Workspace $releaseRoot `
|
||||
-UserUploadDir $userUploadDir `
|
||||
-UserUploadManifestPath $uploadManifestPath `
|
||||
-ExpectedUserUploadManifestSha256 $uploadManifestSha `
|
||||
-UserUploadWriteFreezePath $uploadFreezePath `
|
||||
-ForceApiRestart `
|
||||
-SkipEngineRestart `
|
||||
-SkipWebRestart `
|
||||
|
|
@ -178,6 +306,10 @@ commit/tree와 Python/cloudflared/config SHA를 read-only로 고정하고, confi
|
|||
|
||||
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $startScript `
|
||||
-Workspace $releaseRoot `
|
||||
-UserUploadDir $userUploadDir `
|
||||
-UserUploadManifestPath $uploadManifestPath `
|
||||
-ExpectedUserUploadManifestSha256 $uploadManifestSha `
|
||||
-UserUploadWriteFreezePath $uploadFreezePath `
|
||||
-ForceApiRestart `
|
||||
-SkipEngineRestart `
|
||||
-SkipWebRestart `
|
||||
|
|
@ -190,8 +322,10 @@ commit/tree와 Python/cloudflared/config SHA를 read-only로 고정하고, confi
|
|||
-RuntimeProvenancePath $receipt
|
||||
if ($LASTEXITCODE -ne 0) { throw 'fresh public provenance 승격 실패' }
|
||||
|
||||
receipt에는 raw command line·config contents를 넣지 않고 PID/start/executable·command SHA/실제 cwd와 topology 입력만
|
||||
남긴다. 이 receipt의 PID와 pin을 `run-g7-external-proof-window.py --topology-mode windows-host`에 그대로 전달하고,
|
||||
receipt에는 raw command line·config contents를 넣지 않고 PID/start/executable·command SHA/실제 cwd,
|
||||
secret이 아닌 resolved `user_upload_root`, migration manifest SHA, 초기/current reference count와 privacy-safe digest,
|
||||
write-freeze path hash, topology 입력만 남긴다. 이 receipt의 PID와 pin을
|
||||
`run-g7-external-proof-window.py --topology-mode windows-host`에 그대로 전달하고,
|
||||
공개 health·auth·OpenAPI·local provider ready를 확인하기 전에는 task action을 새 root로 재등록하지 않는다.
|
||||
|
||||
## Read-only CheckOnly
|
||||
|
|
@ -211,6 +345,7 @@ watchdog script를 직접 CheckOnly로 실행할 때도 task와 같은 pin을
|
|||
-ExpectedSourceTree $tree `
|
||||
-ExpectedWatchdogSha256 $watchSha `
|
||||
-ExpectedStartScriptSha256 $startSha `
|
||||
-UserUploadDir $userUploadDir `
|
||||
-CheckOnly
|
||||
|
||||
추가 public host는 DNS와 routing이 실제로 열린 뒤 installer의
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue