## Changes
### port.Deployer interface
- Add PatchProjectSecrets(ctx, projectName, patch) to merge key-value pairs
into all K8s secrets labeled project={projectName}
- Add RestartAll(ctx, projectName) to trigger rolling restart of all deployments
for a project, picking up fresh secrets without waiting for CI
### deployer adapter
- Implement PatchProjectSecrets: lists secrets by label, merges patch into Data,
writes each secret back
- Implement RestartAll: lists deployments by label, sets restartedAt annotation
### domain/credential.go
- Add CredentialCategoryCache = "cache" constant
- Use constant in component_infra.go (was raw string "cache")
### handlers/cache.go (new)
- POST /projects/{projectID}/cache/reprovision
- Calls CreateProjectCache (which handles delete+recreate with new password)
- Updates credential store (REDIS_URL, REDIS_URL_STAGING, REDIS_PREFIX)
- Patches all K8s secrets for the project immediately
- Triggers RestartAll so pods pick up new credentials without waiting for deploy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
354 lines
11 KiB
Go
354 lines
11 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
|
|
}
|
|
|
|
// PatchProjectSecrets merges key-value pairs into all K8s secrets labeled project={projectName}.
|
|
// Existing keys not present in patch are preserved.
|
|
func (d *Deployer) PatchProjectSecrets(ctx context.Context, projectName string, patch map[string]string) error {
|
|
ns := d.config.Namespace
|
|
secretList, err := d.client.CoreV1().Secrets(ns).List(ctx, metav1.ListOptions{
|
|
LabelSelector: fmt.Sprintf("project=%s", projectName),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("list secrets for project %s: %w", projectName, err)
|
|
}
|
|
|
|
for i := range secretList.Items {
|
|
s := &secretList.Items[i]
|
|
if s.Data == nil {
|
|
s.Data = make(map[string][]byte)
|
|
}
|
|
for k, v := range patch {
|
|
s.Data[k] = []byte(v)
|
|
}
|
|
if _, err := d.client.CoreV1().Secrets(ns).Update(ctx, s, metav1.UpdateOptions{}); err != nil {
|
|
return fmt.Errorf("update secret %s: %w", s.Name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// RestartAll triggers a rolling restart of all deployments labeled project={projectName}.
|
|
func (d *Deployer) RestartAll(ctx context.Context, projectName string) error {
|
|
ns := d.config.Namespace
|
|
deploymentList, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{
|
|
LabelSelector: fmt.Sprintf("project=%s", projectName),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("list deployments for project %s: %w", projectName, err)
|
|
}
|
|
|
|
restartedAt := time.Now().Format(time.RFC3339)
|
|
for i := range deploymentList.Items {
|
|
dep := &deploymentList.Items[i]
|
|
if dep.Spec.Template.Annotations == nil {
|
|
dep.Spec.Template.Annotations = make(map[string]string)
|
|
}
|
|
dep.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = restartedAt
|
|
if _, err := d.client.AppsV1().Deployments(ns).Update(ctx, dep, metav1.UpdateOptions{}); err != nil {
|
|
return fmt.Errorf("restart deployment %s: %w", dep.Name, err)
|
|
}
|
|
}
|
|
return 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
|
|
}
|