From 810ff9032ce6064329c7ef5fb6319ad69d2f5ecb Mon Sep 17 00:00:00 2001 From: Eddie Wassef Date: Sat, 24 Jan 2026 14:34:18 -0600 Subject: [PATCH] Add UpdateClustersCommand for cluster certificate updates Introduces UpdateClustersCommand to update cluster certificates across VDK clusters, including logic to update TLS secrets and trigger gateway restarts. Integrates the new command into the update command group and registers it in the service provider. Also adds an alias 'k8s' to UpdateKindVersionInfoCommand. --- cli/src/Vdk/Commands/UpdateClustersCommand.cs | 398 ++++++++++++++++++ cli/src/Vdk/Commands/UpdateCommand.cs | 5 +- .../Commands/UpdateKindVersionInfoCommand.cs | 1 + cli/src/Vdk/ServiceProviderBuilder.cs | 1 + 4 files changed, 404 insertions(+), 1 deletion(-) create mode 100644 cli/src/Vdk/Commands/UpdateClustersCommand.cs diff --git a/cli/src/Vdk/Commands/UpdateClustersCommand.cs b/cli/src/Vdk/Commands/UpdateClustersCommand.cs new file mode 100644 index 0000000..0516016 --- /dev/null +++ b/cli/src/Vdk/Commands/UpdateClustersCommand.cs @@ -0,0 +1,398 @@ +using System.CommandLine; +using System.IO.Abstractions; +using k8s.Models; +using KubeOps.KubernetesClient; +using Vdk.Services; +using IConsole = Vdk.Services.IConsole; + +namespace Vdk.Commands; + +public class UpdateClustersCommand : Command +{ + private readonly IConsole _console; + private readonly IKindClient _kind; + private readonly IFileSystem _fileSystem; + private readonly Func _clientFunc; + + public UpdateClustersCommand( + IConsole console, + IKindClient kind, + IFileSystem fileSystem, + Func clientFunc) + : base("clusters", "Update cluster configurations (certificates, etc.)") + { + _console = console; + _kind = kind; + _fileSystem = fileSystem; + _clientFunc = clientFunc; + + var verboseOption = new Option("--verbose") { Description = "Enable verbose output for debugging" }; + verboseOption.Aliases.Add("-v"); + + Options.Add(verboseOption); + SetAction(parseResult => InvokeAsync(parseResult.GetValue(verboseOption))); + } + + public async Task InvokeAsync(bool verbose = false) + { + // Load local certificates + var fullChainPath = _fileSystem.Path.Combine("Certs", "fullchain.pem"); + var privKeyPath = _fileSystem.Path.Combine("Certs", "privkey.pem"); + + if (!_fileSystem.File.Exists(fullChainPath) || !_fileSystem.File.Exists(privKeyPath)) + { + _console.WriteError("Certificate files not found. Expected: Certs/fullchain.pem and Certs/privkey.pem"); + return; + } + + var localCert = await _fileSystem.File.ReadAllBytesAsync(fullChainPath); + var localKey = await _fileSystem.File.ReadAllBytesAsync(privKeyPath); + + if (verbose) + { + _console.WriteLine($"[DEBUG] Local certificate size: {localCert.Length} bytes"); + _console.WriteLine($"[DEBUG] Local private key size: {localKey.Length} bytes"); + } + + // Get all VDK clusters + var clusters = _kind.ListClusters(); + var vdkClusters = clusters.Where(c => c.isVdk).ToList(); + + if (vdkClusters.Count == 0) + { + _console.WriteWarning("No VDK clusters found."); + return; + } + + _console.WriteLine($"Found {vdkClusters.Count} VDK cluster(s) to check."); + + foreach (var cluster in vdkClusters) + { + await UpdateClusterCertificates(cluster.name, localCert, localKey, verbose); + } + + _console.WriteLine("Cluster certificate update complete."); + } + + private async Task UpdateClusterCertificates(string clusterName, byte[] localCert, byte[] localKey, bool verbose) + { + _console.WriteLine($"Checking cluster: {clusterName}"); + + IKubernetesClient client; + try + { + client = _clientFunc(clusterName); + } + catch (Exception ex) + { + _console.WriteError($"Failed to connect to cluster '{clusterName}': {ex.Message}"); + if (verbose) + { + _console.WriteLine($"[DEBUG] Exception: {ex}"); + } + return; + } + + // Get all namespaces + IList namespaces; + try + { + namespaces = client.List(); + } + catch (Exception ex) + { + _console.WriteError($"Failed to list namespaces in cluster '{clusterName}': {ex.Message}"); + if (verbose) + { + _console.WriteLine($"[DEBUG] Exception: {ex}"); + } + return; + } + + if (verbose) + { + _console.WriteLine($"[DEBUG] Found {namespaces.Count} namespace(s) in cluster '{clusterName}'"); + } + + var updatedSecrets = new List<(string Namespace, string SecretName)>(); + + foreach (var ns in namespaces) + { + var nsName = ns.Metadata?.Name; + if (string.IsNullOrEmpty(nsName)) continue; + + if (verbose) + { + _console.WriteLine($"[DEBUG] Scanning namespace: {nsName}"); + } + + await UpdateNamespaceCertificates(client, clusterName, nsName, localCert, localKey, verbose, updatedSecrets); + } + + // Restart gateways if any secrets were updated + if (updatedSecrets.Count > 0) + { + _console.WriteLine($" Updated {updatedSecrets.Count} secret(s). Restarting affected gateways..."); + await RestartGateways(client, clusterName, updatedSecrets, verbose); + } + else + { + _console.WriteLine($" All certificates are up to date."); + } + } + + private async Task UpdateNamespaceCertificates( + IKubernetesClient client, + string clusterName, + string nsName, + byte[] localCert, + byte[] localKey, + bool verbose, + List<(string Namespace, string SecretName)> updatedSecrets) + { + IList secrets; + try + { + secrets = client.List(nsName); + } + catch (Exception ex) + { + if (verbose) + { + _console.WriteLine($"[DEBUG] Failed to list secrets in namespace '{nsName}': {ex.Message}"); + } + return; + } + + // Filter to TLS secrets only + var tlsSecrets = secrets.Where(s => s.Type == "kubernetes.io/tls").ToList(); + + if (verbose && tlsSecrets.Count > 0) + { + _console.WriteLine($"[DEBUG] Found {tlsSecrets.Count} TLS secret(s) in namespace '{nsName}'"); + } + + foreach (var secret in tlsSecrets) + { + var secretName = secret.Metadata?.Name; + if (string.IsNullOrEmpty(secretName)) continue; + + // Only update secrets that are managed by Vega: + // 1. Named "dev-tls" (the standard Vega TLS secret) + // 2. Have the annotation "vega.dev/managed=true" + var annotations = secret.Metadata?.Annotations; + bool isVegaManaged = secretName == "dev-tls" || + (annotations != null && annotations.TryGetValue("vega.dev/managed", out var managed) && managed == "true"); + + if (!isVegaManaged) + { + if (verbose) + { + _console.WriteLine($"[DEBUG] Secret '{nsName}/{secretName}' is not Vega-managed, skipping."); + } + continue; + } + + if (secret.Data == null) + { + if (verbose) + { + _console.WriteLine($"[DEBUG] Secret '{nsName}/{secretName}' has no data, skipping."); + } + continue; + } + + // Get current cert and key from secret + secret.Data.TryGetValue("tls.crt", out var currentCert); + secret.Data.TryGetValue("tls.key", out var currentKey); + + if (currentCert == null || currentKey == null) + { + if (verbose) + { + _console.WriteLine($"[DEBUG] Secret '{nsName}/{secretName}' is missing tls.crt or tls.key, skipping."); + } + continue; + } + + // Compare certificates + bool certNeedsUpdate = !currentCert.SequenceEqual(localCert); + bool keyNeedsUpdate = !currentKey.SequenceEqual(localKey); + + if (verbose) + { + _console.WriteLine($"[DEBUG] Secret '{nsName}/{secretName}': cert match={!certNeedsUpdate}, key match={!keyNeedsUpdate}"); + } + + if (certNeedsUpdate || keyNeedsUpdate) + { + _console.WriteLine($" Updating secret: {nsName}/{secretName}"); + + try + { + secret.Data["tls.crt"] = localCert; + secret.Data["tls.key"] = localKey; + client.Update(secret); + updatedSecrets.Add((nsName, secretName)); + + if (verbose) + { + _console.WriteLine($"[DEBUG] Successfully updated secret '{nsName}/{secretName}'"); + } + } + catch (Exception ex) + { + _console.WriteError($"Failed to update secret '{nsName}/{secretName}': {ex.Message}"); + if (verbose) + { + _console.WriteLine($"[DEBUG] Exception: {ex}"); + } + } + } + } + + await Task.CompletedTask; + } + + private async Task RestartGateways( + IKubernetesClient client, + string clusterName, + List<(string Namespace, string SecretName)> updatedSecrets, + bool verbose) + { + // Group by namespace for efficiency + var namespaces = updatedSecrets.Select(s => s.Namespace).Distinct(); + + foreach (var nsName in namespaces) + { + var secretsInNs = updatedSecrets.Where(s => s.Namespace == nsName).Select(s => s.SecretName).ToHashSet(); + + // Try to find Gateway resources (gateway.networking.k8s.io) + await RestartGatewayApiGateways(client, nsName, secretsInNs, verbose); + + // Also check for Ingress resources that might reference the secrets + await RestartIngressControllers(client, nsName, secretsInNs, verbose); + } + } + + private async Task RestartGatewayApiGateways( + IKubernetesClient client, + string nsName, + HashSet secretNames, + bool verbose) + { + try + { + // Get deployments in the namespace that might be gateway controllers + var deployments = client.List(nsName); + + foreach (var deployment in deployments) + { + var deploymentName = deployment.Metadata?.Name ?? ""; + + // Look for gateway-related deployments + if (deploymentName.Contains("gateway", StringComparison.OrdinalIgnoreCase) || + deploymentName.Contains("envoy", StringComparison.OrdinalIgnoreCase) || + deploymentName.Contains("ingress", StringComparison.OrdinalIgnoreCase)) + { + if (verbose) + { + _console.WriteLine($"[DEBUG] Found potential gateway deployment: {nsName}/{deploymentName}"); + } + + // Trigger a rollout restart by updating an annotation + await RolloutRestartDeployment(client, deployment, verbose); + } + } + } + catch (Exception ex) + { + if (verbose) + { + _console.WriteLine($"[DEBUG] Failed to check Gateway API gateways in namespace '{nsName}': {ex.Message}"); + } + } + } + + private async Task RestartIngressControllers( + IKubernetesClient client, + string nsName, + HashSet secretNames, + bool verbose) + { + try + { + // Check if any ingresses reference the updated secrets + var ingresses = client.List(nsName); + + foreach (var ingress in ingresses) + { + var ingressName = ingress.Metadata?.Name ?? ""; + var tls = ingress.Spec?.Tls; + + if (tls == null) continue; + + foreach (var tlsEntry in tls) + { + if (!string.IsNullOrEmpty(tlsEntry.SecretName) && secretNames.Contains(tlsEntry.SecretName)) + { + _console.WriteLine($" Ingress '{nsName}/{ingressName}' references updated secret '{tlsEntry.SecretName}'"); + + if (verbose) + { + _console.WriteLine($"[DEBUG] Ingress controller should automatically pick up the new certificate"); + } + } + } + } + } + catch (Exception ex) + { + if (verbose) + { + _console.WriteLine($"[DEBUG] Failed to check ingresses in namespace '{nsName}': {ex.Message}"); + } + } + + await Task.CompletedTask; + } + + private async Task RolloutRestartDeployment(IKubernetesClient client, V1Deployment deployment, bool verbose) + { + var deploymentName = deployment.Metadata?.Name; + var nsName = deployment.Metadata?.NamespaceProperty; + + if (string.IsNullOrEmpty(deploymentName) || string.IsNullOrEmpty(nsName)) + return; + + try + { + // Add/update restart annotation to trigger rollout + deployment.Spec ??= new V1DeploymentSpec(); + deployment.Spec.Template ??= new V1PodTemplateSpec(); + deployment.Spec.Template.Metadata ??= new V1ObjectMeta(); + deployment.Spec.Template.Metadata.Annotations ??= new Dictionary(); + + var restartTime = DateTime.UtcNow.ToString("o"); + deployment.Spec.Template.Metadata.Annotations["vega.dev/restartedAt"] = restartTime; + + client.Update(deployment); + _console.WriteLine($" Restarted deployment: {nsName}/{deploymentName}"); + + if (verbose) + { + _console.WriteLine($"[DEBUG] Set restart annotation to '{restartTime}'"); + } + } + catch (Exception ex) + { + _console.WriteWarning($"Failed to restart deployment '{nsName}/{deploymentName}': {ex.Message}"); + if (verbose) + { + _console.WriteLine($"[DEBUG] Exception: {ex}"); + } + } + + await Task.CompletedTask; + } +} diff --git a/cli/src/Vdk/Commands/UpdateCommand.cs b/cli/src/Vdk/Commands/UpdateCommand.cs index 105bea3..60dfd13 100644 --- a/cli/src/Vdk/Commands/UpdateCommand.cs +++ b/cli/src/Vdk/Commands/UpdateCommand.cs @@ -4,9 +4,12 @@ namespace Vdk.Commands; public class UpdateCommand: Command { - public UpdateCommand(UpdateKindVersionInfoCommand updateKindVersionInfo) : base("update", + public UpdateCommand( + UpdateKindVersionInfoCommand updateKindVersionInfo, + UpdateClustersCommand updateClusters) : base("update", "Update resources in vega development environment") { Subcommands.Add(updateKindVersionInfo); + Subcommands.Add(updateClusters); } } \ No newline at end of file diff --git a/cli/src/Vdk/Commands/UpdateKindVersionInfoCommand.cs b/cli/src/Vdk/Commands/UpdateKindVersionInfoCommand.cs index 7295afa..e0a5d8c 100644 --- a/cli/src/Vdk/Commands/UpdateKindVersionInfoCommand.cs +++ b/cli/src/Vdk/Commands/UpdateKindVersionInfoCommand.cs @@ -11,6 +11,7 @@ public UpdateKindVersionInfoCommand(IKindVersionInfoService client) : base("kind "Update kind version info (Maps kind and Kubernetes versions/enables new releases of kubernetes in vega)") { _client = client; + Aliases.Add("k8s"); SetAction(_ => InvokeAsync()); } diff --git a/cli/src/Vdk/ServiceProviderBuilder.cs b/cli/src/Vdk/ServiceProviderBuilder.cs index 05138a7..06162a0 100644 --- a/cli/src/Vdk/ServiceProviderBuilder.cs +++ b/cli/src/Vdk/ServiceProviderBuilder.cs @@ -49,6 +49,7 @@ public static IServiceProvider Build() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton()