using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading; namespace OpenTap.Package { /// /// Image that specifies a list of to install and a list of repositories to get the packages from. /// public class ImageIdentifier { /// /// A delegate used by /// /// Indicates progress from 0 to 100. /// public delegate void ProgressUpdateDelegate(int progressPercent, string message); /// /// Called by the action to indicate how far it has gotten. Will usually be called with a progressPercent of 100 to indicate that it is done. /// public event ProgressUpdateDelegate ProgressUpdate; internal bool Cached => Packages.All(s => CachedLocation(s) != null); static TraceSource log = Log.CreateSource("OpenTAP"); /// /// Image ID created by hashing the list /// public string Id { get; } /// /// Package configuration of the Image /// public ReadOnlyCollection Packages { get; } /// /// Repositories to retrieve the packages from /// public ReadOnlyCollection Repositories { get; } /// /// An is immutable, but can be converted to an which is mutable. /// /// public ImageSpecifier ToSpecifier() { return new ImageSpecifier() { Packages = Packages.Select(s => new PackageSpecifier(s)).ToList(), Repositories = Repositories.ToList() }; } internal ImageIdentifier(IEnumerable packages, IEnumerable repositories) { if (packages is null) { throw new ArgumentNullException(nameof(packages)); } if (repositories is null) { throw new ArgumentNullException(nameof(repositories)); } var packageList = packages.OrderBy(s => s.Name).ToList(); Id = CalculateId(packageList); Repositories = new ReadOnlyCollection(repositories.ToArray()); Packages = new ReadOnlyCollection(packageList); } private string CalculateId(IEnumerable packageList) { List packageHashes = new List(); foreach (PackageDef pkg in packageList) { if (pkg.Hash != null) packageHashes.Add(pkg.Hash); else { // This can happen if the package was created with OpenTAP < 9.16 that did not set the Hash property. // We can just try to compute the hash now. try { packageHashes.Add(pkg.ComputeHash()); } catch { // This might happen if the PackageDef does not contain elements for each file (for packages crated with OpenTAP < 9.5). // In this case, just use the fields from IPackageIdentifier, they should be unique in most cases. packageHashes.Add($"{pkg.Name} {pkg.Version} {pkg.Architecture} {pkg.OS}"); } } } using var algorithm = SHA1.Create(); var bytes = algorithm.ComputeHash(Encoding.UTF8.GetBytes(string.Join(",", packageHashes))); return BitConverter.ToString(bytes).Replace("-", ""); } /// /// Deploy the as a OpenTAP installation. /// /// Directory to deploy OpenTap installation. /// If the directory is already an OpenTAP installation, the installation will be modified to match the image /// System-Wide packages are not removed /// /// Cancellation token public void Deploy(string targetDir, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) throw new OperationCanceledException("Deployment operation cancelled by user"); Directory.CreateDirectory(targetDir); Installation currentInstallation = new Installation(targetDir); Deploy(currentInstallation, Packages.ToList(), ProgressUpdate, cancellationToken); } /// /// Download all packages to the PackageCache. This is an optional step that can speed up deploying later. /// public void Cache() { if (Cached) return; foreach (var package in Packages) Download(package, null, TapThread.Current.AbortToken); } private static void Deploy(Installation currentInstallation, List dependencies, ProgressUpdateDelegate progress, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) throw new OperationCanceledException("Deployment operation cancelled by user"); var currentPackages = currentInstallation.GetPackages(validOnly: true); var skippedPackages = dependencies.Where(s => currentPackages.Any(p => p.Name == s.Name && p.Version.ToString() == s.Version.ToString())).ToHashSet(); var modifyOrAdd = dependencies.Where(s => !skippedPackages.Contains(s)).ToList(); var packagesToUninstall = currentPackages.Where(pack => !dependencies.Any(p => p.Name == pack.Name) && pack.Class.ToLower() != "system-wide").ToList(); // Uninstall installed packages which are not part of image var versionMismatch = currentPackages.Where(pack => dependencies.Any(p => p.Name == pack.Name && p.Version != pack.Version)).ToList(); // Uninstall installed packages where we're deploying another version if (!packagesToUninstall.Any() && !modifyOrAdd.Any()) { log.Info($"Target installation is already up to date."); return; } if (!currentPackages.Any()) log.Info($"Deploying installation in {currentInstallation.Directory}:"); else log.Info($"Modifying installation in {currentInstallation.Directory}:"); foreach (var package in skippedPackages) log.Info($"- Skipping {package.Name} version {package.Version} ({package.Architecture}-{package.OS}) - Already installed."); foreach (var package in packagesToUninstall) log.Info($"- Removing {package.Name} version {package.Version} ({package.Architecture}-{package.OS})"); foreach (var package in versionMismatch) log.Info($"- Modifying {package.Name} version {package.Version} to {dependencies.FirstOrDefault(s => s.Name == package.Name).Version}"); foreach (var package in modifyOrAdd.Except(packagesToUninstall)) log.Info($"- Installing {package.Name} version {package.Version}"); packagesToUninstall.AddRange(versionMismatch); if (cancellationToken.IsCancellationRequested) throw new OperationCanceledException("Deployment operation cancelled by user"); if (packagesToUninstall.Any()) Uninstall(packagesToUninstall, currentInstallation.Directory, progress, cancellationToken); if (cancellationToken.IsCancellationRequested) throw new OperationCanceledException("Deployment operation cancelled by user"); if (modifyOrAdd.Any()) Install(modifyOrAdd, currentInstallation.Directory, progress, cancellationToken); } private static void Install(IEnumerable modifyOrAdd, string target, ProgressUpdateDelegate progress, CancellationToken cancellationToken) { progress ??= (percent, message) => { }; var packagesInOrder = OrderPackagesForInstallation(modifyOrAdd); var cnt = packagesInOrder.Count; var downloaded = 0; // Download progress is the cumulative download progress divided by 2 void downloadProgress(int percent, string message) { var completed = Percent(downloaded + (double)percent/100, cnt); progress(completed / 2, message); } List paths = new List(); for (var i = 0; i < cnt; i++) { var package = packagesInOrder[i]; // If the package is a file, download it directory instead of caching it if (package.PackageSource is IFilePackageDefSource fd) { paths.Add(fd.PackageFilePath); } else { if (CachedLocation(package) is string cachedLocation) log.Info($"Package {package.Name} exists in cache: {cachedLocation}"); else Download(package, downloadProgress, cancellationToken); paths.Add(CachedLocation(package)); } downloaded = i + 1; downloadProgress(Percent(downloaded, cnt), $"Downloaded {package.Name}"); } // installProgress already accounts for packages that were already downloaded, so the Installer installer = new Installer(target, cancellationToken) { DoSleep = false }; installer.ProgressUpdate += (percent, message) => progress(percent, message); installer.PackagePaths.Clear(); installer.PackagePaths.AddRange(paths); List installErrors = new List(); installer.Error += ex => installErrors.Add(ex); try { installer.InstallThread(); } catch (Exception ex) { installErrors.Add(ex); } if (installErrors.Any()) throw new AggregateException("Image deployment failed to install packages.", installErrors); } private static void Uninstall(IEnumerable packagesToUninstall, string target, ProgressUpdateDelegate progress, CancellationToken cancellationToken) { var orderedPackagesToUninstall = OrderPackagesForInstallation(packagesToUninstall); orderedPackagesToUninstall.Reverse(); List uninstallErrors = new List(); var newInstaller = new Installer(target, cancellationToken) { DoSleep = false }; newInstaller.ProgressUpdate += (percent, message) => progress?.Invoke(percent, message); newInstaller.Error += ex => uninstallErrors.Add(ex); newInstaller.DoSleep = false; newInstaller.PackagePaths.AddRange(orderedPackagesToUninstall.Select(x => (x.PackageSource as XmlPackageDefSource)?.PackageDefFilePath).ToList()); int exitCode = newInstaller.RunCommand(Installer.PrepareUninstall, false, false); if (uninstallErrors.Any() || exitCode != 0) throw new AggregateException("Image deployment failed to uninstall existing packages.", uninstallErrors); exitCode = newInstaller.RunCommand(Installer.Uninstall, false, true); if (uninstallErrors.Any() || exitCode != 0) throw new AggregateException("Image deployment failed to uninstall existing packages.", uninstallErrors); } private static List OrderPackagesForInstallation(IEnumerable packages) { var toInstall = new List(); var toBeSorted = packages.ToList(); while (toBeSorted.Count() > 0) { var packagesWithNoRemainingDepsInList = toBeSorted.Where(pkg => pkg.Dependencies.All(dep => !toBeSorted.Any(p => p.Name == dep.Name))).ToList(); toInstall.AddRange(packagesWithNoRemainingDepsInList); toBeSorted.RemoveAll(p => packagesWithNoRemainingDepsInList.Contains(p)); } return toInstall; } private static int Percent(double n, double of) { return (int)(n / of * 100); } private static void Download(PackageDef package, ProgressUpdateDelegate progress, CancellationToken token) { progress ??= (percent, message) => { }; if (CachedLocation(package) is string cachedLocation) { log.Info($"Package {package.Name} exists in cache: {cachedLocation}"); return; } string filename = PackageCacheHelper.GetCacheFilePath(package); Directory.CreateDirectory(Path.GetDirectoryName(filename)); if (package.PackageSource is IFilePackageDefSource fileSource) { File.Copy(fileSource.PackageFilePath, filename); } else if (package.PackageSource is IRepositoryPackageDefSource repoSource) { IPackageRepository rm = PackageRepositoryHelpers.DetermineRepositoryType(repoSource.RepositoryUrl); if (rm is IPackageDownloadProgress p) { p.OnProgressUpdate += (message, pos, len) => { progress(Percent(pos, len), message); }; } log.Info($"Downloading {package.Name} version {package.Version} from {rm.Url}"); rm.DownloadPackage(package, filename, token); } else if (package.PackageSource is InstalledPackageDefSource) { throw new Exception( $"Unable to download package {package.Name} since it is only available as an installed package."); } else { throw new Exception( $"Unable to downlaod package {package.Name} because its source type '{package.PackageSource.GetType().Name}' is not supported."); } } private static string CachedLocation(PackageDef package) { string filename = PackageCacheHelper.GetCacheFilePath(package); if (File.Exists(filename)) { return filename; } return null; } } }