Skip to content

Commit 052dfbd

Browse files
authored
Symlink actions cache (#4260)
1 parent ecb5f29 commit 052dfbd

File tree

4 files changed

+126
-4
lines changed

4 files changed

+126
-4
lines changed

src/Runner.Common/Constants.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ public static class Agent
289289
public static readonly string ForcedActionsNodeVersion = "ACTIONS_RUNNER_FORCE_ACTIONS_NODE_VERSION";
290290
public static readonly string PrintLogToStdout = "ACTIONS_RUNNER_PRINT_LOG_TO_STDOUT";
291291
public static readonly string ActionArchiveCacheDirectory = "ACTIONS_RUNNER_ACTION_ARCHIVE_CACHE";
292+
public static readonly string SymlinkCachedActions = "ACTIONS_RUNNER_SYMLINK_CACHED_ACTIONS";
292293
public static readonly string EmitCompositeMarkers = "ACTIONS_RUNNER_EMIT_COMPOSITE_MARKERS";
293294
}
294295

src/Runner.Sdk/Util/IOUtil.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,16 @@ public static async Task<string> GetFileContentSha256HashAsync(string path)
9393
}
9494
}
9595

96+
public static FileSystemInfo CreateSymbolicLink(string destDirectory, string srcDirectory)
97+
{
98+
// ensure directory chain exists
99+
Directory.CreateDirectory(destDirectory);
100+
// delete leaf directory
101+
Directory.Delete(destDirectory);
102+
// create symlink for the leaf directory
103+
return Directory.CreateSymbolicLink(destDirectory, srcDirectory);
104+
}
105+
96106
public static void Delete(string path, CancellationToken cancellationToken)
97107
{
98108
DeleteDirectory(path, cancellationToken);

src/Runner.Worker/ActionManager.cs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -773,10 +773,6 @@ private async Task DownloadRepositoryActionAsync(IExecutionContext executionCont
773773
}
774774
else
775775
{
776-
// make sure we get a clean folder ready to use.
777-
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
778-
Directory.CreateDirectory(destDirectory);
779-
780776
if (downloadInfo.PackageDetails != null)
781777
{
782778
executionContext.Output($"##[group]Download immutable action package '{downloadInfo.NameWithOwner}@{downloadInfo.Ref}'");
@@ -811,6 +807,50 @@ private async Task DownloadRepositoryActionAsync(IExecutionContext executionCont
811807
if (!string.IsNullOrEmpty(actionArchiveCacheDir) &&
812808
Directory.Exists(actionArchiveCacheDir))
813809
{
810+
var symlinkCachedActions = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions));
811+
if (symlinkCachedActions)
812+
{
813+
Trace.Info($"Checking if can symlink '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}'");
814+
815+
var cacheDirectory = Path.Combine(actionArchiveCacheDir, downloadInfo.ResolvedNameWithOwner.Replace(Path.DirectorySeparatorChar, '_').Replace(Path.AltDirectorySeparatorChar, '_'), downloadInfo.ResolvedSha);
816+
if (Directory.Exists(cacheDirectory))
817+
{
818+
try
819+
{
820+
Trace.Info($"Found unpacked action directory '{cacheDirectory}' in cache directory '{actionArchiveCacheDir}'");
821+
822+
// repository archive from github always contains a nested folder
823+
var nestedDirectories = new DirectoryInfo(cacheDirectory).GetDirectories();
824+
if (nestedDirectories.Length != 1)
825+
{
826+
throw new InvalidOperationException($"'{cacheDirectory}' contains '{nestedDirectories.Length}' directories");
827+
}
828+
else
829+
{
830+
executionContext.Debug($"Symlink '{nestedDirectories[0].Name}' to '{destDirectory}'");
831+
// make sure we get a clean folder ready to use.
832+
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
833+
IOUtil.CreateSymbolicLink(destDirectory, nestedDirectories[0].FullName);
834+
}
835+
836+
executionContext.Debug($"Created symlink from cached directory '{cacheDirectory}' to '{destDirectory}'");
837+
executionContext.Global.JobTelemetry.Add(new JobTelemetry()
838+
{
839+
Type = JobTelemetryType.General,
840+
Message = $"Action archive cache usage: {downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha} use cache {useActionArchiveCache} has cache {hasActionArchiveCache} via symlink"
841+
});
842+
843+
Trace.Info("Finished getting action repository.");
844+
return;
845+
}
846+
catch (Exception ex)
847+
{
848+
Trace.Error($"Failed to create symlink from cached directory '{cacheDirectory}' to '{destDirectory}'. Error: {ex}");
849+
// Fall through to normal download logic
850+
}
851+
}
852+
}
853+
814854
hasActionArchiveCache = true;
815855
Trace.Info($"Check if action archive '{downloadInfo.ResolvedNameWithOwner}@{downloadInfo.ResolvedSha}' already exists in cache directory '{actionArchiveCacheDir}'");
816856
#if OS_WINDOWS
@@ -892,6 +932,10 @@ private async Task DownloadRepositoryActionAsync(IExecutionContext executionCont
892932
}
893933
#endif
894934

935+
// make sure we get a clean folder ready to use.
936+
IOUtil.DeleteDirectory(destDirectory, executionContext.CancellationToken);
937+
Directory.CreateDirectory(destDirectory);
938+
895939
// repository archive from github always contains a nested folder
896940
var subDirectories = new DirectoryInfo(stagingDirectory).GetDirectories();
897941
if (subDirectories.Length != 1)

src/Test/L0/Worker/ActionManagerL0.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,6 +468,73 @@ public async void PrepareActions_SkipDownloadActionForSelfRepo()
468468
}
469469
}
470470

471+
[Fact]
472+
[Trait("Level", "L0")]
473+
[Trait("Category", "Worker")]
474+
public async void PrepareActions_SymlinkCacheIsReentrant()
475+
{
476+
try
477+
{
478+
//Arrange
479+
Environment.SetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions, "true");
480+
Setup();
481+
var actionId = Guid.NewGuid();
482+
var actions = new List<Pipelines.ActionStep>
483+
{
484+
new Pipelines.ActionStep()
485+
{
486+
Name = "action",
487+
Id = actionId,
488+
Reference = new Pipelines.RepositoryPathReference()
489+
{
490+
Name = "actions/checkout",
491+
Ref = "master",
492+
RepositoryType = "GitHub"
493+
}
494+
},
495+
new Pipelines.ActionStep()
496+
{
497+
Name = "action",
498+
Id = actionId,
499+
Reference = new Pipelines.RepositoryPathReference()
500+
{
501+
Name = "actions/checkout",
502+
Ref = "master",
503+
RepositoryType = "GitHub"
504+
}
505+
}
506+
};
507+
508+
const string Content = @"
509+
name: 'Test'
510+
runs:
511+
using: 'node20'
512+
main: 'dist/index.js'
513+
";
514+
515+
string actionsArchive = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Temp), "actions_archive", "action_checkout");
516+
Directory.CreateDirectory(actionsArchive);
517+
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha"));
518+
Directory.CreateDirectory(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content"));
519+
await File.WriteAllTextAsync(Path.Combine(actionsArchive, "actions_checkout", "master-sha", "content", "action.yml"), Content);
520+
Environment.SetEnvironmentVariable(Constants.Variables.Agent.ActionArchiveCacheDirectory, actionsArchive);
521+
522+
//Act
523+
await _actionManager.PrepareActionsAsync(_ec.Object, actions);
524+
525+
//Assert
526+
string destDirectory = Path.Combine(_hc.GetDirectory(WellKnownDirectory.Actions), "actions", "checkout", "master");
527+
Assert.True(Directory.Exists(destDirectory), "Destination directory does not exist");
528+
var di = new DirectoryInfo(destDirectory);
529+
Assert.NotNull(di.LinkTarget);
530+
}
531+
finally
532+
{
533+
Environment.SetEnvironmentVariable(Constants.Variables.Agent.SymlinkCachedActions, null);
534+
Teardown();
535+
}
536+
}
537+
471538
#if OS_LINUX
472539
[Fact]
473540
[Trait("Level", "L0")]

0 commit comments

Comments
 (0)