diff --git a/.gitignore b/.gitignore index 7459e648dc..f8734414dc 100644 --- a/.gitignore +++ b/.gitignore @@ -313,4 +313,5 @@ __pycache__/ npm-debug.log* yarn-debug.log* yarn-error.log* -Tools/ \ No newline at end of file +Tools/ +context/ \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs b/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs new file mode 100644 index 0000000000..c374fad0b1 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Actions/DockerExecution.cs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Actions +{ + using System; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using VirtualClient.Common; + using VirtualClient.Common.Docker; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + + /// + /// Executes child components within a Docker container environment. + /// + [SupportedPlatforms("linux-x64,linux-arm64")] + internal class DockerExecution : VirtualClientComponentCollection + { + private ISystemManagement systemManager; + private DockerContainerClient dockerClient; + + /// + /// Initializes a new instance of the class. + /// + /// Provides all of the required dependencies to the Virtual Client component. + /// Parameters defined in the execution profile or supplied to the Virtual Client on the command line. + public DockerExecution(IServiceCollection dependencies, IDictionary parameters = null) + : base(dependencies, parameters) + { + this.systemManager = dependencies.GetService(); + this.dockerClient = new DockerContainerClient(this.Logger); + } + + /// + /// The Docker image to use for container execution. + /// + public string Image + { + get + { + return this.Parameters.GetValue(nameof(this.Image)); + } + } + + /// + /// Optional volume mount paths. Format: "/host/path:/container/path". + /// + public string VolumeMounts + { + get + { + return this.Parameters.GetValue(nameof(this.VolumeMounts), string.Empty); + } + } + + /// + /// Optional environment variables for the container. Format: "VAR1=value1,VAR2=value2". + /// + public string EnvironmentVariables + { + get + { + return this.Parameters.GetValue(nameof(this.EnvironmentVariables), string.Empty); + } + } + + /// + /// Initializes Docker container execution requirements. + /// + protected override Task InitializeAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + // Validate Docker image is specified + if (string.IsNullOrWhiteSpace(this.Image)) + { + throw new ArgumentException("Docker image (Image parameter) is required for DockerExecution."); + } + + // Validate that at least one child component is defined + if (this.Count == 0) + { + throw new ArgumentException("DockerExecution must contain at least one child component."); + } + + this.Logger?.LogInformation( + $"DockerExecution: Initializing container execution. Image={this.Image}, Components={this.Count}"); + + return base.InitializeAsync(telemetryContext, cancellationToken); + } + + /// + /// Executes child components within the Docker container via docker exec. + /// + protected override async Task ExecuteAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.Logger?.LogInformation( + $"DockerExecution: Starting container execution. Image={this.Image}"); + + // Get container ID from environment (set by DockerCommand) + string containerId = Environment.GetEnvironmentVariable("VC_DOCKER_CONTAINER_ID"); + + if (string.IsNullOrWhiteSpace(containerId)) + { + this.Logger?.LogWarning( + "DockerExecution: No container ID found. Executing components on host (non-container mode)."); + + // Fallback: execute on host if no container context + await this.ExecuteComponentsOnHostAsync(cancellationToken).ConfigureAwait(false); + } + else + { + // Execute components inside container via docker exec + await this.ExecuteComponentsInContainerAsync(containerId, cancellationToken).ConfigureAwait(false); + } + + this.Logger?.LogInformation($"DockerExecution: Container execution completed."); + } + + /// + /// Cleans up Docker container resources. + /// + protected override Task CleanupAsync(EventContext telemetryContext, CancellationToken cancellationToken) + { + this.Logger?.LogInformation($"DockerExecution: Cleaning up container resources."); + return base.CleanupAsync(telemetryContext, cancellationToken); + } + + /// + /// Executes components on the host (fallback mode). + /// + private async Task ExecuteComponentsOnHostAsync(CancellationToken cancellationToken) + { + foreach (VirtualClientComponent component in this) + { + this.Logger?.LogInformation( + $"DockerExecution: Executing child component on host. Component={component.GetType().Name}"); + + await component.ExecuteAsync(cancellationToken).ConfigureAwait(false); + + if (cancellationToken.IsCancellationRequested) + { + break; + } + } + } + + /// + /// Executes components inside the Docker container. + /// + private async Task ExecuteComponentsInContainerAsync(string containerId, CancellationToken cancellationToken) + { + foreach (VirtualClientComponent component in this) + { + string componentName = component.GetType().Name; + this.Logger?.LogInformation( + $"DockerExecution: Executing child component in container. Component={componentName}, Container={containerId}"); + + try + { + // Build command to execute component in container + // Note: For MVP, we execute components individually + // Full implementation would handle parameters and profiles + string command = $"/app/VirtualClient.Main --component={componentName}"; + + this.Logger?.LogInformation($"DockerExecution: Running docker exec: {command}"); + + var execResult = await this.dockerClient.ExecuteInContainerAsync( + containerId, + command, + cancellationToken).ConfigureAwait(false); + + // Capture output for logging and telemetry + if (!execResult.Success) + { + this.Logger?.LogError( + $"DockerExecution: Component execution failed in container. " + + $"Component={componentName}, ExitCode={execResult.ExitCode}, " + + $"Error={execResult.StandardError}"); + + // Emit telemetry event for error + this.Logger?.LogError($"Container Error Output: {execResult.StandardError}"); + + throw new InvalidOperationException( + $"Component {componentName} failed inside container. Exit code: {execResult.ExitCode}. " + + $"Error: {execResult.StandardError}"); + } + + if (!string.IsNullOrWhiteSpace(execResult.StandardOutput)) + { + this.Logger?.LogInformation($"Container Output: {execResult.StandardOutput}"); + } + } + catch (Exception ex) + { + this.Logger?.LogError($"Exception executing component in container: {ex.Message}"); + throw; + } + + if (cancellationToken.IsCancellationRequested) + { + break; + } + } + } + } +} diff --git a/src/VirtualClient/VirtualClient.Common.UnitTests/Docker/DockerContainerClientTests.cs b/src/VirtualClient/VirtualClient.Common.UnitTests/Docker/DockerContainerClientTests.cs new file mode 100644 index 0000000000..ecccba022c --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common.UnitTests/Docker/DockerContainerClientTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.Docker +{ + using System; + using System.Runtime.InteropServices; + using NUnit.Framework; + + [TestFixture] + [Category("Unit")] + public class DockerContainerClientTests + { + [Test] + [TestCase("linux", "amd64", "", PlatformID.Unix, Architecture.X64)] + [TestCase("linux", "arm64", "", PlatformID.Unix, Architecture.Arm64)] + [TestCase("linux", "arm", "v8", PlatformID.Unix, Architecture.Arm64)] + [TestCase("linux", "arm", "v7", PlatformID.Unix, Architecture.Arm)] + [TestCase("linux", "386", "", PlatformID.Unix, Architecture.X86)] + [TestCase("linux", "x86_64", "", PlatformID.Unix, Architecture.X64)] + [TestCase("linux", "aarch64", "", PlatformID.Unix, Architecture.Arm64)] + [TestCase("windows", "amd64", "", PlatformID.Win32NT, Architecture.X64)] + public void ParsePlatformFromInspectJsonReturnsExpectedPlatformAndArchitecture( + string os, string arch, string variant, PlatformID expectedPlatform, Architecture expectedArch) + { + string json = $@"[{{""Os"":""{os}"",""Architecture"":""{arch}"",""Variant"":""{variant}""}}]"; + + var (platform, architecture) = DockerContainerClient.ParsePlatformFromInspectJson(json); + + Assert.AreEqual(expectedPlatform, platform); + Assert.AreEqual(expectedArch, architecture); + } + + [Test] + public void ParsePlatformFromInspectJsonThrowsOnUnsupportedOs() + { + string json = @"[{""Os"":""freebsd"",""Architecture"":""amd64"",""Variant"":""""}]"; + + Assert.Throws(() => + DockerContainerClient.ParsePlatformFromInspectJson(json)); + } + + [Test] + public void ParsePlatformFromInspectJsonThrowsOnUnsupportedArchitecture() + { + string json = @"[{""Os"":""linux"",""Architecture"":""mips"",""Variant"":""""}]"; + + Assert.Throws(() => + DockerContainerClient.ParsePlatformFromInspectJson(json)); + } + + [Test] + public void ParsePlatformFromInspectJsonThrowsOnInvalidJson() + { + Assert.Throws(() => + DockerContainerClient.ParsePlatformFromInspectJson("not json")); + } + + [Test] + public void ParsePlatformFromInspectJsonThrowsOnEmptyArray() + { + Assert.Throws(() => + DockerContainerClient.ParsePlatformFromInspectJson("[]")); + } + + [Test] + public void ParsePlatformFromInspectJsonHandlesRealUbuntuInspectOutput() + { + // Mirrors actual 'docker image inspect ubuntu:24.04' JSON structure + string json = @"[{ + ""Id"": ""sha256:abc123"", + ""Os"": ""linux"", + ""Architecture"": ""amd64"", + ""Variant"": """", + ""Config"": {} + }]"; + + var (platform, architecture) = DockerContainerClient.ParsePlatformFromInspectJson(json); + + Assert.AreEqual(PlatformID.Unix, platform); + Assert.AreEqual(Architecture.X64, architecture); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Common/Docker/DockerContainerClient.cs b/src/VirtualClient/VirtualClient.Common/Docker/DockerContainerClient.cs new file mode 100644 index 0000000000..fabc31c31a --- /dev/null +++ b/src/VirtualClient/VirtualClient.Common/Docker/DockerContainerClient.cs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Common.Docker +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Runtime.InteropServices; + using System.Text; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using VirtualClient.Common.Extensions; + + /// + /// Manages Docker container operations: creation, execution, cleanup. + /// + public class DockerContainerClient + { + private const string DockerCommand = "docker"; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + public DockerContainerClient(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Checks if Docker is available and running on the system. + /// + public async Task IsDockerAvailableAsync(CancellationToken cancellationToken) + { + try + { + var result = await this.ExecuteDockerCommandAsync("version", null, cancellationToken).ConfigureAwait(false); + return result.ExitCode == 0; + } + catch (Exception ex) + { + this.logger?.LogWarning($"Docker availability check failed: {ex.Message}"); + return false; + } + } + + /// + /// Inspects a Docker image and returns the container platform and architecture. + /// + public async Task<(PlatformID Platform, Architecture Architecture)> InspectImageAsync( + string imageName, + CancellationToken cancellationToken) + { + this.LogDocker($"Inspecting Docker image platform: {imageName}"); + + var result = await this.ExecuteDockerCommandAsync($"image inspect {imageName}", null, cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to inspect Docker image '{imageName}'. Error: {result.StandardError}"); + } + + return DockerContainerClient.ParsePlatformFromInspectJson(result.StandardOutput); + } + + /// + /// Parses the output of 'docker image inspect' JSON to determine the container platform and architecture. + /// + public static (PlatformID Platform, Architecture Architecture) ParsePlatformFromInspectJson(string inspectJson) + { + JsonArray array; + + try + { + array = JsonNode.Parse(inspectJson)?.AsArray() + ?? throw new ArgumentException("Invalid docker inspect JSON output."); + } + catch (System.Text.Json.JsonException ex) + { + throw new ArgumentException("Invalid docker inspect JSON output.", ex); + } + + if (array.Count == 0) + { + throw new ArgumentException("Docker inspect output is empty."); + } + + var root = array[0] + ?? throw new ArgumentException("Docker inspect output is empty."); + + string os = root["Os"]?.GetValue() ?? string.Empty; + string arch = root["Architecture"]?.GetValue() ?? string.Empty; + string variant = root["Variant"]?.GetValue() ?? string.Empty; + + PlatformID platform = os.ToLowerInvariant() switch + { + "linux" => PlatformID.Unix, + "windows" => PlatformID.Win32NT, + _ => throw new NotSupportedException($"Unsupported container OS: '{os}'") + }; + + Architecture architecture = arch.ToLowerInvariant() switch + { + "amd64" or "x86_64" => Architecture.X64, + "arm64" or "aarch64" => Architecture.Arm64, + "arm" when variant.ToLowerInvariant() == "v8" => Architecture.Arm64, + "arm" => Architecture.Arm, + "386" or "i386" => Architecture.X86, + _ => throw new NotSupportedException($"Unsupported architecture: '{arch}'") + }; + + return (platform, architecture); + } + + /// + /// Creates a Docker container from the specified image with volume mounts. + /// + public async Task CreateContainerAsync( + string image, + IDictionary volumeMounts, + IDictionary environmentVariables, + CancellationToken cancellationToken) + { + // Build docker run command with volume mounts and environment variables + var arguments = new List { "run", "-d" }; + + // Add volume mounts + if (volumeMounts != null && volumeMounts.Count > 0) + { + foreach (var mount in volumeMounts) + { + arguments.Add("-v"); + arguments.Add($"{mount.Key}:{mount.Value}"); + } + } + + // Add environment variables + if (environmentVariables != null && environmentVariables.Count > 0) + { + foreach (var env in environmentVariables) + { + arguments.Add("-e"); + arguments.Add($"{env.Key}={env.Value}"); + } + } + + // Keep container running by default (tail -f /dev/null) + arguments.Add(image); + arguments.Add("tail"); + arguments.Add("-f"); + arguments.Add("/dev/null"); + + var argumentsString = string.Join(" ", arguments.Select(a => $"\"{a}\"")); + this.LogDocker($"Creating Docker container: docker {argumentsString}"); + + var result = await this.ExecuteDockerCommandAsync(string.Join(" ", arguments), null, cancellationToken).ConfigureAwait(false); + + if (result.ExitCode != 0) + { + throw new InvalidOperationException( + $"Failed to create Docker container from image '{image}'. Error: {result.StandardError}"); + } + + string containerId = result.StandardOutput?.Trim(); + if (string.IsNullOrWhiteSpace(containerId)) + { + throw new InvalidOperationException("Docker container creation succeeded but no container ID was returned."); + } + + this.LogDocker($"Docker container created successfully. Container ID: {containerId}"); + return containerId; + } + + /// + /// Executes a command inside a running Docker container. + /// + public async Task ExecuteInContainerAsync( + string containerId, + string command, + CancellationToken cancellationToken) + { + var arguments = $"exec {containerId} {command}"; + + this.LogDocker($"Executing command in container {containerId}: {command}"); + + var result = await this.ExecuteDockerCommandAsync(arguments, null, cancellationToken).ConfigureAwait(false); + + return new DockerExecResult + { + ExitCode = result.ExitCode, + StandardOutput = result.StandardOutput, + StandardError = result.StandardError, + Success = result.ExitCode == 0 + }; + } + + /// + /// Stops a running Docker container. + /// + public async Task StopContainerAsync(string containerId, CancellationToken cancellationToken) + { + try + { + this.LogDocker($"Stopping Docker container: {containerId}"); + var result = await this.ExecuteDockerCommandAsync($"stop {containerId}", null, cancellationToken).ConfigureAwait(false); + return result.ExitCode == 0; + } + catch (Exception ex) + { + this.logger?.LogWarning($"Failed to stop Docker container {containerId}: {ex.Message}"); + return false; + } + } + + /// + /// Removes a Docker container. + /// + public async Task RemoveContainerAsync(string containerId, CancellationToken cancellationToken) + { + try + { + this.LogDocker($"Removing Docker container: {containerId}"); + var result = await this.ExecuteDockerCommandAsync($"rm {containerId}", null, cancellationToken).ConfigureAwait(false); + return result.ExitCode == 0; + } + catch (Exception ex) + { + this.logger?.LogWarning($"Failed to remove Docker container {containerId}: {ex.Message}"); + return false; + } + } + + /// + /// Logs docker-related information with yellow color. + /// + private void LogDocker(string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + this.logger?.LogInformation(message); + Console.ResetColor(); + } + + /// + /// Executes a docker command and returns the result. + /// + private async Task ExecuteDockerCommandAsync( + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + var processInfo = new ProcessStartInfo + { + FileName = DockerCommand, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = workingDirectory ?? Environment.CurrentDirectory + }; + + var process = new Process { StartInfo = processInfo }; + + try + { + process.Start(); + + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + var stderrTask = process.StandardError.ReadToEndAsync(); + + await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false); + + // Wait for process exit asynchronously to satisfy AsyncFixer + while (!process.HasExited) + { + await Task.Delay(100, cancellationToken).ConfigureAwait(false); + } + + return new DockerCommandResult + { + ExitCode = process.ExitCode, + StandardOutput = stdoutTask.Result, + StandardError = stderrTask.Result + }; + } + finally + { + process?.Dispose(); + } + } + + /// + /// Result of a Docker command execution. + /// + private class DockerCommandResult + { + /// + /// Gets or sets the exit code from the command execution. + /// + public int ExitCode { get; set; } + + /// + /// Gets or sets the standard output from the command. + /// + public string StandardOutput { get; set; } + + /// + /// Gets or sets the standard error from the command. + /// + public string StandardError { get; set; } + } + } + + /// + /// Result of executing a command inside a Docker container. + /// + public class DockerExecResult + { + /// + /// Gets or sets the exit code from the command execution. + /// + public int ExitCode { get; set; } + + /// + /// Gets or sets the standard output from the command. + /// + public string StandardOutput { get; set; } + + /// + /// Gets or sets the standard error from the command. + /// + public string StandardError { get; set; } + + /// + /// Gets or sets a value indicating whether the command succeeded (exit code 0). + /// + public bool Success { get; set; } + } +} diff --git a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientComponentTests.cs b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientComponentTests.cs index 561f0ddfb5..88a3acf888 100644 --- a/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientComponentTests.cs +++ b/src/VirtualClient/VirtualClient.Contracts.UnitTests/VirtualClientComponentTests.cs @@ -724,6 +724,75 @@ public void VirtualClientComponentIsSupportedRespectsSupportedPlatformAttribute( Assert.IsFalse(VirtualClientComponent.IsSupported(component)); } + [Test] + public void VirtualClientComponentPlatformReturnsContainerPlatformWhenDockerEnvVarSet() + { + this.mockFixture.Setup(PlatformID.Win32NT, System.Runtime.InteropServices.Architecture.X64); + TestVirtualClientComponent component = new TestVirtualClientComponent(this.mockFixture.Dependencies, this.mockFixture.Parameters); + + try + { + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", "Unix"); + + Assert.AreEqual(PlatformID.Unix, component.Platform); + } + finally + { + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", null); + } + } + + [Test] + public void VirtualClientComponentPlatformReturnsHostPlatformWhenDockerEnvVarNotSet() + { + this.mockFixture.Setup(PlatformID.Win32NT, System.Runtime.InteropServices.Architecture.X64); + TestVirtualClientComponent component = new TestVirtualClientComponent(this.mockFixture.Dependencies, this.mockFixture.Parameters); + + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", null); + + Assert.AreEqual(PlatformID.Win32NT, component.Platform); + } + + [Test] + public void VirtualClientComponentCpuArchitectureReturnsContainerArchWhenDockerEnvVarSet() + { + this.mockFixture.Setup(PlatformID.Win32NT, System.Runtime.InteropServices.Architecture.X64); + TestVirtualClientComponent component = new TestVirtualClientComponent(this.mockFixture.Dependencies, this.mockFixture.Parameters); + + try + { + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", "Arm64"); + + Assert.AreEqual(System.Runtime.InteropServices.Architecture.Arm64, component.CpuArchitecture); + } + finally + { + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", null); + } + } + + [Test] + public void VirtualClientComponentIsSupportedUsesContainerPlatformWhenDockerEnvVarsSet() + { + // Windows host targeting a Linux container — component supports linux-x64 only + this.mockFixture.Setup(PlatformID.Win32NT, System.Runtime.InteropServices.Architecture.X64); + TestVirtualClientComponent2 component = new TestVirtualClientComponent2(this.mockFixture.Dependencies, this.mockFixture.Parameters); + + try + { + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", "Unix"); + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", "X64"); + + // linux-x64 is in [SupportedPlatforms("linux-x64,win-arm64,win-x64")] + Assert.IsTrue(component.IsSupported()); + } + finally + { + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", null); + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", null); + } + } + private class TestVirtualClientComponent : VirtualClientComponent { public TestVirtualClientComponent(VirtualClientComponent component) diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs index cfd2d4352b..931bc6b464 100644 --- a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs +++ b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs @@ -99,14 +99,12 @@ protected VirtualClientComponent(IServiceCollection dependencies, IDictionary(); this.AgentId = this.systemInfo.AgentId; - this.CpuArchitecture = this.systemInfo.CpuArchitecture; this.Dependencies = dependencies; this.ExperimentId = this.systemInfo.ExperimentId; this.Logger = NullLogger.Instance; this.Metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); this.MetadataContract = new MetadataContract(); this.PlatformSpecifics = this.systemInfo.PlatformSpecifics; - this.Platform = this.systemInfo.Platform; this.SupportedRoles = new List(); this.CleanupTasks = new List(); this.Extensions = new Dictionary(); @@ -192,9 +190,22 @@ public string ContentPathTemplate } /// - /// The CPU/processor architecture (e.g. amd64, arm). + /// The CPU/processor architecture (e.g. amd64, arm). Returns the container architecture when + /// running in Docker context (VC_DOCKER_ARCH env var set), otherwise returns host architecture. /// - public Architecture CpuArchitecture { get; } + public Architecture CpuArchitecture + { + get + { + string envArch = Environment.GetEnvironmentVariable("VC_DOCKER_ARCH"); + if (!string.IsNullOrEmpty(envArch) && Enum.TryParse(envArch, out Architecture containerArch)) + { + return containerArch; + } + + return this.systemInfo.CpuArchitecture; + } + } /// /// True/false whether log files upload request processing should be deferred @@ -442,9 +453,22 @@ protected set public bool ParametersEvaluated { get; protected set; } /// - /// The OS/system platform (e.g. Windows, Unix). + /// The OS/system platform (e.g. Windows, Unix). Returns the container platform when running + /// in Docker context (VC_DOCKER_PLATFORM env var set), otherwise returns host platform. /// - public PlatformID Platform { get; } + public PlatformID Platform + { + get + { + string envPlatform = Environment.GetEnvironmentVariable("VC_DOCKER_PLATFORM"); + if (!string.IsNullOrEmpty(envPlatform) && Enum.TryParse(envPlatform, out PlatformID containerPlatform)) + { + return containerPlatform; + } + + return this.systemInfo.Platform; + } + } /// /// Provides OS/system platform specific information. @@ -675,13 +699,14 @@ public IEnumerable Tags /// /// The name of the platform/architecture for the system on which the application is - /// running (e.g. linux-x64, linux-arm64, win-x64, win-arm64). + /// running (e.g. linux-x64, linux-arm64, win-x64, win-arm64). Returns the container + /// platform name when running in Docker context (VC_DOCKER_PLATFORM env var set). /// protected string PlatformArchitectureName { get { - return this.PlatformSpecifics.PlatformArchitectureName; + return PlatformSpecifics.GetPlatformArchitectureName(this.Platform, this.CpuArchitecture); } } diff --git a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs index 88cd90c416..a49011fc39 100644 --- a/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs +++ b/src/VirtualClient/VirtualClient.Dependencies.UnitTests/DockerInstallationTests.cs @@ -87,4 +87,4 @@ public TestDockerInstallation(IServiceCollection dependencies, IDictionary args, CancellationTok CommandLineParser.CreateApiSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateBootstrapSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateConvertSubcommand(commandLineArgs, cancellationTokenSource), + CommandLineParser.CreateDockerSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateGetTokenSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateUploadFilesSubcommand(commandLineArgs, cancellationTokenSource), CommandLineParser.CreateUploadTelemetrySubcommand(commandLineArgs, cancellationTokenSource) @@ -468,6 +469,89 @@ private static Command CreateConvertSubcommand(string[] args, CancellationTokenS return convertCommand; } + private static Command CreateDockerSubcommand(string[] args, CancellationTokenSource cancellationTokenSource) + { + Command dockerCommand = new Command( + "docker", + "Executes a workload profile inside a Docker container.") + { + // REQUIRED + // ------------------------------------------------------------------- + // --image + OptionFactory.CreateDockerImageOption(required: true), + + // --profile + OptionFactory.CreateProfileOption(required: true), + + // OPTIONAL + // ------------------------------------------------------------------- + // --parameters + OptionFactory.CreateParametersOption(required: false), + + // --timeout + OptionFactory.CreateTimeoutOption(required: false), + + // --log-dir + OptionFactory.CreateLogDirectoryOption(required: false), + + // --log-level + OptionFactory.CreateLogLevelOption(required: false, LogLevel.Information), + + // --verbose + OptionFactory.CreateVerboseFlag(required: false, false), + + // --keep-container-alive + OptionFactory.CreateKeepContainerAliveFlag(required: false, false), + + // --logger + OptionFactory.CreateLoggerOption(required: false), + + // --content + OptionFactory.CreateContentStoreOption(required: false), + + // --client-id + OptionFactory.CreateClientIdOption(required: false, Environment.MachineName), + + // --clean + OptionFactory.CreateCleanOption(required: false), + + // --event-hub + OptionFactory.CreateEventHubStoreOption(required: false), + + // --experiment-id + OptionFactory.CreateExperimentIdOption(required: false, Guid.NewGuid().ToString().ToLowerInvariant()), + + // --iteration + OptionFactory.CreateIterationsOption(required: false), + + // --key-vault + OptionFactory.CreateKeyVaultStoreOption(required: false), + + // --metadata + OptionFactory.CreateMetadataOption(required: false), + + // --layout + OptionFactory.CreateLayoutOption(required: false), + + // --log-to-file + OptionFactory.CreateLogToFileFlag(required: false), + + // --package-dir + OptionFactory.CreatePackageDirectoryOption(required: false), + + // --scenarios + OptionFactory.CreateScenariosOption(required: false), + + // --version + OptionFactory.CreateVersionOption(required: false) + }; + + dockerCommand.WithOptionValidation(args); + dockerCommand.Handler = CommandHandler.Create(cmd => cmd.ExecuteAsync(args, cancellationTokenSource)); + + return dockerCommand; + } + private static Command CreateGetTokenSubcommand(string[] args, CancellationTokenSource cancellationTokenSource) { Command getTokenCommand = new Command( diff --git a/src/VirtualClient/VirtualClient.Main/DockerCommand.cs b/src/VirtualClient/VirtualClient.Main/DockerCommand.cs new file mode 100644 index 0000000000..5727e247b4 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/DockerCommand.cs @@ -0,0 +1,294 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using VirtualClient.Common.Docker; + using VirtualClient.Common.Extensions; + using VirtualClient.Common.Telemetry; + using VirtualClient.Contracts; + + /// + /// Command executes a workload profile inside a Docker container. + /// + internal class DockerCommand : ExecuteProfileCommand + { + private DockerContainerClient dockerClient; + private string containerId; + + /// + /// The Docker image to use for container execution (e.g. ubuntu:noble, redis:7.0-alpine). + /// + public string DockerImage { get; set; } + + /// + /// Whether to keep the container alive after execution for debugging. + /// + public bool KeepContainerAlive { get; set; } + + /// + /// Initializes the command runtime before dependency initialization and execution. + /// + protected override void Initialize(string[] args, PlatformSpecifics platformSpecifics) + { + // Validate docker image is provided + if (string.IsNullOrWhiteSpace(this.DockerImage)) + { + throw new ArgumentException("Docker image (--image) is required for docker command execution."); + } + + // Validate that at least one profile is specified + if (this.Profiles == null || !this.Profiles.Any()) + { + throw new ArgumentException("At least one profile (--profile) is required for docker command execution."); + } + + // Set timeout to reasonable default if not specified + if (this.Timeout == null) + { + this.Timeout = new ProfileTiming(TimeSpan.FromMinutes(30)); + } + + // Call parent initialization + base.Initialize(args, platformSpecifics); + } + + /// + /// Executes the docker command: creates container, runs profile, cleans up, then flushes telemetry. + /// + protected override async Task ExecuteAsync(string[] args, IServiceCollection dependencies, CancellationTokenSource cancellationTokenSource) + { + ILogger logger = dependencies.GetService(); + this.dockerClient = new DockerContainerClient(logger); + CancellationToken cancellationToken = cancellationTokenSource.Token; + int exitCode = 0; + + try + { + // Step 1: Check Docker availability + this.LogDockerInfo(logger, "Checking Docker availability..."); + bool dockerAvailable = await this.dockerClient.IsDockerAvailableAsync(cancellationToken).ConfigureAwait(false); + + if (!dockerAvailable) + { + throw new InvalidOperationException( + "Docker is not available. Please ensure Docker is installed and the daemon is running. " + + "Run 'docker version' to verify your Docker installation."); + } + + this.LogDockerInfo(logger, "Docker is available and running."); + + // Step 2: Validate profile — fail immediately if DockerExecution is in any action (double Docker) + await this.ValidateProfileForDockerSubcommandAsync(dependencies, cancellationToken).ConfigureAwait(false); + + // Step 3: Create Docker container with volume mounts + this.LogDockerInfo(logger, $"Creating Docker container from image: {this.DockerImage}"); + + var volumeMounts = this.PrepareVolumeMounts(logger); + var environmentVariables = new Dictionary(); + + this.containerId = await this.dockerClient.CreateContainerAsync( + this.DockerImage, + volumeMounts, + environmentVariables, + cancellationToken).ConfigureAwait(false); + + this.LogDockerInfo(logger, $"Docker container created successfully. Container ID: {this.containerId}"); + + // Step 4: Set container ID in environment for child components + Environment.SetEnvironmentVariable("VC_DOCKER_CONTAINER_ID", this.containerId); + + // Step 5: Inspect image to detect container platform and set env vars for package resolution + this.LogDockerInfo(logger, $"Detecting container platform via: docker image inspect {this.DockerImage}"); + + var (containerPlatform, containerArch) = await this.dockerClient.InspectImageAsync( + this.DockerImage, cancellationToken).ConfigureAwait(false); + + Environment.SetEnvironmentVariable("VC_DOCKER_PLATFORM", containerPlatform.ToString()); + Environment.SetEnvironmentVariable("VC_DOCKER_ARCH", containerArch.ToString()); + + this.LogDockerInfo(logger, + $"Container platform detected: {containerPlatform}/{containerArch}. " + + $"Package resolution will use container platform."); + + // Step 6: Install profile dependencies on host (packages are volume-mounted into container) + this.LogDockerInfo(logger, "Installing profile dependencies on host..."); + this.InstallDependencies = true; + await base.ExecuteAsync(args, dependencies, cancellationTokenSource).ConfigureAwait(false); + this.InstallDependencies = false; + + // Step 7: Execute profile actions inside container via docker exec + this.LogDockerInfo(logger, "Executing profile actions inside container..."); + exitCode = await this.ExecuteActionsInContainerAsync(dependencies, logger, cancellationToken).ConfigureAwait(false); + } + finally + { + // Step 8: Cleanup container + if (!string.IsNullOrWhiteSpace(this.containerId)) + { + await this.CleanupContainerAsync(logger, cancellationToken).ConfigureAwait(false); + } + } + + return exitCode; + } + + /// + /// Validates that none of the profile actions use DockerExecution, which conflicts with the docker subcommand. + /// + private async Task ValidateProfileForDockerSubcommandAsync(IServiceCollection dependencies, CancellationToken cancellationToken) + { + IEnumerable profilePaths = await this.EvaluateProfilesAsync(dependencies); + + foreach (string profilePath in profilePaths) + { + if (!File.Exists(profilePath)) + continue; + + string json = await File.ReadAllTextAsync(profilePath, cancellationToken).ConfigureAwait(false); + JsonNode root = JsonNode.Parse(json); + JsonArray actions = root?["Actions"]?.AsArray(); + + if (actions == null) + continue; + + foreach (JsonNode action in actions) + { + string type = action?["Type"]?.GetValue() ?? string.Empty; + if (type.Equals("DockerExecution", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Profile '{Path.GetFileName(profilePath)}' contains a 'DockerExecution' component " + + $"which conflicts with the 'docker' subcommand (double Docker). " + + $"Remove DockerExecution from the profile when using the docker subcommand."); + } + } + } + } + + /// + /// Executes each action component from the profiles inside the container via docker exec. + /// + private async Task ExecuteActionsInContainerAsync( + IServiceCollection dependencies, ILogger logger, CancellationToken cancellationToken) + { + IEnumerable profilePaths = await this.EvaluateProfilesAsync(dependencies); + + foreach (string profilePath in profilePaths) + { + if (!File.Exists(profilePath)) + continue; + + string json = await File.ReadAllTextAsync(profilePath, cancellationToken).ConfigureAwait(false); + JsonNode root = JsonNode.Parse(json); + JsonArray actions = root?["Actions"]?.AsArray(); + + if (actions == null) + continue; + + foreach (JsonNode action in actions) + { + if (cancellationToken.IsCancellationRequested) + return 1; + + string componentType = action?["Type"]?.GetValue() ?? string.Empty; + if (string.IsNullOrEmpty(componentType)) + continue; + + this.LogDockerInfo(logger, $"Executing component in container: {componentType}"); + string command = $"/app/VirtualClient.Main --component={componentType}"; + + var result = await this.dockerClient.ExecuteInContainerAsync( + this.containerId, command, cancellationToken).ConfigureAwait(false); + + if (!result.Success) + { + throw new InvalidOperationException( + $"Component '{componentType}' failed in container. " + + $"Exit code: {result.ExitCode}. Error: {result.StandardError}"); + } + + if (!string.IsNullOrWhiteSpace(result.StandardOutput)) + this.LogDockerInfo(logger, $"Container output: {result.StandardOutput}"); + } + } + + return 0; + } + + /// + /// Prepares volume mounts for container. + /// + private Dictionary PrepareVolumeMounts(ILogger logger) + { + var volumeMounts = new Dictionary(); + var currentDirectory = Environment.CurrentDirectory; + + // Standard mount points + volumeMounts[Path.Combine(currentDirectory, "packages")] = "/mnt/packages"; + volumeMounts[Path.Combine(currentDirectory, "logs")] = "/mnt/logs"; + volumeMounts[Path.Combine(currentDirectory, "state")] = "/mnt/state"; + + this.LogDockerInfo(logger, + $"Volume mounts configured: packages, logs, state directories mounted at /mnt/"); + + return volumeMounts; + } + + /// + /// Cleans up Docker container after execution. + /// + private async Task CleanupContainerAsync(ILogger logger, CancellationToken cancellationToken) + { + try + { + if (this.KeepContainerAlive) + { + this.LogDockerInfo(logger, + $"Container is being kept alive for debugging. Container ID: {this.containerId}. " + + $"To manually inspect: docker exec -it {this.containerId} bash. " + + $"To cleanup: docker stop {this.containerId} && docker rm {this.containerId}"); + } + else + { + this.LogDockerInfo(logger, $"Stopping container: {this.containerId}"); + await this.dockerClient.StopContainerAsync(this.containerId, cancellationToken).ConfigureAwait(false); + + this.LogDockerInfo(logger, $"Removing container: {this.containerId}"); + await this.dockerClient.RemoveContainerAsync(this.containerId, cancellationToken).ConfigureAwait(false); + + this.LogDockerInfo(logger, "Container cleaned up successfully."); + } + } + catch (Exception ex) + { + logger?.LogWarning($"Container cleanup encountered an error: {ex.Message}"); + if (!this.KeepContainerAlive) + { + logger?.LogWarning( + $"Manual cleanup may be needed. Container ID: {this.containerId}. " + + $"Run: docker stop {this.containerId} && docker rm {this.containerId}"); + } + } + } + + /// + /// Logs docker-related information with yellow color. + /// + private void LogDockerInfo(ILogger logger, string message) + { + Console.ForegroundColor = ConsoleColor.Yellow; + logger?.LogInformation(message); + Console.ResetColor(); + } + } +} diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index 6eb71375c3..546abd22cc 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -1914,6 +1914,47 @@ private static ProfileTiming ParseProfileTimeout(ArgumentResult parsedResult) return timing; } + /// + /// Command line option defines the Docker image to use for container execution. + /// + /// Sets this option as required. + /// Sets the default value when none is provided. + public static Option CreateDockerImageOption(bool required = true, object defaultValue = null) + { + Option option = new Option( + new string[] { "--image" }, + description: "The Docker image to use for container execution (e.g. ubuntu:noble, redis:7.0-alpine).") + { + Name = "DockerImage", + ArgumentHelpName = "image" + }; + + OptionFactory.SetOptionRequirements(option, required, defaultValue); + + return option; + } + + /// + /// Creates the '--keep-container-alive' option for the docker subcommand. + /// + /// Whether the option is required. + /// The default value for the option. + /// An option that handles keep container alive flag. + public static Option CreateKeepContainerAliveFlag(bool required = false, object defaultValue = null) + { + Option option = new Option( + new string[] { "--keep-container-alive" }, + description: "Keeps the Docker container alive after execution for debugging purposes. Default behavior is to clean up the container.") + { + Name = "KeepContainerAlive", + IsRequired = false + }; + + option.SetDefaultValue(defaultValue ?? false); + + return option; + } + private static Option SetOptionRequirements(Option option, bool required = false, object defaultValue = null, ValidateSymbol validator = null) { option.IsRequired = required; diff --git a/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json new file mode 100644 index 0000000000..5b4c25537f --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/INSTALL-DOCKER.json @@ -0,0 +1,20 @@ +{ + "Description": "Installs Docker Engine on Linux systems for container-based workloads.", + "Metadata": { + "RecommendedMinimumExecutionTime": "00:05:00", + "SupportedPlatforms": "linux-x64,linux-arm64", + "SupportedOperatingSystems": "Ubuntu" + }, + "Parameters": { + "DockerVersion": "" + }, + "Dependencies": [ + { + "Type": "DockerInstallation", + "Parameters": { + "Scenario": "InstallDocker", + "Version": "$.Parameters.DockerVersion" + } + } + ] +} diff --git a/src/VirtualClient/VirtualClient.Main/profiles/PERF-OPENSSL-DOCKER.json b/src/VirtualClient/VirtualClient.Main/profiles/PERF-OPENSSL-DOCKER.json new file mode 100644 index 0000000000..a42a4c94f6 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/profiles/PERF-OPENSSL-DOCKER.json @@ -0,0 +1,44 @@ +{ + "Description": "OpenSSL CPU performance benchmark running inside a Docker container.", + "Metadata": { + "RecommendedMinimumExecutionTime": "00:05:00", + "SupportedPlatforms": "linux-x64,linux-arm64", + "SupportedOperatingSystems": "Ubuntu" + }, + "Parameters": { + "DockerImage": "ubuntu:noble", + "OpenSSLScenario": "SHA256" + }, + "Actions": [ + { + "Type": "DockerExecution", + "Parameters": { + "Image": "$.Parameters.DockerImage" + }, + "Components": [ + { + "Type": "OpenSslExecutor", + "Parameters": { + "Scenario": "$.Parameters.OpenSSLScenario", + "CommandArguments": "speed -elapsed -seconds 10 sha256", + "PackageName": "openssl", + "Tags": "CPU,OpenSSL,Cryptography,Docker" + } + } + ] + } + ], + "Dependencies": [ + { + "Type": "DependencyPackageInstallation", + "Parameters": { + "Scenario": "InstallOpenSSLWorkloadPackage", + "BlobContainer": "packages", + "BlobName": "openssl.3.0.0.zip", + "PackageName": "openssl", + "Extract": true + } + } + ] +} +