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)"
    }
}