Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
413 lines
14 KiB
Go
413 lines
14 KiB
Go
// Package service provides business logic services.
|
|
package service
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
giteaadapter "github.com/orchard9/rdev/internal/adapter/gitea"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/logging"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// procfilePatterns contains pre-compiled regex patterns for parsing Procfile entries
|
|
// by component type. Compiled once at package init for performance.
|
|
var procfilePatterns = make(map[domain.ComponentType]*regexp.Regexp)
|
|
|
|
func init() {
|
|
// Pre-compile regex patterns for each component type's Procfile entries
|
|
for _, ct := range domain.ValidComponentTypes {
|
|
destDir := ct.DestDir()
|
|
if destDir != "" {
|
|
// Pattern matches: "component-name: cd services/component-name && ..."
|
|
procfilePatterns[ct] = regexp.MustCompile(`^([a-z][a-z0-9-]*): cd (` + destDir + `/[a-z0-9-]+)`)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ComponentService manages components within monorepo projects.
|
|
type ComponentService struct {
|
|
db *sql.DB
|
|
templateProvider port.TemplateProvider
|
|
bulkClient *giteaadapter.BulkFileClient
|
|
deployer port.Deployer
|
|
defaultGitOwner string
|
|
registryURL string
|
|
|
|
// Infrastructure provisioners (optional - needed for postgres/redis/gcs components)
|
|
dbProvisioner port.DatabaseProvisioner
|
|
cacheProvisioner port.CacheProvisioner
|
|
storageProvisioner port.StorageProvisioner
|
|
credentialStore port.CredentialStore
|
|
|
|
// Notify shared-host defaults (injected into deployments when no per-project custom host is stored)
|
|
notifySharedHost string
|
|
notifySharedFrom string
|
|
}
|
|
|
|
// ComponentServiceConfig configures the component service.
|
|
type ComponentServiceConfig struct {
|
|
DefaultGitOwner string // e.g., "threesix"
|
|
RegistryURL string // e.g., "registry.threesix.ai"
|
|
}
|
|
|
|
// NewComponentService creates a new component service.
|
|
func NewComponentService(
|
|
db *sql.DB,
|
|
templateProvider port.TemplateProvider,
|
|
bulkClient *giteaadapter.BulkFileClient,
|
|
deployer port.Deployer,
|
|
cfg ComponentServiceConfig,
|
|
) *ComponentService {
|
|
return &ComponentService{
|
|
db: db,
|
|
templateProvider: templateProvider,
|
|
bulkClient: bulkClient,
|
|
deployer: deployer,
|
|
defaultGitOwner: cfg.DefaultGitOwner,
|
|
registryURL: cfg.RegistryURL,
|
|
}
|
|
}
|
|
|
|
// Ensure ComponentService implements the interface.
|
|
var _ port.ComponentService = (*ComponentService)(nil)
|
|
|
|
// WithDatabaseProvisioner adds a database provisioner for postgres component support.
|
|
func (s *ComponentService) WithDatabaseProvisioner(p port.DatabaseProvisioner) *ComponentService {
|
|
s.dbProvisioner = p
|
|
return s
|
|
}
|
|
|
|
// WithCacheProvisioner adds a cache provisioner for redis component support.
|
|
func (s *ComponentService) WithCacheProvisioner(p port.CacheProvisioner) *ComponentService {
|
|
s.cacheProvisioner = p
|
|
return s
|
|
}
|
|
|
|
// WithStorageProvisioner adds a storage provisioner for gcs component support.
|
|
func (s *ComponentService) WithStorageProvisioner(p port.StorageProvisioner) *ComponentService {
|
|
s.storageProvisioner = p
|
|
return s
|
|
}
|
|
|
|
// WithCredentialStore adds a credential store for storing provisioned credentials.
|
|
func (s *ComponentService) WithCredentialStore(cs port.CredentialStore) *ComponentService {
|
|
s.credentialStore = cs
|
|
return s
|
|
}
|
|
|
|
// WithNotifyDefaults sets the platform shared notify host and from-address.
|
|
// These are injected into component deployments as NOTIFY_HOST and NOTIFY_FROM
|
|
// when the project has not provisioned a custom sending domain.
|
|
func (s *ComponentService) WithNotifyDefaults(host, from string) *ComponentService {
|
|
s.notifySharedHost = host
|
|
s.notifySharedFrom = from
|
|
return s
|
|
}
|
|
|
|
// AddComponent adds a new component to a project's monorepo.
|
|
// For code components (service, worker, app-*, cli), this scaffolds template files.
|
|
// For infrastructure components (postgres, redis), this provisions the resource.
|
|
func (s *ComponentService) AddComponent(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
|
|
// 1. Validate component type
|
|
if !domain.IsValidComponentType(req.Type) {
|
|
return nil, fmt.Errorf("%w: %s", domain.ErrInvalidComponentType, req.Type)
|
|
}
|
|
componentType := domain.ComponentType(req.Type)
|
|
|
|
// 2. Validate component name
|
|
if err := domain.ValidateComponentName(req.Name); err != nil {
|
|
return nil, fmt.Errorf("%w: %s", err, req.Name)
|
|
}
|
|
|
|
// 3. Route infrastructure components to provisioning
|
|
if componentType.IsInfraComponent() {
|
|
return s.addInfraComponent(ctx, projectID, componentType, req.Name)
|
|
}
|
|
|
|
// --- Code component flow (service, worker, app-*, cli) ---
|
|
|
|
// 4. Get project info from database
|
|
var gitRepoOwner, gitRepoName, goModule string
|
|
var projectDomain string
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT COALESCE(git_repo_owner, $2), COALESCE(git_repo_name, $1), COALESCE(domain, '')
|
|
FROM projects WHERE id = $1
|
|
`, projectID, s.defaultGitOwner).Scan(&gitRepoOwner, &gitRepoName, &projectDomain)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("%w: %s", domain.ErrProjectNotFound, projectID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get project: %w", err)
|
|
}
|
|
|
|
// Build Go module path (use actual git host, not github.com)
|
|
goModule = fmt.Sprintf("git.threesix.ai/%s/%s", gitRepoOwner, gitRepoName)
|
|
|
|
// 4. Calculate component path
|
|
destDir := componentType.DestDir()
|
|
componentPath := filepath.Join(destDir, req.Name)
|
|
|
|
// 5. Check for duplicate component by checking for key files
|
|
checkFile := componentPath + "/go.mod"
|
|
if componentType == domain.ComponentTypeAppAstro || componentType == domain.ComponentTypeAppReact {
|
|
checkFile = componentPath + "/package.json"
|
|
}
|
|
existingContent, _, err := s.bulkClient.GetFileContent(ctx, gitRepoOwner, gitRepoName, checkFile)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to check for existing component: %w", err)
|
|
}
|
|
if existingContent != nil {
|
|
return nil, fmt.Errorf("%w: %s", domain.ErrDuplicateComponent, componentPath)
|
|
}
|
|
|
|
// 6. Assign port if needed
|
|
port := req.Port
|
|
if port == 0 && componentType.NeedsPort() {
|
|
port, err = s.assignPort(ctx, projectID, componentType)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to assign port: %w", err)
|
|
}
|
|
}
|
|
|
|
// 7. Prepare template variables
|
|
vars := map[string]string{
|
|
"PROJECT_NAME": projectID,
|
|
"GO_MODULE": goModule,
|
|
"COMPONENT_NAME": req.Name,
|
|
"PORT": strconv.Itoa(port),
|
|
"DOMAIN": projectDomain,
|
|
}
|
|
|
|
// For frontend apps, inject the primary service name/port for API proxy
|
|
if componentType.IsAppComponent() {
|
|
if svc := s.findFirstServiceComponent(ctx, projectID); svc != nil {
|
|
vars["SERVICE_NAME"] = svc.Name
|
|
vars["SERVICE_PORT"] = strconv.Itoa(svc.Port)
|
|
}
|
|
}
|
|
|
|
// 8. Get component template files
|
|
componentFiles, err := s.templateProvider.GetComponentFiles(ctx, req.Type, componentPath, vars)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get component template files: %w", err)
|
|
}
|
|
|
|
// 9. Read and update monorepo files
|
|
fileOps, err := s.prepareMonorepoUpdates(ctx, gitRepoOwner, gitRepoName, componentType, req.Name, componentPath, port, vars)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to prepare monorepo updates: %w", err)
|
|
}
|
|
|
|
// 10. Add component files to the operations
|
|
for _, cf := range componentFiles {
|
|
// Skip the .woodpecker.step.yml file - it's merged into main .woodpecker.yml
|
|
if strings.HasSuffix(cf.Path, ".woodpecker.step.yml") {
|
|
continue
|
|
}
|
|
encodedContent := base64.StdEncoding.EncodeToString([]byte(cf.Content))
|
|
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
|
|
Operation: "create",
|
|
Path: cf.Path,
|
|
Content: encodedContent,
|
|
})
|
|
}
|
|
|
|
// 11. Commit all files in a single atomic commit
|
|
opts := giteaadapter.ChangeFilesOptions{
|
|
Files: fileOps,
|
|
Message: fmt.Sprintf("Add %s component: %s", req.Type, req.Name),
|
|
}
|
|
|
|
_, err = s.bulkClient.ChangeFiles(ctx, gitRepoOwner, gitRepoName, opts)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to commit component files: %w", err)
|
|
}
|
|
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
log.Info("component added successfully",
|
|
logging.FieldProjectID, projectID,
|
|
"component_type", req.Type,
|
|
"component_name", req.Name,
|
|
"path", componentPath,
|
|
"port", port,
|
|
)
|
|
|
|
// 12. Build and return the component
|
|
component := &domain.Component{
|
|
Type: componentType,
|
|
Name: req.Name,
|
|
Path: componentPath,
|
|
Port: port,
|
|
Template: req.Type,
|
|
Dependencies: []string{}, // Could be parsed from component.yaml
|
|
}
|
|
|
|
// 13. Ensure a JWT_SECRET exists for this project (required by skeleton service startup).
|
|
// Generated once per project on the first code component; reused for all subsequent components.
|
|
s.ensureProjectJWTSecret(ctx, projectID)
|
|
|
|
// 14. Create initial K8s deployment for components that need one.
|
|
// This ensures kubectl set image will find the deployment when CI runs.
|
|
s.createInitialComponentDeployment(ctx, projectID, projectDomain, component)
|
|
|
|
return component, nil
|
|
}
|
|
|
|
// prepareMonorepoUpdates reads existing monorepo files and prepares updates.
|
|
func (s *ComponentService) prepareMonorepoUpdates(
|
|
ctx context.Context,
|
|
owner, repo string,
|
|
componentType domain.ComponentType,
|
|
componentName, componentPath string,
|
|
port int,
|
|
vars map[string]string,
|
|
) ([]giteaadapter.ChangeFileOperation, error) {
|
|
var fileOps []giteaadapter.ChangeFileOperation
|
|
|
|
// 1. Update Procfile
|
|
procfileContent, procfileSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "Procfile")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get Procfile: %w", err)
|
|
}
|
|
if procfileContent != nil {
|
|
updated := s.updateProcfile(string(procfileContent), componentType, componentName, componentPath, port)
|
|
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
|
|
Operation: "update",
|
|
Path: "Procfile",
|
|
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
|
|
SHA: procfileSHA,
|
|
})
|
|
}
|
|
|
|
// 2. Update go.work for Go components
|
|
if componentType.IsGoComponent() {
|
|
goWorkContent, goWorkSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "go.work")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get go.work: %w", err)
|
|
}
|
|
if goWorkContent != nil {
|
|
updated := s.updateGoWork(string(goWorkContent), componentPath)
|
|
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
|
|
Operation: "update",
|
|
Path: "go.work",
|
|
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
|
|
SHA: goWorkSHA,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 3. Update .woodpecker.yml
|
|
woodpeckerContent, woodpeckerSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, ".woodpecker.yml")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get .woodpecker.yml: %w", err)
|
|
}
|
|
if woodpeckerContent != nil {
|
|
// Get the CI step template for this component type
|
|
stepYaml, err := s.templateProvider.GetComponentWoodpeckerStep(ctx, string(componentType), vars)
|
|
if err != nil {
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
log.Warn("failed to get woodpecker step template", logging.FieldError, err)
|
|
} else {
|
|
updated := s.updateWoodpeckerYml(string(woodpeckerContent), stepYaml)
|
|
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
|
|
Operation: "update",
|
|
Path: ".woodpecker.yml",
|
|
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
|
|
SHA: woodpeckerSHA,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 4. Update CLAUDE.md
|
|
claudeMdContent, claudeMdSHA, err := s.bulkClient.GetFileContent(ctx, owner, repo, "CLAUDE.md")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get CLAUDE.md: %w", err)
|
|
}
|
|
if claudeMdContent != nil {
|
|
updated := s.updateClaudeMd(string(claudeMdContent), componentType, componentName, componentPath)
|
|
fileOps = append(fileOps, giteaadapter.ChangeFileOperation{
|
|
Operation: "update",
|
|
Path: "CLAUDE.md",
|
|
Content: base64.StdEncoding.EncodeToString([]byte(updated)),
|
|
SHA: claudeMdSHA,
|
|
})
|
|
}
|
|
|
|
return fileOps, nil
|
|
}
|
|
|
|
// ensureProjectJWTSecret generates and stores a random JWT_SECRET for the project
|
|
// if one does not already exist. Called on first code component add.
|
|
// The secret is stored as "{projectID}:JWT_SECRET" in the credential store.
|
|
func (s *ComponentService) ensureProjectJWTSecret(ctx context.Context, projectID string) {
|
|
if s.credentialStore == nil {
|
|
return
|
|
}
|
|
|
|
log := logging.FromContext(ctx).WithService("component")
|
|
|
|
key := projectID + ":" + domain.CredKeyJWTSecret
|
|
existing, err := s.credentialStore.Get(ctx, key)
|
|
if err != nil {
|
|
log.Warn("failed to check JWT secret existence",
|
|
logging.FieldProjectID, projectID,
|
|
logging.FieldError, err,
|
|
)
|
|
return
|
|
}
|
|
if existing != "" {
|
|
return // Already provisioned - don't overwrite
|
|
}
|
|
|
|
// Generate a cryptographically random 32-byte secret
|
|
secret := make([]byte, 32)
|
|
if _, err := rand.Read(secret); err != nil {
|
|
log.Warn("failed to generate JWT secret",
|
|
logging.FieldProjectID, projectID,
|
|
logging.FieldError, err,
|
|
)
|
|
return
|
|
}
|
|
|
|
secretValue := base64.URLEncoding.EncodeToString(secret)
|
|
if err := s.credentialStore.Set(ctx, domain.Credential{
|
|
Key: key,
|
|
Value: secretValue,
|
|
Description: "JWT signing secret for project " + projectID,
|
|
Category: "project",
|
|
}); err != nil {
|
|
log.Warn("failed to store JWT secret",
|
|
logging.FieldProjectID, projectID,
|
|
logging.FieldError, err,
|
|
)
|
|
return
|
|
}
|
|
|
|
log.Info("provisioned JWT secret for project",
|
|
logging.FieldProjectID, projectID,
|
|
)
|
|
}
|
|
|
|
// findFirstServiceComponent returns the first service component in a project, or nil.
|
|
func (s *ComponentService) findFirstServiceComponent(ctx context.Context, projectID string) *domain.Component {
|
|
components, err := s.ListComponents(ctx, projectID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
for i := range components {
|
|
if components[i].Type == domain.ComponentTypeService {
|
|
return &components[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|