Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fallback keys to caching #2340

Closed
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,7 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.TeamFoundation.Build.WebApi;
using Microsoft.VisualStudio.Services.BlobStore.Common;
using Microsoft.VisualStudio.Services.Content.Common.Tracing;
using Microsoft.VisualStudio.Services.BlobStore.WebApi;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.WebApi;
using Microsoft.TeamFoundation.DistributedTask.WebApi;
using Microsoft.VisualStudio.Services.Agent.Util;
using Agent.Sdk;
using System.Text.RegularExpressions;
Expand Down
234 changes: 234 additions & 0 deletions src/Agent.Plugins/PipelineCache/FingerprintCreator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
using Agent.Sdk;
using BuildXL.Cache.ContentStore.Interfaces.Utils;
using Microsoft.VisualStudio.Services.BlobStore.Common;
using Microsoft.VisualStudio.Services.PipelineCache.WebApi;
using Minimatch;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Security.Cryptography;
using System.Text;

[assembly: InternalsVisibleTo("Test")]

namespace Agent.Plugins.PipelineCache
{
public static class FingerprintCreator
{
private static readonly bool isWindows = Helpers.IsWindowsPlatform(Environment.OSVersion);

// https://github.com/Microsoft/azure-pipelines-task-lib/blob/master/node/docs/findingfiles.md#matchoptions
private static readonly Options minimatchOptions = new Options
{
Dot = true,
NoBrace = true,
NoCase = isWindows,
johnterickson marked this conversation as resolved.
Show resolved Hide resolved
AllowWindowsPaths = isWindows,
};

private static readonly char[] GlobChars = new [] { '*', '?', '[', ']' };

private static bool IsPathyChar(char c)
{
if (GlobChars.Contains(c)) return true;
if (c == Path.DirectorySeparatorChar) return true;
if (c == Path.AltDirectorySeparatorChar) return true;
if (c == Path.VolumeSeparatorChar) return true;
return !Path.GetInvalidFileNameChars().Contains(c);
}

internal static bool IsPathy(string keySegment)
{
if (keySegment.First() == '\'' && keySegment.Last() == '\'') return false;
if (keySegment.First() == '"' && keySegment.Last() == '"') return false;
if (keySegment.Any(c => !IsPathyChar(c))) return false;
//if (Uri.TryCreate(keySegment, UriKind.Absolute, out Uri dummy)) return false;
if (!keySegment.Contains(".")) return false;
if (keySegment.Last() == '.') return false;
return true;
}

internal static bool IsAbsolutePath(string path) =>
johnterickson marked this conversation as resolved.
Show resolved Hide resolved
path.StartsWith("/", StringComparison.Ordinal)
|| (isWindows && path.Length >= 3 && char.IsLetter(path[0]) && path[1] == ':' && path[2] == '\\');

internal static Func<string,bool> CreateMinimatchFilter(AgentTaskPluginExecutionContext context, string rule, bool invert)
{
Func<string,bool> filter = Minimatcher.CreateFilter(rule, minimatchOptions);
Func<string,bool> tracedFilter = (path) => {
bool result = invert ^ filter(path);
context.Verbose($"Path `{path}` is {(result ? "included" : "excluded")} because of pattern `{(invert ? "!" : "")}{rule}`.");
return result;
};

return tracedFilter;
}

internal static string MakePathAbsolute(string workingDirectory, string path)
{
if (workingDirectory != null)
{
path = $"{workingDirectory}{Path.DirectorySeparatorChar}{path}";
johnterickson marked this conversation as resolved.
Show resolved Hide resolved
}

return path;
}

internal static Func<string,bool> CreateFilter(
AgentTaskPluginExecutionContext context,
string workingDirectory,
string includeRule,
IEnumerable<string> excludeRules)
{
Func<string,bool> includeFilter = CreateMinimatchFilter(context, includeRule, false);
Func<string,bool>[] excludeFilters = excludeRules.Select(excludeRule =>
CreateMinimatchFilter(context, excludeRule, true)).ToArray();
Func<string,bool> filter = (path) => includeFilter(path) && excludeFilters.All(f => f(path));
return filter;
}


internal static void DetermineEnumeration(
johnterickson marked this conversation as resolved.
Show resolved Hide resolved
string workingDirectory,
string rootRule,
out string enumerateRootPath,
out string enumeratePattern,
out SearchOption enumerateDepth)
{
int firstGlob = rootRule.IndexOfAny(GlobChars);

// no globbing
if (firstGlob < 0)
{
if (workingDirectory == null)
{
enumerateRootPath = Path.GetDirectoryName(rootRule);
}
else
{
enumerateRootPath = workingDirectory;
}

enumeratePattern = Path.GetFileName(rootRule);
enumerateDepth = SearchOption.TopDirectoryOnly;
}
// starts with glob
else if(firstGlob == 0)
johnterickson marked this conversation as resolved.
Show resolved Hide resolved
{
if(workingDirectory == null) throw new InvalidOperationException();
enumerateRootPath = workingDirectory;
enumeratePattern = "*";
enumerateDepth = SearchOption.AllDirectories;
}
else
{
int rootDirLength = rootRule.Substring(0,firstGlob).LastIndexOfAny( new [] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to have a "root rule" like a*/b? If so, I think this logic fails since there is no path separator character in a*.

enumerateRootPath = rootRule.Substring(0,rootDirLength);
enumeratePattern = "*";
enumerateDepth = SearchOption.AllDirectories;
}
}

public static Fingerprint CreateFingerprint(
AgentTaskPluginExecutionContext context,
IEnumerable<string> keySegments)
{
var sha256 = new SHA256Managed();

string workingDirectoryValue = context.Variables.GetValueOrDefault(
"system.defaultworkingdirectory" // Constants.Variables.System.DefaultWorkingDirectory
)?.Value;

var resolvedSegments = new List<string>();

foreach (string keySegment in keySegments)
{
bool isPathy = IsPathy(keySegment);
bool isWildCard = keySegment.Equals("**", StringComparison.Ordinal);
johnterickson marked this conversation as resolved.
Show resolved Hide resolved

if (isWildCard)
johnterickson marked this conversation as resolved.
Show resolved Hide resolved
{
resolvedSegments.Add("**");
}
else if (isPathy)
{
context.Verbose($"Interpretting `{keySegment}` as a path.");

var segment = new StringBuilder();
bool foundFile = false;

string[] pathSections = keySegment.Split(new []{';'}, StringSplitOptions.RemoveEmptyEntries);
johnterickson marked this conversation as resolved.
Show resolved Hide resolved
foreach(string pathSection in pathSections)
{
string[] pathRules = pathSection.Split(new []{','}, StringSplitOptions.RemoveEmptyEntries);
string rootRule = pathRules.First();
if(rootRule.Length == 0 || rootRule[1] == '!')
{
throw new ArgumentException();
}

string workingDirectory = null;
if (!IsAbsolutePath(rootRule))
{
workingDirectory = workingDirectoryValue;
}

string absoluteRootRule = MakePathAbsolute(workingDirectory, rootRule);
johnterickson marked this conversation as resolved.
Show resolved Hide resolved
context.Verbose($"Expanded include rule is `{absoluteRootRule}`.");
IEnumerable<string> absoluteExcludeRules = pathRules.Skip(1).Select(r => MakePathAbsolute(workingDirectory, r));
Func<string,bool> filter = CreateFilter(context, workingDirectory, absoluteRootRule, absoluteExcludeRules);

DetermineEnumeration(
workingDirectory,
absoluteRootRule,
out string enumerateRootPath,
out string enumeratePattern,
out SearchOption enumerateDepth);

context.Verbose($"Enumerating starting at root `{enumerateRootPath}` with pattern `{enumeratePattern}`.");
IEnumerable<string> files = Directory.EnumerateFiles(enumerateRootPath, enumeratePattern, enumerateDepth);
files = files.Where(f => filter(f)).Distinct();

foreach(string path in files)
{
foundFile = true;

using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
{
byte[] hash = sha256.ComputeHash(fs);
string displayPath = workingDirectory == null ? path : path.Substring(enumerateRootPath.Length + 1);
segment.Append($"\nSHA256({displayPath})=[{fs.Length}]{hash.ToHex()}");
}
}
}

if (!foundFile)
{
throw new FileNotFoundException("No files found.");
}

string fileHashString = segment.ToString();
string fileHashStringHash = SummarizeString(fileHashString);
context.Output($"File hashes summarized as `{fileHashStringHash}` from BASE64(SHA256(`{fileHashString}`))");
resolvedSegments.Add(fileHashStringHash);
}
else
{
context.Verbose($"Interpretting `{keySegment}` as a string.");
resolvedSegments.Add($"{keySegment}");
}
}

return new Fingerprint() { Segments = resolvedSegments.ToArray() };
}

internal static string SummarizeString(string input)
{
var sha256 = new SHA256Managed();
byte[] fileHashStringBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(input));
return Convert.ToBase64String(fileHashStringBytes);
}
}
}
59 changes: 28 additions & 31 deletions src/Agent.Plugins/PipelineCache/PipelineCacheServer.cs
Original file line number Diff line number Diff line change
@@ -1,35 +1,25 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Threading;
using System;
using Agent.Plugins.PipelineArtifact;
using Agent.Plugins.PipelineArtifact.Telemetry;
using Agent.Plugins.PipelineCache.Telemetry;
using Agent.Sdk;
using Microsoft.TeamFoundation.Build.WebApi;
using Microsoft.TeamFoundation.DistributedTask.WebApi;
using Microsoft.VisualStudio.Services.Agent.Util;
using Microsoft.VisualStudio.Services.BlobStore.Common;
using Microsoft.VisualStudio.Services.BlobStore.Common.Telemetry;
using Microsoft.VisualStudio.Services.BlobStore.WebApi;
using Microsoft.VisualStudio.Services.Content.Common;
using Microsoft.VisualStudio.Services.Common;
using Microsoft.VisualStudio.Services.Content.Common.Tracing;
using Microsoft.VisualStudio.Services.PipelineCache.WebApi;
using Microsoft.VisualStudio.Services.WebApi;
using Newtonsoft.Json;

namespace Agent.Plugins.PipelineCache
{
public class PipelineCacheServer
{
internal async Task UploadAsync(
AgentTaskPluginExecutionContext context,
IEnumerable<string> key,
Fingerprint fingerprint,
string path,
string salt,
CancellationToken cancellationToken)
{
VssConnection connection = context.VssConnection;
Expand All @@ -40,20 +30,15 @@ internal async Task UploadAsync(
using (clientTelemetry)
{
// Check if the key exists.
GetPipelineCacheArtifactOptions getOptions = new GetPipelineCacheArtifactOptions
{
Key = key,
Salt = salt,
};
PipelineCacheActionRecord cacheRecordGet = clientTelemetry.CreateRecord<PipelineCacheActionRecord>((level, uri, type) =>
new PipelineCacheActionRecord(level, uri, type, PipelineArtifactConstants.RestoreCache, context));
PipelineCacheArtifact getResult = await pipelineCacheClient.GetPipelineCacheArtifactAsync(getOptions, cancellationToken, cacheRecordGet);
PipelineCacheArtifact getResult = await pipelineCacheClient.GetPipelineCacheArtifactAsync(new [] {fingerprint}, cancellationToken, cacheRecordGet);
// Send results to CustomerIntelligence
context.PublishTelemetry(area: PipelineArtifactConstants.AzurePipelinesAgent, feature: PipelineArtifactConstants.PipelineCache, record: cacheRecordGet);
//If cache exists, return.
if (getResult != null)
{
context.Output($"Cache with fingerprint {getResult.Fingerprint} already exists.");
context.Output($"Cache with fingerprint `{getResult.Fingerprint}` already exists.");
return;
}

Expand All @@ -69,11 +54,10 @@ internal async Task UploadAsync(

CreatePipelineCacheArtifactOptions options = new CreatePipelineCacheArtifactOptions
{
Key = key,
Fingerprint = fingerprint,
RootId = result.RootId,
ManifestId = result.ManifestId,
ProofNodes = result.ProofNodes.ToArray(),
Salt = salt
};

// Cache the artifact
Expand All @@ -90,34 +74,29 @@ internal async Task UploadAsync(

internal async Task DownloadAsync(
AgentTaskPluginExecutionContext context,
IEnumerable<string> key,
Fingerprint[] fingerprints,
string path,
string salt,
string cacheHitVariable,
CancellationToken cancellationToken)
{
VssConnection connection = context.VssConnection;
BlobStoreClientTelemetry clientTelemetry;
DedupManifestArtifactClient dedupManifestClient = DedupManifestArtifactClientFactory.CreateDedupManifestClient(context, connection, out clientTelemetry);
PipelineCacheClient pipelineCacheClient = this.CreateClient(clientTelemetry, context, connection);
GetPipelineCacheArtifactOptions options = new GetPipelineCacheArtifactOptions
{
Key = key,
Salt = salt,
};


using (clientTelemetry)
{
PipelineCacheActionRecord cacheRecord = clientTelemetry.CreateRecord<PipelineCacheActionRecord>((level, uri, type) =>
new PipelineCacheActionRecord(level, uri, type, PipelineArtifactConstants.RestoreCache, context));
PipelineCacheArtifact result = await pipelineCacheClient.GetPipelineCacheArtifactAsync(options, cancellationToken, cacheRecord);
PipelineCacheArtifact result = await pipelineCacheClient.GetPipelineCacheArtifactAsync(fingerprints, cancellationToken, cacheRecord);

// Send results to CustomerIntelligence
context.PublishTelemetry(area: PipelineArtifactConstants.AzurePipelinesAgent, feature: PipelineArtifactConstants.PipelineCache, record: cacheRecord);

if (result != null)
{
context.Output($"Manifest ID is: {result.ManifestId.ValueString}");
context.Output($"Entry found at fingerprint: `{result.Fingerprint.ToString()}`");
context.Verbose($"Manifest ID is: {result.ManifestId.ValueString}");
PipelineCacheActionRecord downloadRecord = clientTelemetry.CreateRecord<PipelineCacheActionRecord>((level, uri, type) =>
new PipelineCacheActionRecord(level, uri, type, nameof(DownloadAsync), context));
await clientTelemetry.MeasureActionAsync(
Expand All @@ -135,7 +114,25 @@ await clientTelemetry.MeasureActionAsync(

if (!string.IsNullOrEmpty(cacheHitVariable))
{
context.SetVariable(cacheHitVariable, result != null ? "true" : "false");
if (result == null) {
context.SetVariable(cacheHitVariable, "false");
} else {
context.Verbose($"Exact fingerprint: `{result.Fingerprint.ToString()}`");

bool foundExact = false;
foreach(var fingerprint in fingerprints)
{
context.Verbose($"This fingerprint: `{fingerprint.ToString()}`");

if(fingerprint == result.Fingerprint)
{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so we're only going output inexact on wildcard related fingerprints and not exclusively fallback keys?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A primary key can't be a wildcard because that's the name it saves as. Did that answer your question?

foundExact = true;
break;
}
}

context.SetVariable(cacheHitVariable, foundExact ? "true" : "inexact");
}
}
}
}
Expand Down
Loading