Modules/businessdev.ALbuild.Containers/Public/Set-BcDockerDataRoot.ps1
|
function Set-BcDockerDataRoot { <# .SYNOPSIS Moves Docker's data root to another drive, with a full preflight and an automatic rollback. .DESCRIPTION On a build server the Docker root decides how much can be cached and how many containers fit. A BC container copies the service tier, the web client and the apps into its writable layer and restores the demo database there, and a version image costs about 6.4 GB - so a Docker root on the system drive is what caps a build host long before its CPU or RAM do. WHAT THIS DOES, AND WHAT IT DELIBERATELY DOES NOT It sets 'data-root' in daemon.json and restarts the service. It does NOT copy the existing layer store, and that is the single most important thing about it. Windows layers ('windowsfilter') are built from hard links and NTFS metadata; copying them is unreliable and produces images that cannot be removed later. So the new root starts EMPTY: images are pulled again on demand. The consequence is worth stating plainly, because it is also the safety property. Nothing is deleted. The old root stays exactly as it was, which means: * every image and container is still on disk, just not visible to the daemon any more; * rollback is complete - putting daemon.json back restores the previous state entirely; * the old directory can be deleted later, deliberately, once the new root has proven out. HOW IT FAILS Every check runs before anything is touched, and each one names what to do about it. After that the operation is journalled step by step, and any failure - a service that will not start, a daemon that comes up on the wrong root - triggers a rollback that restores daemon.json byte for byte and restarts the service. The result object says whether it rolled back, and the original failure is preserved rather than replaced by whatever the rollback ran into. .PARAMETER Path The new data root, e.g. 'G:\Docker'. Created if it does not exist. .PARAMETER ServiceName The Docker service to restart. Detected automatically ('docker', then 'com.docker.service'). .PARAMETER DockerExecutable The Docker executable. Default 'docker'. .PARAMETER ConfigPath The daemon configuration file. Defaults to the standard Windows location. Other keys in it are preserved - on many hosts 'hosts' is set and the daemon will not start without it. .PARAMETER MinimumFreeGb Refuse a target with less free space than this. Default 100, which is roughly one BC container plus a small image cache. Use -Force to proceed anyway. .PARAMETER TimeoutSeconds How long to wait for the service to stop, and for the daemon to answer after starting. Default 180. .PARAMETER Force Proceed despite conditions that are survivable but usually mistakes: running containers, a non-empty target, too little free space, Docker Desktop, or deduplication on the target volume. It does not skip the checks - they still report - and it never suppresses the rollback. .EXAMPLE Set-BcDockerDataRoot -Path 'G:\Docker' -WhatIf Runs the whole preflight and prints what would change, touching nothing. .EXAMPLE Set-BcDockerDataRoot -Path 'G:\Docker' Prompts (the impact is High), then moves and verifies. .OUTPUTS PSCustomObject with Moved, RolledBack, PreviousPath, Path, ServiceName, RetainedDataAt, ImagesToPullAgain, Warnings. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Path, [string] $ServiceName, [string] $DockerExecutable = 'docker', [string] $ConfigPath = (Join-Path $env:ProgramData 'Docker\config\daemon.json'), [ValidateRange(0, 100000)] [int] $MinimumFreeGb = 100, [ValidateRange(10, 3600)] [int] $TimeoutSeconds = 180, [switch] $Force ) $warnings = [System.Collections.Generic.List[string]]::new() # ============================================================================================= # Preflight. Nothing below this block writes anything - a failure here leaves the host untouched. # ============================================================================================= if ([System.Environment]::OSVersion.Platform -ne 'Win32NT') { throw 'Set-BcDockerDataRoot configures the Windows Docker service and only runs on Windows.' } if (-not (Test-BcAdministrator)) { throw 'Moving the Docker data root edits daemon.json under ProgramData and restarts a service, both of which need an elevated session. Re-run from an elevated PowerShell.' } # The daemon has to be reachable, or 'the root in use' is unknown and there is nothing to verify against. $state = Get-BcDockerDataRoot -DockerExecutable $DockerExecutable -ConfigPath $ConfigPath if (-not $state.Path) { throw "The Docker daemon did not report a data root (is it running?). Start Docker and try again - moving the root blind would leave the host in a state nothing could verify." } $target = try { [System.IO.Path]::GetFullPath($Path) } catch { throw "'$Path' is not a usable path: $($_.Exception.Message)" } $target = $target.TrimEnd('\', '/') $current = "$($state.Path)".TrimEnd('\', '/') if ($target -eq $current) { Write-ALbuildLog "Docker already stores its data in '$current'; nothing to do." return [PSCustomObject]@{ Moved = $false; RolledBack = $false; PreviousPath = $current; Path = $current ServiceName = $state.ServiceName; RetainedDataAt = $null; ImagesToPullAgain = 0 Warnings = @() } } if ($target.StartsWith("$current\", [StringComparison]::OrdinalIgnoreCase) -or $current.StartsWith("$target\", [StringComparison]::OrdinalIgnoreCase)) { throw "'$target' and the current data root '$current' contain one another. Nesting one layer store inside another corrupts both; choose a separate directory." } if ($target -match '^\\\\') { throw "'$target' is a UNC path. The Windows storage driver cannot host layers on a network location - use a local fixed disk." } # --- the target volume ----------------------------------------------------------------------- $volume = Get-BcHostVolumeInfo -Path $target if (-not $volume.DriveType) { throw "The volume for '$target' could not be read. Check that the drive exists and is ready." } if ($volume.DriveType -ne 'Fixed') { throw "'$target' is on a $($volume.DriveType) drive. The Windows storage driver needs a fixed local disk." } if ($volume.FileSystem -and $volume.FileSystem -ne 'NTFS') { throw "'$target' is on $($volume.FileSystem). The Windows storage driver needs NTFS - ReFS cannot mount layers." } if ($volume.DeduplicationEnabled) { $message = "Data Deduplication is enabled on $($volume.Drive). It rewrites layer files underneath the storage driver and corrupts images." if (-not $Force) { throw "$message Exclude the folder from dedup, or pass -Force if you know the volume is excluded." } $warnings.Add($message) } if ($null -ne $volume.FreeGb -and $volume.FreeGb -lt $MinimumFreeGb) { $message = "'$target' has $($volume.FreeGb) GB free, below the required $MinimumFreeGb GB." if (-not $Force) { throw "$message Free space, choose another volume, lower -MinimumFreeGb, or pass -Force." } $warnings.Add($message) } if ((Test-Path -LiteralPath $target) -and @(Get-ChildItem -LiteralPath $target -Force -ErrorAction SilentlyContinue).Count -gt 0) { $message = "'$target' already exists and is not empty." if (-not $Force) { throw "$message Docker must own this directory alone; point at an empty or new folder, or pass -Force." } $warnings.Add($message) } # --- the service ----------------------------------------------------------------------------- $svcName = if ($ServiceName) { $ServiceName } else { $state.ServiceName } if (-not $svcName) { throw 'No Docker service could be found, so the daemon cannot be restarted. Pass -ServiceName with the service that runs your daemon.' } $svc = Get-Service -Name $svcName -ErrorAction SilentlyContinue if (-not $svc) { throw "There is no service named '$svcName' on this host." } if ($state.IsDockerDesktop) { $message = 'This host has Docker Desktop installed, and Desktop owns its own configuration - it can overwrite daemon.json on its next start.' if ($svcName -eq 'com.docker.service') { # Desktop's own service: editing the file is very likely to be undone, so stop. if (-not $Force) { throw "$message Move the disk image in Docker Desktop settings instead, or pass -Force to edit daemon.json anyway." } $warnings.Add($message) } else { # The Engine service was selected, which is the right one on a build host that has both. # Worth saying out loud rather than refusing: this is the normal case on a developer box. $warnings.Add("$message The Engine service '$svcName' is being configured, which is the right one here - but if Desktop is ever started it may reset this.") } } if ($state.Warnings | Where-Object { $_ -match 'could not be parsed' }) { throw "daemon.json at '$ConfigPath' cannot be parsed. Fix or remove it first - writing over a broken file risks a daemon that will not start." } # --- what the operator is about to lose sight of ---------------------------------------------- $running = 0 $dockerPs = Invoke-BcDocker -DockerExecutable $DockerExecutable -Quiet -PassThru -Arguments @('ps', '-q') if ($dockerPs.Success) { $running = @("$($dockerPs.StdOut)".Trim() -split '\r?\n' | Where-Object { $_ }).Count } if ($running -gt 0) { $message = "$running container(s) are running and will be stopped by the service restart." if (-not $Force) { throw "$message Stop them first, or pass -Force." } $warnings.Add($message) } # These are warnings, not questions: they describe what the move means and are always printed. Write-ALbuildLog -Level Warning "The new data root starts EMPTY. $($state.ImageCount) image(s) and $($state.ContainerCount) container(s) will no longer be visible to Docker and must be pulled or created again." Write-ALbuildLog -Level Warning "Nothing is deleted: the existing store stays at '$current'. Delete it yourself once the new root has proven out - and only then." Write-ALbuildLog -Level Warning "The layer store is deliberately NOT copied. Windows layers are hard links plus NTFS metadata, and copying them produces images that cannot be removed." Write-ALbuildLog -Level Warning "Service '$svcName' will be restarted; every container on this host stops, including other agents' work." foreach ($w in $warnings) { Write-ALbuildLog -Level Warning $w } $description = "Move the Docker data root from '$current' to '$target' (service '$svcName' restarts; the old store is kept)" if (-not $PSCmdlet.ShouldProcess($target, $description)) { return [PSCustomObject]@{ Moved = $false; RolledBack = $false; PreviousPath = $current; Path = $target ServiceName = $svcName; RetainedDataAt = $current; ImagesToPullAgain = $state.ImageCount Warnings = @($warnings) } } # ============================================================================================= # Execute. Journalled, so the rollback undoes exactly what was done and nothing else. # ============================================================================================= $configExisted = Test-Path -LiteralPath $ConfigPath $originalBytes = if ($configExisted) { [System.IO.File]::ReadAllBytes($ConfigPath) } else { $null } $serviceWasRunning = "$($svc.Status)" -eq 'Running' $configWritten = $false # Restores the exact previous file - not a re-serialised equivalent - and puts the service back. $rollback = { param([string] $Reason) Write-ALbuildLog -Level Warning "Rolling back: $Reason" try { if ($configWritten) { if ($null -ne $originalBytes) { [System.IO.File]::WriteAllBytes($ConfigPath, $originalBytes) } elseif (Test-Path -LiteralPath $ConfigPath) { Remove-Item -LiteralPath $ConfigPath -Force } Write-ALbuildLog 'daemon.json restored to its previous content.' } if ($serviceWasRunning) { Start-Service -Name $svcName -ErrorAction Stop $null = Wait-BcDockerDaemon -DockerExecutable $DockerExecutable -TimeoutSeconds $TimeoutSeconds Write-ALbuildLog "Service '$svcName' started again." } } catch { # A failed rollback is the one case an operator must act on by hand, so say exactly what to do. Write-ALbuildLog -Level Error "The rollback did not complete: $($_.Exception.Message). Restore '$ConfigPath' manually (the previous content is $(if ($configExisted) { 'the file that was there before' } else { 'no file at all' })) and start service '$svcName'." } } try { if (-not (Test-Path -LiteralPath $target)) { New-Item -ItemType Directory -Force -Path $target | Out-Null Write-ALbuildLog "Created '$target'." } Write-ALbuildLog "Stopping service '$svcName' ..." Stop-Service -Name $svcName -Force -ErrorAction Stop if (-not (Wait-BcServiceStatus -Name $svcName -Status 'Stopped' -TimeoutSeconds $TimeoutSeconds)) { throw "Service '$svcName' did not stop within $TimeoutSeconds s. Nothing has been changed yet." } # Other keys are preserved: 'hosts' is set on many hosts and the daemon will not start without it. $configDir = Split-Path -Parent $ConfigPath if ($configDir -and -not (Test-Path -LiteralPath $configDir)) { New-Item -ItemType Directory -Force -Path $configDir | Out-Null } $doc = if ($configExisted) { Get-Content -LiteralPath $ConfigPath -Raw -Encoding UTF8 | ConvertFrom-Json } else { [PSCustomObject]@{} } $doc | Add-Member -NotePropertyName 'data-root' -NotePropertyValue $target -Force # The deprecated spelling would win over 'data-root' on some engine versions - drop it. if ($doc.PSObject.Properties['graph']) { $doc.PSObject.Properties.Remove('graph') } $json = $doc | ConvertTo-Json -Depth 32 [System.IO.File]::WriteAllText($ConfigPath, $json, (New-Object System.Text.UTF8Encoding($false))) $configWritten = $true Write-ALbuildLog "Wrote data-root '$target' to '$ConfigPath'." Write-ALbuildLog "Starting service '$svcName' ..." Start-Service -Name $svcName -ErrorAction Stop if (-not (Wait-BcDockerDaemon -DockerExecutable $DockerExecutable -TimeoutSeconds $TimeoutSeconds)) { throw "The daemon did not answer within $TimeoutSeconds s after the service was started." } # Verify against the daemon, not against the file: the file is an intention, this is the fact. $after = Invoke-BcDocker -DockerExecutable $DockerExecutable -Quiet -PassThru -Arguments @('info', '--format', '{{.DockerRootDir}}') $actual = if ($after.Success) { "$($after.StdOut)".Trim().TrimEnd('\', '/') } else { '' } if ($actual -ne $target) { throw "The daemon came up on '$actual' instead of '$target'." } Write-ALbuildLog "Docker now stores its data in '$target'." Write-ALbuildLog -Level Warning "The previous store is still at '$current'. Verify a container starts, then delete it to reclaim the space." return [PSCustomObject]@{ Moved = $true RolledBack = $false PreviousPath = $current Path = $target ServiceName = $svcName RetainedDataAt = $current ImagesToPullAgain = $state.ImageCount Warnings = @($warnings) } } catch { $failure = $_ & $rollback "$($failure.Exception.Message)" # The original failure is what the operator needs; the rollback only reports on itself. throw "Moving the Docker data root to '$target' failed and was rolled back: $($failure.Exception.Message)" } } |