package service import ( "context" "fmt" "github.com/orchard9/rdev/internal/domain" "github.com/orchard9/rdev/internal/logging" ) // 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) log := logging.FromContext(ctx).WithService("project_infra") if s.deployer != nil { if err := s.deployer.AddIngressHost(ctx, req.ProjectID, req.Domain); err != nil { log.Warn("failed to add ingress host", logging.FieldError, err, logging.FieldProjectID, req.ProjectID, "domain", req.Domain) // Don't fail the request - DNS and DB are already set up } } log.Info("domain added", logging.FieldProjectID, 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") } log := logging.FromContext(ctx).WithService("project_infra") // 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 { log.Warn("failed to delete DNS record", logging.FieldError, 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 { log.Warn("failed to remove ingress host", logging.FieldError, err, logging.FieldProjectID, 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) } log.Info("domain removed", logging.FieldProjectID, 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) }