Public/Move-FileShare.ps1

function Move-FileShare {
    <#
    .SYNOPSIS
        Migrates Azure file shares from one storage account to another using azcopy.
    .DESCRIPTION
        Enumerates file shares on the source storage account and copies them to the
        destination using azcopy with SAS token authentication. Supports parallel
        copy, dry-run mode, and per-share log files.
    .PARAMETER SourceAccountName
        Name of the source storage account.
    .PARAMETER DestinationAccountName
        Name of the destination storage account.
    .PARAMETER SasExpiryHours
        Hours until generated SAS tokens expire. Default: 24.
    .PARAMETER ShareNames
        Optional list of specific share names to copy. When omitted, all shares are copied.
    .PARAMETER ThrottleLimit
        Maximum parallel share copies. Default: 10.
    .PARAMETER LogDirectory
        Base directory for log files. Default: current directory.
    .EXAMPLE
        Move-FileShare -SourceAccountName 'sourceaccount' -DestinationAccountName 'destaccount'
    .EXAMPLE
        Move-FileShare -SourceAccountName 'sourceaccount' -DestinationAccountName 'destaccount' -ShareNames 'finance','hr' -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess)]
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingPositionalParameters', '', Justification = 'Join-Path positional parameters are idiomatic and readable')]
    param(
        [Parameter(Mandatory)]
        [string]$SourceAccountName,

        [Parameter(Mandatory)]
        [string]$DestinationAccountName,

        [Parameter()]
        [int]$SasExpiryHours = 24,

        [Parameter()]
        [string[]]$ShareNames,

        [Parameter()]
        [ValidateRange(1, 64)]
        [int]$ThrottleLimit = 10,

        [Parameter()]
        [string]$LogDirectory = (Get-Location).Path
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    # ── Pre-flight ───────────────────────────────────────────────────────────
    Assert-AzCopyInstalled
    Assert-AzCliLogin | Out-Null
    Test-StorageAccountAccess -AccountName $SourceAccountName
    Test-StorageAccountAccess -AccountName $DestinationAccountName

    # ── Generate SAS tokens ──────────────────────────────────────────────────
    $sourceSas = New-SasToken -AccountName $SourceAccountName -Permissions 'rl' -ExpiryHours $SasExpiryHours
    $destSas   = New-SasToken -AccountName $DestinationAccountName -Permissions 'rwdlc' -ExpiryHours $SasExpiryHours

    # ── Enumerate source shares ──────────────────────────────────────────────
    Write-Host "`n[$(Get-Timestamp)] Enumerating file shares on '$SourceAccountName'..." -ForegroundColor Cyan
    $sharesOutput = az storage share list --account-name $SourceAccountName --query '[].name' -o json 2>&1
    if ($LASTEXITCODE -ne 0) { throw "Failed to list file shares: $sharesOutput" }

    $sharesJson = ($sharesOutput | Where-Object { $_ -isnot [System.Management.Automation.ErrorRecord] }) -join "`n"
    $shares = @($sharesJson | ConvertFrom-Json)
    if ($shares.Count -eq 0) {
        Write-Warning "No file shares found on '$SourceAccountName'. Nothing to do."
        return
    }
    Write-Host " Found $($shares.Count) file share(s) on source." -ForegroundColor Green

    # Filter to requested shares
    if ($ShareNames) {
        $missing = $ShareNames | Where-Object { $_ -notin $shares }
        if ($missing) {
            throw "The following share(s) do not exist on '$SourceAccountName': $($missing -join ', ')"
        }
        $shares = @($shares | Where-Object { $_ -in $ShareNames })
        Write-Host " Targeting $($shares.Count) share(s): $($shares -join ', ')" -ForegroundColor Yellow
    }
    else {
        Write-Host " Targeting all $($shares.Count) share(s): $($shares -join ', ')" -ForegroundColor Green
    }

    # ── Prepare ──────────────────────────────────────────────────────────────
    $dryRunFlag = if ($WhatIfPreference) { '--dry-run' } else { $null }
    $modeLabel  = if ($WhatIfPreference) { 'DRY-RUN' } else { 'COPY' }

    $failed    = [System.Collections.Concurrent.ConcurrentBag[string]]::new()
    $succeeded = [System.Collections.Concurrent.ConcurrentBag[string]]::new()
    $logEntries = [System.Collections.Concurrent.ConcurrentBag[string]]::new()

    $logDir = Join-Path $LogDirectory 'logs' "move-fileshares_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
    New-Item -ItemType Directory -Path $logDir -Force -Confirm:$false -WhatIf:$false | Out-Null

    $scriptStart = Get-Date
    Write-Host "`n[$(Get-Timestamp)] Starting parallel copy of $($shares.Count) share(s) with ThrottleLimit=$ThrottleLimit..." -ForegroundColor Cyan
    Write-Host "[$(Get-Timestamp)] Log directory: $logDir" -ForegroundColor Cyan

    # ── Parallel copy ────────────────────────────────────────────────────────
    $shares | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel {
        $share        = $_
        $srcAcct      = $using:SourceAccountName
        $dstAcct      = $using:DestinationAccountName
        $srcSas       = $using:sourceSas
        $dstSas       = $using:destSas
        $dryRun       = $using:dryRunFlag
        $mode         = $using:modeLabel
        $failedBag    = $using:failed
        $succeededBag = $using:succeeded
        $logBag       = $using:logEntries
        $logFile      = Join-Path $using:logDir "$share.log"

        function Write-Log {
            param([string]$Message, [string]$Color = 'White', [string]$LogPath)
            $ts = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')
            $line = "[$ts] [$share] $Message"
            Write-Host $line -ForegroundColor $Color
            $line | Out-File -FilePath $LogPath -Append -Encoding utf8
        }

        $sourceUrl = "https://$srcAcct.file.core.windows.net/$share/?$srcSas"
        $destUrl   = "https://$dstAcct.file.core.windows.net/$share/?$dstSas"

        $shareStart = Get-Date
        Write-Log "Starting $mode..." -Color Yellow -LogPath $logFile
        Write-Log " Source : https://$srcAcct.file.core.windows.net/$share/" -Color Gray -LogPath $logFile
        Write-Log " Dest : https://$dstAcct.file.core.windows.net/$share/" -Color Gray -LogPath $logFile

        $azcopyArgs = @(
            'copy'
            $sourceUrl
            $destUrl
            '--recursive'
            '--preserve-smb-permissions=false'
            '--preserve-smb-info=true'
            '--overwrite=ifSourceNewer'
        )
        if ($dryRun) { $azcopyArgs += $dryRun }

        $azcopyOutput = & azcopy @azcopyArgs 2>&1
        $exitCode = $LASTEXITCODE
        $shareEnd = Get-Date
        $elapsed  = $shareEnd - $shareStart

        foreach ($line in $azcopyOutput) {
            $ts = $shareEnd.ToString('yyyy-MM-dd HH:mm:ss')
            $prefixed = "[$ts] [$share] $line"
            Write-Host $prefixed
            $prefixed | Out-File -FilePath $logFile -Append -Encoding utf8
        }

        if ($exitCode -ne 0) {
            Write-Log "FAILED (exit code $exitCode) after $($elapsed.ToString('hh\:mm\:ss'))" -Color Red -LogPath $logFile
            $failedBag.Add($share)
            $logBag.Add("FAILED | $share | exit=$exitCode | duration=$($elapsed.ToString('hh\:mm\:ss')) | log=$logFile")
        }
        else {
            Write-Log "SUCCEEDED in $($elapsed.ToString('hh\:mm\:ss'))" -Color Green -LogPath $logFile
            $succeededBag.Add($share)
            $logBag.Add("SUCCESS | $share | duration=$($elapsed.ToString('hh\:mm\:ss')) | log=$logFile")
        }
    }

    # ── Summary ──────────────────────────────────────────────────────────────
    $totalElapsed = (Get-Date) - $scriptStart

    Write-Host "`n════════════════════════════════════════════════════════════" -ForegroundColor Cyan
    Write-Host " $modeLabel SUMMARY" -ForegroundColor Cyan
    Write-Host '════════════════════════════════════════════════════════════' -ForegroundColor Cyan
    Write-Host " Total time : $($totalElapsed.ToString('hh\:mm\:ss'))" -ForegroundColor White
    Write-Host " Succeeded : $($succeeded.Count) — $(($succeeded | Sort-Object) -join ', ')" -ForegroundColor Green
    if ($failed.Count -gt 0) {
        Write-Host " Failed : $($failed.Count) — $(($failed | Sort-Object) -join ', ')" -ForegroundColor Red
    }
    Write-Host ''
    Write-Host ' Per-share details:' -ForegroundColor Cyan
    foreach ($entry in ($logEntries | Sort-Object)) {
        $color = if ($entry.StartsWith('SUCCESS')) { 'Green' } else { 'Red' }
        Write-Host " $entry" -ForegroundColor $color
    }
    Write-Host ''
    Write-Host " Log directory: $logDir" -ForegroundColor Cyan
    Write-Host ''

    if ($failed.Count -gt 0) {
        throw "$($failed.Count) file share(s) failed to copy."
    }
}