package deployer import ( "bytes" "context" "fmt" "time" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/orchard9/rdev/internal/domain" ) // UndeployComponent removes deployment resources for a specific component. func (d *Deployer) UndeployComponent(ctx context.Context, projectName, componentPath string) error { // Build deployment name from project and component spec := domain.DeploySpec{ ProjectName: projectName, ComponentPath: componentPath, } deploymentName := spec.DeploymentName() ns := d.config.Namespace // Delete Ingress err := d.client.NetworkingV1().Ingresses(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("failed to delete ingress: %w", err) } // Delete Service err = d.client.CoreV1().Services(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("failed to delete service: %w", err) } // Delete Deployment err = d.client.AppsV1().Deployments(ns).Delete(ctx, deploymentName, metav1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("failed to delete deployment: %w", err) } // Delete Secret err = d.client.CoreV1().Secrets(ns).Delete(ctx, deploymentName+"-env", metav1.DeleteOptions{}) if err != nil && !errors.IsNotFound(err) { return fmt.Errorf("failed to delete secret: %w", err) } return nil } // GetComponentStatus returns deployment status for a specific component. func (d *Deployer) GetComponentStatus(ctx context.Context, projectName, componentPath string) (*domain.DeployStatus, error) { // Build deployment name from project and component spec := domain.DeploySpec{ ProjectName: projectName, ComponentPath: componentPath, } deploymentName := spec.DeploymentName() ns := d.config.Namespace deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{}) if err != nil { if errors.IsNotFound(err) { return nil, nil } return nil, fmt.Errorf("failed to get deployment: %w", err) } // Determine status var status domain.DeploymentStatus switch { case deployment.Status.ReadyReplicas == *deployment.Spec.Replicas: status = domain.DeploymentStatusRunning case deployment.Status.UnavailableReplicas > 0: status = domain.DeploymentStatusFailed case deployment.Status.ReadyReplicas < *deployment.Spec.Replicas: status = domain.DeploymentStatusPending default: status = domain.DeploymentStatusUnknown } // Get URL from ingress var url string ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, deploymentName, metav1.GetOptions{}) if err == nil && len(ingress.Spec.Rules) > 0 { host := ingress.Spec.Rules[0].Host url = "https://" + host } return &domain.DeployStatus{ ProjectName: projectName, ComponentPath: componentPath, Image: deployment.Spec.Template.Spec.Containers[0].Image, Replicas: int(*deployment.Spec.Replicas), ReadyReplicas: int(deployment.Status.ReadyReplicas), URL: url, Status: status, CreatedAt: deployment.CreationTimestamp.Time, UpdatedAt: time.Now(), }, nil } // ListComponentStatuses returns deployment status for all components in a project. func (d *Deployer) ListComponentStatuses(ctx context.Context, projectName string) (*domain.ProjectDeployStatus, error) { ns := d.config.Namespace // List all deployments for this project deployments, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{ LabelSelector: fmt.Sprintf("project=%s", projectName), }) if err != nil { return nil, fmt.Errorf("failed to list deployments: %w", err) } result := &domain.ProjectDeployStatus{ ProjectName: projectName, Components: make([]domain.ComponentDeployStatus, 0, len(deployments.Items)), } for _, dep := range deployments.Items { componentPath := dep.Labels["component"] componentName := dep.Name if componentPath == "" { // This is the main project deployment, not a component componentName = projectName } // Determine status var status domain.DeploymentStatus switch { case dep.Status.ReadyReplicas == *dep.Spec.Replicas: status = domain.DeploymentStatusRunning case dep.Status.UnavailableReplicas > 0: status = domain.DeploymentStatusFailed case dep.Status.ReadyReplicas < *dep.Spec.Replicas: status = domain.DeploymentStatusPending default: status = domain.DeploymentStatusUnknown } // Get URL from ingress var url string ingress, err := d.client.NetworkingV1().Ingresses(ns).Get(ctx, dep.Name, metav1.GetOptions{}) if err == nil && len(ingress.Spec.Rules) > 0 { url = "https://" + ingress.Spec.Rules[0].Host // Set overall URL to first component URL if result.OverallURL == "" { result.OverallURL = url } } // Determine component type from path componentType := "unknown" if componentPath != "" { parts := splitComponentPath(componentPath) if len(parts) > 0 { switch parts[0] { case "services": componentType = "service" case "workers": componentType = "worker" case "apps": componentType = "app" case "cli": componentType = "cli" } } } result.Components = append(result.Components, domain.ComponentDeployStatus{ ComponentPath: componentPath, ComponentName: componentName, ComponentType: componentType, Image: dep.Spec.Template.Spec.Containers[0].Image, Replicas: int(*dep.Spec.Replicas), ReadyReplicas: int(dep.Status.ReadyReplicas), URL: url, Status: status, }) } return result, nil } // splitComponentPath splits a component path like "services/auth-api" into ["services", "auth-api"]. func splitComponentPath(path string) []string { var parts []string current := "" for _, c := range path { if c == '/' { if current != "" { parts = append(parts, current) current = "" } } else { current += string(c) } } if current != "" { parts = append(parts, current) } return parts } // RestartComponent triggers a rolling restart of a specific component. func (d *Deployer) RestartComponent(ctx context.Context, projectName, componentPath string) error { // Build deployment name spec := domain.DeploySpec{ ProjectName: projectName, ComponentPath: componentPath, } deploymentName := spec.DeploymentName() ns := d.config.Namespace deployment, err := d.client.AppsV1().Deployments(ns).Get(ctx, deploymentName, metav1.GetOptions{}) if err != nil { return fmt.Errorf("failed to get deployment: %w", err) } // Add annotation to trigger rollout if deployment.Spec.Template.Annotations == nil { deployment.Spec.Template.Annotations = make(map[string]string) } deployment.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = time.Now().Format(time.RFC3339) _, err = d.client.AppsV1().Deployments(ns).Update(ctx, deployment, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("failed to update deployment: %w", err) } return nil } // ScaleComponent adjusts the replica count for a component. func (d *Deployer) ScaleComponent(ctx context.Context, projectName, componentPath string, replicas int) error { // Build deployment name spec := domain.DeploySpec{ ProjectName: projectName, ComponentPath: componentPath, } deploymentName := spec.DeploymentName() ns := d.config.Namespace scale, err := d.client.AppsV1().Deployments(ns).GetScale(ctx, deploymentName, metav1.GetOptions{}) if err != nil { return fmt.Errorf("failed to get scale: %w", err) } scale.Spec.Replicas = int32(replicas) _, err = d.client.AppsV1().Deployments(ns).UpdateScale(ctx, deploymentName, scale, metav1.UpdateOptions{}) if err != nil { return fmt.Errorf("failed to update scale: %w", err) } return nil } // GetComponentLogs returns recent logs from a specific component's pods. func (d *Deployer) GetComponentLogs(ctx context.Context, projectName, componentPath string, tailLines int) (string, error) { // Build deployment name spec := domain.DeploySpec{ ProjectName: projectName, ComponentPath: componentPath, } deploymentName := spec.DeploymentName() ns := d.config.Namespace // List pods for the component deployment pods, err := d.client.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{ LabelSelector: fmt.Sprintf("app=%s", deploymentName), }) if err != nil { return "", fmt.Errorf("failed to list pods: %w", err) } if len(pods.Items) == 0 { return "", fmt.Errorf("no pods found for component %s in project %s", componentPath, projectName) } // Get logs from the first pod tail := int64(tailLines) opts := &corev1.PodLogOptions{ TailLines: &tail, } req := d.client.CoreV1().Pods(ns).GetLogs(pods.Items[0].Name, opts) logs, err := req.Stream(ctx) if err != nil { return "", fmt.Errorf("failed to get logs: %w", err) } defer func() { _ = logs.Close() }() buf := new(bytes.Buffer) _, err = buf.ReadFrom(logs) if err != nil { return "", fmt.Errorf("failed to read logs: %w", err) } return buf.String(), nil }