Compare commits

..

2 Commits

Author SHA1 Message Date
github-actions[bot]
945b418b51 chore: npm audit fix for hashFiles dependencies 2025-10-20 07:03:02 +00:00
Lawrence Gripper
60af948051 Custom Image: Preflight checks (#4081)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-16 20:16:14 +00:00
7 changed files with 230 additions and 7 deletions

View File

@@ -1 +1 @@
2.329.0
<Update to ./src/runnerversion when creating release>

View File

@@ -1815,10 +1815,11 @@
}
},
"node_modules/eslint-plugin-github/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -5904,9 +5905,9 @@
}
},
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"requires": {
"balanced-match": "^1.0.0"

View File

@@ -170,6 +170,8 @@ namespace GitHub.Runner.Common
public static readonly string AddCheckRunIdToJobContext = "actions_add_check_run_id_to_job_context";
public static readonly string DisplayHelpfulActionsDownloadErrors = "actions_display_helpful_actions_download_errors";
public static readonly string ContainerActionRunnerTemp = "actions_container_action_runner_temp";
public static readonly string SnapshotPreflightHostedRunnerCheck = "actions_snapshot_preflight_hosted_runner_check";
public static readonly string SnapshotPreflightImageGenPoolCheck = "actions_snapshot_preflight_image_gen_pool_check";
}
// Node version migration related constants

View File

@@ -400,6 +400,10 @@ namespace GitHub.Runner.Worker
if (snapshotRequest != null)
{
var snapshotOperationProvider = HostContext.GetService<ISnapshotOperationProvider>();
// Check that that runner is capable of taking a snapshot
snapshotOperationProvider.RunSnapshotPreflightChecks(context);
// Add postjob step to write snapshot file
jobContext.RegisterPostJobStep(new JobExtensionRunner(
runAsync: (executionContext, _) => snapshotOperationProvider.CreateSnapshotRequestAsync(executionContext, snapshotRequest),
condition: snapshotRequest.Condition,

View File

@@ -1,15 +1,19 @@
#nullable enable
using System;
using System.IO;
using System.Threading.Tasks;
using GitHub.DistributedTask.Pipelines;
using GitHub.DistributedTask.WebApi;
using GitHub.Runner.Common;
using GitHub.Runner.Sdk;
using GitHub.Runner.Worker.Handlers;
namespace GitHub.Runner.Worker;
[ServiceLocator(Default = typeof(SnapshotOperationProvider))]
public interface ISnapshotOperationProvider : IRunnerService
{
Task CreateSnapshotRequestAsync(IExecutionContext executionContext, Snapshot snapshotRequest);
void RunSnapshotPreflightChecks(IExecutionContext jobContext);
}
public class SnapshotOperationProvider : RunnerService, ISnapshotOperationProvider
@@ -24,9 +28,32 @@ public class SnapshotOperationProvider : RunnerService, ISnapshotOperationProvid
}
IOUtil.SaveObject(snapshotRequest, snapshotRequestFilePath);
executionContext.Output($"Image Name: {snapshotRequest.ImageName} Version: {snapshotRequest.Version}");
executionContext.Output($"Request written to: {snapshotRequestFilePath}");
executionContext.Output("This request will be processed after the job completes. You will not receive any feedback on the snapshot process within the workflow logs of this job.");
executionContext.Output("If the snapshot process is successful, you should see a new image with the requested name in the list of available custom images when creating a new GitHub-hosted Runner.");
return Task.CompletedTask;
}
public void RunSnapshotPreflightChecks(IExecutionContext context)
{
var shouldCheckRunnerEnvironment = context.Global.Variables.GetBoolean(Constants.Runner.Features.SnapshotPreflightHostedRunnerCheck) ?? false;
if (shouldCheckRunnerEnvironment &&
context.Global.Variables.TryGetValue(WellKnownDistributedTaskVariables.RunnerEnvironment, out var runnerEnvironment) &&
!string.IsNullOrEmpty(runnerEnvironment))
{
context.Debug($"Snapshot: RUNNER_ENVIRONMENT={runnerEnvironment}");
if (!string.Equals(runnerEnvironment, "github-hosted", StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("Snapshot workflows must be run on a GitHub Hosted Runner");
}
}
var imageGenEnabled = StringUtil.ConvertToBoolean(Environment.GetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED"));
context.Debug($"Snapshot: GITHUB_ACTIONS_IMAGE_GEN_ENABLED={imageGenEnabled}");
var shouldCheckImageGenPool = context.Global.Variables.GetBoolean(Constants.Runner.Features.SnapshotPreflightImageGenPoolCheck) ?? false;
if (shouldCheckImageGenPool && !imageGenEnabled)
{
throw new ArgumentException("Snapshot workflows must be run a hosted runner with Image Generation enabled");
}
}
}

View File

@@ -567,5 +567,193 @@ namespace GitHub.Runner.Common.Tests.Worker
}
}
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task SnapshotPreflightChecks_HostedRunnerCheck_Enabled_GitHubHosted_Success()
{
using (TestHostContext hc = CreateTestContext())
{
_jobEc.Global.Variables.Set(WellKnownDistributedTaskVariables.RunnerEnvironment, "github-hosted");
hc.SetSingleton<ISnapshotOperationProvider>(new SnapshotOperationProvider());
_jobEc.Global.Variables.Set(Constants.Runner.Features.SnapshotPreflightHostedRunnerCheck, "true");
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
var snapshot = new Pipelines.Snapshot("TestImageNameForPreflightCheck");
var imageNameValueStringToken = new StringToken(null, null, null, snapshot.ImageName);
_message.Snapshot = imageNameValueStringToken;
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
await jobExtension.InitializeJob(_jobEc, _message);
var postJobSteps = _jobEc.PostJobSteps;
Assert.Equal(1, postJobSteps.Count);
}
Environment.SetEnvironmentVariable("RUNNER_ENVIRONMENT", null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task SnapshotPreflightChecks_HostedRunnerCheck_Enabled_SelfHosted_ThrowsException()
{
using (TestHostContext hc = CreateTestContext())
{
_jobEc.Global.Variables.Set(WellKnownDistributedTaskVariables.RunnerEnvironment, "self-hosted");
hc.SetSingleton<ISnapshotOperationProvider>(new SnapshotOperationProvider());
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
_jobEc.Global.Variables.Set(Constants.Runner.Features.SnapshotPreflightHostedRunnerCheck, "true");
var snapshot = new Pipelines.Snapshot("TestImageNameForPreflightCheck");
var imageNameValueStringToken = new StringToken(null, null, null, snapshot.ImageName);
_message.Snapshot = imageNameValueStringToken;
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
var exception = await Assert.ThrowsAsync<ArgumentException>(() => jobExtension.InitializeJob(_jobEc, _message));
Assert.Contains("Snapshot workflows must be run on a GitHub Hosted Runner", exception.Message);
}
Environment.SetEnvironmentVariable("RUNNER_ENVIRONMENT", null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task SnapshotPreflightChecks_ImageGenPoolCheck_Enabled_ImageGenEnabled_Success()
{
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", "true");
using (TestHostContext hc = CreateTestContext())
{
hc.SetSingleton<ISnapshotOperationProvider>(new SnapshotOperationProvider());
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
_jobEc.Global.Variables.Set(Constants.Runner.Features.SnapshotPreflightImageGenPoolCheck, "true");
var snapshot = new Pipelines.Snapshot("TestImageNameForPreflightCheck");
var imageNameValueStringToken = new StringToken(null, null, null, snapshot.ImageName);
_message.Snapshot = imageNameValueStringToken;
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
await jobExtension.InitializeJob(_jobEc, _message);
var postJobSteps = _jobEc.PostJobSteps;
Assert.Equal(1, postJobSteps.Count);
}
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task SnapshotPreflightChecks_ImageGenPoolCheck_Enabled_ImageGen_False_ThrowsException()
{
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", "false");
using (TestHostContext hc = CreateTestContext())
{
hc.SetSingleton<ISnapshotOperationProvider>(new SnapshotOperationProvider());
_jobEc.SetRunnerContext("environment", "github-hosted");
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
_jobEc.Global.Variables.Set(Constants.Runner.Features.SnapshotPreflightImageGenPoolCheck, "true");
var snapshot = new Pipelines.Snapshot("TestImageNameForPreflightCheck");
var imageNameValueStringToken = new StringToken(null, null, null, snapshot.ImageName);
_message.Snapshot = imageNameValueStringToken;
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
var exception = await Assert.ThrowsAsync<ArgumentException>(() => jobExtension.InitializeJob(_jobEc, _message));
Assert.Contains("Snapshot workflows must be run a hosted runner with Image Generation enabled", exception.Message);
}
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task SnapshotPreflightChecks_ImageGenPoolCheck_Enabled_ImageGen_Missing_ThrowsException()
{
using (TestHostContext hc = CreateTestContext())
{
hc.SetSingleton<ISnapshotOperationProvider>(new SnapshotOperationProvider());
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
_jobEc.Global.Variables.Set(Constants.Runner.Features.SnapshotPreflightImageGenPoolCheck, "true");
var snapshot = new Pipelines.Snapshot("TestImageNameForPreflightCheck");
var imageNameValueStringToken = new StringToken(null, null, null, snapshot.ImageName);
_message.Snapshot = imageNameValueStringToken;
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
var exception = await Assert.ThrowsAsync<ArgumentException>(() => jobExtension.InitializeJob(_jobEc, _message));
Assert.Contains("Snapshot workflows must be run a hosted runner with Image Generation enabled", exception.Message);
}
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null);
}
[Fact]
[Trait("Level", "L0")]
[Trait("Category", "Worker")]
public async Task SnapshotPreflightChecks_BothChecks_Enabled_AllConditionsMet_Success()
{
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", "true");
using (TestHostContext hc = CreateTestContext())
{
hc.SetSingleton<ISnapshotOperationProvider>(new SnapshotOperationProvider());
var jobExtension = new JobExtension();
jobExtension.Initialize(hc);
// Enable both preflight checks
_jobEc.Global.Variables.Set(WellKnownDistributedTaskVariables.RunnerEnvironment, "github-hosted");
_jobEc.Global.Variables.Set(Constants.Runner.Features.SnapshotPreflightHostedRunnerCheck, "true");
_jobEc.Global.Variables.Set(Constants.Runner.Features.SnapshotPreflightImageGenPoolCheck, "true");
var snapshot = new Pipelines.Snapshot("TestImageNameForPreflightCheck");
var imageNameValueStringToken = new StringToken(null, null, null, snapshot.ImageName);
_message.Snapshot = imageNameValueStringToken;
_actionManager.Setup(x => x.PrepareActionsAsync(It.IsAny<IExecutionContext>(), It.IsAny<IEnumerable<Pipelines.JobStep>>(), It.IsAny<Guid>()))
.Returns(Task.FromResult(new PrepareResult(new List<JobExtensionRunner>(), new Dictionary<Guid, IActionRunner>())));
await jobExtension.InitializeJob(_jobEc, _message);
var postJobSteps = _jobEc.PostJobSteps;
Assert.Equal(1, postJobSteps.Count);
}
Environment.SetEnvironmentVariable("RUNNER_ENVIRONMENT", null);
Environment.SetEnvironmentVariable("GITHUB_ACTIONS_IMAGE_GEN_ENABLED", null);
}
}
}

View File

@@ -38,6 +38,7 @@ public class SnapshotOperationProviderL0
Assert.NotNull(actualSnapshot);
Assert.Equal(expectedSnapshot.ImageName, actualSnapshot!.ImageName);
_ec.Verify(ec => ec.Write(null, $"Request written to: {_snapshotRequestFilePath}"), Times.Once);
_ec.Verify(ec => ec.Write(null, $"Image Name: {expectedSnapshot.ImageName} Version: {expectedSnapshot.Version}"), Times.Once);
_ec.Verify(ec => ec.Write(null, "This request will be processed after the job completes. You will not receive any feedback on the snapshot process within the workflow logs of this job."), Times.Once);
_ec.Verify(ec => ec.Write(null, "If the snapshot process is successful, you should see a new image with the requested name in the list of available custom images when creating a new GitHub-hosted Runner."), Times.Once);
_ec.VerifyNoOtherCalls();