Skip to content

Commit a153170

Browse files
author
David Kale
committed
Revert "Revert "Allow registry credentials for job/service containers (#694)""
This reverts commit a41a9ba.
1 parent c5904d5 commit a153170

File tree

9 files changed

+241
-4
lines changed

9 files changed

+241
-4
lines changed

src/Runner.Worker/Container/ContainerInfo.cs

+6
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ public ContainerInfo(IHostContext hostContext, Pipelines.JobContainer container,
3434
_environmentVariables = container.Environment;
3535
this.IsJobContainer = isJobContainer;
3636
this.ContainerNetworkAlias = networkAlias;
37+
this.RegistryAuthUsername = container.Credentials?.Username;
38+
this.RegistryAuthPassword = container.Credentials?.Password;
39+
this.RegistryServer = DockerUtil.ParseRegistryHostnameFromImageName(this.ContainerImage);
3740

3841
#if OS_WINDOWS
3942
_pathMappings.Add(new PathMapping(hostContext.GetDirectory(WellKnownDirectory.Work), "C:\\__w"));
@@ -79,6 +82,9 @@ public ContainerInfo(IHostContext hostContext, Pipelines.JobContainer container,
7982
public string ContainerWorkDirectory { get; set; }
8083
public string ContainerCreateOptions { get; private set; }
8184
public string ContainerRuntimePath { get; set; }
85+
public string RegistryServer { get; set; }
86+
public string RegistryAuthUsername { get; set; }
87+
public string RegistryAuthPassword { get; set; }
8288
public bool IsJobContainer { get; set; }
8389

8490
public IDictionary<string, string> ContainerEnvironmentVariables

src/Runner.Worker/Container/DockerCommandManager.cs

+36-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Linq;
55
using System.Text.RegularExpressions;
66
using System.Threading;
7+
using System.Threading.Channels;
78
using System.Threading.Tasks;
89
using GitHub.Runner.Common;
910
using GitHub.Runner.Sdk;
@@ -17,6 +18,7 @@ public interface IDockerCommandManager : IRunnerService
1718
string DockerInstanceLabel { get; }
1819
Task<DockerVersion> DockerVersion(IExecutionContext context);
1920
Task<int> DockerPull(IExecutionContext context, string image);
21+
Task<int> DockerPull(IExecutionContext context, string image, string configFileDirectory);
2022
Task<int> DockerBuild(IExecutionContext context, string workingDirectory, string dockerFile, string dockerContext, string tag);
2123
Task<string> DockerCreate(IExecutionContext context, ContainerInfo container);
2224
Task<int> DockerRun(IExecutionContext context, ContainerInfo container, EventHandler<ProcessDataReceivedEventArgs> stdoutDataReceived, EventHandler<ProcessDataReceivedEventArgs> stderrDataReceived);
@@ -31,6 +33,7 @@ public interface IDockerCommandManager : IRunnerService
3133
Task<int> DockerExec(IExecutionContext context, string containerId, string options, string command, List<string> outputs);
3234
Task<List<string>> DockerInspect(IExecutionContext context, string dockerObject, string options);
3335
Task<List<PortMapping>> DockerPort(IExecutionContext context, string containerId);
36+
Task<int> DockerLogin(IExecutionContext context, string configFileDirectory, string registry, string username, string password);
3437
}
3538

3639
public class DockerCommandManager : RunnerService, IDockerCommandManager
@@ -82,9 +85,18 @@ public async Task<DockerVersion> DockerVersion(IExecutionContext context)
8285
return new DockerVersion(serverVersion, clientVersion);
8386
}
8487

85-
public async Task<int> DockerPull(IExecutionContext context, string image)
88+
public Task<int> DockerPull(IExecutionContext context, string image)
8689
{
87-
return await ExecuteDockerCommandAsync(context, "pull", image, context.CancellationToken);
90+
return DockerPull(context, image, null);
91+
}
92+
93+
public async Task<int> DockerPull(IExecutionContext context, string image, string configFileDirectory)
94+
{
95+
if (string.IsNullOrEmpty(configFileDirectory))
96+
{
97+
return await ExecuteDockerCommandAsync(context, $"pull", image, context.CancellationToken);
98+
}
99+
return await ExecuteDockerCommandAsync(context, $"--config {configFileDirectory} pull", image, context.CancellationToken);
88100
}
89101

90102
public async Task<int> DockerBuild(IExecutionContext context, string workingDirectory, string dockerFile, string dockerContext, string tag)
@@ -346,6 +358,28 @@ public async Task<List<PortMapping>> DockerPort(IExecutionContext context, strin
346358
return DockerUtil.ParseDockerPort(portMappingLines);
347359
}
348360

361+
public Task<int> DockerLogin(IExecutionContext context, string configFileDirectory, string registry, string username, string password)
362+
{
363+
string args = $"--config {configFileDirectory} login {registry} -u {username} --password-stdin";
364+
context.Command($"{DockerPath} {args}");
365+
366+
var input = Channel.CreateBounded<string>(new BoundedChannelOptions(1) { SingleReader = true, SingleWriter = true });
367+
input.Writer.TryWrite(password);
368+
369+
var processInvoker = HostContext.CreateService<IProcessInvoker>();
370+
371+
return processInvoker.ExecuteAsync(
372+
workingDirectory: context.GetGitHubContext("workspace"),
373+
fileName: DockerPath,
374+
arguments: args,
375+
environment: null,
376+
requireExitCodeZero: false,
377+
outputEncoding: null,
378+
killProcessOnCancel: false,
379+
redirectStandardIn: input,
380+
cancellationToken: context.CancellationToken);
381+
}
382+
349383
private Task<int> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, CancellationToken cancellationToken = default(CancellationToken))
350384
{
351385
return ExecuteDockerCommandAsync(context, command, options, null, cancellationToken);

src/Runner.Worker/Container/DockerUtil.cs

+16
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,21 @@ public static string ParsePathFromConfigEnv(IList<string> configEnvLines)
4545
}
4646
return "";
4747
}
48+
49+
public static string ParseRegistryHostnameFromImageName(string name)
50+
{
51+
var nameSplit = name.Split('/');
52+
// Single slash is implictly from Dockerhub, unless first part has .tld or :port
53+
if (nameSplit.Length == 2 && (nameSplit[0].Contains(":") || nameSplit[0].Contains(".")))
54+
{
55+
return nameSplit[0];
56+
}
57+
// All other non Dockerhub registries
58+
else if (nameSplit.Length > 2)
59+
{
60+
return nameSplit[0];
61+
}
62+
return "";
63+
}
4864
}
4965
}

src/Runner.Worker/ContainerOperationProvider.cs

+88-1
Original file line numberDiff line numberDiff line change
@@ -198,12 +198,18 @@ private async Task StartContainerAsync(IExecutionContext executionContext, Conta
198198
}
199199
}
200200

201+
// TODO: Add at a later date. This currently no local package registry to test with
202+
// UpdateRegistryAuthForGitHubToken(executionContext, container);
203+
204+
// Before pulling, generate client authentication if required
205+
var configLocation = await ContainerRegistryLogin(executionContext, container);
206+
201207
// Pull down docker image with retry up to 3 times
202208
int retryCount = 0;
203209
int pullExitCode = 0;
204210
while (retryCount < 3)
205211
{
206-
pullExitCode = await _dockerManger.DockerPull(executionContext, container.ContainerImage);
212+
pullExitCode = await _dockerManger.DockerPull(executionContext, container.ContainerImage, configLocation);
207213
if (pullExitCode == 0)
208214
{
209215
break;
@@ -220,6 +226,9 @@ private async Task StartContainerAsync(IExecutionContext executionContext, Conta
220226
}
221227
}
222228

229+
// Remove credentials after pulling
230+
ContainerRegistryLogout(configLocation);
231+
223232
if (retryCount == 3 && pullExitCode != 0)
224233
{
225234
throw new InvalidOperationException($"Docker pull failed with exit code {pullExitCode}");
@@ -437,5 +446,83 @@ private async Task ContainerHealthcheck(IExecutionContext executionContext, Cont
437446
throw new InvalidOperationException($"Failed to initialize, {container.ContainerNetworkAlias} service is {serviceHealth}.");
438447
}
439448
}
449+
450+
private async Task<string> ContainerRegistryLogin(IExecutionContext executionContext, ContainerInfo container)
451+
{
452+
if (string.IsNullOrEmpty(container.RegistryAuthUsername) || string.IsNullOrEmpty(container.RegistryAuthPassword))
453+
{
454+
// No valid client config can be generated
455+
return "";
456+
}
457+
var configLocation = Path.Combine(HostContext.GetDirectory(WellKnownDirectory.Temp), $".docker_{Guid.NewGuid()}");
458+
try
459+
{
460+
var dirInfo = Directory.CreateDirectory(configLocation);
461+
}
462+
catch (Exception e)
463+
{
464+
throw new InvalidOperationException($"Failed to create directory to store registry client credentials: {e.Message}");
465+
}
466+
var loginExitCode = await _dockerManger.DockerLogin(
467+
executionContext,
468+
configLocation,
469+
container.RegistryServer,
470+
container.RegistryAuthUsername,
471+
container.RegistryAuthPassword);
472+
473+
if (loginExitCode != 0)
474+
{
475+
throw new InvalidOperationException($"Docker login for '{container.RegistryServer}' failed with exit code {loginExitCode}");
476+
}
477+
return configLocation;
478+
}
479+
480+
private void ContainerRegistryLogout(string configLocation)
481+
{
482+
try
483+
{
484+
if (!string.IsNullOrEmpty(configLocation) && Directory.Exists(configLocation))
485+
{
486+
Directory.Delete(configLocation, recursive: true);
487+
}
488+
}
489+
catch (Exception e)
490+
{
491+
throw new InvalidOperationException($"Failed to remove directory containing Docker client credentials: {e.Message}");
492+
}
493+
}
494+
495+
private void UpdateRegistryAuthForGitHubToken(IExecutionContext executionContext, ContainerInfo container)
496+
{
497+
var registryIsTokenCompatible = container.RegistryServer.Equals("docker.pkg.github.com", StringComparison.OrdinalIgnoreCase);
498+
if (!registryIsTokenCompatible)
499+
{
500+
return;
501+
}
502+
503+
var registryMatchesWorkflow = false;
504+
505+
// REGISTRY/OWNER/REPO/IMAGE[:TAG]
506+
var imageParts = container.ContainerImage.Split('/');
507+
if (imageParts.Length != 4)
508+
{
509+
executionContext.Warning($"Could not identify owner and repo for container image {container.ContainerImage}. Skipping automatic token auth");
510+
return;
511+
}
512+
var owner = imageParts[1];
513+
var repo = imageParts[2];
514+
var nwo = $"{owner}/{repo}";
515+
if (nwo.Equals(executionContext.GetGitHubContext("repository"), StringComparison.OrdinalIgnoreCase))
516+
{
517+
registryMatchesWorkflow = true;
518+
}
519+
520+
var registryCredentialsNotSupplied = string.IsNullOrEmpty(container.RegistryAuthUsername) && string.IsNullOrEmpty(container.RegistryAuthPassword);
521+
if (registryCredentialsNotSupplied && registryMatchesWorkflow)
522+
{
523+
container.RegistryAuthUsername = executionContext.GetGitHubContext("actor");
524+
container.RegistryAuthPassword = executionContext.GetGitHubContext("token");
525+
}
526+
}
440527
}
441528
}

src/Sdk/DTPipelines/Pipelines/JobContainer.cs

+31
Original file line numberDiff line numberDiff line change
@@ -56,5 +56,36 @@ public IList<String> Ports
5656
get;
5757
set;
5858
}
59+
60+
/// <summary>
61+
/// Gets or sets the credentials used for pulling the container iamge.
62+
/// </summary>
63+
public ContainerRegistryCredentials Credentials
64+
{
65+
get;
66+
set;
67+
}
68+
}
69+
70+
[EditorBrowsable(EditorBrowsableState.Never)]
71+
public sealed class ContainerRegistryCredentials
72+
{
73+
/// <summary>
74+
/// Gets or sets the user to authenticate to a registry with
75+
/// </summary>
76+
public String Username
77+
{
78+
get;
79+
set;
80+
}
81+
82+
/// <summary>
83+
/// Gets or sets the password to authenticate to a registry with
84+
/// </summary>
85+
public String Password
86+
{
87+
get;
88+
set;
89+
}
5990
}
6091
}

src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConstants.cs

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public sealed class PipelineTemplateConstants
1414
public const String Clean= "clean";
1515
public const String Container = "container";
1616
public const String ContinueOnError = "continue-on-error";
17+
public const String Credentials = "credentials";
1718
public const String Defaults = "defaults";
1819
public const String Env = "env";
1920
public const String Event = "event";
@@ -45,6 +46,7 @@ public sealed class PipelineTemplateConstants
4546
public const String Options = "options";
4647
public const String Outputs = "outputs";
4748
public const String OutputsPattern = "needs.*.outputs";
49+
public const String Password = "password";
4850
public const String Path = "path";
4951
public const String Pool = "pool";
5052
public const String Ports = "ports";
@@ -68,6 +70,7 @@ public sealed class PipelineTemplateConstants
6870
public const String Success = "success";
6971
public const String Template = "template";
7072
public const String TimeoutMinutes = "timeout-minutes";
73+
public const String Username = "username";
7174
public const String Uses = "uses";
7275
public const String VmImage = "vmImage";
7376
public const String Volumes = "volumes";

src/Sdk/DTPipelines/Pipelines/ObjectTemplating/PipelineTemplateConverter.cs

+27
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,30 @@ internal static Dictionary<String, String> ConvertToStepInputs(
209209
return (Int32)numberToken.Value;
210210
}
211211

212+
internal static ContainerRegistryCredentials ConvertToContainerCredentials(TemplateToken token)
213+
{
214+
var credentials = token.AssertMapping(PipelineTemplateConstants.Credentials);
215+
var result = new ContainerRegistryCredentials();
216+
foreach (var credentialProperty in credentials)
217+
{
218+
var propertyName = credentialProperty.Key.AssertString($"{PipelineTemplateConstants.Credentials} key");
219+
switch (propertyName.Value)
220+
{
221+
case PipelineTemplateConstants.Username:
222+
result.Username = credentialProperty.Value.AssertString(PipelineTemplateConstants.Username).Value;
223+
break;
224+
case PipelineTemplateConstants.Password:
225+
result.Password = credentialProperty.Value.AssertString(PipelineTemplateConstants.Password).Value;
226+
break;
227+
default:
228+
propertyName.AssertUnexpectedValue($"{PipelineTemplateConstants.Credentials} key {propertyName}");
229+
break;
230+
}
231+
}
232+
233+
return result;
234+
}
235+
212236
internal static JobContainer ConvertToJobContainer(
213237
TemplateContext context,
214238
TemplateToken value,
@@ -275,6 +299,9 @@ internal static JobContainer ConvertToJobContainer(
275299
}
276300
result.Volumes = volumeList;
277301
break;
302+
case PipelineTemplateConstants.Credentials:
303+
result.Credentials = ConvertToContainerCredentials(containerPropertyPair.Value);
304+
break;
278305
default:
279306
propertyName.AssertUnexpectedValue($"{PipelineTemplateConstants.Container} key");
280307
break;

src/Sdk/DTPipelines/workflow-v1.0.json

+16-1
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,8 @@
373373
"options": "non-empty-string",
374374
"env": "container-env",
375375
"ports": "sequence-of-non-empty-string",
376-
"volumes": "sequence-of-non-empty-string"
376+
"volumes": "sequence-of-non-empty-string",
377+
"credentials": "container-registry-credentials"
377378
}
378379
}
379380
},
@@ -404,6 +405,20 @@
404405
]
405406
},
406407

408+
"container-registry-credentials": {
409+
"context": [
410+
"secrets",
411+
"env",
412+
"github"
413+
],
414+
"mapping": {
415+
"properties": {
416+
"username": "non-empty-string",
417+
"password": "non-empty-string"
418+
}
419+
}
420+
},
421+
407422
"container-env": {
408423
"mapping": {
409424
"loose-key-type": "non-empty-string",

src/Test/L0/Container/DockerUtilL0.cs

+18
Original file line numberDiff line numberDiff line change
@@ -126,5 +126,23 @@ public void RegexParsesPathFromDockerConfigEnv()
126126
Assert.NotNull(result5);
127127
Assert.Equal("/foo/bar:/baz", result5);
128128
}
129+
130+
[Theory]
131+
[Trait("Level", "L0")]
132+
[Trait("Category", "Worker")]
133+
[InlineData("dockerhub/repo", "")]
134+
[InlineData("localhost/doesnt_work", "")]
135+
[InlineData("localhost:port/works", "localhost:port")]
136+
[InlineData("host.tld/works", "host.tld")]
137+
[InlineData("ghcr.io/owner/image", "ghcr.io")]
138+
[InlineData("gcr.io/project/image", "gcr.io")]
139+
[InlineData("myregistry.azurecr.io/namespace/image", "myregistry.azurecr.io")]
140+
[InlineData("account.dkr.ecr.region.amazonaws.com/image", "account.dkr.ecr.region.amazonaws.com")]
141+
[InlineData("docker.pkg.github.com/owner/repo/image", "docker.pkg.github.com")]
142+
public void ParseRegistryHostnameFromImageName(string input, string expected)
143+
{
144+
var actual = DockerUtil.ParseRegistryHostnameFromImageName(input);
145+
Assert.Equal(expected, actual);
146+
}
129147
}
130148
}

0 commit comments

Comments
 (0)