diff --git a/source/Octopus.Tentacle.Tests/Kubernetes/ClusterVersionTests.cs b/source/Octopus.Tentacle.Tests/Kubernetes/ClusterVersionTests.cs new file mode 100644 index 000000000..b3eb9edec --- /dev/null +++ b/source/Octopus.Tentacle.Tests/Kubernetes/ClusterVersionTests.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using k8s.Models; +using NUnit.Framework; +using Octopus.Tentacle.Kubernetes; + +namespace Octopus.Tentacle.Tests.Kubernetes +{ + [TestFixture] + public class ClusterVersionTests + { + [TestCase("0", "1", 0, 1, false)] + [TestCase("1abc", "1", 1, 1, false)] + [TestCase("0", "1abc", 0, 1, false)] + [TestCase("1+", "0", 1, 0, false)] + [TestCase("0", "1+", 0, 1, false)] + [TestCase("abc", "1", 999, 999, true)] + public void FromVersionInfo_SanitizesAndReturnsNewClusterVersion(string major, string minor, int expectedMajor, int expectedMinor, bool shouldFail) + { + try + { + var versionInfo = new VersionInfo + { + Major = major, + Minor = minor + }; + var result = ClusterVersion.FromVersionInfo(versionInfo); + result.Should().BeEquivalentTo(new ClusterVersion(expectedMajor, expectedMinor)); + } + catch (Exception e) + { + if (shouldFail) + e.Should().BeOfType(); + else + throw; + } + } + + static IEnumerable FromVersionTestData() + { + yield return new TestCaseData(new Version("0.0.1"), 0, 0); + yield return new TestCaseData(new Version("1.31"), 1, 31); + yield return new TestCaseData(new Version("2.24.4"), 2, 24); + } + + [TestCaseSource(nameof(FromVersionTestData))] + public void FromVersion_ReturnsNewClusterVersion(Version version, int expectedMajor, int expectedMinor) + { + var result = ClusterVersion.FromVersion(version); + result.Should().BeEquivalentTo(new ClusterVersion(expectedMajor, expectedMinor)); + } + + static IEnumerable CompareClusterVersionsTestData() + { + yield return new TestCaseData(new ClusterVersion(0, 0), null, 1); + yield return new TestCaseData(new ClusterVersion(0, 0), new ClusterVersion(0, 0), 0); + yield return new TestCaseData(new ClusterVersion(0, 5), new ClusterVersion(0, 6), -1); + yield return new TestCaseData(new ClusterVersion(1, 1), new ClusterVersion(2, 0), -1); + yield return new TestCaseData(new ClusterVersion(1, 30), new ClusterVersion(1, 29), 1); + yield return new TestCaseData(new ClusterVersion(3, 0), new ClusterVersion(2, 11), 1); + yield return new TestCaseData(new ClusterVersion(3, 14), new ClusterVersion(3, 14), 0); + } + + [TestCaseSource(nameof(CompareClusterVersionsTestData))] + public void CompareClusterVersions(ClusterVersion thisClusterVersion, ClusterVersion otherClusterVersion, int expected) + { + var result = thisClusterVersion.CompareTo(otherClusterVersion); + result.Should().Be(expected); + } + + static IEnumerable CheckEqualityClusterVersionsTestData() + { + yield return new TestCaseData(new ClusterVersion(0, 0), null, false); + yield return new TestCaseData(new ClusterVersion(0, 5), new ClusterVersion(0, 6), false); + yield return new TestCaseData(new ClusterVersion(1, 0), new ClusterVersion(2, 0), false); + yield return new TestCaseData(new ClusterVersion(3, 14), new ClusterVersion(3, 14), true); + } + + [TestCaseSource(nameof(CheckEqualityClusterVersionsTestData))] + public void CheckEqualityClusterVersions(ClusterVersion thisClusterVersion, ClusterVersion otherClusterVersion, bool expected) + { + var result = thisClusterVersion.Equals(otherClusterVersion); + result.Should().Be(expected); + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Tests/Kubernetes/KubernetesPodContainerResolverTests.cs b/source/Octopus.Tentacle.Tests/Kubernetes/KubernetesPodContainerResolverTests.cs new file mode 100644 index 000000000..216938fdc --- /dev/null +++ b/source/Octopus.Tentacle.Tests/Kubernetes/KubernetesPodContainerResolverTests.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using NSubstitute; +using NSubstitute.ReturnsExtensions; +using NUnit.Framework; +using Octopus.Tentacle.Kubernetes; + +namespace Octopus.Tentacle.Tests.Kubernetes +{ + [TestFixture] + public class KubernetesPodContainerResolverTests + { + readonly KubernetesAgentToolsImageVersionMetadata testVersionMetadata = new(new KubernetesAgentToolVersions(new List + { + new("1.31.1"), + new("1.30.5"), + new("1.29.9"), + new("1.28.14") + }, new List { new("3.16.1") }, + new List { new("7.4.5") }), new Version("1.30"), "Juaa5J", new Dictionary + { + { new Version("1.26"), new KubernetesAgentToolDeprecation("1.26@sha256:a0892db") }, + { new Version("1.27"), new KubernetesAgentToolDeprecation("1.27@sha256:9d1ce87") } + }); + + readonly IToolsImageVersionMetadataProvider mockToolsImageVersionMetadataProvider = Substitute.For(); + + [SetUp] + public void Init() + { + mockToolsImageVersionMetadataProvider.TryGetVersionMetadata().Returns(testVersionMetadata); + } + + [TestCase(30)] + [TestCase(29)] + [TestCase(28)] + public async Task GetContainerImageForCluster_VersionMetadataExists_ClusterVersionSupported_GetsImageWithRevision(int clusterMinorVersion) + { + // Arrange + var clusterService = Substitute.For(); + clusterService.GetClusterVersion().Returns(new ClusterVersion(1, clusterMinorVersion)); + + var podContainerResolver = new KubernetesPodContainerResolver(clusterService, mockToolsImageVersionMetadataProvider); + + // Act + var result = await podContainerResolver.GetContainerImageForCluster(); + + // Assert + result.Should().Be($"octopusdeploy/kubernetes-agent-tools-base:1.{clusterMinorVersion}-Juaa5J"); + } + + [TestCase(27, "1.27@sha256:9d1ce87")] + [TestCase(26, "1.26@sha256:a0892db")] + public async Task GetContainerImageForCluster_VersionMetadataExists_ClusterVersionDeprecated_GetsLatestDeprecatedTag(int clusterMinorVersion, string expectedImageTag) + { + // Arrange + var clusterService = Substitute.For(); + clusterService.GetClusterVersion().Returns(new ClusterVersion(1, clusterMinorVersion)); + + var podContainerResolver = new KubernetesPodContainerResolver(clusterService, mockToolsImageVersionMetadataProvider); + + // Act + var result = await podContainerResolver.GetContainerImageForCluster(); + + // Assert + result.Should().Be($"octopusdeploy/kubernetes-agent-tools-base:{expectedImageTag}"); + } + + [Test] + public async Task GetContainerImageForCluster_VersionMetadataExists_ClusterVersionGreaterThanLatest_FallbackToLatest() + { + // Arrange + var clusterService = Substitute.For(); + clusterService.GetClusterVersion().Returns(new ClusterVersion(1, 31)); + + var podContainerResolver = new KubernetesPodContainerResolver(clusterService, mockToolsImageVersionMetadataProvider); + + // Act + var result = await podContainerResolver.GetContainerImageForCluster(); + + // Assert + result.Should().Be("octopusdeploy/kubernetes-agent-tools-base:latest"); + } + + [Test] + public async Task GetContainerImageForCluster_VersionMetadataExists_ClusterVersionNotFound_FallbackToLatest() + { + // Arrange + var clusterService = Substitute.For(); + clusterService.GetClusterVersion().Returns(new ClusterVersion(1, 40)); + + var podContainerResolver = new KubernetesPodContainerResolver(clusterService, mockToolsImageVersionMetadataProvider); + + // Act + var result = await podContainerResolver.GetContainerImageForCluster(); + + // Assert + result.Should().Be("octopusdeploy/kubernetes-agent-tools-base:latest"); + } + + [TestCase(31, "latest")] + [TestCase(30, "1.30")] + [TestCase(29, "1.29")] + [TestCase(28, "1.28")] + [TestCase(27, "1.27")] + [TestCase(26, "1.26")] + [TestCase(25, "latest")] + public async Task GetContainerImageForCluster_VersionMetadataNotFound_FallBackToKnownTags(int clusterMinorVersion, string expectedImageTag) + { + // Arrange + var clusterService = Substitute.For(); + clusterService.GetClusterVersion().Returns(new ClusterVersion(1, clusterMinorVersion)); + mockToolsImageVersionMetadataProvider.TryGetVersionMetadata().ReturnsNull(); + + var podContainerResolver = new KubernetesPodContainerResolver(clusterService, mockToolsImageVersionMetadataProvider); + + // Act + var result = await podContainerResolver.GetContainerImageForCluster(); + + // Assert + result.Should().Be($"octopusdeploy/kubernetes-agent-tools-base:{expectedImageTag}"); + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle.Tests/Kubernetes/KubernetesVersionParserTests.cs b/source/Octopus.Tentacle.Tests/Kubernetes/KubernetesVersionParserTests.cs deleted file mode 100644 index 99143f6cf..000000000 --- a/source/Octopus.Tentacle.Tests/Kubernetes/KubernetesVersionParserTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using FluentAssertions; -using k8s.Models; -using NUnit.Framework; -using Octopus.Tentacle.Kubernetes; - -namespace Octopus.Tentacle.Tests.Kubernetes -{ - [TestFixture] - public class KubernetesVersionParserTests - { - [TestCase("0", "1", 0, 1, false)] - [TestCase("1abc", "1", 1, 1, false)] - [TestCase("0", "1abc", 0, 1, false)] - [TestCase("1+", "0", 1, 0, false)] - [TestCase("0", "1+", 0, 1, false)] - [TestCase("abc", "1", 999, 999, true)] - public void ParseClusterVersion_SanitizesAndReturnsClusterVersion(string major, string minor, int expectedMajor, int expectedMinor, bool shouldFail) - { - try - { - var versionInfo = new VersionInfo - { - Major = major, - Minor = minor - }; - var result = KubernetesVersionParser.ParseClusterVersion(versionInfo); - result.Should().BeEquivalentTo(new ClusterVersion(expectedMajor, expectedMinor)); - } - catch (Exception e) - { - if (shouldFail) - { - e.Should().BeOfType(); - } - else - { - throw; - } - } - } - } -} \ No newline at end of file diff --git a/source/Octopus.Tentacle/Kubernetes/ClusterVersion.cs b/source/Octopus.Tentacle/Kubernetes/ClusterVersion.cs new file mode 100644 index 000000000..2b6b5bbb9 --- /dev/null +++ b/source/Octopus.Tentacle/Kubernetes/ClusterVersion.cs @@ -0,0 +1,69 @@ +using System; +using System.Text.RegularExpressions; +using k8s.Models; + +namespace Octopus.Tentacle.Kubernetes +{ + public class ClusterVersion : IComparable + { + public ClusterVersion(int major, int minor) + { + Major = major; + Minor = minor; + } + + public int Major { get; } + public int Minor { get; } + + public static ClusterVersion FromVersion(Version version) + { + return new ClusterVersion(version.Major, version.Minor); + } + + public static ClusterVersion FromVersionInfo(VersionInfo versionInfo) + { + return new ClusterVersion(SanitizeAndParseVersionNumber(versionInfo.Major), SanitizeAndParseVersionNumber(versionInfo.Minor)); + } + + static int SanitizeAndParseVersionNumber(string version) + { + return int.Parse(Regex.Replace(version, "[^0-9]", "")); + } + + public int CompareTo(ClusterVersion? other) + { + if (other == null) return 1; + if (Major > other.Major || (Major == other.Major && Minor > other.Minor)) return 1; + if (Major == other.Major && Minor == other.Minor) return 0; + return -1; + } + + public override bool Equals(object? obj) + { + if (obj is not ClusterVersion clusterVersion) + return false; + + return clusterVersion.Major == Major && clusterVersion.Minor == Minor; + } + + public override int GetHashCode() + { +#if NET8_0_OR_GREATER + return HashCode.Combine(Major, Minor); +#else + unchecked // Overflow is fine in hash code calculations + { + int hash = 17; + hash = hash * 23 + Major.GetHashCode(); + hash = hash * 23 + Minor.GetHashCode(); + return hash; + } +#endif + } + + public override string ToString() + { + return $"{Major}.{Minor}"; + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesAgentToolsImageVersionMetadata.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesAgentToolsImageVersionMetadata.cs new file mode 100644 index 000000000..9c8eed170 --- /dev/null +++ b/source/Octopus.Tentacle/Kubernetes/KubernetesAgentToolsImageVersionMetadata.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Octopus.Tentacle.Kubernetes +{ + public record KubernetesAgentToolsImageVersionMetadata( + [JsonProperty("tools")] KubernetesAgentToolVersions ToolVersions, + [JsonProperty("latest")] Version Latest, + [JsonProperty("revisionHash")] string RevisionHash, + [JsonProperty("deprecations")] Dictionary Deprecations); + + public record KubernetesAgentToolVersions( + [JsonProperty("kubectl")] List Kubectl, + [JsonProperty("helm")] List Helm, + [JsonProperty("powershell")] List Powershell + ); + + public record KubernetesAgentToolDeprecation( + [JsonProperty("latestTag")] string LatestTag); +} \ No newline at end of file diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesAgentToolsImageVersionMetadataProvider.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesAgentToolsImageVersionMetadataProvider.cs new file mode 100644 index 000000000..13c543be8 --- /dev/null +++ b/source/Octopus.Tentacle/Kubernetes/KubernetesAgentToolsImageVersionMetadataProvider.cs @@ -0,0 +1,63 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; +using Octopus.Diagnostics; + +namespace Octopus.Tentacle.Kubernetes +{ + public interface IToolsImageVersionMetadataProvider + { + Task TryGetVersionMetadata(); + } + + public class CachingKubernetesAgentToolsImageVersionMetadataProvider : IToolsImageVersionMetadataProvider + { + readonly IToolsImageVersionMetadataProvider inner; + readonly IMemoryCache memoryCache; + static readonly TimeSpan CacheExpiry = TimeSpan.FromHours(1); + + public CachingKubernetesAgentToolsImageVersionMetadataProvider(IToolsImageVersionMetadataProvider inner, IMemoryCache memoryCache) + { + this.inner = inner; + this.memoryCache = memoryCache; + } + + public Task TryGetVersionMetadata() + { + var cacheKey = $"{nameof(CachingKubernetesAgentToolsImageVersionMetadataProvider)}_VersionMetadata"; + return memoryCache.GetOrCreateAsync(cacheKey, async entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheExpiry; + return await inner.TryGetVersionMetadata(); + }); + } + } + + public class KubernetesAgentToolsImageVersionMetadataProvider : IToolsImageVersionMetadataProvider + { + readonly ISystemLog log; + + public KubernetesAgentToolsImageVersionMetadataProvider(ISystemLog log) + { + this.log = log; + } + + public async Task TryGetVersionMetadata() + { + using var httpClient = new HttpClient(); + try + { + var response = await httpClient.GetAsync("https://oc.to/kubernetes-agent-tools-image-metadata"); + var json = await response.Content.ReadAsStringAsync(); + return JsonConvert.DeserializeObject(json); + } + catch (Exception ex) + { + log.Error(ex, "Failed to fetch version metadata for the agent tools container image."); + return null; + } + } + } +} \ No newline at end of file diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesClusterService.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesClusterService.cs index 8d51ea7a9..af2435813 100644 --- a/source/Octopus.Tentacle/Kubernetes/KubernetesClusterService.cs +++ b/source/Octopus.Tentacle/Kubernetes/KubernetesClusterService.cs @@ -21,7 +21,7 @@ public KubernetesClusterService(IKubernetesClientConfigProvider configProvider, lazyVersion = new AsyncLazy(async () => { var versionInfo = await Client.Version.GetCodeAsync(); - return KubernetesVersionParser.ParseClusterVersion(versionInfo); + return ClusterVersion.FromVersionInfo(versionInfo); }, AsyncLazyFlags.RetryOnFailure); } diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs index d7d8e7bc1..619bfba9d 100644 --- a/source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs +++ b/source/Octopus.Tentacle/Kubernetes/KubernetesModule.cs @@ -15,6 +15,13 @@ protected override void Load(ContainerBuilder builder) { builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); + + const string agentToolsImageMetadataProvider = "AgentToolsImageMetadataProvider"; + builder.RegisterType().Named(agentToolsImageMetadataProvider); + builder.RegisterDecorator( + (context, inner) => new CachingKubernetesAgentToolsImageVersionMetadataProvider( + inner, context.Resolve()), fromKey: agentToolsImageMetadataProvider); + builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().SingleInstance(); diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesPodContainerRegistry.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesPodContainerRegistry.cs deleted file mode 100644 index 21e24866f..000000000 --- a/source/Octopus.Tentacle/Kubernetes/KubernetesPodContainerRegistry.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Octopus.Tentacle.Util; - -namespace Octopus.Tentacle.Kubernetes -{ - public interface IKubernetesPodContainerResolver - { - Task GetContainerImageForCluster(); - } - - public class KubernetesPodContainerResolver : IKubernetesPodContainerResolver - { - readonly IKubernetesClusterService clusterService; - - public KubernetesPodContainerResolver(IKubernetesClusterService clusterService) - { - this.clusterService = clusterService; - } - - static readonly List KnownLatestContainerTags = new() - { - new(1, 26), - new(1, 27), - new(1, 28), - new(1, 29), - new(1, 30), - }; - - public async Task GetContainerImageForCluster() - { - var imageRepository = KubernetesConfig.ScriptPodContainerImage; - if (imageRepository.IsNullOrEmpty()) - { - return await GetKubernetesSpecificContainer(); - } - - var imageTag = KubernetesConfig.ScriptPodContainerImageTag; - return $"{imageRepository}:{imageTag}"; - } - - async Task GetKubernetesSpecificContainer() - { - var clusterVersion = await clusterService.GetClusterVersion(); - - //find the highest tag for this cluster version - var tagVersion = KnownLatestContainerTags.FirstOrDefault(tag => tag.Major == clusterVersion.Major && tag.Minor == clusterVersion.Minor); - - var tag = tagVersion?.ToString(2) ?? "latest"; - - return $"octopusdeploy/kubernetes-agent-tools-base:{tag}"; - } - } -} diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesPodContainerResolver.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesPodContainerResolver.cs new file mode 100644 index 000000000..eab1790bd --- /dev/null +++ b/source/Octopus.Tentacle/Kubernetes/KubernetesPodContainerResolver.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Octopus.Tentacle.Util; + +namespace Octopus.Tentacle.Kubernetes +{ + public interface IKubernetesPodContainerResolver + { + Task GetContainerImageForCluster(); + } + + public class KubernetesPodContainerResolver : IKubernetesPodContainerResolver + { + readonly IKubernetesClusterService clusterService; + readonly IToolsImageVersionMetadataProvider imageVersionMetadataProvider; + + public KubernetesPodContainerResolver(IKubernetesClusterService clusterService, IToolsImageVersionMetadataProvider imageVersionMetadataProvider) + { + this.clusterService = clusterService; + this.imageVersionMetadataProvider = imageVersionMetadataProvider; + } + + const string DefaultKubernetesAgentToolsImage = "octopusdeploy/kubernetes-agent-tools-base"; + const string FallbackImageTag = "latest"; + + static readonly List KnownLatestContainerTags = new() + { + new(1, 26), + new(1, 27), + new(1, 28), + new(1, 29), + new(1, 30), + }; + + public async Task GetContainerImageForCluster() + { + var imageRepository = KubernetesConfig.ScriptPodContainerImage; + if (imageRepository.IsNullOrEmpty()) + { + return await GetAgentToolsContainerImage(); + } + + var imageTag = KubernetesConfig.ScriptPodContainerImageTag; + return $"{imageRepository}:{imageTag}"; + } + + async Task GetAgentToolsContainerImage() + { + var clusterVersion = await clusterService.GetClusterVersion(); + + var versionMetadata = await imageVersionMetadataProvider.TryGetVersionMetadata(); + if (TryGetImageTagFromVersionMetadata(versionMetadata, clusterVersion, out var imageTag)) + { + return $"{DefaultKubernetesAgentToolsImage}:{imageTag}"; + } + + return GetFallbackAgentToolsImage(clusterVersion); + } + + static bool TryGetImageTagFromVersionMetadata(KubernetesAgentToolsImageVersionMetadata? versionMetadata, ClusterVersion clusterVersion, out string imageTag) + { + imageTag = ""; + if (versionMetadata is null) + { + return false; + } + + var versionDeprecation = versionMetadata.Deprecations.FirstOrDefault(kvp => ClusterVersion.FromVersion(kvp.Key).Equals(clusterVersion)); + if (versionDeprecation.Key is not null) + { + imageTag = versionDeprecation.Value.LatestTag; + return true; + } + + if (ClusterVersion.FromVersion(versionMetadata.Latest).CompareTo(clusterVersion) < 0) + { + imageTag = FallbackImageTag; + return true; + } + + var imageExists = versionMetadata.ToolVersions.Kubectl.Any(v => ClusterVersion.FromVersion(v).Equals(clusterVersion)); + if (imageExists) + { + imageTag = $"{clusterVersion}-{versionMetadata.RevisionHash}"; + return true; + } + + imageTag = FallbackImageTag; + return true; + } + + static string GetFallbackAgentToolsImage(ClusterVersion clusterVersion) + { + var tagVersion = KnownLatestContainerTags.FirstOrDefault(tag => tag.Major == clusterVersion.Major && tag.Minor == clusterVersion.Minor); + + var tag = tagVersion?.ToString(2) ?? "latest"; + + return $"{DefaultKubernetesAgentToolsImage}:{tag}"; + } + } +} diff --git a/source/Octopus.Tentacle/Kubernetes/KubernetesVersionParser.cs b/source/Octopus.Tentacle/Kubernetes/KubernetesVersionParser.cs deleted file mode 100644 index 53d0ce4cf..000000000 --- a/source/Octopus.Tentacle/Kubernetes/KubernetesVersionParser.cs +++ /dev/null @@ -1,21 +0,0 @@ -using System; -using System.Text.RegularExpressions; -using k8s.Models; - -namespace Octopus.Tentacle.Kubernetes -{ - public static class KubernetesVersionParser - { - public static ClusterVersion ParseClusterVersion(VersionInfo versionInfo) - { - return new ClusterVersion(SanitizeAndParseVersionNumber(versionInfo.Major), SanitizeAndParseVersionNumber(versionInfo.Minor)); - } - - static int SanitizeAndParseVersionNumber(string version) - { - return int.Parse(Regex.Replace(version, "[^0-9]", "")); - } - - } - public record ClusterVersion(int Major, int Minor); -} \ No newline at end of file