All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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>
161 lines
5.7 KiB
Go
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
|
|
}
|