feat: add POST /projects/{id}/notify/provision to repair Resend domain
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Repairs projects where notify account was created but Resend domain
provisioning (steps 7-9) failed — e.g., RESEND_API_KEY not yet
configured at project creation time.
- ProvisionNotifyDomain in port.NotifyProvisioner interface
- Provisioner.ProvisionNotifyDomain: creates Resend domain for existing
notify host, upserts DKIM/SPF DNS records in Cloudflare, kicks off
async verification via verifyWithRetry
- POST /projects/{id}/notify/provision handler:
- reads NOTIFY_HOST from credential store (fails if not set)
- rejects if NOTIFY_RESEND_DOMAIN_ID already present (use /verify)
- stores returned domain ID as NOTIFY_RESEND_DOMAIN_ID credential
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b03c1f8028
commit
c54664b751
@ -321,6 +321,54 @@ func (p *Provisioner) VerifyProjectNotify(ctx context.Context, projectID, resend
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProvisionNotifyDomain creates the Resend domain for an existing notify host, adds DKIM/SPF DNS
|
||||
// records via Cloudflare, and kicks off async verification. Use this to repair projects where
|
||||
// Resend domain creation failed during initial provisioning (steps 7-9 of CreateProjectNotify).
|
||||
// Returns the Resend domain ID which must be stored as NOTIFY_RESEND_DOMAIN_ID by the caller.
|
||||
func (p *Provisioner) ProvisionNotifyDomain(ctx context.Context, projectID, host string) (string, error) {
|
||||
if p.resend == nil {
|
||||
return "", fmt.Errorf("notify: resend not configured")
|
||||
}
|
||||
if host == "" {
|
||||
return "", fmt.Errorf("notify: host is required")
|
||||
}
|
||||
|
||||
// Step 7: Create Resend domain for the existing notify host.
|
||||
resendDomainID, dnsRecords, err := p.resend.createDomain(ctx, host, "us-east-1")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("notify: create resend domain for %s: %w", host, err)
|
||||
}
|
||||
p.logger.Info("resend domain created", "host", host, "domain_id", resendDomainID, "project_id", projectID)
|
||||
|
||||
// Step 8: Add DKIM/SPF DNS records (non-fatal).
|
||||
if p.dns != nil && len(dnsRecords) > 0 {
|
||||
for _, rec := range dnsRecords {
|
||||
fqdn := rec.Name + "." + host
|
||||
if _, upsertErr := p.dns.UpsertRecord(ctx, domain.DNSRecord{
|
||||
Type: rec.Record,
|
||||
Name: fqdn,
|
||||
Content: rec.Value,
|
||||
TTL: 1,
|
||||
}); upsertErr != nil {
|
||||
p.logger.Warn("failed to upsert notify DNS record",
|
||||
"name", fqdn,
|
||||
"record", rec.Record,
|
||||
"project_id", projectID,
|
||||
"error", upsertErr,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 9: Fire-and-forget async verification with DNS propagation wait.
|
||||
go func() {
|
||||
verifyCtx := context.WithoutCancel(ctx)
|
||||
p.verifyWithRetry(verifyCtx, resendDomainID, host, projectID)
|
||||
}()
|
||||
|
||||
return resendDomainID, nil
|
||||
}
|
||||
|
||||
// GetNotifyDomainStatus returns the Resend verification status for the project's email domain.
|
||||
func (p *Provisioner) GetNotifyDomainStatus(ctx context.Context, host, resendDomainID string) (*domain.NotifyDomainStatus, error) {
|
||||
if p.resend == nil || resendDomainID == "" {
|
||||
|
||||
@ -37,6 +37,8 @@ func (h *NotifyHandler) Mount(r api.Router) {
|
||||
Get("/status", h.GetStatus)
|
||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||
Post("/verify", h.Verify)
|
||||
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||
Post("/provision", h.Provision)
|
||||
})
|
||||
}
|
||||
|
||||
@ -120,6 +122,76 @@ func (h *NotifyHandler) Verify(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// Provision creates the Resend domain and DNS records for a project whose notify account exists
|
||||
// but whose Resend domain was never provisioned (e.g., RESEND_API_KEY was added after project creation).
|
||||
// POST /projects/{projectID}/notify/provision
|
||||
func (h *NotifyHandler) Provision(w http.ResponseWriter, r *http.Request) {
|
||||
if h.notifyProvisioner == nil {
|
||||
api.WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "notify not configured")
|
||||
return
|
||||
}
|
||||
|
||||
projectID := chi.URLParam(r, "projectID")
|
||||
if projectID == "" {
|
||||
api.WriteBadRequest(w, r, "project ID is required")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
|
||||
defer cancel()
|
||||
|
||||
log := logging.FromContext(ctx).WithHandler("NotifyProvision")
|
||||
|
||||
host, existingDomainID := h.lookupNotifyCredentials(ctx, projectID)
|
||||
if host == "" {
|
||||
api.WriteBadRequest(w, r, "NOTIFY_HOST not found for project — notify account not provisioned")
|
||||
return
|
||||
}
|
||||
if existingDomainID != "" {
|
||||
// Already has a Resend domain ID; use /verify or /status instead
|
||||
api.WriteBadRequest(w, r, "Resend domain already provisioned — use POST /notify/verify to re-verify")
|
||||
return
|
||||
}
|
||||
|
||||
resendDomainID, err := h.notifyProvisioner.ProvisionNotifyDomain(ctx, projectID, host)
|
||||
if err != nil {
|
||||
log.Error("failed to provision notify domain",
|
||||
logging.FieldError, err,
|
||||
logging.FieldProjectID, projectID,
|
||||
)
|
||||
api.WriteInternalError(w, r, "failed to provision Resend domain")
|
||||
return
|
||||
}
|
||||
|
||||
// Store the new domain ID so future status/verify calls work.
|
||||
if h.credStore != nil {
|
||||
if err := h.credStore.Set(ctx, domain.Credential{
|
||||
Key: projectID + ":" + domain.CredKeyNotifyResendDomainID,
|
||||
Value: resendDomainID,
|
||||
Category: domain.CredentialCategoryNotify,
|
||||
}); err != nil {
|
||||
log.Error("provisioned Resend domain but failed to store domain ID",
|
||||
logging.FieldError, err,
|
||||
logging.FieldProjectID, projectID,
|
||||
"resend_domain_id", resendDomainID,
|
||||
)
|
||||
// Return success anyway — the domain IS provisioned; the ID can be stored manually.
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("notify domain provisioned",
|
||||
logging.FieldProjectID, projectID,
|
||||
"host", host,
|
||||
"resend_domain_id", resendDomainID,
|
||||
)
|
||||
|
||||
api.WriteSuccess(w, r, map[string]string{
|
||||
"host": host,
|
||||
"resend_domain_id": resendDomainID,
|
||||
"status": "verifying",
|
||||
})
|
||||
}
|
||||
|
||||
// lookupNotifyCredentials fetches NOTIFY_HOST and NOTIFY_RESEND_DOMAIN_ID from the credential store.
|
||||
// Returns empty strings if credStore is nil or the credentials are not found.
|
||||
func (h *NotifyHandler) lookupNotifyCredentials(ctx context.Context, projectID string) (host, resendDomainID string) {
|
||||
|
||||
@ -30,4 +30,10 @@ type NotifyProvisioner interface {
|
||||
|
||||
// GetNotifyDomainStatus returns the Resend verification status for the project's email domain.
|
||||
GetNotifyDomainStatus(ctx context.Context, host, resendDomainID string) (*domain.NotifyDomainStatus, error)
|
||||
|
||||
// ProvisionNotifyDomain creates the Resend domain for an existing notify host,
|
||||
// adds DKIM/SPF DNS records, and starts async verification.
|
||||
// Use this to repair projects where steps 7-9 of CreateProjectNotify failed.
|
||||
// Returns the Resend domain ID for storage in the credential store.
|
||||
ProvisionNotifyDomain(ctx context.Context, projectID, host string) (resendDomainID string, err error)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user