diff --git a/src/KubernetesClient.Kubectl/Beta/AsyncKubectl.Rollout.cs b/src/KubernetesClient.Kubectl/Beta/AsyncKubectl.Rollout.cs new file mode 100644 index 00000000..d5cc63d3 --- /dev/null +++ b/src/KubernetesClient.Kubectl/Beta/AsyncKubectl.Rollout.cs @@ -0,0 +1,481 @@ +using Json.Patch; +using k8s.Models; +using System.Text.Json; + +namespace k8s.kubectl.beta; + +public partial class AsyncKubectl +{ + private const string RestartedAtAnnotation = "kubectl.kubernetes.io/restartedAt"; + private const string RevisionAnnotation = "deployment.kubernetes.io/revision"; + private const string ChangeCauseAnnotation = "kubernetes.io/change-cause"; + + private static string BuildLabelSelector(IDictionary matchLabels) + { + return string.Join(",", matchLabels.Select(kvp => $"{kvp.Key}={kvp.Value}")); + } + + /// + /// Restart a workload resource by adding a restart annotation to trigger a rollout. + /// + /// The type of workload resource (V1Deployment, V1DaemonSet, or V1StatefulSet). + /// The name of the resource. + /// The namespace of the resource. + /// Cancellation token. + /// A task representing the asynchronous operation. + public async Task RolloutRestartAsync(string name, string @namespace, CancellationToken cancellationToken = default) + where T : IKubernetesObject + { + if (typeof(T) == typeof(V1Deployment)) + { + var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + var old = JsonSerializer.SerializeToDocument(deployment); + + deployment.Spec.Template.Metadata ??= new V1ObjectMeta(); + deployment.Spec.Template.Metadata.Annotations ??= new Dictionary(); + deployment.Spec.Template.Metadata.Annotations[RestartedAtAnnotation] = DateTime.UtcNow.ToString("o"); + + var patch = old.CreatePatch(deployment); + await client.AppsV1.PatchNamespacedDeploymentAsync(new V1Patch(patch, V1Patch.PatchType.JsonPatch), name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + } + else if (typeof(T) == typeof(V1DaemonSet)) + { + var daemonSet = await client.AppsV1.ReadNamespacedDaemonSetAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + var old = JsonSerializer.SerializeToDocument(daemonSet); + + daemonSet.Spec.Template.Metadata ??= new V1ObjectMeta(); + daemonSet.Spec.Template.Metadata.Annotations ??= new Dictionary(); + daemonSet.Spec.Template.Metadata.Annotations[RestartedAtAnnotation] = DateTime.UtcNow.ToString("o"); + + var patch = old.CreatePatch(daemonSet); + await client.AppsV1.PatchNamespacedDaemonSetAsync(new V1Patch(patch, V1Patch.PatchType.JsonPatch), name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + } + else if (typeof(T) == typeof(V1StatefulSet)) + { + var statefulSet = await client.AppsV1.ReadNamespacedStatefulSetAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + var old = JsonSerializer.SerializeToDocument(statefulSet); + + statefulSet.Spec.Template.Metadata ??= new V1ObjectMeta(); + statefulSet.Spec.Template.Metadata.Annotations ??= new Dictionary(); + statefulSet.Spec.Template.Metadata.Annotations[RestartedAtAnnotation] = DateTime.UtcNow.ToString("o"); + + var patch = old.CreatePatch(statefulSet); + await client.AppsV1.PatchNamespacedStatefulSetAsync(new V1Patch(patch, V1Patch.PatchType.JsonPatch), name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + } + else + { + throw new ArgumentException($"Unsupported resource type: {typeof(T).Name}. Only V1Deployment, V1DaemonSet, and V1StatefulSet are supported.", nameof(T)); + } + } + + /// + /// Get the rollout status of a workload resource. + /// + /// The type of workload resource (V1Deployment, V1DaemonSet, or V1StatefulSet). + /// The name of the resource. + /// The namespace of the resource. + /// Cancellation token. + /// A string describing the rollout status. + public async Task RolloutStatusAsync(string name, string @namespace, CancellationToken cancellationToken = default) + where T : IKubernetesObject + { + if (typeof(T) == typeof(V1Deployment)) + { + return await RolloutStatusDeploymentInternalAsync(name, @namespace, cancellationToken).ConfigureAwait(false); + } + else if (typeof(T) == typeof(V1DaemonSet)) + { + return await RolloutStatusDaemonSetInternalAsync(name, @namespace, cancellationToken).ConfigureAwait(false); + } + else if (typeof(T) == typeof(V1StatefulSet)) + { + return await RolloutStatusStatefulSetInternalAsync(name, @namespace, cancellationToken).ConfigureAwait(false); + } + else + { + throw new ArgumentException($"Unsupported resource type: {typeof(T).Name}. Only V1Deployment, V1DaemonSet, and V1StatefulSet are supported.", nameof(T)); + } + } + + /// + /// Pause a Deployment rollout. + /// + /// The type of resource (must be V1Deployment). + /// The name of the Deployment. + /// The namespace of the Deployment. + /// Cancellation token. + /// A task representing the asynchronous operation. + public async Task RolloutPauseAsync(string name, string @namespace, CancellationToken cancellationToken = default) + where T : IKubernetesObject + { + if (typeof(T) != typeof(V1Deployment)) + { + throw new ArgumentException($"Pause is only supported for V1Deployment, not {typeof(T).Name}.", nameof(T)); + } + + var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + var old = JsonSerializer.SerializeToDocument(deployment); + + deployment.Spec.Paused = true; + + var patch = old.CreatePatch(deployment); + await client.AppsV1.PatchNamespacedDeploymentAsync(new V1Patch(patch, V1Patch.PatchType.JsonPatch), name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Resume a paused Deployment rollout. + /// + /// The type of resource (must be V1Deployment). + /// The name of the Deployment. + /// The namespace of the Deployment. + /// Cancellation token. + /// A task representing the asynchronous operation. + public async Task RolloutResumeAsync(string name, string @namespace, CancellationToken cancellationToken = default) + where T : IKubernetesObject + { + if (typeof(T) != typeof(V1Deployment)) + { + throw new ArgumentException($"Resume is only supported for V1Deployment, not {typeof(T).Name}.", nameof(T)); + } + + var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + var old = JsonSerializer.SerializeToDocument(deployment); + + deployment.Spec.Paused = false; + + var patch = old.CreatePatch(deployment); + await client.AppsV1.PatchNamespacedDeploymentAsync(new V1Patch(patch, V1Patch.PatchType.JsonPatch), name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + /// + /// Undo a Deployment rollout to a previous revision. + /// + /// The type of resource (must be V1Deployment). + /// The name of the Deployment. + /// The namespace of the Deployment. + /// The revision to roll back to. If 0 or not specified, rolls back to the previous revision. + /// Cancellation token. + /// A task representing the asynchronous operation. + public async Task RolloutUndoAsync(string name, string @namespace, long? toRevision = null, CancellationToken cancellationToken = default) + where T : IKubernetesObject + { + if (typeof(T) != typeof(V1Deployment)) + { + throw new ArgumentException($"Undo is only supported for V1Deployment, not {typeof(T).Name}.", nameof(T)); + } + + await RolloutUndoDeploymentInternalAsync(name, @namespace, toRevision, cancellationToken).ConfigureAwait(false); + } + + /// + /// Get the rollout history of a workload resource. + /// + /// The type of workload resource (V1Deployment, V1DaemonSet, or V1StatefulSet). + /// The name of the resource. + /// The namespace of the resource. + /// Cancellation token. + /// A list of revision history entries. + public async Task> RolloutHistoryAsync(string name, string @namespace, CancellationToken cancellationToken = default) + where T : IKubernetesObject + { + if (typeof(T) == typeof(V1Deployment)) + { + return await RolloutHistoryDeploymentInternalAsync(name, @namespace, cancellationToken).ConfigureAwait(false); + } + else if (typeof(T) == typeof(V1DaemonSet)) + { + return await RolloutHistoryDaemonSetInternalAsync(name, @namespace, cancellationToken).ConfigureAwait(false); + } + else if (typeof(T) == typeof(V1StatefulSet)) + { + return await RolloutHistoryStatefulSetInternalAsync(name, @namespace, cancellationToken).ConfigureAwait(false); + } + else + { + throw new ArgumentException($"Unsupported resource type: {typeof(T).Name}. Only V1Deployment, V1DaemonSet, and V1StatefulSet are supported.", nameof(T)); + } + } + + // Internal implementation methods + private async Task RolloutStatusDeploymentInternalAsync(string name, string @namespace, CancellationToken cancellationToken) + { + var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + + var status = deployment.Status; + var spec = deployment.Spec; + + if (status == null) + { + return "Waiting for deployment spec update to be observed..."; + } + + if (status.ObservedGeneration < deployment.Metadata.Generation) + { + return "Waiting for deployment spec update to be observed..."; + } + + if (status.UpdatedReplicas < spec.Replicas) + { + return $"Waiting for deployment \"{name}\" rollout to finish: {status.UpdatedReplicas ?? 0} out of {spec.Replicas ?? 0} new replicas have been updated..."; + } + + if (status.Replicas > status.UpdatedReplicas) + { + return $"Waiting for deployment \"{name}\" rollout to finish: {status.Replicas - status.UpdatedReplicas} old replicas are pending termination..."; + } + + if (status.AvailableReplicas < status.UpdatedReplicas) + { + return $"Waiting for deployment \"{name}\" rollout to finish: {status.AvailableReplicas ?? 0} of {status.UpdatedReplicas ?? 0} updated replicas are available..."; + } + + return $"deployment \"{name}\" successfully rolled out"; + } + + private async Task RolloutStatusDaemonSetInternalAsync(string name, string @namespace, CancellationToken cancellationToken) + { + var daemonSet = await client.AppsV1.ReadNamespacedDaemonSetAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + + var status = daemonSet.Status; + + if (status == null) + { + return "Waiting for daemon set spec update to be observed..."; + } + + if (status.ObservedGeneration < daemonSet.Metadata.Generation) + { + return "Waiting for daemon set spec update to be observed..."; + } + + if (status.UpdatedNumberScheduled < status.DesiredNumberScheduled) + { + return $"Waiting for daemon set \"{name}\" rollout to finish: {status.UpdatedNumberScheduled} out of {status.DesiredNumberScheduled} new pods have been updated..."; + } + + if (status.NumberAvailable < status.DesiredNumberScheduled) + { + return $"Waiting for daemon set \"{name}\" rollout to finish: {status.NumberAvailable ?? 0} of {status.DesiredNumberScheduled} updated pods are available..."; + } + + return $"daemon set \"{name}\" successfully rolled out"; + } + + private async Task RolloutStatusStatefulSetInternalAsync(string name, string @namespace, CancellationToken cancellationToken) + { + var statefulSet = await client.AppsV1.ReadNamespacedStatefulSetAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + + var status = statefulSet.Status; + var spec = statefulSet.Spec; + + if (status == null) + { + return "Waiting for statefulset spec update to be observed..."; + } + + if (status.ObservedGeneration < statefulSet.Metadata.Generation) + { + return "Waiting for statefulset spec update to be observed..."; + } + + if (spec.Replicas != null && status.ReadyReplicas < spec.Replicas) + { + return $"Waiting for {spec.Replicas - status.ReadyReplicas} pods to be ready..."; + } + + if (spec.UpdateStrategy?.Type == "RollingUpdate" && spec.UpdateStrategy.RollingUpdate != null) + { + if (spec.Replicas != null && spec.UpdateStrategy.RollingUpdate.Partition != null) + { + if (status.UpdatedReplicas < (spec.Replicas - spec.UpdateStrategy.RollingUpdate.Partition)) + { + return $"Waiting for partitioned roll out to finish: {status.UpdatedReplicas} out of {spec.Replicas - spec.UpdateStrategy.RollingUpdate.Partition} new pods have been updated..."; + } + } + } + + if (status.UpdateRevision != status.CurrentRevision) + { + return $"waiting for statefulset rolling update to complete {status.UpdatedReplicas} pods at revision {status.UpdateRevision}..."; + } + + return $"statefulset rolling update complete {status.CurrentReplicas} pods at revision {status.CurrentRevision}..."; + } + + private async Task RolloutUndoDeploymentInternalAsync(string name, string @namespace, long? toRevision, CancellationToken cancellationToken) + { + var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + + var labelSelector = BuildLabelSelector(deployment.Spec.Selector.MatchLabels); + var replicaSets = await client.AppsV1.ListNamespacedReplicaSetAsync(@namespace, labelSelector: labelSelector, cancellationToken: cancellationToken).ConfigureAwait(false); + + var ownedReplicaSets = replicaSets.Items + .Where(rs => rs.Metadata.OwnerReferences?.Any(or => or.Uid == deployment.Metadata.Uid) == true) + .OrderByDescending(rs => + { + if (rs.Metadata.Annotations?.TryGetValue(RevisionAnnotation, out var revisionStr) == true) + { + return long.TryParse(revisionStr, out var revision) ? revision : 0; + } + + return 0; + }) + .ToList(); + + if (ownedReplicaSets.Count == 0) + { + throw new InvalidOperationException($"No ReplicaSets found for deployment {name}"); + } + + V1ReplicaSet? targetReplicaSet; + + if (toRevision.HasValue && toRevision.Value > 0) + { + targetReplicaSet = ownedReplicaSets.FirstOrDefault(rs => + { + if (rs.Metadata.Annotations?.TryGetValue(RevisionAnnotation, out var revisionStr) == true) + { + return long.TryParse(revisionStr, out var revision) && revision == toRevision.Value; + } + + return false; + }); + + if (targetReplicaSet == null) + { + throw new InvalidOperationException($"Revision {toRevision} not found for deployment {name}"); + } + } + else + { + if (ownedReplicaSets.Count < 2) + { + throw new InvalidOperationException($"No previous revision found for deployment {name}"); + } + + targetReplicaSet = ownedReplicaSets[1]; + } + + var old = JsonSerializer.SerializeToDocument(deployment); + + deployment.Spec.Template = targetReplicaSet.Spec.Template; + + deployment.Metadata.Annotations ??= new Dictionary(); + deployment.Metadata.Annotations[RevisionAnnotation] = + targetReplicaSet.Metadata.Annotations?[RevisionAnnotation] ?? "0"; + + var patch = old.CreatePatch(deployment); + + await client.AppsV1.PatchNamespacedDeploymentAsync(new V1Patch(patch, V1Patch.PatchType.JsonPatch), name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + } + + private async Task> RolloutHistoryDeploymentInternalAsync(string name, string @namespace, CancellationToken cancellationToken) + { + var deployment = await client.AppsV1.ReadNamespacedDeploymentAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + + var labelSelector = BuildLabelSelector(deployment.Spec.Selector.MatchLabels); + var replicaSets = await client.AppsV1.ListNamespacedReplicaSetAsync(@namespace, labelSelector: labelSelector, cancellationToken: cancellationToken).ConfigureAwait(false); + + var history = replicaSets.Items + .Where(rs => rs.Metadata.OwnerReferences?.Any(or => or.Uid == deployment.Metadata.Uid) == true) + .Select(rs => + { + var revision = 0L; + if (rs.Metadata.Annotations?.TryGetValue(RevisionAnnotation, out var revisionStr) == true) + { + long.TryParse(revisionStr, out revision); + } + + var changeCause = ""; + if (rs.Metadata.Annotations?.TryGetValue(ChangeCauseAnnotation, out var cause) == true && !string.IsNullOrEmpty(cause)) + { + changeCause = cause; + } + + return new RolloutHistoryEntry + { + Revision = revision, + ChangeCause = changeCause, + }; + }) + .Where(entry => entry.Revision > 0) + .OrderBy(entry => entry.Revision) + .ToList(); + + return history; + } + + private async Task> RolloutHistoryDaemonSetInternalAsync(string name, string @namespace, CancellationToken cancellationToken) + { + var daemonSet = await client.AppsV1.ReadNamespacedDaemonSetAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + + var labelSelector = BuildLabelSelector(daemonSet.Spec.Selector.MatchLabels); + var controllerRevisions = await client.AppsV1.ListNamespacedControllerRevisionAsync(@namespace, labelSelector: labelSelector, cancellationToken: cancellationToken).ConfigureAwait(false); + + var history = controllerRevisions.Items + .Where(cr => cr.Metadata.OwnerReferences?.Any(or => or.Uid == daemonSet.Metadata.Uid) == true) + .Select(cr => + { + var changeCause = ""; + if (cr.Metadata.Annotations?.TryGetValue(ChangeCauseAnnotation, out var cause) == true && !string.IsNullOrEmpty(cause)) + { + changeCause = cause; + } + + return new RolloutHistoryEntry + { + Revision = cr.Revision, + ChangeCause = changeCause, + }; + }) + .OrderBy(entry => entry.Revision) + .ToList(); + + return history; + } + + private async Task> RolloutHistoryStatefulSetInternalAsync(string name, string @namespace, CancellationToken cancellationToken) + { + var statefulSet = await client.AppsV1.ReadNamespacedStatefulSetAsync(name, @namespace, cancellationToken: cancellationToken).ConfigureAwait(false); + + var labelSelector = BuildLabelSelector(statefulSet.Spec.Selector.MatchLabels); + var controllerRevisions = await client.AppsV1.ListNamespacedControllerRevisionAsync(@namespace, labelSelector: labelSelector, cancellationToken: cancellationToken).ConfigureAwait(false); + + var history = controllerRevisions.Items + .Where(cr => cr.Metadata.OwnerReferences?.Any(or => or.Uid == statefulSet.Metadata.Uid) == true) + .Select(cr => + { + var changeCause = ""; + if (cr.Metadata.Annotations?.TryGetValue(ChangeCauseAnnotation, out var cause) == true && !string.IsNullOrEmpty(cause)) + { + changeCause = cause; + } + + return new RolloutHistoryEntry + { + Revision = cr.Revision, + ChangeCause = changeCause, + }; + }) + .OrderBy(entry => entry.Revision) + .ToList(); + + return history; + } +} + +/// +/// Represents a single entry in the rollout history. +/// +public class RolloutHistoryEntry +{ + /// + /// The revision number. + /// + public long Revision { get; set; } + + /// + /// The change cause annotation for this revision. + /// + public string ChangeCause { get; set; } = ""; +} diff --git a/src/KubernetesClient.Kubectl/Beta/Kubectl.Rollout.cs b/src/KubernetesClient.Kubectl/Beta/Kubectl.Rollout.cs new file mode 100644 index 00000000..d195349c --- /dev/null +++ b/src/KubernetesClient.Kubectl/Beta/Kubectl.Rollout.cs @@ -0,0 +1,79 @@ +namespace k8s.kubectl.beta; + +public partial class Kubectl +{ + /// + /// Restart a workload resource by adding a restart annotation to trigger a rollout. + /// + /// The type of workload resource (V1Deployment, V1DaemonSet, or V1StatefulSet). + /// The name of the resource. + /// The namespace of the resource. + public void RolloutRestart(string name, string @namespace) + where T : IKubernetesObject + { + client.RolloutRestartAsync(name, @namespace).GetAwaiter().GetResult(); + } + + /// + /// Get the rollout status of a workload resource. + /// + /// The type of workload resource (V1Deployment, V1DaemonSet, or V1StatefulSet). + /// The name of the resource. + /// The namespace of the resource. + /// A string describing the rollout status. + public string RolloutStatus(string name, string @namespace) + where T : IKubernetesObject + { + return client.RolloutStatusAsync(name, @namespace).GetAwaiter().GetResult(); + } + + /// + /// Pause a Deployment rollout. + /// + /// The type of resource (must be V1Deployment). + /// The name of the Deployment. + /// The namespace of the Deployment. + public void RolloutPause(string name, string @namespace) + where T : IKubernetesObject + { + client.RolloutPauseAsync(name, @namespace).GetAwaiter().GetResult(); + } + + /// + /// Resume a paused Deployment rollout. + /// + /// The type of resource (must be V1Deployment). + /// The name of the Deployment. + /// The namespace of the Deployment. + public void RolloutResume(string name, string @namespace) + where T : IKubernetesObject + { + client.RolloutResumeAsync(name, @namespace).GetAwaiter().GetResult(); + } + + /// + /// Undo a Deployment rollout to a previous revision. + /// + /// The type of resource (must be V1Deployment). + /// The name of the Deployment. + /// The namespace of the Deployment. + /// The revision to roll back to. If 0 or not specified, rolls back to the previous revision. + public void RolloutUndo(string name, string @namespace, long? toRevision = null) + where T : IKubernetesObject + { + client.RolloutUndoAsync(name, @namespace, toRevision).GetAwaiter().GetResult(); + } + + /// + /// Get the rollout history of a workload resource. + /// + /// The type of workload resource (V1Deployment, V1DaemonSet, or V1StatefulSet). + /// The name of the resource. + /// The namespace of the resource. + /// A list of revision history entries. + public IList RolloutHistory(string name, string @namespace) + where T : IKubernetesObject + { + return client.RolloutHistoryAsync(name, @namespace).GetAwaiter().GetResult(); + } +} diff --git a/tests/Kubectl.Tests/KubectlTests.Rollout.cs b/tests/Kubectl.Tests/KubectlTests.Rollout.cs new file mode 100644 index 00000000..aa5c6adc --- /dev/null +++ b/tests/Kubectl.Tests/KubectlTests.Rollout.cs @@ -0,0 +1,648 @@ +using k8s.Autorest; +using k8s.E2E; +using k8s.kubectl.beta; +using k8s.Models; +using Xunit; + +namespace k8s.kubectl.Tests; + +public partial class KubectlTests +{ + [MinikubeFact] + public void RolloutRestartDeployment() + { + using var kubernetes = MinikubeTests.CreateClient(); + var client = new Kubectl(kubernetes); + var namespaceParameter = "default"; + var deploymentName = "k8scsharp-e2e-rollout-deployment"; + + // Create a test deployment + var deployment = new V1Deployment + { + Metadata = new V1ObjectMeta + { + Name = deploymentName, + NamespaceProperty = namespaceParameter, + }, + Spec = new V1DeploymentSpec + { + Replicas = 1, + Selector = new V1LabelSelector + { + MatchLabels = new Dictionary + { + { "app", "test-rollout" }, + }, + }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta + { + Labels = new Dictionary + { + { "app", "test-rollout" }, + }, + }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "test", + Image = "nginx:latest", + }, + }, + }, + }, + }, + }; + + try + { + kubernetes.AppsV1.CreateNamespacedDeployment(deployment, namespaceParameter); + + // Wait a moment for the deployment to stabilize + System.Threading.Thread.Sleep(2000); + + // Restart the deployment + client.RolloutRestart(deploymentName, namespaceParameter); + + // Verify the restart annotation was added + var updatedDeployment = kubernetes.AppsV1.ReadNamespacedDeployment(deploymentName, namespaceParameter); + Assert.NotNull(updatedDeployment.Spec.Template.Metadata.Annotations); + Assert.Contains("kubectl.kubernetes.io/restartedAt", updatedDeployment.Spec.Template.Metadata.Annotations.Keys); + } + finally + { + // Cleanup + try + { + kubernetes.AppsV1.DeleteNamespacedDeployment(deploymentName, namespaceParameter); + } + catch (HttpOperationException) + { + // Ignore cleanup errors + } + } + } + + [MinikubeFact] + public void RolloutStatusDeployment() + { + using var kubernetes = MinikubeTests.CreateClient(); + var client = new Kubectl(kubernetes); + var namespaceParameter = "default"; + var deploymentName = "k8scsharp-e2e-rollout-status"; + + // Create a test deployment + var deployment = new V1Deployment + { + Metadata = new V1ObjectMeta + { + Name = deploymentName, + NamespaceProperty = namespaceParameter, + }, + Spec = new V1DeploymentSpec + { + Replicas = 1, + Selector = new V1LabelSelector + { + MatchLabels = new Dictionary + { + { "app", "test-status" }, + }, + }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta + { + Labels = new Dictionary + { + { "app", "test-status" }, + }, + }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "test", + Image = "nginx:latest", + }, + }, + }, + }, + }, + }; + + try + { + kubernetes.AppsV1.CreateNamespacedDeployment(deployment, namespaceParameter); + + // Get rollout status + var status = client.RolloutStatus(deploymentName, namespaceParameter); + + // Status should contain the deployment name + Assert.Contains(deploymentName, status); + } + finally + { + // Cleanup + try + { + kubernetes.AppsV1.DeleteNamespacedDeployment(deploymentName, namespaceParameter); + } + catch (HttpOperationException) + { + // Ignore cleanup errors + } + } + } + + [MinikubeFact] + public void RolloutPauseAndResumeDeployment() + { + using var kubernetes = MinikubeTests.CreateClient(); + var client = new Kubectl(kubernetes); + var namespaceParameter = "default"; + var deploymentName = "k8scsharp-e2e-rollout-pause"; + + // Create a test deployment + var deployment = new V1Deployment + { + Metadata = new V1ObjectMeta + { + Name = deploymentName, + NamespaceProperty = namespaceParameter, + }, + Spec = new V1DeploymentSpec + { + Replicas = 1, + Selector = new V1LabelSelector + { + MatchLabels = new Dictionary + { + { "app", "test-pause" }, + }, + }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta + { + Labels = new Dictionary + { + { "app", "test-pause" }, + }, + }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "test", + Image = "nginx:latest", + }, + }, + }, + }, + }, + }; + + try + { + kubernetes.AppsV1.CreateNamespacedDeployment(deployment, namespaceParameter); + + // Pause the deployment + client.RolloutPause(deploymentName, namespaceParameter); + + // Verify the deployment is paused + var pausedDeployment = kubernetes.AppsV1.ReadNamespacedDeployment(deploymentName, namespaceParameter); + Assert.True(pausedDeployment.Spec.Paused); + + // Resume the deployment + client.RolloutResume(deploymentName, namespaceParameter); + + // Verify the deployment is resumed + var resumedDeployment = kubernetes.AppsV1.ReadNamespacedDeployment(deploymentName, namespaceParameter); + Assert.False(resumedDeployment.Spec.Paused); + } + finally + { + // Cleanup + try + { + kubernetes.AppsV1.DeleteNamespacedDeployment(deploymentName, namespaceParameter); + } + catch (HttpOperationException) + { + // Ignore cleanup errors + } + } + } + + [MinikubeFact] + public void RolloutHistoryDeployment() + { + using var kubernetes = MinikubeTests.CreateClient(); + var client = new Kubectl(kubernetes); + var namespaceParameter = "default"; + var deploymentName = "k8scsharp-e2e-rollout-history"; + + // Create a test deployment with change-cause annotation + var deployment = new V1Deployment + { + Metadata = new V1ObjectMeta + { + Name = deploymentName, + NamespaceProperty = namespaceParameter, + Annotations = new Dictionary + { + { "kubernetes.io/change-cause", "Initial deployment" }, + }, + }, + Spec = new V1DeploymentSpec + { + Replicas = 1, + Selector = new V1LabelSelector + { + MatchLabels = new Dictionary + { + { "app", "test-history" }, + }, + }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta + { + Labels = new Dictionary + { + { "app", "test-history" }, + }, + }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "test", + Image = "nginx:latest", + }, + }, + }, + }, + }, + }; + + try + { + kubernetes.AppsV1.CreateNamespacedDeployment(deployment, namespaceParameter); + + // Wait for deployment to create ReplicaSets + System.Threading.Thread.Sleep(3000); + + // Get rollout history + var history = client.RolloutHistory(deploymentName, namespaceParameter); + + // Should have at least one revision + Assert.NotNull(history); + Assert.NotEmpty(history); + } + finally + { + // Cleanup + try + { + kubernetes.AppsV1.DeleteNamespacedDeployment(deploymentName, namespaceParameter); + } + catch (HttpOperationException) + { + // Ignore cleanup errors + } + } + } + + [MinikubeFact] + public void RolloutRestartDaemonSet() + { + using var kubernetes = MinikubeTests.CreateClient(); + var client = new Kubectl(kubernetes); + var namespaceParameter = "default"; + var daemonSetName = "k8scsharp-e2e-rollout-daemonset"; + + // Create a test daemonset + var daemonSet = new V1DaemonSet + { + Metadata = new V1ObjectMeta + { + Name = daemonSetName, + NamespaceProperty = namespaceParameter, + }, + Spec = new V1DaemonSetSpec + { + Selector = new V1LabelSelector + { + MatchLabels = new Dictionary + { + { "app", "test-daemonset" }, + }, + }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta + { + Labels = new Dictionary + { + { "app", "test-daemonset" }, + }, + }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "test", + Image = "nginx:latest", + }, + }, + Tolerations = new[] + { + new V1Toleration + { + OperatorProperty = "Exists", + }, + }, + }, + }, + }, + }; + + try + { + kubernetes.AppsV1.CreateNamespacedDaemonSet(daemonSet, namespaceParameter); + + // Wait a moment for the daemonset to stabilize + System.Threading.Thread.Sleep(2000); + + // Restart the daemonset + client.RolloutRestart(daemonSetName, namespaceParameter); + + // Verify the restart annotation was added + var updatedDaemonSet = kubernetes.AppsV1.ReadNamespacedDaemonSet(daemonSetName, namespaceParameter); + Assert.NotNull(updatedDaemonSet.Spec.Template.Metadata.Annotations); + Assert.Contains("kubectl.kubernetes.io/restartedAt", updatedDaemonSet.Spec.Template.Metadata.Annotations.Keys); + } + finally + { + // Cleanup + try + { + kubernetes.AppsV1.DeleteNamespacedDaemonSet(daemonSetName, namespaceParameter); + } + catch (HttpOperationException) + { + // Ignore cleanup errors + } + } + } + + [MinikubeFact] + public void RolloutStatusDaemonSet() + { + using var kubernetes = MinikubeTests.CreateClient(); + var client = new Kubectl(kubernetes); + var namespaceParameter = "default"; + var daemonSetName = "k8scsharp-e2e-rollout-ds-status"; + + // Create a test daemonset + var daemonSet = new V1DaemonSet + { + Metadata = new V1ObjectMeta + { + Name = daemonSetName, + NamespaceProperty = namespaceParameter, + }, + Spec = new V1DaemonSetSpec + { + Selector = new V1LabelSelector + { + MatchLabels = new Dictionary + { + { "app", "test-ds-status" }, + }, + }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta + { + Labels = new Dictionary + { + { "app", "test-ds-status" }, + }, + }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "test", + Image = "nginx:latest", + }, + }, + Tolerations = new[] + { + new V1Toleration + { + OperatorProperty = "Exists", + }, + }, + }, + }, + }, + }; + + try + { + kubernetes.AppsV1.CreateNamespacedDaemonSet(daemonSet, namespaceParameter); + + // Get rollout status + var status = client.RolloutStatus(daemonSetName, namespaceParameter); + + // Status should contain the daemonset name + Assert.Contains(daemonSetName, status); + } + finally + { + // Cleanup + try + { + kubernetes.AppsV1.DeleteNamespacedDaemonSet(daemonSetName, namespaceParameter); + } + catch (HttpOperationException) + { + // Ignore cleanup errors + } + } + } + + [MinikubeFact] + public void RolloutRestartStatefulSet() + { + using var kubernetes = MinikubeTests.CreateClient(); + var client = new Kubectl(kubernetes); + var namespaceParameter = "default"; + var statefulSetName = "k8scsharp-e2e-rollout-statefulset"; + + // Create a test statefulset + var statefulSet = new V1StatefulSet + { + Metadata = new V1ObjectMeta + { + Name = statefulSetName, + NamespaceProperty = namespaceParameter, + }, + Spec = new V1StatefulSetSpec + { + ServiceName = "test-service", + Replicas = 1, + Selector = new V1LabelSelector + { + MatchLabels = new Dictionary + { + { "app", "test-statefulset" }, + }, + }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta + { + Labels = new Dictionary + { + { "app", "test-statefulset" }, + }, + }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "test", + Image = "nginx:latest", + }, + }, + }, + }, + }, + }; + + try + { + kubernetes.AppsV1.CreateNamespacedStatefulSet(statefulSet, namespaceParameter); + + // Wait a moment for the statefulset to stabilize + System.Threading.Thread.Sleep(2000); + + // Restart the statefulset + client.RolloutRestart(statefulSetName, namespaceParameter); + + // Verify the restart annotation was added + var updatedStatefulSet = kubernetes.AppsV1.ReadNamespacedStatefulSet(statefulSetName, namespaceParameter); + Assert.NotNull(updatedStatefulSet.Spec.Template.Metadata.Annotations); + Assert.Contains("kubectl.kubernetes.io/restartedAt", updatedStatefulSet.Spec.Template.Metadata.Annotations.Keys); + } + finally + { + // Cleanup + try + { + kubernetes.AppsV1.DeleteNamespacedStatefulSet(statefulSetName, namespaceParameter); + } + catch (HttpOperationException) + { + // Ignore cleanup errors + } + } + } + + [MinikubeFact] + public void RolloutStatusStatefulSet() + { + using var kubernetes = MinikubeTests.CreateClient(); + var client = new Kubectl(kubernetes); + var namespaceParameter = "default"; + var statefulSetName = "k8scsharp-e2e-rollout-sts-status"; + + // Create a test statefulset + var statefulSet = new V1StatefulSet + { + Metadata = new V1ObjectMeta + { + Name = statefulSetName, + NamespaceProperty = namespaceParameter, + }, + Spec = new V1StatefulSetSpec + { + ServiceName = "test-service", + Replicas = 1, + Selector = new V1LabelSelector + { + MatchLabels = new Dictionary + { + { "app", "test-sts-status" }, + }, + }, + Template = new V1PodTemplateSpec + { + Metadata = new V1ObjectMeta + { + Labels = new Dictionary + { + { "app", "test-sts-status" }, + }, + }, + Spec = new V1PodSpec + { + Containers = new[] + { + new V1Container + { + Name = "test", + Image = "nginx:latest", + }, + }, + }, + }, + }, + }; + + try + { + kubernetes.AppsV1.CreateNamespacedStatefulSet(statefulSet, namespaceParameter); + + // Get rollout status + var status = client.RolloutStatus(statefulSetName, namespaceParameter); + + // Status should contain the statefulset name or status information + Assert.NotNull(status); + Assert.NotEmpty(status); + } + finally + { + // Cleanup + try + { + kubernetes.AppsV1.DeleteNamespacedStatefulSet(statefulSetName, namespaceParameter); + } + catch (HttpOperationException) + { + // Ignore cleanup errors + } + } + } +}