-
Notifications
You must be signed in to change notification settings - Fork 235
Expand file tree
/
Copy pathGitStore.cs
More file actions
832 lines (722 loc) · 36.6 KB
/
GitStore.cs
File metadata and controls
832 lines (722 loc) · 36.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
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>
/// 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>
/// <returns></returns>
public async Task Push(string pathToAssetsJson) {
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.");
Environment.ExitCode = -1;
return;
}
SetOrigin(config);
var pendingChanges = DetectPendingChanges(config);
var generatedTagName = config.TagPrefix;
bool codeCommitted = false;
if (pendingChanges.Length > 0)
{
if (CheckForSecrets(config, pendingChanges))
{
Environment.ExitCode = -1;
return;
}
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);
}
/// <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
*
* The output from git status porcelain will include two possible additional values
* " ?? path/to/file" -> File that is new
* " M path/to/file" -> File that is modified
* " D path/to/file" -> File that is deleted
*/
var individualResults = diffResult.StdOut.Split("\n")
// strim the leading space, the characters for ADDED or MODIFIED, and the space after them
.Select(x => x.Trim().TrimStart('?', 'M').Trim())
// exclude empty paths or paths that have been DELETED
.Where(x => !string.IsNullOrWhiteSpace(x) && !x.StartsWith("D")).ToArray();
return individualResults;
}
return new string[] {};
}
private void SetOrigin(GitAssetsConfiguration config)
{
var cloneUrl = GetCloneUrl(config.AssetsRepo, config.RepoRoot);
GitHandler.Run($"remote set-url origin {cloneUrl}", config);
}
private void HideOrigin(GitAssetsConfiguration config)
{
var publicOrigin = GetCloneUrl(config.AssetsRepo, config.RepoRoot, honorToken: false);
GitHandler.Run($"remote set-url origin {publicOrigin}", config);
}
/// <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($"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))
{
string pattern = @"""Tag"":\s*""\s*""";
content = Regex.Replace(content, pattern, $"\"Tag\": \"{newSha}\"", RegexOptions.IgnoreCase);
}
else
{
content = content.Replace(currentSHA, newSha);
}
File.WriteAllText(config.AssetsJsonLocation.ToString(), content);
}
}
#endregion
}
}