rdev/internal/adapter/deployer/deployer_components.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
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>
2026-01-31 19:11:42 -07:00

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
}