Adds the composable monorepo template system that generates project skeletons with pluggable components (service, worker, app-react, app-astro, cli). Key changes: - Monorepo skeleton templates with shared pkg/, scripts/, and git hooks - Component templates (service, worker, app-react, app-astro, cli) with Dockerfiles, CI steps, and component.yaml manifests - Component domain model with validation and dependency resolution - Component handler endpoints for CRUD and composition - Template provider extended with BuildComposableProject and component assembly - Deployer extended with composable project deployment support - Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning) - envutil package for centralized env var reads with defaults - api.DecodeJSON helper for standardized request body decoding - Standardized response helpers (WriteBadRequest, WriteNotFound, etc.) - Replaced fullstack-app cookbook with composable-app cookbook - Hardened handler timeouts, logging, and error responses across all handlers Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
304 lines
8.9 KiB
Go
304 lines
8.9 KiB
Go
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
|
|
}
|