rdev/internal/adapter/notify/provisioner_reprovision.go
jordan 96219a647f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: add POST /projects/{id}/notify/reprovision to migrate notify host
Implements ReprovisionNotifyHost to migrate a project's email sending
from an old notify host to a new one (e.g., from project-name-based to
slug-based host). Preserves the project's notify account and send key.

- Adds ReprovisionNotifyHost to port.NotifyProvisioner interface
- Implements revokeHostAccess on notifyAdminAPI + adminClient
- Implements Provisioner.ReprovisionNotifyHost (12-step migration)
  in provisioner_reprovision.go (split to keep provisioner.go < 500 lines)
- Adds NotifyHandler.Reprovision handler (POST /notify/reprovision)
- Updates OpenAPI spec with reprovision endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:28:59 -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.NotifyCredentials, 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.NotifyCredentials{
ProjectID: projectID,
Host: newHost,
From: newFrom,
ResendDomainID: newResendDomainID,
}, nil
}