File: BuiltInManagedProvider\GlobalSettingsTemplatePackageProvider.cs
Web Access
Project: src\src\sdk\src\TemplateEngine\Microsoft.TemplateEngine.Edge\Microsoft.TemplateEngine.Edge.csproj (Microsoft.TemplateEngine.Edge)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.Extensions.Logging;
using Microsoft.TemplateEngine.Abstractions;
using Microsoft.TemplateEngine.Abstractions.Installer;
using Microsoft.TemplateEngine.Abstractions.TemplatePackage;

namespace Microsoft.TemplateEngine.Edge.BuiltInManagedProvider
{
    internal class GlobalSettingsTemplatePackageProvider : IManagedTemplatePackageProvider, IDisposable
    {
        private const string DebugLogCategory = "Installer";

        private readonly ILogger _logger;
        private readonly Dictionary<Guid, IInstaller> _installersByGuid = new Dictionary<Guid, IInstaller>();
        private readonly Dictionary<string, IInstaller> _installersByName = new Dictionary<string, IInstaller>();
        private readonly GlobalSettings _globalSettings;
        private volatile bool _disposed;

        public GlobalSettingsTemplatePackageProvider(GlobalSettingsTemplatePackageProviderFactory factory, IEngineEnvironmentSettings settings)
        {
            Factory = factory ?? throw new ArgumentNullException(nameof(factory));
            IEngineEnvironmentSettings environmentSettings = settings ?? throw new ArgumentNullException(nameof(settings));
            _logger = settings.Host.LoggerFactory.CreateLogger<GlobalSettingsTemplatePackageProvider>();

            string packagesFolder = Path.Combine(settings.Paths.GlobalSettingsDir, "packages");
            if (!settings.Host.FileSystem.DirectoryExists(packagesFolder))
            {
                settings.Host.FileSystem.CreateDirectory(packagesFolder);
            }
            foreach (var installerFactory in settings.Components.OfType<IInstallerFactory>())
            {
                var installer = installerFactory.CreateInstaller(settings, packagesFolder);

                //this provider cannot work with installers that do not implement ISerializableInstaller
                if (installer is ISerializableInstaller)
                {
                    _installersByName[installerFactory.Name] = installer;
                    _installersByGuid[installerFactory.Id] = installer;
                }
            }

            string globalSettingsFilePath = Path.Combine(environmentSettings.Paths.GlobalSettingsDir, "packages.json");
            _globalSettings = new GlobalSettings(environmentSettings, globalSettingsFilePath);
            // We can't just add "SettingsChanged+=TemplatePackagesChanged", because TemplatePackagesChanged is null at this time.
            _globalSettings.SettingsChanged += OnGlobalSettingsChanged;
        }

        public event Action? TemplatePackagesChanged;

        public ITemplatePackageProviderFactory Factory { get; }

        public async Task<IReadOnlyList<ITemplatePackage>> GetAllTemplatePackagesAsync(CancellationToken cancellationToken)
        {
            var list = new List<ITemplatePackage>();
            foreach (TemplatePackageData entry in await _globalSettings.GetInstalledTemplatePackagesAsync(cancellationToken).ConfigureAwait(false))
            {
                if (_installersByGuid.TryGetValue(entry.InstallerId, out var installer))
                {
                    try
                    {
                        list.Add(((ISerializableInstaller)installer).Deserialize(this, entry));
                    }
                    catch (Exception e)
                    {
                        _logger.LogDebug($"[{Factory.DisplayName}] Failed to deserialize template package data entry {entry.MountPointUri}, details: {e}.", DebugLogCategory);
                        //adding template package as non-managed
                        list.Add(new TemplatePackage(this, entry.MountPointUri, entry.LastChangeTime));
                    }
                }
                else
                {
                    list.Add(new TemplatePackage(this, entry.MountPointUri, entry.LastChangeTime));
                }
            }
            return list;
        }

        public async Task<IReadOnlyList<CheckUpdateResult>> GetLatestVersionsAsync(IEnumerable<IManagedTemplatePackage> packages, CancellationToken cancellationToken)
        {
            _ = packages ?? throw new ArgumentNullException(nameof(packages));

            var tasks = new List<Task<IReadOnlyList<CheckUpdateResult>>>();
            foreach (var packagesGroupedByInstaller in packages.GroupBy(s => s.Installer))
            {
                tasks.Add(packagesGroupedByInstaller.Key.GetLatestVersionAsync(packagesGroupedByInstaller, this, cancellationToken));
            }
            await Task.WhenAll(tasks).ConfigureAwait(false);

            var result = new List<CheckUpdateResult>();
            foreach (var task in tasks)
            {
                result.AddRange(task.Result);
            }

            await UpdateTemplatePackagesMetadataAsync(result.Select(r => r.TemplatePackage), cancellationToken).ConfigureAwait(false);

            return result;
        }

        /// <inheritdoc/>
        /// <exception cref="ArgumentException">when <paramref name="installRequests"/> has duplicate identifiers for given installer.</exception>
        public async Task<IReadOnlyList<InstallResult>> InstallAsync(IEnumerable<InstallRequest> installRequests, CancellationToken cancellationToken)
        {
            _ = installRequests ?? throw new ArgumentNullException(nameof(installRequests));
            if (!installRequests.Any())
            {
                return new List<InstallResult>();
            }

            //validate that install requests are different - install requests should have unique identifier for given installer
            HashSet<InstallRequest> uniqueInstallRequests = new HashSet<InstallRequest>(new InstallRequestEqualityComparer());
            foreach (InstallRequest installRequest in installRequests)
            {
                if (uniqueInstallRequests.Add(installRequest))
                {
                    continue;
                }
                throw new ArgumentException($"{nameof(installRequests)} has duplicate install requests", nameof(installRequest));
            }

            using var disposable = await _globalSettings.LockAsync(cancellationToken).ConfigureAwait(false);
            var packages = new List<TemplatePackageData>(await _globalSettings.GetInstalledTemplatePackagesAsync(cancellationToken).ConfigureAwait(false));
            var results = await Task.WhenAll(installRequests.Select(async installRequest =>
            {
                var installersThatCanInstall = new List<IInstaller>();
                foreach (var install in _installersByName.Values)
                {
                    if (await install.CanInstallAsync(installRequest, cancellationToken).ConfigureAwait(false))
                    {
                        installersThatCanInstall.Add(install);
                    }
                }
                if (installersThatCanInstall.Count == 0)
                {
                    return InstallResult.CreateFailure(
                        installRequest,
                        InstallerErrorCode.UnsupportedRequest,
                        string.Format(LocalizableStrings.GlobalSettingsTemplatePackageProvider_InstallResult_Error_PackageCannotBeInstalled, installRequest.PackageIdentifier),
                        []);
                }
                if (installersThatCanInstall.Count > 1)
                {
                    return InstallResult.CreateFailure(
                        installRequest,
                        InstallerErrorCode.UnsupportedRequest,
                        string.Format(LocalizableStrings.GlobalSettingsTemplatePackageProvider_InstallResult_Error_MultipleInstallersCanBeUsed, installRequest.PackageIdentifier),
                        []);
                }

                IInstaller installer = installersThatCanInstall[0];
                return await InstallAsync(packages, installRequest, installer, cancellationToken).ConfigureAwait(false);
            })).ConfigureAwait(false);
            await _globalSettings.SetInstalledTemplatePackagesAsync(packages, cancellationToken).ConfigureAwait(false);
            return results;
        }

        public async Task<IReadOnlyList<UninstallResult>> UninstallAsync(IEnumerable<IManagedTemplatePackage> packages, CancellationToken cancellationToken)
        {
            _ = packages ?? throw new ArgumentNullException(nameof(packages));
            if (!packages.Any())
            {
                return new List<UninstallResult>();
            }

            using var disposable = await _globalSettings.LockAsync(cancellationToken).ConfigureAwait(false);

            var packagesInSettings = new List<TemplatePackageData>(await _globalSettings.GetInstalledTemplatePackagesAsync(cancellationToken).ConfigureAwait(false));
            var results = await Task.WhenAll(packages.Select(async package =>
             {
                 UninstallResult result = await package.Installer.UninstallAsync(package, this, cancellationToken).ConfigureAwait(false);
                 if (result.Success)
                 {
                     lock (packagesInSettings)
                     {
                         packagesInSettings.RemoveAll(p => p.MountPointUri == package.MountPointUri);
                     }
                 }
                 return result;
             })).ConfigureAwait(false);
            await _globalSettings.SetInstalledTemplatePackagesAsync(packagesInSettings, cancellationToken).ConfigureAwait(false);
            return results;
        }

        public async Task<IReadOnlyList<UpdateResult>> UpdateAsync(IEnumerable<UpdateRequest> updateRequests, CancellationToken cancellationToken)
        {
            _ = updateRequests ?? throw new ArgumentNullException(nameof(updateRequests));
            IEnumerable<UpdateRequest> updatesToApply = updateRequests.Where(request => request.Version != request.TemplatePackage.Version);

            using var disposable = await _globalSettings.LockAsync(cancellationToken).ConfigureAwait(false);

            var packages = new List<TemplatePackageData>(await _globalSettings.GetInstalledTemplatePackagesAsync(cancellationToken).ConfigureAwait(false));
            var results = await Task.WhenAll(updatesToApply.Select(updateRequest => UpdateAsync(packages, updateRequest, cancellationToken))).ConfigureAwait(false);
            await _globalSettings.SetInstalledTemplatePackagesAsync(packages, cancellationToken).ConfigureAwait(false);
            return results;
        }

        public void Dispose()
        {
            _disposed = true;
            _globalSettings.SettingsChanged -= OnGlobalSettingsChanged;
            _globalSettings.Dispose();
        }

        private void OnGlobalSettingsChanged()
        {
            // Guard against SettingsChanged firing during cascading disposal: Dispose() sets _disposed
            // then unsubscribes, but an in-flight callback may already be past the delegate check.
            if (_disposed)
            {
                return;
            }
            TemplatePackagesChanged?.Invoke();
        }

        private async Task UpdateTemplatePackagesMetadataAsync(IEnumerable<IManagedTemplatePackage> templatePackages, CancellationToken cancellationToken)
        {
            _ = templatePackages ?? throw new ArgumentNullException(nameof(templatePackages));
            var updatedPackages = new List<TemplatePackageData>(templatePackages.Select(tp => ((ISerializableInstaller)tp.Installer).Serialize(tp)));
            var cachedPackages = await _globalSettings.GetInstalledTemplatePackagesAsync(cancellationToken).ConfigureAwait(false);

            var updatedCachePackages = cachedPackages.Select(cp =>
            {
                var updatedPackage = updatedPackages.FirstOrDefault(up => up.MountPointUri == cp.MountPointUri);
                return updatedPackage ?? cp;
            }).ToArray();
            using var disposable = await _globalSettings.LockAsync(cancellationToken).ConfigureAwait(false);
            await _globalSettings.SetInstalledTemplatePackagesAsync(updatedCachePackages, cancellationToken).ConfigureAwait(false);
        }

        private async Task<UpdateResult> UpdateAsync(List<TemplatePackageData> packages, UpdateRequest updateRequest, CancellationToken cancellationToken)
        {
            (InstallerErrorCode result, string message) = await EnsureInstallPrerequisites(packages, updateRequest.TemplatePackage.Identifier, updateRequest.Version, updateRequest.TemplatePackage.Installer, cancellationToken, update: true).ConfigureAwait(false);
            if (result != InstallerErrorCode.Success)
            {
                return UpdateResult.CreateFailure(updateRequest, result, message, []);
            }

            UpdateResult updateResult = await updateRequest.TemplatePackage.Installer.UpdateAsync(updateRequest, provider: this, cancellationToken).ConfigureAwait(false);
            if (!updateResult.Success)
            {
                return updateResult;
            }

            if (updateResult.TemplatePackage is null)
            {
                throw new InvalidOperationException($"{nameof(updateResult.TemplatePackage)} cannot be null when {nameof(updateResult.Success)} is 'true'");
            }

            lock (packages)
            {
                packages.Add(((ISerializableInstaller)updateRequest.TemplatePackage.Installer).Serialize(updateResult.TemplatePackage));
            }
            return updateResult;
        }

        private async Task<(InstallerErrorCode, string)> EnsureInstallPrerequisites(List<TemplatePackageData> packagesInSettings, string identifier, string? version, IInstaller installer, CancellationToken cancellationToken, bool update = false, bool forceUpdate = false)
        {
            var packages = await GetAllTemplatePackagesAsync(cancellationToken).ConfigureAwait(false);

            //check if the package with same identifier is already installed
            if (packages.OfType<IManagedTemplatePackage>().FirstOrDefault(s => s.Identifier == identifier && s.Installer == installer) is IManagedTemplatePackage packageToBeUpdated)
            {
                //if same version is already installed - return
                if (!forceUpdate && packageToBeUpdated.Version == version)
                {
                    return (InstallerErrorCode.AlreadyInstalled, string.Format(LocalizableStrings.GlobalSettingsTemplatePackageProvider_InstallResult_Error_PackageAlreadyInstalled, packageToBeUpdated.DisplayName));
                }
                if (!update)
                {
                    _logger.LogInformation(
                        string.Format(
                            LocalizableStrings.GlobalSettingsTemplatePackagesProvider_Info_PackageAlreadyInstalled,
                            string.IsNullOrWhiteSpace(packageToBeUpdated.Version)
                                ? packageToBeUpdated.Identifier
                                : $"{packageToBeUpdated.Identifier} ({string.Format(LocalizableStrings.Generic_Version, packageToBeUpdated.Version)})",
                            string.IsNullOrWhiteSpace(version) ?
                                LocalizableStrings.Generic_LatestVersion :
                                string.Format(LocalizableStrings.Generic_Version, version)));
                }
                //uninstall previous version first
                UninstallResult uninstallResult = await installer.UninstallAsync(packageToBeUpdated, this, cancellationToken).ConfigureAwait(false);
                if (!uninstallResult.Success)
                {
                    if (uninstallResult.ErrorMessage is null)
                    {
                        throw new InvalidOperationException($"{nameof(uninstallResult.ErrorMessage)} cannot be null when {nameof(uninstallResult.Success)} is 'true'");
                    }
                    return (InstallerErrorCode.UpdateUninstallFailed, uninstallResult.ErrorMessage);
                }
                _logger.LogInformation(
                    string.Format(
                        LocalizableStrings.GlobalSettingsTemplatePackagesProvider_Info_PackageUninstalled,
                        packageToBeUpdated.DisplayName));

                lock (packagesInSettings)
                {
                    packagesInSettings.RemoveAll(p => p.MountPointUri == packageToBeUpdated.MountPointUri);
                }
            }
            return (InstallerErrorCode.Success, string.Empty);
        }

        private async Task<InstallResult> InstallAsync(List<TemplatePackageData> packages, InstallRequest installRequest, IInstaller installer, CancellationToken cancellationToken)
        {
            _ = installRequest ?? throw new ArgumentNullException(nameof(installRequest));
            _ = installer ?? throw new ArgumentNullException(nameof(installer));

            (InstallerErrorCode result, string message) = await EnsureInstallPrerequisites(
                packages,
                installRequest.PackageIdentifier,
                installRequest.Version,
                installer,
                cancellationToken,
                forceUpdate: installRequest.Force).ConfigureAwait(false);
            if (result != InstallerErrorCode.Success)
            {
                return InstallResult.CreateFailure(installRequest, result, message, []);
            }

            InstallResult installResult = await installer.InstallAsync(installRequest, this, cancellationToken).ConfigureAwait(false);
            if (!installResult.Success)
            {
                return installResult;
            }
            if (installResult.TemplatePackage is null)
            {
                throw new InvalidOperationException($"{nameof(installResult.TemplatePackage)} cannot be null when {nameof(installResult.Success)} is 'true'");
            }

            lock (packages)
            {
                packages.Add(((ISerializableInstaller)installer).Serialize(installResult.TemplatePackage));
            }
            return installResult;
        }

        private class InstallRequestEqualityComparer : IEqualityComparer<InstallRequest>
        {
            public bool Equals(InstallRequest x, InstallRequest y)
            {
                if (!x.PackageIdentifier.Equals(y.PackageIdentifier, StringComparison.OrdinalIgnoreCase))
                {
                    return false;
                }

                if (x.InstallerName != null
                    && y.InstallerName != null
                    && x.InstallerName.Equals(y.InstallerName, StringComparison.OrdinalIgnoreCase))
                {
                    return true;
                }
                return false;
            }

            public int GetHashCode(InstallRequest obj)
            {
                if (obj == null)
                {
                    return 0;
                }
                return new { a = obj.InstallerName?.ToLowerInvariant(), b = obj.PackageIdentifier.ToLowerInvariant() }.GetHashCode();
            }
        }
    }
}