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