rdev/internal/adapter/notify/provisioner_reprovision.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

161 lines
5.7 KiB
Go

package notify
import (
"context"
"fmt"
"github.com/orchard9/rdev/internal/domain"
)
// ReprovisionNotifyHost migrates a project's notify setup from oldHost to newHost.
// The project's notify account and send key are preserved; only the sending host,
// Resend domain, and DNS records are replaced.
//
// Steps:
// 1. Create new notify host newHost
// 2. Add Resend provider to newHost
// 3. Register noreply@newHost as from-address on newHost
// 4. Find the project's existing notify account
// 5. Revoke account access to oldHost (non-fatal)
// 6. Grant account access to newHost (non-fatal)
// 7. Create Resend domain for newHost
// 8. Add DKIM/SPF DNS records for newHost (non-fatal)
// 9. Delete old Resend domain (non-fatal)
//
// 10. Delete old DNS records for oldHost (non-fatal)
// 11. Delete old notify host (non-fatal)
// 12. Fire-and-forget async domain verification
func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyHostCredentials, error) {
newFrom := "noreply@" + newHost
// 1. Create new notify host.
if err := p.client.createHost(ctx, newHost, "failover"); err != nil {
return nil, fmt.Errorf("notify: create host %s for project %s: %w", newHost, projectID, err)
}
// 2. Add Resend provider to new host.
if p.resend != nil {
if err := p.client.createProvider(ctx, newHost, "resend", map[string]string{"api_key": p.resendAPIKey}, 1, 3, 1000); err != nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: create provider on host %s for project %s: %w", newHost, projectID, err)
}
}
// 3. Register from-address on new host.
slug := newHost
if err := p.client.createFromAddress(ctx, newHost, newFrom, slug); err != nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: create from-address %s for project %s: %w", newFrom, projectID, err)
}
// 4. Find existing account.
acct, err := p.findAccountByProject(ctx, projectID)
if err != nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: find account for project %s: %w", projectID, err)
}
if acct == nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: no account found for project %s", projectID)
}
// 5. Revoke old host access (non-fatal).
if oldHost != "" {
if err := p.client.revokeHostAccess(ctx, oldHost, acct.ID); err != nil {
p.logger.Warn("failed to revoke old notify host access",
"old_host", oldHost, "account_id", acct.ID, "project_id", projectID, "error", err)
}
}
// 6. Grant new host access (non-fatal).
if err := p.client.grantHostAccess(ctx, newHost, acct.ID); err != nil {
p.logger.Warn("failed to grant new notify host access",
"new_host", newHost, "account_id", acct.ID, "project_id", projectID, "error", err)
}
// 7. Create Resend domain for new host.
var newResendDomainID string
var dnsRecords []resendDNSRecord
if p.resend != nil {
var resendErr error
newResendDomainID, dnsRecords, resendErr = p.resend.createDomain(ctx, newHost, "us-east-1")
if resendErr != nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: create resend domain for %s: %w", newHost, resendErr)
}
p.logger.Info("resend domain created for new host", "host", newHost, "domain_id", newResendDomainID, "project_id", projectID)
}
// 8. Add DKIM/SPF DNS records for new host (non-fatal).
if p.dns != nil && len(dnsRecords) > 0 {
for _, rec := range dnsRecords {
if _, upsertErr := p.dns.UpsertRecord(ctx, domain.DNSRecord{
Type: rec.DNSType,
Name: rec.Name,
Content: rec.Value,
TTL: 1,
Priority: rec.Priority,
}); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record for new host",
"name", rec.Name, "type", rec.DNSType, "project_id", projectID, "error", upsertErr)
}
}
}
// 9. Delete old Resend domain (non-fatal).
if p.resend != nil && oldResendDomainID != "" {
if err := p.resend.deleteDomain(ctx, oldResendDomainID); err != nil {
p.logger.Warn("failed to delete old resend domain",
"domain_id", oldResendDomainID, "project_id", projectID, "error", err)
}
}
// 10. Delete old DNS records for oldHost (non-fatal).
if p.dns != nil && oldHost != "" {
dkimName := "resend._domainkey." + oldHost
if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil {
p.logger.Warn("failed to delete old DKIM DNS record",
"name", dkimName, "project_id", projectID, "error", err)
}
spfSendName := "send." + oldHost
if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil {
p.logger.Warn("failed to delete old SPF MX DNS record",
"name", spfSendName, "project_id", projectID, "error", err)
}
if err := p.dns.DeleteRecordByName(ctx, "TXT", spfSendName); err != nil {
p.logger.Warn("failed to delete old SPF TXT DNS record",
"name", spfSendName, "project_id", projectID, "error", err)
}
}
// 11. Delete old notify host (non-fatal).
if oldHost != "" {
if err := p.client.deleteHost(ctx, oldHost); err != nil {
p.logger.Warn("failed to delete old notify host",
"host", oldHost, "project_id", projectID, "error", err)
}
}
// 12. Fire-and-forget async domain verification.
if p.resend != nil && newResendDomainID != "" {
go func() {
verifyCtx := context.WithoutCancel(ctx)
p.verifyWithRetry(verifyCtx, newResendDomainID, newHost, projectID)
}()
}
p.logger.Info("notify host reprovisioned",
"project_id", projectID,
"old_host", oldHost,
"new_host", newHost,
"resend_domain_id", newResendDomainID,
)
return &domain.NotifyHostCredentials{
ProjectID: projectID,
Host: newHost,
From: newFrom,
ResendDomainID: newResendDomainID,
}, nil
}