(bugfix): stop kubeconfig bak churn and allow multiline secret data

This commit is contained in:
Maksym Sadovnychyy 2026-08-21 21:48:18 +02:00
parent 14952c50b9
commit bfe49c4603
10 changed files with 223 additions and 34 deletions

View File

@ -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

View File

@ -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)

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.4.1</Version>
<Version>0.4.2</Version>
<Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup>

View File

@ -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();
/// <summary>
/// Updates only the <c>current-context</c> line without rewriting clusters/users or creating a backup.
/// </summary>
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,

View File

@ -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

View File

@ -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");

View File

@ -622,15 +622,28 @@
<Button Content="Apply data" Command="{Binding ActivePage.ApplyDataCommand}" />
<TextBlock Text="Decoded secret values are saved back as stringData." Classes="hint" VerticalAlignment="Center" />
</StackPanel>
<DockPanel DockPanel.Dock="Bottom" Margin="0,8,0,0" MinHeight="160">
<TextBlock DockPanel.Dock="Top"
Text="Value (multiline paste supported)"
Classes="hint"
Margin="0,0,0,4" />
<TextBox Text="{Binding ActivePage.SelectedDataEntry.Value}"
AcceptsReturn="True"
TextWrapping="Wrap"
FontFamily="Consolas, monospace"
IsEnabled="{Binding ActivePage.HasSelectedDataEntry}"
PlaceholderText="Select a key, then edit or paste its value here" />
</DockPanel>
<DataGrid Name="DataEntriesGrid"
ItemsSource="{Binding ActivePage.DataEntries}"
SelectedItem="{Binding ActivePage.SelectedDataEntry}"
AutoGenerateColumns="False"
CanUserResizeColumns="True"
IsReadOnly="False"
x:CompileBindings="False">
<DataGrid.Columns>
<DataGridTextColumn Header="Key" Binding="{Binding Key}" Width="140" />
<DataGridTextColumn Header="Value" Binding="{Binding Value}" Width="*" />
<DataGridTextColumn Header="Value" Binding="{Binding ValuePreview}" IsReadOnly="True" Width="*" />
<DataGridCheckBoxColumn Header="Binary" Binding="{Binding IsBinary}" Width="80" />
</DataGrid.Columns>
</DataGrid>

View File

@ -69,6 +69,11 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
public ObservableCollection<DataEntryViewModel> DataEntries { get; } = [];
[ObservableProperty]
private DataEntryViewModel? selectedDataEntry;
public bool HasSelectedDataEntry => SelectedDataEntry is not null;
public ObservableCollection<WorkloadKindCount> WorkloadCounts { get; } = [];
public ObservableCollection<NodeUsageViewModel> NodeUsages { get; } = [];
@ -538,6 +543,9 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
OnPropertyChanged(nameof(CanApplyLimits));
}
partial void OnSelectedDataEntryChanged(DataEntryViewModel? value) =>
OnPropertyChanged(nameof(HasSelectedDataEntry));
partial void OnSelectedRelatedPodChanged(ResourceRow? value) {
if (_updatingPodContext)
return;
@ -680,7 +688,10 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
[RelayCommand]
private void AddDataEntry() {
DataEntries.Add(new DataEntryViewModel { Key = "new-key", Value = "" });
var entry = new DataEntryViewModel { Key = "new-key", Value = "" };
entry.PropertyChanged += OnDataEntryPropertyChanged;
DataEntries.Add(entry);
SelectedDataEntry = entry;
IsDirty = true;
}
@ -1375,7 +1386,7 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
EventsText = "";
LogsText = "";
TerminalText = "";
DataEntries.Clear();
ReplaceDataEntries(null);
ReplaceRelatedPods([]);
ApplyContainers(null);
NotifyDetailsUi();
@ -1431,17 +1442,37 @@ public partial class ClusterPageViewModel : ObservableObject, IDisposable {
}
private void ReplaceDataEntries(JsonObject? document) {
foreach (var entry in DataEntries)
entry.PropertyChanged -= OnDataEntryPropertyChanged;
var keepKey = SelectedDataEntry?.Key;
DataEntries.Clear();
if (document is null)
if (document is null) {
SelectedDataEntry = null;
OnPropertyChanged(nameof(HasSelectedDataEntry));
return;
}
foreach (var entry in ResourceDocument.ReadDataEntries(document)) {
DataEntries.Add(new DataEntryViewModel {
var vm = new DataEntryViewModel {
Key = entry.Key,
Value = entry.Value,
IsBinary = entry.IsBinary
});
};
vm.PropertyChanged += OnDataEntryPropertyChanged;
DataEntries.Add(vm);
}
SelectedDataEntry = DataEntries.FirstOrDefault(e => e.Key == keepKey)
?? DataEntries.FirstOrDefault();
OnPropertyChanged(nameof(HasSelectedDataEntry));
}
private void OnDataEntryPropertyChanged(object? sender, PropertyChangedEventArgs e) {
if (e.PropertyName is nameof(DataEntryViewModel.Key)
or nameof(DataEntryViewModel.Value)
or nameof(DataEntryViewModel.IsBinary))
IsDirty = true;
}
private async Task LoadLogsAsync() {

View File

@ -109,10 +109,10 @@ public partial class ConnectionWizardViewModel : ObservableObject {
public bool IsBasicAuth => SelectedAuthKind?.Id == "basic";
public string ClusterWatermark =>
string.IsNullOrWhiteSpace(ContextName) ? "blank → {context}-cluster" : ContextName.Trim() + "-cluster";
string.IsNullOrWhiteSpace(ContextName) ? "blank → same as context" : ContextName.Trim();
public string UserWatermark =>
string.IsNullOrWhiteSpace(ContextName) ? "blank → {context}-user" : ContextName.Trim() + "-user";
string.IsNullOrWhiteSpace(ContextName) ? "blank → same as context" : ContextName.Trim();
partial void OnStepChanged(int value) {
OnPropertyChanged(nameof(IsIdentityStep));

View File

@ -163,10 +163,24 @@ public partial class DataEntryViewModel : ObservableObject {
private string key = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ValuePreview))]
private string value = string.Empty;
[ObservableProperty]
private bool isBinary;
public string ValuePreview {
get {
if (string.IsNullOrEmpty(Value))
return "";
var end = Value.IndexOfAny(['\r', '\n']);
if (end < 0)
return Value;
return Value[..end] + "…";
}
}
}
public partial class CatalogItemViewModel : ObservableObject {