diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d813f8..78e2531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.4.2] - 2026-08-21 + +### Fixed + +- Switching kubectl current-context only patches `current-context` and never writes sibling `config.bak.*` files (Lens treats those as extra kubeconfigs). Blank cluster/user names reuse existing context entries (or match the context name) instead of creating `{name}-cluster` / `{name}-user` orphans. Structural kubeconfig saves also skip `.bak` siblings for the same reason. +- ConfigMap and Secret Data editors accept multiline values and multiline paste via a dedicated value box under the key list. + ## [0.4.1] - 2026-08-21 ### Fixed diff --git a/README.md b/README.md index 4a66d78..812c781 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # MaksIT.ClusterConsole -![Line Coverage](https://img.shields.io/badge/Line%20Coverage-53.6%25-yellowgreen) -![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.1%25-yellowgreen) -![Method Coverage](https://img.shields.io/badge/Method%20Coverage-61.3%25-green) +![Line Coverage](https://img.shields.io/badge/Line%20Coverage-53.8%25-yellowgreen) +![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-45.4%25-yellowgreen) +![Method Coverage](https://img.shields.io/badge/Method%20Coverage-61.5%25-green) ![.NET](https://img.shields.io/badge/.NET-10-512BD4) ![License](https://img.shields.io/badge/License-Apache%202.0-blue) ![Platform](https://img.shields.io/badge/Platform-Windows%20%7C%20Linux-0078D6) diff --git a/src/Directory.Build.props b/src/Directory.Build.props index fe2f03b..97c61f0 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.4.1 + 0.4.2 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfigEditor.cs b/src/MaksIT.ClusterConsole.Client/KubeConfigEditor.cs index e84f28d..07df425 100644 --- a/src/MaksIT.ClusterConsole.Client/KubeConfigEditor.cs +++ b/src/MaksIT.ClusterConsole.Client/KubeConfigEditor.cs @@ -1,4 +1,5 @@ using System.Text; +using System.Text.RegularExpressions; using k8s; using k8s.KubeConfigModels; using YamlDotNet.Serialization; @@ -8,6 +9,10 @@ using YamlDotNet.Serialization.NamingConventions; namespace MaksIT.ClusterConsole.Client; internal static class KubeConfigEditor { + private static readonly Regex CurrentContextLine = new( + @"^[ \t]*current-context:[ \t]*.*$", + RegexOptions.Multiline | RegexOptions.CultureInvariant); + public static K8SConfiguration LoadOrCreate(string path) { if (!File.Exists(path)) { return new K8SConfiguration { @@ -27,13 +32,6 @@ internal static class KubeConfigEditor { if (!string.IsNullOrWhiteSpace(directory)) Directory.CreateDirectory(directory); - if (File.Exists(path)) { - var backup = path + ".bak." + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss"); - if (File.Exists(backup)) - backup += "-" + Guid.NewGuid().ToString("N")[..6]; - File.Copy(path, backup, overwrite: false); - } - if (string.IsNullOrWhiteSpace(config.ApiVersion)) config.ApiVersion = "v1"; if (string.IsNullOrWhiteSpace(config.Kind)) @@ -47,21 +45,72 @@ internal static class KubeConfigEditor { | DefaultValuesHandling.OmitDefaults | DefaultValuesHandling.OmitEmptyCollections) .Build(); - File.WriteAllText(path, serializer.Serialize(config)); + + // Write in place — never create config.bak.* beside kubeconfig. Tools like Lens + // treat those sibling files as extra kubeconfigs / duplicate connections. + var temp = path + ".tmp." + Guid.NewGuid().ToString("N")[..8]; + File.WriteAllText(temp, serializer.Serialize(config)); + File.Move(temp, path, overwrite: true); } - public static string EffectiveClusterName(KubeConnectionRequest request) => - string.IsNullOrWhiteSpace(request.ClusterName) - ? request.ContextName + "-cluster" - : request.ClusterName.Trim(); + /// + /// Updates only the current-context line without rewriting clusters/users or creating a backup. + /// + public static bool TrySetCurrentContext(string path, string contextName) { + if (!File.Exists(path) || string.IsNullOrWhiteSpace(contextName)) + return false; - public static string EffectiveUserName(KubeConnectionRequest request) => - string.IsNullOrWhiteSpace(request.UserName) - ? request.ContextName + "-user" - : request.UserName.Trim(); + var text = File.ReadAllText(path); + var newline = text.Contains("\r\n", StringComparison.Ordinal) ? "\r\n" : "\n"; + var line = "current-context: " + contextName.Trim(); + string updated; + var match = CurrentContextLine.Match(text); + if (match.Success) { + var replacement = match.Value.EndsWith('\r') ? line + "\r" : line; + updated = CurrentContextLine.Replace(text, replacement, 1); + } + else { + var insertAt = text.IndexOf("contexts:", StringComparison.Ordinal); + if (insertAt < 0) + insertAt = text.IndexOf("clusters:", StringComparison.Ordinal); + if (insertAt < 0) + return false; + updated = text.Insert(insertAt, line + newline); + } + + if (string.Equals(updated, text, StringComparison.Ordinal)) + return true; + + var temp = path + ".tmp." + Guid.NewGuid().ToString("N")[..8]; + File.WriteAllText(temp, updated); + File.Move(temp, path, overwrite: true); + return true; + } + + public static string EffectiveClusterName(KubeConnectionRequest request, K8SConfiguration? config = null) { + if (!string.IsNullOrWhiteSpace(request.ClusterName)) + return request.ClusterName.Trim(); + + var existing = config?.Contexts?.FirstOrDefault(c => c.Name == request.ContextName); + if (existing?.ContextDetails?.Cluster is { Length: > 0 } cluster) + return cluster; + + return request.ContextName.Trim(); + } + + public static string EffectiveUserName(KubeConnectionRequest request, K8SConfiguration? config = null) { + if (!string.IsNullOrWhiteSpace(request.UserName)) + return request.UserName.Trim(); + + var existing = config?.Contexts?.FirstOrDefault(c => c.Name == request.ContextName); + if (existing?.ContextDetails?.User is { Length: > 0 } user) + return user; + + return request.ContextName.Trim(); + } public static Cluster UpsertCluster(K8SConfiguration config, KubeConnectionRequest request) { - var name = EffectiveClusterName(request); + var name = EffectiveClusterName(request, config); var clusters = config.Clusters?.ToList() ?? []; var cluster = clusters.FirstOrDefault(c => c.Name == name); if (cluster is null) { @@ -78,7 +127,7 @@ internal static class KubeConfigEditor { } public static User UpsertUser(K8SConfiguration config, KubeConnectionRequest request) { - var name = EffectiveUserName(request); + var name = EffectiveUserName(request, config); var users = config.Users?.ToList() ?? []; var user = users.FirstOrDefault(u => u.Name == name); if (user is null) { @@ -92,6 +141,23 @@ internal static class KubeConfigEditor { return user; } + public static void PruneUnreferenced(K8SConfiguration config) { + var contexts = config.Contexts ?? []; + var usedClusters = contexts + .Select(c => c.ContextDetails?.Cluster) + .Where(n => !string.IsNullOrEmpty(n)) + .ToHashSet(StringComparer.Ordinal); + var usedUsers = contexts + .Select(c => c.ContextDetails?.User) + .Where(n => !string.IsNullOrEmpty(n)) + .ToHashSet(StringComparer.Ordinal); + + if (config.Clusters is not null) + config.Clusters = config.Clusters.Where(c => usedClusters.Contains(c.Name)).ToList(); + if (config.Users is not null) + config.Users = config.Users.Where(u => usedUsers.Contains(u.Name)).ToList(); + } + public static Context UpsertContext( K8SConfiguration config, KubeConnectionRequest request, diff --git a/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs b/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs index 3f3d43a..918cf22 100644 --- a/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs +++ b/src/MaksIT.ClusterConsole.Client/KubeConfigService.cs @@ -79,8 +79,14 @@ public sealed class KubeConfigService : IKubeConfigService { if (config.Contexts is null || config.Contexts.All(c => c.Name != contextName)) return Result.NotFound("Context not found: " + contextName); - config.CurrentContext = contextName; - KubeConfigEditor.Save(path, config); + if (string.Equals(config.CurrentContext, contextName, StringComparison.Ordinal)) + return Result.Ok("Already using context: " + contextName); + + // Only patch current-context — never rewrite or create sibling .bak files + // (Lens and similar tools treat config.bak.* as extra kubeconfigs). + if (!KubeConfigEditor.TrySetCurrentContext(path, contextName)) + return Result.InternalServerError("Could not update current-context in kubeconfig."); + return Result.Ok("Switched to context: " + contextName); } catch (Exception ex) { @@ -99,6 +105,7 @@ public sealed class KubeConfigService : IKubeConfigService { var cluster = KubeConfigEditor.UpsertCluster(config, request); var user = KubeConfigEditor.UpsertUser(config, request); KubeConfigEditor.UpsertContext(config, request, cluster.Name, user.Name); + KubeConfigEditor.PruneUnreferenced(config); KubeConfigEditor.Save(path, config); return Result.Ok( "Added/updated context: " + request.ContextName diff --git a/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs b/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs index e321e93..3622817 100644 --- a/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs +++ b/src/MaksIT.ClusterConsole.Tests/KubeConfigServiceTests.cs @@ -84,8 +84,8 @@ public class KubeConfigServiceTests { Assert.True(listed.IsSuccess); var item = Assert.Single(listed.Value!); Assert.Equal("k3s", item.Name); - Assert.Equal("k3s-cluster", item.Cluster); - Assert.Equal("k3s-user", item.User); + Assert.Equal("k3s", item.Cluster); + Assert.Equal("k3s", item.User); Assert.Equal("https://127.0.0.1:6443", item.Server); Assert.True(item.SkipTlsVerify); Assert.Equal("Auth: token present", item.AuthSummary); @@ -168,6 +168,57 @@ public class KubeConfigServiceTests { Assert.False(added.IsSuccess); } + [Fact] + public void UpsertConnection_reuses_existing_cluster_and_user_names() { + var path = CopyFixture(); + var service = new KubeConfigService(); + var updated = service.UpsertConnection(new KubeConnectionRequest { + ContextName = "lab", + Server = "https://127.0.0.1:6443", + AuthKind = KubeAuthKind.Token, + Token = "rotated-token", + InsecureSkipTlsVerify = true, + UseAfterAdd = false + }, path); + + Assert.True(updated.IsSuccess, string.Join("; ", updated.Messages)); + var details = service.ListContextDetails(path).Value!.Single(d => d.Name == "lab"); + Assert.Equal("lab", details.Cluster); + Assert.Equal("admin", details.User); + + var config = k8s.KubernetesClientConfiguration.LoadKubeConfig(path); + Assert.Single(config.Clusters!); + Assert.Single(config.Users!); + } + + [Fact] + public void UseContext_updates_current_context_without_backup() { + var path = CopyFixture(); + var service = new KubeConfigService(); + + var switched = service.UseContext("other", path); + Assert.True(switched.IsSuccess, string.Join("; ", switched.Messages)); + Assert.Equal("other", service.GetCurrentContext(path).Value); + + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, Path.GetFileName(path) + ".bak.*")); + } + + [Fact] + public void UpsertConnection_does_not_create_sibling_bak_files() { + var path = CopyFixture(); + var service = new KubeConfigService(); + var added = service.UpsertConnection(new KubeConnectionRequest { + ContextName = "edge", + Server = "https://10.0.0.2:6443", + AuthKind = KubeAuthKind.Token, + Token = "edge-token", + UseAfterAdd = false + }, path); + + Assert.True(added.IsSuccess, string.Join("; ", added.Messages)); + Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, Path.GetFileName(path) + ".bak.*")); + } + private static string CopyFixture() { var source = Path.Combine(AppContext.BaseDirectory, "Fixtures", "kubeconfig.yaml"); var path = Path.Combine(Path.GetTempPath(), "maksit-cluster-console-" + Guid.NewGuid().ToString("N") + ".yaml"); diff --git a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml index 8e7d5cc..650e681 100644 --- a/src/MaksIT.ClusterConsole.UI/MainWindow.axaml +++ b/src/MaksIT.ClusterConsole.UI/MainWindow.axaml @@ -622,15 +622,28 @@