mirror of
https://github.com/MAKS-IT-COM/maksit-cluster-console.git
synced 2026-09-30 00:38:10 +02:00
(bugfix): stop kubeconfig bak churn and allow multiline secret data
This commit is contained in:
parent
14952c50b9
commit
bfe49c4603
@ -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
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
# MaksIT.ClusterConsole
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user