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); } public async Task DockerExec(IExecutionContext context, string containerId, string options, string command, List output) { 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 (!Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux)) { throw new NotSupportedException("Container operations are only supported on Linux runners"); } return await processInvoker.ExecuteAsync( workingDirectory: HostContext.GetDirectory(WellKnownDirectory.Work), fileName: DockerPath, arguments: arg, environment: null, requireExitCodeZero: false, outputEncoding: null, cancellationToken: CancellationToken.None); } 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); } private async Task ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, IDictionary environment, EventHandler stdoutDataReceived, EventHandler stderrDataReceived, CancellationToken cancellationToken = default(CancellationToken)) { string arg = $"{command} {options}".Trim(); context.Command($"{DockerPath} {arg}"); var processInvoker = HostContext.CreateService(); processInvoker.OutputDataReceived += stdoutDataReceived; processInvoker.ErrorDataReceived += stderrDataReceived; if (!Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux)) { throw new NotSupportedException("Container operations are only supported on Linux runners"); } return await processInvoker.ExecuteAsync( workingDirectory: context.GetGitHubContext("workspace"), fileName: DockerPath, arguments: arg, environment: environment, requireExitCodeZero: false, outputEncoding: null, killProcessOnCancel: false, cancellationToken: cancellationToken); } private async Task ExecuteDockerCommandAsync(IExecutionContext context, string command, string options, string workingDirectory, CancellationToken cancellationToken = default(CancellationToken)) { 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 (!Constants.Runner.Platform.Equals(Constants.OSPlatform.Linux)) { throw new NotSupportedException("Container operations are only supported on Linux runners"); } 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); } 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; } } }