Part 09: Kubeconfig Management — IKubeconfigStore
"The wrong kubectl context is the most expensive 200 OK in IT."
Why
A Kubernetes user's ~/.kube/config is a multi-document YAML file containing clusters, contexts, and users. Most developers have one or two contexts. A freelancer with three clients has at least four (three clients plus a "personal" cluster). A platform engineer with environments per service has fifteen. The cost of running kubectl delete deployment payments against the wrong context is the cost of a production outage — the operation succeeds, the response is deployment.apps "payments" deleted, and you do not learn the mistake until the alerts fire.
The problem has three sub-problems:
- Generating the kubeconfig file for each cluster (the user does not write it by hand)
- Merging it into
~/.kube/configwithout breaking other contexts - Switching between contexts with confidence and visibility
The thesis: IKubeconfigStore is a typed plugin contract that handles all three. Each HomeLab K8s instance owns one kubeconfig context. The merge is non-destructive. The switch is one verb. The active context is visible in the user's prompt.
The shape
public interface IKubeconfigStore
{
string Name { get; }
Task<Result<KubeconfigBundle>> ReadAsync(string clusterName, CancellationToken ct);
Task<Result> WriteAsync(KubeconfigBundle bundle, CancellationToken ct);
Task<Result<IReadOnlyList<KubeconfigContext>>> ListAsync(CancellationToken ct);
Task<Result> SetActiveContextAsync(string contextName, CancellationToken ct);
Task<Result<string?>> GetActiveContextAsync(CancellationToken ct);
}
public sealed record KubeconfigBundle(
string ClusterName,
string ApiServerUrl,
byte[] CaCertificate,
string AdminClientCert,
string AdminClientKey,
string ContextName);
public sealed record KubeconfigContext(string Name, string Cluster, string User, string? Namespace);public interface IKubeconfigStore
{
string Name { get; }
Task<Result<KubeconfigBundle>> ReadAsync(string clusterName, CancellationToken ct);
Task<Result> WriteAsync(KubeconfigBundle bundle, CancellationToken ct);
Task<Result<IReadOnlyList<KubeconfigContext>>> ListAsync(CancellationToken ct);
Task<Result> SetActiveContextAsync(string contextName, CancellationToken ct);
Task<Result<string?>> GetActiveContextAsync(CancellationToken ct);
}
public sealed record KubeconfigBundle(
string ClusterName,
string ApiServerUrl,
byte[] CaCertificate,
string AdminClientCert,
string AdminClientKey,
string ContextName);
public sealed record KubeconfigContext(string Name, string Cluster, string User, string? Namespace);Two implementations ship with K8s.Dsl:
MergedKubeconfigStore (default)
Reads and writes ~/.kube/config non-destructively. Other contexts the user already had — their work cluster, their friend's cluster, their old GKE cluster — are preserved. K8s.Dsl entries are tagged with an annotation so it can find and remove its own without touching anything else.
[Injectable(ServiceLifetime.Singleton)]
public sealed class MergedKubeconfigStore : IKubeconfigStore
{
public string Name => "merged";
private readonly IFileSystem _fs;
private readonly IClock _clock;
private readonly string _path;
public MergedKubeconfigStore(IFileSystem fs, IClock clock)
{
_fs = fs;
_clock = clock;
_path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".kube", "config");
}
public async Task<Result> WriteAsync(KubeconfigBundle bundle, CancellationToken ct)
{
Kubeconfig existing;
if (_fs.File.Exists(_path))
{
var content = await _fs.File.ReadAllTextAsync(_path, ct);
existing = KubeconfigSerializer.Deserialize(content);
}
else
{
existing = new Kubeconfig();
}
// Strip any existing K8s.Dsl-managed entry for this cluster name
var marker = $"# managed-by-k8sdsl:{bundle.ClusterName}";
existing.Clusters.RemoveAll(c => c.Name == bundle.ClusterName);
existing.Users.RemoveAll(u => u.Name == $"{bundle.ClusterName}-admin");
existing.Contexts.RemoveAll(c => c.Name == bundle.ContextName);
// Add fresh entries with the marker comment
existing.Clusters.Add(new ClusterEntry
{
Name = bundle.ClusterName,
Cluster = new ClusterDetails
{
Server = bundle.ApiServerUrl,
CertificateAuthorityData = Convert.ToBase64String(bundle.CaCertificate)
},
Annotations = new() { ["k8sdsl.managed"] = "true", ["k8sdsl.updated"] = _clock.UtcNow.ToString("O") }
});
existing.Users.Add(new UserEntry
{
Name = $"{bundle.ClusterName}-admin",
User = new UserDetails
{
ClientCertificateData = bundle.AdminClientCert,
ClientKeyData = bundle.AdminClientKey
}
});
existing.Contexts.Add(new ContextEntry
{
Name = bundle.ContextName,
Context = new ContextDetails
{
Cluster = bundle.ClusterName,
User = $"{bundle.ClusterName}-admin"
}
});
var directory = Path.GetDirectoryName(_path)!;
if (!_fs.Directory.Exists(directory)) _fs.Directory.CreateDirectory(directory);
var rendered = KubeconfigSerializer.Serialize(existing);
await _fs.File.WriteAllTextAsync(_path, rendered, ct);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
return Result.Success();
}
public async Task<Result> SetActiveContextAsync(string contextName, CancellationToken ct)
{
if (!_fs.File.Exists(_path))
return Result.Failure($"no kubeconfig at {_path}");
var content = await _fs.File.ReadAllTextAsync(_path, ct);
var existing = KubeconfigSerializer.Deserialize(content);
if (existing.Contexts.All(c => c.Name != contextName))
return Result.Failure($"unknown context: {contextName}");
existing.CurrentContext = contextName;
await _fs.File.WriteAllTextAsync(_path, KubeconfigSerializer.Serialize(existing), ct);
return Result.Success();
}
public async Task<Result<string?>> GetActiveContextAsync(CancellationToken ct)
{
if (!_fs.File.Exists(_path))
return Result.Success<string?>(null);
var content = await _fs.File.ReadAllTextAsync(_path, ct);
var existing = KubeconfigSerializer.Deserialize(content);
return Result.Success<string?>(existing.CurrentContext);
}
// ReadAsync, ListAsync similar
}[Injectable(ServiceLifetime.Singleton)]
public sealed class MergedKubeconfigStore : IKubeconfigStore
{
public string Name => "merged";
private readonly IFileSystem _fs;
private readonly IClock _clock;
private readonly string _path;
public MergedKubeconfigStore(IFileSystem fs, IClock clock)
{
_fs = fs;
_clock = clock;
_path = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
".kube", "config");
}
public async Task<Result> WriteAsync(KubeconfigBundle bundle, CancellationToken ct)
{
Kubeconfig existing;
if (_fs.File.Exists(_path))
{
var content = await _fs.File.ReadAllTextAsync(_path, ct);
existing = KubeconfigSerializer.Deserialize(content);
}
else
{
existing = new Kubeconfig();
}
// Strip any existing K8s.Dsl-managed entry for this cluster name
var marker = $"# managed-by-k8sdsl:{bundle.ClusterName}";
existing.Clusters.RemoveAll(c => c.Name == bundle.ClusterName);
existing.Users.RemoveAll(u => u.Name == $"{bundle.ClusterName}-admin");
existing.Contexts.RemoveAll(c => c.Name == bundle.ContextName);
// Add fresh entries with the marker comment
existing.Clusters.Add(new ClusterEntry
{
Name = bundle.ClusterName,
Cluster = new ClusterDetails
{
Server = bundle.ApiServerUrl,
CertificateAuthorityData = Convert.ToBase64String(bundle.CaCertificate)
},
Annotations = new() { ["k8sdsl.managed"] = "true", ["k8sdsl.updated"] = _clock.UtcNow.ToString("O") }
});
existing.Users.Add(new UserEntry
{
Name = $"{bundle.ClusterName}-admin",
User = new UserDetails
{
ClientCertificateData = bundle.AdminClientCert,
ClientKeyData = bundle.AdminClientKey
}
});
existing.Contexts.Add(new ContextEntry
{
Name = bundle.ContextName,
Context = new ContextDetails
{
Cluster = bundle.ClusterName,
User = $"{bundle.ClusterName}-admin"
}
});
var directory = Path.GetDirectoryName(_path)!;
if (!_fs.Directory.Exists(directory)) _fs.Directory.CreateDirectory(directory);
var rendered = KubeconfigSerializer.Serialize(existing);
await _fs.File.WriteAllTextAsync(_path, rendered, ct);
if (!OperatingSystem.IsWindows())
File.SetUnixFileMode(_path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
return Result.Success();
}
public async Task<Result> SetActiveContextAsync(string contextName, CancellationToken ct)
{
if (!_fs.File.Exists(_path))
return Result.Failure($"no kubeconfig at {_path}");
var content = await _fs.File.ReadAllTextAsync(_path, ct);
var existing = KubeconfigSerializer.Deserialize(content);
if (existing.Contexts.All(c => c.Name != contextName))
return Result.Failure($"unknown context: {contextName}");
existing.CurrentContext = contextName;
await _fs.File.WriteAllTextAsync(_path, KubeconfigSerializer.Serialize(existing), ct);
return Result.Success();
}
public async Task<Result<string?>> GetActiveContextAsync(CancellationToken ct)
{
if (!_fs.File.Exists(_path))
return Result.Success<string?>(null);
var content = await _fs.File.ReadAllTextAsync(_path, ct);
var existing = KubeconfigSerializer.Deserialize(content);
return Result.Success<string?>(existing.CurrentContext);
}
// ReadAsync, ListAsync similar
}IsolatedKubeconfigStore
Writes a per-instance kubeconfig file in the instance's working directory (./.homelab/kubeconfig) and never touches ~/.kube/config. Use this when you want hard isolation between instances and you are willing to set KUBECONFIG=./.homelab/kubeconfig in your shell.
[Injectable(ServiceLifetime.Singleton)]
public sealed class IsolatedKubeconfigStore : IKubeconfigStore
{
public string Name => "isolated";
private readonly IFileSystem _fs;
private readonly DirectoryInfo _instanceDir;
public async Task<Result> WriteAsync(KubeconfigBundle bundle, CancellationToken ct)
{
var path = Path.Combine(_instanceDir.FullName, ".homelab", "kubeconfig");
// ... write a single-context file with no merge
}
// ...
}[Injectable(ServiceLifetime.Singleton)]
public sealed class IsolatedKubeconfigStore : IKubeconfigStore
{
public string Name => "isolated";
private readonly IFileSystem _fs;
private readonly DirectoryInfo _instanceDir;
public async Task<Result> WriteAsync(KubeconfigBundle bundle, CancellationToken ct)
{
var path = Path.Combine(_instanceDir.FullName, ".homelab", "kubeconfig");
// ... write a single-context file with no merge
}
// ...
}The user picks via config:
k8s:
kubeconfig:
store: merged # default; or "isolated"k8s:
kubeconfig:
store: merged # default; or "isolated"Most users use merged because they want kubectl to find the contexts without setting KUBECONFIG. Paranoid users use isolated to guarantee no cross-instance leakage at the kubeconfig layer.
The homelab k8s use-context verb
The verb is the user-facing surface for switching:
[Injectable(ServiceLifetime.Singleton)]
[VerbGroup("k8s")]
public sealed class K8sUseContextCommand : IHomeLabVerbCommand
{
private readonly IMediator _mediator;
private readonly IHomeLabConsole _console;
public K8sUseContextCommand(IMediator mediator, IHomeLabConsole console)
{
_mediator = mediator;
_console = console;
}
public Command Build()
{
var name = new Argument<string>("name", "the context name");
var cmd = new Command("use-context", "Switch the active kubectl context");
cmd.AddArgument(name);
cmd.SetHandler(async (string n) =>
{
var result = await _mediator.SendAsync(new K8sUseContextRequest(n), CancellationToken.None);
_console.Render(result);
Environment.ExitCode = result.IsSuccess ? 0 : 1;
}, name);
return cmd;
}
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class K8sUseContextRequestHandler : IRequestHandler<K8sUseContextRequest, Result<K8sUseContextResponse>>
{
private readonly IKubeconfigStore _store;
private readonly IHomeLabEventBus _events;
private readonly IClock _clock;
public async Task<Result<K8sUseContextResponse>> HandleAsync(K8sUseContextRequest req, CancellationToken ct)
{
var setResult = await _store.SetActiveContextAsync(req.ContextName, ct);
if (setResult.IsFailure) return setResult.Map<K8sUseContextResponse>();
await _events.PublishAsync(new KubeconfigContextSwitched(req.ContextName, _clock.UtcNow), ct);
return Result.Success(new K8sUseContextResponse(req.ContextName));
}
}[Injectable(ServiceLifetime.Singleton)]
[VerbGroup("k8s")]
public sealed class K8sUseContextCommand : IHomeLabVerbCommand
{
private readonly IMediator _mediator;
private readonly IHomeLabConsole _console;
public K8sUseContextCommand(IMediator mediator, IHomeLabConsole console)
{
_mediator = mediator;
_console = console;
}
public Command Build()
{
var name = new Argument<string>("name", "the context name");
var cmd = new Command("use-context", "Switch the active kubectl context");
cmd.AddArgument(name);
cmd.SetHandler(async (string n) =>
{
var result = await _mediator.SendAsync(new K8sUseContextRequest(n), CancellationToken.None);
_console.Render(result);
Environment.ExitCode = result.IsSuccess ? 0 : 1;
}, name);
return cmd;
}
}
[Injectable(ServiceLifetime.Singleton)]
public sealed class K8sUseContextRequestHandler : IRequestHandler<K8sUseContextRequest, Result<K8sUseContextResponse>>
{
private readonly IKubeconfigStore _store;
private readonly IHomeLabEventBus _events;
private readonly IClock _clock;
public async Task<Result<K8sUseContextResponse>> HandleAsync(K8sUseContextRequest req, CancellationToken ct)
{
var setResult = await _store.SetActiveContextAsync(req.ContextName, ct);
if (setResult.IsFailure) return setResult.Map<K8sUseContextResponse>();
await _events.PublishAsync(new KubeconfigContextSwitched(req.ContextName, _clock.UtcNow), ct);
return Result.Success(new K8sUseContextResponse(req.ContextName));
}
}The verb is the canonical thin shell from homelab-docker Part 03. Five lines: parse, send, render, exit. The handler reads the store, validates the context, sets it, publishes the event.
The published KubeconfigContextSwitched event is the bridge to shell prompt integrations (kube-ps1, starship, etc.). A subscriber that watches the event bus can update the prompt without polling kubectl config current-context.
The integration: kube-ps1, starship, oh-my-posh
The KubeconfigContextSwitched event also lands in a small file at ~/.homelab/active-context on every switch. Shell prompt integrations read that file (one syscall, no kubectl invocation) and display the current context. Sample starship config:
[custom.k8s]
command = "cat ~/.homelab/active-context 2>/dev/null"
when = "test -f ~/.homelab/active-context"
format = "[$output](bold cyan) "[custom.k8s]
command = "cat ~/.homelab/active-context 2>/dev/null"
when = "test -f ~/.homelab/active-context"
format = "[$output](bold cyan) "The user sees acme or globex in their prompt, in real time, with no shell hook overhead.
The test
public sealed class MergedKubeconfigStoreTests
{
[Fact]
public async Task write_preserves_existing_unmanaged_contexts()
{
var fs = new MockFileSystem();
fs.AddFile("/home/me/.kube/config", new MockFileData("""
apiVersion: v1
kind: Config
clusters:
- name: external-prod
cluster: { server: https://prod.example.com }
users:
- name: external-prod-user
user: { token: abc }
contexts:
- name: external-prod
context: { cluster: external-prod, user: external-prod-user }
current-context: external-prod
"""));
var store = new MergedKubeconfigStore(fs, new FakeClock(DateTimeOffset.UtcNow), homeOverride: "/home/me");
var bundle = TestKubeconfigBundle("acme", apiUrl: "https://api.acme.lab");
var result = await store.WriteAsync(bundle, default);
result.IsSuccess.Should().BeTrue();
var content = fs.File.ReadAllText("/home/me/.kube/config");
content.Should().Contain("name: external-prod");
content.Should().Contain("name: acme");
}
[Fact]
public async Task write_overwrites_existing_managed_entry_for_same_cluster()
{
var fs = new MockFileSystem();
var store = new MergedKubeconfigStore(fs, new FakeClock(DateTimeOffset.UtcNow), homeOverride: "/home/me");
await store.WriteAsync(TestKubeconfigBundle("acme", apiUrl: "https://api.acme.lab"), default);
await store.WriteAsync(TestKubeconfigBundle("acme", apiUrl: "https://api.acme.lab.NEW"), default);
var content = fs.File.ReadAllText("/home/me/.kube/config");
content.Should().Contain("api.acme.lab.NEW");
content.Should().NotContain("api.acme.lab\n").And.NotContain("api.acme.lab\""); // old API URL gone
}
[Fact]
public async Task set_active_context_publishes_event_and_updates_file()
{
var fs = new MockFileSystem();
var store = new MergedKubeconfigStore(fs, new FakeClock(DateTimeOffset.UtcNow), homeOverride: "/home/me");
await store.WriteAsync(TestKubeconfigBundle("acme"), default);
await store.WriteAsync(TestKubeconfigBundle("globex"), default);
var bus = new RecordingEventBus();
var handler = new K8sUseContextRequestHandler(store, bus, new FakeClock(DateTimeOffset.UtcNow));
await handler.HandleAsync(new K8sUseContextRequest("globex"), default);
var current = await store.GetActiveContextAsync(default);
current.Value.Should().Be("globex");
bus.Recorded.OfType<KubeconfigContextSwitched>().Should().ContainSingle();
}
[Fact]
public async Task set_active_context_with_unknown_name_returns_failure()
{
var fs = new MockFileSystem();
var store = new MergedKubeconfigStore(fs, new FakeClock(DateTimeOffset.UtcNow), homeOverride: "/home/me");
await store.WriteAsync(TestKubeconfigBundle("acme"), default);
var result = await store.SetActiveContextAsync("nonexistent", default);
result.IsFailure.Should().BeTrue();
result.Errors.Should().Contain(e => e.Contains("nonexistent"));
}
}public sealed class MergedKubeconfigStoreTests
{
[Fact]
public async Task write_preserves_existing_unmanaged_contexts()
{
var fs = new MockFileSystem();
fs.AddFile("/home/me/.kube/config", new MockFileData("""
apiVersion: v1
kind: Config
clusters:
- name: external-prod
cluster: { server: https://prod.example.com }
users:
- name: external-prod-user
user: { token: abc }
contexts:
- name: external-prod
context: { cluster: external-prod, user: external-prod-user }
current-context: external-prod
"""));
var store = new MergedKubeconfigStore(fs, new FakeClock(DateTimeOffset.UtcNow), homeOverride: "/home/me");
var bundle = TestKubeconfigBundle("acme", apiUrl: "https://api.acme.lab");
var result = await store.WriteAsync(bundle, default);
result.IsSuccess.Should().BeTrue();
var content = fs.File.ReadAllText("/home/me/.kube/config");
content.Should().Contain("name: external-prod");
content.Should().Contain("name: acme");
}
[Fact]
public async Task write_overwrites_existing_managed_entry_for_same_cluster()
{
var fs = new MockFileSystem();
var store = new MergedKubeconfigStore(fs, new FakeClock(DateTimeOffset.UtcNow), homeOverride: "/home/me");
await store.WriteAsync(TestKubeconfigBundle("acme", apiUrl: "https://api.acme.lab"), default);
await store.WriteAsync(TestKubeconfigBundle("acme", apiUrl: "https://api.acme.lab.NEW"), default);
var content = fs.File.ReadAllText("/home/me/.kube/config");
content.Should().Contain("api.acme.lab.NEW");
content.Should().NotContain("api.acme.lab\n").And.NotContain("api.acme.lab\""); // old API URL gone
}
[Fact]
public async Task set_active_context_publishes_event_and_updates_file()
{
var fs = new MockFileSystem();
var store = new MergedKubeconfigStore(fs, new FakeClock(DateTimeOffset.UtcNow), homeOverride: "/home/me");
await store.WriteAsync(TestKubeconfigBundle("acme"), default);
await store.WriteAsync(TestKubeconfigBundle("globex"), default);
var bus = new RecordingEventBus();
var handler = new K8sUseContextRequestHandler(store, bus, new FakeClock(DateTimeOffset.UtcNow));
await handler.HandleAsync(new K8sUseContextRequest("globex"), default);
var current = await store.GetActiveContextAsync(default);
current.Value.Should().Be("globex");
bus.Recorded.OfType<KubeconfigContextSwitched>().Should().ContainSingle();
}
[Fact]
public async Task set_active_context_with_unknown_name_returns_failure()
{
var fs = new MockFileSystem();
var store = new MergedKubeconfigStore(fs, new FakeClock(DateTimeOffset.UtcNow), homeOverride: "/home/me");
await store.WriteAsync(TestKubeconfigBundle("acme"), default);
var result = await store.SetActiveContextAsync("nonexistent", default);
result.IsFailure.Should().BeTrue();
result.Errors.Should().Contain(e => e.Contains("nonexistent"));
}
}What this gives you that hand-managed ~/.kube/config doesn't
A hand-managed kubeconfig is kubectl config set-cluster ... plus kubectl config set-credentials ... plus kubectl config set-context ... plus a hand-edit when something breaks. Every line is a string. Every typo silently merges into the file. Removing a stale context is kubectl config delete-context, which works but does not garbage-collect the cluster/user entries.
A typed IKubeconfigStore with two implementations gives you, for the same surface area:
- Generated kubeconfig from the typed
KubeconfigBundle - Non-destructive merge that preserves user-added contexts
- Idempotent overwrite of K8s.Dsl-managed entries
- A typed
homelab k8s use-contextverb with the standard thin-CLI pattern - An event bus signal that prompt integrations can pick up
- A test suite that exercises both stores against fakes
The bargain pays back the first time you switch from one client's cluster to another in two seconds and your prompt updates to show which one you are on.