From c2a2177dd448b52b9e407c7455b48eb0ef67a00b Mon Sep 17 00:00:00 2001 From: nash Date: Sat, 14 Feb 2026 09:19:46 +0000 Subject: [PATCH] feat(k8s/cluster): add provider status normalization Add normalized ClusterStatus type to provide consistent status representation across all cloud providers (EKS, GKE, AKS). Changes: - Add ClusterStatus enum with values: ready, creating, updating, deleting, error, unknown - Add NormalizedStatus field to ClusterInfo struct - Add NormalizeEKSStatus, NormalizeGKEStatus, NormalizeAKSStatus functions to convert provider-specific statuses - Add IsReady, IsTransitioning, IsError helper methods - Update EKS and GKE providers to populate NormalizedStatus - Add comprehensive tests for status normalization The original provider-specific Status field is preserved for backwards compatibility, while NormalizedStatus enables consistent status handling across providers. Closes #69 --- internal/k8s/cluster/eks.go | 10 +- internal/k8s/cluster/gke.go | 2 + internal/k8s/cluster/provider.go | 117 ++++++++++- internal/k8s/cluster/status_test.go | 293 ++++++++++++++++++++++++++++ 4 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 internal/k8s/cluster/status_test.go diff --git a/internal/k8s/cluster/eks.go b/internal/k8s/cluster/eks.go index 9e90e98..c5fa76b 100644 --- a/internal/k8s/cluster/eks.go +++ b/internal/k8s/cluster/eks.go @@ -478,10 +478,11 @@ func (p *EKSProvider) ListClusters(ctx context.Context) ([]ClusterInfo, error) { if err != nil { // Include basic info even if full details fail clusters = append(clusters, ClusterInfo{ - Name: name, - Type: ClusterTypeEKS, - Status: "unknown", - Region: p.region, + Name: name, + Type: ClusterTypeEKS, + Status: "unknown", + NormalizedStatus: ClusterStatusUnknown, + Region: p.region, }) continue } @@ -502,6 +503,7 @@ func (p *EKSProvider) GetCluster(ctx context.Context, clusterName string) (*Clus Name: cluster.Name, Type: ClusterTypeEKS, Status: cluster.Status, + NormalizedStatus: NormalizeEKSStatus(cluster.Status), KubernetesVersion: cluster.Version, Endpoint: cluster.Endpoint, Region: p.region, diff --git a/internal/k8s/cluster/gke.go b/internal/k8s/cluster/gke.go index d1ff056..3b78ee3 100644 --- a/internal/k8s/cluster/gke.go +++ b/internal/k8s/cluster/gke.go @@ -330,6 +330,7 @@ func (p *GKEProvider) ListClusters(ctx context.Context) ([]ClusterInfo, error) { Name: c.Name, Type: ClusterTypeGKE, Status: c.Status, + NormalizedStatus: NormalizeGKEStatus(c.Status), KubernetesVersion: c.CurrentMasterVersion, Endpoint: c.Endpoint, Region: c.Location, @@ -366,6 +367,7 @@ func (p *GKEProvider) GetCluster(ctx context.Context, clusterName string) (*Clus Name: cluster.Name, Type: ClusterTypeGKE, Status: cluster.Status, + NormalizedStatus: NormalizeGKEStatus(cluster.Status), KubernetesVersion: cluster.CurrentMasterVersion, Endpoint: cluster.Endpoint, Region: cluster.Location, diff --git a/internal/k8s/cluster/provider.go b/internal/k8s/cluster/provider.go index dcaa20c..4dd9d02 100644 --- a/internal/k8s/cluster/provider.go +++ b/internal/k8s/cluster/provider.go @@ -11,12 +11,31 @@ type ClusterType string const ( ClusterTypeEKS ClusterType = "eks" ClusterTypeGKE ClusterType = "gke" + ClusterTypeAKS ClusterType = "aks" ClusterTypeKubeadm ClusterType = "kubeadm" ClusterTypeKops ClusterType = "kops" ClusterTypeK3s ClusterType = "k3s" ClusterTypeExisting ClusterType = "existing" ) +// ClusterStatus represents the normalized status of a cluster +type ClusterStatus string + +const ( + // ClusterStatusReady indicates the cluster is ready and operational + ClusterStatusReady ClusterStatus = "ready" + // ClusterStatusCreating indicates the cluster is being created + ClusterStatusCreating ClusterStatus = "creating" + // ClusterStatusUpdating indicates the cluster is being updated + ClusterStatusUpdating ClusterStatus = "updating" + // ClusterStatusDeleting indicates the cluster is being deleted + ClusterStatusDeleting ClusterStatus = "deleting" + // ClusterStatusError indicates the cluster is in an error state + ClusterStatusError ClusterStatus = "error" + // ClusterStatusUnknown indicates the cluster status could not be determined + ClusterStatusUnknown ClusterStatus = "unknown" +) + // NodeInfo contains information about a cluster node type NodeInfo struct { Name string `json:"name"` @@ -30,16 +49,17 @@ type NodeInfo struct { // ClusterInfo contains cluster details type ClusterInfo struct { - Name string `json:"name"` - Type ClusterType `json:"type"` - Status string `json:"status"` - KubernetesVersion string `json:"kubernetes_version"` - Endpoint string `json:"endpoint"` - ControlPlaneNodes []NodeInfo `json:"control_plane_nodes,omitempty"` - WorkerNodes []NodeInfo `json:"worker_nodes,omitempty"` - CreatedAt time.Time `json:"created_at"` - Region string `json:"region,omitempty"` - VPCID string `json:"vpc_id,omitempty"` + Name string `json:"name"` + Type ClusterType `json:"type"` + Status string `json:"status"` + NormalizedStatus ClusterStatus `json:"normalized_status"` + KubernetesVersion string `json:"kubernetes_version"` + Endpoint string `json:"endpoint"` + ControlPlaneNodes []NodeInfo `json:"control_plane_nodes,omitempty"` + WorkerNodes []NodeInfo `json:"worker_nodes,omitempty"` + CreatedAt time.Time `json:"created_at"` + Region string `json:"region,omitempty"` + VPCID string `json:"vpc_id,omitempty"` } // HealthStatus represents cluster health @@ -246,3 +266,80 @@ type ErrInvalidConfiguration struct { func (e *ErrInvalidConfiguration) Error() string { return "invalid cluster configuration: " + e.Message } + +// NormalizeEKSStatus converts EKS-specific status to normalized ClusterStatus +func NormalizeEKSStatus(status string) ClusterStatus { + switch status { + case "ACTIVE": + return ClusterStatusReady + case "CREATING": + return ClusterStatusCreating + case "UPDATING": + return ClusterStatusUpdating + case "DELETING": + return ClusterStatusDeleting + case "FAILED": + return ClusterStatusError + case "PENDING": + return ClusterStatusCreating + default: + return ClusterStatusUnknown + } +} + +// NormalizeGKEStatus converts GKE-specific status to normalized ClusterStatus +func NormalizeGKEStatus(status string) ClusterStatus { + switch status { + case "RUNNING": + return ClusterStatusReady + case "PROVISIONING": + return ClusterStatusCreating + case "RECONCILING": + return ClusterStatusUpdating + case "STOPPING": + return ClusterStatusDeleting + case "ERROR": + return ClusterStatusError + case "DEGRADED": + return ClusterStatusError + case "STATUS_UNSPECIFIED": + return ClusterStatusUnknown + default: + return ClusterStatusUnknown + } +} + +// NormalizeAKSStatus converts AKS-specific status to normalized ClusterStatus +func NormalizeAKSStatus(provisioningState string) ClusterStatus { + switch provisioningState { + case "Succeeded": + return ClusterStatusReady + case "Creating": + return ClusterStatusCreating + case "Updating": + return ClusterStatusUpdating + case "Deleting": + return ClusterStatusDeleting + case "Failed": + return ClusterStatusError + case "Canceled": + return ClusterStatusError + default: + return ClusterStatusUnknown + } +} + +// IsReady returns true if the cluster is in a ready state +func (s ClusterStatus) IsReady() bool { + return s == ClusterStatusReady +} + +// IsTransitioning returns true if the cluster is in a transitional state +func (s ClusterStatus) IsTransitioning() bool { + return s == ClusterStatusCreating || s == ClusterStatusUpdating || s == ClusterStatusDeleting +} + +// IsError returns true if the cluster is in an error state +func (s ClusterStatus) IsError() bool { + return s == ClusterStatusError +} diff --git a/internal/k8s/cluster/status_test.go b/internal/k8s/cluster/status_test.go new file mode 100644 index 0000000..e301837 --- /dev/null +++ b/internal/k8s/cluster/status_test.go @@ -0,0 +1,293 @@ +package cluster + +import ( + "testing" +) + +func TestNormalizeEKSStatus(t *testing.T) { + tests := []struct { + name string + status string + expected ClusterStatus + }{ + { + name: "active", + status: "ACTIVE", + expected: ClusterStatusReady, + }, + { + name: "creating", + status: "CREATING", + expected: ClusterStatusCreating, + }, + { + name: "updating", + status: "UPDATING", + expected: ClusterStatusUpdating, + }, + { + name: "deleting", + status: "DELETING", + expected: ClusterStatusDeleting, + }, + { + name: "failed", + status: "FAILED", + expected: ClusterStatusError, + }, + { + name: "pending", + status: "PENDING", + expected: ClusterStatusCreating, + }, + { + name: "unknown status", + status: "SOMETHING_ELSE", + expected: ClusterStatusUnknown, + }, + { + name: "empty status", + status: "", + expected: ClusterStatusUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeEKSStatus(tt.status) + if result != tt.expected { + t.Errorf("NormalizeEKSStatus(%q) = %q, want %q", tt.status, result, tt.expected) + } + }) + } +} + +func TestNormalizeGKEStatus(t *testing.T) { + tests := []struct { + name string + status string + expected ClusterStatus + }{ + { + name: "running", + status: "RUNNING", + expected: ClusterStatusReady, + }, + { + name: "provisioning", + status: "PROVISIONING", + expected: ClusterStatusCreating, + }, + { + name: "reconciling", + status: "RECONCILING", + expected: ClusterStatusUpdating, + }, + { + name: "stopping", + status: "STOPPING", + expected: ClusterStatusDeleting, + }, + { + name: "error", + status: "ERROR", + expected: ClusterStatusError, + }, + { + name: "degraded", + status: "DEGRADED", + expected: ClusterStatusError, + }, + { + name: "status unspecified", + status: "STATUS_UNSPECIFIED", + expected: ClusterStatusUnknown, + }, + { + name: "unknown status", + status: "SOMETHING_ELSE", + expected: ClusterStatusUnknown, + }, + { + name: "empty status", + status: "", + expected: ClusterStatusUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeGKEStatus(tt.status) + if result != tt.expected { + t.Errorf("NormalizeGKEStatus(%q) = %q, want %q", tt.status, result, tt.expected) + } + }) + } +} + +func TestNormalizeAKSStatus(t *testing.T) { + tests := []struct { + name string + status string + expected ClusterStatus + }{ + { + name: "succeeded", + status: "Succeeded", + expected: ClusterStatusReady, + }, + { + name: "creating", + status: "Creating", + expected: ClusterStatusCreating, + }, + { + name: "updating", + status: "Updating", + expected: ClusterStatusUpdating, + }, + { + name: "deleting", + status: "Deleting", + expected: ClusterStatusDeleting, + }, + { + name: "failed", + status: "Failed", + expected: ClusterStatusError, + }, + { + name: "canceled", + status: "Canceled", + expected: ClusterStatusError, + }, + { + name: "unknown status", + status: "SomethingElse", + expected: ClusterStatusUnknown, + }, + { + name: "empty status", + status: "", + expected: ClusterStatusUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeAKSStatus(tt.status) + if result != tt.expected { + t.Errorf("NormalizeAKSStatus(%q) = %q, want %q", tt.status, result, tt.expected) + } + }) + } +} + +func TestClusterStatusIsReady(t *testing.T) { + tests := []struct { + status ClusterStatus + expected bool + }{ + {ClusterStatusReady, true}, + {ClusterStatusCreating, false}, + {ClusterStatusUpdating, false}, + {ClusterStatusDeleting, false}, + {ClusterStatusError, false}, + {ClusterStatusUnknown, false}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + result := tt.status.IsReady() + if result != tt.expected { + t.Errorf("ClusterStatus(%q).IsReady() = %v, want %v", tt.status, result, tt.expected) + } + }) + } +} + +func TestClusterStatusIsTransitioning(t *testing.T) { + tests := []struct { + status ClusterStatus + expected bool + }{ + {ClusterStatusReady, false}, + {ClusterStatusCreating, true}, + {ClusterStatusUpdating, true}, + {ClusterStatusDeleting, true}, + {ClusterStatusError, false}, + {ClusterStatusUnknown, false}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + result := tt.status.IsTransitioning() + if result != tt.expected { + t.Errorf("ClusterStatus(%q).IsTransitioning() = %v, want %v", tt.status, result, tt.expected) + } + }) + } +} + +func TestClusterStatusIsError(t *testing.T) { + tests := []struct { + status ClusterStatus + expected bool + }{ + {ClusterStatusReady, false}, + {ClusterStatusCreating, false}, + {ClusterStatusUpdating, false}, + {ClusterStatusDeleting, false}, + {ClusterStatusError, true}, + {ClusterStatusUnknown, false}, + } + + for _, tt := range tests { + t.Run(string(tt.status), func(t *testing.T) { + result := tt.status.IsError() + if result != tt.expected { + t.Errorf("ClusterStatus(%q).IsError() = %v, want %v", tt.status, result, tt.expected) + } + }) + } +} + +func TestClusterStatusConstants(t *testing.T) { + // Verify status string values + tests := []struct { + status ClusterStatus + expected string + }{ + {ClusterStatusReady, "ready"}, + {ClusterStatusCreating, "creating"}, + {ClusterStatusUpdating, "updating"}, + {ClusterStatusDeleting, "deleting"}, + {ClusterStatusError, "error"}, + {ClusterStatusUnknown, "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.expected, func(t *testing.T) { + if string(tt.status) != tt.expected { + t.Errorf("ClusterStatus constant %q has value %q, want %q", tt.expected, string(tt.status), tt.expected) + } + }) + } +} + +func TestClusterInfoWithNormalizedStatus(t *testing.T) { + info := ClusterInfo{ + Name: "test-cluster", + Type: ClusterTypeEKS, + Status: "ACTIVE", + NormalizedStatus: NormalizeEKSStatus("ACTIVE"), + } + + if info.NormalizedStatus != ClusterStatusReady { + t.Errorf("expected NormalizedStatus to be %q, got %q", ClusterStatusReady, info.NormalizedStatus) + } + + if !info.NormalizedStatus.IsReady() { + t.Error("expected IsReady() to return true") + } +}