From 18945f6da49c752c98ae664afda61cb8a1f5a5c5 Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Mon, 15 Jun 2026 19:58:27 -0700 Subject: [PATCH 1/2] Add Podman container support for Attach to Process Adds Podman as a container runtime option in the Attach to Process dialog, enabling developers to discover and debug processes inside Podman containers. - New PodmanConnection, PodmanContainerInstance, PodmanDiscoveryStrategy, PodmanExecutionManager, PodmanHelper, PodmanPortPicker, PodmanPortSupplier, and PodmanTransportSettings classes - Renamed DockerContainerInstance to ContainerInstance (shared by both runtimes) - Renamed DockerHostPrefixRegex/DockerHostPrefix to HostPrefixRegex/HostPrefix - Registered Podman port supplier and CLSID in pkgdef files - Added ContainerRuntimeType.Podman enum value - Added Podman case in ContainerPickerViewModel and ConnectionManager NOTE: The Podman port supplier also needs to be registered in vsdbg's VsIntegration.pkgdef for full VS integration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.MIDebugEngine.pkgdef | 2 + src/SSHDebugPS/ConnectionManager.cs | 41 ++++ src/SSHDebugPS/ContainerRuntimeType.cs | 3 +- ...tainerInstance.cs => ContainerInstance.cs} | 69 +++++- src/SSHDebugPS/Docker/DockerConnection.cs | 12 +- .../Docker/DockerDiscoveryStrategy.cs | 12 +- src/SSHDebugPS/Docker/DockerHelper.cs | 14 +- src/SSHDebugPS/IContainerDiscoveryStrategy.cs | 6 +- src/SSHDebugPS/Microsoft.SSHDebugPS.pkgdef | 17 ++ src/SSHDebugPS/Podman/PodmanConnection.cs | 200 ++++++++++++++++++ .../Podman/PodmanContainerInstance.cs | 63 ++++++ .../Podman/PodmanDiscoveryStrategy.cs | 37 ++++ .../Podman/PodmanExecutionManager.cs | 19 ++ src/SSHDebugPS/Podman/PodmanHelper.cs | 155 ++++++++++++++ src/SSHDebugPS/Podman/PodmanJsonConverter.cs | 52 +++++ src/SSHDebugPS/Podman/PodmanPort.cs | 20 ++ src/SSHDebugPS/Podman/PodmanPortPicker.cs | 18 ++ src/SSHDebugPS/Podman/PodmanPortSupplier.cs | 58 +++++ .../Podman/PodmanTransportSettings.cs | 46 ++++ src/SSHDebugPS/StringResources.Designer.cs | 36 ++++ src/SSHDebugPS/StringResources.resx | 12 ++ src/SSHDebugPS/UI/ContainerInstance.cs | 70 +----- src/SSHDebugPS/UI/UIResources.Designer.cs | 45 ++++ src/SSHDebugPS/UI/UIResources.resx | 16 ++ .../UI/ViewModels/ContainerPickerViewModel.cs | 5 +- .../UI/ViewModels/ContainerViewModel.cs | 4 +- 26 files changed, 926 insertions(+), 106 deletions(-) rename src/SSHDebugPS/Docker/{DockerContainerInstance.cs => ContainerInstance.cs} (56%) create mode 100644 src/SSHDebugPS/Podman/PodmanConnection.cs create mode 100644 src/SSHDebugPS/Podman/PodmanContainerInstance.cs create mode 100644 src/SSHDebugPS/Podman/PodmanDiscoveryStrategy.cs create mode 100644 src/SSHDebugPS/Podman/PodmanExecutionManager.cs create mode 100644 src/SSHDebugPS/Podman/PodmanHelper.cs create mode 100644 src/SSHDebugPS/Podman/PodmanJsonConverter.cs create mode 100644 src/SSHDebugPS/Podman/PodmanPort.cs create mode 100644 src/SSHDebugPS/Podman/PodmanPortPicker.cs create mode 100644 src/SSHDebugPS/Podman/PodmanPortSupplier.cs create mode 100644 src/SSHDebugPS/Podman/PodmanTransportSettings.cs diff --git a/src/MIDebugEngine/Microsoft.MIDebugEngine.pkgdef b/src/MIDebugEngine/Microsoft.MIDebugEngine.pkgdef index ce626ec28..e6f821ef3 100644 --- a/src/MIDebugEngine/Microsoft.MIDebugEngine.pkgdef +++ b/src/MIDebugEngine/Microsoft.MIDebugEngine.pkgdef @@ -54,6 +54,8 @@ "1"="{A2BBC114-47E4-473F-A49C-69EE89711243}" ; WSL Port supplier "2"="{267B1341-AC92-44DC-94DF-2EE4205DD17E}" +; Podman Port Supplier +"3"="{D4F2F3A5-6B7C-4E8D-9F0A-1B2C3D4E5F6A}" ; Registration to use lldb with the port suppliers [$RootKey$\AD7Metrics\Engine\{5D630903-189D-4837-9785-699B05BEC2A9}] diff --git a/src/SSHDebugPS/ConnectionManager.cs b/src/SSHDebugPS/ConnectionManager.cs index 3bd40dec3..bc8be3627 100644 --- a/src/SSHDebugPS/ConnectionManager.cs +++ b/src/SSHDebugPS/ConnectionManager.cs @@ -14,6 +14,7 @@ using liblinux; using liblinux.Persistence; using Microsoft.SSHDebugPS.Docker; +using Microsoft.SSHDebugPS.Podman; using Microsoft.SSHDebugPS.SSH; using Microsoft.SSHDebugPS.UI; using Microsoft.SSHDebugPS.Utilities; @@ -65,6 +66,46 @@ public static DockerConnection GetDockerConnection(string name, bool supportSSHC } } + public static PodmanConnection GetPodmanConnection(string name, bool supportSSHConnections) + { + if (string.IsNullOrWhiteSpace(name)) + return null; + + PodmanContainerTransportSettings settings; + Connection remoteConnection; + + ThreadHelper.ThrowIfNotOnUIThread(); + if (!PodmanConnection.TryConvertConnectionStringToSettings(name, out settings, out remoteConnection) || settings == null) + { + string connectionString; + + bool success = ShowContainerPickerWindow(IntPtr.Zero, supportSSHConnections, ContainerRuntimeType.Podman, out connectionString); + if (success) + { + success = PodmanConnection.TryConvertConnectionStringToSettings(connectionString, out settings, out remoteConnection); + } + + if (!success || settings == null) + { + VSMessageBoxHelper.PostErrorMessage(StringResources.Error_ContainerConnectionStringInvalidTitle, StringResources.Error_ContainerConnectionStringInvalidMessage); + return null; + } + } + + string displayName = PodmanConnection.CreateConnectionString(settings.ContainerName, remoteConnection?.Name, settings.HostName); + if (PodmanHelper.IsContainerRunning(settings.HostName, settings.ContainerName, remoteConnection)) + { + return new PodmanConnection(settings, remoteConnection, displayName); + } + else + { + VSMessageBoxHelper.PostErrorMessage( + StringResources.Error_ContainerUnavailableTitle, + StringResources.Error_ContainerUnavailableMessage.FormatCurrentCultureWithArgs(settings.ContainerName)); + return null; + } + } + public static SSHConnection GetSSHConnection(string name) { ThreadHelper.ThrowIfNotOnUIThread(); diff --git a/src/SSHDebugPS/ContainerRuntimeType.cs b/src/SSHDebugPS/ContainerRuntimeType.cs index 1a8afa73a..7f8827b12 100644 --- a/src/SSHDebugPS/ContainerRuntimeType.cs +++ b/src/SSHDebugPS/ContainerRuntimeType.cs @@ -9,6 +9,7 @@ namespace Microsoft.SSHDebugPS public enum ContainerRuntimeType { Unknown, - Docker + Docker, + Podman } } diff --git a/src/SSHDebugPS/Docker/DockerContainerInstance.cs b/src/SSHDebugPS/Docker/ContainerInstance.cs similarity index 56% rename from src/SSHDebugPS/Docker/DockerContainerInstance.cs rename to src/SSHDebugPS/Docker/ContainerInstance.cs index e0df057a6..2d1de8d42 100644 --- a/src/SSHDebugPS/Docker/DockerContainerInstance.cs +++ b/src/SSHDebugPS/Docker/ContainerInstance.cs @@ -11,18 +11,18 @@ namespace Microsoft.SSHDebugPS.Docker { - public class DockerContainerInstance : ContainerInstance + public class ContainerInstance : IContainerInstance { /// - /// Create a DockerContainerInstance from the results of docker ps in JSON format + /// Create a ContainerInstance from the results of docker ps in JSON format /// - public static bool TryCreate(string json, out DockerContainerInstance instance) + public static bool TryCreate(string json, out ContainerInstance instance) { instance = null; try { JObject obj = JObject.Parse(json); - instance = obj.ToObject(); + instance = obj.ToObject(); } catch (Exception e) { @@ -37,15 +37,15 @@ public static bool TryCreate(string json, out DockerContainerInstance instance) return instance != null; } - protected DockerContainerInstance() { } + protected ContainerInstance() { } #region JsonProperties [JsonProperty("ID")] - public override string Id { get; set; } + public virtual string Id { get; set; } [JsonProperty("Names")] - public override string Name { get; set; } + public virtual string Name { get; set; } [JsonProperty(nameof(Image))] public virtual string Image { get; protected set; } @@ -67,10 +67,55 @@ protected DockerContainerInstance() { } #endregion - // Docker container names: only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed. It is also case sensitive - protected override bool EqualsInternal(ContainerInstance instance) + #region IEquatable + + public static bool operator ==(ContainerInstance left, ContainerInstance right) + { + if (left is null || right is null) + { + return ReferenceEquals(left, right); + } + + return left.Equals(right); + } + + public static bool operator !=(ContainerInstance left, ContainerInstance right) + { + return !(left == right); + } + + public bool Equals(IContainerInstance instance) { - if (instance is DockerContainerInstance other) + if (!ReferenceEquals(null, instance) && instance is ContainerInstance container) + { + return this.EqualsInternal(container); + } + + return false; + } + + public override bool Equals(object obj) + { + if (obj is IContainerInstance instance) + { + return this.Equals(instance); + } + return false; + } + + public override int GetHashCode() + { + return GetHashCodeInternal(); + } + + #endregion + + #region Helper Methods + + // Container names: only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed. It is also case sensitive + protected virtual bool EqualsInternal(ContainerInstance instance) + { + if (instance is ContainerInstance other) { // the id can be a partial on a container return String.Equals(Id, other.Id, StringComparison.Ordinal) || @@ -81,10 +126,12 @@ protected override bool EqualsInternal(ContainerInstance instance) return false; } - protected override int GetHashCodeInternal() + protected virtual int GetHashCodeInternal() { // Since IDs can be partial, we don't have a good way to get a good hash code. return string.IsNullOrWhiteSpace(Id) ? 0 : Id.Substring(0,1).GetHashCode(); } + + #endregion } } diff --git a/src/SSHDebugPS/Docker/DockerConnection.cs b/src/SSHDebugPS/Docker/DockerConnection.cs index 846245dbf..bf99504fb 100644 --- a/src/SSHDebugPS/Docker/DockerConnection.cs +++ b/src/SSHDebugPS/Docker/DockerConnection.cs @@ -20,8 +20,8 @@ internal class DockerConnection : PipeConnection internal const string SshPrefixRegex = @"^[Ss]{2}[Hh]\s*=\s*"; internal const string SshPrefix = "ssh="; - internal const string DockerHostPrefixRegex = @"^host\s*=\s*"; - internal const string DockerHostPrefix = "host="; + internal const string HostPrefixRegex = @"^host\s*=\s*"; + internal const string HostPrefix = "host="; internal const char Separator = ';'; internal static string CreateConnectionString(string containerName, string remoteConnectionName, string hostName) @@ -34,7 +34,7 @@ internal static string CreateConnectionString(string containerName, string remot if (!string.IsNullOrWhiteSpace(hostName)) { - connectionString += Separator + DockerHostPrefix + hostName; + connectionString += Separator + HostPrefix + hostName; } return connectionString; @@ -56,7 +56,7 @@ internal static bool TryConvertConnectionStringToSettings(string connectionStrin if (connectionStrings.Length <= 3 && connectionStrings.Length > 0) { Regex SshRegex = new Regex(SshPrefixRegex); - Regex dockerHostRegex = new Regex(DockerHostPrefixRegex); + Regex hostRegex = new Regex(HostPrefixRegex); foreach (var item in connectionStrings) { @@ -66,9 +66,9 @@ internal static bool TryConvertConnectionStringToSettings(string connectionStrin Match match = SshRegex.Match(segment); remoteConnection = ConnectionManager.GetSSHConnection(segment.Substring(match.Length)); } - else if (dockerHostRegex.IsMatch(segment)) + else if (hostRegex.IsMatch(segment)) { - Match match = dockerHostRegex.Match(segment); + Match match = hostRegex.Match(segment); hostName = segment.Substring(match.Length); } else if (segment.Contains("=")) diff --git a/src/SSHDebugPS/Docker/DockerDiscoveryStrategy.cs b/src/SSHDebugPS/Docker/DockerDiscoveryStrategy.cs index d47aad0b6..e6847967d 100644 --- a/src/SSHDebugPS/Docker/DockerDiscoveryStrategy.cs +++ b/src/SSHDebugPS/Docker/DockerDiscoveryStrategy.cs @@ -20,17 +20,17 @@ internal sealed class DockerDiscoveryStrategy : IContainerDiscoveryStrategy public string ConnectionToolTip => UIResources.ConnectionToolTip; public string HostnameAutomationName => UIResources.HostnameAutomationName; - public IEnumerable GetLocalContainers(string hostname, out int totalContainers) + public IEnumerable GetLocalContainers(string hostname, out int totalContainers) { return DockerHelper.GetLocalDockerContainers(hostname, out totalContainers); } - public IEnumerable GetRemoteContainers(IConnection connection, string hostname, out int totalContainers) + public IEnumerable GetRemoteContainers(IConnection connection, string hostname, out int totalContainers) { return DockerHelper.GetRemoteDockerContainers(connection, hostname, out totalContainers); } - public void AssignPlatforms(IEnumerable containers, string hostname) + public void AssignPlatforms(IEnumerable containers, string hostname) { if (!containers.Any()) return; @@ -44,7 +44,7 @@ public void AssignPlatforms(IEnumerable containers, str if (lcow && serverOS.IndexOf("windows", StringComparison.OrdinalIgnoreCase) >= 0) { - foreach (DockerContainerInstance container in containers) + foreach (ContainerInstance container in containers) { string containerPlatform = string.Empty; if (DockerHelper.TryGetContainerPlatform(hostname, container.Name, out containerPlatform)) @@ -60,7 +60,7 @@ public void AssignPlatforms(IEnumerable containers, str else { string platform = textInfo.ToTitleCase(serverOS); - foreach (DockerContainerInstance container in containers) + foreach (ContainerInstance container in containers) { container.Platform = platform; } @@ -68,7 +68,7 @@ public void AssignPlatforms(IEnumerable containers, str } else { - foreach (DockerContainerInstance container in containers) + foreach (ContainerInstance container in containers) { container.Platform = unknownOS; } diff --git a/src/SSHDebugPS/Docker/DockerHelper.cs b/src/SSHDebugPS/Docker/DockerHelper.cs index f5a7a06ae..c433cb9d6 100644 --- a/src/SSHDebugPS/Docker/DockerHelper.cs +++ b/src/SSHDebugPS/Docker/DockerHelper.cs @@ -174,11 +174,11 @@ internal static bool TryGetContainerPlatform(string hostname, string containerNa return true; } - internal static IEnumerable GetLocalDockerContainers(string hostname, out int totalContainers) + internal static IEnumerable GetLocalDockerContainers(string hostname, out int totalContainers) { totalContainers = 0; int containerCount = 0; - List containers = new List(); + List containers = new List(); DockerCommandSettings settings = new DockerCommandSettings(hostname, false); settings.SetCommand(dockerPSCommand, dockerPSArgs); @@ -187,7 +187,7 @@ internal static IEnumerable GetLocalDockerContainers(st { if (args.Trim()[0] == '{') { - if (DockerContainerInstance.TryCreate(args, out DockerContainerInstance containerInstance)) + if (ContainerInstance.TryCreate(args, out ContainerInstance containerInstance)) { containers.Add(containerInstance); } @@ -205,7 +205,7 @@ internal static IEnumerable GetLocalDockerContainers(st // Another fallback option would be to: docker inspect --format {{.State.Status}} which should return "running" internal static bool IsContainerRunning(string hostName, string containerName, Connection remoteConnection) { - IEnumerable containers; + IEnumerable containers; if (remoteConnection != null) { containers = GetRemoteDockerContainers(remoteConnection, hostName, out _); @@ -228,7 +228,7 @@ internal static bool IsContainerRunning(string hostName, string containerName, C return false; } - internal static IEnumerable GetRemoteDockerContainers(IConnection connection, string hostname, out int totalContainers) + internal static IEnumerable GetRemoteDockerContainers(IConnection connection, string hostname, out int totalContainers) { totalContainers = 0; SSHConnection sshConnection = connection as SSHConnection; @@ -239,7 +239,7 @@ internal static IEnumerable GetRemoteDockerContainers(I return null; } - List containers = new List(); + List containers = new List(); DockerCommandSettings settings = new DockerCommandSettings(hostname, true); settings.SetCommand(dockerPSCommand, dockerPSArgs); @@ -300,7 +300,7 @@ internal static IEnumerable GetRemoteDockerContainers(I foreach (var item in outputLines) { - if (DockerContainerInstance.TryCreate(item, out DockerContainerInstance containerInstance)) + if (ContainerInstance.TryCreate(item, out ContainerInstance containerInstance)) { containers.Add(containerInstance); } diff --git a/src/SSHDebugPS/IContainerDiscoveryStrategy.cs b/src/SSHDebugPS/IContainerDiscoveryStrategy.cs index c7fd87bb7..c64cff6ec 100644 --- a/src/SSHDebugPS/IContainerDiscoveryStrategy.cs +++ b/src/SSHDebugPS/IContainerDiscoveryStrategy.cs @@ -14,8 +14,8 @@ internal interface IContainerDiscoveryStrategy string ConnectionToolTip { get; } string HostnameAutomationName { get; } - IEnumerable GetLocalContainers(string hostname, out int totalContainers); - IEnumerable GetRemoteContainers(IConnection connection, string hostname, out int totalContainers); - void AssignPlatforms(IEnumerable containers, string hostname); + IEnumerable GetLocalContainers(string hostname, out int totalContainers); + IEnumerable GetRemoteContainers(IConnection connection, string hostname, out int totalContainers); + void AssignPlatforms(IEnumerable containers, string hostname); } } diff --git a/src/SSHDebugPS/Microsoft.SSHDebugPS.pkgdef b/src/SSHDebugPS/Microsoft.SSHDebugPS.pkgdef index 66c5f6034..bd613b575 100644 --- a/src/SSHDebugPS/Microsoft.SSHDebugPS.pkgdef +++ b/src/SSHDebugPS/Microsoft.SSHDebugPS.pkgdef @@ -7,6 +7,11 @@ "PortPickerCLSID"="{91BDF293-E6A0-49C4-B033-6F36CFC4FF98}" "Name"="Docker (Linux Container)" +[$RootKey$\AD7Metrics\PortSupplier\{D4F2F3A5-6B7C-4E8D-9F0A-1B2C3D4E5F6A}] +"CLSID"="{C9E1E1E4-3E5A-4F2B-8D1A-5C6F7A8B9D0E}" +"PortPickerCLSID"="{E2A3B4C5-6D7E-4F8A-9B0C-1D2E3F4A5B6C}" +"Name"="Podman (Linux Container)" + [$RootKey$\AD7Metrics\PortSupplier\{267B1341-AC92-44DC-94DF-2EE4205DD17E}] "CLSID"="{B8587A49-00BD-4DEE-94B9-6EBF49003E04}" "Name"="Windows Subsystem for Linux (WSL)" @@ -47,6 +52,18 @@ "InprocServer32"="$WinDir$\SYSTEM32\MSCOREE.DLL" "CodeBase"="$PackageFolder$\Microsoft.SSHDebugPS.dll" +[$RootKey$\CLSID\{C9E1E1E4-3E5A-4F2B-8D1A-5C6F7A8B9D0E}] +"Assembly"="Microsoft.SSHDebugPS" +"Class"="Microsoft.SSHDebugPS.Podman.PodmanPortSupplier" +"InprocServer32"="$WinDir$\SYSTEM32\MSCOREE.DLL" +"CodeBase"="$PackageFolder$\Microsoft.SSHDebugPS.dll" + +[$RootKey$\CLSID\{E2A3B4C5-6D7E-4F8A-9B0C-1D2E3F4A5B6C}] +"Assembly"="Microsoft.SSHDebugPS" +"Class"="Microsoft.SSHDebugPS.Podman.PodmanLinuxPortPicker" +"InprocServer32"="$WinDir$\SYSTEM32\MSCOREE.DLL" +"CodeBase"="$PackageFolder$\Microsoft.SSHDebugPS.dll" + [$RootKey$\RuntimeConfiguration\dependentAssembly\codeBase\{7E3052B2-FB42-4E38-B22C-1FD281BD4413}] "name"="Microsoft.SSHDebugPS" ; With local development workflow and release workflow, there are two publicKeyTokens but no way to specify both. diff --git a/src/SSHDebugPS/Podman/PodmanConnection.cs b/src/SSHDebugPS/Podman/PodmanConnection.cs new file mode 100644 index 000000000..7bf8b369d --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanConnection.cs @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; +using System.Diagnostics; +using Microsoft.SSHDebugPS.Docker; +using Microsoft.SSHDebugPS.Utilities; +using Microsoft.VisualStudio.Debugger.Interop.UnixPortSupplier; +using Microsoft.VisualStudio.Shell; + +namespace Microsoft.SSHDebugPS.Podman +{ + internal class PodmanConnection : PipeConnection + { + #region Statics + + internal static string CreateConnectionString(string containerName, string remoteConnectionName, string hostName) + { + // Reuses the same format as Docker connection strings + return DockerConnection.CreateConnectionString(containerName, remoteConnectionName, hostName); + } + + internal static bool TryConvertConnectionStringToSettings(string connectionString, out PodmanContainerTransportSettings settings, out Connection remoteConnection) + { + ThreadHelper.ThrowIfNotOnUIThread(); + remoteConnection = null; + settings = null; + + string containerName = string.Empty; + string hostName = string.Empty; + bool invalidString = false; + + string[] connectionStrings = connectionString.Split(DockerConnection.Separator); + + if (connectionStrings.Length <= 3 && connectionStrings.Length > 0) + { + Regex SshRegex = new Regex(DockerConnection.SshPrefixRegex); + Regex hostRegex = new Regex(DockerConnection.HostPrefixRegex); + + foreach (var item in connectionStrings) + { + string segment = item.Trim(' '); + if (SshRegex.IsMatch(segment)) + { + Match match = SshRegex.Match(segment); + remoteConnection = ConnectionManager.GetSSHConnection(segment.Substring(match.Length)); + } + else if (hostRegex.IsMatch(segment)) + { + Match match = hostRegex.Match(segment); + hostName = segment.Substring(match.Length); + } + else if (segment.Contains("=")) + { + invalidString = true; + } + else + { + if (!string.IsNullOrWhiteSpace(containerName)) + { + Debug.Fail("containerName should be empty"); + invalidString = true; + } + else + { + containerName = segment; + } + } + } + } + + if (!string.IsNullOrWhiteSpace(containerName) && !invalidString) + { + settings = new PodmanContainerTransportSettings(hostName, containerName, remoteConnection != null); + return true; + } + + return false; + } + + #endregion + + private readonly string _containerName; + private readonly PodmanExecutionManager _executionManager; + private readonly PodmanContainerTransportSettings _settings; + + public PodmanConnection(PodmanContainerTransportSettings settings, Connection outerConnection, string name) + : base(outerConnection, name) + { + _settings = settings; + _containerName = settings.ContainerName; + _executionManager = new PodmanExecutionManager(settings, outerConnection); + } + + public override int ExecuteCommand(string commandText, int timeout, out string commandOutput, out string errorMessage) + { + return _executionManager.ExecuteCommand(commandText, timeout, out commandOutput, out errorMessage); + } + + /// + public override void BeginExecuteAsyncCommand(string commandText, bool runInShell, IDebugUnixShellCommandCallback callback, out IDebugUnixShellAsyncCommand asyncCommand) + { + if (IsClosed) + { + throw new ObjectDisposedException(nameof(PipeConnection)); + } + + var commandRunner = GetExecCommandRunner(commandText, handleRawOutput: runInShell == false); + asyncCommand = new PipeAsyncCommand(commandRunner, callback); + } + + public override void CopyFile(string sourcePath, string destinationPath) + { + PodmanCopySettings settings; + string tmpFile = null; + + if (!Directory.Exists(sourcePath) && !File.Exists(sourcePath)) + { + throw new ArgumentException(StringResources.Error_CopyFile_SourceNotFound.FormatCurrentCultureWithArgs(sourcePath), nameof(sourcePath)); + } + + if (OuterConnection != null) + { + tmpFile = "/tmp" + "/" + StringResources.CopyFile_TempFilePrefix + Guid.NewGuid(); + OuterConnection.CopyFile(sourcePath, tmpFile); + settings = new PodmanCopySettings(_settings, tmpFile, destinationPath); + } + else + { + settings = new PodmanCopySettings(_settings, sourcePath, destinationPath); + } + + ICommandRunner runner = GetCommandRunner(settings); + + ManualResetEvent resetEvent = new ManualResetEvent(false); + int exitCode = -1; + runner.Closed += (e, args) => + { + exitCode = args; + resetEvent.Set(); + try + { + if (OuterConnection != null && !string.IsNullOrEmpty(tmpFile)) + { + string output; + string errorMessage; + int exit = OuterConnection.ExecuteCommand("rm " + tmpFile, 5000, out output, out errorMessage); + Debug.Assert(exit == 0, FormattableString.Invariant($"Removing file exited with {exit} and message {output}. {errorMessage}")); + } + } + catch (Exception ex) + { + Debug.Fail("Exception thrown while cleaning up temp file. " + ex.Message); + } + }; + + runner.Start(); + + bool complete = resetEvent.WaitOne(Timeout.Infinite); + if (!complete || exitCode != 0) + { + throw new CommandFailedException(StringResources.Error_CopyFileFailed); + } + } + + public override string GetUserHomeDirectory() + { + return ExecuteCommand("eval echo '~'", Timeout.Infinite); + } + + private ICommandRunner GetExecCommandRunner(string commandText, bool handleRawOutput = false) + { + var execSettings = new PodmanExecSettings(this._settings, commandText, handleRawOutput); + return GetCommandRunner(execSettings, handleRawOutput: handleRawOutput); + } + + private ICommandRunner GetCommandRunner(IPipeTransportSettings settings, bool handleRawOutput = false) + { + if (OuterConnection == null) + { + return LocalCommandRunner.CreateInstance(handleRawOutput, settings); + } + else + { + return new RemoteCommandRunner(settings, OuterConnection, handleRawOutput); + } + } + + protected override string ProcFSErrorMessage + { + get + { + return String.Concat(base.ProcFSErrorMessage, Environment.NewLine, StringResources.Error_EnsurePodmanContainerIsLinux); + } + } + } +} diff --git a/src/SSHDebugPS/Podman/PodmanContainerInstance.cs b/src/SSHDebugPS/Podman/PodmanContainerInstance.cs new file mode 100644 index 000000000..46056f8bb --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanContainerInstance.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using Microsoft.DebugEngineHost; +using Microsoft.SSHDebugPS.Docker; +using Microsoft.SSHDebugPS.Utilities; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.SSHDebugPS.Podman +{ + public class PodmanContainerInstance : ContainerInstance + { + public static bool TryCreate(string json, out PodmanContainerInstance instance) + { + instance = null; + try + { + JObject obj = JObject.Parse(json); + instance = obj.ToObject(); + } + catch (Exception e) + { + HostTelemetry.SendEvent(TelemetryHelper.Event_DockerPSParseFailure, new KeyValuePair[] { + new KeyValuePair(TelemetryHelper.Property_ExceptionName, e.GetType().Name) + }); + + string error = e.ToString(); + VsOutputWindowWrapper.WriteLine(StringResources.Error_PodmanPSParseFailed.FormatCurrentCultureWithArgs(json, error), StringResources.Podman_PSName); + Debug.Fail(error); + } + return instance != null; + } + + [JsonProperty("Command")] + [JsonConverter(typeof(PodmanJsonConverter))] + public override string Command { get; protected set; } + + [JsonProperty("Ports")] + [JsonConverter(typeof(PodmanJsonConverter))] + public override string Ports { get; set; } + + [JsonProperty("Names")] + [JsonConverter(typeof(PodmanJsonConverter))] + public override string Name { get; set; } + + protected override bool EqualsInternal(ContainerInstance instance) + { + if (instance is PodmanContainerInstance other) + { + return String.Equals(Id, other.Id, StringComparison.Ordinal) || + Id.StartsWith(other.Id, StringComparison.Ordinal) || + other.Id.StartsWith(Id, StringComparison.Ordinal); + } + + return false; + } + } +} diff --git a/src/SSHDebugPS/Podman/PodmanDiscoveryStrategy.cs b/src/SSHDebugPS/Podman/PodmanDiscoveryStrategy.cs new file mode 100644 index 000000000..6d4571e52 --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanDiscoveryStrategy.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using Microsoft.SSHDebugPS.Docker; +using Microsoft.SSHDebugPS.UI; + +namespace Microsoft.SSHDebugPS.Podman +{ + internal sealed class PodmanDiscoveryStrategy : IContainerDiscoveryStrategy + { + public string ConnectionLabel => UIResources.Podman_ConnectionLabel; + public string HostnameLabel => UIResources.Podman_HostnameLabel; + public string HostnameTip => UIResources.Podman_HostnameTip; + public string ConnectionToolTip => UIResources.Podman_ConnectionToolTip; + public string HostnameAutomationName => UIResources.Podman_HostnameAutomationName; + + public IEnumerable GetLocalContainers(string hostname, out int totalContainers) + { + return PodmanHelper.GetLocalPodmanContainers(hostname, out totalContainers); + } + + public IEnumerable GetRemoteContainers(IConnection connection, string hostname, out int totalContainers) + { + return PodmanHelper.GetRemotePodmanContainers(connection, hostname, out totalContainers); + } + + public void AssignPlatforms(IEnumerable containers, string hostname) + { + // Podman only supports Linux containers + foreach (ContainerInstance container in containers) + { + container.Platform = "Linux"; + } + } + } +} diff --git a/src/SSHDebugPS/Podman/PodmanExecutionManager.cs b/src/SSHDebugPS/Podman/PodmanExecutionManager.cs new file mode 100644 index 000000000..0d90cdbbf --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanExecutionManager.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.SSHDebugPS.Docker; + +namespace Microsoft.SSHDebugPS.Podman +{ + internal class PodmanExecutionManager : DockerExecutionManager + { + public PodmanExecutionManager(PodmanContainerTransportSettings baseSettings, Connection outerConnection) + : base(baseSettings, outerConnection) + { } + + protected override ContainerExecSettings CreateExecSettings(ContainerTargetTransportSettings baseSettings, string command, bool runInShell, bool makeInteractive) + { + return new PodmanExecSettings((PodmanContainerTransportSettings)baseSettings, command, runInShell, makeInteractive); + } + } +} diff --git a/src/SSHDebugPS/Podman/PodmanHelper.cs b/src/SSHDebugPS/Podman/PodmanHelper.cs new file mode 100644 index 000000000..63d8912dd --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanHelper.cs @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading; +using Microsoft.SSHDebugPS.Docker; +using Microsoft.SSHDebugPS.SSH; +using Microsoft.SSHDebugPS.Utilities; + +namespace Microsoft.SSHDebugPS.Podman +{ + public class PodmanHelper + { + private const string podmanPSCommand = "ps"; + private const string podmanPSArgs = "-f status=running --no-trunc --format \"{{json .}}\""; + + + internal static IEnumerable GetLocalPodmanContainers(string hostname, out int totalContainers) + { + totalContainers = 0; + int containerCount = 0; + List containers = new List(); + + PodmanCommandSettings settings = new PodmanCommandSettings(hostname, false); + settings.SetCommand(podmanPSCommand, podmanPSArgs); + + DockerHelper.RunContainerCommand(settings, delegate (string args) + { + if (args.Trim()[0] == '{') + { + if (PodmanContainerInstance.TryCreate(args, out PodmanContainerInstance containerInstance)) + { + containers.Add(containerInstance); + } + containerCount++; + } + }); + + totalContainers = containerCount; + return containers; + } + + /// + /// Checks if the specified container is in the list of containers from the target host. + /// + internal static bool IsContainerRunning(string hostName, string containerName, Connection remoteConnection) + { + IEnumerable containers; + if (remoteConnection != null) + { + containers = GetRemotePodmanContainers(remoteConnection, hostName, out _); + } + else + { + containers = GetLocalPodmanContainers(hostName, out _); + } + + if (containers != null) + { + if (containers.Any(container => string.Equals(container.Name, containerName, StringComparison.Ordinal) + || container.Id.StartsWith(containerName, StringComparison.Ordinal))) + { + return true; + } + } + + return false; + } + + internal static IEnumerable GetRemotePodmanContainers(IConnection connection, string hostname, out int totalContainers) + { + totalContainers = 0; + SSHConnection sshConnection = connection as SSHConnection; + List outputLines = new List(); + StringBuilder errorSB = new StringBuilder(); + if (sshConnection == null) + { + return null; + } + + List containers = new List(); + + PodmanCommandSettings settings = new PodmanCommandSettings(hostname, true); + settings.SetCommand(podmanPSCommand, podmanPSArgs); + + RemoteCommandRunner commandRunner = new RemoteCommandRunner(settings, sshConnection, handleRawOutput: false); + + ManualResetEvent resetEvent = new ManualResetEvent(false); + int exitCode = 0; + commandRunner.ErrorOccured += ((sender, args) => + { + errorSB.Append(args); + }); + + commandRunner.Closed += ((sender, args) => + { + exitCode = args; + resetEvent.Set(); + }); + + commandRunner.OutputReceived += ((sender, line) => + { + if (!string.IsNullOrWhiteSpace(line)) + { + Debug.Assert(line.IndexOf('\n') < 0, "Why does `line` have embedded newline characters?"); + + if (line.Trim()[0] != '{') + { + errorSB.Append(line); + } + + outputLines.Add(line); + } + }); + + commandRunner.Start(); + + bool cancellationRequested = false; + VS.VSOperationWaiter.Wait(UIResources.QueryingForContainersMessage, false, (cancellationToken) => + { + while (!resetEvent.WaitOne(2000) && !cancellationToken.IsCancellationRequested) + { } + cancellationRequested = cancellationToken.IsCancellationRequested; + }); + + if (!cancellationRequested) + { + if (exitCode != 0) + { + string exceptionMessage = UIResources.CommandExecutionErrorWithExitCodeFormat.FormatCurrentCultureWithArgs( + "{0} {1}".FormatInvariantWithArgs(settings.Command, settings.CommandArgs), + exitCode, + errorSB.ToString()); + + throw new CommandFailedException(exceptionMessage); + } + + foreach (var item in outputLines) + { + if (PodmanContainerInstance.TryCreate(item, out PodmanContainerInstance containerInstance)) + { + containers.Add(containerInstance); + } + totalContainers++; + } + } + + return containers; + } + } +} diff --git a/src/SSHDebugPS/Podman/PodmanJsonConverter.cs b/src/SSHDebugPS/Podman/PodmanJsonConverter.cs new file mode 100644 index 000000000..d850508ee --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanJsonConverter.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Microsoft.SSHDebugPS.Podman +{ + // Handles JSON values that may be a string, array of strings, or array of objects (port mappings). + internal class PodmanJsonConverter : JsonConverter + { + public override bool CanConvert(Type objectType) => objectType == typeof(string); + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + var token = JToken.Load(reader); + switch (token.Type) + { + case JTokenType.String: + return token.Value(); + case JTokenType.Array: + return string.Join(", ", token.Select(t => + { + if (t.Type == JTokenType.String) + return t.Value(); + if (t.Type == JTokenType.Object) + { + // Handle Podman port mapping objects: {"host_ip":"0.0.0.0","container_port":80,"host_port":8080,"range":1,"protocol":"tcp"} + var hostIp = t.Value("host_ip") ?? "0.0.0.0"; + var hostPort = t.Value("host_port"); + var containerPort = t.Value("container_port"); + var protocol = t.Value("protocol") ?? "tcp"; + if (hostPort.HasValue && containerPort.HasValue) + return $"{hostIp}:{hostPort}->{containerPort}/{protocol}"; + } + return t.ToString(); + })); + case JTokenType.Null: + return string.Empty; + default: + return token.ToString(); + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } + } +} diff --git a/src/SSHDebugPS/Podman/PodmanPort.cs b/src/SSHDebugPS/Podman/PodmanPort.cs new file mode 100644 index 000000000..413f541a6 --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanPort.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.VisualStudio.Shell; + +namespace Microsoft.SSHDebugPS.Podman +{ + internal class PodmanPort : AD7Port + { + public PodmanPort(AD7PortSupplier portSupplier, string name, bool isInAddPort) + : base(portSupplier, name, isInAddPort) + { } + + protected override Connection GetConnectionInternal() + { + ThreadHelper.ThrowIfNotOnUIThread(); + return ConnectionManager.GetPodmanConnection(Name, supportSSHConnections: true); + } + } +} diff --git a/src/SSHDebugPS/Podman/PodmanPortPicker.cs b/src/SSHDebugPS/Podman/PodmanPortPicker.cs new file mode 100644 index 000000000..cd4fd2868 --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanPortPicker.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Runtime.InteropServices; +using Microsoft.SSHDebugPS.Docker; +using Microsoft.VisualStudio.Shell; + +namespace Microsoft.SSHDebugPS.Podman +{ + [ComVisible(true)] + [Guid("E2A3B4C5-6D7E-4F8A-9B0C-1D2E3F4A5B6C")] + public class PodmanLinuxPortPicker : DockerPortPickerBase + { + internal override bool SupportSSHConnections => true; + internal override ContainerRuntimeType RuntimeType => ContainerRuntimeType.Podman; + } +} diff --git a/src/SSHDebugPS/Podman/PodmanPortSupplier.cs b/src/SSHDebugPS/Podman/PodmanPortSupplier.cs new file mode 100644 index 000000000..d53fa734d --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanPortSupplier.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.Debugger.Interop; + +namespace Microsoft.SSHDebugPS.Podman +{ + [ComVisible(true)] + [Guid("C9E1E1E4-3E5A-4F2B-8D1A-5C6F7A8B9D0E")] + internal class PodmanPortSupplier : AD7PortSupplier + { + private readonly Guid _Id = new Guid("D4F2F3A5-6B7C-4E8D-9F0A-1B2C3D4E5F6A"); + + protected override Guid Id { get { return _Id; } } + protected override string Name { get { return StringResources.Podman_PSName; } } + protected override string Description { get { return StringResources.Podman_PSDescription; } } + + public PodmanPortSupplier() : base() + { } + + public override int AddPort(IDebugPortRequest2 request, out IDebugPort2 port) + { + string name; + HR.Check(request.GetPortName(out name)); + + if (!string.IsNullOrWhiteSpace(name)) + { + AD7Port newPort = new PodmanPort(this, name, isInAddPort: true); + + if (newPort.IsConnected) + { + port = newPort; + return HR.S_OK; + } + } + + port = null; + return HR.E_REMOTE_CONNECT_USER_CANCELED; + } + + public override unsafe int EnumPersistedPorts(BSTR_ARRAY portNames, out IEnumDebugPorts2 portEnum) + { + IDebugPort2[] ports = new IDebugPort2[portNames.dwCount]; + for (int c = 0; c < portNames.dwCount; c++) + { + char* bstrPortName = ((char**)portNames.Members)[c]; + string name = new string(bstrPortName); + + ports[c] = new PodmanPort(this, name, isInAddPort: false); + } + + portEnum = new AD7PortEnum(ports); + return HR.S_OK; + } + } +} diff --git a/src/SSHDebugPS/Podman/PodmanTransportSettings.cs b/src/SSHDebugPS/Podman/PodmanTransportSettings.cs new file mode 100644 index 000000000..1dd1d1248 --- /dev/null +++ b/src/SSHDebugPS/Podman/PodmanTransportSettings.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.SSHDebugPS.Podman +{ + internal sealed class PodmanContainerTransportSettings : ContainerTargetTransportSettings + { + internal const string WindowsExeName = "podman.exe"; + internal const string UnixExeName = "podman"; + internal const string HostFlag = "--url \"{0}\""; + + public PodmanContainerTransportSettings(string hostname, string containerName, bool hostIsUnix) + : base(hostname, containerName, hostIsUnix, WindowsExeName, UnixExeName, HostFlag) + { } + + public PodmanContainerTransportSettings(PodmanContainerTransportSettings settings) + : base(settings) + { } + } + + internal sealed class PodmanExecSettings : ContainerExecSettings + { + public PodmanExecSettings(PodmanContainerTransportSettings settings, string command, bool runInShell, bool makeInteractive = true) + : base(settings, command, runInShell, makeInteractive) + { } + } + + internal sealed class PodmanCopySettings : ContainerCopySettings + { + public PodmanCopySettings(string hostname, string sourcePath, string destinationPath, string containerName, bool hostIsUnix) + : base(hostname, sourcePath, destinationPath, containerName, hostIsUnix, PodmanContainerTransportSettings.WindowsExeName, PodmanContainerTransportSettings.UnixExeName, PodmanContainerTransportSettings.HostFlag) + { } + + public PodmanCopySettings(PodmanContainerTransportSettings settings, string sourcePath, string destinationPath) + : base(settings, sourcePath, destinationPath) + { } + } + + internal sealed class PodmanCommandSettings : ContainerCommandSettings + { + public PodmanCommandSettings(string hostname, bool hostIsUnix) + : base(hostname, hostIsUnix, PodmanContainerTransportSettings.WindowsExeName, PodmanContainerTransportSettings.UnixExeName, PodmanContainerTransportSettings.HostFlag) + { } + } +} + diff --git a/src/SSHDebugPS/StringResources.Designer.cs b/src/SSHDebugPS/StringResources.Designer.cs index 78a19bb65..120983ae5 100644 --- a/src/SSHDebugPS/StringResources.Designer.cs +++ b/src/SSHDebugPS/StringResources.Designer.cs @@ -114,6 +114,33 @@ internal static string Docker_PSName { } } + /// + /// Looks up a localized string similar to Podman (Linux Container). + /// + internal static string Podman_PSName { + get { + return ResourceManager.GetString("Podman_PSName", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The Podman (Linux Container) connection type allows Visual Studio to connect to Podman containers running locally or remotely (using SSH).. + /// + internal static string Podman_PSDescription { + get { + return ResourceManager.GetString("Podman_PSDescription", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Failed to parse output of '{0}'. + /// + internal static string Error_PodmanPSParseFailed { + get { + return ResourceManager.GetString("Error_PodmanPSParseFailed", resourceCulture); + } + } + /// /// Looks up a localized string similar to Command failed to execute. /// @@ -195,6 +222,15 @@ internal static string Error_EnsureDockerContainerIsLinux { } } + /// + /// Looks up a localized string similar to Ensure the selected Podman Connection target is a Linux container.. + /// + internal static string Error_EnsurePodmanContainerIsLinux { + get { + return ResourceManager.GetString("Error_EnsurePodmanContainerIsLinux", resourceCulture); + } + } + /// /// Looks up a localized string similar to Unable to parse exit code.. /// diff --git a/src/SSHDebugPS/StringResources.resx b/src/SSHDebugPS/StringResources.resx index af44a77c5..e60a78aa0 100644 --- a/src/SSHDebugPS/StringResources.resx +++ b/src/SSHDebugPS/StringResources.resx @@ -139,6 +139,15 @@ Docker (Linux Container) + + Podman (Linux Container) + + + The Podman (Linux Container) connection type allows Visual Studio to connect to Podman containers running locally or remotely (using SSH). + + + Failed to parse output of '{0}' + Command failed to execute @@ -165,6 +174,9 @@ Ensure the selected Docker Connection target is a Linux container. + + Ensure the selected Podman Connection target is a Linux container. + Failed to parse json '{0}'.\r\nError: '{1}' {0} is a json output item from the output 'docker ps' and {1} is the error message diff --git a/src/SSHDebugPS/UI/ContainerInstance.cs b/src/SSHDebugPS/UI/ContainerInstance.cs index 4ec33929e..fa182104a 100644 --- a/src/SSHDebugPS/UI/ContainerInstance.cs +++ b/src/SSHDebugPS/UI/ContainerInstance.cs @@ -1,79 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; -using System; -using System.Collections.Generic; -using System.ComponentModel; -using System.Diagnostics; -using System.Globalization; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; - namespace Microsoft.SSHDebugPS.Docker { - public interface IContainerInstance : IEquatable + public interface IContainerInstance : System.IEquatable { string Id { get; } string Name { get; } } - - public abstract class ContainerInstance : IContainerInstance - { - public abstract string Id { get; set; } - public abstract string Name { get; set; } - - #region IEquatable - - public static bool operator ==(ContainerInstance left, ContainerInstance right) - { - if (left is null || right is null) - { - return ReferenceEquals(left, right); - } - - return left.Equals(right); - } - - public static bool operator !=(ContainerInstance left, ContainerInstance right) - { - return !(left == right); - } - - public bool Equals(IContainerInstance instance) - { - if (!ReferenceEquals(null, instance) && instance is ContainerInstance container) - { - return this.EqualsInternal(container); - } - - return false; - } - - public override bool Equals(object obj) - { - if (obj is IContainerInstance instance) - { - return this.Equals(instance); - } - return false; - } - - public override int GetHashCode() - { - return GetHashCodeInternal(); - } - - #endregion - - #region Helper Methods - - protected abstract bool EqualsInternal(ContainerInstance instance); - protected abstract int GetHashCodeInternal(); - - #endregion - } } diff --git a/src/SSHDebugPS/UI/UIResources.Designer.cs b/src/SSHDebugPS/UI/UIResources.Designer.cs index 12e90b5c2..fce792a7a 100644 --- a/src/SSHDebugPS/UI/UIResources.Designer.cs +++ b/src/SSHDebugPS/UI/UIResources.Designer.cs @@ -267,6 +267,51 @@ public static string HostnameAutomationName { } } + /// + /// Looks up a localized string similar to Podman _CLI host:. + /// + public static string Podman_ConnectionLabel { + get { + return ResourceManager.GetString("Podman_ConnectionLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Podman _host (Optional):. + /// + public static string Podman_HostnameLabel { + get { + return ResourceManager.GetString("Podman_HostnameLabel", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Specify a URL for connecting to a different Podman host. . + /// + public static string Podman_HostnameTip { + get { + return ResourceManager.GetString("Podman_HostnameTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Location from which to run the Podman CLI.... + /// + public static string Podman_ConnectionToolTip { + get { + return ResourceManager.GetString("Podman_ConnectionToolTip", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Optional Podman Host name. + /// + public static string Podman_HostnameAutomationName { + get { + return ResourceManager.GetString("Podman_HostnameAutomationName", resourceCulture); + } + } + /// /// Looks up a localized string similar to Docker _host (Optional):. /// diff --git a/src/SSHDebugPS/UI/UIResources.resx b/src/SSHDebugPS/UI/UIResources.resx index 7ce103459..9e3d3738d 100644 --- a/src/SSHDebugPS/UI/UIResources.resx +++ b/src/SSHDebugPS/UI/UIResources.resx @@ -239,6 +239,22 @@ Optional Docker Host name + + Podman _CLI host: + + + Podman _host (Optional): + Hostname for Podman daemon configuration + + + Specify a URL for connecting to a different Podman host. + + + Location from which to run the Podman CLI. To manage remote connections, in the menu go to Tools -> Options and find Cross Platform -> Connection Manager. + + + Optional Podman Host name + Container List diff --git a/src/SSHDebugPS/UI/ViewModels/ContainerPickerViewModel.cs b/src/SSHDebugPS/UI/ViewModels/ContainerPickerViewModel.cs index 0f19c2f20..ea58f08de 100644 --- a/src/SSHDebugPS/UI/ViewModels/ContainerPickerViewModel.cs +++ b/src/SSHDebugPS/UI/ViewModels/ContainerPickerViewModel.cs @@ -11,6 +11,7 @@ using System.Windows.Threading; using liblinux.Persistence; using Microsoft.SSHDebugPS.Docker; +using Microsoft.SSHDebugPS.Podman; using Microsoft.SSHDebugPS.SSH; using Microsoft.SSHDebugPS.Utilities; using System.Globalization; @@ -151,6 +152,8 @@ private static IContainerDiscoveryStrategy CreateDiscoveryStrategy(ContainerRunt { case ContainerRuntimeType.Docker: return new DockerDiscoveryStrategy(); + case ContainerRuntimeType.Podman: + return new PodmanDiscoveryStrategy(); default: Debug.Fail($"Unsupported container runtime type: {runtimeType}"); return null; @@ -183,7 +186,7 @@ private void RefreshContainersListInternal() return; } - IEnumerable containers; + IEnumerable containers; if (SelectedConnection is LocalConnectionViewModel) { diff --git a/src/SSHDebugPS/UI/ViewModels/ContainerViewModel.cs b/src/SSHDebugPS/UI/ViewModels/ContainerViewModel.cs index b36798a10..1a0f4df51 100644 --- a/src/SSHDebugPS/UI/ViewModels/ContainerViewModel.cs +++ b/src/SSHDebugPS/UI/ViewModels/ContainerViewModel.cs @@ -131,9 +131,9 @@ public bool IsSelected } public class DockerContainerViewModel - : ContainerViewModel + : ContainerViewModel { - public DockerContainerViewModel(DockerContainerInstance instance) + public DockerContainerViewModel(ContainerInstance instance) : base(instance) { } From 114c05a8bc405f8615c602b50a01761e1eaabb91 Mon Sep 17 00:00:00 2001 From: Andrew Wang Date: Thu, 18 Jun 2026 19:11:56 -0700 Subject: [PATCH 2/2] Address PR comments --- .../Microsoft.MIDebugEngine.pkgdef | 2 + src/SSHDebugPS/ContainerRuntimeType.cs | 1 - src/SSHDebugPS/Docker/ContainerInstance.cs | 14 ++--- src/SSHDebugPS/Podman/PodmanConnection.cs | 53 ++----------------- .../Podman/PodmanContainerInstance.cs | 2 +- .../Podman/PodmanExecutionManager.cs | 2 +- src/SSHDebugPS/Podman/PodmanHelper.cs | 2 +- src/SSHDebugPS/Podman/PodmanJsonConverter.cs | 2 +- src/SSHDebugPS/Podman/PodmanPort.cs | 2 +- src/SSHDebugPS/Podman/PodmanPortSupplier.cs | 2 +- src/SSHDebugPS/StringResources.Designer.cs | 2 +- src/SSHDebugPS/StringResources.resx | 3 +- src/SSHDebugPS/UI/UIResources.resx | 2 +- src/SSHDebugPS/Utilities/TelemetryHelper.cs | 1 + 14 files changed, 24 insertions(+), 66 deletions(-) diff --git a/src/MIDebugEngine/Microsoft.MIDebugEngine.pkgdef b/src/MIDebugEngine/Microsoft.MIDebugEngine.pkgdef index e6f821ef3..85c7fe331 100644 --- a/src/MIDebugEngine/Microsoft.MIDebugEngine.pkgdef +++ b/src/MIDebugEngine/Microsoft.MIDebugEngine.pkgdef @@ -85,6 +85,8 @@ "0"="{3FDDF14E-E758-4695-BE0C-7509920432C9}" ; WSL Port supplier "1"="{267B1341-AC92-44DC-94DF-2EE4205DD17E}" +; Podman Port Supplier +"2"="{D4F2F3A5-6B7C-4E8D-9F0A-1B2C3D4E5F6A}" [$RootKey$\AD7Metrics\Engine\{5D630903-189D-4837-9785-699B05BEC2A9}\IncompatibleList] "MI Debug Engine - gdb"="{91744D97-430F-42C1-9779-A5813EBD6AB2}" diff --git a/src/SSHDebugPS/ContainerRuntimeType.cs b/src/SSHDebugPS/ContainerRuntimeType.cs index 7f8827b12..612c91035 100644 --- a/src/SSHDebugPS/ContainerRuntimeType.cs +++ b/src/SSHDebugPS/ContainerRuntimeType.cs @@ -8,7 +8,6 @@ namespace Microsoft.SSHDebugPS /// public enum ContainerRuntimeType { - Unknown, Docker, Podman } diff --git a/src/SSHDebugPS/Docker/ContainerInstance.cs b/src/SSHDebugPS/Docker/ContainerInstance.cs index 2d1de8d42..1e830d4cb 100644 --- a/src/SSHDebugPS/Docker/ContainerInstance.cs +++ b/src/SSHDebugPS/Docker/ContainerInstance.cs @@ -86,7 +86,7 @@ protected ContainerInstance() { } public bool Equals(IContainerInstance instance) { - if (!ReferenceEquals(null, instance) && instance is ContainerInstance container) + if (instance is ContainerInstance container) { return this.EqualsInternal(container); } @@ -115,15 +115,15 @@ public override int GetHashCode() // Container names: only [a-zA-Z0-9][a-zA-Z0-9_.-] are allowed. It is also case sensitive protected virtual bool EqualsInternal(ContainerInstance instance) { - if (instance is ContainerInstance other) + if (GetType() != instance.GetType()) { - // the id can be a partial on a container - return String.Equals(Id, other.Id, StringComparison.Ordinal) || - Id.StartsWith(other.Id, StringComparison.Ordinal) || - other.Id.StartsWith(Id, StringComparison.Ordinal); + return false; } - return false; + // the id can be a partial on a container + return String.Equals(Id, instance.Id, StringComparison.Ordinal) || + Id.StartsWith(instance.Id, StringComparison.Ordinal) || + instance.Id.StartsWith(Id, StringComparison.Ordinal); } protected virtual int GetHashCodeInternal() diff --git a/src/SSHDebugPS/Podman/PodmanConnection.cs b/src/SSHDebugPS/Podman/PodmanConnection.cs index 7bf8b369d..839a776e3 100644 --- a/src/SSHDebugPS/Podman/PodmanConnection.cs +++ b/src/SSHDebugPS/Podman/PodmanConnection.cs @@ -3,7 +3,6 @@ using System; using System.IO; -using System.Text.RegularExpressions; using System.Threading; using System.Diagnostics; using Microsoft.SSHDebugPS.Docker; @@ -13,7 +12,7 @@ namespace Microsoft.SSHDebugPS.Podman { - internal class PodmanConnection : PipeConnection + internal sealed class PodmanConnection : PipeConnection { #region Statics @@ -26,58 +25,14 @@ internal static string CreateConnectionString(string containerName, string remot internal static bool TryConvertConnectionStringToSettings(string connectionString, out PodmanContainerTransportSettings settings, out Connection remoteConnection) { ThreadHelper.ThrowIfNotOnUIThread(); - remoteConnection = null; - settings = null; - - string containerName = string.Empty; - string hostName = string.Empty; - bool invalidString = false; - - string[] connectionStrings = connectionString.Split(DockerConnection.Separator); - - if (connectionStrings.Length <= 3 && connectionStrings.Length > 0) - { - Regex SshRegex = new Regex(DockerConnection.SshPrefixRegex); - Regex hostRegex = new Regex(DockerConnection.HostPrefixRegex); - - foreach (var item in connectionStrings) - { - string segment = item.Trim(' '); - if (SshRegex.IsMatch(segment)) - { - Match match = SshRegex.Match(segment); - remoteConnection = ConnectionManager.GetSSHConnection(segment.Substring(match.Length)); - } - else if (hostRegex.IsMatch(segment)) - { - Match match = hostRegex.Match(segment); - hostName = segment.Substring(match.Length); - } - else if (segment.Contains("=")) - { - invalidString = true; - } - else - { - if (!string.IsNullOrWhiteSpace(containerName)) - { - Debug.Fail("containerName should be empty"); - invalidString = true; - } - else - { - containerName = segment; - } - } - } - } - if (!string.IsNullOrWhiteSpace(containerName) && !invalidString) + if (DockerConnection.TryConvertConnectionStringToSettings(connectionString, out DockerContainerTransportSettings dockerSettings, out remoteConnection)) { - settings = new PodmanContainerTransportSettings(hostName, containerName, remoteConnection != null); + settings = new PodmanContainerTransportSettings(dockerSettings.HostName, dockerSettings.ContainerName, remoteConnection != null); return true; } + settings = null; return false; } diff --git a/src/SSHDebugPS/Podman/PodmanContainerInstance.cs b/src/SSHDebugPS/Podman/PodmanContainerInstance.cs index 46056f8bb..72f004ee9 100644 --- a/src/SSHDebugPS/Podman/PodmanContainerInstance.cs +++ b/src/SSHDebugPS/Podman/PodmanContainerInstance.cs @@ -25,7 +25,7 @@ public static bool TryCreate(string json, out PodmanContainerInstance instance) } catch (Exception e) { - HostTelemetry.SendEvent(TelemetryHelper.Event_DockerPSParseFailure, new KeyValuePair[] { + HostTelemetry.SendEvent(TelemetryHelper.Event_PodmanPSParseFailure, new KeyValuePair[] { new KeyValuePair(TelemetryHelper.Property_ExceptionName, e.GetType().Name) }); diff --git a/src/SSHDebugPS/Podman/PodmanExecutionManager.cs b/src/SSHDebugPS/Podman/PodmanExecutionManager.cs index 0d90cdbbf..aef6ccc8f 100644 --- a/src/SSHDebugPS/Podman/PodmanExecutionManager.cs +++ b/src/SSHDebugPS/Podman/PodmanExecutionManager.cs @@ -5,7 +5,7 @@ namespace Microsoft.SSHDebugPS.Podman { - internal class PodmanExecutionManager : DockerExecutionManager + internal sealed class PodmanExecutionManager : DockerExecutionManager { public PodmanExecutionManager(PodmanContainerTransportSettings baseSettings, Connection outerConnection) : base(baseSettings, outerConnection) diff --git a/src/SSHDebugPS/Podman/PodmanHelper.cs b/src/SSHDebugPS/Podman/PodmanHelper.cs index 63d8912dd..5d25de158 100644 --- a/src/SSHDebugPS/Podman/PodmanHelper.cs +++ b/src/SSHDebugPS/Podman/PodmanHelper.cs @@ -144,8 +144,8 @@ internal static IEnumerable GetRemotePodmanContainers(I if (PodmanContainerInstance.TryCreate(item, out PodmanContainerInstance containerInstance)) { containers.Add(containerInstance); + totalContainers++; } - totalContainers++; } } diff --git a/src/SSHDebugPS/Podman/PodmanJsonConverter.cs b/src/SSHDebugPS/Podman/PodmanJsonConverter.cs index d850508ee..c82819bba 100644 --- a/src/SSHDebugPS/Podman/PodmanJsonConverter.cs +++ b/src/SSHDebugPS/Podman/PodmanJsonConverter.cs @@ -9,7 +9,7 @@ namespace Microsoft.SSHDebugPS.Podman { // Handles JSON values that may be a string, array of strings, or array of objects (port mappings). - internal class PodmanJsonConverter : JsonConverter + internal sealed class PodmanJsonConverter : JsonConverter { public override bool CanConvert(Type objectType) => objectType == typeof(string); diff --git a/src/SSHDebugPS/Podman/PodmanPort.cs b/src/SSHDebugPS/Podman/PodmanPort.cs index 413f541a6..edda987a3 100644 --- a/src/SSHDebugPS/Podman/PodmanPort.cs +++ b/src/SSHDebugPS/Podman/PodmanPort.cs @@ -5,7 +5,7 @@ namespace Microsoft.SSHDebugPS.Podman { - internal class PodmanPort : AD7Port + internal sealed class PodmanPort : AD7Port { public PodmanPort(AD7PortSupplier portSupplier, string name, bool isInAddPort) : base(portSupplier, name, isInAddPort) diff --git a/src/SSHDebugPS/Podman/PodmanPortSupplier.cs b/src/SSHDebugPS/Podman/PodmanPortSupplier.cs index d53fa734d..82031b256 100644 --- a/src/SSHDebugPS/Podman/PodmanPortSupplier.cs +++ b/src/SSHDebugPS/Podman/PodmanPortSupplier.cs @@ -9,7 +9,7 @@ namespace Microsoft.SSHDebugPS.Podman { [ComVisible(true)] [Guid("C9E1E1E4-3E5A-4F2B-8D1A-5C6F7A8B9D0E")] - internal class PodmanPortSupplier : AD7PortSupplier + internal sealed class PodmanPortSupplier : AD7PortSupplier { private readonly Guid _Id = new Guid("D4F2F3A5-6B7C-4E8D-9F0A-1B2C3D4E5F6A"); diff --git a/src/SSHDebugPS/StringResources.Designer.cs b/src/SSHDebugPS/StringResources.Designer.cs index 120983ae5..5706b852b 100644 --- a/src/SSHDebugPS/StringResources.Designer.cs +++ b/src/SSHDebugPS/StringResources.Designer.cs @@ -133,7 +133,7 @@ internal static string Podman_PSDescription { } /// - /// Looks up a localized string similar to Failed to parse output of '{0}'. + /// Looks up a localized string similar to Failed to parse output of '{0}': {1}. /// internal static string Error_PodmanPSParseFailed { get { diff --git a/src/SSHDebugPS/StringResources.resx b/src/SSHDebugPS/StringResources.resx index e60a78aa0..571c143fb 100644 --- a/src/SSHDebugPS/StringResources.resx +++ b/src/SSHDebugPS/StringResources.resx @@ -146,7 +146,8 @@ The Podman (Linux Container) connection type allows Visual Studio to connect to Podman containers running locally or remotely (using SSH). - Failed to parse output of '{0}' + Failed to parse output of '{0}': {1} + {0} is the JSON line that failed to parse. {1} is the exception details. Command failed to execute diff --git a/src/SSHDebugPS/UI/UIResources.resx b/src/SSHDebugPS/UI/UIResources.resx index 9e3d3738d..e06689608 100644 --- a/src/SSHDebugPS/UI/UIResources.resx +++ b/src/SSHDebugPS/UI/UIResources.resx @@ -247,7 +247,7 @@ Hostname for Podman daemon configuration - Specify a URL for connecting to a different Podman host. + Specify a URL for connecting to a different Podman host. Location from which to run the Podman CLI. To manage remote connections, in the menu go to Tools -> Options and find Cross Platform -> Connection Manager. diff --git a/src/SSHDebugPS/Utilities/TelemetryHelper.cs b/src/SSHDebugPS/Utilities/TelemetryHelper.cs index 85a13f327..8f019b1c2 100644 --- a/src/SSHDebugPS/Utilities/TelemetryHelper.cs +++ b/src/SSHDebugPS/Utilities/TelemetryHelper.cs @@ -6,6 +6,7 @@ namespace Microsoft.SSHDebugPS.Utilities internal static class TelemetryHelper { public const string Event_DockerPSParseFailure = @"VS/Diagnostics/Debugger/SSHDebugPS/DockerPSParseFailure"; + public const string Event_PodmanPSParseFailure = @"VS/Diagnostics/Debugger/SSHDebugPS/PodmanPSParseFailure"; public const string Event_ProcFSError = @"VS/Diagnostics/Debugger/SSHDebugPS/ProcFSError"; public static readonly string Property_ExceptionName = "vs.diagnostics.debugger.ExceptionName"; }