From bc7e0c5cd66f72c8f9df3364b139cac41982faa4 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Fri, 13 Mar 2026 09:05:03 -0700 Subject: [PATCH 1/6] fixing layout error --- src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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))) }, From 034040cf95214d8469891d84e1987a54839adfc7 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sun, 15 Mar 2026 17:10:40 -0700 Subject: [PATCH 2/6] docker support --- .../ContainerExecutionContext.cs | 129 +++++++++ .../VirtualClientComponent.cs | 29 +- .../ContainerAwareProcessManager.cs | 258 ++++++++++++++++++ .../VirtualClient.Core/DockerRuntime.cs | 247 +++++++++++++++++ .../VirtualClient.Main/CommandLineParser.cs | 10 +- .../Images/Dockerfile.ubuntu | 55 ++++ .../VirtualClient.Main/Images/build-image.ps1 | 23 ++ .../OptionFactory.Container.cs | 35 +++ .../VirtualClient.Main/OptionFactory.cs | 2 +- 9 files changed, 784 insertions(+), 4 deletions(-) create mode 100644 src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs create mode 100644 src/VirtualClient/VirtualClient.Core/ContainerAwareProcessManager.cs create mode 100644 src/VirtualClient/VirtualClient.Core/DockerRuntime.cs create mode 100644 src/VirtualClient/VirtualClient.Main/Images/Dockerfile.ubuntu create mode 100644 src/VirtualClient/VirtualClient.Main/Images/build-image.ps1 create mode 100644 src/VirtualClient/VirtualClient.Main/OptionFactory.Container.cs 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/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(); From 4089d9400a0f13959c1766277db3b18d6f4bebbb Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Wed, 18 Mar 2026 17:24:18 -0700 Subject: [PATCH 3/6] crap --- .../VirtualClientComponent.cs | 22 ++-- .../VirtualClient.Core/DockerRuntime.cs | 61 ++++++++- .../VirtualClient.Main/CommandBase.cs | 12 ++ .../ExecuteProfileCommand.cs | 124 +++++++++++++++++- 4 files changed, 206 insertions(+), 13 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs index 3b45d7594a..d461e507b0 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(); @@ -398,13 +398,19 @@ 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 + 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 container mode is active, report container platform if (ContainerExecutionContext.Current.IsContainerMode) { return ContainerExecutionContext.Current.ContainerPlatform; @@ -649,7 +655,7 @@ protected string PlatformArchitectureName { get { - // If container mode is active, report container platform/architecture + // Use TARGET platform for workload selection if (ContainerExecutionContext.Current.IsContainerMode) { string os = ContainerExecutionContext.Current.ContainerPlatform == PlatformID.Unix ? "linux" : "win"; @@ -662,7 +668,7 @@ protected string PlatformArchitectureName return $"{os}-{arch}"; } - return this.PlatformSpecifics.PlatformArchitectureName; + return this.systemInfo.PlatformArchitectureName; } } @@ -729,7 +735,7 @@ public async Task EvaluateParametersAsync(CancellationToken cancellationToken, b /// public async Task ExecuteAsync(CancellationToken cancellationToken) { - this.StartTime = DateTime.UtcNow; + this.StartTime = DateTime.UtcNow; try { @@ -783,7 +789,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 index 1761f36399..06e7ff9d74 100644 --- a/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs +++ b/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs @@ -75,7 +75,7 @@ public async Task PullImageAsync(string image, CancellationToken cancellationTok this.logger?.LogInformation("Pulling Docker image: {Image}", image); using IProcessProxy process = this.processManager.CreateProcess("docker", $"pull {image}"); - await process.StartAndWaitAsync(cancellationToken); + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); if (process.ExitCode != 0) { @@ -121,6 +121,65 @@ public async Task RunAsync( return result; } + /// + /// Builds an image from a Dockerfile in the specified directory. + /// + /// Full path to the Dockerfile. + /// Name and tag for the image (e.g., vc-ubuntu:22.04). + /// Cancellation token. + public async Task BuildImageAsync(string dockerfilePath, string imageName, CancellationToken cancellationToken) + { + if (!System.IO.File.Exists(dockerfilePath)) + { + throw new DependencyException( + $"Dockerfile not found at '{dockerfilePath}'", + ErrorReason.DependencyNotFound); + } + + string contextDir = System.IO.Path.GetDirectoryName(dockerfilePath); + string dockerfileName = System.IO.Path.GetFileName(dockerfilePath); + + this.logger?.LogInformation("Building Docker image '{Image}' from {Dockerfile}", imageName, dockerfilePath); + + string args = $"build -t {imageName} -f \"{dockerfileName}\" ."; + + using IProcessProxy process = this.processManager.CreateProcess("docker", args, contextDir); + await process.StartAndWaitAsync(cancellationToken); + + 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 the path to a built-in Dockerfile for the given image name. + /// + /// Image name (e.g., vc-ubuntu:22.04). + /// Path to Dockerfile if found, null otherwise. + public static string GetBuiltInDockerfilePath(string imageName) + { + // Extract base name (vc-ubuntu:22.04 -> ubuntu) + string baseName = imageName.Split(':')[0].Replace("vc-", string.Empty); + + // Look in the Images folder relative to the executable + string exeDir = AppDomain.CurrentDomain.BaseDirectory; + string imagesDir = System.IO.Path.Combine(exeDir, "Images"); + + string dockerfilePath = System.IO.Path.Combine(imagesDir, $"Dockerfile.{baseName}"); + + if (System.IO.File.Exists(dockerfilePath)) + { + return dockerfilePath; + } + + return null; + } + private string BuildDockerRunArgs( string image, string command, diff --git a/src/VirtualClient/VirtualClient.Main/CommandBase.cs b/src/VirtualClient/VirtualClient.Main/CommandBase.cs index 081074e23b..740beac7bf 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 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/ExecuteProfileCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs index 7ee7ec731d..7a3edbb7ca 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; @@ -194,6 +195,15 @@ protected override async Task ExecuteAsync(string[] args, IServiceCollectio packageManager = dependencies.GetService(); systemManagement = dependencies.GetService(); + // ===================================================== + // CONTAINER MODE INITIALIZATION - ADD THIS BLOCK + // ===================================================== + if (!string.IsNullOrWhiteSpace(this.Image)) + { + await this.InitializeContainerModeAsync(dependencies, logger, cancellationToken); + } + // ===================================================== + EventContext telemetryContext = EventContext.Persisted(); logger.LogMessage($"Platform.Initialize", telemetryContext); @@ -233,6 +243,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 @@ -993,5 +1007,107 @@ 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) + { + logger.LogMessage($"Container.Initialize", EventContext.Persisted() + .AddContext("image", this.Image) + .AddContext("pullPolicy", this.PullPolicy ?? "IfNotPresent")); + + // Get platform specifics for path resolution + ISystemInfo systemInfo = dependencies.GetService(); + ProcessManager processManager = dependencies.GetService().ProcessManager; + + // Create Docker runtime + var dockerRuntime = new DockerRuntime(processManager, systemInfo.PlatformSpecifics, logger); + + // Check if Docker is available + if (!await dockerRuntime.IsAvailableAsync(cancellationToken)) + { + 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); + } + + string pullPolicy = this.PullPolicy ?? "IfNotPresent"; + bool imageExists = await dockerRuntime.ImageExistsAsync(this.Image, cancellationToken); + + // Try to auto-build if image doesn't exist and it's a vc-* image + if (!imageExists && this.Image.StartsWith("vc-", StringComparison.OrdinalIgnoreCase)) + { + string dockerfilePath = DockerRuntime.GetBuiltInDockerfilePath(this.Image); + + if (dockerfilePath != null) + { + logger.LogMessage($"Container.BuildImage", EventContext.Persisted() + .AddContext("image", this.Image) + .AddContext("dockerfile", dockerfilePath)); + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[Container] Building image '{this.Image}' from {dockerfilePath}..."); + Console.ResetColor(); + + await dockerRuntime.BuildImageAsync(dockerfilePath, this.Image, cancellationToken); + imageExists = true; + } + } + + bool shouldPull = pullPolicy switch + { + "Always" => true, + "Never" => false, + "IfNotPresent" => !imageExists, + _ => !imageExists + }; + + if (shouldPull) + { + logger.LogMessage($"Container.PullImage", EventContext.Persisted() + .AddContext("image", this.Image)); + + await dockerRuntime.PullImageAsync(this.Image, cancellationToken); + } + else if (pullPolicy == "Never" && !imageExists) + { + throw new DependencyException( + $"Container image '{this.Image}' not found locally and pull policy is 'Never'.", + ErrorReason.DependencyNotFound); + } + + // Set up the container execution context + ContainerExecutionContext.Current = new ContainerExecutionContext + { + IsContainerMode = true, + Image = this.Image, + ContainerPlatform = PlatformID.Unix, // Linux containers + ContainerArchitecture = systemInfo.CpuArchitecture, // Match host architecture + Configuration = new ContainerConfiguration + { + Image = this.Image, + PullPolicy = pullPolicy, + Mounts = new ContainerMountConfig + { + Packages = true, + Logs = true, + State = true, + Temp = true + } + } + }; + + logger.LogMessage($"Container.ModeEnabled", EventContext.Persisted() + .AddContext("image", this.Image) + .AddContext("effectivePlatform", ContainerExecutionContext.Current.EffectivePlatform) + .AddContext("effectiveArchitecture", ContainerExecutionContext.Current.EffectiveArchitecture)); + + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine($"[Container Mode] Image: {this.Image}"); + Console.WriteLine($"[Container Mode] Platform: linux-{ContainerExecutionContext.Current.ContainerArchitecture.ToString().ToLower()}"); + Console.ResetColor(); + } } } From 3cef1d19ba1cf0dc13e1992fbfcc0417abc09541 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Wed, 18 Mar 2026 17:24:25 -0700 Subject: [PATCH 4/6] w/e --- .../VirtualClient.Contracts/VirtualClientComponent.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs b/src/VirtualClient/VirtualClient.Contracts/VirtualClientComponent.cs index d461e507b0..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(); @@ -735,7 +734,7 @@ public async Task EvaluateParametersAsync(CancellationToken cancellationToken, b /// public async Task ExecuteAsync(CancellationToken cancellationToken) { - this.StartTime = DateTime.UtcNow; + this.StartTime = DateTime.UtcNow; try { From 86eaa328d4ea43b8143cd06088f9269a76416ac1 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Sun, 29 Mar 2026 18:30:48 -0700 Subject: [PATCH 5/6] foo --- .../DependencyContainerStore.cs | 38 ++++++++ .../DependencyStore.cs | 5 + .../VirtualClient.Core/DockerRuntime.cs | 29 ++++-- .../DockerRuntimeExtensions.cs | 60 ++++++++++++ .../VirtualClient.Core/DockerUtility.cs | 95 +++++++++++++++++++ .../VirtualClient.Main/CommandBase.cs | 2 +- .../ExecuteProfileCommand.cs | 11 +-- .../OptionFactory.Container.cs | 35 ------- .../VirtualClient.Main/OptionFactory.cs | 79 ++++++++++++++- 9 files changed, 299 insertions(+), 55 deletions(-) create mode 100644 src/VirtualClient/VirtualClient.Contracts/DependencyContainerStore.cs create mode 100644 src/VirtualClient/VirtualClient.Core/DockerRuntimeExtensions.cs create mode 100644 src/VirtualClient/VirtualClient.Core/DockerUtility.cs delete mode 100644 src/VirtualClient/VirtualClient.Main/OptionFactory.Container.cs diff --git a/src/VirtualClient/VirtualClient.Contracts/DependencyContainerStore.cs b/src/VirtualClient/VirtualClient.Contracts/DependencyContainerStore.cs new file mode 100644 index 0000000000..15467fc613 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Contracts/DependencyContainerStore.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using VirtualClient.Contracts; + + /// + /// + public class DependencyContainerStore + { + /// + /// + /// + /// + /// + /// + public DependencyContainerStore(string imageName, string containerName, PlatformSpecifics platformSpecifics) + { + this.ImageName = imageName; + this.ContainerName = containerName; + this.PlatformSpecifics = platformSpecifics; + } + + /// + /// + public string ImageName { get; } + + /// + /// + public string ContainerName { get; } + + /// + /// Gets or sets the platform-specific details for the docker Container. + /// + public PlatformSpecifics PlatformSpecifics { get; set; } + } +} 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.Core/DockerRuntime.cs b/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs index 06e7ff9d74..5d6c8cfa20 100644 --- a/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs +++ b/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs @@ -3,13 +3,15 @@ namespace VirtualClient { + using Microsoft.Extensions.Logging; using System; using System.Collections.Generic; 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; using VirtualClient.Common.Extensions; using VirtualClient.Contracts; @@ -22,6 +24,7 @@ public class DockerRuntime private readonly ProcessManager processManager; private readonly PlatformSpecifics platformSpecifics; private readonly ILogger logger; + private bool dependenciesInitialized; /// /// @@ -29,32 +32,38 @@ public class DockerRuntime /// /// /// - public DockerRuntime(ProcessManager processManager, PlatformSpecifics platformSpecifics, ILogger logger = null) + public DockerRuntime(string imageName, ProcessManager processManager, PlatformSpecifics platformSpecifics, ILogger logger = null) { processManager.ThrowIfNull(nameof(processManager)); platformSpecifics.ThrowIfNull(nameof(platformSpecifics)); this.processManager = processManager; this.platformSpecifics = platformSpecifics; this.logger = logger; + this.dependenciesInitialized = false; } /// - /// Checks if Docker is available and configured for Linux containers. + /// Gets platform-specific configuration settings for Docker environments. + /// + public PlatformSpecifics DockerPlatformSpecifics { get; internal set; } + + /// + /// /// - public async Task IsAvailableAsync(CancellationToken cancellationToken) + /// + /// + public async InitializeDependencies(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 + catch () { - return false; + } + + this.dependenciesInitialized = true; } /// diff --git a/src/VirtualClient/VirtualClient.Core/DockerRuntimeExtensions.cs b/src/VirtualClient/VirtualClient.Core/DockerRuntimeExtensions.cs new file mode 100644 index 0000000000..cd2ae24812 --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core/DockerRuntimeExtensions.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace VirtualClient +{ + using System; + using System.Runtime.InteropServices; + using System.Text.Json.Nodes; + using VirtualClient.Contracts; + + /// + /// Methods for extending the functionality of the + /// file system class, and related classes. (i.e. IFile, IPath, etc.) + /// + internal static class DockerRuntimeExtensions + { + /// + /// Parses JSON output from 'docker image inspect' and returns platform-specific information. + /// + public static PlatformSpecifics GetPlatform(string dockerImageInspectJson) + { + var array = JsonNode.Parse(dockerImageInspectJson)?.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 = ParsePlatform(os); + Architecture architecture = ParseArchitecture(arch, variant); + + return new PlatformSpecifics(platform, architecture); + } + + private static PlatformID ParsePlatform(string os) + { + return os.ToLowerInvariant() switch + { + "linux" => PlatformID.Unix, + "windows" => PlatformID.Win32NT, + _ => throw new NotSupportedException($"The OS/system platform '{os}' is not supported.") + }; + } + + private static Architecture ParseArchitecture(string arch, string variant) + { + return arch.ToLowerInvariant() switch + { + "amd64" or "x86_64" => Architecture.X64, + "arm64" or "aarch64" => Architecture.Arm64, + "arm" when variant.ToLowerInvariant() == "v8" => Architecture.Arm64, + _ => throw new NotSupportedException($"The CPU/processor architecture '{arch}' is not supported.") + }; + } + + } +} diff --git a/src/VirtualClient/VirtualClient.Core/DockerUtility.cs b/src/VirtualClient/VirtualClient.Core/DockerUtility.cs new file mode 100644 index 0000000000..f634e2713b --- /dev/null +++ b/src/VirtualClient/VirtualClient.Core/DockerUtility.cs @@ -0,0 +1,95 @@ +namespace VirtualClient +{ + using System; + using System.IO.Abstractions; + using System.Linq; + using System.Runtime.InteropServices; + using System.Text.Json.Nodes; + using System.Threading; + using System.Threading.Tasks; + using VirtualClient.Common; + using VirtualClient.Contracts; + + /// + /// Provides features for managing requirements for docker images. + /// + public static class DockerUtility + { + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task CreateDockerContainerReference(string imageName, PlatformSpecifics platformSpecifics, ProcessManager processManager, IFileSystem fileSystem, CancellationToken token) + { + // step 1: verify the image exists locally or remotely. + string tempImageName = "vcImage01"; + string containerName = Guid.NewGuid().ToString(); + string imageDirectory = platformSpecifics.Combine(platformSpecifics.CurrentDirectory, "Images"); + string imagePath = fileSystem.Directory.GetFiles(imageDirectory).ToList().Where(x => x.StartsWith(imageName, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); + PlatformSpecifics platformSpecificsNew = null; + + if (string.IsNullOrEmpty(imagePath)) + { + // todo: better error. + throw new Exception("Image not found"); + } + + // step 2: verify docker is installed. + // docker -v + { + var process = processManager.CreateProcess("docker.exe", "-v", "C:\\Program Files\\Docker\\Docker\\resources\\bin"); + await process.StartAndWaitAsync(token).ConfigureAwait(false); + Console.WriteLine(process.StandardOutput); + } + + // step3: build the image or use the existing build from ps1. + // docker build -t {tempName} {imagePath} + { + var process = processManager.CreateProcess("docker.exe", $"build -t {tempImageName} {imagePath}", "C:\\Program Files\\Docker\\Docker\\resources\\bin"); + await process.StartAndWaitAsync(token).ConfigureAwait(false); + Console.WriteLine(process.StandardOutput); + } + + // step3.5 get full platform details from the image build. and verify it is valid and save it. + // docker image inspect vc-ubuntu:22.04 --format '{{.Os}}/{{.Architecture}}' + { + var process = processManager.CreateProcess("docker.exe", $"image inspect {tempImageName}"); + await process.StartAndWaitAsync(token).ConfigureAwait(false); + // Console.WriteLine(process.StandardOutput); + platformSpecificsNew = DockerUtility.GetPlatform(process.StandardOutput.ToString()); + fileSystem.File.WriteAllText(platformSpecifics.Combine(platformSpecifics.StateDirectory, "ImageInspectOutput.txt"), process.StandardOutput.ToString()); + } + + // step4: build the container + // docker run --name {containerName} {tempName} + { + var process = processManager.CreateProcess("docker.exe", $"build -name {containerName} {tempImageName}", "C:\\Program Files\\Docker\\Docker\\resources\\bin"); + await process.StartAndWaitAsync(token).ConfigureAwait(false); + Console.WriteLine(process.StandardOutput); + } + + // step5: save container inspect file in state. + { + var process = processManager.CreateProcess("docker.exe", $"build -name {containerName} {tempImageName}", "C:\\Program Files\\Docker\\Docker\\resources\\bin"); + await process.StartAndWaitAsync(token).ConfigureAwait(false); + fileSystem.File.WriteAllText(platformSpecifics.Combine(platformSpecifics.StateDirectory, "ContainerInspectOutput.txt"), process.StandardOutput.ToString()); + // Console.WriteLine(process.StandardOutput); + } + + return new DependencyContainerStore(imageName, containerName, platformSpecificsNew); + } + + /// + /// + /// + /// + /// + /// + + } +} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/CommandBase.cs b/src/VirtualClient/VirtualClient.Main/CommandBase.cs index 740beac7bf..7f9cbc51fd 100644 --- a/src/VirtualClient/VirtualClient.Main/CommandBase.cs +++ b/src/VirtualClient/VirtualClient.Main/CommandBase.cs @@ -155,7 +155,7 @@ public string ArchiveLogsPath /// Docker image for containerized workload execution. /// When provided, workloads run inside the specified container. /// - public string Image { get; set; } + public DependencyContainerStore Image { get; set; } /// /// Image pull policy: Always, IfNotPresent, Never. diff --git a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs index 7a3edbb7ca..bbb07e6b52 100644 --- a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs @@ -191,18 +191,17 @@ protected override async Task ExecuteAsync(string[] args, IServiceCollectio try { - logger = dependencies.GetService(); - packageManager = dependencies.GetService(); - systemManagement = dependencies.GetService(); - // ===================================================== - // CONTAINER MODE INITIALIZATION - ADD THIS BLOCK + // CONTAINER MODE INITIALIZATION // ===================================================== if (!string.IsNullOrWhiteSpace(this.Image)) { await this.InitializeContainerModeAsync(dependencies, logger, cancellationToken); } - // ===================================================== + + logger = dependencies.GetService(); + packageManager = dependencies.GetService(); + systemManagement = dependencies.GetService(); EventContext telemetryContext = EventContext.Persisted(); diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.Container.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.Container.cs deleted file mode 100644 index e07f2a171c..0000000000 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.Container.cs +++ /dev/null @@ -1,35 +0,0 @@ -// 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 7aed8d7397..f7a2d6e463 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; @@ -26,12 +29,13 @@ 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 partial class OptionFactory + public static class OptionFactory { internal const string HtmlQuote = """; 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,57 @@ 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, ICertificateManager certificateManager = null, IFileSystem fileSystem = null) + { + // The logic on the command line will handle downloading docker. + // Creating Image and running on container + + Option option = new Option( + new string[] { "--image" }, + new ParseArgument(result => OptionFactory.ParseDockerImageStore( + result, + DependencyStore.Content, + certificateManager ?? OptionFactory.defaultCertificateManager, + fileSystem ?? OptionFactory.defaultFileSystem))) + + { + Name = "DockerImage", + Description = "Docker image for containerized execution. When provided, workloads run inside the container.", + AllowMultipleArgumentsPerToken = false + }; + + OptionFactory.SetOptionRequirements(option, required); + + 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 = "DockerImagePullPolicy", + 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. /// @@ -1692,6 +1747,24 @@ private static DependencyStore ParseBlobStore(ArgumentResult parsedResult, strin return store; } + private static DependencyContainerStore ParseDockerImageStore(ArgumentResult parsedResult, string imageName, ICertificateManager certificateManager, IFileSystem fileSystem) + { + string endpoint = OptionFactory.GetValue(parsedResult); + DependencyContainerStore store = DockerUtility.CreateDockerContainerReference(imageName, OptionFactory.defaultPlatformSpecifics, OptionFactory.defaultProcessManager, OptionFactory.defaultFileSystem, CancellationToken.None).GetAwaiter().GetResult(); + + // If the certificate is not found, the certificate manager will throw and exception. The logic that follows + // here would happen if the user provided invalid information that precedes the search for the actual certificate. + if (store == null) + { + // todo: add better error. The value provided could be in an invalid format, the image may not exist, or there may be an issue with the docker service on the system. . + // We should attempt to detect these different cases and provide better error messages to the user. + throw new SchemaException($"The value provided for docker Image is invalid. More errors to add later"); + } + + return store; + } + + private static EnvironmentLayout ParseEnvironmentLayout(ArgumentResult parsedResult, IFileSystem fileSystem, PlatformSpecifics platformSpecifics) { EnvironmentLayout layout = null; From b0f8ecebc139dc1b539ead66c4812e8d30cab251 Mon Sep 17 00:00:00 2001 From: Nirjan Chapagain Date: Tue, 31 Mar 2026 10:25:08 -0700 Subject: [PATCH 6/6] foo --- .../ContainerExecutionContext.cs | 20 +- .../DependencyContainerStore.cs | 38 -- .../PlatformSpecifics.cs | 20 +- .../ContainerAwareProcessManager.cs | 258 ---------- .../VirtualClient.Core/DockerRuntime.cs | 447 ++++++++++++------ .../DockerRuntimeExtensions.cs | 60 --- .../VirtualClient.Core/DockerUtility.cs | 95 ---- .../VirtualClient.Main/CommandBase.cs | 4 +- .../ExecuteProfileCommand.cs | 165 ++++--- .../Images/Dockerfile.ubuntu | 4 +- .../VirtualClient.Main/OptionFactory.cs | 40 +- .../VirtualClient.Main.csproj | 12 + 12 files changed, 478 insertions(+), 685 deletions(-) delete mode 100644 src/VirtualClient/VirtualClient.Contracts/DependencyContainerStore.cs delete mode 100644 src/VirtualClient/VirtualClient.Core/ContainerAwareProcessManager.cs delete mode 100644 src/VirtualClient/VirtualClient.Core/DockerRuntimeExtensions.cs delete mode 100644 src/VirtualClient/VirtualClient.Core/DockerUtility.cs diff --git a/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs b/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs index e20579690d..8a311d75e3 100644 --- a/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs +++ b/src/VirtualClient/VirtualClient.Contracts/ContainerExecutionContext.cs @@ -6,6 +6,7 @@ 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 @@ -30,19 +31,30 @@ public static ContainerExecutionContext Current public bool IsContainerMode { get; set; } /// - /// The container image being used. + /// Docker image name for containerized workload execution. + /// When provided, workloads run inside the specified container. /// public string Image { get; set; } /// - /// The platform inside the container (typically Linux). + /// Gets or sets the unique identifier of the container associated with this instance. /// - public PlatformID ContainerPlatform { get; set; } = PlatformID.Unix; + 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; } = Architecture.X64; + public Architecture ContainerArchitecture { get; set; } /// /// Gets the effective platform - container platform if in container mode, diff --git a/src/VirtualClient/VirtualClient.Contracts/DependencyContainerStore.cs b/src/VirtualClient/VirtualClient.Contracts/DependencyContainerStore.cs deleted file mode 100644 index 15467fc613..0000000000 --- a/src/VirtualClient/VirtualClient.Contracts/DependencyContainerStore.cs +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace VirtualClient -{ - using VirtualClient.Contracts; - - /// - /// - public class DependencyContainerStore - { - /// - /// - /// - /// - /// - /// - public DependencyContainerStore(string imageName, string containerName, PlatformSpecifics platformSpecifics) - { - this.ImageName = imageName; - this.ContainerName = containerName; - this.PlatformSpecifics = platformSpecifics; - } - - /// - /// - public string ImageName { get; } - - /// - /// - public string ContainerName { get; } - - /// - /// Gets or sets the platform-specific details for the docker Container. - /// - public PlatformSpecifics PlatformSpecifics { get; set; } - } -} 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.Core/ContainerAwareProcessManager.cs b/src/VirtualClient/VirtualClient.Core/ContainerAwareProcessManager.cs deleted file mode 100644 index ae3f11bc2c..0000000000 --- a/src/VirtualClient/VirtualClient.Core/ContainerAwareProcessManager.cs +++ /dev/null @@ -1,258 +0,0 @@ -// 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 index 5d6c8cfa20..eae3897053 100644 --- a/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs +++ b/src/VirtualClient/VirtualClient.Core/DockerRuntime.cs @@ -3,15 +3,18 @@ namespace VirtualClient { - using Microsoft.Extensions.Logging; 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; 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; @@ -21,49 +24,179 @@ namespace VirtualClient /// 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 bool dependenciesInitialized; + private readonly IFileSystem fileSystem; /// - /// + /// Initializes a new instance of the class. /// - /// - /// - /// - public DockerRuntime(string imageName, ProcessManager processManager, PlatformSpecifics platformSpecifics, ILogger logger = null) + /// 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; - this.dependenciesInitialized = false; } /// - /// Gets platform-specific configuration settings for Docker environments. + /// Determines if the provided value is a Dockerfile path rather than an image name. /// - public PlatformSpecifics DockerPlatformSpecifics { get; internal set; } + /// 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. /// - /// - /// - public async InitializeDependencies(CancellationToken cancellationToken) + /// The path to the Dockerfile. + /// A generated image name (e.g., "vc-ubuntu:latest" from "Dockerfile.ubuntu"). + public static string GenerateImageNameFromDockerfile(string dockerfilePath) { - try + 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; } - catch () + + // 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); + } } - this.dependenciesInitialized = true; + 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; + } } /// @@ -72,12 +205,12 @@ public async InitializeDependencies(CancellationToken cancellationToken) public async Task ImageExistsAsync(string image, CancellationToken cancellationToken) { using IProcessProxy process = this.processManager.CreateProcess("docker", $"image inspect {image}"); - await process.StartAndWaitAsync(cancellationToken); + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); return process.ExitCode == 0; } /// - /// Pulls an image. + /// Pulls an image from a registry. /// public async Task PullImageAsync(string image, CancellationToken cancellationToken) { @@ -95,170 +228,199 @@ public async Task PullImageAsync(string image, CancellationToken cancellationTok } /// - /// Executes a command inside a container. + /// Builds an image from a Dockerfile. /// - public async Task RunAsync( - string image, - string command, - ContainerConfiguration config, - PlatformSpecifics hostPlatformSpecifics, - CancellationToken cancellationToken) + public async Task BuildImageAsync(string dockerfilePath, string imageName, 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 + if (!File.Exists(dockerfilePath)) { - ContainerName = containerName, - StartTime = DateTime.UtcNow - }; + throw new DependencyException( + $"Dockerfile not found at '{dockerfilePath}'", + ErrorReason.DependencyNotFound); + } - using IProcessProxy process = this.processManager.CreateProcess("docker", args); + string contextDir = Path.GetDirectoryName(dockerfilePath); + string dockerfileName = Path.GetFileName(dockerfilePath); - await process.StartAndWaitAsync(cancellationToken); + this.logger?.LogInformation("Building Docker image '{Image}' from {Dockerfile}", imageName, dockerfilePath); - result.EndTime = DateTime.UtcNow; - result.ExitCode = process.ExitCode; - result.StandardOutput = process.StandardOutput.ToString(); - result.StandardError = process.StandardError.ToString(); + using IProcessProxy process = this.processManager.CreateProcess( + "docker", + $"build -t {imageName} -f \"{dockerfileName}\" .", + contextDir); - this.logger?.LogDebug("Container {ContainerName} exited with code {ExitCode}", containerName, result.ExitCode); + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); - return result; + 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); } /// - /// Builds an image from a Dockerfile in the specified directory. + /// Gets platform information from a Docker image. /// - /// Full path to the Dockerfile. - /// Name and tag for the image (e.g., vc-ubuntu:22.04). - /// Cancellation token. - public async Task BuildImageAsync(string dockerfilePath, string imageName, CancellationToken cancellationToken) + public async Task<(PlatformID Platform, Architecture Architecture)> GetImagePlatformAsync(string imageName, CancellationToken cancellationToken) { - if (!System.IO.File.Exists(dockerfilePath)) + using IProcessProxy process = this.processManager.CreateProcess("docker", $"image inspect {imageName}"); + await process.StartAndWaitAsync(cancellationToken).ConfigureAwait(false); + + if (process.ExitCode != 0) { throw new DependencyException( - $"Dockerfile not found at '{dockerfilePath}'", + $"Failed to inspect Docker image '{imageName}': {process.StandardError}", ErrorReason.DependencyNotFound); } - string contextDir = System.IO.Path.GetDirectoryName(dockerfilePath); - string dockerfileName = System.IO.Path.GetFileName(dockerfilePath); + return DockerRuntime.ParsePlatformFromInspectJson(process.StandardOutput.ToString()); + } - this.logger?.LogInformation("Building Docker image '{Image}' from {Dockerfile}", imageName, dockerfilePath); + /// + /// 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."); - string args = $"build -t {imageName} -f \"{dockerfileName}\" ."; - - using IProcessProxy process = this.processManager.CreateProcess("docker", args, contextDir); - await process.StartAndWaitAsync(cancellationToken); + var root = array[0] + ?? throw new ArgumentException("Docker inspect output is empty."); - if (process.ExitCode != 0) + 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 { - throw new DependencyException( - $"Failed to build Docker image '{imageName}': {process.StandardError}", - ErrorReason.DependencyInstallationFailed); - } + "linux" => PlatformID.Unix, + "windows" => PlatformID.Win32NT, + _ => throw new NotSupportedException($"Unsupported container OS: '{os}'") + }; - this.logger?.LogInformation("Successfully built Docker image '{Image}'", imageName); + 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. /// - /// Image name (e.g., vc-ubuntu:22.04). - /// Path to Dockerfile if found, null otherwise. public static string GetBuiltInDockerfilePath(string imageName) { - // Extract base name (vc-ubuntu:22.04 -> ubuntu) string baseName = imageName.Split(':')[0].Replace("vc-", string.Empty); - - // Look in the Images folder relative to the executable string exeDir = AppDomain.CurrentDomain.BaseDirectory; - string imagesDir = System.IO.Path.Combine(exeDir, "Images"); - - string dockerfilePath = System.IO.Path.Combine(imagesDir, $"Dockerfile.{baseName}"); - - if (System.IO.File.Exists(dockerfilePath)) - { - return dockerfilePath; - } + string imagesDir = Path.Combine(exeDir, "Images"); + string dockerfilePath = Path.Combine(imagesDir, $"Dockerfile.{baseName}"); - return null; + 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; } - private string BuildDockerRunArgs( + /// + /// Executes a command inside a container. + /// + public async Task RunAsync( string image, string command, ContainerConfiguration config, - string containerName, - PlatformSpecifics hostPlatformSpecifics) + PlatformSpecifics hostPlatformSpecifics, + CancellationToken cancellationToken) { - var args = new List - { - "run", - "--rm", - $"--name {containerName}" - }; + string containerName = $"vc-{Guid.NewGuid():N}"[..32]; + string args = this.BuildDockerRunArgs(image, command, config, containerName, hostPlatformSpecifics); + + this.logger?.LogDebug("Docker command: docker {Args}", args); - // Working directory - string workDir = config?.WorkingDirectory ?? "/vc"; - args.Add($"-w {workDir}"); + var result = new DockerRunResult { ContainerName = containerName, StartTime = DateTime.UtcNow }; - // Standard mounts - ContainerMountConfig mounts = config?.Mounts ?? new ContainerMountConfig(); + 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) { - string hostPath = this.ToDockerPath(hostPlatformSpecifics.PackagesDirectory); - args.Add($"-v \"{hostPath}:/vc/packages\""); + args.Add($"-v \"{this.ToDockerPath(hostPlatformSpecifics.PackagesDirectory)}:/vc/packages\""); } if (mounts.Logs) { - string hostPath = this.ToDockerPath(hostPlatformSpecifics.LogsDirectory); - args.Add($"-v \"{hostPath}:/vc/logs\""); + args.Add($"-v \"{this.ToDockerPath(hostPlatformSpecifics.LogsDirectory)}:/vc/logs\""); } if (mounts.State) { - string hostPath = this.ToDockerPath(hostPlatformSpecifics.StateDirectory); - args.Add($"-v \"{hostPath}:/vc/state\""); + args.Add($"-v \"{this.ToDockerPath(hostPlatformSpecifics.StateDirectory)}:/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}\""); - } + args.Add($"-v \"{this.ToDockerPath(hostPlatformSpecifics.TempDirectory)}:/vc/temp\""); } - // Environment variables - if (config?.EnvironmentVariables?.Any() == true) - { - foreach (KeyValuePair env in config.EnvironmentVariables) - { - args.Add($"-e \"{env.Key}={env.Value}\""); - } - } + config?.AdditionalMounts?.ToList().ForEach(m => args.Add($"-v \"{m}\"")); + config?.EnvironmentVariables?.ToList().ForEach(e => args.Add($"-e \"{e.Key}={e.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); @@ -267,16 +429,11 @@ private string BuildDockerRunArgs( 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 $"/{char.ToLower(path[0])}{path[2..].Replace('\\', '/')}"; } return path; @@ -288,28 +445,44 @@ private string ToDockerPath(string path) /// public class DockerRunResult { - /// Container name. + /// + /// Gets or sets the name of the container. + /// public string ContainerName { get; set; } - /// Exit code from the container. + /// + /// Gets or sets the exit code returned by the process. + /// public int ExitCode { get; set; } - /// Standard output from the container. + /// + /// Gets or sets the standard output from the process. + /// public string StandardOutput { get; set; } - /// Standard error from the container. + /// + /// Gets or sets the standard error output. + /// public string StandardError { get; set; } - /// When the container started. + /// + /// Gets or sets the start time. + /// public DateTime StartTime { get; set; } - /// When the container exited. + /// + /// Gets or sets the end time. + /// public DateTime EndTime { get; set; } - /// Duration of execution. + /// + /// Gets the duration. + /// public TimeSpan Duration => this.EndTime - this.StartTime; - /// True if exit code was 0. + /// + /// 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.Core/DockerRuntimeExtensions.cs b/src/VirtualClient/VirtualClient.Core/DockerRuntimeExtensions.cs deleted file mode 100644 index cd2ae24812..0000000000 --- a/src/VirtualClient/VirtualClient.Core/DockerRuntimeExtensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace VirtualClient -{ - using System; - using System.Runtime.InteropServices; - using System.Text.Json.Nodes; - using VirtualClient.Contracts; - - /// - /// Methods for extending the functionality of the - /// file system class, and related classes. (i.e. IFile, IPath, etc.) - /// - internal static class DockerRuntimeExtensions - { - /// - /// Parses JSON output from 'docker image inspect' and returns platform-specific information. - /// - public static PlatformSpecifics GetPlatform(string dockerImageInspectJson) - { - var array = JsonNode.Parse(dockerImageInspectJson)?.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 = ParsePlatform(os); - Architecture architecture = ParseArchitecture(arch, variant); - - return new PlatformSpecifics(platform, architecture); - } - - private static PlatformID ParsePlatform(string os) - { - return os.ToLowerInvariant() switch - { - "linux" => PlatformID.Unix, - "windows" => PlatformID.Win32NT, - _ => throw new NotSupportedException($"The OS/system platform '{os}' is not supported.") - }; - } - - private static Architecture ParseArchitecture(string arch, string variant) - { - return arch.ToLowerInvariant() switch - { - "amd64" or "x86_64" => Architecture.X64, - "arm64" or "aarch64" => Architecture.Arm64, - "arm" when variant.ToLowerInvariant() == "v8" => Architecture.Arm64, - _ => throw new NotSupportedException($"The CPU/processor architecture '{arch}' is not supported.") - }; - } - - } -} diff --git a/src/VirtualClient/VirtualClient.Core/DockerUtility.cs b/src/VirtualClient/VirtualClient.Core/DockerUtility.cs deleted file mode 100644 index f634e2713b..0000000000 --- a/src/VirtualClient/VirtualClient.Core/DockerUtility.cs +++ /dev/null @@ -1,95 +0,0 @@ -namespace VirtualClient -{ - using System; - using System.IO.Abstractions; - using System.Linq; - using System.Runtime.InteropServices; - using System.Text.Json.Nodes; - using System.Threading; - using System.Threading.Tasks; - using VirtualClient.Common; - using VirtualClient.Contracts; - - /// - /// Provides features for managing requirements for docker images. - /// - public static class DockerUtility - { - /// - /// - /// - /// - /// - /// - /// - /// - /// - public static async Task CreateDockerContainerReference(string imageName, PlatformSpecifics platformSpecifics, ProcessManager processManager, IFileSystem fileSystem, CancellationToken token) - { - // step 1: verify the image exists locally or remotely. - string tempImageName = "vcImage01"; - string containerName = Guid.NewGuid().ToString(); - string imageDirectory = platformSpecifics.Combine(platformSpecifics.CurrentDirectory, "Images"); - string imagePath = fileSystem.Directory.GetFiles(imageDirectory).ToList().Where(x => x.StartsWith(imageName, StringComparison.OrdinalIgnoreCase)).FirstOrDefault(); - PlatformSpecifics platformSpecificsNew = null; - - if (string.IsNullOrEmpty(imagePath)) - { - // todo: better error. - throw new Exception("Image not found"); - } - - // step 2: verify docker is installed. - // docker -v - { - var process = processManager.CreateProcess("docker.exe", "-v", "C:\\Program Files\\Docker\\Docker\\resources\\bin"); - await process.StartAndWaitAsync(token).ConfigureAwait(false); - Console.WriteLine(process.StandardOutput); - } - - // step3: build the image or use the existing build from ps1. - // docker build -t {tempName} {imagePath} - { - var process = processManager.CreateProcess("docker.exe", $"build -t {tempImageName} {imagePath}", "C:\\Program Files\\Docker\\Docker\\resources\\bin"); - await process.StartAndWaitAsync(token).ConfigureAwait(false); - Console.WriteLine(process.StandardOutput); - } - - // step3.5 get full platform details from the image build. and verify it is valid and save it. - // docker image inspect vc-ubuntu:22.04 --format '{{.Os}}/{{.Architecture}}' - { - var process = processManager.CreateProcess("docker.exe", $"image inspect {tempImageName}"); - await process.StartAndWaitAsync(token).ConfigureAwait(false); - // Console.WriteLine(process.StandardOutput); - platformSpecificsNew = DockerUtility.GetPlatform(process.StandardOutput.ToString()); - fileSystem.File.WriteAllText(platformSpecifics.Combine(platformSpecifics.StateDirectory, "ImageInspectOutput.txt"), process.StandardOutput.ToString()); - } - - // step4: build the container - // docker run --name {containerName} {tempName} - { - var process = processManager.CreateProcess("docker.exe", $"build -name {containerName} {tempImageName}", "C:\\Program Files\\Docker\\Docker\\resources\\bin"); - await process.StartAndWaitAsync(token).ConfigureAwait(false); - Console.WriteLine(process.StandardOutput); - } - - // step5: save container inspect file in state. - { - var process = processManager.CreateProcess("docker.exe", $"build -name {containerName} {tempImageName}", "C:\\Program Files\\Docker\\Docker\\resources\\bin"); - await process.StartAndWaitAsync(token).ConfigureAwait(false); - fileSystem.File.WriteAllText(platformSpecifics.Combine(platformSpecifics.StateDirectory, "ContainerInspectOutput.txt"), process.StandardOutput.ToString()); - // Console.WriteLine(process.StandardOutput); - } - - return new DependencyContainerStore(imageName, containerName, platformSpecificsNew); - } - - /// - /// - /// - /// - /// - /// - - } -} \ No newline at end of file diff --git a/src/VirtualClient/VirtualClient.Main/CommandBase.cs b/src/VirtualClient/VirtualClient.Main/CommandBase.cs index 7f9cbc51fd..364d557cc8 100644 --- a/src/VirtualClient/VirtualClient.Main/CommandBase.cs +++ b/src/VirtualClient/VirtualClient.Main/CommandBase.cs @@ -152,10 +152,10 @@ public string ArchiveLogsPath public string ExperimentId { get; set; } /// - /// Docker image for containerized workload execution. + /// Docker image name for containerized workload execution. /// When provided, workloads run inside the specified container. /// - public DependencyContainerStore Image { get; set; } + public string Image { get; set; } /// /// Image pull policy: Always, IfNotPresent, Never. diff --git a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs index bbb07e6b52..149a2b7292 100644 --- a/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs +++ b/src/VirtualClient/VirtualClient.Main/ExecuteProfileCommand.cs @@ -196,7 +196,14 @@ protected override async Task ExecuteAsync(string[] args, IServiceCollectio // ===================================================== if (!string.IsNullOrWhiteSpace(this.Image)) { - await this.InitializeContainerModeAsync(dependencies, logger, cancellationToken); + 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(); @@ -1010,21 +1017,42 @@ private void Validate(IServiceCollection dependencies, ExecutionProfile profile) /// /// Initializes container execution mode when --image is provided. /// - private async Task InitializeContainerModeAsync(IServiceCollection dependencies, ILogger logger, CancellationToken cancellationToken) + private async Task InitializeContainerModeAsync(IServiceCollection dependencies, ILogger logger, CancellationToken cancellationToken) { - logger.LogMessage($"Container.Initialize", EventContext.Persisted() - .AddContext("image", this.Image) - .AddContext("pullPolicy", this.PullPolicy ?? "IfNotPresent")); + ISystemManagement systemManagement = dependencies.GetService(); + ProcessManager processManager = systemManagement.ProcessManager; + PlatformSpecifics platformSpecifics = systemManagement.PlatformSpecifics; + IFileSystem fileSystem = dependencies.GetService(); - // Get platform specifics for path resolution - ISystemInfo systemInfo = dependencies.GetService(); - ProcessManager processManager = dependencies.GetService().ProcessManager; + /**************************************************************/ + 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, systemInfo.PlatformSpecifics, logger); + var dockerRuntime = new DockerRuntime(processManager, platformSpecifics, fileSystem, logger); // Check if Docker is available - if (!await dockerRuntime.IsAvailableAsync(cancellationToken)) + if (!await dockerRuntime.IsAvailableAsync(cancellationToken).ConfigureAwait(false)) { throw new DependencyException( "Docker is not available or not configured for Linux containers. " + @@ -1032,61 +1060,79 @@ private async Task InitializeContainerModeAsync(IServiceCollection dependencies, 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(this.Image, cancellationToken); + bool imageExists = await dockerRuntime.ImageExistsAsync(imageName, cancellationToken).ConfigureAwait(false); - // Try to auto-build if image doesn't exist and it's a vc-* image - if (!imageExists && this.Image.StartsWith("vc-", StringComparison.OrdinalIgnoreCase)) + // Only try to pull if it's NOT a Dockerfile path (those are already built by ResolveImageAsync) + if (!DockerRuntime.IsDockerfilePath(this.Image)) { - string dockerfilePath = DockerRuntime.GetBuiltInDockerfilePath(this.Image); - - if (dockerfilePath != null) + // Try to auto-build if image doesn't exist and it's a vc-* image + if (!imageExists && imageName.StartsWith("vc-", StringComparison.OrdinalIgnoreCase)) { - logger.LogMessage($"Container.BuildImage", EventContext.Persisted() - .AddContext("image", this.Image) - .AddContext("dockerfile", dockerfilePath)); + 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 '{this.Image}' from {dockerfilePath}..."); - Console.ResetColor(); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"[Container] Building image '{imageName}' from {dockerfilePath}..."); + Console.ResetColor(); - await dockerRuntime.BuildImageAsync(dockerfilePath, this.Image, cancellationToken); - imageExists = true; + await dockerRuntime.BuildImageAsync(dockerfilePath, imageName, cancellationToken).ConfigureAwait(false); + imageExists = true; + } } - } - bool shouldPull = pullPolicy switch - { - "Always" => true, - "Never" => false, - "IfNotPresent" => !imageExists, - _ => !imageExists - }; + bool shouldPull = pullPolicy switch + { + "Always" => true, + "Never" => false, + "IfNotPresent" => !imageExists, + _ => !imageExists + }; - if (shouldPull) - { - logger.LogMessage($"Container.PullImage", EventContext.Persisted() - .AddContext("image", this.Image)); + if (shouldPull) + { + logger?.LogMessage($"Container.PullImage", EventContext.Persisted() + .AddContext("image", imageName)); - await dockerRuntime.PullImageAsync(this.Image, cancellationToken); - } - else if (pullPolicy == "Never" && !imageExists) - { - throw new DependencyException( - $"Container image '{this.Image}' not found locally and pull policy is 'Never'.", - ErrorReason.DependencyNotFound); + 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.Current = new ContainerExecutionContext + ContainerExecutionContext containerContext = new ContainerExecutionContext { IsContainerMode = true, - Image = this.Image, - ContainerPlatform = PlatformID.Unix, // Linux containers - ContainerArchitecture = systemInfo.CpuArchitecture, // Match host architecture + Image = imageName, // Use resolved image name, not the input + ContainerId = containerName, + ContainerPlatform = containerPlatform, + ContainerArchitecture = containerArchitecture, + ContainerWorkingDirectory = "/agent", Configuration = new ContainerConfiguration { - Image = this.Image, + Image = imageName, PullPolicy = pullPolicy, Mounts = new ContainerMountConfig { @@ -1098,15 +1144,26 @@ private async Task InitializeContainerModeAsync(IServiceCollection dependencies, } }; - logger.LogMessage($"Container.ModeEnabled", EventContext.Persisted() - .AddContext("image", this.Image) - .AddContext("effectivePlatform", ContainerExecutionContext.Current.EffectivePlatform) - .AddContext("effectiveArchitecture", ContainerExecutionContext.Current.EffectiveArchitecture)); + 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: {this.Image}"); - Console.WriteLine($"[Container Mode] Platform: linux-{ContainerExecutionContext.Current.ContainerArchitecture.ToString().ToLower()}"); + 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 index c7a9c9eb3b..c9f6e08336 100644 --- a/src/VirtualClient/VirtualClient.Main/Images/Dockerfile.ubuntu +++ b/src/VirtualClient/VirtualClient.Main/Images/Dockerfile.ubuntu @@ -41,10 +41,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # Create standard VC directories -RUN mkdir -p /vc/packages /vc/logs /vc/state /vc/temp /vc/output +# RUN mkdir -p /vc/packages /vc/logs /vc/state /vc/temp /vc/output # Set working directory -WORKDIR /vc +WORKDIR /agent # Default environment variables ENV VC_CONTAINER_MODE=true diff --git a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs index f7a2d6e463..0ad4544706 100644 --- a/src/VirtualClient/VirtualClient.Main/OptionFactory.cs +++ b/src/VirtualClient/VirtualClient.Main/OptionFactory.cs @@ -1511,27 +1511,17 @@ public static Option CreateVersionOption(bool required = false) /// Container image for workload execution. /// When provided, VC runs workloads inside this container. /// - public static Option CreateImageOption(bool required = false, object defaultValue = null, ICertificateManager certificateManager = null, IFileSystem fileSystem = null) + public static Option CreateImageOption(bool required = false, object defaultValue = null) { - // The logic on the command line will handle downloading docker. - // Creating Image and running on container - - Option option = new Option( - new string[] { "--image" }, - new ParseArgument(result => OptionFactory.ParseDockerImageStore( - result, - DependencyStore.Content, - certificateManager ?? OptionFactory.defaultCertificateManager, - fileSystem ?? OptionFactory.defaultFileSystem))) - + Option option = new Option(new string[] { "--image" }) { - Name = "DockerImage", + Name = "Image", Description = "Docker image for containerized execution. When provided, workloads run inside the container.", + ArgumentHelpName = "Image", AllowMultipleArgumentsPerToken = false }; - OptionFactory.SetOptionRequirements(option, required); - + OptionFactory.SetOptionRequirements(option, required, defaultValue); return option; } @@ -1547,7 +1537,7 @@ public static Option CreatePullPolicyOption(bool required = false) Option option = new Option(new string[] { "--pull-policy" }) { - Name = "DockerImagePullPolicy", + Name = "PullPolicy", Description = "Image pull policy: Always, IfNotPresent, Never. Default: IfNotPresent", AllowMultipleArgumentsPerToken = false }; @@ -1747,24 +1737,6 @@ private static DependencyStore ParseBlobStore(ArgumentResult parsedResult, strin return store; } - private static DependencyContainerStore ParseDockerImageStore(ArgumentResult parsedResult, string imageName, ICertificateManager certificateManager, IFileSystem fileSystem) - { - string endpoint = OptionFactory.GetValue(parsedResult); - DependencyContainerStore store = DockerUtility.CreateDockerContainerReference(imageName, OptionFactory.defaultPlatformSpecifics, OptionFactory.defaultProcessManager, OptionFactory.defaultFileSystem, CancellationToken.None).GetAwaiter().GetResult(); - - // If the certificate is not found, the certificate manager will throw and exception. The logic that follows - // here would happen if the user provided invalid information that precedes the search for the actual certificate. - if (store == null) - { - // todo: add better error. The value provided could be in an invalid format, the image may not exist, or there may be an issue with the docker service on the system. . - // We should attempt to detect these different cases and provide better error messages to the user. - throw new SchemaException($"The value provided for docker Image is invalid. More errors to add later"); - } - - return store; - } - - private static EnvironmentLayout ParseEnvironmentLayout(ArgumentResult parsedResult, IFileSystem fileSystem, PlatformSpecifics platformSpecifics) { EnvironmentLayout layout = null; 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 @@ + + + + + + + + + +