using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using GitHub.Runner.Common; using GitHub.Runner.Sdk; namespace GitHub.Runner.Worker.Container { [ServiceLocator(Default = typeof(DockerCommandManager))] public interface IDockerCommandManager : IRunnerService { string DockerPath { get; } string DockerInstanceLabel { get; } Task DockerVersion(IExecutionContext context); Task DockerPull(IExecutionContext context, string image); Task DockerBuild(IExecutionContext context, string workingDirectory, string dockerFile, string tag); Task DockerCreate(IExecutionContext context, ContainerInfo container); Task DockerRun(IExecutionContext context, ContainerInfo container, EventHandler stdoutDataReceived, EventHandler stderrDataReceived); Task DockerStart(IExecutionContext context, string containerId); Task DockerLogs(IExecutionContext context, string containerId); Task> DockerPS(IExecutionContext context, string options); Task DockerRemove(IExecutionContext context, string containerId); Task DockerNetworkCreate(IExecutionContext context, string network); Task DockerNetworkRemove(IExecutionContext context, string network); Task DockerNetworkPrune(IExecutionContext context); Task DockerExec(IExecutionContext context, string containerId, string options, string command); 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); } public class DockerCommandManager : RunnerService, IDockerCommandManager { public string DockerPath { get; private set; } public string DockerInstanceLabel { get; private set; } public override void Initialize(IHostContext hostContext) { base.Initialize(hostContext); DockerPath = WhichUtil.Which("docker", true, Trace); DockerInstanceLabel = IOUtil.GetPathHash(hostContext.GetDirectory(WellKnownDirectory.Root)).Substring(0, 6); } public async Task DockerVersion(IExecutionContext context) { string serverVersionStr = (await ExecuteDockerCommandAsync(context, "version", "--format '{{.Server.APIVersion}}'")).FirstOrDefault(); ArgUtil.NotNullOrEmpty(serverVersionStr, "Docker.Server.Version"); context.Output($"Docker daemon API version: {serverVersionStr}"); string clientVersionStr = (await ExecuteDockerCommandAsync(context, "version", "--format '{{.Client.APIVersion}}'")).FirstOrDefault(); ArgUtil.NotNullOrEmpty(serverVersionStr, "Docker.Client.Version"); context.Output($"Docker client API version: {clientVersionStr}"); // we interested about major.minor.patch version Regex verRegex = new Regex("\\d+\\.\\d+(\\.\\d+)?", RegexOptions.IgnoreCase); Version serverVersion = null; var serverVersionMatchResult = verRegex.Match(serverVersionStr); if (serverVersionMatchResult.Success && !string.IsNullOrEmpty(serverVersionMatchResult.Value)) { if (!Version.TryParse(serverVersionMatchResult.Value, out serverVersion)) { serverVersion = null; } } Version clientVersion = null; var clientVersionMatchResult = verRegex.Match(serverVersionStr); if (clientVersionMatchResult.Success && !string.IsNullOrEmpty(clientVersionMatchResult.Value)) { if (!Version.TryParse(clientVersionMatchResult.Value, out clientVersion)) { clientVersion = null; } } return new DockerVersion(serverVersion, clientVersion); } public async Task DockerPull(IExecutionContext context, string image) { return await ExecuteDockerCommandAsync(context, "pull", image, context.CancellationToken); } public async Task DockerBuild(IExecutionContext context, string workingDirectory, string dockerFile, string tag) { return await ExecuteDockerCommandAsync(context, "build", $"-t {tag} \"{dockerFile}\"", workingDirectory, context.CancellationToken); } public async Task DockerCreate(IExecutionContext context, ContainerInfo container) { IList dockerOptions = new List(); // OPTIONS dockerOptions.Add($"--name {container.ContainerDisplayName}"); dockerOptions.Add($"--label {DockerInstanceLabel}"); if (!string.IsNullOrEmpty(container.ContainerWorkDirectory)) { dockerOptions.Add($"--workdir {container.ContainerWorkDirectory}"); } if (!string.IsNullOrEmpty(container.ContainerNetwork)) { dockerOptions.Add($"--network {container.ContainerNetwork}"); } if (!string.IsNullOrEmpty(container.ContainerNetworkAlias)) { dockerOptions.Add($"--network-alias {container.ContainerNetworkAlias}"); } foreach (var port in container.UserPortMappings) { dockerOptions.Add($"-p {port.Value}"); } dockerOptions.Add($"{container.ContainerCreateOptions}"); foreach (var env in container.ContainerEnvironmentVariables) { if (String.IsNullOrEmpty(env.Value)) { dockerOptions.Add($"-e \"{env.Key}\""); } else { dockerOptions.Add($"-e \"{env.Key}={env.Value.Replace("\"", "\\\"")}\""); } } // Watermark for GitHub Action environment dockerOptions.Add("-e GITHUB_ACTIONS=true"); foreach (var volume in container.MountVolumes) { // replace `"` with `\"` and add `"{0}"` to all path. String volumeArg; if (String.IsNullOrEmpty(volume.SourceVolumePath)) { // Anonymous docker volume volumeArg = $"-v \"{volume.TargetVolumePath.Replace("\"", "\\\"")}\""; } else { // Named Docker volume / host bind mount volumeArg = $"-v \"{volume.SourceVolumePath.Replace("\"", "\\\"")}\":\"{volume.TargetVolumePath.Replace("\"", "\\\"")}\""; } if (volume.ReadOnly) { volumeArg += ":ro"; } dockerOptions.Add(volumeArg); } if (!string.IsNullOrEmpty(container.ContainerEntryPoint)) { dockerOptions.Add($"--entrypoint \"{container.ContainerEntryPoint}\""); } // IMAGE dockerOptions.Add($"{container.ContainerImage}"); // COMMAND // Intentionally blank. Always overwrite ENTRYPOINT and/or send ARGs // [ARG...] dockerOptions.Add($"{container.ContainerEntryPointArgs}"); var optionsString = string.Join(" ", dockerOptions); List outputStrings = await ExecuteDockerCommandAsync(context, "create", optionsString); return outputStrings.FirstOrDefault(); } public async Task DockerRun(IExecutionContext context, ContainerInfo container, EventHandler stdoutDataReceived, EventHandler stderrDataReceived) { IList dockerOptions = new List(); // OPTIONS dockerOptions.Add($"--name {container.ContainerDisplayName}"); dockerOptions.Add($"--label {DockerInstanceLabel}"); dockerOptions.Add($"--workdir {container.ContainerWorkDirectory}"); dockerOptions.Add($"--rm"); foreach (var env in container.ContainerEnvironmentVariables) { // e.g. -e MY_SECRET maps the value into the exec'ed process without exposing // the value directly in the command dockerOptions.Add($"-e {env.Key}"); } // Watermark for GitHub Action environment dockerOptions.Add("-e GITHUB_ACTIONS=true"); if (!string.IsNullOrEmpty(container.ContainerEntryPoint)) { dockerOptions.Add($"--entrypoint \"{container.ContainerEntryPoint}\""); } if (!string.IsNullOrEmpty(container.ContainerNetwork)) { dockerOptions.Add($"--network {container.ContainerNetwork}"); } foreach (var volume in container.MountVolumes) { // replace `"` with `\"` and add `"{0}"` to all path. String volumeArg; if (String.IsNullOrEmpty(volume.SourceVolumePath)) { // Anonymous docker volume volumeArg = $"-v \"{volume.TargetVolumePath.Replace("\"", "\\\"")}\""; } else { // Named Docker volume / host bind mount volumeArg = $"-v \"{volume.SourceVolumePath.Replace("\"", "\\\"")}\":\"{volume.TargetVolumePath.Replace("\"", "\\\"")}\""; } if (volume.ReadOnly) { volumeArg += ":ro"; } dockerOptions.Add(volumeArg); } // IMAGE dockerOptions.Add($"{container.ContainerImage}"); // COMMAND // Intentionally blank. Always overwrite ENTRYPOINT and/or send ARGs // [ARG...] dockerOptions.Add($"{container.ContainerEntryPointArgs}"); var optionsString = string.Join(" ", dockerOptions); return await ExecuteDockerCommandAsync(context, "run", optionsString, container.ContainerEnvironmentVariables, stdoutDataReceived, stderrDataReceived, context.CancellationToken); } public async Task DockerStart(IExecutionContext context, string containerId) { return await ExecuteDockerCommandAsync(context, "start", containerId, context.CancellationToken); } public async Task DockerRemove(IExecutionContext context, string containerId) { return await ExecuteDockerCommandAsync(context, "rm", $"--force {containerId}", context.CancellationToken); } public async Task DockerLogs(IExecutionContext context, string containerId) { return await ExecuteDockerCommandAsync(context, "logs", $"--details {containerId}", context.CancellationToken); } public async Task> DockerPS(IExecutionContext context, string options) { return await ExecuteDockerCommandAsync(context, "ps", options); } public async Task DockerNetworkCreate(IExecutionContext context, string network) { #if OS_WINDOWS return await ExecuteDockerCommandAsync(context, "network", $"create --label {DockerInstanceLabel} {network} --driver nat", context.CancellationToken); #else return await ExecuteDockerCommandAsync(context, "network", $"create --label {DockerInstanceLabel} {network}", context.CancellationToken); #endif } public async Task DockerNetworkRemove(IExecutionContext context, string network) { return await ExecuteDockerCommandAsync(context, "network", $"rm {network}", context.CancellationToken); } public async Task DockerNetworkPrune(IExecutionContext context) { return await ExecuteDockerCommandAsync(context, "network", $"prune --force --filter \"label={DockerInstanceLabel}\"", context.CancellationToken); } public async Task DockerExec(IExecutionContext context, string containerId, string options, string command) { return await ExecuteDockerCommandAsync(context, "exec", $"{options} {containerId} {command}", context.CancellationToken); } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously (method has async logic on only certain platforms) public async Task DockerExec(IExecutionContext context, string containerId, string options, string command, List output) #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { ArgUtil.NotNull(output, nameof(output)); string arg = $"exec {options} {containerId} {command}".Trim(); context.Command($"{DockerPath} {arg}"); object outputLock = new object(); var processInvoker = HostContext.CreateService(); processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message) { if (!string.IsNullOrEmpty(message.Data)) { lock (outputLock) { output.Add(message.Data); } } }; processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message) { if (!string.IsNullOrEmpty(message.Data)) { lock (outputLock) { output.Add(message.Data); } } }; #if OS_WINDOWS || OS_OSX throw new NotSupportedException($"Container operation is only supported on Linux"); #else return await processInvoker.ExecuteAsync( workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Work), fileName: DockerPath, arguments: arg, environment: null, requireExitCodeZero: false, outputEncoding: null, cancellationToken: CancellationToken.None); #endif } public async Task> DockerInspect(IExecutionContext context, string dockerObject, string options) { return await ExecuteDockerCommandAsync(context, "inspect", $"{options} {dockerObject}"); } public async Task> DockerPort(IExecutionContext context, string containerId) { List portMappingLines = await ExecuteDockerCommandAsync(context, "port", containerId); return DockerUtil.ParseDockerPort(portMappingLines); } private Task ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, CancellationToken cancellationToken = default(CancellationToken)) { return ExecuteDockerCommandAsync(context, command, options, null, cancellationToken); } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously (method has async logic on only certain platforms) private async Task ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, IDictionary environment, EventHandler stdoutDataReceived, EventHandler stderrDataReceived, CancellationToken cancellationToken = default(CancellationToken)) #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { string arg = $"{command} {options}".Trim(); context.Command($"{DockerPath} {arg}"); var processInvoker = HostContext.CreateService(); processInvoker.OutputDataReceived += stdoutDataReceived; processInvoker.ErrorDataReceived += stderrDataReceived; #if OS_WINDOWS || OS_OSX throw new NotSupportedException($"Container operation is only supported on Linux"); #else return await processInvoker.ExecuteAsync( workingDirectory: context.GetGitHubContext("workspace"), fileName: DockerPath, arguments: arg, environment: environment, requireExitCodeZero: false, outputEncoding: null, killProcessOnCancel: false, cancellationToken: cancellationToken); #endif } #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously (method has async logic on only certain platforms) private async Task ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, string workingDirectory, CancellationToken cancellationToken = default(CancellationToken)) #pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously { string arg = $"{command} {options}".Trim(); context.Command($"{DockerPath} {arg}"); var processInvoker = HostContext.CreateService(); processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message) { context.Output(message.Data); }; processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message) { context.Output(message.Data); }; #if OS_WINDOWS || OS_OSX throw new NotSupportedException($"Container operation is only supported on Linux"); #else return await processInvoker.ExecuteAsync( workingDirectory: workingDirectory ?? context.GetGitHubContext("workspace"), fileName: DockerPath, arguments: arg, environment: null, requireExitCodeZero: false, outputEncoding: null, killProcessOnCancel: false, redirectStandardIn: null, cancellationToken: cancellationToken); #endif } private async Task> ExecuteDockerCommandAsync(IExecutionContext context, string command, string options) { string arg = $"{command} {options}".Trim(); context.Command($"{DockerPath} {arg}"); List output = new List(); var processInvoker = HostContext.CreateService(); processInvoker.OutputDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message) { if (!string.IsNullOrEmpty(message.Data)) { output.Add(message.Data); context.Output(message.Data); } }; processInvoker.ErrorDataReceived += delegate (object sender, ProcessDataReceivedEventArgs message) { if (!string.IsNullOrEmpty(message.Data)) { context.Output(message.Data); } }; await processInvoker.ExecuteAsync( workingDirectory: context.GetGitHubContext("workspace"), fileName: DockerPath, arguments: arg, environment: null, requireExitCodeZero: true, outputEncoding: null, cancellationToken: CancellationToken.None); return output; } } }