From 70d07bd9e858e3085c0683b6e18571af3a4abbde Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 14 Aug 2026 02:50:40 +1000 Subject: [PATCH 1/4] Make Box replacement recoverable --- ...romote-box-replacements-transactionally.md | 64 ++++++++ install.ps1 | 144 ++++++++++++++--- install.sh | 150 +++++++++++++++--- tests/test-lifecycle-install-state.sh | 77 +++++++-- tests/test-lifecycle-powershell.ps1 | 6 + 5 files changed, 390 insertions(+), 51 deletions(-) create mode 100644 docs/adr/0008-promote-box-replacements-transactionally.md diff --git a/docs/adr/0008-promote-box-replacements-transactionally.md b/docs/adr/0008-promote-box-replacements-transactionally.md new file mode 100644 index 0000000..f20fd1a --- /dev/null +++ b/docs/adr/0008-promote-box-replacements-transactionally.md @@ -0,0 +1,64 @@ +# ADR 0008: Promote Box replacements transactionally + +Status: Accepted + +## Context + +Rebuild and reset previously removed the canonical Box before its replacement +was created. They also published Install identity before later configuration or +requested provisioning could fail. A handled late failure could therefore leave +Workspace and Managed home intact but no runnable Box, with checkout source, +image alias, configuration, and state describing different attempts. + +## Decision + +Lifecycle adapters use a prepare/promote transaction for Docker and Podman. + +During prepare they resolve and check out the Candidate source, acquire its +image, update installer-owned configuration, create the replacement under a +bounded `-candidate-` name, and complete requested provisioning. +The canonical prior Box remains present throughout prepare. Workspace and the +Managed-home volume may be mounted and used, but rollback never copies, deletes, +or reconstructs either data tier. + +Promotion is the commit protocol: + +1. rename the canonical Box to `-rollback-`; +2. rename the validated Candidate to the canonical name; +3. atomically replace `install-state` with the validated Candidate identity; +4. disarm rollback and remove the prior Box. + +Before step 3, a handled error restores the prior Box name, source revision, +image alias, host shell integration, and prior state. Candidate resources are +then removed only after their Install-identity label is verified. Cleanup errors +are warnings and never replace the original failure status or diagnostic. If a +runtime rename cannot be completed, the adapter prints the exact candidate, +rollback, and canonical names needed for recovery. + +Installer-owned configuration is prepared before promotion so invalid source or +profile content cannot replace a working Box. User-modified configuration is +still preserved by the existing blob trackers. Selection and Managed-home +contents are deliberately outside rollback: provisioning may have made useful +forward changes there, and treating user data as disposable transaction state +would be more dangerous than retaining it. + +`SQUAREBOX_FAIL_AT` is a test-only deterministic fault hook at the checkout, +image-alias, Managed-home creation, managed-config, Candidate creation, +provisioning, prior-Box rename, Candidate promotion, and state-publication +boundaries. Both native adapters implement the same named boundaries. + +## Crash consistency + +Handled rollback is not crash atomicity. A kill, host reboot, or runtime failure +between the two renames and state publication can leave candidate or rollback +names behind. The bounded names and unchanged Install identity make that state +inspectable without guessing, and adapters refuse to overwrite stale transaction +names. Automated discovery and resume/rollback of interrupted operations is a +separate recovery protocol rather than an unsafe inference in this transaction. + +## Consequences + +Rebuild temporarily consumes space for two Box metadata/layers, while Workspace +and Managed home remain shared. Successful promotion has only the runtime's +short rename interval without a canonical name. Unsupported or failed rename +does not delete the prior Box and produces an actionable recovery path. diff --git a/install.ps1 b/install.ps1 index 3be6430..4c8e95e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -34,6 +34,17 @@ $script:ContainerCreated = $false $script:ImageAliasMutated = $false $script:PriorImageAliasId = '' $script:NewImageAliasId = '' +$script:PriorSourceCommit = '' +$script:CandidateName = '' +$script:RollbackName = '' +$script:OldContainerRenamed = $false +$script:CandidatePromoted = $false +$script:StatePublished = $false +$script:ProfileBackup = '' +$script:ProfilePath = '' +$script:ProfileExisted = $false +$script:ManagedBackupDir = '' +$script:ManagedBackups = @() function Invoke-InstallRollback { if (-not $script:RollbackArmed -or $script:RollbackInProgress) { return } @@ -41,12 +52,25 @@ function Invoke-InstallRollback { $script:RollbackArmed = $false try { if ($script:RuntimeReady) { + if ($script:CandidatePromoted) { + & $Runtime rename $ContainerName $script:CandidateName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { $script:CandidatePromoted = $false } + else { Write-Warning "Rollback could not rename '$ContainerName' to '$($script:CandidateName)'." } + } + if ($script:OldContainerRenamed) { + & $Runtime rename $script:RollbackName $ContainerName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { $script:OldContainerRenamed = $false } + else { + Write-Warning "Rollback could not restore '$($script:RollbackName)' to '$ContainerName'." + Write-Warning "Recovery: inspect '$($script:CandidateName)' and '$($script:RollbackName)', then restore the rollback Box to '$ContainerName'." + } + } if ($script:ContainerCreated) { - & $Runtime container inspect $ContainerName 2>$null | Out-Null + & $Runtime container inspect $script:CandidateName 2>$null | Out-Null if ($LASTEXITCODE -eq 0) { - $owner = (& $Runtime inspect -f '{{ index .Config.Labels "io.squarebox.install-id" }}' $ContainerName 2>$null) + $owner = (& $Runtime inspect -f '{{ index .Config.Labels "io.squarebox.install-id" }}' $script:CandidateName 2>$null) if ($LASTEXITCODE -eq 0 -and $owner -and $owner.Trim() -ceq $InstallId) { - & $Runtime rm -f $ContainerName 2>$null | Out-Null + & $Runtime rm -f $script:CandidateName 2>$null | Out-Null } } } @@ -73,6 +97,26 @@ function Invoke-InstallRollback { } catch { Write-Warning "Install rollback could not clean every runtime resource: $($_.Exception.Message)" } + try { + if (-not $script:StatePublished -and $script:PriorSourceCommit -and -not $script:CheckoutCreated) { + & git -C $InstallDir checkout --detach $script:PriorSourceCommit 2>$null | Out-Null + & git -C $InstallDir reset --hard $script:PriorSourceCommit 2>$null | Out-Null + } + if (-not $script:StatePublished -and $script:ProfilePath) { + if ($script:ProfileExisted) { [IO.File]::Copy($script:ProfileBackup, $script:ProfilePath, $true) } + elseif (Test-Path -LiteralPath $script:ProfilePath) { Remove-Item -Force -LiteralPath $script:ProfilePath } + } + if (-not $script:StatePublished -and $script:ManagedBackupDir) { + foreach ($backup in $script:ManagedBackups) { + if ($backup.Existed) { + [IO.Directory]::CreateDirectory((Split-Path $backup.Path)) | Out-Null + [IO.File]::Copy($backup.Backup, $backup.Path, $true) + } elseif (Test-Path -LiteralPath $backup.Path -PathType Leaf) { + Remove-Item -Force -LiteralPath $backup.Path + } + } + } + } catch { Write-Warning "Install rollback could not restore source or profile: $($_.Exception.Message)" } try { if ($script:CheckoutCreated -and $InstallDir -and $StateFile -and -not (Test-Path -LiteralPath $StateFile)) { Remove-Item -Recurse -Force -LiteralPath $InstallDir -ErrorAction Stop @@ -87,6 +131,9 @@ function Abort([string]$Message) { Write-Host "Error: $Message" -ForegroundColor Red exit 1 } +function Invoke-FailureInjection([string]$Boundary) { + if ($env:SQUAREBOX_FAIL_AT -ceq $Boundary) { throw "Injected lifecycle failure at '$Boundary'." } +} trap { $failure = $_.Exception.Message Invoke-InstallRollback @@ -289,6 +336,10 @@ if (Test-Path $InstallDir) { $origin = (& git -C $InstallDir remote get-url origin 2>$null) if ($LASTEXITCODE -ne 0 -or -not (Test-Origin $origin)) { Abort "Unexpected checkout origin '$origin'; refusing reset." } if (-not $State -and -not $Adopt) { Abort 'Existing checkout has no Install identity; verify it, then use -Adopt.' } + $priorCommit = (& git -C $InstallDir rev-parse HEAD 2>$null) + if ($LASTEXITCODE -eq 0 -and $priorCommit -and $priorCommit.Trim() -cmatch '^[0-9a-f]{40}$') { + $script:PriorSourceCommit = $priorCommit.Trim() + } Write-Host 'Updating managed checkout...' & git -C $InstallDir fetch --force origin '+refs/heads/main:refs/remotes/origin/main' '+refs/tags/*:refs/tags/*' if ($LASTEXITCODE -ne 0) { Abort 'git fetch failed.' } @@ -351,6 +402,7 @@ if ($Edge) { } $SourceCommit = (& git -C $InstallDir rev-parse HEAD).Trim() if ($Manifest -and $SourceCommit -cne $Manifest.source_sha) { Abort 'Checked-out source does not match release.json.' } +Invoke-FailureInjection 'checkout' if (-not $Runtime) { if ($env:SQUAREBOX_RUNTIME) { $Runtime = $env:SQUAREBOX_RUNTIME } @@ -440,6 +492,7 @@ if ($Build) { $ImageId = (& $Runtime image inspect -f '{{.Id}}' $ImageAlias).Trim() if ($LASTEXITCODE -ne 0) { Abort 'Unable to inspect the Candidate image.' } $script:NewImageAliasId = $ImageId +Invoke-FailureInjection 'image-alias' $repoDigestOutput = @() if (-not $Build) { $repoDigestOutput = @(& $Runtime image inspect -f '{{range .RepoDigests}}{{println .}}{{end}}' $ImageRef 2>$null) @@ -481,14 +534,38 @@ if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -ne 0) { Abort "Unable to create Managed home '$HomeVolume'." } $script:VolumeCreated = $true $HomeVolumeAdopted = $false + Invoke-FailureInjection 'managed-home-create' } & $Runtime container inspect $ContainerName 2>$null | Out-Null -if ($LASTEXITCODE -eq 0) { +$HadContainer = $LASTEXITCODE -eq 0 +if ($HadContainer) { $owner = Get-ResourceOwner container $ContainerName if ($owner -cne $InstallId -and -not (-not $owner -and $Adopt)) { Abort "Box '$ContainerName' is not owned by this Install identity." } - & $Runtime rm -f $ContainerName | Out-Null - if ($LASTEXITCODE -ne 0) { Abort "Unable to replace managed Box '$ContainerName'." } +} +$suffix = ($InstallId -replace '[^A-Za-z0-9_.-]', '-').Substring(0, [Math]::Min(12, $InstallId.Length)) +$prefix = $ContainerName.Substring(0, [Math]::Min(96, $ContainerName.Length)) +$script:CandidateName = "$prefix-candidate-$suffix" +$script:RollbackName = "$prefix-rollback-$suffix" +foreach ($transactionName in @($script:CandidateName, $script:RollbackName)) { + & $Runtime container inspect $transactionName 2>$null | Out-Null + if ($LASTEXITCODE -eq 0) { Abort "Lifecycle transaction Box '$transactionName' already exists; inspect and remove or rename it before retrying." } +} +$script:ManagedBackupDir = Join-Path ([IO.Path]::GetTempPath()) "squarebox-managed-$([guid]::NewGuid().ToString('N'))" +[IO.Directory]::CreateDirectory($script:ManagedBackupDir) | Out-Null +$managedPaths = @( + (Join-Path $GitConfigDir 'config'), + (Join-Path $InstallDir '.config\starship.toml'), + (Join-Path $InstallDir '.config\lazygit\config.yml'), + (Join-Path $InstallDir '.squarebox\managed-config\starship.toml.blob'), + (Join-Path $InstallDir '.squarebox\managed-config\lazygit-config.yml.blob') +) +for ($index = 0; $index -lt $managedPaths.Count; $index++) { + $path = $managedPaths[$index] + $existed = Test-Path -LiteralPath $path -PathType Leaf + $backup = Join-Path $script:ManagedBackupDir ([string]$index) + if ($existed) { [IO.File]::Copy($path, $backup, $true) } + $script:ManagedBackups += [pscustomobject]@{ Path = $path; Backup = $backup; Existed = $existed } } # Copy only Git identity values into private install state; never mount the @@ -640,6 +717,7 @@ try { # Blob identity of the v1.0 generated default after repository EOL rules. $LegacyLazygitBlobs = if ($State -or $Adopt) { @($LegacyLazygitBlob) } else { @() } Update-ManagedFile $LazygitDefault $LazygitConfig (Join-Path $ManagedConfigDir 'lazygit-config.yml.blob') $LegacyLazygitBlobs + Invoke-FailureInjection 'managed-config' } finally { if (Test-Path -LiteralPath $LazygitDefault) { Remove-Item -Force -LiteralPath $LazygitDefault } } @@ -709,12 +787,19 @@ $RuntimeVolumes = @( $SshDir = Join-Path $UserHome '.ssh' if (Test-Path $SshDir) { $RuntimeVolumes += @('-v', "${SshDir}:/home/dev/.ssh$ReadOnlyBindSuffix") } -Write-Host 'Creating managed Box...' -& $Runtime create -it --name $ContainerName @RuntimeOptions @RuntimeVolumes $ImageAlias | Out-Null -if ($LASTEXITCODE -ne 0) { Abort "Unable to create managed Box '$ContainerName'." } +Write-Host 'Creating Candidate Box...' +& $Runtime create -it --name $script:CandidateName @RuntimeOptions @RuntimeVolumes $ImageAlias | Out-Null +if ($LASTEXITCODE -ne 0) { Abort "Unable to create Candidate Box '$($script:CandidateName)'." } $script:ContainerCreated = $true +Invoke-FailureInjection 'candidate-create' $ProfilePath = $PROFILE.CurrentUserAllHosts +$script:ProfilePath = $ProfilePath +$script:ProfileExisted = Test-Path -LiteralPath $ProfilePath -PathType Leaf +if ($script:ProfileExisted) { + $script:ProfileBackup = Join-Path ([IO.Path]::GetTempPath()) "squarebox-profile-$([guid]::NewGuid().ToString('N'))" + [IO.File]::Copy($ProfilePath, $script:ProfileBackup, $true) +} $ShellInit = $ProfilePath $stateValues = @( $InstallDir, $WorkspaceDir, $GitConfigDir, $HomeVolume, $ContainerName, @@ -743,10 +828,9 @@ try { if ($LASTEXITCODE -ne 0) { Abort 'Unable to secure the Install identity state.' } } [void](Read-InstallState $StateTemp $InstallDir) - [IO.File]::Move($StateTemp, $StateFile, $true) - $script:RollbackArmed = $false -} finally { +} catch { if (Test-Path -LiteralPath $StateTemp) { Remove-Item -Force -LiteralPath $StateTemp } + throw } $ProfileDir = Split-Path $ProfilePath @@ -857,18 +941,40 @@ Add-SquareboxProfileBlock $ProfilePath $profileBlock Write-Host "Installed shell integration -> $ProfilePath" if ($SeedSections.Count -gt 0) { - Write-Host "Provisioning requested Selection on the retained Box ($($SeedSections -join ', '))..." - & $Runtime start $ContainerName | Out-Null - if ($LASTEXITCODE -ne 0) { Abort 'Unable to start the retained Box for provisioning.' } - & $Runtime exec -u dev -e HOME=/home/dev $ContainerName /usr/local/lib/squarebox/setup.sh --rerun @SeedSections + Write-Host "Provisioning requested Selection on the Candidate Box ($($SeedSections -join ', '))..." + & $Runtime start $script:CandidateName | Out-Null + if ($LASTEXITCODE -ne 0) { Abort 'Unable to start the Candidate Box for provisioning.' } + & $Runtime exec -u dev -e HOME=/home/dev $script:CandidateName /usr/local/lib/squarebox/setup.sh --rerun @SeedSections $provisionExit = $LASTEXITCODE - & $Runtime stop $ContainerName | Out-Null + & $Runtime stop $script:CandidateName | Out-Null if ($provisionExit -ne 0) { - foreach ($path in $SeededFiles) { Remove-Item -Force -LiteralPath $path -ErrorAction SilentlyContinue } - Abort 'Requested provisioning failed; the retained Box was not discarded.' + Abort 'Requested provisioning failed; the prior Box remains available.' } + Invoke-FailureInjection 'provision' } +if ($HadContainer) { + Write-Host 'Promoting Candidate Box...' + & $Runtime rename $ContainerName $script:RollbackName + if ($LASTEXITCODE -ne 0) { Abort 'Unable to preserve the prior Box for rollback.' } + $script:OldContainerRenamed = $true + Invoke-FailureInjection 'old-box-preserved' +} +& $Runtime rename $script:CandidateName $ContainerName +if ($LASTEXITCODE -ne 0) { Abort 'Unable to promote the Candidate Box.' } +$script:CandidatePromoted = $true +Invoke-FailureInjection 'candidate-promoted' +Invoke-FailureInjection 'state-publish' +[IO.File]::Move($StateTemp, $StateFile, $true) +$script:StatePublished = $true +$script:RollbackArmed = $false +if ($HadContainer) { + & $Runtime rm -f $script:RollbackName | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Warning "Committed successfully; remove stale prior Box with: $Runtime rm -f '$($script:RollbackName)'" } +} +if ($script:ProfileBackup -and (Test-Path -LiteralPath $script:ProfileBackup)) { Remove-Item -Force -LiteralPath $script:ProfileBackup } +if ($script:ManagedBackupDir -and (Test-Path -LiteralPath $script:ManagedBackupDir)) { Remove-Item -Recurse -Force -LiteralPath $script:ManagedBackupDir } + Write-Host "Install identity recorded at $StateFile" if ([Console]::IsInputRedirected) { Write-Host "Install complete. Start a new PowerShell session, then run 'squarebox'." diff --git a/install.sh b/install.sh index 38d8138..f3da72e 100755 --- a/install.sh +++ b/install.sh @@ -300,6 +300,12 @@ rt_interactive() { MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' winpty "$RUNTIME" "$@" else "$RUNTIME" "$@"; fi } +fail_at() { + [ "${SQUAREBOX_FAIL_AT:-}" != "$1" ] || { + echo "Error: injected lifecycle failure at '$1'." >&2 + return 97 + } +} canonical_origin() { case "$1" in https://github.com/SquareWaveSystems/squarebox|https://github.com/SquareWaveSystems/squarebox.git|git@github.com:SquareWaveSystems/squarebox.git|ssh://git@github.com/SquareWaveSystems/squarebox.git) return 0 ;; @@ -311,14 +317,25 @@ GIT_QUIET=(--quiet); [ "$VERBOSE" = 1 ] && GIT_QUIET=() _release_json=""; _log=""; _create_log=""; _rc_tmp=""; _lazygit_default=""; _shell_init_tmp="" CHECKOUT_CREATED=0; RUNTIME_READY=0; VOLUME_CREATED=0; CONTAINER_CREATED=0 IMAGE_ALIAS_MUTATED=0; PRIOR_IMAGE_ALIAS_ID=""; STATE_WRITTEN=0 +PRIOR_SOURCE_COMMIT=""; HAD_CONTAINER=0; OLD_CONTAINER_RENAMED=0; CANDIDATE_PROMOTED=0 +CANDIDATE_NAME=""; ROLLBACK_NAME=""; HOST_BACKUP_DIR=""; MANAGED_BACKUP_DIR="" +MANAGED_PATHS=(); MANAGED_EXISTED=() cleanup_install() { - local rc=$? owner="" current_id="" + local rc=$? owner="" current_id="" rollback_failed=0 trap - EXIT rm -f "$_release_json" "$_log" "$_create_log" "$_rc_tmp" "$_lazygit_default" "$_shell_init_tmp" 2>/dev/null || true if [ "$rc" -ne 0 ] && [ "$STATE_WRITTEN" != 1 ] && [ "$RUNTIME_READY" = 1 ]; then - if [ "$CONTAINER_CREATED" = 1 ] && rt_cmd container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then - owner="$(rt_cmd inspect -f '{{ index .Config.Labels "io.squarebox.install-id" }}' "$CONTAINER_NAME" 2>/dev/null || true)" - [ "$owner" = "$INSTALL_ID" ] && rt_cmd rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [ "$CANDIDATE_PROMOTED" = 1 ]; then + rt_cmd rename "$CONTAINER_NAME" "$CANDIDATE_NAME" >/dev/null 2>&1 || rollback_failed=1 + CANDIDATE_PROMOTED=0 + fi + if [ "$OLD_CONTAINER_RENAMED" = 1 ]; then + rt_cmd rename "$ROLLBACK_NAME" "$CONTAINER_NAME" >/dev/null 2>&1 || rollback_failed=1 + OLD_CONTAINER_RENAMED=0 + fi + if [ "$CONTAINER_CREATED" = 1 ] && [ -n "$CANDIDATE_NAME" ] && rt_cmd container inspect "$CANDIDATE_NAME" >/dev/null 2>&1; then + owner="$(rt_cmd inspect -f '{{ index .Config.Labels "io.squarebox.install-id" }}' "$CANDIDATE_NAME" 2>/dev/null || true)" + [ "$owner" = "$INSTALL_ID" ] && rt_cmd rm -f "$CANDIDATE_NAME" >/dev/null 2>&1 || rollback_failed=1 fi if [ "$VOLUME_CREATED" = 1 ] && rt_cmd volume inspect "$HOME_VOLUME" >/dev/null 2>&1; then owner="$(rt_cmd volume inspect -f '{{ index .Labels "io.squarebox.install-id" }}' "$HOME_VOLUME" 2>/dev/null || true)" @@ -334,9 +351,39 @@ cleanup_install() { fi fi fi + if [ "$rc" -ne 0 ] && [ "$STATE_WRITTEN" != 1 ] && [ -n "$PRIOR_SOURCE_COMMIT" ] && [ "$CHECKOUT_CREATED" != 1 ]; then + git -C "$INSTALL_DIR" checkout --detach "${GIT_QUIET[@]}" "$PRIOR_SOURCE_COMMIT" >/dev/null 2>&1 || rollback_failed=1 + git -C "$INSTALL_DIR" reset --hard "${GIT_QUIET[@]}" "$PRIOR_SOURCE_COMMIT" >/dev/null 2>&1 || rollback_failed=1 + fi + if [ "$rc" -ne 0 ] && [ "$STATE_WRITTEN" != 1 ] && [ -n "$HOST_BACKUP_DIR" ]; then + for _host_file in .bashrc .zshrc .bash_profile .squarebox-shell-init; do + if [ -f "$HOST_BACKUP_DIR/$_host_file" ]; then + cp -p -- "$HOST_BACKUP_DIR/$_host_file" "$HOME/$_host_file" 2>/dev/null || rollback_failed=1 + else + rm -f -- "$HOME/$_host_file" 2>/dev/null || rollback_failed=1 + fi + done + fi + if [ "$rc" -ne 0 ] && [ "$STATE_WRITTEN" != 1 ] && [ -n "$MANAGED_BACKUP_DIR" ]; then + for _managed_index in "${!MANAGED_PATHS[@]}"; do + _managed_path="${MANAGED_PATHS[$_managed_index]}" + if [ "${MANAGED_EXISTED[$_managed_index]}" = 1 ]; then + mkdir -p -- "$(dirname "$_managed_path")" + cp -p -- "$MANAGED_BACKUP_DIR/$_managed_index" "$_managed_path" 2>/dev/null || rollback_failed=1 + else + rm -f -- "$_managed_path" 2>/dev/null || rollback_failed=1 + fi + done + fi if [ "$rc" -ne 0 ] && [ "$STATE_WRITTEN" != 1 ] && [ "$CHECKOUT_CREATED" = 1 ] && [ ! -e "$STATE_FILE" ]; then rm -rf -- "$INSTALL_DIR" 2>/dev/null || true fi + if [ "$rollback_failed" = 1 ]; then + echo "Warning: rollback cleanup was incomplete; original failure status $rc is preserved." >&2 + echo "Recovery: inspect '$CANDIDATE_NAME' and '$ROLLBACK_NAME', then restore '$ROLLBACK_NAME' to '$CONTAINER_NAME'." >&2 + fi + [ -z "$HOST_BACKUP_DIR" ] || rm -rf -- "$HOST_BACKUP_DIR" 2>/dev/null || true + [ -z "$MANAGED_BACKUP_DIR" ] || rm -rf -- "$MANAGED_BACKUP_DIR" 2>/dev/null || true exit "$rc" } trap cleanup_install EXIT @@ -347,6 +394,8 @@ if [ -e "$INSTALL_DIR" ]; then if [ "$HAD_STATE" = 0 ] && [ "$ADOPT" != 1 ]; then echo "Error: existing checkout has no Install identity; verify it, then re-run with --adopt." >&2; exit 1 fi + PRIOR_SOURCE_COMMIT="$(git -C "$INSTALL_DIR" rev-parse HEAD 2>/dev/null || true)" + [[ "$PRIOR_SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]] || PRIOR_SOURCE_COMMIT="" echo "Updating managed checkout..." git -C "$INSTALL_DIR" fetch "${GIT_QUIET[@]}" --force origin '+refs/heads/main:refs/remotes/origin/main' '+refs/tags/*:refs/tags/*' else @@ -457,6 +506,7 @@ else fi SOURCE_REF="${MANIFEST_SOURCE_REF:-$RELEASE_TAG}" fi +fail_at checkout # Runtime selection consumes the recorded adapter on rebuild. if [ -n "${SQUAREBOX_RUNTIME:-}" ]; then RUNTIME="$SQUAREBOX_RUNTIME" @@ -559,6 +609,7 @@ else rt_cmd tag "$IMAGE_REF" "$IMAGE_ALIAS" IMAGE_ALIAS_MUTATED=1 fi +fail_at image-alias IMAGE_ID="$(rt_cmd image inspect -f '{{.Id}}' "$IMAGE_ALIAS")" IMAGE_DIGEST="" if [ "$BUILD" = 0 ] && [ "$LEGACY_RELEASE" = 0 ]; then @@ -592,6 +643,7 @@ else rt_cmd volume create --label "$MANAGED_LABEL=true" --label "$IDENTITY_LABEL=$INSTALL_ID" "$HOME_VOLUME" >/dev/null VOLUME_CREATED=1 HOME_VOLUME_ADOPTED=0 + fail_at managed-home-create fi if rt_cmd container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then @@ -599,9 +651,38 @@ if rt_cmd container inspect "$CONTAINER_NAME" >/dev/null 2>&1; then if [ "$_c_owner" != "$INSTALL_ID" ] && ! { [ -z "$_c_owner" ] && [ "$ADOPT" = 1 ]; }; then echo "Error: Box '$CONTAINER_NAME' is not owned by this Install identity." >&2; exit 1 fi - echo "Replacing managed Box..."; rt_cmd rm -f "$CONTAINER_NAME" >/dev/null + HAD_CONTAINER=1 fi +_transaction_suffix="${INSTALL_ID//[^A-Za-z0-9_.-]/-}" +_transaction_suffix="${_transaction_suffix:0:12}" +_candidate_prefix="${CONTAINER_NAME:0:96}" +CANDIDATE_NAME="${_candidate_prefix}-candidate-${_transaction_suffix}" +ROLLBACK_NAME="${_candidate_prefix}-rollback-${_transaction_suffix}" +for _transaction_name in "$CANDIDATE_NAME" "$ROLLBACK_NAME"; do + if rt_cmd container inspect "$_transaction_name" >/dev/null 2>&1; then + echo "Error: lifecycle transaction name already exists: '$_transaction_name'." >&2 + echo " Inspect and remove or rename that stale Box before retrying." >&2 + exit 1 + fi +done + +MANAGED_BACKUP_DIR="$(mktemp -d)" +MANAGED_PATHS=( + "$GIT_CONFIG_DIR/config" + "$INSTALL_DIR/.config/starship.toml" + "$INSTALL_DIR/.config/lazygit/config.yml" + "$INSTALL_DIR/.squarebox/managed-config/starship.toml.blob" + "$INSTALL_DIR/.squarebox/managed-config/lazygit-config.yml.blob" +) +for _managed_index in "${!MANAGED_PATHS[@]}"; do + if [ -f "${MANAGED_PATHS[$_managed_index]}" ]; then + MANAGED_EXISTED[$_managed_index]=1 + cp -p -- "${MANAGED_PATHS[$_managed_index]}" "$MANAGED_BACKUP_DIR/$_managed_index" + else MANAGED_EXISTED[$_managed_index]=0 + fi +done + # Host Git configuration is never mounted. Copy only identity values into a # private, install-owned config directory. Every managed path segment is # checked before mkdir so an untracked symlink cannot redirect writes. @@ -730,6 +811,7 @@ extract_lazygit_default "$INSTALL_DIR/install.sh" "$_lazygit_default" if [ "$HAD_STATE" = 1 ] || [ "$ADOPT" = 1 ]; then _legacy_lazygit_blob="$LEGACY_LAZYGIT_BLOB"; fi update_managed_file "$_lazygit_default" "$INSTALL_DIR/.config/lazygit/config.yml" \ "$INSTALL_DIR/.squarebox/managed-config/lazygit-config.yml.blob" "$_legacy_lazygit_blob" +fail_at managed-config [ -f "$INSTALL_DIR/dotfiles/bashrc" ] || { echo "Error: selected source lacks dotfiles/bashrc." >&2; exit 1; } _seed_dir="$WORKSPACE_DIR/.squarebox"; _seed_sections=(); _seeded_files=() @@ -802,17 +884,22 @@ elif [ -d "$USER_HOME/.ssh" ]; then RT_VOLUMES+=(-v "$(bind_spec "$USER_HOME/.ssh" /home/dev/.ssh "$ro_bind_mode")") fi -echo "Creating managed Box..." +echo "Creating Candidate Box..." _create_log="$(mktemp)" -if ! rt_cmd create -it --name "$CONTAINER_NAME" "${RT_OPTS[@]}" "${RT_VOLUMES[@]}" "$IMAGE_ALIAS" >"$_create_log" 2>&1; then +if ! rt_cmd create -it --name "$CANDIDATE_NAME" "${RT_OPTS[@]}" "${RT_VOLUMES[@]}" "$IMAGE_ALIAS" >"$_create_log" 2>&1; then cat "$_create_log" >&2; exit 1 fi CONTAINER_CREATED=1 +fail_at candidate-create if [ -n "${MSYSTEM:-}" ]; then SHELL_RC="$HOME/.bashrc" else case "${SHELL:-}" in */zsh) SHELL_RC="$HOME/.zshrc" ;; *) SHELL_RC="$HOME/.bashrc" ;; esac fi SHELL_INIT="$HOME/.squarebox-shell-init" +HOST_BACKUP_DIR="$(mktemp -d)" +for _host_file in .bashrc .zshrc .bash_profile .squarebox-shell-init; do + [ ! -f "$HOME/$_host_file" ] || cp -p -- "$HOME/$_host_file" "$HOST_BACKUP_DIR/$_host_file" +done write_state() { local tmp value @@ -834,9 +921,6 @@ write_state() { if ! load_state "$tmp"; then rm -f -- "$tmp"; return 1; fi mv -f -- "$tmp" "$STATE_FILE" } -write_state -STATE_WRITTEN=1 - assert_regular_host_file() { local file="$1" description="$2" [ ! -L "$file" ] || { echo "Error: $description must not be a symlink: $file" >&2; return 1; } @@ -985,17 +1069,41 @@ case "$SHELL_RC" in *) bash -n "$SHELL_RC" ;; esac -# Requested provisioning must mutate the retained Box layer. A failed request -# is reported as an install failure and the Box remains available to inspect. -if [ ${#_seed_sections[@]} -gt 0 ]; then - echo "Provisioning requested Selection on the retained Box (${_seed_sections[*]})..." - rt_cmd start "$CONTAINER_NAME" >/dev/null - if ! rt_cmd exec -u dev -e HOME=/home/dev "$CONTAINER_NAME" /usr/local/lib/squarebox/setup.sh --rerun "${_seed_sections[@]}"; then - rt_cmd stop "$CONTAINER_NAME" >/dev/null 2>&1 || true - [ ${#_seeded_files[@]} -eq 0 ] || rm -f -- "${_seeded_files[@]}" - echo "Error: requested provisioning failed; the retained Box was not discarded." >&2; exit 1 - fi - rt_cmd stop "$CONTAINER_NAME" >/dev/null + # Provision the Candidate before it can replace the prior canonical Box. + if [ ${#_seed_sections[@]} -gt 0 ]; then + echo "Provisioning requested Selection on the Candidate Box (${_seed_sections[*]})..." + rt_cmd start "$CANDIDATE_NAME" >/dev/null + if ! rt_cmd exec -u dev -e HOME=/home/dev "$CANDIDATE_NAME" /usr/local/lib/squarebox/setup.sh --rerun "${_seed_sections[@]}"; then + rt_cmd stop "$CANDIDATE_NAME" >/dev/null 2>&1 || true + echo "Error: requested provisioning failed; the prior Box remains available." >&2; exit 1 + fi + rt_cmd stop "$CANDIDATE_NAME" >/dev/null + fail_at provision + fi + +# Commit point: preserve the prior Box under a rollback name, promote the +# validated Candidate to the canonical name, then atomically publish state. +if [ "$HAD_CONTAINER" = 1 ]; then + echo "Promoting Candidate Box..." + rt_cmd rename "$CONTAINER_NAME" "$ROLLBACK_NAME" + OLD_CONTAINER_RENAMED=1 + fail_at old-box-preserved +fi +rt_cmd rename "$CANDIDATE_NAME" "$CONTAINER_NAME" +CANDIDATE_PROMOTED=1 +fail_at candidate-promoted +fail_at state-publish +write_state +STATE_WRITTEN=1 +[ -z "$HOST_BACKUP_DIR" ] || rm -rf -- "$HOST_BACKUP_DIR" +HOST_BACKUP_DIR="" +rm -rf -- "$MANAGED_BACKUP_DIR" +MANAGED_BACKUP_DIR="" +if [ "$OLD_CONTAINER_RENAMED" = 1 ]; then + rt_cmd rm -f "$ROLLBACK_NAME" >/dev/null || { + echo "Warning: committed successfully but could not remove prior Box '$ROLLBACK_NAME'." >&2 + echo "Recovery: verify '$CONTAINER_NAME', then run: $RUNTIME rm -f '$ROLLBACK_NAME'" >&2 + } fi echo "Install identity recorded at $STATE_FILE" diff --git a/tests/test-lifecycle-install-state.sh b/tests/test-lifecycle-install-state.sh index 2fb62b8..654c952 100755 --- a/tests/test-lifecycle-install-state.sh +++ b/tests/test-lifecycle-install-state.sh @@ -89,6 +89,7 @@ digest='ghcr.io/squarewavesystems/squarebox@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbb decoy_digest='ghcr.io/squarewavesystems/squarebox@sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd' case "${1:-}" in info) + [ "${FAIL_RUNTIME_INFO:-0}" != 1 ] || exit 42 if [[ "$*" == *'{{.Host.Security.Rootless}}'* ]] && [ "${MOCK_ROOTLESS:-0}" = 1 ]; then echo true else echo ok fi ;; @@ -126,19 +127,36 @@ case "${1:-}" in esac ;; container) [ "${2:-}" = inspect ] || exit 2 - [ -f "$MOCK_RUNTIME/container" ] || exit 1 + name="${@: -1}" + case "$name" in + *-candidate-*|*-rollback-*) [ -f "$MOCK_RUNTIME/name-$name" ] || exit 1 ;; + *) [ -f "$MOCK_RUNTIME/container" ] || exit 1 ;; + esac echo '{}' ;; inspect) - [ -f "$MOCK_RUNTIME/container" ] || exit 1 - if [[ "$*" == *Labels* ]]; then cat "$MOCK_RUNTIME/container"; else echo false; fi ;; + name="${@: -1}" + case "$name" in + *-candidate-*|*-rollback-*) resource="$MOCK_RUNTIME/name-$name" ;; + *) resource="$MOCK_RUNTIME/container" ;; + esac + [ -f "$resource" ] || exit 1 + if [[ "$*" == *Labels* ]]; then cat "$resource"; else echo false; fi ;; create) - owner='' + owner=''; name='' while [ $# -gt 0 ]; do if [ "$1" = --label ] && [[ "$2" == io.squarebox.install-id=* ]]; then owner="${2#*=}"; fi + if [ "$1" = --name ]; then name="$2"; fi shift done - printf '%s' "$owner" >"$MOCK_RUNTIME/container" ;; - rm) rm -f "$MOCK_RUNTIME/container" ;; + printf '%s' "$owner" >"$MOCK_RUNTIME/name-$name" ;; + rename) + old="$2"; new="$3" + case "$old" in *-candidate-*|*-rollback-*) source="$MOCK_RUNTIME/name-$old" ;; *) source="$MOCK_RUNTIME/container" ;; esac + case "$new" in *-candidate-*|*-rollback-*) destination="$MOCK_RUNTIME/name-$new" ;; *) destination="$MOCK_RUNTIME/container" ;; esac + mv "$source" "$destination" ;; + rm) + name="${@: -1}" + case "$name" in *-candidate-*|*-rollback-*) rm -f "$MOCK_RUNTIME/name-$name" ;; *) rm -f "$MOCK_RUNTIME/container" ;; esac ;; rmi) rm -f "$MOCK_RUNTIME/image" ;; start|stop) ;; exec) [ "${FAIL_PROVISION:-0}" != 1 ] ;; @@ -216,6 +234,18 @@ for requested_identity in "$BAD_UID:$HOST_GID" "$HOST_UID:$BAD_GID"; do test ! -e "$MISMATCH_RUNTIME/calls" done +# An unreachable runtime fails before any lifecycle mutation, including with a +# custom Install path, and retains the diagnostic from the runtime boundary. +UNREACHABLE_RUNTIME="$TMP/runtime-unreachable" +mkdir -p "$UNREACHABLE_RUNTIME" "$TMP/unreachable-home" +if HOME="$TMP/unreachable-home" MOCK_RUNTIME="$UNREACHABLE_RUNTIME" FAIL_RUNTIME_INFO=1 \ + SQUAREBOX_DIR="$TMP/unreachable-install" SQUAREBOX_RUNTIME=docker SQUAREBOX_TAG=v1.1.0 \ + "$ROOT/install.sh" "$TMP/unreachable.out" 2>&1; then + echo 'installer treated an unreachable runtime as usable' >&2; exit 1 +fi +grep -q 'installed but unreachable' "$TMP/unreachable.out" +test ! -e "$UNREACHABLE_RUNTIME/container" + MATCH_RUNTIME="$TMP/runtime-puid-match" mkdir -p "$MATCH_RUNTIME" "$TMP/puid-match-home" HOME="$TMP/puid-match-home" MOCK_RUNTIME="$MATCH_RUNTIME" \ @@ -355,9 +385,8 @@ if "$ROOT/install.sh" "$TMP/provision-failure.out" 2>&1; then echo 'installer ignored an injected requested-provisioning failure' >&2; exit 1 fi grep -q 'requested provisioning failed' "$TMP/provision-failure.out" -test -f "$SQUAREBOX_DIR/.squarebox/install-state" -test -f "$MOCK_RUNTIME/container" -test ! -e "$SQUAREBOX_DIR/workspace/.squarebox/ai-tool" +test ! -e "$SQUAREBOX_DIR" +test ! -e "$MOCK_RUNTIME/container" unset SQUAREBOX_AI FAIL_PROVISION # First v1 adoption can claim only byte-exact v1.0 generated defaults. Track @@ -430,10 +459,36 @@ grep -q 'user.name=Lifecycle Test' "$INSTALL/.squarebox/identity/git/config" grep -q 'user.email=lifecycle@example.test' "$INSTALL/.squarebox/identity/git/config" grep -q 'pull ghcr.io/squarewavesystems/squarebox@sha256:' "$MOCK_RUNTIME/calls" grep -q -- '--label io.squarebox.install-id=' "$MOCK_RUNTIME/calls" -grep -q '^start squarebox$' "$MOCK_RUNTIME/calls" -grep -q '^exec -u dev -e HOME=/home/dev squarebox ' "$MOCK_RUNTIME/calls" +grep -Eq '^start squarebox-candidate-' "$MOCK_RUNTIME/calls" +grep -Eq '^exec -u dev -e HOME=/home/dev squarebox-candidate-.* /usr/local/lib/squarebox/setup.sh' "$MOCK_RUNTIME/calls" +grep -Eq '^rename squarebox-candidate-.* squarebox$' "$MOCK_RUNTIME/calls" ! grep -q '^run ' "$MOCK_RUNTIME/calls" + +# Every handled rebuild boundary before state publication restores the same +# canonical Box and Install identity. Temporary names must not survive cleanup. +cp "$STATE" "$TMP/transaction-state.before" INSTALL_ID=$(sed -n 's/^INSTALL_ID=//p' "$STATE") +for boundary in checkout image-alias managed-config candidate-create provision old-box-preserved candidate-promoted state-publish; do + if SQUAREBOX_FAIL_AT="$boundary" "$INSTALL/install.sh" "$TMP/fail-$boundary.out" 2>&1; then + echo "installer ignored injected lifecycle failure at $boundary" >&2; exit 1 + fi + grep -q "injected lifecycle failure at '$boundary'" "$TMP/fail-$boundary.out" + cmp -s "$STATE" "$TMP/transaction-state.before" + test -f "$MOCK_RUNTIME/container" + test "$(cat "$MOCK_RUNTIME/container")" = "$INSTALL_ID" + ! find "$MOCK_RUNTIME" -maxdepth 1 -type f \( -name 'name-*-candidate-*' -o -name 'name-*-rollback-*' \) | grep -q . +done + +# Recorded custom Box names use bounded, derived transaction names and return to +# their exact canonical identity after promotion. +sed -i 's/^CONTAINER_NAME=.*/CONTAINER_NAME=team.box-custom/' "$STATE" +: >"$MOCK_RUNTIME/calls" +"$INSTALL/install.sh" /dev/null printf '%s' other-install >"$MOCK_RUNTIME/container" diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index a9f7326..baa1361 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -56,6 +56,12 @@ Assert-True (-not (($install + $uninstall) -match '\$owner(?:\.Trim\(\))?\s+-ne\ Assert-True ($install.Contains('Unable to verify ownership label')) 'installer does not fail closed on label inspection' Assert-True ($uninstall.Contains('changed ownership after confirmation')) 'uninstaller does not revalidate ownership after planning' Assert-True ($install.Contains('$script:RollbackArmed')) 'installer has no pre-state rollback transaction' +Assert-True ($install.Contains('$script:CandidateName')) 'installer has no temporary Candidate Box identity' +Assert-True ($install.Contains('& $Runtime rename $ContainerName $script:RollbackName')) 'installer does not preserve the prior Box before promotion' +Assert-True ($install.Contains('& $Runtime rename $script:CandidateName $ContainerName')) 'installer does not promote the Candidate Box by rename' +foreach ($boundary in @('checkout', 'image-alias', 'managed-home-create', 'managed-config', 'candidate-create', 'provision', 'old-box-preserved', 'candidate-promoted', 'state-publish')) { + Assert-True ($install.Contains("Invoke-FailureInjection '$boundary'")) "installer lacks deterministic failure boundary '$boundary'" +} Assert-True ($install.Contains('Malformed squarebox marker block') -and $uninstall.Contains('Malformed squarebox marker block')) 'profile marker validation is absent' Assert-True ($install.Contains('[regex]::Replace($profileBlock')) 'profile interpolation can rescan inserted path placeholders' Assert-True ($uninstall.Contains('Assert-PurgeCheckout')) 'purge does not revalidate checkout identity' From 604ff6855b5e241333fc581720e6a32c40c761f5 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 14 Aug 2026 02:55:32 +1000 Subject: [PATCH 2/4] Validate candidates before lifecycle promotion --- ...romote-box-replacements-transactionally.md | 8 ++-- install.ps1 | 42 ++++++++++++------- install.sh | 13 ++++-- tests/test-lifecycle-install-state.sh | 2 +- tests/test-lifecycle-powershell.ps1 | 2 +- 5 files changed, 42 insertions(+), 25 deletions(-) diff --git a/docs/adr/0008-promote-box-replacements-transactionally.md b/docs/adr/0008-promote-box-replacements-transactionally.md index f20fd1a..8ecd480 100644 --- a/docs/adr/0008-promote-box-replacements-transactionally.md +++ b/docs/adr/0008-promote-box-replacements-transactionally.md @@ -16,7 +16,8 @@ Lifecycle adapters use a prepare/promote transaction for Docker and Podman. During prepare they resolve and check out the Candidate source, acquire its image, update installer-owned configuration, create the replacement under a -bounded `-candidate-` name, and complete requested provisioning. +bounded `-candidate-` name, start it successfully, and complete +requested provisioning. The canonical prior Box remains present throughout prepare. Workspace and the Managed-home volume may be mounted and used, but rollback never copies, deletes, or reconstructs either data tier. @@ -44,8 +45,9 @@ would be more dangerous than retaining it. `SQUAREBOX_FAIL_AT` is a test-only deterministic fault hook at the checkout, image-alias, Managed-home creation, managed-config, Candidate creation, -provisioning, prior-Box rename, Candidate promotion, and state-publication -boundaries. Both native adapters implement the same named boundaries. +host-profile publication, Candidate start, provisioning, prior-Box rename, +Candidate promotion, and state-publication boundaries. Both native adapters +implement the same named boundaries. ## Crash consistency diff --git a/install.ps1 b/install.ps1 index 4c8e95e..038b030 100644 --- a/install.ps1 +++ b/install.ps1 @@ -40,9 +40,7 @@ $script:RollbackName = '' $script:OldContainerRenamed = $false $script:CandidatePromoted = $false $script:StatePublished = $false -$script:ProfileBackup = '' -$script:ProfilePath = '' -$script:ProfileExisted = $false +$script:ProfileBackups = @() $script:ManagedBackupDir = '' $script:ManagedBackups = @() @@ -98,13 +96,17 @@ function Invoke-InstallRollback { Write-Warning "Install rollback could not clean every runtime resource: $($_.Exception.Message)" } try { - if (-not $script:StatePublished -and $script:PriorSourceCommit -and -not $script:CheckoutCreated) { + if (-not $script:StatePublished -and $script:PriorSourceCommit -and -not $script:CheckoutCreated) { & git -C $InstallDir checkout --detach $script:PriorSourceCommit 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "git checkout could not restore source $($script:PriorSourceCommit)" } & git -C $InstallDir reset --hard $script:PriorSourceCommit 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { throw "git reset could not restore source $($script:PriorSourceCommit)" } } - if (-not $script:StatePublished -and $script:ProfilePath) { - if ($script:ProfileExisted) { [IO.File]::Copy($script:ProfileBackup, $script:ProfilePath, $true) } - elseif (Test-Path -LiteralPath $script:ProfilePath) { Remove-Item -Force -LiteralPath $script:ProfilePath } + if (-not $script:StatePublished) { + foreach ($profileBackup in $script:ProfileBackups) { + if ($profileBackup.Existed) { [IO.File]::Copy($profileBackup.Backup, $profileBackup.Path, $true) } + elseif (Test-Path -LiteralPath $profileBackup.Path) { Remove-Item -Force -LiteralPath $profileBackup.Path } + } } if (-not $script:StatePublished -and $script:ManagedBackupDir) { foreach ($backup in $script:ManagedBackups) { @@ -794,12 +796,6 @@ $script:ContainerCreated = $true Invoke-FailureInjection 'candidate-create' $ProfilePath = $PROFILE.CurrentUserAllHosts -$script:ProfilePath = $ProfilePath -$script:ProfileExisted = Test-Path -LiteralPath $ProfilePath -PathType Leaf -if ($script:ProfileExisted) { - $script:ProfileBackup = Join-Path ([IO.Path]::GetTempPath()) "squarebox-profile-$([guid]::NewGuid().ToString('N'))" - [IO.File]::Copy($ProfilePath, $script:ProfileBackup, $true) -} $ShellInit = $ProfilePath $stateValues = @( $InstallDir, $WorkspaceDir, $GitConfigDir, $HomeVolume, $ContainerName, @@ -887,6 +883,12 @@ function Add-SquareboxProfileBlock([string]$Path, [string]$Block) { # v1.0 wrote the adapter to the current-host profile. Remove it there before # installing the portable all-hosts adapter so stale definitions cannot win. $ProfilePaths = @($ProfilePath, $PROFILE.CurrentUserCurrentHost) | Select-Object -Unique +foreach ($path in $ProfilePaths) { + $existed = Test-Path -LiteralPath $path -PathType Leaf + $backup = Join-Path ([IO.Path]::GetTempPath()) "squarebox-profile-$([guid]::NewGuid().ToString('N'))" + if ($existed) { [IO.File]::Copy($path, $backup, $true) } + $script:ProfileBackups += [pscustomobject]@{ Path = $path; Backup = $backup; Existed = $existed } +} foreach ($path in $ProfilePaths) { $hasBlock = Test-SquareboxProfileBlock $path if ($hasBlock) { @@ -939,11 +941,15 @@ try { [void][scriptblock]::Create($profileBlock) } catch { Abort "Generated PowerShell profile failed to parse after interpolation: $($_.Exception.Message)" } Add-SquareboxProfileBlock $ProfilePath $profileBlock Write-Host "Installed shell integration -> $ProfilePath" +Invoke-FailureInjection 'host-profile' + +Write-Host 'Validating Candidate Box...' +& $Runtime start $script:CandidateName | Out-Null +if ($LASTEXITCODE -ne 0) { Abort 'Unable to start the Candidate Box.' } +Invoke-FailureInjection 'candidate-start' if ($SeedSections.Count -gt 0) { Write-Host "Provisioning requested Selection on the Candidate Box ($($SeedSections -join ', '))..." - & $Runtime start $script:CandidateName | Out-Null - if ($LASTEXITCODE -ne 0) { Abort 'Unable to start the Candidate Box for provisioning.' } & $Runtime exec -u dev -e HOME=/home/dev $script:CandidateName /usr/local/lib/squarebox/setup.sh --rerun @SeedSections $provisionExit = $LASTEXITCODE & $Runtime stop $script:CandidateName | Out-Null @@ -952,6 +958,8 @@ if ($SeedSections.Count -gt 0) { } Invoke-FailureInjection 'provision' } +& $Runtime stop $script:CandidateName | Out-Null +if ($LASTEXITCODE -ne 0) { Abort 'Unable to stop the validated Candidate Box.' } if ($HadContainer) { Write-Host 'Promoting Candidate Box...' @@ -972,7 +980,9 @@ if ($HadContainer) { & $Runtime rm -f $script:RollbackName | Out-Null if ($LASTEXITCODE -ne 0) { Write-Warning "Committed successfully; remove stale prior Box with: $Runtime rm -f '$($script:RollbackName)'" } } -if ($script:ProfileBackup -and (Test-Path -LiteralPath $script:ProfileBackup)) { Remove-Item -Force -LiteralPath $script:ProfileBackup } +foreach ($profileBackup in $script:ProfileBackups) { + if (Test-Path -LiteralPath $profileBackup.Backup) { Remove-Item -Force -LiteralPath $profileBackup.Backup } +} if ($script:ManagedBackupDir -and (Test-Path -LiteralPath $script:ManagedBackupDir)) { Remove-Item -Recurse -Force -LiteralPath $script:ManagedBackupDir } Write-Host "Install identity recorded at $StateFile" diff --git a/install.sh b/install.sh index f3da72e..6b77990 100755 --- a/install.sh +++ b/install.sh @@ -1064,22 +1064,27 @@ if [ -n "${MSYSTEM:-}" ] && [ "$SHELL_RC" = "$HOME/.bashrc" ]; then EOF fi -case "$SHELL_RC" in + case "$SHELL_RC" in *.zshrc) if command -v zsh >/dev/null 2>&1; then zsh -n "$SHELL_RC"; fi ;; *) bash -n "$SHELL_RC" ;; -esac + esac + fail_at host-profile + + # Every Candidate must prove that its entrypoint reaches a running state. + echo "Validating Candidate Box..." + rt_cmd start "$CANDIDATE_NAME" >/dev/null + fail_at candidate-start # Provision the Candidate before it can replace the prior canonical Box. if [ ${#_seed_sections[@]} -gt 0 ]; then echo "Provisioning requested Selection on the Candidate Box (${_seed_sections[*]})..." - rt_cmd start "$CANDIDATE_NAME" >/dev/null if ! rt_cmd exec -u dev -e HOME=/home/dev "$CANDIDATE_NAME" /usr/local/lib/squarebox/setup.sh --rerun "${_seed_sections[@]}"; then rt_cmd stop "$CANDIDATE_NAME" >/dev/null 2>&1 || true echo "Error: requested provisioning failed; the prior Box remains available." >&2; exit 1 fi - rt_cmd stop "$CANDIDATE_NAME" >/dev/null fail_at provision fi + rt_cmd stop "$CANDIDATE_NAME" >/dev/null # Commit point: preserve the prior Box under a rollback name, promote the # validated Candidate to the canonical name, then atomically publish state. diff --git a/tests/test-lifecycle-install-state.sh b/tests/test-lifecycle-install-state.sh index 654c952..68e27d6 100755 --- a/tests/test-lifecycle-install-state.sh +++ b/tests/test-lifecycle-install-state.sh @@ -468,7 +468,7 @@ grep -Eq '^rename squarebox-candidate-.* squarebox$' "$MOCK_RUNTIME/calls" # canonical Box and Install identity. Temporary names must not survive cleanup. cp "$STATE" "$TMP/transaction-state.before" INSTALL_ID=$(sed -n 's/^INSTALL_ID=//p' "$STATE") -for boundary in checkout image-alias managed-config candidate-create provision old-box-preserved candidate-promoted state-publish; do +for boundary in checkout image-alias managed-config candidate-create host-profile candidate-start provision old-box-preserved candidate-promoted state-publish; do if SQUAREBOX_FAIL_AT="$boundary" "$INSTALL/install.sh" "$TMP/fail-$boundary.out" 2>&1; then echo "installer ignored injected lifecycle failure at $boundary" >&2; exit 1 fi diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index baa1361..acb5a7e 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -59,7 +59,7 @@ Assert-True ($install.Contains('$script:RollbackArmed')) 'installer has no pre-s Assert-True ($install.Contains('$script:CandidateName')) 'installer has no temporary Candidate Box identity' Assert-True ($install.Contains('& $Runtime rename $ContainerName $script:RollbackName')) 'installer does not preserve the prior Box before promotion' Assert-True ($install.Contains('& $Runtime rename $script:CandidateName $ContainerName')) 'installer does not promote the Candidate Box by rename' -foreach ($boundary in @('checkout', 'image-alias', 'managed-home-create', 'managed-config', 'candidate-create', 'provision', 'old-box-preserved', 'candidate-promoted', 'state-publish')) { +foreach ($boundary in @('checkout', 'image-alias', 'managed-home-create', 'managed-config', 'candidate-create', 'host-profile', 'candidate-start', 'provision', 'old-box-preserved', 'candidate-promoted', 'state-publish')) { Assert-True ($install.Contains("Invoke-FailureInjection '$boundary'")) "installer lacks deterministic failure boundary '$boundary'" } Assert-True ($install.Contains('Malformed squarebox marker block') -and $uninstall.Contains('Malformed squarebox marker block')) 'profile marker validation is absent' From a0bdad40230927654eb50ce7ddf277fe34229b78 Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 14 Aug 2026 02:58:42 +1000 Subject: [PATCH 3/4] Exercise native lifecycle rollback --- install.ps1 | 27 +++++++--- install.sh | 2 + tests/test-lifecycle-install-state.sh | 5 +- tests/test-lifecycle-powershell.ps1 | 72 +++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 9 deletions(-) diff --git a/install.ps1 b/install.ps1 index 038b030..3ddb4a8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -95,20 +95,24 @@ function Invoke-InstallRollback { } catch { Write-Warning "Install rollback could not clean every runtime resource: $($_.Exception.Message)" } - try { - if (-not $script:StatePublished -and $script:PriorSourceCommit -and -not $script:CheckoutCreated) { + if (-not $script:StatePublished -and $script:PriorSourceCommit -and -not $script:CheckoutCreated) { + try { & git -C $InstallDir checkout --detach $script:PriorSourceCommit 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { throw "git checkout could not restore source $($script:PriorSourceCommit)" } & git -C $InstallDir reset --hard $script:PriorSourceCommit 2>$null | Out-Null if ($LASTEXITCODE -ne 0) { throw "git reset could not restore source $($script:PriorSourceCommit)" } - } - if (-not $script:StatePublished) { + } catch { Write-Warning "Install rollback could not restore source: $($_.Exception.Message)" } + } + if (-not $script:StatePublished) { + try { foreach ($profileBackup in $script:ProfileBackups) { if ($profileBackup.Existed) { [IO.File]::Copy($profileBackup.Backup, $profileBackup.Path, $true) } elseif (Test-Path -LiteralPath $profileBackup.Path) { Remove-Item -Force -LiteralPath $profileBackup.Path } } - } - if (-not $script:StatePublished -and $script:ManagedBackupDir) { + } catch { Write-Warning "Install rollback could not restore profiles: $($_.Exception.Message)" } + } + if (-not $script:StatePublished -and $script:ManagedBackupDir) { + try { foreach ($backup in $script:ManagedBackups) { if ($backup.Existed) { [IO.Directory]::CreateDirectory((Split-Path $backup.Path)) | Out-Null @@ -117,8 +121,13 @@ function Invoke-InstallRollback { Remove-Item -Force -LiteralPath $backup.Path } } - } - } catch { Write-Warning "Install rollback could not restore source or profile: $($_.Exception.Message)" } + } catch { Write-Warning "Install rollback could not restore managed configuration: $($_.Exception.Message)" } + } + foreach ($profileBackup in $script:ProfileBackups) { + Remove-Item -Force -LiteralPath $profileBackup.Backup -ErrorAction SilentlyContinue + } + if ($script:ManagedBackupDir) { Remove-Item -Recurse -Force -LiteralPath $script:ManagedBackupDir -ErrorAction SilentlyContinue } + if ($StateTemp) { Remove-Item -Force -LiteralPath $StateTemp -ErrorAction SilentlyContinue } try { if ($script:CheckoutCreated -and $InstallDir -and $StateFile -and -not (Test-Path -LiteralPath $StateFile)) { Remove-Item -Recurse -Force -LiteralPath $InstallDir -ErrorAction Stop @@ -946,6 +955,8 @@ Invoke-FailureInjection 'host-profile' Write-Host 'Validating Candidate Box...' & $Runtime start $script:CandidateName | Out-Null if ($LASTEXITCODE -ne 0) { Abort 'Unable to start the Candidate Box.' } +$candidateRunning = (& $Runtime inspect -f '{{.State.Running}}' $script:CandidateName 2>$null) +if ($LASTEXITCODE -ne 0 -or -not $candidateRunning -or $candidateRunning.Trim() -cne 'true') { Abort 'Candidate Box exited during validation.' } Invoke-FailureInjection 'candidate-start' if ($SeedSections.Count -gt 0) { diff --git a/install.sh b/install.sh index 6b77990..581fbc0 100755 --- a/install.sh +++ b/install.sh @@ -1073,6 +1073,8 @@ fi # Every Candidate must prove that its entrypoint reaches a running state. echo "Validating Candidate Box..." rt_cmd start "$CANDIDATE_NAME" >/dev/null + _candidate_running="$(rt_cmd inspect -f '{{.State.Running}}' "$CANDIDATE_NAME" 2>/dev/null || true)" + [ "$_candidate_running" = true ] || { echo "Error: Candidate Box exited during validation." >&2; exit 1; } fail_at candidate-start # Provision the Candidate before it can replace the prior canonical Box. diff --git a/tests/test-lifecycle-install-state.sh b/tests/test-lifecycle-install-state.sh index 68e27d6..bf7ff98 100755 --- a/tests/test-lifecycle-install-state.sh +++ b/tests/test-lifecycle-install-state.sh @@ -140,7 +140,10 @@ case "${1:-}" in *) resource="$MOCK_RUNTIME/container" ;; esac [ -f "$resource" ] || exit 1 - if [[ "$*" == *Labels* ]]; then cat "$resource"; else echo false; fi ;; + if [[ "$*" == *Labels* ]]; then cat "$resource" + elif [[ "$*" == *State.Running* ]]; then echo true + else echo false + fi ;; create) owner=''; name='' while [ $# -gt 0 ]; do diff --git a/tests/test-lifecycle-powershell.ps1 b/tests/test-lifecycle-powershell.ps1 index acb5a7e..e3c7efa 100755 --- a/tests/test-lifecycle-powershell.ps1 +++ b/tests/test-lifecycle-powershell.ps1 @@ -70,6 +70,78 @@ Assert-True ($install.Contains('{{range .RepoDigests}}{{println .}}{{end}}') -an Assert-True ($install -match '\$repoDigestOutput = @\(\)\s+if \(-not \$Build\)') 'local builds still derive identity from unordered RepoDigests' Assert-True ($install.Contains('$SelectionStateFiles') -and $install.Contains('Selection state file must not be a reparse point or symlink')) 'PowerShell seeding can follow Workspace Selection links' +# Execute the native rollback implementation against an in-memory runtime. This +# complements source-contract assertions by proving both rename phases restore +# the canonical Box and remove the Candidate without requiring Docker on CI. +foreach ($functionName in @('Invoke-InstallRollback', 'Invoke-FailureInjection')) { + $definition = $installAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -ceq $functionName + }, $true) + Assert-True ($null -ne $definition) "install.ps1 has no $functionName function" + Invoke-Expression $definition.Extent.Text +} +function global:docker { + param([Parameter(ValueFromRemainingArguments = $true)][object[]]$RuntimeArgs) + $global:LASTEXITCODE = 0 + $command = @($RuntimeArgs | ForEach-Object { [string]$_ }) + switch ($command[0]) { + 'rename' { + if (-not $script:RuntimeBoxes.ContainsKey($command[1]) -or $script:RuntimeBoxes.ContainsKey($command[2])) { + $global:LASTEXITCODE = 1; return + } + $script:RuntimeBoxes[$command[2]] = $script:RuntimeBoxes[$command[1]] + $script:RuntimeBoxes.Remove($command[1]) + } + 'container' { + if ($command[1] -cne 'inspect' -or -not $script:RuntimeBoxes.ContainsKey($command[-1])) { $global:LASTEXITCODE = 1 } + else { '{}' } + } + 'inspect' { + if (-not $script:RuntimeBoxes.ContainsKey($command[-1])) { $global:LASTEXITCODE = 1 } + else { $script:RuntimeBoxes[$command[-1]] } + } + 'rm' { $script:RuntimeBoxes.Remove($command[-1]) } + 'tag' {} + 'image' { $global:LASTEXITCODE = 1 } + 'volume' { $global:LASTEXITCODE = 1 } + default { throw "unexpected mock runtime command: $($command -join ' ')" } + } +} +function global:git { $global:LASTEXITCODE = 0 } +$Runtime = 'docker'; $ContainerName = 'custom.box'; $InstallId = 'test-install-123' +$HomeVolume = 'custom-home'; $ImageAlias = 'custom-image'; $StateFile = '' +$script:StatePublished = $false; $script:RuntimeReady = $true; $script:VolumeCreated = $false +$script:ImageAliasMutated = $false; $script:PriorSourceCommit = ''; $script:CheckoutCreated = $false +$script:ProfileBackups = @(); $script:ManagedBackupDir = ''; $script:ManagedBackups = @() +foreach ($phase in @('candidate-created', 'old-preserved', 'candidate-promoted')) { + $script:CandidateName = 'custom.box-candidate-test'; $script:RollbackName = 'custom.box-rollback-test' + $script:RuntimeBoxes = @{ 'custom.box' = $InstallId; 'custom.box-candidate-test' = $InstallId } + $script:ContainerCreated = $true; $script:OldContainerRenamed = $false; $script:CandidatePromoted = $false + if ($phase -cin @('old-preserved', 'candidate-promoted')) { + $script:RuntimeBoxes[$script:RollbackName] = $script:RuntimeBoxes[$ContainerName] + $script:RuntimeBoxes.Remove($ContainerName) + $script:OldContainerRenamed = $true + } + if ($phase -ceq 'candidate-promoted') { + $script:RuntimeBoxes[$ContainerName] = $script:RuntimeBoxes[$script:CandidateName] + $script:RuntimeBoxes.Remove($script:CandidateName) + $script:CandidatePromoted = $true + } + $script:RollbackArmed = $true; $script:RollbackInProgress = $false + Invoke-InstallRollback + Assert-True ($script:RuntimeBoxes.ContainsKey($ContainerName)) "rollback phase '$phase' lost canonical custom Box" + Assert-True ($script:RuntimeBoxes[$ContainerName] -ceq $InstallId) "rollback phase '$phase' changed canonical ownership" + Assert-True (-not $script:RuntimeBoxes.ContainsKey($script:CandidateName)) "rollback phase '$phase' retained Candidate" + Assert-True (-not $script:RuntimeBoxes.ContainsKey($script:RollbackName)) "rollback phase '$phase' retained rollback name" +} +$env:SQUAREBOX_FAIL_AT = 'candidate-promoted' +$injected = $false +try { Invoke-FailureInjection 'candidate-promoted' } catch { $injected = $_.Exception.Message -like '*candidate-promoted*' } +Remove-Item Env:SQUAREBOX_FAIL_AT +Assert-True $injected 'native failure injection did not throw at the selected boundary' +Remove-Item Function:docker, Function:git -ErrorAction SilentlyContinue + $schema = Get-Content -Raw (Join-Path $Root 'scripts/lib/install-state-schema.json') | ConvertFrom-Json $cases = Get-Content -Raw (Join-Path $Root 'tests/fixtures/install-state-cases.json') | ConvertFrom-Json $StateFields = @($schema.fields) From 7ab5ef3ed1860186e985286eb5a1d746ebb6bf2a Mon Sep 17 00:00:00 2001 From: Brett Kinny Date: Fri, 14 Aug 2026 02:59:47 +1000 Subject: [PATCH 4/4] Avoid duplicate Candidate stop --- install.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install.ps1 b/install.ps1 index 3ddb4a8..4c59b67 100644 --- a/install.ps1 +++ b/install.ps1 @@ -963,8 +963,8 @@ if ($SeedSections.Count -gt 0) { Write-Host "Provisioning requested Selection on the Candidate Box ($($SeedSections -join ', '))..." & $Runtime exec -u dev -e HOME=/home/dev $script:CandidateName /usr/local/lib/squarebox/setup.sh --rerun @SeedSections $provisionExit = $LASTEXITCODE - & $Runtime stop $script:CandidateName | Out-Null if ($provisionExit -ne 0) { + & $Runtime stop $script:CandidateName 2>$null | Out-Null Abort 'Requested provisioning failed; the prior Box remains available.' } Invoke-FailureInjection 'provision'