From ab17ec1e48db4b5470256848262ba2f233182073 Mon Sep 17 00:00:00 2001 From: Laurent Chedanne Date: Thu, 17 Sep 2026 09:32:15 +0200 Subject: [PATCH] V1 --- Install.md | 83 ++++++++++++ README.md | 116 ++++++++++++++++ wsl-manage-core.ps1 | 318 ++++++++++++++++++++++++++++++++++++++++++++ wsl-manage.cmd | 3 + 4 files changed, 520 insertions(+) create mode 100644 Install.md create mode 100644 README.md create mode 100644 wsl-manage-core.ps1 create mode 100644 wsl-manage.cmd diff --git a/Install.md b/Install.md new file mode 100644 index 0000000..00c52b1 --- /dev/null +++ b/Install.md @@ -0,0 +1,83 @@ +# Installation de wsl-manage + +## 1. Placer les fichiers + +Conservez les fichiers suivants dans un dossier permanent, par exemple : + +```text +E:\Drive\Apps\WSL\ +|-- wsl-manage.cmd +|-- wsl-manage-core.ps1 +|-- README.md +`-- Install.md +``` + +Ne placez pas ce dossier dans un emplacement temporaire. + +## 2. Ajouter le dossier au PATH + +Ouvrez PowerShell et executez : + +```powershell +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +$installPath = 'E:\Drive\Apps\WSL' + +if (($userPath -split ';') -notcontains $installPath) { + [Environment]::SetEnvironmentVariable( + 'Path', + (($userPath.TrimEnd(';') + ';' + $installPath).Trim(';')), + 'User' + ) +} +``` + +Fermez puis ouvrez un nouveau terminal PowerShell pour que le changement soit +pris en compte. + +## 3. Verifier l'installation + +Executez : + +```powershell +Get-Command wsl-manage +``` + +La commande doit pointer vers : + +```text +E:\Drive\Apps\WSL\wsl-manage.cmd +``` + +Affichez ensuite l'aide : + +```powershell +wsl-manage -? +``` + +## 4. Utiliser le script + +Sauvegarde par defaut : + +```powershell +wsl-manage +``` + +Restauration : + +```powershell +wsl-manage restore +``` + +## Depannage + +Si PowerShell tente de charger directement un fichier `.ps1`, verifiez que le +fichier public s'appelle bien `wsl-manage.cmd` et que seul le dossier +d'installation est present dans le `PATH`. + +Le lanceur utilise `-ExecutionPolicy Bypass` uniquement pour cette execution. +Il n'est normalement pas necessaire de modifier la politique d'execution +PowerShell du systeme. + +L'execution en administrateur n'est pas requise pour une sauvegarde standard. +Elle peut etre necessaire pour acceder a certains fichiers VHDX ou les +compacter. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b1dad5 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# wsl-manage + +Outil PowerShell interactif de sauvegarde et de restauration des distributions WSL. +Il gere aussi les donnees Docker Desktop lorsque la distribution `docker` ou +`docker-desktop` est selectionnee. + +## Fichiers + +- `wsl-manage.cmd` : commande a lancer. +- `wsl-manage-core.ps1` : implementation PowerShell. + +Le dossier contenant ces fichiers doit etre dans le `PATH` Windows pour pouvoir +lancer `wsl-manage` depuis n'importe quel terminal. + +## Prerequis + +- Windows avec WSL installe et accessible par `wsl.exe`. +- PowerShell. +- Docker Desktop doit etre completement arrete lorsqu'une sauvegarde ou une + restauration Docker est concernee. +- Le lecteur de destination doit etre accessible et disposer de suffisamment + d'espace libre. + +L'execution en administrateur n'est normalement pas necessaire. Elle peut etre +necessaire pour acceder a certains fichiers VHDX ou effectuer un compactage. + +## Sauvegarde + +Lancer sans argument : + +```powershell +wsl-manage +``` + +Le script : + +1. Liste les distributions WSL disponibles. +2. Demande de sauvegarder toutes les distributions ou une seule. +3. Demande le lecteur de destination et affiche son espace libre. +4. Demande confirmation. +5. Arrete WSL avec `wsl --shutdown`. +6. Exporte les distributions selectionnees au format `.tar`. +7. Affiche une progression estimee et le temps ecoule. + +Les sauvegardes sont rangees dans un dossier date sous `WSL-Backup` : + +```text +D:\WSL-Backup\2026-09-16_20-05-42\ +|-- Ubuntu.tar +|-- Debian.tar +`-- ... +``` + +Le pourcentage affiche est une estimation basee sur l'espace utilise par la +distribution. `wsl.exe --export` ne fournit pas de progression native. + +## Donnees Docker Desktop + +Si `docker` ou `docker-desktop` est selectionne, le script : + +1. Verifie que Docker Desktop est arrete. +2. Demande le chemin des donnees Docker. +3. Utilise par defaut : + +```text +E:\WSL\Docker\desktop-data +``` + +4. Verifie la presence des dossiers `disk` et `main`. +5. Copie ces dossiers dans : + +```text +D:\WSL-Backup\2026-09-16_20-05-42\Docker\ +|-- disk\ +`-- main\ +``` + +La distribution `docker-desktop` exportee en `.tar` contient principalement +les fichiers systeme. Les images, conteneurs et volumes Docker sont dans les +donnees `disk` et `main`. + +## Restauration + +Lancer avec l'argument `restore` : + +```powershell +wsl-manage restore +``` + +Le script : + +1. Demande le lecteur contenant `WSL-Backup`. +2. Demande la date de sauvegarde. +3. Demande une distribution ou `Toutes les machines`. +4. Demande confirmation apres recapitulatif. +5. Arrete WSL. +6. Importe les distributions sous `WSL-Install\DATE`. +7. Restaure les donnees Docker uniquement si `docker` ou `docker-desktop` est + selectionne. + +Une distribution restauree porte le nom de son fichier sans extension : + +```text +Ubuntu.tar -> Ubuntu +``` + +Le dossier d'installation d'une distribution ne doit pas deja exister. + +## Conseils + +- Quitter Docker Desktop depuis son icone de notification avant une operation + Docker. +- Ne pas interrompre la copie des fichiers VHDX. +- Conserver une copie de secours des donnees Docker avant restauration. +- Les anciennes sauvegardes ne contenant pas de dossier `Docker` ne permettent + pas de restaurer les donnees Docker. diff --git a/wsl-manage-core.ps1 b/wsl-manage-core.ps1 new file mode 100644 index 0000000..8928fb4 --- /dev/null +++ b/wsl-manage-core.ps1 @@ -0,0 +1,318 @@ +# Gestion interactive des distributions WSL. +[CmdletBinding()] +param( + [ValidateSet('backup', 'restore')] + [string]$Mode = 'backup' +) + +$ErrorActionPreference = 'Stop' + +function Stop-WithMessage { + param([string]$Message) + Write-Error $Message + exit 1 +} + +if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + Stop-WithMessage 'wsl.exe est introuvable. Lancez ce script depuis Windows avec WSL installe.' +} + +function Invoke-Wsl { + param([string[]]$Arguments) + + $output = & wsl.exe @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + $details = (($output | ForEach-Object { $_.ToString() }) -join ' ').Trim() + if (-not $details) { $details = 'aucun detail fourni par wsl.exe' } + throw ('wsl.exe a retourne le code {0} : {1}' -f $LASTEXITCODE, $details) + } + return $output +} + +function Get-Distros { + @(Invoke-Wsl @('--list', '--quiet') | ForEach-Object { + ($_.ToString() -replace '\x00', '').Trim() + } | Where-Object { $_ }) +} + +function Select-Drive { + $drives = @(Get-PSDrive -PSProvider FileSystem | Where-Object { + $_.Root -and (Test-Path -LiteralPath $_.Root) + } | Sort-Object Name) + + if ($drives.Count -eq 0) { + Stop-WithMessage 'Aucun disque accessible n''a ete trouve.' + } + + Write-Host '' + Write-Host 'Disques disponibles :' -ForegroundColor Cyan + for ($i = 0; $i -lt $drives.Count; $i++) { + $drive = New-Object -TypeName System.IO.DriveInfo -ArgumentList $drives[$i].Root + $free = '{0:N2} Go libres' -f ($drive.AvailableFreeSpace / 1GB) + Write-Host ('[{0}] {1} ({2}) - {3}' -f ($i + 1), $drives[$i].Name, $drives[$i].Root, $free) + } + + $selection = Read-Host ('Choisissez le disque (1-{0})' -f $drives.Count) + $driveIndex = 0 + if (-not [int]::TryParse($selection, [ref]$driveIndex) -or + $driveIndex -lt 1 -or $driveIndex -gt $drives.Count) { + Stop-WithMessage 'Selection de disque invalide.' + } + return $drives[$driveIndex - 1] +} + +function Confirm-Action { + param([string]$Message) + $answer = Read-Host "$Message (o/n)" + return $answer -match '^(o|oui|y|yes)$' +} + +function Get-DockerDataPath { + param([bool]$RequireData) + $defaultPath = 'E:\WSL\Docker\desktop-data' + $path = Read-Host ('Chemin des donnees Docker Desktop [{0}]' -f $defaultPath) + if (-not $path) { $path = $defaultPath } + $path = [IO.Path]::GetFullPath($path) + if ($RequireData -and (-not (Test-Path -LiteralPath (Join-Path $path 'disk')) -or + -not (Test-Path -LiteralPath (Join-Path $path 'main')))) { + Stop-WithMessage ('Le chemin Docker doit contenir les dossiers disk et main : {0}' -f $path) + } + if (-not $RequireData -and -not (Test-Path -LiteralPath $path)) { + New-Item -ItemType Directory -Path $path -Force | Out-Null + } + return $path +} + +function Assert-DockerStopped { + $dockerProcesses = @(Get-Process -ErrorAction SilentlyContinue | Where-Object { + $_.ProcessName -in @('Docker Desktop', 'com.docker.backend', 'com.docker.proxy', 'com.docker.build') + }) + if ($dockerProcesses.Count -gt 0) { + Stop-WithMessage 'Docker Desktop est encore en cours d''execution. Quittez-le completement puis relancez la commande.' + } +} + +function Copy-DockerData { + param([string]$SourceRoot, [string]$DestinationRoot) + New-Item -ItemType Directory -Path $DestinationRoot -Force | Out-Null + foreach ($folder in @('disk', 'main')) { + $source = Join-Path $SourceRoot $folder + $destination = Join-Path $DestinationRoot $folder + New-Item -ItemType Directory -Path $destination -Force | Out-Null + & robocopy.exe $source $destination /E /COPY:DAT /DCOPY:DAT /R:2 /W:2 /XJ + if ($LASTEXITCODE -gt 7) { + throw ('La copie Docker a echoue pour {0} (code robocopy {1}).' -f $folder, $LASTEXITCODE) + } + } +} + +function Get-DistroSize { + param([string]$Distro) + try { + $sizeOutput = & wsl.exe --distribution $Distro -- df -B1 --output=used / 2>$null + $value = @($sizeOutput | ForEach-Object { $_.ToString() -replace '\x00', '' } | + Where-Object { $_ -match '^\s*\d+\s*$' } | Select-Object -Last 1) + if ($value) { return [int64]$value.Trim() } + } catch { } + return 0 +} + +function Export-Distro { + param( + [string]$Distro, + [string]$Archive, + [int64]$EstimatedBytes + ) + + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = 'wsl.exe' + $psi.Arguments = '--export "' + $Distro.Replace('"', '\"') + '" "' + $Archive.Replace('"', '\"') + '"' + $psi.UseShellExecute = $false + $psi.CreateNoWindow = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $process = New-Object System.Diagnostics.Process + $process.StartInfo = $psi + [void]$process.Start() + $stdoutTask = $process.StandardOutput.ReadToEndAsync() + $stderrTask = $process.StandardError.ReadToEndAsync() + $started = Get-Date + do { + Start-Sleep -Milliseconds 500 + $percent = 0 + if ($EstimatedBytes -gt 0 -and (Test-Path -LiteralPath $Archive)) { + $percent = [math]::Min(99, [int](100 * ((Get-Item -LiteralPath $Archive).Length / $EstimatedBytes))) + } + $elapsed = ((Get-Date) - $started).ToString('hh\:mm\:ss') + Write-Progress -Activity ('Export de {0}' -f $Distro) -Status ('{0}% - {1} ecoule' -f $percent, $elapsed) -PercentComplete $percent + } while (-not $process.HasExited) + $process.WaitForExit() + Write-Progress -Activity ('Export de {0}' -f $Distro) -Completed + if ($process.ExitCode -ne 0) { + $details = $stderrTask.Result.Trim() + if (-not $details) { $details = 'aucun detail fourni par wsl.exe' } + throw ('wsl.exe a retourne le code {0} : {1}' -f $process.ExitCode, $details) + } +} + +if ($Mode -eq 'restore') { + $drive = Select-Drive + $backupRoot = Join-Path $drive.Root 'WSL-Backup' + $dates = @(Get-ChildItem -LiteralPath $backupRoot -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending) + if ($dates.Count -eq 0) { + Stop-WithMessage ('Aucune sauvegarde trouvee dans {0}.' -f $backupRoot) + } + + Write-Host '' + Write-Host 'Sauvegardes disponibles :' -ForegroundColor Cyan + for ($i = 0; $i -lt $dates.Count; $i++) { + Write-Host ('[{0}] {1}' -f ($i + 1), $dates[$i].Name) + } + $dateSelection = Read-Host ('Choisissez une date (1-{0})' -f $dates.Count) + $dateIndex = 0 + if (-not [int]::TryParse($dateSelection, [ref]$dateIndex) -or + $dateIndex -lt 1 -or $dateIndex -gt $dates.Count) { + Stop-WithMessage 'Selection de date invalide.' + } + $dateFolder = $dates[$dateIndex - 1] + $dockerBackupPath = Join-Path $dateFolder.FullName 'Docker' + $archives = @(Get-ChildItem -LiteralPath $dateFolder.FullName -File | + Where-Object { $_.Name -match '\.tar(\.gz|\.xz)?$' } | Sort-Object Name) + if ($archives.Count -eq 0) { + Stop-WithMessage 'Cette sauvegarde ne contient aucune archive exploitable.' + } + + Write-Host '' + Write-Host 'Machines disponibles :' -ForegroundColor Cyan + Write-Host '[1] Toutes les machines' + for ($i = 0; $i -lt $archives.Count; $i++) { + Write-Host ('[{0}] {1}' -f ($i + 2), $archives[$i].Name) + } + $machineSelection = Read-Host ('Choisissez une machine (1-{0})' -f ($archives.Count + 1)) + $machineIndex = 0 + if (-not [int]::TryParse($machineSelection, [ref]$machineIndex) -or + $machineIndex -lt 1 -or $machineIndex -gt ($archives.Count + 1)) { + Stop-WithMessage 'Selection de machine invalide.' + } + if ($machineIndex -eq 1) { $selectedArchives = $archives } else { $selectedArchives = @($archives[$machineIndex - 2]) } + + $restoreDocker = @($selectedArchives | Where-Object { + (($_.Name -replace '\.tar(\.gz|\.xz)?$', '') -ieq 'docker' -or + ($_.Name -replace '\.tar(\.gz|\.xz)?$', '') -ieq 'docker-desktop') + }).Count -gt 0 + if ($restoreDocker) { + if (-not (Test-Path -LiteralPath (Join-Path $dockerBackupPath 'disk')) -or + -not (Test-Path -LiteralPath (Join-Path $dockerBackupPath 'main'))) { + Stop-WithMessage 'Cette sauvegarde ne contient pas les donnees Docker (disk et main).' + } + Assert-DockerStopped + $dockerDataPath = Get-DockerDataPath $false + } + + Write-Host '' + Write-Host 'Recapitulatif :' -ForegroundColor Cyan + Write-Host (' Date : {0}' -f $dateFolder.Name) + Write-Host (' Machine(s) : {0}' -f (($selectedArchives | ForEach-Object { $_.BaseName -replace '\.tar$' }) -join ', ')) + if ($restoreDocker) { Write-Host (' Donnees Docker : {0}' -f $dockerDataPath) } + Write-Host (' Lecteur : {0}' -f $drive.Root) + if (-not (Confirm-Action 'Lancer la restauration')) { Write-Host 'Operation annulee.'; exit 0 } + + try { Invoke-Wsl @('--shutdown') | Out-Null } catch { Stop-WithMessage ('Impossible d''arreter WSL : {0}' -f $_.Exception.Message) } + if ($restoreDocker) { try { + Copy-DockerData -SourceRoot $dockerBackupPath -DestinationRoot $dockerDataPath + Write-Host 'Donnees Docker restaurees.' -ForegroundColor Green + } catch { Stop-WithMessage ('Restauration Docker impossible : {0}' -f $_.Exception.Message) } } + $failed = @() + $installRoot = Join-Path $drive.Root ('WSL-Install\' + $dateFolder.Name) + New-Item -ItemType Directory -Path $installRoot -Force | Out-Null + foreach ($archive in $selectedArchives) { + $name = $archive.Name -replace '\.tar(\.gz|\.xz)?$', '' + $installPath = Join-Path $installRoot $name + try { + if (Test-Path -LiteralPath $installPath) { + throw ('Le dossier d''installation existe deja : {0}' -f $installPath) + } + Invoke-Wsl @('--import', $name, $installPath, $archive.FullName) | Out-Null + Write-Host ('Restauration OK : {0}' -f $name) -ForegroundColor Green + } catch { $failed += $name; Write-Warning ('Echec pour {0} : {1}' -f $name, $_.Exception.Message) } + } + if ($failed.Count -gt 0) { Stop-WithMessage ('Restauration terminee avec erreurs : {0}' -f ($failed -join ', ')) } + Write-Host 'Restauration terminee avec succes.' -ForegroundColor Green + exit 0 +} + +$distros = Get-Distros + +if ($distros.Count -eq 0) { + Stop-WithMessage 'Aucune distribution WSL n''a ete trouvee.' +} + +Write-Host '' +Write-Host 'Distributions a sauvegarder :' -ForegroundColor Cyan +Write-Host '[1] Toutes les distributions' +for ($i = 0; $i -lt $distros.Count; $i++) { + Write-Host ('[{0}] {1}' -f ($i + 2), $distros[$i]) +} +$distroSelection = Read-Host ('Choisissez une option (1-{0})' -f ($distros.Count + 1)) +$distroIndex = 0 +if (-not [int]::TryParse($distroSelection, [ref]$distroIndex) -or + $distroIndex -lt 1 -or $distroIndex -gt ($distros.Count + 1)) { + Stop-WithMessage 'Selection de distribution invalide.' +} +if ($distroIndex -eq 1) { $selectedDistros = $distros } else { $selectedDistros = @($distros[$distroIndex - 2]) } + +$includeDockerData = @($selectedDistros | Where-Object { + $_ -ieq 'docker' -or $_ -ieq 'docker-desktop' +}).Count -gt 0 +if ($includeDockerData) { + Assert-DockerStopped + $dockerDataPath = Get-DockerDataPath $true +} + +$drive = Select-Drive +$backupRoot = Join-Path -Path $drive.Root -ChildPath 'WSL-Backup' +$backupFolder = Join-Path -Path $backupRoot -ChildPath (Get-Date -Format 'yyyy-MM-dd_HH-mm-ss') +New-Item -ItemType Directory -Path $backupFolder -Force | Out-Null +Write-Host ('Sauvegarde dans : {0}' -f $backupFolder) -ForegroundColor Green +Write-Host ('Distributions : {0}' -f ($selectedDistros -join ', ')) +$freeSpace = New-Object -TypeName System.IO.DriveInfo -ArgumentList $drive.Root +Write-Host ('Espace libre : {0:N2} Go' -f ($freeSpace.AvailableFreeSpace / 1GB)) -ForegroundColor Cyan +$estimatedSizes = @{} +foreach ($distro in $selectedDistros) { $estimatedSizes[$distro] = Get-DistroSize $distro } +if (-not (Confirm-Action 'Lancer la sauvegarde')) { Remove-Item -LiteralPath $backupFolder -Force; Write-Host 'Operation annulee.'; exit 0 } + +try { Invoke-Wsl @('--shutdown') | Out-Null } catch { Stop-WithMessage ('Impossible d''arreter WSL : {0}' -f $_.Exception.Message) } + +if ($includeDockerData) { + $dockerBackupPath = Join-Path $backupFolder 'Docker' + try { + Copy-DockerData -SourceRoot $dockerDataPath -DestinationRoot $dockerBackupPath + Write-Host ('Donnees Docker sauvegardees dans : {0}' -f $dockerBackupPath) -ForegroundColor Green + } catch { + Stop-WithMessage ('Sauvegarde Docker impossible : {0}' -f $_.Exception.Message) + } +} + +$failed = @() +foreach ($distro in $selectedDistros) { + $archive = Join-Path -Path $backupFolder -ChildPath ($distro + '.tar') + Write-Host ('Export de {0}...' -f $distro) -ForegroundColor Yellow + + try { + Export-Distro -Distro $distro -Archive $archive -EstimatedBytes $estimatedSizes[$distro] + Write-Host (' OK : {0}' -f $archive) -ForegroundColor Green + } + catch { + $failed += $distro + Write-Warning ('Echec pour {0} : {1}' -f $distro, $_.Exception.Message) + } +} + +if ($failed.Count -gt 0) { + Write-Warning ('Sauvegarde terminee avec des erreurs : {0}' -f ($failed -join ', ')) + exit 1 +} + +Write-Host '' +Write-Host 'Sauvegarde terminee avec succes.' -ForegroundColor Green diff --git a/wsl-manage.cmd b/wsl-manage.cmd new file mode 100644 index 0000000..0d3ecc0 --- /dev/null +++ b/wsl-manage.cmd @@ -0,0 +1,3 @@ +@echo off +chcp 65001 >nul +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0wsl-manage-core.ps1" %*