diff --git a/src/Runner.Worker/Container/ContainerInfo.cs b/src/Runner.Worker/Container/ContainerInfo.cs index 695364c4a..bd906c5a2 100644 --- a/src/Runner.Worker/Container/ContainerInfo.cs +++ b/src/Runner.Worker/Container/ContainerInfo.cs @@ -34,6 +34,9 @@ namespace GitHub.Runner.Worker.Container _environmentVariables = container.Environment; this.IsJobContainer = isJobContainer; this.ContainerNetworkAlias = networkAlias; + this.RegistryAuthUsername = container.Credentials?.Username; + this.RegistryAuthPassword = container.Credentials?.Password; + this.RegistryServer = DockerUtil.ParseRegistryHostnameFromImageName(this.ContainerImage); #if OS_WINDOWS _pathMappings.Add(new PathMapping(hostContext.GetDirectory(WellKnownDirectory.Work), "C:\\__w")); @@ -79,6 +82,9 @@ namespace GitHub.Runner.Worker.Container public string ContainerWorkDirectory { get; set; } public string ContainerCreateOptions { get; private set; } public string ContainerRuntimePath { get; set; } + public string RegistryServer { get; set; } + public string RegistryAuthUsername { get; set; } + public string RegistryAuthPassword { get; set; } public bool IsJobContainer { get; set; } public IDictionary ContainerEnvironmentVariables diff --git a/src/Runner.Worker/Container/DockerCommandManager.cs b/src/Runner.Worker/Container/DockerCommandManager.cs index fd2d10517..8663e9340 100644 --- a/src/Runner.Worker/Container/DockerCommandManager.cs +++ b/src/Runner.Worker/Container/DockerCommandManager.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; using GitHub.Runner.Common; using GitHub.Runner.Sdk; @@ -17,6 +18,7 @@ namespace GitHub.Runner.Worker.Container string DockerInstanceLabel { get; } Task DockerVersion(IExecutionContext context); Task DockerPull(IExecutionContext context, string image); + Task DockerPull(IExecutionContext context, string image, string configFileDirectory); Task DockerBuild(IExecutionContext context, string workingDirectory, string dockerFile, string dockerContext, string tag); Task DockerCreate(IExecutionContext context, ContainerInfo container); Task DockerRun(IExecutionContext context, ContainerInfo container, EventHandler stdoutDataReceived, EventHandler stderrDataReceived); @@ -31,6 +33,7 @@ namespace GitHub.Runner.Worker.Container Task DockerExec(IExecutionContext context, string containerId, string options, string command, List outputs); Task> DockerInspect(IExecutionContext context, string dockerObject, string options); Task> DockerPort(IExecutionContext context, string containerId); + Task DockerLogin(IExecutionContext context, string configFileDirectory, string registry, string username, string password); } public class DockerCommandManager : RunnerService, IDockerCommandManager @@ -82,9 +85,18 @@ namespace GitHub.Runner.Worker.Container return new DockerVersion(serverVersion, clientVersion); } - public async Task DockerPull(IExecutionContext context, string image) + public Task DockerPull(IExecutionContext context, string image) { - return await ExecuteDockerCommandAsync(context, "pull", image, context.CancellationToken); + return DockerPull(context, image, null); + } + + public async Task DockerPull(IExecutionContext context, string image, string configFileDirectory) + { + if (string.IsNullOrEmpty(configFileDirectory)) + { + return await ExecuteDockerCommandAsync(context, $"pull", image, context.CancellationToken); + } + return await ExecuteDockerCommandAsync(context, $"--config {configFileDirectory} pull", image, context.CancellationToken); } public async Task DockerBuild(IExecutionContext context, string workingDirectory, string dockerFile, string dockerContext, string tag) @@ -346,6 +358,28 @@ namespace GitHub.Runner.Worker.Container return DockerUtil.ParseDockerPort(portMappingLines); } + public Task DockerLogin(IExecutionContext context, string configFileDirectory, string registry, string username, string password) + { + string args = $"--config {configFileDirectory} login {registry} -u {username} --password-stdin"; + context.Command($"{DockerPath} {args}"); + + var input = Channel.CreateBounded(new BoundedChannelOptions(1) { SingleReader = true, SingleWriter = true }); + input.Writer.TryWrite(password); + + var processInvoker = HostContext.CreateService(); + + return processInvoker.ExecuteAsync( + workingDirectory: context.GetGitHubContext("workspace"), + fileName: DockerPath, + arguments: args, + environment: null, + requireExitCodeZero: false, + outputEncoding: null, + killProcessOnCancel: false, + redirectStandardIn: input, + cancellationToken: context.CancellationToken); + } + private Task ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, CancellationToken cancellationToken = default(CancellationToken)) { return ExecuteDockerCommandAsync(context, command, options, null, cancellationToken); diff --git a/src/Runner.Worker/Container/DockerUtil.cs b/src/Runner.Worker/Container/DockerUtil.cs index 638a89cce..02c2ece5b 100644 --- a/src/Runner.Worker/Container/DockerUtil.cs +++ b/src/Runner.Worker/Container/DockerUtil.cs @@ -45,5 +45,21 @@ namespace GitHub.Runner.Worker.Container } return ""; } + + public static string ParseRegistryHostnameFromImageName(string name) + { + var nameSplit = name.Split('/'); + // Single slash is implictly from Dockerhub, unless first part has .tld or :port + if (nameSplit.Length == 2 && (nameSplit[0].Contains(":") || nameSplit[0].Contains("."))) + { + return nameSplit[0]; + } + // All other non Dockerhub registries + else if (nameSplit.Length > 2) + { + return nameSplit[0]; + } + return ""; + } } } diff --git a/src/Runner.Worker/ContainerOperationProvider.cs b/src/Runner.Worker/ContainerOperationProvider.cs index 8ec8a9644..bf1076480 100644 --- a/src/Runner.Worker/ContainerOperationProvider.cs +++ b/src/Runner.Worker/ContainerOperationProvider.cs @@ -198,12 +198,18 @@ namespace GitHub.Runner.Worker } } + // TODO: Add at a later date. This currently no local package registry to test with + // UpdateRegistryAuthForGitHubToken(executionContext, container); + + // Before pulling, generate client authentication if required + var configLocation = await ContainerRegistryLogin(executionContext, container); + // Pull down docker image with retry up to 3 times int retryCount = 0; int pullExitCode = 0; while (retryCount < 3) { - pullExitCode = await _dockerManger.DockerPull(executionContext, container.ContainerImage); + pullExitCode = await _dockerManger.DockerPull(executionContext, container.ContainerImage, configLocation); if (pullExitCode == 0) { break; @@ -220,6 +226,9 @@ namespace GitHub.Runner.Worker } } + // Remove credentials after pulling + ContainerRegistryLogout(configLocation); + if (retryCount == 3 && pullExitCode != 0) { throw new InvalidOperationException($"Docker pull failed with exit code {pullExitCode}"); @@ -437,5 +446,83 @@ namespace GitHub.Runner.Worker throw new InvalidOperationException($"Failed to initialize, {container.ContainerNetworkAlias} service is {serviceHealth}."); } } + + private async Task ContainerRegistryLogin(IExecutionContext executionContext, ContainerInfo container) + { + if (string.IsNullOrEmpty(container.RegistryAuthUsername) || string.IsNullOrEmpty(container.RegistryAuthPassword)) + { + // No valid client config can be generated + return ""; + } + var configLocation = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Temp), $".docker_{Guid.NewGuid()}"); + try + { + var dirInfo = Directory.CreateDirectory(configLocation); + } + catch (Exception e) + { + throw new InvalidOperationException($"Failed to create directory to store registry client credentials: {e.Message}"); + } + var loginExitCode = await _dockerManger.DockerLogin( + executionContext, + configLocation, + container.RegistryServer, + container.RegistryAuthUsername, + container.RegistryAuthPassword); + + if (loginExitCode != 0) + { + throw new InvalidOperationException($"Docker login for '{container.RegistryServer}' failed with exit code {loginExitCode}"); + } + return configLocation; + } + + private void ContainerRegistryLogout(string configLocation) + { + try + { + if (!string.IsNullOrEmpty(configLocation) && Directory.Exists(configLocation)) + { + Directory.Delete(configLocation, recursive: true); + } + } + catch (Exception e) + { + throw new InvalidOperationException($"Failed to remove directory containing Docker client credentials: {e.Message}"); + } + } + + private void UpdateRegistryAuthForGitHubToken(IExecutionContext executionContext, ContainerInfo container) + { + var registryIsTokenCompatible = container.RegistryServer.Equals("docker.pkg.github.com", StringComparison.OrdinalIgnoreCase); + if (!registryIsTokenCompatible) + { + return; + } + + var registryMatchesWorkflow = false; + + // REGISTRY/OWNER/REPO/IMAGE[:TAG] + var imageParts = container.ContainerImage.Split('/'); + if (imageParts.Length != 4) + { + executionContext.Warning($"Could not identify owner and repo for container image {container.ContainerImage}. Skipping automatic token auth"); + return; + } + var owner = imageParts[1]; + var repo = imageParts[2]; + var nwo = $"{owner}/{repo}"; + if (nwo.Equals(executionContext.GetGitHubContext("repository"), StringComparison.OrdinalIgnoreCase)) + { + registryMatchesWorkflow = true; + } + + var registryCredentialsNotSupplied = string.IsNullOrEmpty(container.RegistryAuthUsername) && string.IsNullOrEmpty(container.RegistryAuthPassword); + if (registryCredentialsNotSupplied && registryMatchesWorkflow) + { + container.RegistryAuthUsername = executionContext.GetGitHubContext("actor"); + container.RegistryAuthPassword = executionContext.GetGitHubContext("token"); + } + } } } diff --git a/src/Sdk/DTPipelines/Pipelines/JobContainer.cs b/src/Sdk/DTPipelines/Pipelines/JobContainer.cs index ad952cf53..901c4fed1 100644 --- a/src/Sdk/DTPipelines/Pipelines/JobContainer.cs +++ b/src/Sdk/DTPipelines/Pipelines/JobContainer.cs @@ -56,5 +56,36 @@ namespace GitHub.DistributedTask.Pipelines get; set; } + + /// + /// Gets or sets the credentials used for pulling the container iamge. + /// + public ContainerRegistryCredentials Credentials + { + get; + set; + } + } + + [EditorBrowsable(EditorBrowsableState.Never)] + public sealed class ContainerRegistryCredentials + { + /// + /// Gets or sets the user to authenticate to a registry with + /// + public String Username + { + get; + set; + } + + /// + /// Gets or sets the password to authenticate to a registry with + /// + public String Password + { + get; + set; + } } } diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs index 894b1ba6c..f769b1719 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs @@ -14,6 +14,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating public const String Clean= "clean"; public const String Container = "container"; public const String ContinueOnError = "continue-on-error"; + public const String Credentials = "credentials"; public const String Defaults = "defaults"; public const String Env = "env"; public const String Event = "event"; @@ -45,6 +46,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating public const String Options = "options"; public const String Outputs = "outputs"; public const String OutputsPattern = "needs.*.outputs"; + public const String Password = "password"; public const String Path = "path"; public const String Pool = "pool"; public const String Ports = "ports"; @@ -68,6 +70,7 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating public const String Success = "success"; public const String Template = "template"; public const String TimeoutMinutes = "timeout-minutes"; + public const String Username = "username"; public const String Uses = "uses"; public const String VmImage = "vmImage"; public const String Volumes = "volumes"; diff --git a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs index 5a9bc11b9..153e4e89b 100644 --- a/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs +++ b/src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs @@ -209,6 +209,30 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating return (Int32)numberToken.Value; } + internal static ContainerRegistryCredentials ConvertToContainerCredentials(TemplateToken token) + { + var credentials = token.AssertMapping(PipelineTemplateConstants.Credentials); + var result = new ContainerRegistryCredentials(); + foreach (var credentialProperty in credentials) + { + var propertyName = credentialProperty.Key.AssertString($"{PipelineTemplateConstants.Credentials} key"); + switch (propertyName.Value) + { + case PipelineTemplateConstants.Username: + result.Username = credentialProperty.Value.AssertString(PipelineTemplateConstants.Username).Value; + break; + case PipelineTemplateConstants.Password: + result.Password = credentialProperty.Value.AssertString(PipelineTemplateConstants.Password).Value; + break; + default: + propertyName.AssertUnexpectedValue($"{PipelineTemplateConstants.Credentials} key {propertyName}"); + break; + } + } + + return result; + } + internal static JobContainer ConvertToJobContainer( TemplateContext context, TemplateToken value, @@ -275,6 +299,9 @@ namespace GitHub.DistributedTask.Pipelines.ObjectTemplating } result.Volumes = volumeList; break; + case PipelineTemplateConstants.Credentials: + result.Credentials = ConvertToContainerCredentials(containerPropertyPair.Value); + break; default: propertyName.AssertUnexpectedValue($"{PipelineTemplateConstants.Container} key"); break; diff --git a/src/Sdk/DTPipelines/workflow-v1.0.json b/src/Sdk/DTPipelines/workflow-v1.0.json index 1903a90f7..9902a36dc 100644 --- a/src/Sdk/DTPipelines/workflow-v1.0.json +++ b/src/Sdk/DTPipelines/workflow-v1.0.json @@ -373,7 +373,8 @@ "options": "non-empty-string", "env": "container-env", "ports": "sequence-of-non-empty-string", - "volumes": "sequence-of-non-empty-string" + "volumes": "sequence-of-non-empty-string", + "credentials": "container-registry-credentials" } } }, @@ -404,6 +405,20 @@ ] }, + "container-registry-credentials": { + "context": [ + "secrets", + "env", + "github" + ], + "mapping": { + "properties": { + "username": "non-empty-string", + "password": "non-empty-string" + } + } + }, + "container-env": { "mapping": { "loose-key-type": "non-empty-string", diff --git a/src/Test/L0/Container/DockerUtilL0.cs b/src/Test/L0/Container/DockerUtilL0.cs index 4e9d06373..c069255a8 100644 --- a/src/Test/L0/Container/DockerUtilL0.cs +++ b/src/Test/L0/Container/DockerUtilL0.cs @@ -126,5 +126,23 @@ namespace GitHub.Runner.Common.Tests.Worker.Container Assert.NotNull(result5); Assert.Equal("/foo/bar:/baz", result5); } + + [Theory] + [Trait("Level", "L0")] + [Trait("Category", "Worker")] + [InlineData("dockerhub/repo", "")] + [InlineData("localhost/doesnt_work", "")] + [InlineData("localhost:port/works", "localhost:port")] + [InlineData("host.tld/works", "host.tld")] + [InlineData("ghcr.io/owner/image", "ghcr.io")] + [InlineData("gcr.io/project/image", "gcr.io")] + [InlineData("myregistry.azurecr.io/namespace/image", "myregistry.azurecr.io")] + [InlineData("account.dkr.ecr.region.amazonaws.com/image", "account.dkr.ecr.region.amazonaws.com")] + [InlineData("docker.pkg.github.com/owner/repo/image", "docker.pkg.github.com")] + public void ParseRegistryHostnameFromImageName(string input, string expected) + { + var actual = DockerUtil.ParseRegistryHostnameFromImageName(input); + Assert.Equal(expected, actual); + } } }