tools/test-proxy/Azure.Sdk.Tools.TestProxy/Store/GitStore.cs (585 lines of code) (raw):
using System.IO;
using System.Net;
using System;
using System.Text.Json;
using System.Threading.Tasks;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Linq;
using Azure.Sdk.Tools.TestProxy.Common.Exceptions;
using Azure.Sdk.Tools.TestProxy.Common;
using Azure.Sdk.Tools.TestProxy.Console;
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Azure.Sdk.tools.TestProxy.Common;
using Microsoft.Security.Utilities;
namespace Azure.Sdk.Tools.TestProxy.Store
{
public class DirectoryEvaluation
{
public bool IsRoot;
public bool IsGitRoot;
public bool AssetsJsonPresent;
}
/// <summary>
/// This class provides an abstraction for dealing with git assets that are stored in an external repository. An "assets.json" within a repo folder is used to inform targeting.
/// </summary>
public class GitStore : IAssetsStore
{
private HttpClient httpClient = new HttpClient();
private IConsoleWrapper _consoleWrapper;
public GitProcessHandler GitHandler = new GitProcessHandler();
public string DefaultBranch = "main";
public string AssetsJsonFileName = "assets.json";
public static readonly string GIT_TOKEN_ENV_VAR = "GIT_TOKEN";
// Note: These are slightly different from the GIT_COMMITTER_NAME and GIT_COMMITTER_EMAIL
// variables that GIT recognizes, this is on purpose.
public static readonly string GIT_COMMIT_OWNER_ENV_VAR = "GIT_COMMIT_OWNER";
public static readonly string GIT_COMMIT_EMAIL_ENV_VAR = "GIT_COMMIT_EMAIL";
private bool LocalCacheRefreshed = false;
public SecretScanner SecretScanner;
public readonly object LocalCacheLock = new object();
public GitStoreBreadcrumb BreadCrumb = new GitStoreBreadcrumb();
/// <summary>
/// We need to lock repo inititialization behind a queue.
/// This is due to the fact that Restore() can be called from multiple parallel
/// requests, as multiple "startplayback" can be firing at the same time.
///
/// While the Restore() action itself is idempotent, the Initialization of the assets repo
/// is NOT. We will use this queue to force ONE single initialization at a time.
///
/// We don't want to gate ALL initializations behind the same gate though. We can restore
/// multiple DIFFERENT assets.jsons at the same time. It's specifically when two restores for the SAME
/// assets.json are fired that we run into problems.
///
/// Everything else will still run in parallel.
/// </summary>
private ConcurrentDictionary<string, TaskQueue> InitTasks = new ConcurrentDictionary<string, TaskQueue>();
public ConcurrentDictionary<string, string> Assets = new ConcurrentDictionary<string, string>();
public GitStore()
{
_consoleWrapper = new ConsoleWrapper();
SecretScanner = new SecretScanner(_consoleWrapper);
}
public GitStore(IConsoleWrapper consoleWrapper)
{
_consoleWrapper = consoleWrapper;
SecretScanner = new SecretScanner(consoleWrapper);
}
#region push, reset, restore, and other asset repo implementations
/// <summary>
/// Set the GitHandler exception mode.
///
/// When false: unrecoverable git exceptions will print the error, and early exit
/// When true: unrecoverable git exceptions will log, then be rethrown for the Exception middleware to handle and return as a valid non-successful http response.
/// </summary>
/// <param name="throwOnException"></param>
public void SetStoreExceptionMode(bool throwOnException)
{
this.GitHandler.ThrowOnException = throwOnException;
}
/// <summary>
/// Given a config, locate the cloned assets.
/// </summary>
/// <param name="pathToAssetsJson"></param>
/// <returns></returns>
public async Task<NormalizedString> GetPath(string pathToAssetsJson)
{
var config = await ParseConfigurationFile(pathToAssetsJson);
if (!string.IsNullOrWhiteSpace(config.AssetsRepoPrefixPath))
{
return new NormalizedString(Path.Combine(config.AssetsRepoLocation, config.AssetsRepoPrefixPath));
}
return new NormalizedString(config.AssetsRepoLocation);
}
/// <summary>
/// Scans the changed files, checking for possible secrets. Returns true if secrets are discovered.
/// </summary>
/// <param name="assetsConfiguration"></param>
/// <param name="pendingChanges"></param>
/// <returns></returns>
public bool CheckForSecrets(GitAssetsConfiguration assetsConfiguration, string[] pendingChanges)
{
_consoleWrapper.WriteLine($"Detected new recordings. Prior to pushing to destination repo, test-proxy will scan {pendingChanges.Length} files.");
var detectedSecrets = SecretScanner.DiscoverSecrets(assetsConfiguration.AssetsRepoLocation, pendingChanges);
if (detectedSecrets.Count > 0)
{
_consoleWrapper.WriteLine("At least one secret was detected in the pushed code. Please register a sanitizer, re-record, and attempt pushing again. Detailed errors follow: ");
foreach (var detection in detectedSecrets)
{
_consoleWrapper.WriteLine($"{detection.Item1}");
_consoleWrapper.WriteLine($"\t{detection.Item2.Id}: {detection.Item2.Name}");
_consoleWrapper.WriteLine($"\tStart: {detection.Item2.Start}, End: {detection.Item2.End}.\n");
}
}
return detectedSecrets.Count > 0;
}
/// <summary>
/// Pushes a set of changed files to the assets repo. Honors configuration of assets.json passed into it.
/// </summary>
/// <param name="pathToAssetsJson"></param>
/// <param name="ignoreSecretProtection"></param>
/// <returns></returns>
public async Task<int> Push(string pathToAssetsJson, bool ignoreSecretProtection = false) {
var config = await ParseConfigurationFile(pathToAssetsJson);
var initialized = IsAssetsRepoInitialized(config);
if (!initialized)
{
_consoleWrapper.WriteLine($"The targeted assets.json \"{config.AssetsJsonRelativeLocation}\" has not been restored prior to attempting push. " +
$"Are you certain you're pushing the correct assets.json? Please invoke \'test-proxy restore \"{config.AssetsJsonRelativeLocation}\"\' prior to invoking a push operation.");
return -1;
}
SetOrigin(config);
var pendingChanges = DetectPendingChanges(config);
var generatedTagName = config.TagPrefix;
bool codeCommitted = false;
if (pendingChanges.Length > 0)
{
if (CheckForSecrets(config, pendingChanges))
{
if (!ignoreSecretProtection)
{
return -1;
}
}
try
{
string branchGuid = Guid.NewGuid().ToString().Substring(0, 8);
string gitUserName = GetGitOwnerName(config);
string gitUserEmail = GetGitOwnerEmail(config);
string assetMessage = "Automatic asset update from test-proxy.";
string configurationString = $"-c user.name=\"{gitUserName}\" -c user.email=\"{gitUserEmail}\"";
GitHandler.Run($"branch {branchGuid}", config);
GitHandler.Run($"checkout {branchGuid}", config);
/*
* This code works by generating a patch file for SPECIFICALLY the eng folder from main.
* Given that these changes appear as "new" changes, they just look like normal file additions.
* This totally eliminates the possibility of weird historical merge if main has code that we don't expect.
* Under azure-sdk-assets, we should never see this, but we have already seen it with specific integration
* test tags under azure-sdk-assets-integration. By keeping it as "patch", the soft RESET on unsuccessful
* push action will properly put their repo into the expected "ready to push" state that a failed
* merge would NOT.
*/
var engPatchLocation = Path.Combine(config.AssetsRepoLocation, "changes.patch");
GitHandler.Run($"diff --output=changes.patch --no-color --binary --no-prefix HEAD main -- eng/", config);
if (GitHandler.TryRun($"apply --check --directory=eng/ changes.patch", config.AssetsRepoLocation.ToString(), out var engPatchResult))
{
GitHandler.Run($"apply --directory=eng/ changes.patch", config);
}
if (File.Exists(engPatchLocation)) {
File.Delete(engPatchLocation);
}
GitHandler.Run($"diff --output=changes.patch --no-color --binary HEAD main -- .gitignore", config);
if (GitHandler.TryRun($"apply --check changes.patch", config.AssetsRepoLocation.ToString(), out var applyResult))
{
GitHandler.Run($"apply changes.patch", config);
}
if (File.Exists(engPatchLocation))
{
File.Delete(engPatchLocation);
}
// add all the recording changes and commit them
GitHandler.Run($"add -A .", config);
GitHandler.Run($"{configurationString} commit --no-gpg-sign -m \"{assetMessage}\"", config);
codeCommitted = true;
// Get the first 10 digits of the combined SHA. The generatedTagName will be the
// config.TagPrefix_<SHA>
if (GitHandler.TryRun("rev-parse --short=10 HEAD", config.AssetsRepoLocation.ToString(), out CommandResult SHAResult))
{
var newSHA = SHAResult.StdOut.Trim();
generatedTagName += $"_{newSHA}";
} else
{
throw GenerateInvokeException(SHAResult);
}
GitHandler.Run($"tag --no-sign {generatedTagName}", config);
var remoteResult = GitHandler.Run($"ls-remote origin --tags {generatedTagName}", config);
if (string.IsNullOrWhiteSpace(remoteResult.StdOut))
{
GitHandler.Run($"push origin {generatedTagName}", config);
}
else
{
_consoleWrapper.WriteLine($"Not attempting to push tag '{generatedTagName}', as it already exists within the assets repo");
}
}
catch(GitProcessException e)
{
HideOrigin(config);
// we should not reset soft if we haven't ever committed.
if (codeCommitted)
{
// the only executions that have a real chance of failing are
// - ls-remote origin
// - push
// if we have a failure on either of these, we need to unstage our changes for an easy re-attempt at pushing.
GitHandler.TryRun("reset --soft HEAD^", config.AssetsRepoLocation.ToString(), out CommandResult ResetResult);
}
throw GenerateInvokeException(e.Result);
}
await UpdateAssetsJson(generatedTagName, config);
await BreadCrumb.Update(config);
}
HideOrigin(config);
return 0;
}
/// <summary>
/// Restores a set of recordings from the assets repo. Honors configuration of assets.json passed into it.
/// </summary>
/// <param name="pathToAssetsJson"></param>
/// <returns></returns>
public async Task<string> Restore(string pathToAssetsJson) {
var config = await ParseConfigurationFile(pathToAssetsJson);
var restoreQueue = InitTasks.GetOrAdd(config.AssetsJsonRelativeLocation, new TaskQueue());
await restoreQueue.EnqueueAsync(async () =>
{
var initialized = IsAssetsRepoInitialized(config);
if (!initialized)
{
InitializeAssetsRepo(config);
}
CheckoutRepoAtConfig(config, cleanEnabled: true);
await BreadCrumb.Update(config);
});
return config.AssetsRepoLocation.ToString();
}
/// <summary>
/// Resets a cloned assets repository to the default contained within the assets.json targeted commit. This
/// function should only be called by the user as the server will only use Restore.
/// </summary>
/// <param name="pathToAssetsJson"></param>
/// <returns></returns>
public async Task Reset(string pathToAssetsJson)
{
var config = await ParseConfigurationFile(pathToAssetsJson);
var initialized = IsAssetsRepoInitialized(config);
var allowReset = false;
if (!initialized)
{
InitializeAssetsRepo(config);
}
SetOrigin(config);
var pendingChanges = DetectPendingChanges(config);
if (pendingChanges.Length > 0)
{
_consoleWrapper.WriteLine($"There are pending git changes, are you sure you want to reset? [Y|N]");
while (true)
{
string response = _consoleWrapper.ReadLine();
response = response.ToLowerInvariant();
if (response.Equals("y"))
{
allowReset = true;
break;
}
else if (response.Equals("n"))
{
allowReset = false;
break;
}
else
{
_consoleWrapper.WriteLine("Please answer [Y|N]");
}
}
}
if (allowReset)
{
if (!string.IsNullOrWhiteSpace(config.Tag))
{
Clean(config);
CheckoutRepoAtConfig(config, cleanEnabled: false);
await BreadCrumb.Update(config);
}
}
HideOrigin(config);
}
private void Clean(GitAssetsConfiguration config)
{
try
{
GitHandler.Run("checkout .", config);
GitHandler.Run("clean -xdf", config);
}
catch (GitProcessException e)
{
HideOrigin(config);
throw GenerateInvokeException(e.Result);
}
}
/// <summary>
/// Given a CommandResult, generate an HttpException.
/// </summary>
/// <param name="result"></param>
/// <returns></returns>
public HttpException GenerateInvokeException(CommandResult result)
{
var message = $"Invocation of \"git {result.Arguments}\" had a non-zero exit code {result.ExitCode}.\nStdOut: {result.StdOut}\nStdErr: {result.StdErr}\n";
return new HttpException(HttpStatusCode.InternalServerError, message);
}
private void SetSafeDirectory(GitAssetsConfiguration config)
{
// Workaround for git directory ownership checks that may fail when running in a container as a different user.
if ("true" == Environment.GetEnvironmentVariable("TEST_PROXY_CONTAINER"))
{
GitHandler.Run($"config --global --add safe.directory {config.AssetsRepoLocation}", config);
}
}
/// <summary>
/// Checks an asset repository for pending changes. Equivalent of "git status --porcelain".
/// </summary>
/// <param name="config"></param>
/// <returns></returns>
public string[] DetectPendingChanges(GitAssetsConfiguration config)
{
SetSafeDirectory(config);
if (!GitHandler.TryRun($"status --porcelain", config.AssetsRepoLocation.ToString(), out var diffResult))
{
throw GenerateInvokeException(diffResult);
}
if (!string.IsNullOrWhiteSpace(diffResult.StdOut))
{
// Normally, we'd use Environment.NewLine here but this doesn't work on Windows since its NewLine is \r\n and
// Git's NewLine is just \n
var individualResults = diffResult.StdOut.Split("\n")
.Select(x => x.Trim())
.Where(x => !string.IsNullOrWhiteSpace(x)).ToArray();
return individualResults;
}
return new string[] {};
}
private void SetOrigin(GitAssetsConfiguration config)
{
var cloneUrl = GetCloneUrl(config.AssetsRepo, config.RepoRoot);
// in cases of failure to initialize a real git repo. we need to NOT run git remote set-url
if (config.IsAssetsRepoInitialized())
{
GitHandler.Run($"remote set-url origin {cloneUrl}", config);
}
else
{
_consoleWrapper.WriteLine($"The assets folder within \"{config.AssetsRepoLocation.ToString()}\" was not properly initialized, and as such the proxy is skipping override of the origin url.");
}
}
private void HideOrigin(GitAssetsConfiguration config)
{
var publicOrigin = GetCloneUrl(config.AssetsRepo, config.RepoRoot, honorToken: false);
if (config.IsAssetsRepoInitialized())
{
GitHandler.Run($"remote set-url origin {publicOrigin}", config);
}
else
{
_consoleWrapper.WriteLine($"The assets folder within \"{config.AssetsRepoLocation.ToString()}\" was not properly initialized, and as such the proxy is skipping override of the origin url.");
}
}
/// <summary>
/// Given a configuration, set the sparse-checkout directory for the config, then attempt checkout of the targeted Tag.
/// </summary>
/// <param name="config"></param>
/// <param name="cleanEnabled">A newly initialized repo should not be 'cleaned', as that will result in a git error. However, a new
/// clone looks the same as being on the wrong tag. This variable allows us to prevent over-active cleaning that would result in exceptions.</param>
public void CheckoutRepoAtConfig(GitAssetsConfiguration config, bool cleanEnabled = true)
{
// we are already on a targeted tag and as such don't want to discard our recordings
if (Assets.TryGetValue(config.AssetsJsonRelativeLocation.ToString(), out var value) && value == config.Tag)
{
return;
}
// if we are NOT on our targeted tag, before we attempt to switch we need to reset without asking for permission
else if (cleanEnabled)
{
Clean(config);
}
var checkoutPaths = ResolveCheckoutPaths(config);
try
{
SetSafeDirectory(config);
if (!string.IsNullOrEmpty(config.Tag))
{
SetOrigin(config);
// Always retrieve latest as we don't know when the last time we fetched from origin was. If we're lucky, this is a
// no-op. However, we are only paying this price _once_ per startup of the server (as we cache assets.json status remember!).
GitHandler.Run($"fetch origin refs/tags/{config.Tag}:refs/tags/{config.Tag}", config);
}
// Set non-cone mode otherwise path filters will not work in git >= 2.37.0
// See https://github.blog/2022-06-27-highlights-from-git-2-37/#tidbits
GitHandler.Run($"sparse-checkout set --no-cone {checkoutPaths}", config);
// The -c advice.detachedHead=false removes the verbose detatched head state
// warning that happens when syncing sparse-checkout to a particular Tag
GitHandler.Run($"-c advice.detachedHead=false checkout {config.Tag}", config);
// the first argument, the key, is the path to the assets json relative location
// the second argument, the value, is the value we want to set the json elative location to
// the third argument is a function argument that resolves what to do in the "update" case. If the key already exists
// update the tag to what we just checked out.
Assets.AddOrUpdate(config.AssetsJsonRelativeLocation.ToString(), config.Tag, (key, oldValue) => config.Tag);
HideOrigin(config);
}
catch(GitProcessException e)
{
HideOrigin(config);
throw GenerateInvokeException(e.Result);
}
}
public string GetGitOwnerName(GitAssetsConfiguration config)
{
var ownerName = Environment.GetEnvironmentVariable(GIT_COMMIT_OWNER_ENV_VAR);
// If the owner wasn't set as part of the environment, check to see if there's
// a user.name set, if not
if (string.IsNullOrWhiteSpace(ownerName))
{
ownerName = GitHandler.Run("config --get user.name", config).StdOut;
if (string.IsNullOrWhiteSpace(ownerName))
{
// At this point we need to prompt the user
ownerName = "";
}
}
return ownerName.Trim();
}
public string GetGitOwnerEmail(GitAssetsConfiguration config)
{
var ownerEmail = Environment.GetEnvironmentVariable(GIT_COMMIT_EMAIL_ENV_VAR);
// If the owner wasn't set as part of the environment, check to see if there's
// a user.name set, if not
if (string.IsNullOrWhiteSpace(ownerEmail))
{
ownerEmail = GitHandler.Run("config --get user.email", config).StdOut;
if (string.IsNullOrWhiteSpace(ownerEmail))
{
// At this point we need to prompt the user
ownerEmail = "";
}
}
return ownerEmail.Trim();
}
public static string GetCloneUrl(string assetsRepo, string repositoryLocation, bool honorToken = true)
{
var GitHandler = new GitProcessHandler();
var consoleWrapper = new ConsoleWrapper();
var sshUrl = $"git@github.com:{assetsRepo}.git";
var httpUrl = $"https://github.com/{assetsRepo}";
if (honorToken)
{
var gitToken = Environment.GetEnvironmentVariable(GIT_TOKEN_ENV_VAR);
if (!string.IsNullOrWhiteSpace(gitToken))
{
httpUrl = $"https://{gitToken}@github.com/{assetsRepo}";
}
}
if (String.IsNullOrEmpty(repositoryLocation))
{
consoleWrapper.WriteLine("No git repository detected, defaulting to https protocol for assets repository.");
return httpUrl;
}
try
{
var remoteRan = GitHandler.TryRun("remote -v", repositoryLocation, out var result);
var repoRemote = result.StdOut.Split(Environment.NewLine).First();
if (remoteRan && !String.IsNullOrEmpty(repoRemote) && repoRemote.Contains("git@"))
{
return sshUrl;
}
// we want this to work when a targeted directory isn't a git repo yet.
// If that is the case, we will get an exit code 128. In this case only return the standard httpurl.
if(result.ExitCode > 0 && result.ExitCode != 128)
{
throw new GitProcessException(result);
}
return httpUrl;
}
catch
{
consoleWrapper.WriteLine("No git repository detected, defaulting to https protocol for assets repository.");
return httpUrl;
}
}
/// <summary>
/// Verifies whether or not a local repo has initialized for the targeted assets configuration
/// </summary>
/// <param name="config"></param>
public bool IsAssetsRepoInitialized(GitAssetsConfiguration config)
{
// we have to ensure that multiple threads hitting this same segment of code won't stomp on each other. restore is incredibly important.
lock (LocalCacheLock)
{
if (!LocalCacheRefreshed)
{
BreadCrumb.RefreshLocalCache(Assets, config);
LocalCacheRefreshed = true;
}
}
if (Assets.ContainsKey(config.AssetsJsonRelativeLocation.ToString()))
{
return true;
}
return config.IsAssetsRepoInitialized();
}
/// <summary>
/// Initializes an asset repo for a given configuration. This includes creating the target repo directory, cloning, and taking care of initial restore operations.
/// </summary>
/// <param name="config"></param>
/// <param name="forceInit"></param>
/// <returns></returns>
public bool InitializeAssetsRepo(GitAssetsConfiguration config, bool forceInit = false)
{
var workCompleted = false;
var initQueue = InitTasks.GetOrAdd(config.AssetsRepoLocation, new TaskQueue());
initQueue.Enqueue(() =>
{
var assetRepo = config.AssetsRepoLocation;
var initialized = IsAssetsRepoInitialized(config);
if (forceInit)
{
DirectoryHelper.DeleteGitDirectory(assetRepo.ToString());
Directory.CreateDirectory(assetRepo.ToString());
initialized = false;
}
if (!initialized)
{
try
{
var cloneUrl = GetCloneUrl(config.AssetsRepo, config.RepoRoot);
// The -c core.longpaths=true is basically for Windows and is a noop for other platforms
GitHandler.Run($"clone -c core.longpaths=true --no-checkout --filter=tree:0 {cloneUrl} .", config);
GitHandler.Run("config --local core.safecrlf false", config);
GitHandler.Run($"sparse-checkout init", config);
}
catch (GitProcessException e)
{
throw GenerateInvokeException(e.Result);
}
CheckoutRepoAtConfig(config, cleanEnabled: false);
workCompleted = true;
}
});
return workCompleted;
}
/// <summary>
/// Evaluates an assets configuration and returns the correct sparse checkout path.
/// </summary>
/// <param name="config"></param>
/// <returns>A relative path for use within the assets repo.</returns>
/// <exception cref="NotImplementedException"></exception>
public string ResolveCheckoutPaths(GitAssetsConfiguration config)
{
var combinedPath = new NormalizedString(Path.Join(config.AssetsRepoPrefixPath ?? String.Empty, config.AssetsJsonRelativeLocation)).ToString();
if (combinedPath.ToLower() == AssetsJsonFileName)
{
return "./ eng/ .gitignore";
}
else
{
return combinedPath.Substring(0, combinedPath.Length - (AssetsJsonFileName.Length + 1)) + " eng/ .gitignore";
}
}
#endregion
#region code repo interactions
/// <summary>
/// Parses a configuration assets.json into a strongly typed representation of the same. A GitAssetConfiguration is used to describe work throughout the GitStore.
/// </summary>
/// <param name="assetsJsonPath"></param>
/// <returns></returns>
/// <exception cref="HttpException"></exception>
public async Task<GitAssetsConfiguration> ParseConfigurationFile(string assetsJsonPath)
{
if (!File.Exists(assetsJsonPath) && !Directory.Exists(assetsJsonPath))
{
throw new HttpException(HttpStatusCode.BadRequest, $"The provided {AssetsJsonFileName} path of \"{assetsJsonPath}\" does not exist.");
}
var pathToAssets = ResolveAssetsJson(assetsJsonPath);
var assetsContent = await File.ReadAllTextAsync(pathToAssets);
if (string.IsNullOrWhiteSpace(assetsContent) || assetsContent.Trim() == "{}")
{
throw new HttpException(HttpStatusCode.BadRequest, $"The provided {AssetsJsonFileName} at \"{assetsJsonPath}\" did not have valid json present.");
}
try
{
var assetConfig = JsonSerializer.Deserialize<GitAssetsConfiguration>(assetsContent, options: new JsonSerializerOptions() { AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip });
if (string.IsNullOrWhiteSpace(assetConfig.AssetsRepo))
{
throw new HttpException(HttpStatusCode.BadRequest, $"Unable to utilize the {AssetsJsonFileName} present at \"{assetsJsonPath}. It must contain value for the key \"AssetsRepo\" to be considered a valid {AssetsJsonFileName}.");
}
var repoRoot = AscendToRepoRoot(pathToAssets);
assetConfig.AssetsJsonLocation = new NormalizedString(pathToAssets);
assetConfig.AssetsJsonRelativeLocation = new NormalizedString(Path.GetRelativePath(repoRoot, pathToAssets));
assetConfig.RepoRoot = new NormalizedString(repoRoot);
assetConfig.AssetsFileName = AssetsJsonFileName;
return assetConfig;
}
catch (Exception e)
{
throw new HttpException(HttpStatusCode.BadRequest, $"Unable to parse {AssetsJsonFileName} content at \"{assetsJsonPath}\". Exception: {e.Message}");
}
}
/// <summary>
/// Reaches out to a git repo and resolves the name of the default branch.
/// </summary>
/// <param name="config">A valid and populated GitAssetsConfiguration generated from a assets.json.</param>
/// <returns>The default branch</returns>
public async Task<string> GetDefaultBranch(GitAssetsConfiguration config)
{
var token = Environment.GetEnvironmentVariable(GIT_TOKEN_ENV_VAR);
HttpRequestMessage msg = new HttpRequestMessage()
{
RequestUri = new Uri($"https://api.github.com/repos/{config.AssetsRepo}"),
Method = HttpMethod.Get
};
if (token != null)
{
msg.Headers.Authorization = new AuthenticationHeaderValue("token", token);
msg.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
msg.Headers.Add("User-Agent", "Azure-Sdk-Test-Proxy");
}
var webResult = await httpClient.SendAsync(msg);
if (webResult.StatusCode == HttpStatusCode.OK)
{
var doc = JsonDocument.Parse(webResult.Content.ReadAsStream(), options: new JsonDocumentOptions() { AllowTrailingCommas = true });
if (doc.RootElement.TryGetProperty("default_branch", out var result))
{
return result.ToString();
}
}
return DefaultBranch;
}
/// <summary>
/// Used to ascend to the repo root of any given startup path. Unlike ResolveAssetsJson, which implements similar ascension logic, this function returns the repo root, NOT the assets.json.
/// </summary>
/// <param name="path"></param>
/// <returns>An absolute path to the discovered repo root.</returns>
/// <exception cref="HttpException"></exception>
public string AscendToRepoRoot(string path)
{
var originalPath = path.Clone();
var fileAttributes = File.GetAttributes(path);
if (!(fileAttributes.HasFlag(FileAttributes.Directory)))
{
path = Path.GetDirectoryName(path);
}
while (true)
{
var evaluation = EvaluateDirectory(path);
if (evaluation.IsGitRoot)
{
return path;
}
else if (evaluation.IsRoot)
{
throw new HttpException(HttpStatusCode.BadRequest, $"The target directory \"{originalPath}\" does not exist within a git repository. This is disallowed when utilizing git store.");
}
path = Path.GetDirectoryName(path);
}
}
/// <summary>
/// Verify that the inputPath is either a full path to the assets json or a full directory path that contains an assets.json
/// </summary>
/// <param name="inputPath">A valid directory. If passed an assets json file directly instead of a directory, that value will be returned.</param>
/// <returns>A path to a file named "assets.json"</returns>
/// <exception cref="HttpException"></exception>
public string ResolveAssetsJson(string inputPath)
{
if (inputPath.ToLowerInvariant().EndsWith(AssetsJsonFileName))
{
return inputPath;
}
var originalPath = inputPath.Clone();
var directoryEval = EvaluateDirectory(inputPath);
if (directoryEval.AssetsJsonPresent)
{
return Path.Join(inputPath, AssetsJsonFileName);
}
throw new HttpException(HttpStatusCode.BadRequest, $"Unable to locate an {AssetsJsonFileName} at or above the targeted directory \"{originalPath}\".");
}
/// <summary>
/// Evaluates a directory and determines whether it contains an assets json, whether it is a git repo root, and if it is a root folder.
/// </summary>
/// <param name="directoryPath">Path to a directory. If given an actual file path, it will use the directory CONTAINING that file as the directory it is evaluating.</param>
/// <returns></returns>
public DirectoryEvaluation EvaluateDirectory(string directoryPath)
{
var fileAttributes = File.GetAttributes(directoryPath);
if (!(fileAttributes.HasFlag(FileAttributes.Directory)))
{
directoryPath = Path.GetDirectoryName(directoryPath);
}
var assetsJsonLocation = Path.Join(directoryPath, AssetsJsonFileName);
var gitLocation = Path.Join(directoryPath, ".git");
return new DirectoryEvaluation()
{
AssetsJsonPresent = File.Exists(assetsJsonLocation),
IsGitRoot = Directory.Exists(gitLocation) || File.Exists(gitLocation),
IsRoot = new DirectoryInfo(directoryPath).Parent == null
};
}
/// <summary>
/// Do we have a new update for the assets.json? Right now, only the recording Tag is automatically updatable by the test-proxy.
/// </summary>
/// <param name="newSha"></param>
/// <param name="config"></param>
public async Task UpdateAssetsJson(string newSha, GitAssetsConfiguration config)
{
// only do work if the SHAs aren't equivalent
if (config.Tag != newSha)
{
config.Tag = newSha;
// we deliberately do an extremely stripped down version parse and update here. We do this primarily to maintain
// any comments left in the assets.json though maintaining attribute ordering is also nice. To do this, we read all the file content, then
// simply replace the existing Tag value with the new one, then write the content back to the json file.
var currentSHA = (await ParseConfigurationFile(config.AssetsJsonLocation.ToString())).Tag;
var content = await File.ReadAllTextAsync(config.AssetsJsonLocation.ToString());
if (String.IsNullOrWhiteSpace(currentSHA))
{
// we can only do the tag replacement if we HAVE a Tag property in the json
if (content.Contains("\"Tag\""))
{
string pattern = @"""Tag"":\s*""\s*""";
content = Regex.Replace(content, pattern, $"\"Tag\": \"{newSha}\"", RegexOptions.IgnoreCase);
}
// if not we just have to reserialize the entire thing. This edge case is not worth the amount of extra code
// necessary to maintain the cohesion of the tag file.
else
{
content = JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true });
}
}
else
{
content = content.Replace(currentSHA, newSha);
}
File.WriteAllText(config.AssetsJsonLocation.ToString(), content);
}
}
#endregion
}
}