diff --git a/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs b/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs new file mode 100644 index 0000000000..8a311d75e3 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient.Contracts +{ + using System; + using System.Collections.Generic; + using System.Runtime.InteropServices; + using VirtualClient.Common.Extensions; + + /// + /// 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; } + + /// + /// Docker image name for containerized workload execution. + /// When provided, workloads run inside the specified container. + /// + public string Image { get; set; } + + /// + /// Gets or sets the unique identifier of the container associated with this instance. + /// + public string ContainerId { get; set; } + + /// + /// Working directory inside the container. This is where workloads will be executed and where mounts are rooted. + /// + public string ContainerWorkingDirectory { get; set; } + + /// + /// The platform inside the container. + /// + public PlatformID ContainerPlatform { get; set; } + + /// + /// The CPU architecture inside the container. + /// + public Architecture ContainerArchitecture { get; set; } + + /// + /// 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/DependencyStore.cs b/src/VirtualClient/VirtualClient.Contracts/DependencyStore.cs index b746461e55..32677427e3 100644 --- a/src/VirtualClient/VirtualClient.Contracts/DependencyStore.cs +++ b/src/VirtualClient/VirtualClient.Contracts/DependencyStore.cs @@ -61,6 +61,11 @@ public class DependencyStore /// public const string StoreTypeAzureCDN = "AzureCDN"; + /// + /// Store Type = DockerImage + /// + public const string StoreTypeDockerImage = "DockerImage"; + /// /// Telemetry store name. /// diff --git a/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs b/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs index 9dc058d497..e1adcfd6a9 100644 --- a/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs +++ b/src/VirtualClient/VirtualClient.Contracts/PlatformSpecifics.cs @@ -164,6 +164,22 @@ public PlatformSpecifics(PlatformID platform, Architecture architecture, string /// public bool UseUnixStylePathsOnly { get; } + /// + /// Gets the effective platform for workload execution. + /// Returns the container platform when in container mode, otherwise the host platform. + /// + public PlatformID EffectivePlatform => PlatformSpecifics.IsRunningInContainer() + ? ContainerExecutionContext.Current.ContainerPlatform + : this.Platform; + + /// + /// Gets the effective CPU architecture for workload execution. + /// Returns the container architecture when in container mode, otherwise the host architecture. + /// + public Architecture EffectiveCpuArchitecture => PlatformSpecifics.IsRunningInContainer() + ? ContainerExecutionContext.Current.ContainerArchitecture + : this.CpuArchitecture; + /// /// Whether VC is running in the context of docker container. /// @@ -260,8 +276,10 @@ public static bool IsFullyQualifiedPath(string path) /// public static bool IsRunningInContainer() { + return ContainerExecutionContext.Current.IsContainerMode; + // DOTNET does not properly recognize some containers. Adding /.dockerenv file as back up. - return (Convert.ToBoolean(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")) == true || File.Exists("/.dockerenv")); + // return (Convert.ToBoolean(Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER")) == true || File.Exists("/.dockerenv")); } /// diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs index 1e6c25f2d7..b5a3f32ab4 100644 --- a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs +++ b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs @@ -109,7 +109,6 @@ protected VirtualClientComponent(IServiceCollection dependencies, IDictionary(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(); @@ -398,9 +397,27 @@ protected set public bool ParametersEvaluated { get; protected set; } /// - /// The OS/system platform (e.g. Windows, Unix). + /// The OS/system platform (e.g. Windows, Unix) where VirtualClient is running. + /// This is the HOST platform, not the container platform. /// - public PlatformID Platform { get; } + public PlatformID Platform => this.systemInfo.Platform; + + /// + /// The target platform for workload execution. When in container mode, + /// this returns the container platform. Used for workload selection. + /// + public PlatformID TargetPlatform + { + get + { + if (ContainerExecutionContext.Current.IsContainerMode) + { + return ContainerExecutionContext.Current.ContainerPlatform; + } + + return this.systemInfo.Platform; + } + } /// /// Provides OS/system platform specific information. @@ -637,7 +654,20 @@ protected string PlatformArchitectureName { get { - return this.PlatformSpecifics.PlatformArchitectureName; + // Use TARGET platform for workload selection + 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.systemInfo.PlatformArchitectureName; } } @@ -758,7 +788,7 @@ public async Task ExecuteAsync(CancellationToken cancellationToken) this.MetadataContract.Apply(telemetryContext); - await this.Logger.LogMessageAsync($"{this.TypeName}.Execute", telemetryContext, async () => + await this.Logger.LogMessageAsync($"{this.ComponentType}.Execute", telemetryContext, async () => { bool succeeded = true; diff --git a/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs b/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs new file mode 100644 index 0000000000..eae3897053 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs @@ -0,0 +1,488 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Collections.Generic; + using System.ComponentModel; + using System.IO; + using System.IO.Abstractions; + using System.Linq; + using System.Runtime.InteropServices; + using System.Text.Json.Nodes; + using System.Text.RegularExpressions; + 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 static readonly Regex DockerfilePattern = new Regex( + @"^Dockerfile(\.[a-zA-Z0-9_-]+)?$|\.dockerfile$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly ProcessManager processManager; + private readonly PlatformSpecifics platformSpecifics; + private readonly ILogger logger; + private readonly IFileSystem fileSystem; + + /// + /// Initializes a new instance of the class. + /// + /// The process manager for executing Docker commands. + /// Platform-specific configuration settings. + /// + /// Optional logger for diagnostic output. + public DockerRuntime(ProcessManager processManager, PlatformSpecifics platformSpecifics, IFileSystem fileSystem, ILogger logger = null) + { + processManager.ThrowIfNull(nameof(processManager)); + platformSpecifics.ThrowIfNull(nameof(platformSpecifics)); + fileSystem.ThrowIfNull(nameof(fileSystem)); + this.processManager = processManager; + this.platformSpecifics = platformSpecifics; + this.fileSystem = fileSystem; + this.logger = logger; + } + + /// + /// Determines if the provided value is a Dockerfile path rather than an image name. + /// + /// The image name or Dockerfile path. + /// True if the value appears to be a Dockerfile path. + public static bool IsDockerfilePath(string imageOrPath) + { + if (string.IsNullOrWhiteSpace(imageOrPath)) + { + return false; + } + + // Check if it's a full path that exists + if (File.Exists(imageOrPath)) + { + string fileName = Path.GetFileName(imageOrPath); + return DockerfilePattern.IsMatch(fileName); + } + + // Check if the filename matches Dockerfile pattern + string name = Path.GetFileName(imageOrPath); + return DockerfilePattern.IsMatch(name); + } + + /// + /// Generates an image name from a Dockerfile path. + /// + /// The path to the Dockerfile. + /// A generated image name (e.g., "vc-ubuntu:latest" from "Dockerfile.ubuntu"). + public static string GenerateImageNameFromDockerfile(string dockerfilePath) + { + string fileName = Path.GetFileName(dockerfilePath); + string baseName; + + if (fileName.StartsWith("Dockerfile.", StringComparison.OrdinalIgnoreCase)) + { + // Dockerfile.ubuntu -> ubuntu + baseName = fileName.Substring("Dockerfile.".Length); + } + else if (fileName.EndsWith(".dockerfile", StringComparison.OrdinalIgnoreCase)) + { + // ubuntu.dockerfile -> ubuntu + baseName = fileName.Substring(0, fileName.Length - ".dockerfile".Length); + } + else + { + // Dockerfile -> default + baseName = "custom"; + } + + // Sanitize the name for Docker (lowercase, alphanumeric and hyphens) + baseName = Regex.Replace(baseName.ToLowerInvariant(), @"[^a-z0-9-]", "-"); + + return $"vc-{baseName}:latest"; + } + + /// + /// Resolves a Dockerfile path to search in standard locations. + /// + /// The Dockerfile reference (can be filename or full path). + /// The full path to the Dockerfile, or null if not found. + public string ResolveDockerfilePath(string dockerfileReference) + { + // If it's already a full path and exists, return it + if (Path.IsPathRooted(dockerfileReference) && File.Exists(dockerfileReference)) + { + return dockerfileReference; + } + + // Search in standard locations + string[] searchPaths = new[] + { + // Current directory + Path.Combine(Environment.CurrentDirectory, dockerfileReference), + // Images folder in executable directory + Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Images", dockerfileReference), + // Images folder relative to current directory + Path.Combine(Environment.CurrentDirectory, "Images", dockerfileReference) + }; + + foreach (string path in searchPaths) + { + if (File.Exists(path)) + { + return Path.GetFullPath(path); + } + } + + return null; + } + + /// + /// Resolves the image reference - builds from Dockerfile if needed, returns image name. + /// + /// Either an image name or a Dockerfile path. + /// Cancellation token. + /// The image name to use for container execution. + public async Task ResolveImageAsync(string imageOrDockerfilePath, CancellationToken cancellationToken) + { + if (!IsDockerfilePath(imageOrDockerfilePath)) + { + // It's already an image name + return imageOrDockerfilePath; + } + + // It's a Dockerfile path - resolve and build + string dockerfilePath = this.ResolveDockerfilePath(imageOrDockerfilePath); + + if (string.IsNullOrEmpty(dockerfilePath)) + { + throw new DependencyException( + $"Dockerfile not found: '{imageOrDockerfilePath}'. Searched in current directory and Images folder.", + ErrorReason.DependencyNotFound); + } + + string imageName = GenerateImageNameFromDockerfile(dockerfilePath); + + this.logger?.LogInformation( + "Building image '{ImageName}' from Dockerfile: {DockerfilePath}", + imageName, + dockerfilePath); + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[Container] Building image '{imageName}' from {dockerfilePath}..."); + Console.ResetColor(); + + await this.BuildImageAsync(dockerfilePath, imageName, cancellationToken).ConfigureAwait(false); + + return imageName; + } + + /// + /// Checks if Docker is available and running. + /// + public async Task IsAvailableAsync(CancellationToken cancellationToken) + { + try + { + using IProcessProxy process = this.processManager.CreateProcess("docker", "version"); + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); + return process.ExitCode == 0; + } + 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).ConfigureAwait(false); + return process.ExitCode == 0; + } + + /// + /// Pulls an image from a registry. + /// + 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).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + throw new DependencyException( + $"Failed to pull Docker image '{image}': {process.StandardError}", + ErrorReason.DependencyInstallationFailed); + } + } + + /// + /// Builds an image from a Dockerfile. + /// + public async Task BuildImageAsync(string dockerfilePath, string imageName, CancellationToken cancellationToken) + { + if (!File.Exists(dockerfilePath)) + { + throw new DependencyException( + $"Dockerfile not found at '{dockerfilePath}'", + ErrorReason.DependencyNotFound); + } + + string contextDir = Path.GetDirectoryName(dockerfilePath); + string dockerfileName = Path.GetFileName(dockerfilePath); + + this.logger?.LogInformation("Building Docker image '{Image}' from {Dockerfile}", imageName, dockerfilePath); + + using IProcessProxy process = this.processManager.CreateProcess( + "docker", + $"build -t {imageName} -f \"{dockerfileName}\" .", + contextDir); + + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + throw new DependencyException( + $"Failed to build Docker image '{imageName}': {process.StandardError}", + ErrorReason.DependencyInstallationFailed); + } + + this.logger?.LogInformation("Successfully built Docker image '{Image}'", imageName); + } + + /// + /// Gets platform information from a Docker image. + /// + public async Task<(PlatformID Platform, Architecture Architecture)> GetImagePlatformAsync(string imageName, CancellationToken cancellationToken) + { + using IProcessProxy process = this.processManager.CreateProcess("docker", $"image inspect {imageName}"); + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) + { + throw new DependencyException( + $"Failed to inspect Docker image '{imageName}': {process.StandardError}", + ErrorReason.DependencyNotFound); + } + + return DockerRuntime.ParsePlatformFromInspectJson(process.StandardOutput.ToString()); + } + + /// + /// Parses platform info from 'docker image inspect' JSON output. + /// + public static (PlatformID Platform, Architecture Architecture) ParsePlatformFromInspectJson(string inspectJson) + { + var array = JsonNode.Parse(inspectJson)?.AsArray() + ?? throw new ArgumentException("Invalid docker inspect JSON output."); + + 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); + } + + /// + /// Gets the path to a built-in Dockerfile for the given image name. + /// + public static string GetBuiltInDockerfilePath(string imageName) + { + string baseName = imageName.Split(':')[0].Replace("vc-", string.Empty); + string exeDir = AppDomain.CurrentDomain.BaseDirectory; + string imagesDir = Path.Combine(exeDir, "Images"); + string dockerfilePath = Path.Combine(imagesDir, $"Dockerfile.{baseName}"); + + return File.Exists(dockerfilePath) ? dockerfilePath : null; + } + + /// + /// Stops a running container by ID or name. + /// + public async Task StopContainer(string containerId, CancellationToken cancellationToken) + { + // Graceful => 'docker stop {containerId}' + // Forceful => 'docker kill {containerId}' + using IProcessProxy process = this.processManager.CreateProcess("docker", $"stop {containerId}"); + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Start a container in detached mode with the specified image and mounting paths. Returns the container ID. + /// mountingPaths format: each entry is "C:\host\path:/container/path" or "C:\host\path:/container/path:ro" + /// + public async Task StartContainerInDetachedMode(string imageName, CancellationToken cancellationToken, string workingDirectory, params string[] mountPaths) + { + string readonlyMounts = $"-v \"{workingDirectory}:/agent:ro\""; + + // example: -v "C:\repos\VirtualClient\out\bin\Release\x64\VirtualClient.Main\net9.0\win-x64\state:/agent/state" + IEnumerable readWriteMounts = mountPaths.Select(p => $"-v \"{p}:/{this.fileSystem.Path.GetFileName(p)}:rw\""); + + string args = string.Join( + " ", + new[] { "run", "-d", "--rm", "--name vc-container", readonlyMounts, string.Join(" ", readWriteMounts), imageName, "sleep infinity" }); + + using IProcessProxy process = this.processManager.CreateProcess("docker", args); + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); + + // 'docker run -d' outputs the full container ID on stdout + string containerId = process.StandardOutput.ToString().Trim(); + return containerId; + } + + /// + /// 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?.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).ConfigureAwait(false); + + result.EndTime = DateTime.UtcNow; + result.ExitCode = process.ExitCode; + result.StandardOutput = process.StandardOutput.ToString(); + result.StandardError = process.StandardError.ToString(); + + return result; + } + + private string BuildDockerRunArgs(string image, string command, ContainerConfiguration config, string containerName, PlatformSpecifics hostPlatformSpecifics) + { + var args = new List { "run", "--rm", $"--name {containerName}" }; + + args.Add($"-w {config?.WorkingDirectory ?? "/vc"}"); + + ContainerMountConfig mounts = config?.Mounts ?? new ContainerMountConfig(); + if (mounts.Packages) + { + args.Add($"-v \"{this.ToDockerPath(hostPlatformSpecifics.PackagesDirectory)}:/vc/packages\""); + } + + if (mounts.Logs) + { + args.Add($"-v \"{this.ToDockerPath(hostPlatformSpecifics.LogsDirectory)}:/vc/logs\""); + } + + if (mounts.State) + { + args.Add($"-v \"{this.ToDockerPath(hostPlatformSpecifics.StateDirectory)}:/vc/state\""); + } + + if (mounts.Temp) + { + args.Add($"-v \"{this.ToDockerPath(hostPlatformSpecifics.TempDirectory)}:/vc/temp\""); + } + + config?.AdditionalMounts?.ToList().ForEach(m => args.Add($"-v \"{m}\"")); + config?.EnvironmentVariables?.ToList().ForEach(e => args.Add($"-e \"{e.Key}={e.Value}\"")); + + args.Add("-e \"VC_CONTAINER_MODE=true\""); + args.Add(image); + if (!string.IsNullOrWhiteSpace(command)) + { + args.Add(command); + } + + return string.Join(" ", args); + } + + private string ToDockerPath(string path) + { + if (this.platformSpecifics.Platform == PlatformID.Win32NT && path.Length >= 2 && path[1] == ':') + { + return $"/{char.ToLower(path[0])}{path[2..].Replace('\\', '/')}"; + } + + return path; + } + } + + /// + /// Result of a Docker run operation. + /// + public class DockerRunResult + { + /// + /// Gets or sets the name of the container. + /// + public string ContainerName { get; set; } + + /// + /// Gets or sets the exit code returned by the process. + /// + public int ExitCode { get; set; } + + /// + /// Gets or sets the standard output from the process. + /// + public string StandardOutput { get; set; } + + /// + /// Gets or sets the standard error output. + /// + public string StandardError { get; set; } + + /// + /// Gets or sets the start time. + /// + public DateTime StartTime { get; set; } + + /// + /// Gets or sets the end time. + /// + public DateTime EndTime { get; set; } + + /// + /// Gets the duration. + /// + public TimeSpan Duration => this.EndTime - this.StartTime; + + /// + /// Gets a value indicating whether the operation succeeded. + /// + public bool Succeeded => this.ExitCode == 0; + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/CommandBase.cs b/src/VirtualClient/VirtualClient.Main/CommandBase.cs index 081074e23b..364d557cc8 100644 --- a/src/VirtualClient/VirtualClient.Main/CommandBase.cs +++ b/src/VirtualClient/VirtualClient.Main/CommandBase.cs @@ -151,6 +151,18 @@ public string ArchiveLogsPath /// public string ExperimentId { get; set; } + /// + /// Docker image name for containerized workload execution. + /// When provided, workloads run inside the specified container. + /// + public string Image { get; set; } + + /// + /// Image pull policy: Always, IfNotPresent, Never. + /// Default is IfNotPresent. + /// + public string PullPolicy { get; set; } + /// /// True if a request to archive existing log files is provided. /// 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..149a2b7292 100644 --- a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs @@ -3,6 +3,10 @@ namespace VirtualClient { + using Microsoft.CodeAnalysis; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Globalization; @@ -13,10 +17,7 @@ namespace VirtualClient using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; - using Microsoft.CodeAnalysis; - using Microsoft.Extensions.DependencyInjection; - using Microsoft.Extensions.Logging; - using Newtonsoft.Json; + using VirtualClient.Common; using VirtualClient.Common.Contracts; using VirtualClient.Common.Extensions; using VirtualClient.Common.Telemetry; @@ -190,6 +191,21 @@ protected override async Task ExecuteAsync(string[] args, IServiceCollectio try { + // ===================================================== + // CONTAINER MODE INITIALIZATION + // ===================================================== + if (!string.IsNullOrWhiteSpace(this.Image)) + { + ContainerExecutionContext containerExecutionContext = await this.InitializeContainerModeAsync(dependencies, logger, cancellationToken); + + PlatformSpecifics containerPlatform = new PlatformSpecifics(containerExecutionContext.ContainerPlatform, containerExecutionContext.ContainerArchitecture, containerExecutionContext.ContainerWorkingDirectory); + + // todo: throw if false. + bool isRunningInContainer = PlatformSpecifics.IsRunningInContainer(); + + dependencies = this.InitializeDependencies(args, containerPlatform); + } + logger = dependencies.GetService(); packageManager = dependencies.GetService(); systemManagement = dependencies.GetService(); @@ -233,6 +249,10 @@ protected override async Task ExecuteAsync(string[] args, IServiceCollectio } } } + catch(Exception exc) + { + Console.Write($"Error: {exc.Message}.\nStacktrace:{exc.StackTrace}."); + } finally { // In order to include all of the experiment + agent etc... context, we need to @@ -669,7 +689,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))) }, @@ -993,5 +1013,157 @@ private void Validate(IServiceCollection dependencies, ExecutionProfile profile) "iterations (e.g. --iterations) is not supported."); } } + + /// + /// Initializes container execution mode when --image is provided. + /// + private async Task InitializeContainerModeAsync(IServiceCollection dependencies, ILogger logger, CancellationToken cancellationToken) + { + ISystemManagement systemManagement = dependencies.GetService(); + ProcessManager processManager = systemManagement.ProcessManager; + PlatformSpecifics platformSpecifics = systemManagement.PlatformSpecifics; + IFileSystem fileSystem = dependencies.GetService(); + + /**************************************************************/ + string[] paths = { + platformSpecifics.LogsDirectory, + platformSpecifics.ContentUploadsDirectory, + platformSpecifics.PackagesDirectory, + platformSpecifics.StateDirectory, + platformSpecifics.TempDirectory + }; + + // Folder must exist before mounting for r/w access in the container. + foreach (string path in paths) + { + if (!fileSystem.Directory.Exists(path)) + { + fileSystem.Directory.CreateDirectory(path); + } + } + + /**************************************************************/ + + logger?.LogMessage($"Container.Initialize", EventContext.Persisted() + .AddContext("imageInput", this.Image) + .AddContext("pullPolicy", this.PullPolicy ?? "IfNotPresent")); + + // Create Docker runtime + var dockerRuntime = new DockerRuntime(processManager, platformSpecifics, fileSystem, logger); + + // Check if Docker is available + if (!await dockerRuntime.IsAvailableAsync(cancellationToken).ConfigureAwait(false)) + { + throw new DependencyException( + "Docker is not available or not configured for Linux containers. " + + "Please ensure Docker Desktop is installed and set to use Linux containers.", + ErrorReason.DependencyNotFound); + } + + // IMPORTANT: Resolve the image first - this handles Dockerfile paths by building them + string imageName = await dockerRuntime.ResolveImageAsync(this.Image, cancellationToken).ConfigureAwait(false); + + logger?.LogMessage($"Container.ImageResolved", EventContext.Persisted() + .AddContext("imageInput", this.Image) + .AddContext("resolvedImage", imageName)); + + string pullPolicy = this.PullPolicy ?? "IfNotPresent"; + bool imageExists = await dockerRuntime.ImageExistsAsync(imageName, cancellationToken).ConfigureAwait(false); + + // Only try to pull if it's NOT a Dockerfile path (those are already built by ResolveImageAsync) + if (!DockerRuntime.IsDockerfilePath(this.Image)) + { + // Try to auto-build if image doesn't exist and it's a vc-* image + if (!imageExists && imageName.StartsWith("vc-", StringComparison.OrdinalIgnoreCase)) + { + string dockerfilePath = DockerRuntime.GetBuiltInDockerfilePath(imageName); + + if (dockerfilePath != null) + { + logger?.LogMessage($"Container.BuildImage", EventContext.Persisted() + .AddContext("image", imageName) + .AddContext("dockerfile", dockerfilePath)); + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[Container] Building image '{imageName}' from {dockerfilePath}..."); + Console.ResetColor(); + + await dockerRuntime.BuildImageAsync(dockerfilePath, imageName, cancellationToken).ConfigureAwait(false); + imageExists = true; + } + } + + bool shouldPull = pullPolicy switch + { + "Always" => true, + "Never" => false, + "IfNotPresent" => !imageExists, + _ => !imageExists + }; + + if (shouldPull) + { + logger?.LogMessage($"Container.PullImage", EventContext.Persisted() + .AddContext("image", imageName)); + + await dockerRuntime.PullImageAsync(imageName, cancellationToken).ConfigureAwait(false); + } + else if (string.Equals(pullPolicy, "Never", StringComparison.OrdinalIgnoreCase) && !imageExists) + { + throw new DependencyException( + $"Container image '{imageName}' not found locally and pull policy is 'Never'.", + ErrorReason.DependencyNotFound); + } + } + + // Get platform info from the image + var (containerPlatform, containerArchitecture) = await dockerRuntime.GetImagePlatformAsync(imageName, cancellationToken).ConfigureAwait(false); + + string containerName = await dockerRuntime.StartContainerInDetachedMode(imageName, cancellationToken, platformSpecifics.CurrentDirectory, paths).ConfigureAwait(false); + + // Set up the container execution context + ContainerExecutionContext containerContext = new ContainerExecutionContext + { + IsContainerMode = true, + Image = imageName, // Use resolved image name, not the input + ContainerId = containerName, + ContainerPlatform = containerPlatform, + ContainerArchitecture = containerArchitecture, + ContainerWorkingDirectory = "/agent", + Configuration = new ContainerConfiguration + { + Image = imageName, + PullPolicy = pullPolicy, + Mounts = new ContainerMountConfig + { + Packages = true, + Logs = true, + State = true, + Temp = true + } + } + }; + + logger?.LogMessage($"Container.ModeEnabled", EventContext.Persisted() + .AddContext("image", imageName) + .AddContext("effectivePlatform", containerContext.EffectivePlatform) + .AddContext("effectiveArchitecture", containerContext.EffectiveArchitecture)); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"[Container Mode] Image: {imageName}"); + Console.WriteLine($"[Container Mode] Platform: {containerPlatform}-{containerArchitecture.ToString().ToLowerInvariant()}"); + Console.ResetColor(); + + // Create container-specific platform specifics and register in DI + var containerPlatformSpecifics = new PlatformSpecifics( + containerPlatform, + containerArchitecture, + useUnixStylePathsOnly: true); + + // Replace the existing registration + dependencies.AddSingleton(containerPlatformSpecifics); + + return containerContext; + } } } diff --git a/src/VirtualClient/VirtualClient.Main/Images/Dockerfile.ubuntu b/src/VirtualClient/VirtualClient.Main/Images/Dockerfile.ubuntu new file mode 100644 index 0000000000..c9f6e08336 --- /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 /agent + +# 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.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index a69948c09c..0ad4544706 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -3,6 +3,8 @@ namespace VirtualClient { + using Microsoft.CodeAnalysis; + using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; using System.CommandLine; @@ -13,9 +15,10 @@ namespace VirtualClient using System.Linq; using System.Net; using System.Runtime.InteropServices; + using System.Security.Cryptography.X509Certificates; using System.Text.RegularExpressions; - using Microsoft.CodeAnalysis; - using Microsoft.Extensions.Logging; + using System.Threading; + using VirtualClient.Common; using VirtualClient.Common.Contracts; using VirtualClient.Common.Extensions; using VirtualClient.Contracts; @@ -32,6 +35,7 @@ public static class OptionFactory private static readonly ICertificateManager defaultCertificateManager = new CertificateManager(); private static readonly IFileSystem defaultFileSystem = new FileSystem(); private static readonly PlatformSpecifics defaultPlatformSpecifics = new PlatformSpecifics(Environment.OSVersion.Platform, RuntimeInformation.ProcessArchitecture); + private static readonly ProcessManager defaultProcessManager = ProcessManager.Create(Environment.OSVersion.Platform); private static readonly char[] argumentTrimChars = new char[] { '\'', '"', ' ' }; /// @@ -1503,6 +1507,47 @@ public static Option CreateVersionOption(bool required = false) return option; } + /// + /// Container image for workload execution. + /// When provided, VC runs workloads inside this container. + /// + public static Option CreateImageOption(bool required = false, object defaultValue = null) + { + Option option = new Option(new string[] { "--image" }) + { + Name = "Image", + Description = "Docker image for containerized execution. When provided, workloads run inside the container.", + ArgumentHelpName = "Image", + AllowMultipleArgumentsPerToken = false + }; + + OptionFactory.SetOptionRequirements(option, required, defaultValue); + return option; + } + + /// + /// Image Pull Policy controls whether Docker should download (pull) a container image from a registry before running it. + /// + public static Option CreatePullPolicyOption(bool required = false) + { + //// Policy + //// : IfNotPresent (default) - Pull the image from the registry only if it does not exist locally - Avoid unnecessary network calls and speed up execution when the image is already available + //// : Never - Never pull the image from the registry, even if it does not exist locally - Ensure you always use the locally available image + //// : Always - Always pull the image from the registry, even if it exists locally - Ensure you always have the latest version + + Option option = new Option(new string[] { "--pull-policy" }) + { + Name = "PullPolicy", + Description = "Image pull policy: Always, IfNotPresent, Never. Default: IfNotPresent", + AllowMultipleArgumentsPerToken = false + }; + + OptionFactory.SetOptionRequirements(option, required); + + return option; + } + + /// /// Applies backwards compatibility to the set of command line arguments. /// diff --git a/src/VirtualClient/VirtualClient.Main/VirtualClient.Main.csproj b/src/VirtualClient/VirtualClient.Main/VirtualClient.Main.csproj index e4d7627d2c..4b436f8a74 100644 --- a/src/VirtualClient/VirtualClient.Main/VirtualClient.Main.csproj +++ b/src/VirtualClient/VirtualClient.Main/VirtualClient.Main.csproj @@ -67,6 +67,18 @@ + + + + + + + + + +