rdev/internal/service/component_deploy.go
jordan ddcfe52b5c
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
feat: implement shared notify host model for platform email delivery
Replace per-project notify host provisioning (7-9 API calls + DNS + async
Resend verification) with a shared platform host for all *.threesix.ai projects.

Under the new model:
- CreateProjectNotify: 3 calls only (account + send key + host grant)
- No per-project Resend domain, DNS records, or async verification
- All *.threesix.ai projects share `threesix.ai` as the platform host
- Custom domains still get a dedicated host via ReprovisionNotifyHost

Changes:
- domain/notify.go: slim NotifyCredentials (no Host/From/ResendDomainID);
  add NotifyHostCredentials for reprovision return path
- port/notify_provisioner.go: update interface signatures and docs
- adapter/notify/provisioner.go: rewrite CreateProjectNotify (3 steps);
  rewrite DeleteProjectNotify (account-only vs full cleanup)
- adapter/notify/provisioner_reprovision.go: return *NotifyHostCredentials
- adapter/notify/provisioner_test.go: update tests for new model
- service/project_infra_crud.go: store only NOTIFY_API_KEY on provision
- domain/credential.go: add CredKeyNotifySharedHost/CredKeyNotifySharedFrom
- cmd/rdev-api/config.go: add NotifySharedHost/NotifySharedFrom to InfraConfig
- service/component.go: add notifySharedHost/notifySharedFrom + WithNotifyDefaults
- service/component_deploy.go: inject shared host defaults when no custom host stored
- handlers/notify.go: handle shared-host projects in Reprovision guard;
  add WithSharedNotifyHost builder
- cmd/rdev-api/main.go: wire SharedHost to provisioner, component service,
  and notify handler

Bootstrap: NOTIFY_SHARED_HOST=threesix.ai and NOTIFY_SHARED_FROM=noreply@threesix.ai
stored in credential store (host id=1 already provisioned with Resend provider).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:04:11 -07:00

280 lines
9.2 KiB
Go

package service
import (
"context"
"fmt"
"net"
"strconv"
"strings"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/logging"
)
// createInitialComponentDeployment creates a K8s Deployment for a newly added component.
// This ensures the deployment exists before CI runs, so kubectl set image succeeds.
// Deployments start with 0 replicas to avoid ImagePullBackOff (image doesn't exist yet).
// The CI deploy step scales to 1 after building and verifying the image.
// For monorepo projects, updates the project's unified Ingress with path-based routing.
// Failures are logged but don't fail the component creation.
func (s *ComponentService) createInitialComponentDeployment(
ctx context.Context,
projectID, projectDomain string,
component *domain.Component,
) {
// Skip if no deployer or component doesn't need a deployment
if s.deployer == nil || !component.Type.NeedsDeployment() {
return
}
// Build the image path - uses "latest" as placeholder until CI builds a real image
image := fmt.Sprintf("%s/%s/%s:latest", s.registryURL, projectID, component.Name)
// Assign URL path based on component type
basePath := assignComponentPath(component)
// Build sibling service URLs for service discovery
siblingServices := s.buildSiblingServiceURLs(ctx, projectID, component.Name)
// Fetch project credentials (DATABASE_URL, REDIS_URL, etc.) from credential store
secrets := s.fetchProjectCredentials(ctx, projectID)
// Look up Citadel labels for log routing
extraLabels := s.fetchCitadelLabels(ctx, projectID, component.Name)
spec := domain.DeploySpec{
ProjectName: projectID,
ComponentPath: component.Path,
Image: image,
Domain: projectDomain,
Port: component.Port,
Replicas: -1, // Negative = create with 0 replicas (no pods until CI builds the image)
BasePath: basePath,
SiblingServices: siblingServices,
Secrets: secrets,
ExtraLabels: extraLabels,
}
log := logging.FromContext(ctx).WithService("component")
// Create Deployment and Service (without Ingress - we manage that separately)
if err := s.deployer.Deploy(ctx, spec); err != nil {
log.Warn("failed to create initial component deployment",
logging.FieldProjectID, projectID,
"component", component.Name,
logging.FieldError, err,
)
return
}
// Add path to project's unified Ingress (only for components with an HTTP port)
if component.Port > 0 && basePath != "" {
serviceName := spec.DeploymentName()
if err := s.deployer.AddIngressPath(ctx, projectID, projectDomain, basePath, serviceName, component.Port); err != nil {
log.Warn("failed to add ingress path for component",
logging.FieldProjectID, projectID,
"component", component.Name,
"path", basePath,
logging.FieldError, err,
)
// Continue anyway - the deployment exists and CI will work
}
}
log.Info("created initial component deployment",
logging.FieldProjectID, projectID,
"component", component.Name,
"image", image,
"path", basePath,
)
}
// assignComponentPath determines the URL path for a component.
// Services get /api/{name}, apps get /.
//
// LIMITATION: All app components (app-react, app-astro, app-nextjs) get "/" path.
// If a project has multiple apps, they will conflict on the same path.
// Future enhancement: track assigned paths and use /apps/{name} for additional apps.
func assignComponentPath(component *domain.Component) string {
switch {
case component.Type == domain.ComponentTypeService:
return "/api/" + component.Name
case component.Type.IsAppComponent():
return "/"
default:
return "" // Workers, CLI don't get HTTP paths
}
}
// assignPort finds the next available port for a component type.
func (s *ComponentService) assignPort(ctx context.Context, projectID string, componentType domain.ComponentType) (int, error) {
// Get existing components to find the highest used port
components, err := s.ListComponents(ctx, projectID)
if err != nil {
return 0, err
}
startingPort := componentType.StartingPort()
if startingPort == 0 {
return 0, nil // Component type doesn't need a port
}
maxPort := startingPort - 1
for _, c := range components {
// Only consider components that share the same port range
if c.Type.StartingPort() == startingPort && c.Port > maxPort {
maxPort = c.Port
}
}
return maxPort + 1, nil
}
// buildSiblingServiceURLs constructs service discovery URLs for all sibling services.
// Returns a map of env var names to internal K8s service URLs.
// Example: {"AUTH_SVC_URL": "http://myproject-auth-svc:8001", "CHAT_SVC_URL": "http://myproject-chat-svc:8002"}
func (s *ComponentService) buildSiblingServiceURLs(ctx context.Context, projectID, currentComponent string) map[string]string {
components, err := s.ListComponents(ctx, projectID)
if err != nil {
log := logging.FromContext(ctx).WithService("component")
log.Warn("failed to list components for sibling discovery",
logging.FieldProjectID, projectID,
logging.FieldError, err,
)
return nil
}
urls := make(map[string]string)
for _, c := range components {
// Skip the current component, non-service types, and components without ports
if c.Name == currentComponent || c.Type != domain.ComponentTypeService || c.Port == 0 {
continue
}
// Build env var name: auth-svc -> AUTH_SVC_URL
envKey := toUpperSnake(c.Name) + "_URL"
// Build internal K8s service URL: http://projectid-componentname:port
serviceName := projectID + "-" + c.Name
urls[envKey] = "http://" + net.JoinHostPort(serviceName, strconv.Itoa(c.Port))
}
return urls
}
// toUpperSnake converts a kebab-case string to UPPER_SNAKE_CASE.
// Example: "auth-svc" -> "AUTH_SVC"
func toUpperSnake(s string) string {
return strings.ToUpper(strings.ReplaceAll(s, "-", "_"))
}
// fetchProjectCredentials retrieves stored infrastructure credentials for a project.
// Returns a map of env var names to values (e.g., {"DATABASE_URL": "postgres://...", "REDIS_URL": "redis://..."}).
// Missing credentials are silently skipped - not all projects have all infrastructure.
func (s *ComponentService) fetchProjectCredentials(ctx context.Context, projectID string) map[string]string {
if s.credentialStore == nil {
return nil
}
// Project-scoped credentials (stored as "{projectID}:{key}")
projectScopedKeys := []string{
"DATABASE_URL",
"DATABASE_URL_STAGING",
"REDIS_URL",
"REDIS_URL_STAGING",
"REDIS_PREFIX",
domain.CredKeyGCSBucket,
domain.CredKeyGCSServiceAccountJSON,
domain.CredKeyNotifyAPIKey,
domain.CredKeyNotifyHost,
domain.CredKeyNotifyFrom,
domain.CredKeyJWTSecret,
}
// Global credentials (stored without project prefix, shared across all projects)
globalKeys := []string{
domain.CredKeyLaozhangAPIKey,
domain.CredKeyGeminiAPIKey,
domain.CredKeyNotifyURL,
}
secrets := make(map[string]string)
log := logging.FromContext(ctx).WithService("component")
// Fetch project-scoped credentials
if len(projectScopedKeys) > 0 {
scopedKeys := make([]string, len(projectScopedKeys))
for i, key := range projectScopedKeys {
scopedKeys[i] = projectID + ":" + key
}
creds, err := s.credentialStore.GetMultiple(ctx, scopedKeys)
if err != nil {
log.Warn("failed to fetch project-scoped credentials",
logging.FieldProjectID, projectID,
logging.FieldError, err,
)
} else {
for scopedKey, value := range creds {
// Extract env var name: "myproject:DATABASE_URL" -> "DATABASE_URL"
parts := strings.SplitN(scopedKey, ":", 2)
if len(parts) == 2 && value != "" {
secrets[parts[1]] = value
}
}
}
}
// Fetch global credentials (AI providers, etc.)
if len(globalKeys) > 0 {
creds, err := s.credentialStore.GetMultiple(ctx, globalKeys)
if err != nil {
log.Warn("failed to fetch global credentials",
logging.FieldProjectID, projectID,
logging.FieldError, err,
)
} else {
for key, value := range creds {
if value != "" {
secrets[key] = value
}
}
}
}
// For projects using the platform shared notify host, inject shared NOTIFY_HOST and NOTIFY_FROM
// defaults so deployed components get the correct env vars. Projects with a custom sending
// domain already have NOTIFY_HOST stored in the per-project credential store; those take
// precedence via the project-scoped fetch above.
if secrets[domain.CredKeyNotifyHost] == "" && s.notifySharedHost != "" {
secrets[domain.CredKeyNotifyHost] = s.notifySharedHost
if secrets[domain.CredKeyNotifyFrom] == "" {
secrets[domain.CredKeyNotifyFrom] = s.notifySharedFrom
}
}
if len(secrets) > 0 {
log.Debug("fetched credentials for deployment",
logging.FieldProjectID, projectID,
"credential_count", len(secrets),
)
}
return secrets
}
// fetchCitadelLabels looks up the project's Citadel tenant_id and slug from the database
// and returns k8s labels for agent log routing. Returns nil if no Citadel environment exists.
func (s *ComponentService) fetchCitadelLabels(ctx context.Context, projectID, serviceName string) map[string]string {
var slug, tenantID string
err := s.db.QueryRowContext(ctx, `
SELECT COALESCE(slug, ''), COALESCE(citadel_tenant_id, '') FROM projects WHERE id = $1
`, projectID).Scan(&slug, &tenantID)
if err != nil || tenantID == "" {
return nil
}
return map[string]string{
"citadel.io/environment": slug,
"citadel.io/service": serviceName,
}
}