diff --git a/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs b/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs new file mode 100644 index 0000000000..e20579690d --- /dev/null +++ b/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs @@ -0,0 +1,129 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Contracts +{ + using System; + using System.Collections.Generic; + using System.Runtime.InteropServices; + + /// + /// Tracks whether execution is happening inside a container context + /// and provides the effective platform for component execution. + /// + public class ContainerExecutionContext + { + private static ContainerExecutionContext current; + + /// + /// Gets or sets the current container execution context. + /// + public static ContainerExecutionContext Current + { + get => current ??= new ContainerExecutionContext(); + set => current = value; + } + + /// + /// True if running with container mode enabled (--image was passed). + /// + public bool IsContainerMode { get; set; } + + /// + /// The container image being used. + /// + public string Image { get; set; } + + /// + /// The platform inside the container (typically Linux). + /// + public PlatformID ContainerPlatform { get; set; } = PlatformID.Unix; + + /// + /// The CPU architecture inside the container. + /// + public Architecture ContainerArchitecture { get; set; } = Architecture.X64; + + /// + /// Gets the effective platform - container platform if in container mode, + /// otherwise the host platform. + /// + public PlatformID EffectivePlatform => this.IsContainerMode + ? this.ContainerPlatform + : Environment.OSVersion.Platform; + + /// + /// Gets the effective architecture. + /// + public Architecture EffectiveArchitecture => this.IsContainerMode + ? this.ContainerArchitecture + : RuntimeInformation.ProcessArchitecture; + + /// + /// Container configuration from profile. + /// + public ContainerConfiguration Configuration { get; set; } + } + + /// + /// Container configuration from profile's Container section. + /// + public class ContainerConfiguration + { + /// + /// Default image (can be overridden by --image CLI). + /// + public string Image { get; set; } + + /// + /// Standard mount configuration. + /// + public ContainerMountConfig Mounts { get; set; } = new ContainerMountConfig(); + + /// + /// Working directory inside container. + /// + public string WorkingDirectory { get; set; } = "/vc"; + + /// + /// Environment variables to pass to container. + /// + public IDictionary EnvironmentVariables { get; set; } + + /// + /// Additional mount paths beyond the defaults. + /// + public IList AdditionalMounts { get; set; } + + /// + /// Pull policy: Always, IfNotPresent, Never. + /// + public string PullPolicy { get; set; } = "IfNotPresent"; + } + + /// + /// Standard VC directory mounts configuration. + /// + public class ContainerMountConfig + { + /// + /// Mount the packages directory (/vc/packages). + /// + public bool Packages { get; set; } = true; + + /// + /// Mount the logs directory (/vc/logs). + /// + public bool Logs { get; set; } = true; + + /// + /// Mount the state directory (/vc/state). + /// + public bool State { get; set; } = true; + + /// + /// Mount the temp directory (/vc/temp). + /// + public bool Temp { get; set; } = true; + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs index 1e6c25f2d7..3b45d7594a 100644 --- a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs +++ b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs @@ -109,7 +109,7 @@ protected VirtualClientComponent(IServiceCollection dependencies, IDictionary(StringComparer.OrdinalIgnoreCase); this.MetadataContract = new MetadataContract(); this.PlatformSpecifics = this.systemInfo.PlatformSpecifics; - this.Platform = this.systemInfo.Platform; + // this.Platform = this.systemInfo.Platform; this.SupportedRoles = new List(); this.CleanupTasks = new List(); this.Extensions = new Dictionary(); @@ -400,7 +400,19 @@ protected set /// /// The OS/system platform (e.g. Windows, Unix). /// - public PlatformID Platform { get; } + public PlatformID Platform + { + get + { + // If container mode is active, report container platform + if (ContainerExecutionContext.Current.IsContainerMode) + { + return ContainerExecutionContext.Current.ContainerPlatform; + } + + return this.systemInfo.Platform; + } + } /// /// Provides OS/system platform specific information. @@ -637,6 +649,19 @@ protected string PlatformArchitectureName { get { + // If container mode is active, report container platform/architecture + if (ContainerExecutionContext.Current.IsContainerMode) + { + string os = ContainerExecutionContext.Current.ContainerPlatform == PlatformID.Unix ? "linux" : "win"; + string arch = ContainerExecutionContext.Current.ContainerArchitecture switch + { + Architecture.X64 => "x64", + Architecture.Arm64 => "arm64", + _ => "x64" + }; + return $"{os}-{arch}"; + } + return this.PlatformSpecifics.PlatformArchitectureName; } } diff --git a/src/VirtualClient/VirtualClient.Core/ContainerAwareProcessManager.cs b/src/VirtualClient/VirtualClient.Core/ContainerAwareProcessManager.cs new file mode 100644 index 0000000000..ae3f11bc2c --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core/ContainerAwareProcessManager.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Collections.Specialized; + using System.Diagnostics; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + using VirtualClient.Common; + using VirtualClient.Contracts; + + /// + /// Process manager that wraps commands in Docker when container mode is active. + /// + public class ContainerAwareProcessManager : ProcessManager + { + private readonly DockerRuntime dockerRuntime; + private readonly PlatformSpecifics platformSpecifics; + private readonly ProcessManager innerProcessManager; + + /// + /// + /// + /// + /// + /// + /// + public ContainerAwareProcessManager( + DockerRuntime dockerRuntime, + PlatformSpecifics platformSpecifics, + ProcessManager innerProcessManager) + { + this.dockerRuntime = dockerRuntime ?? throw new ArgumentNullException(nameof(dockerRuntime)); + this.platformSpecifics = platformSpecifics ?? throw new ArgumentNullException(nameof(platformSpecifics)); + this.innerProcessManager = innerProcessManager ?? throw new ArgumentNullException(nameof(innerProcessManager)); + } + + /// + public override PlatformID Platform => this.innerProcessManager.Platform; + + /// + /// Creates a process. If in container mode, the process runs inside Docker. + /// + public override IProcessProxy CreateProcess(string command, string arguments = null, string workingDirectory = null) + { + if (ContainerExecutionContext.Current.IsContainerMode) + { + // Wrap in Docker execution + return new ContainerProcessProxy( + this.dockerRuntime, + ContainerExecutionContext.Current.Image, + command, + arguments, + workingDirectory, + ContainerExecutionContext.Current.Configuration, + this.platformSpecifics); + } + + // Normal host execution - delegate to inner manager + return this.innerProcessManager.CreateProcess(command, arguments, workingDirectory); + } + } + + /// + /// Process proxy that executes inside a container. + /// + public class ContainerProcessProxy : IProcessProxy + { + private readonly DockerRuntime runtime; + private readonly string image; + private readonly string command; + private readonly string arguments; + private readonly string workingDirectory; + private readonly ContainerConfiguration config; + private readonly PlatformSpecifics platformSpecifics; + + private DockerRunResult result; + private bool hasStarted; + private DateTime startTime; + private DateTime exitTime; + private bool disposed; + + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public ContainerProcessProxy( + DockerRuntime runtime, + string image, + string command, + string arguments, + string workingDirectory, + ContainerConfiguration config, + PlatformSpecifics platformSpecifics) + { + this.runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + this.image = image ?? throw new ArgumentNullException(nameof(image)); + this.command = command; + this.arguments = arguments; + this.workingDirectory = workingDirectory; + this.config = config; + this.platformSpecifics = platformSpecifics; + + this.StandardOutput = new ConcurrentBuffer(); + this.StandardError = new ConcurrentBuffer(); + } + + /// + public int Id => -1; // Container processes don't have a host PID + + /// + public string Name => $"docker:{this.image}"; + + /// + public StringDictionary EnvironmentVariables => null; + + /// + public int ExitCode => this.result?.ExitCode ?? -1; + + /// + public DateTime ExitTime + { + get => this.exitTime; + set => this.exitTime = value; + } + + /// + public IntPtr? Handle => null; + + /// + public bool HasExited => this.result != null; + + /// + public bool RedirectStandardError { get; set; } = true; + + /// + public bool RedirectStandardInput { get; set; } = false; + + /// + public bool RedirectStandardOutput { get; set; } = true; + + /// + public ConcurrentBuffer StandardOutput { get; } + + /// + public ConcurrentBuffer StandardError { get; } + + /// + public StreamWriter StandardInput => null; + + /// + public ProcessStartInfo StartInfo => new ProcessStartInfo + { + FileName = "docker", + Arguments = $"run {this.image} {this.command} {this.arguments}".Trim() + }; + + /// + public DateTime StartTime + { + get => this.startTime; + set => this.startTime = value; + } + + /// + public void Close() + { + // Container is already removed with --rm flag + } + + /// + public void Dispose() + { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public void Kill() + { + // TODO: Implement docker stop/kill if needed + } + + /// + public void Kill(bool entireProcessTree) + { + this.Kill(); + } + + /// + public bool Start() + { + this.hasStarted = true; + this.startTime = DateTime.UtcNow; + return true; + } + + /// + public async Task WaitForExitAsync(CancellationToken cancellationToken, TimeSpan? timeout = null) + { + if (!this.hasStarted) + { + this.Start(); + } + + var fullCommand = string.IsNullOrWhiteSpace(this.arguments) + ? this.command + : $"{this.command} {this.arguments}"; + + this.result = await this.runtime.RunAsync( + this.image, + fullCommand, + this.config, + this.platformSpecifics, + cancellationToken); + + this.exitTime = DateTime.UtcNow; + this.StandardOutput.Append(this.result.StandardOutput ?? string.Empty); + this.StandardError.Append(this.result.StandardError ?? string.Empty); + } + + /// + public IProcessProxy WriteInput(string input) + { + // Container stdin not supported in this implementation + return this; + } + + /// + /// Disposes of resources used by the proxy. + /// + /// True to dispose of unmanaged resources. + protected virtual void Dispose(bool disposing) + { + if (!this.disposed) + { + if (disposing) + { + // no underlying process defined yet. + ////this.UnderlyingProcess.Close(); + ////this.UnderlyingProcess.Dispose(); + } + + this.disposed = true; + } + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs b/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs new file mode 100644 index 0000000000..1761f36399 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs @@ -0,0 +1,247 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + using Microsoft.Extensions.Logging; + using VirtualClient.Common; + using VirtualClient.Common.Extensions; + using VirtualClient.Contracts; + + /// + /// Docker runtime for executing commands inside containers. + /// + public class DockerRuntime + { + private readonly ProcessManager processManager; + private readonly PlatformSpecifics platformSpecifics; + private readonly ILogger logger; + + /// + /// + /// + /// + /// + /// + public DockerRuntime(ProcessManager processManager, PlatformSpecifics platformSpecifics, ILogger logger = null) + { + processManager.ThrowIfNull(nameof(processManager)); + platformSpecifics.ThrowIfNull(nameof(platformSpecifics)); + this.processManager = processManager; + this.platformSpecifics = platformSpecifics; + this.logger = logger; + } + + /// + /// Checks if Docker is available and configured for Linux containers. + /// + public async Task IsAvailableAsync(CancellationToken cancellationToken) + { + try + { + using IProcessProxy process = this.processManager.CreateProcess("docker", "info --format {{.OSType}}"); + await process.StartAndWaitAsync(cancellationToken); + + string osType = process.StandardOutput.ToString().Trim().ToLowerInvariant(); + return process.ExitCode == 0 && osType.Contains("linux"); + } + catch + { + return false; + } + } + + /// + /// Checks if an image exists locally. + /// + public async Task ImageExistsAsync(string image, CancellationToken cancellationToken) + { + using IProcessProxy process = this.processManager.CreateProcess("docker", $"image inspect {image}"); + await process.StartAndWaitAsync(cancellationToken); + return process.ExitCode == 0; + } + + /// + /// Pulls an image. + /// + public async Task PullImageAsync(string image, CancellationToken cancellationToken) + { + this.logger?.LogInformation("Pulling Docker image: {Image}", image); + + using IProcessProxy process = this.processManager.CreateProcess("docker", $"pull {image}"); + await process.StartAndWaitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + throw new DependencyException( + $"Failed to pull Docker image '{image}': {process.StandardError}", + ErrorReason.DependencyInstallationFailed); + } + } + + /// + /// Executes a command inside a container. + /// + public async Task RunAsync( + string image, + string command, + ContainerConfiguration config, + PlatformSpecifics hostPlatformSpecifics, + CancellationToken cancellationToken) + { + string containerName = $"vc-{Guid.NewGuid():N}"[..32]; + string args = this.BuildDockerRunArgs(image, command, config, containerName, hostPlatformSpecifics); + + this.logger?.LogInformation("Running container: {ContainerName}", containerName); + this.logger?.LogDebug("Docker command: docker {Args}", args); + + var result = new DockerRunResult + { + ContainerName = containerName, + StartTime = DateTime.UtcNow + }; + + using IProcessProxy process = this.processManager.CreateProcess("docker", args); + + await process.StartAndWaitAsync(cancellationToken); + + result.EndTime = DateTime.UtcNow; + result.ExitCode = process.ExitCode; + result.StandardOutput = process.StandardOutput.ToString(); + result.StandardError = process.StandardError.ToString(); + + this.logger?.LogDebug("Container {ContainerName} exited with code {ExitCode}", containerName, result.ExitCode); + + return result; + } + + private string BuildDockerRunArgs( + string image, + string command, + ContainerConfiguration config, + string containerName, + PlatformSpecifics hostPlatformSpecifics) + { + var args = new List + { + "run", + "--rm", + $"--name {containerName}" + }; + + // Working directory + string workDir = config?.WorkingDirectory ?? "/vc"; + args.Add($"-w {workDir}"); + + // Standard mounts + ContainerMountConfig mounts = config?.Mounts ?? new ContainerMountConfig(); + + if (mounts.Packages) + { + string hostPath = this.ToDockerPath(hostPlatformSpecifics.PackagesDirectory); + args.Add($"-v \"{hostPath}:/vc/packages\""); + } + + if (mounts.Logs) + { + string hostPath = this.ToDockerPath(hostPlatformSpecifics.LogsDirectory); + args.Add($"-v \"{hostPath}:/vc/logs\""); + } + + if (mounts.State) + { + string hostPath = this.ToDockerPath(hostPlatformSpecifics.StateDirectory); + args.Add($"-v \"{hostPath}:/vc/state\""); + } + + if (mounts.Temp) + { + string hostPath = this.ToDockerPath(hostPlatformSpecifics.TempDirectory); + args.Add($"-v \"{hostPath}:/vc/temp\""); + } + + // Additional mounts + if (config?.AdditionalMounts?.Any() == true) + { + foreach (string mount in config.AdditionalMounts) + { + args.Add($"-v \"{mount}\""); + } + } + + // Environment variables + if (config?.EnvironmentVariables?.Any() == true) + { + foreach (KeyValuePair env in config.EnvironmentVariables) + { + args.Add($"-e \"{env.Key}={env.Value}\""); + } + } + + // Always pass these VC context vars + args.Add("-e \"VC_CONTAINER_MODE=true\""); + + // Image + args.Add(image); + + // Command (if provided) + if (!string.IsNullOrWhiteSpace(command)) + { + args.Add(command); + } + + return string.Join(" ", args); + } + + /// + /// Converts Windows path to Docker-compatible format. + /// C:\path\to\dir -> /c/path/to/dir + /// + private string ToDockerPath(string path) + { + if (this.platformSpecifics.Platform == PlatformID.Win32NT && path.Length >= 2 && path[1] == ':') + { + char drive = char.ToLower(path[0]); + return $"/{drive}{path[2..].Replace('\\', '/')}"; + } + + return path; + } + } + + /// + /// Result of a Docker run operation. + /// + public class DockerRunResult + { + /// Container name. + public string ContainerName { get; set; } + + /// Exit code from the container. + public int ExitCode { get; set; } + + /// Standard output from the container. + public string StandardOutput { get; set; } + + /// Standard error from the container. + public string StandardError { get; set; } + + /// When the container started. + public DateTime StartTime { get; set; } + + /// When the container exited. + public DateTime EndTime { get; set; } + + /// Duration of execution. + public TimeSpan Duration => this.EndTime - this.StartTime; + + /// True if exit code was 0. + public bool Succeeded => this.ExitCode == 0; + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs b/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs index b21f54fae9..94f67c5144 100644 --- a/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs +++ b/src/VirtualClient/VirtualClient.Main/CommandLineParser.cs @@ -156,7 +156,15 @@ public static CommandLineParser Create(IEnumerable args, CancellationTok OptionFactory.CreateTimeoutOption(required: false), // --verbose - OptionFactory.CreateVerboseFlag(required: false, false) + OptionFactory.CreateVerboseFlag(required: false, false), + + // CONTAINER OPTIONS + // ------------------------------------------------------------------- + // --image + OptionFactory.CreateImageOption(required: false), + + // --pull-policy + OptionFactory.CreatePullPolicyOption(required: false) }; rootCommand.TreatUnmatchedTokensAsErrors = true; diff --git a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs index bf5c5f655b..7ee7ec731d 100644 --- a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs @@ -669,7 +669,7 @@ protected void SetHostMetadataTelemetryProperties(IEnumerable profiles, new Dictionary { { "exitWait", this.ExitWait }, - { "layout", this.Layout.ToString() }, + { "layout", this.Layout?.ToString() }, { "logToFile", this.LogToFile }, { "iterations", this.Iterations?.ProfileIterations }, { "profiles", string.Join(",", profiles.Select(p => Path.GetFileName(p))) }, diff --git a/src/VirtualClient/VirtualClient.Main/Images/Dockerfile.ubuntu b/src/VirtualClient/VirtualClient.Main/Images/Dockerfile.ubuntu new file mode 100644 index 0000000000..c7a9c9eb3b --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/Images/Dockerfile.ubuntu @@ -0,0 +1,55 @@ +# VirtualClient Ubuntu Base Image +# This image provides a Linux environment for running workloads from Windows hosts + +FROM ubuntu:22.04 + +LABEL maintainer="VirtualClient Team" +LABEL description="Ubuntu base image for VirtualClient containerized workload execution" + +# Prevent interactive prompts during package installation +ENV DEBIAN_FRONTEND=noninteractive + +# Install common dependencies needed for most workloads +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Build essentials + build-essential \ + gcc \ + g++ \ + make \ + cmake \ + # Common utilities + curl \ + wget \ + git \ + unzip \ + tar \ + gzip \ + # Python (many workloads need it) + python3 \ + python3-pip \ + # Libraries + libc6-dev \ + libssl-dev \ + libffi-dev \ + libnuma-dev \ + # Debugging tools + strace \ + htop \ + procps \ + # Cleanup + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Create standard VC directories +RUN mkdir -p /vc/packages /vc/logs /vc/state /vc/temp /vc/output + +# Set working directory +WORKDIR /vc + +# Default environment variables +ENV VC_CONTAINER_MODE=true +ENV LC_ALL=C.UTF-8 +ENV LANG=C.UTF-8 + +# Default command (can be overridden) +CMD ["/bin/bash"] \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/Images/build-image.ps1 b/src/VirtualClient/VirtualClient.Main/Images/build-image.ps1 new file mode 100644 index 0000000000..08f0999f69 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/Images/build-image.ps1 @@ -0,0 +1,23 @@ +# Build script for VirtualClient Ubuntu image +param( + [string]$ImageName = "vc-ubuntu", + [string]$Tag = "22.04" +) + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$DockerfilePath = Join-Path $ScriptDir "Dockerfile.ubuntu" + +Write-Host "Building VirtualClient Ubuntu image..." -ForegroundColor Cyan +Write-Host "Dockerfile: $DockerfilePath" -ForegroundColor Gray + +docker build -t "${ImageName}:${Tag}" -f $DockerfilePath $ScriptDir + +if ($LASTEXITCODE -eq 0) { + Write-Host "`nImage built successfully!" -ForegroundColor Green + Write-Host "Image: ${ImageName}:${Tag}" -ForegroundColor Green + Write-Host "`nTo run VirtualClient with this image:" -ForegroundColor Yellow + Write-Host " VirtualClient.exe --profile=PERF-CPU-OPENSSL.json --image=${ImageName}:${Tag}" -ForegroundColor White +} else { + Write-Host "`nBuild failed!" -ForegroundColor Red + exit 1 +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.Container.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.Container.cs new file mode 100644 index 0000000000..e07f2a171c --- /dev/null +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.Container.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System.CommandLine; + + public static partial class OptionFactory + { + /// + /// Container image for workload execution. + /// When provided, VC runs workloads inside this container. + /// + public static Option CreateImageOption(bool required = false) + { + return new Option( + aliases: new[] { "--image", "-i" }, + description: "Docker image for containerized execution. When provided, workloads run inside the container.") + { + IsRequired = required + }; + } + + /// + /// Image pull policy. + /// + public static Option CreatePullPolicyOption(bool required = false) + { + return new Option( + aliases: new[] { "--pull-policy" }, + getDefaultValue: () => "IfNotPresent", + description: "Image pull policy: Always, IfNotPresent, Never. Default: IfNotPresent"); + } + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index a69948c09c..7aed8d7397 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -26,7 +26,7 @@ namespace VirtualClient /// Provides a factory for the creation of Command Options used by application command line operations. /// [SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1118:Parameter should not span multiple lines", Justification = "Allow for longer description text.")] - public static class OptionFactory + public static partial class OptionFactory { internal const string HtmlQuote = """; private static readonly ICertificateManager defaultCertificateManager = new CertificateManager();