All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Fix no-op RequireProjectAccess middleware to enforce project_ids
- Apply project access middleware to all project-scoped routes
- Filter GET /projects by allowed project IDs for restricted keys
- Add GET /me endpoint with key identity, scopes, and project access info
- Add PATCH /keys/{id} for partial key updates (name, scopes, project_ids, allowed_ips, expires_in)
- Add GET/POST/DELETE /projects/{id}/access for project-centric access management
- Auto-grant creating key access when using POST /project/create-and-build
- Accept grant_to_key_ids in create-and-build to grant multiple keys on project creation
- Move newProvisionerWithDeps test helper from production code to test file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
266 lines
8.5 KiB
Go
266 lines
8.5 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.NeedsPort() {
|
|
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
|
|
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/service exist 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,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|