File: src\PushToBuildStorage.cs
Web Access
Project: Microsoft.DotNet.Build.Tasks.Feed.csproj (Microsoft.DotNet.Build.Tasks.Feed)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.CodeDom;
using System.IO;
using System.Linq;
using System.Security;
using System.Xml.Linq;
using Microsoft.Arcade.Common;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using Microsoft.DotNet.Build.Manifest;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace Microsoft.DotNet.Build.Tasks.Feed;

/// <summary>
/// This task is used to gather a set of artifacts produced by a build and move them
/// to a staging location, either locally or in Azure DevOps, as well as produce a manifest describing those
/// artifacts. This is used today in the following scenarios:
/// - VMR builds, for each repo that builds within a vertical. the artifacts are copied to a local directory,
///   and the manifest is produced. These artifacts are used for downstream repos in the vertical. In this case:
///     - PushToLocalStorage is set to true
///     - AssetsLocalStorageDir, ShippingPackagesLocalStorageDir, NonShippingPackagesLocalStorageDir, AssetManifestsLocalStorageDir,
///       and PdbArtifactsLocalStorageDir are set.
///     - FutureArtifactName and FutureArtifactPublishBasePath are not set.
///     - Publishing version should be v3 or v4.
/// - VMR builds, at the end of a vertical. Or non-VMR builds using v4 publishing. In these cases, the artifacts are copied to a staging directory,
///   ready for upload as pipeline artifacts. The manifest is produced for all artifacts that should exit the vertical,
///   and the assets future pipeline artifact location is recorded in the manifest based on 
///   FutureArtifactName and FutureArtifactPublishBasePath. In this case,
///     - PushToLocalStorage is set to true
///     - AssetsLocalStorageDir, ShippingPackagesLocalStorageDir, NonShippingPackagesLocalStorageDir, AssetManifestsLocalStorageDir,
///       and PdbArtifactsLocalStorageDir are set.
///     - FutureArtifactName and FutureArtifactPublishBasePath are set.
///     - Publishing version should be v4.
/// - Non-VMR builds, with publishing V3. In this case, the artifacts are uploaded to Azure DevOps using logging commands to
///   a set of well known artifact locations. PackageArtifacts, PdbArtifacts, BlobArtifacts, and AssetManifests.
///   When this scenario is active,
///     - PushToLocalStorage is set to false
///     - AssetsLocalStorageDir, ShippingPackagesLocalStorageDir, NonShippingPackagesLocalStorageDir, AssetManifestsLocalStorageDir.
///     - PdbArtifactsLocalStorageDir must be set if PDBs are present.
///     - FutureArtifactName and FutureArtifactPublishBasePath are not set.
///     - Publishing version should be v3.
/// </summary>
public class PushToBuildStorage : MSBuildTaskBase
{
    [Required]
    public ITaskItem[] ItemsToPush { get; set; }

    public string ManifestRepoName { get; set; }

    public string ManifestRepoUri { get; set; }

    public string ManifestBuildId { get; set; } = "no build id provided";

    public string ManifestBranch { get; set; }

    public string ManifestCommit { get; set; }

    /// <summary>
    /// Indicates the source of the artifacts. For a VMR build, the `ManifestRepoName` is dotnet/dotnet,
    /// while the `ManifestRepoOrigin` corresponds to the actual product repository.
    /// </summary>
    public string ManifestRepoOrigin { get; set; }

    public string[] ManifestBuildData { get; set; }

    public string AzureDevOpsCollectionUri { get; set; }

    public string AzureDevOpsProject { get; set; }

    public int AzureDevOpsBuildId { get; set; }
    
    // Sign* parameters are deprecated and no longer used.

    public ITaskItem[] ItemsToSign { get; set; }

    public ITaskItem[] StrongNameSignInfo { get; set; }

    public ITaskItem[] FileSignInfo { get; set; }

    public ITaskItem[] FileExtensionSignInfo { get; set; }

    public ITaskItem[] CertificatesSignInfo { get; set; }

    public string AssetManifestPath { get; set; }

    public bool IsStableBuild { get; set; }

    public bool IsReleaseOnlyPackageVersion { get; set; }

    /// <summary>
    /// Represents where assets should be copied locally, either for staging for upload
    /// or for propagation to another phase of the VMR build.
    /// </summary>
    public string AssetsLocalStorageDir { get; set; }

    /// <summary>
    /// Represents where shipping packages should be copied locally, either for staging for upload
    /// or for propagation to another phase of the VMR build.
    /// </summary>
    public string ShippingPackagesLocalStorageDir { get; set; }

    /// <summary>
    /// Represents where nonshipping packages should be copied locally, either for staging for upload
    /// or for propagation to another phase of the VMR build.
    /// </summary>
    public string NonShippingPackagesLocalStorageDir { get; set; }

    /// <summary>
    /// Represents where asset manifests should be copied locally, either for staging for upload
    /// or for propagation to another phase of the VMR build.
    /// </summary>
    public string AssetManifestsLocalStorageDir { get; set; }

    /// <summary>
    /// Represents where pdb artifacts should be copied locally, either for staging for upload
    /// or for propagation to another phase of the VMR build.
    /// 
    /// NOTE: In non-VMR builds, this represents the location of the PDBs that are copied
    /// to before uploading to the PDBArtifacts dir.
    /// </summary>
    public string PdbArtifactsLocalStorageDir { get; set; }

    /// <summary>
    /// If true, the artifacts will be copied to the local storage directories.
    /// This is also true if the publishing version is V4.
    /// </summary>
    public bool PushToLocalStorage { get; set; }

    /// <summary>
    /// The final path for any packages published to <see cref="ShippingPackagesLocalStorageDir"/>
    /// or <see cref="NonShippingPackagesLocalStorageDir"/> should have the artifact's RepoOrigin
    /// appended as a subfolder to the published path.
    /// </summary>
    public bool PreserveRepoOrigin { get; set; }

    /// <summary>
    /// The visibility of the artifacts to put in the manifest.
    /// </summary>
    public ITaskItem[] ArtifactVisibilitiesToPublish { get; set; }

    /// <summary>
    /// Which version should the build manifest be tagged with.
    /// By default he latest version is used.
    /// </summary>
    public int PublishingVersion { get; set; }

    /// <summary>
    /// Gets or sets a value that indicates whether to use hard links for the copied files
    /// rather than copy the files, if it's possible to do so.
    /// </summary>
    public bool UseHardlinksIfPossible { get; set; } = true;

    /// <summary>
    /// Under v4 publishing, the artifact name is the name of the file that will be published to the blob feed.
    /// </summary>
    public string FutureArtifactName { get; set; }

    /// <summary>
    /// Under v4 publishing, the path that will be published to FutureArtifactName. If FutureArtifactName is passed,
    /// then FutureArtifactPublishBasePath must also be passed.
    /// </summary>
    public string FutureArtifactPublishBasePath { get; set; }

    private bool ShouldLocallyStageArtifacts { get => PushToLocalStorage || _publishingVersion >= PublishingInfraVersion.V4; }

    private PublishingInfraVersion _publishingVersion = PublishingInfraVersion.Latest;

    private IFileSystem _fileSystem;

    public override void ConfigureServices(IServiceCollection collection)
    {
        collection.TryAddSingleton<IBlobArtifactModelFactory, BlobArtifactModelFactory>();
        collection.TryAddSingleton<IPackageArtifactModelFactory, PackageArtifactModelFactory>();
        collection.TryAddSingleton<IPdbArtifactModelFactory, PdbArtifactModelFactory>();
        collection.TryAddSingleton<IBuildModelFactory, BuildModelFactory>();
        collection.TryAddSingleton<IFileSystem>(provider => new PushToBuildStorageFileSystem(UseHardlinksIfPossible, Log));
        collection.TryAddSingleton<IPackageArchiveReaderFactory, PackageArchiveReaderFactory>();
        collection.TryAddSingleton<INupkgInfoFactory, NupkgInfoFactory>();
        collection.TryAddSingleton(Log);
    }

    public bool PrepAndValidateTask()
    {
        if (PublishingVersion != default)
        {
            if (!Enum.IsDefined(typeof(PublishingInfraVersion), PublishingVersion))
            {
                Log.LogError($"Invalid publishing version '{PublishingVersion}'");
                return false;
            }
            _publishingVersion = (PublishingInfraVersion)PublishingVersion;
        }

        // Must always have something to push, or to create a manifest with.
        if (ItemsToPush == null || ItemsToPush.Length == 0)
        {
            Log.LogError($"ItemsToPush is not specified.");
            return false;
        }

        if (ShouldLocallyStageArtifacts)
        {
            if (string.IsNullOrEmpty(AssetsLocalStorageDir) ||
                string.IsNullOrEmpty(ShippingPackagesLocalStorageDir) ||
                string.IsNullOrEmpty(NonShippingPackagesLocalStorageDir) ||
                string.IsNullOrEmpty(PdbArtifactsLocalStorageDir) ||
                string.IsNullOrEmpty(AssetManifestsLocalStorageDir))
            {
                Log.LogError("AssetsLocalStorageDir, ShippingPackagesLocalStorageDir, NonShippingPackagesLocalStorageDir, PdbArtifactsLocalStorageDir and AssetManifestsLocalStorageDir need " +
                    "to be specified if PublishToLocalStorage is set to true or V4 publishing is enabled");
                return false;
            }
        }

        // Validation of parameters specific to a publishing version
        switch (_publishingVersion)
        {
            case PublishingInfraVersion.UnsupportedV1:
            case PublishingInfraVersion.UnsupportedV2:
                Log.LogError($"Publishing version '{_publishingVersion}' is not supported.");
                return false;
            case PublishingInfraVersion.V3:
                if (!string.IsNullOrEmpty(FutureArtifactName) ||
                    !string.IsNullOrEmpty(FutureArtifactPublishBasePath))
                {
                    Log.LogError($"FutureArtifactName and FutureArtifactPublishBasePath are not supported in publishing version '{_publishingVersion}'.");
                    return false;
                }
                break;
            case PublishingInfraVersion.V4:
                if (string.IsNullOrEmpty(FutureArtifactName) !=
                    string.IsNullOrEmpty(FutureArtifactPublishBasePath))
                {
                    Log.LogError($"FutureArtifactName and FutureArtifactPublishBasePath must be both be specified if either is specified.");
                    return false;
                }
                break;
            default:
                throw new ArgumentOutOfRangeException(nameof(_publishingVersion), _publishingVersion, null);
        }

        if (ShouldLocallyStageArtifacts)
        {
            Log.LogMessage(MessageImportance.High, "Performing push to local artifacts storage.");
        }
        else
        {
            Log.LogMessage(MessageImportance.High, "Performing push to Azure DevOps artifacts storage.");
        }

        return true;
    }

    public bool ExecuteTask(IFileSystem fileSystem,
        IBlobArtifactModelFactory blobArtifactModelFactory,
        IPackageArtifactModelFactory packageArtifactModelFactory,
        IPdbArtifactModelFactory pdbArtifactModelFactory,
        IBuildModelFactory buildModelFactory)
    {
        _fileSystem = fileSystem;

        try
        {
            if (!PrepAndValidateTask())
            {
                return false;
            }

            var artifactVisibilities = GetVisibilitiesToPublish(ArtifactVisibilitiesToPublish);

            var buildModel = buildModelFactory.CreateModel(
                ItemsToPush,
                artifactVisibilities,
                ManifestBuildId,
                ManifestBuildData,
                !string.IsNullOrEmpty(ManifestRepoName) ? ManifestRepoName : ManifestRepoUri,
                ManifestBranch,
                ManifestCommit,
                ManifestRepoOrigin,
                _publishingVersion <= PublishingInfraVersion.V3 ? IsStableBuild : false,
                _publishingVersion,
                _publishingVersion <= PublishingInfraVersion.V3 ? IsReleaseOnlyPackageVersion : false);

            if (buildModel == null)
            {
                Log.LogError($"Failed to construct build model from input artifacts.");
                return false;
            }

            foreach (var package in buildModel.Artifacts.Packages)
            {
                if (!fileSystem.FileExists(package.OriginalFile))
                {
                    Log.LogError($"Could not find file {package.OriginalFile}.");
                    continue;
                }

                LocallyStageArtifactsOrPushToAzDO(package);
            }

            foreach (var blobArtifact in buildModel.Artifacts.Blobs)
            {
                if (!fileSystem.FileExists(blobArtifact.OriginalFile))
                {
                    Log.LogError($"Could not find file {blobArtifact.OriginalFile}.");
                    continue;
                }

                LocallyStageArtifactsOrPushToAzDO(blobArtifact);
            }

            // We allow users to not specify a PdbArtifactsLocalStorageDir to avoid breaking
            // some repos. Check now that it exists.
            // Note that if publishing version was v4, or if we are pushing to local storage,
            // then this was already checked.
            if (string.IsNullOrEmpty(PdbArtifactsLocalStorageDir) && buildModel.Artifacts.Pdbs.Count > 0)
            {
                Log.LogError($"PdbArtifactsLocalStorageDir must be specified if PDBs are present.");
                return false;
            }

            foreach (var pdbArtifact in buildModel.Artifacts.Pdbs)
            {
                if (!fileSystem.FileExists(pdbArtifact.OriginalFile))
                {
                    Log.LogError($"Could not find file {pdbArtifact.OriginalFile}.");
                    continue;
                }
                LocallyStageArtifactsOrPushToAzDO(pdbArtifact);
            }

            if (_publishingVersion <= PublishingInfraVersion.V3 && !ShouldLocallyStageArtifacts && buildModel.Artifacts.Pdbs.Any())
            {
                // Upload the full set of PDBs
                Log.LogMessage(MessageImportance.High,
                    $"##vso[artifact.upload containerfolder=PdbArtifacts;artifactname=PdbArtifacts]{PdbArtifactsLocalStorageDir}");
            }

            // Write the manifest, then create an artifact for it.
            Log.LogMessage(MessageImportance.High, $"Writing build manifest file '{AssetManifestPath}'...");
            fileSystem.WriteToFile(AssetManifestPath, buildModel.ToXml().ToString(SaveOptions.DisableFormatting));

            // Generate an artifact for the asset manifest and push it to storage.
            AssetManifestModel assetManifestModel = new AssetManifestModel
            {
                OriginalFile = AssetManifestPath,
                Id = _fileSystem.GetFileName(AssetManifestPath)
            };
            LocallyStageArtifactsOrPushToAzDO(assetManifestModel);
        }
        catch (Exception e)
        {
            Log.LogErrorFromException(e, true);
        }

        return !Log.HasLoggedErrors;
    }

    private void LocallyStageArtifactsOrPushToAzDO(ArtifactModel artifactModel)
    {
        string originalArtifactPath = artifactModel.OriginalFile;
        string artifactDestinationPath = originalArtifactPath;

        if (ShouldLocallyStageArtifacts)
        {
            artifactDestinationPath = PushArtifactToLocalStorage(artifactModel, originalArtifactPath);
        }
        else if (_publishingVersion == PublishingInfraVersion.V3)
        {
            PushArtifactToBuildStorage(artifactModel, originalArtifactPath);
        }

        // If using V4, then record the pipeline artifact location of the asset.
        if (_publishingVersion == PublishingInfraVersion.V4 && !string.IsNullOrEmpty(FutureArtifactPublishBasePath))
        {
            try
            {
                string relativePath = _fileSystem.GetRelativePath(FutureArtifactPublishBasePath, artifactDestinationPath);
                // Set the pipeline artifact path to the relative path.
                // This path should be in unix-style path form.
                artifactModel.PipelineArtifactName = FutureArtifactName;
                artifactModel.PipelineArtifactPath = relativePath.Replace(@"\", "/");
            }
            catch (ArgumentException)
            {
                Log.LogError($"Could not determine relative path from '{FutureArtifactPublishBasePath}' to '{artifactDestinationPath}'.");
            }
        }
    }


    /// <summary>
    /// Copies the artifact to the locally specified storage directory.
    /// </summary>
    /// <param name="artifactModel">Artifact model</param>
    /// <param name="originalArtifactPath">Original path to the artifact</param>
    /// <param name="filename"></param>
    /// <returns>Target path that the artifact was copied to.</returns>
    /// <exception cref="ArgumentOutOfRangeException">Invalid publishing version.</exception>
    private string PushArtifactToLocalStorage(ArtifactModel artifactModel, string originalArtifactPath)
    {
        string filename = _fileSystem.GetFileName(originalArtifactPath);
        string artifactDestinationPath;
        switch (artifactModel)
        {
            case AssetManifestModel _:
                artifactDestinationPath = _fileSystem.PathCombine(AssetManifestsLocalStorageDir, filename);
                EnsureDirectoryAndCopyFile(originalArtifactPath, artifactDestinationPath);
                break;

            case PackageArtifactModel _:
                string packageDestinationDirectory = artifactModel.NonShipping
                    ? NonShippingPackagesLocalStorageDir
                    : ShippingPackagesLocalStorageDir;

                if (PreserveRepoOrigin)
                {
                    packageDestinationDirectory = _fileSystem.PathCombine(packageDestinationDirectory, artifactModel.RepoOrigin);
                }
                artifactDestinationPath = _fileSystem.PathCombine(packageDestinationDirectory, filename);
                EnsureDirectoryAndCopyFile(originalArtifactPath, artifactDestinationPath);
                break;

            case BlobArtifactModel _:
                string relativeBlobPath = artifactModel.Id;
                artifactDestinationPath = _fileSystem.PathCombine(
                                        AssetsLocalStorageDir,
                                        string.IsNullOrEmpty(relativeBlobPath) ? filename : relativeBlobPath);

                EnsureDirectoryAndCopyFile(originalArtifactPath, artifactDestinationPath);
                break;

            case PdbArtifactModel _:
                string relativePdbPath = artifactModel.Id;
                artifactDestinationPath = _fileSystem.PathCombine(
                                            PdbArtifactsLocalStorageDir,
                                            string.IsNullOrEmpty(relativePdbPath) ? filename : relativePdbPath);

                EnsureDirectoryAndCopyFile(originalArtifactPath, artifactDestinationPath);
                break;

            default:
                throw new ArgumentOutOfRangeException(nameof(artifactModel));
        }

        return artifactDestinationPath;

        void EnsureDirectoryAndCopyFile(string artifactPath, string artifactDestinationPath)
        {
            _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(artifactDestinationPath));
            _fileSystem.CopyFile(artifactPath, artifactDestinationPath, true);
        }
    }

    /// <summary>
    /// Pushes artifacts to build storage using logging commands, with the exception of PDBs, which are staged for upload as a unit.
    /// </summary>
    /// <param name="artifactModel">Artifact</param>
    /// <param name="originalArtifactPath">Original path to the artifact</param>
    /// <exception cref="ArgumentOutOfRangeException">Publishing version was out of range</exception>
    private void PushArtifactToBuildStorage(ArtifactModel artifactModel, string originalArtifactPath)
    {
        switch (artifactModel)
        {
            case AssetManifestModel _:
                Log.LogMessage(MessageImportance.High,
                    $"##vso[artifact.upload containerfolder=AssetManifests;artifactname=AssetManifests]{originalArtifactPath}");
                break;
            case PackageArtifactModel _:
                Log.LogMessage(MessageImportance.High,
                    $"##vso[artifact.upload containerfolder=PackageArtifacts;artifactname=PackageArtifacts]{originalArtifactPath}");
                break;
            case BlobArtifactModel _:
                Log.LogMessage(MessageImportance.High,
                    $"##vso[artifact.upload containerfolder=BlobArtifacts;artifactname=BlobArtifacts]{originalArtifactPath}");
                break;
            case PdbArtifactModel _:
                string pdbArtifactTarget = _fileSystem.PathCombine(PdbArtifactsLocalStorageDir, artifactModel.Id);
                _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(pdbArtifactTarget));
                // Copy the PDB artifact to the temp local dir.
                _fileSystem.CopyFile(originalArtifactPath, pdbArtifactTarget, true);
                break;
            default:
                throw new ArgumentOutOfRangeException(nameof(artifactModel));
        }
    }

    private static ArtifactVisibility GetVisibilitiesToPublish(ITaskItem[] allowedVisibilities)
    {
        if (allowedVisibilities is null || allowedVisibilities.Length == 0)
        {
            return ArtifactVisibility.External;
        }

        ArtifactVisibility visibility = 0;

        foreach (var item in allowedVisibilities)
        {
            if (Enum.TryParse(item.ItemSpec, true, out ArtifactVisibility parsedVisibility))
            {
                visibility |= parsedVisibility;
            }
            else
            {
                throw new ArgumentException($"Invalid visibility: {item.ItemSpec}");
            }
        }

        return visibility;
    }
}

/// <summary>
/// File system with some extra hard linking functionality useful for the PushToBuildStorage task.
/// Specifically, we override CopyFile to enable hard linking
/// </summary>
class PushToBuildStorageFileSystem : FileSystem
{
    bool _enableHardLinking = false;
    private readonly TaskLoggingHelper _log;

    public PushToBuildStorageFileSystem(
        bool enableHardLinking,
        TaskLoggingHelper log)
    {
        _enableHardLinking = enableHardLinking;
        _log = log;
    }

    /// <summary>
    /// 
    /// </summary>
    /// <param name="basePath">Base path</param>
    /// <param name="targetPath">Target path</param>
    /// <returns>Relative path from basePath to targetPath</returns>
    /// <exception cref="ArgumentNullException"></exception>
    public override string GetRelativePath(string basePath, string targetPath)
    {
        // Resolve the basePath and targetPath to absolute paths.
        basePath = GetFullPath(basePath);
        targetPath = GetFullPath(targetPath);

        if (targetPath.IndexOf(basePath) != 0)
        {
            throw new ArgumentException("targetPath is not relative to basePath");
        }

        return targetPath.Substring(basePath.Length).TrimStart('/', '\\');
    }

    /// <summary>
    /// The below method implementation is copied from msbuild's Copy task and adjusted.
    /// </summary>
    /// <param name="sourceFileName">Source file for the copy</param>
    /// <param name="destFileName">Destination file for the copy</param>
    /// <param name="overwrite">Overwrite?</param>
    public override void CopyFile(string sourceFileName, string destFileName, bool overwrite)
    {
        FileInfo destFile = new(destFileName);

        if (_enableHardLinking)
        {
            // NativeMethods.MakeHardLink cannot overwrite an existing file or link
            // so we need to delete the existing entry before we create the hard link.
            if (destFile.Exists && !destFile.IsReadOnly)
            {
                try
                {
                    DeleteFile(destFile.FullName);
                }
                catch (Exception ex) when (IsIoRelatedException(ex))
                {
                }
            }

            _log.LogMessage(MessageImportance.Normal, $"Creating hard link to copy \"{sourceFileName}\" to \"{destFileName}\".");

            string errorMessage = string.Empty;
            if (!NativeMethods.MakeHardLink(destFileName, sourceFileName, ref errorMessage))
            {
                _log.LogMessage(MessageImportance.Normal, $"Could not use a link to copy \"{sourceFileName}\" to \"{destFileName}\". Copying the file instead. {errorMessage}");
                File.Copy(sourceFileName, destFileName, overwrite);
            }
        }
        else
        {
            File.Copy(sourceFileName, destFileName, overwrite);
        }

        // If the destinationFile file exists, then make sure it's read-write.
        // The File.Copy command copies attributes, but our copy needs to
        // leave the file writeable.
        if (new FileInfo(sourceFileName).IsReadOnly)
        {
            // Ensure the read-only attribute on the specified file is off, so
            // the file is writeable.
            if (destFile.Exists)
            {
                if (destFile.IsReadOnly)
                {
                    _log.LogMessage(MessageImportance.Low, $"Removing read-only attribute from \"{destFile.FullName}\".");
                    File.SetAttributes(destFile.FullName, FileAttributes.Normal);
                }
            }
        }

        // Determine whether the exception is file-IO related.
        static bool IsIoRelatedException(Exception e)
        {
            // These all derive from IOException
            //     DirectoryNotFoundException
            //     DriveNotFoundException
            //     EndOfStreamException
            //     FileLoadException
            //     FileNotFoundException
            //     PathTooLongException
            //     PipeException
            return e is UnauthorizedAccessException
                || e is NotSupportedException
                || (e is ArgumentException && !(e is ArgumentNullException))
                || e is SecurityException
                || e is IOException;
        }
    }
}