rdev/internal/service/project_infra_domains.go
jordan 34e72687e6 feat: Complete automation gaps for repeatable project deployments
- Initial K8s deployment auto-creation during project creation
- DNS record upsert support (create or update existing records)
- Ingress host management for domain aliases (AddIngressHost/RemoveIngressHost)
- Woodpecker deployer RBAC manifest for CI deploy steps
- Single-commit template seeding via Gitea bulk file API

Closes automation gaps exposed during www.threesix.ai launch:
- Projects now auto-create K8s Deployment/Service/Ingress on creation
- Domain aliases automatically update both DNS and K8s ingress
- CI deploy steps work without manual RBAC setup
- Template seeding triggers only one CI pipeline (not per-file)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 15:18:31 -07:00

170 lines
5.1 KiB
Go

package service
import (
"context"
"fmt"
"github.com/orchard9/rdev/internal/domain"
)
// AddDomain adds a new domain to a project.
// For threesix.ai subdomains, it creates the DNS record automatically.
// For external domains, it returns instructions for the user to configure their DNS.
func (s *ProjectInfraService) AddDomain(ctx context.Context, req AddDomainRequest) (*domain.ProjectDomain, error) {
if s.domainRepo == nil {
return nil, fmt.Errorf("domain repository not configured")
}
// Validate domain format
if err := domain.ValidateFQDN(req.Domain); err != nil {
return nil, fmt.Errorf("invalid domain: %w", err)
}
// Check if domain already exists
exists, err := s.domainRepo.Exists(ctx, req.Domain)
if err != nil {
return nil, fmt.Errorf("failed to check domain: %w", err)
}
if exists {
return nil, domain.ErrDuplicateDomain
}
// Default record type
recordType := req.RecordType
if recordType == "" {
recordType = "A"
}
// Default domain type
domainType := req.Type
if domainType == "" {
domainType = domain.DomainTypeAlias
}
pd := &domain.ProjectDomain{
ProjectID: req.ProjectID,
Domain: req.Domain,
Type: domainType,
DNSRecordType: recordType,
Verified: false,
}
// Create DNS record for threesix.ai subdomains
if domain.IsSubdomainOf(req.Domain, s.defaultDomain) && s.dns != nil {
subdomain := domain.ExtractSubdomain(req.Domain, s.defaultDomain)
content := s.clusterIP
if recordType == "CNAME" {
// CNAME points to the project's primary auto domain
status, err := s.GetStatus(ctx, req.ProjectID)
if err == nil && status.Slug != "" {
content = status.Slug + "." + s.defaultDomain
}
}
dnsRecord, err := s.dns.UpsertRecord(ctx, domain.DNSRecord{
Type: recordType,
Name: subdomain,
Content: content,
TTL: 1,
Proxied: req.Proxied,
})
if err != nil {
return nil, fmt.Errorf("failed to upsert DNS record: %w", err)
}
pd.DNSRecordID = dnsRecord.ID
pd.Verified = true
}
// Store in database
if err := s.domainRepo.Create(ctx, pd); err != nil {
// Rollback DNS record if database insert fails
if pd.DNSRecordID != "" && s.dns != nil {
_ = s.dns.DeleteRecord(ctx, pd.DNSRecordID)
}
return nil, fmt.Errorf("failed to store domain: %w", err)
}
// Add host to K8s ingress (for both threesix.ai and external domains)
if s.deployer != nil {
if err := s.deployer.AddIngressHost(ctx, req.ProjectID, req.Domain); err != nil {
s.logger.Warn("failed to add ingress host", "error", err, "project", req.ProjectID, "domain", req.Domain)
// Don't fail the request - DNS and DB are already set up
}
}
s.logger.Info("domain added", "project", req.ProjectID, "domain", req.Domain, "type", domainType)
return pd, nil
}
// ListDomains returns all domains for a project.
func (s *ProjectInfraService) ListDomains(ctx context.Context, projectID string) ([]*domain.ProjectDomain, error) {
if s.domainRepo == nil {
return nil, fmt.Errorf("domain repository not configured")
}
return s.domainRepo.ListByProject(ctx, projectID)
}
// RemoveDomain removes a domain from a project.
// Returns error if attempting to remove the primary_auto domain.
func (s *ProjectInfraService) RemoveDomain(ctx context.Context, projectID, fqdn string) error {
if s.domainRepo == nil {
return fmt.Errorf("domain repository not configured")
}
// Get the domain record
pd, err := s.domainRepo.GetByDomain(ctx, fqdn)
if err != nil {
return fmt.Errorf("failed to get domain: %w", err)
}
if pd == nil {
return domain.ErrDomainNotFound
}
// Verify it belongs to this project
if pd.ProjectID != projectID {
return fmt.Errorf("domain does not belong to project")
}
// Prevent deletion of primary_auto domain
if pd.Type == domain.DomainTypePrimaryAuto {
return fmt.Errorf("cannot remove auto-generated primary domain; delete the project instead")
}
// Delete DNS record if we have the record ID
if pd.DNSRecordID != "" && s.dns != nil {
if err := s.dns.DeleteRecord(ctx, pd.DNSRecordID); err != nil {
s.logger.Warn("failed to delete DNS record", "error", err, "domain", fqdn)
}
} else if domain.IsSubdomainOf(fqdn, s.defaultDomain) && s.dns != nil {
// Fallback: try to delete by name
subdomain := domain.ExtractSubdomain(fqdn, s.defaultDomain)
if subdomain != "" {
_ = s.dns.DeleteRecordByName(ctx, pd.DNSRecordType, subdomain)
}
}
// Remove host from K8s ingress
if s.deployer != nil {
if err := s.deployer.RemoveIngressHost(ctx, projectID, fqdn); err != nil {
s.logger.Warn("failed to remove ingress host", "error", err, "project", projectID, "domain", fqdn)
// Continue anyway - DNS record is already deleted
}
}
// Delete from database
if err := s.domainRepo.Delete(ctx, pd.ID); err != nil {
return fmt.Errorf("failed to delete domain: %w", err)
}
s.logger.Info("domain removed", "project", projectID, "domain", fqdn)
return nil
}
// GetPrimaryDomain returns the primary domain for a project.
func (s *ProjectInfraService) GetPrimaryDomain(ctx context.Context, projectID string) (*domain.ProjectDomain, error) {
if s.domainRepo == nil {
return nil, fmt.Errorf("domain repository not configured")
}
return s.domainRepo.GetPrimary(ctx, projectID)
}