using System.Globalization; using Microsoft.Extensions.Logging; using PSLauncher.Core.ReportTool; using PSLauncher.Models; namespace PSLauncher.Core.DocTool; /// /// Implémentation : copy récursif + atomic rename + backups horodatés. /// Strictement parallèle à mais avec /// _doc/ comme source au lieu de _report/. Voir le commentaire /// de cette dernière classe pour les détails du mécanisme. /// public sealed class DocToolDeployer : IDocToolDeployer { private const string SourceSubdir = "_doc"; private const string BackupSuffix = ".backup-"; private const string TimestampFormat = "yyyyMMdd-HHmmss"; private readonly Func _configProvider; private readonly ILogger _logger; public DocToolDeployer( Func configProvider, ILogger logger) { _configProvider = configProvider; _logger = logger; } public async Task DeployAsync( string installFolder, IProgress? progress, CancellationToken ct) { var cfg = _configProvider(); var source = Path.Combine(installFolder, SourceSubdir); var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName); if (!Directory.Exists(source)) { _logger.LogInformation("No {Sub}/ directory in {Folder}, skipping doc deploy", SourceSubdir, installFolder); return new DeployResult(DeployStatus.SkippedNoSourceFolder, 0, 0, target); } if (!Directory.Exists(cfg.HtdocsRoot)) { // Crée le dossier htdocs si absent (cf. ReportToolDeployer pour le rationale). try { Directory.CreateDirectory(cfg.HtdocsRoot); _logger.LogWarning( "HtdocsRoot {Root} did not exist, created it. Verify that XAMPP/Apache is configured for this path.", cfg.HtdocsRoot); } catch (Exception ex) { _logger.LogError(ex, "Could not create HtdocsRoot {Root}", cfg.HtdocsRoot); return new DeployResult(DeployStatus.XamppNotFound, 0, 0, target, $"Impossible de créer {cfg.HtdocsRoot} : {ex.Message}"); } } var stagingTarget = target + ".new"; var backupTarget = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture); try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); var allFiles = Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories).ToList(); int total = allFiles.Count; long bytesTotal = 0; await Task.Run(() => { Directory.CreateDirectory(stagingTarget); int done = 0; foreach (var srcFile in allFiles) { ct.ThrowIfCancellationRequested(); var rel = Path.GetRelativePath(source, srcFile); var dstFile = Path.Combine(stagingTarget, rel); var dstDir = Path.GetDirectoryName(dstFile); if (!string.IsNullOrEmpty(dstDir) && !Directory.Exists(dstDir)) Directory.CreateDirectory(dstDir); File.Copy(srcFile, dstFile, overwrite: true); bytesTotal += new FileInfo(srcFile).Length; done++; progress?.Report(new DeployProgress(done, total, rel)); } }, ct).ConfigureAwait(false); if (Directory.Exists(target)) Directory.Move(target, backupTarget); Directory.Move(stagingTarget, target); PruneOldBackups(cfg); _logger.LogInformation("Doc tool deployed to {Target} ({Files} files, {Bytes} bytes), backup at {Backup}", target, total, bytesTotal, backupTarget); return new DeployResult(DeployStatus.Deployed, total, bytesTotal, target); } catch (Exception ex) { _logger.LogError(ex, "Failed to deploy doc tool to {Target}", target); try { if (Directory.Exists(stagingTarget)) Directory.Delete(stagingTarget, recursive: true); } catch { /* ignore */ } return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message); } } public Task> ListBackupsAsync(CancellationToken ct) { var cfg = _configProvider(); var prefix = cfg.FolderName + BackupSuffix; if (!Directory.Exists(cfg.HtdocsRoot)) return Task.FromResult>(Array.Empty()); var list = new List(); foreach (var dir in Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly)) { ct.ThrowIfCancellationRequested(); var name = Path.GetFileName(dir); var tsPart = name.Substring(prefix.Length); if (!DateTime.TryParseExact(tsPart, TimestampFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var ts)) { continue; } long size = 0; try { foreach (var f in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories)) size += new FileInfo(f).Length; } catch { /* size best-effort */ } list.Add(new BackupInfo(name, ts, size)); } list.Sort((a, b) => b.TimestampUtc.CompareTo(a.TimestampUtc)); return Task.FromResult>(list); } public async Task RevertAsync(string backupFolderName, CancellationToken ct) { var cfg = _configProvider(); var backupPath = Path.Combine(cfg.HtdocsRoot, backupFolderName); var target = Path.Combine(cfg.HtdocsRoot, cfg.FolderName); if (!Directory.Exists(backupPath)) { return new DeployResult(DeployStatus.BackupNotFound, 0, 0, target, $"Le backup {backupFolderName} n'existe plus."); } var newBackupForCurrent = target + BackupSuffix + DateTime.UtcNow.ToString(TimestampFormat, CultureInfo.InvariantCulture); try { await Task.Run(() => { ct.ThrowIfCancellationRequested(); if (Directory.Exists(target)) Directory.Move(target, newBackupForCurrent); Directory.Move(backupPath, target); }, ct).ConfigureAwait(false); int files = 0; long bytes = 0; try { foreach (var f in Directory.EnumerateFiles(target, "*", SearchOption.AllDirectories)) { files++; bytes += new FileInfo(f).Length; } } catch { /* best-effort */ } PruneOldBackups(cfg); _logger.LogInformation("Reverted doc to backup {Backup} ({Files} files), previous current saved as {New}", backupFolderName, files, newBackupForCurrent); return new DeployResult(DeployStatus.Deployed, files, bytes, target); } catch (Exception ex) { _logger.LogError(ex, "Doc revert to {Backup} failed", backupFolderName); return new DeployResult(DeployStatus.Failed, 0, 0, target, ex.Message); } } private void PruneOldBackups(DocToolConfig cfg) { try { var prefix = cfg.FolderName + BackupSuffix; var dirs = Directory.EnumerateDirectories(cfg.HtdocsRoot, prefix + "*", SearchOption.TopDirectoryOnly) .Select(d => new { Path = d, Name = Path.GetFileName(d) }) .OrderByDescending(d => d.Name, StringComparer.Ordinal) .ToList(); int keep = Math.Max(0, cfg.MaxBackups); for (int i = keep; i < dirs.Count; i++) { try { Directory.Delete(dirs[i].Path, recursive: true); _logger.LogDebug("Pruned old doc backup {Dir}", dirs[i].Name); } catch (Exception ex) { _logger.LogDebug(ex, "Could not prune doc backup {Dir} (will retry next deploy)", dirs[i].Name); } } } catch (Exception ex) { _logger.LogDebug(ex, "Doc backup pruning skipped"); } } }