Compare commits
2 Commits
62a9bbb237
...
ddcfe52b5c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddcfe52b5c | ||
|
|
17240f4efd |
@ -89,9 +89,11 @@ type InfraConfig struct {
|
|||||||
GCSLocation string // Bucket location (default: "US")
|
GCSLocation string // Bucket location (default: "US")
|
||||||
|
|
||||||
// Notify provisioner (for per-project email delivery)
|
// Notify provisioner (for per-project email delivery)
|
||||||
NotifyURL string // e.g., "https://notify.orchard9.ai"
|
NotifyURL string // e.g., "https://notify.orchard9.ai"
|
||||||
NotifyAdminKey string // notify_admin_... admin API key
|
NotifyAdminKey string // notify_admin_... admin API key
|
||||||
ResendAPIKey string // re_... Resend API key for per-project domain provisioning
|
ResendAPIKey string // re_... Resend API key for per-project domain provisioning
|
||||||
|
NotifySharedHost string // pre-provisioned platform sending host (e.g., "mail.threesix.ai")
|
||||||
|
NotifySharedFrom string // from-address for the shared host (e.g., "noreply@mail.threesix.ai")
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig() Config {
|
func loadConfig() Config {
|
||||||
@ -155,6 +157,8 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
|||||||
domain.CredKeyNotifyURL,
|
domain.CredKeyNotifyURL,
|
||||||
domain.CredKeyNotifyAdminKey,
|
domain.CredKeyNotifyAdminKey,
|
||||||
domain.CredKeyResendAPIKey,
|
domain.CredKeyResendAPIKey,
|
||||||
|
domain.CredKeyNotifySharedHost,
|
||||||
|
domain.CredKeyNotifySharedFrom,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("failed to load credentials from store, using env vars", "error", err)
|
logger.Warn("failed to load credentials from store, using env vars", "error", err)
|
||||||
@ -199,9 +203,11 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
|||||||
GCSLocation: envutil.GetEnv("GCS_LOCATION", "US"),
|
GCSLocation: envutil.GetEnv("GCS_LOCATION", "US"),
|
||||||
|
|
||||||
// Notify provisioner (credential store with env fallback)
|
// Notify provisioner (credential store with env fallback)
|
||||||
NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")),
|
NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")),
|
||||||
NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")),
|
NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")),
|
||||||
ResendAPIKey: getOrFallback(domain.CredKeyResendAPIKey, os.Getenv("RESEND_API_KEY")),
|
ResendAPIKey: getOrFallback(domain.CredKeyResendAPIKey, os.Getenv("RESEND_API_KEY")),
|
||||||
|
NotifySharedHost: getOrFallback(domain.CredKeyNotifySharedHost, os.Getenv("NOTIFY_SHARED_HOST")),
|
||||||
|
NotifySharedFrom: getOrFallback(domain.CredKeyNotifySharedFrom, os.Getenv("NOTIFY_SHARED_FROM")),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log which credentials were loaded from store vs env
|
// Log which credentials were loaded from store vs env
|
||||||
|
|||||||
@ -266,12 +266,16 @@ func main() {
|
|||||||
AdminKey: infraCfg.NotifyAdminKey,
|
AdminKey: infraCfg.NotifyAdminKey,
|
||||||
ResendAPIKey: infraCfg.ResendAPIKey,
|
ResendAPIKey: infraCfg.ResendAPIKey,
|
||||||
BaseDomain: infraCfg.DefaultDomain,
|
BaseDomain: infraCfg.DefaultDomain,
|
||||||
|
SharedHost: infraCfg.NotifySharedHost,
|
||||||
}, dnsClient, logger)
|
}, dnsClient, logger)
|
||||||
if err := np.TestConnection(context.Background()); err != nil {
|
if err := np.TestConnection(context.Background()); err != nil {
|
||||||
logger.Warn("notify provisioner connection test failed, disabling", "error", err)
|
logger.Warn("notify provisioner connection test failed, disabling", "error", err)
|
||||||
} else {
|
} else {
|
||||||
notifyProvisioner = np
|
notifyProvisioner = np
|
||||||
logger.Info("notify provisioner initialized", "url", infraCfg.NotifyURL)
|
logger.Info("notify provisioner initialized",
|
||||||
|
"url", infraCfg.NotifyURL,
|
||||||
|
"shared_host", infraCfg.NotifySharedHost,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -562,7 +566,8 @@ func main() {
|
|||||||
WithDatabaseProvisioner(dbProvisioner).
|
WithDatabaseProvisioner(dbProvisioner).
|
||||||
WithCacheProvisioner(cacheProvisioner).
|
WithCacheProvisioner(cacheProvisioner).
|
||||||
WithStorageProvisioner(storageProvisioner).
|
WithStorageProvisioner(storageProvisioner).
|
||||||
WithCredentialStore(credentialStore)
|
WithCredentialStore(credentialStore).
|
||||||
|
WithNotifyDefaults(infraCfg.NotifySharedHost, infraCfg.NotifySharedFrom)
|
||||||
componentsHandler = handlers.NewComponentsHandler(componentService).
|
componentsHandler = handlers.NewComponentsHandler(componentService).
|
||||||
SetOperationService(operationService)
|
SetOperationService(operationService)
|
||||||
logger.Info("component service initialized",
|
logger.Info("component service initialized",
|
||||||
@ -637,7 +642,11 @@ func main() {
|
|||||||
verifyHandler := handlers.NewVerifyHandler(verifyService, streamPub)
|
verifyHandler := handlers.NewVerifyHandler(verifyService, streamPub)
|
||||||
|
|
||||||
// Initialize notify handler (domain status and re-verification)
|
// Initialize notify handler (domain status and re-verification)
|
||||||
notifyHandler := handlers.NewNotifyHandler(notifyProvisioner, credentialStore, logger)
|
notifyHandler := handlers.NewNotifyHandler(notifyProvisioner, credentialStore, logger).
|
||||||
|
WithSharedNotifyHost(infraCfg.NotifySharedHost)
|
||||||
|
|
||||||
|
// Initialize cache handler (Redis reprovision after ACL reset)
|
||||||
|
cacheHandler := handlers.NewCacheHandler(cacheProvisioner, credentialStore, deployerAdapter, logger)
|
||||||
|
|
||||||
// Initialize operations handler (for debugging project failures)
|
// Initialize operations handler (for debugging project failures)
|
||||||
operationsHandler := handlers.NewOperationsHandler(operationRepo)
|
operationsHandler := handlers.NewOperationsHandler(operationRepo)
|
||||||
@ -722,6 +731,7 @@ func main() {
|
|||||||
verifyHandler.Mount(app.Router())
|
verifyHandler.Mount(app.Router())
|
||||||
sagaHandler.Mount(app.Router())
|
sagaHandler.Mount(app.Router())
|
||||||
notifyHandler.Mount(app.Router())
|
notifyHandler.Mount(app.Router())
|
||||||
|
cacheHandler.Mount(app.Router())
|
||||||
|
|
||||||
// Start queue processor worker (per-project command queue)
|
// Start queue processor worker (per-project command queue)
|
||||||
queueProcessor := worker.NewQueueProcessor(
|
queueProcessor := worker.NewQueueProcessor(
|
||||||
|
|||||||
@ -184,6 +184,56 @@ func (d *Deployer) ListComponentStatuses(ctx context.Context, projectName string
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PatchProjectSecrets merges key-value pairs into all K8s secrets labeled project={projectName}.
|
||||||
|
// Existing keys not present in patch are preserved.
|
||||||
|
func (d *Deployer) PatchProjectSecrets(ctx context.Context, projectName string, patch map[string]string) error {
|
||||||
|
ns := d.config.Namespace
|
||||||
|
secretList, err := d.client.CoreV1().Secrets(ns).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: fmt.Sprintf("project=%s", projectName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list secrets for project %s: %w", projectName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range secretList.Items {
|
||||||
|
s := &secretList.Items[i]
|
||||||
|
if s.Data == nil {
|
||||||
|
s.Data = make(map[string][]byte)
|
||||||
|
}
|
||||||
|
for k, v := range patch {
|
||||||
|
s.Data[k] = []byte(v)
|
||||||
|
}
|
||||||
|
if _, err := d.client.CoreV1().Secrets(ns).Update(ctx, s, metav1.UpdateOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("update secret %s: %w", s.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestartAll triggers a rolling restart of all deployments labeled project={projectName}.
|
||||||
|
func (d *Deployer) RestartAll(ctx context.Context, projectName string) error {
|
||||||
|
ns := d.config.Namespace
|
||||||
|
deploymentList, err := d.client.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{
|
||||||
|
LabelSelector: fmt.Sprintf("project=%s", projectName),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list deployments for project %s: %w", projectName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
restartedAt := time.Now().Format(time.RFC3339)
|
||||||
|
for i := range deploymentList.Items {
|
||||||
|
dep := &deploymentList.Items[i]
|
||||||
|
if dep.Spec.Template.Annotations == nil {
|
||||||
|
dep.Spec.Template.Annotations = make(map[string]string)
|
||||||
|
}
|
||||||
|
dep.Spec.Template.Annotations["kubectl.kubernetes.io/restartedAt"] = restartedAt
|
||||||
|
if _, err := d.client.AppsV1().Deployments(ns).Update(ctx, dep, metav1.UpdateOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("restart deployment %s: %w", dep.Name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// splitComponentPath splits a component path like "services/auth-api" into ["services", "auth-api"].
|
// splitComponentPath splits a component path like "services/auth-api" into ["services", "auth-api"].
|
||||||
func splitComponentPath(path string) []string {
|
func splitComponentPath(path string) []string {
|
||||||
var parts []string
|
var parts []string
|
||||||
|
|||||||
@ -11,14 +11,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Provisioner implements port.NotifyProvisioner using the notify admin API.
|
// Provisioner implements port.NotifyProvisioner using the notify admin API.
|
||||||
// Each project gets an isolated sending host (mail.{slug}.{baseDomain}),
|
// Under the shared-host model, default projects share a pre-provisioned platform
|
||||||
// a Resend domain with DKIM/SPF DNS records, and a dedicated send key.
|
// sending host; only an account, send key, and host grant are created per-project.
|
||||||
|
// Custom domains still receive a dedicated host via ReprovisionNotifyHost.
|
||||||
type Provisioner struct {
|
type Provisioner struct {
|
||||||
client notifyAdminAPI
|
client notifyAdminAPI
|
||||||
resend resendAPI // nil when ResendAPIKey not configured
|
resend resendAPI // nil when ResendAPIKey not configured
|
||||||
resendAPIKey string // passed to createProvider; kept separate from resend for interface compatibility
|
resendAPIKey string // passed to createProvider; kept separate from resend for interface compatibility
|
||||||
dns port.DNSProvider // nil when Cloudflare not configured
|
dns port.DNSProvider // nil when Cloudflare not configured
|
||||||
baseDomain string // e.g., "threesix.ai"
|
baseDomain string // e.g., "threesix.ai"
|
||||||
|
sharedHost string // pre-provisioned platform sending host (e.g., "mail.threesix.ai")
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,6 +30,7 @@ type Config struct {
|
|||||||
AdminKey string // Required: admin API key (notify_admin_...)
|
AdminKey string // Required: admin API key (notify_admin_...)
|
||||||
ResendAPIKey string // Optional: Resend API key for per-project domain provisioning
|
ResendAPIKey string // Optional: Resend API key for per-project domain provisioning
|
||||||
BaseDomain string // Base domain for per-project hosts (default: "threesix.ai")
|
BaseDomain string // Base domain for per-project hosts (default: "threesix.ai")
|
||||||
|
SharedHost string // Pre-provisioned platform sending host (e.g., "mail.threesix.ai"). Required.
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProvisioner creates a new notify provisioner.
|
// NewProvisioner creates a new notify provisioner.
|
||||||
@ -40,6 +43,7 @@ func NewProvisioner(cfg Config, dns port.DNSProvider, logger *slog.Logger) *Prov
|
|||||||
client: newAdminClient(cfg.BaseURL, cfg.AdminKey),
|
client: newAdminClient(cfg.BaseURL, cfg.AdminKey),
|
||||||
dns: dns,
|
dns: dns,
|
||||||
baseDomain: baseDomain,
|
baseDomain: baseDomain,
|
||||||
|
sharedHost: cfg.SharedHost,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
if cfg.ResendAPIKey != "" {
|
if cfg.ResendAPIKey != "" {
|
||||||
@ -49,131 +53,52 @@ func NewProvisioner(cfg Config, dns port.DNSProvider, logger *slog.Logger) *Prov
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateProjectNotify provisions a per-project notify host, Resend domain, DNS records,
|
// CreateProjectNotify provisions a notify account with send key and grants access
|
||||||
// and notify account with send key.
|
// to the shared platform sending host.
|
||||||
//
|
//
|
||||||
// Steps:
|
// Steps:
|
||||||
// 1. Create notify host mail.{slug}.{baseDomain}
|
// 1. Create notify account "project-{projectID}"
|
||||||
// 2. Add Resend provider to the host (skipped if ResendAPIKey not configured)
|
// 2. Create send key for the account
|
||||||
// 3. Register from-address noreply@mail.{slug}.{baseDomain}
|
// 3. Grant the account access to p.sharedHost (non-fatal)
|
||||||
// 4. Create notify account "project-{projectID}"
|
|
||||||
// 5. Create send key for the account
|
|
||||||
// 6. Grant the account access to the host (non-fatal)
|
|
||||||
// 7. Create Resend domain (non-fatal — skipped if ResendAPIKey not configured)
|
|
||||||
// 8. Add DNS records via Cloudflare (non-fatal — skipped if DNS not configured)
|
|
||||||
// 9. Fire-and-forget async domain verification
|
|
||||||
func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug string) (*domain.NotifyCredentials, error) {
|
func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID, slug string) (*domain.NotifyCredentials, error) {
|
||||||
host := "mail." + slug + "." + p.baseDomain
|
if p.sharedHost == "" {
|
||||||
from := "noreply@" + host
|
return nil, fmt.Errorf("notify: shared host not configured")
|
||||||
|
}
|
||||||
accountName := "project-" + projectID
|
accountName := "project-" + projectID
|
||||||
|
|
||||||
// 1. Create notify host
|
// 1. Create account
|
||||||
if err := p.client.createHost(ctx, host, "failover"); err != nil {
|
|
||||||
return nil, fmt.Errorf("notify: create host %s for project %s: %w", host, projectID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Add Resend provider to the host (only when Resend is configured)
|
|
||||||
if p.resend != nil {
|
|
||||||
if err := p.client.createProvider(ctx, host, "resend", map[string]string{"api_key": p.resendAPIKey}, 1, 3, 1000); err != nil {
|
|
||||||
p.bestEffortDeleteHost(ctx, host, projectID)
|
|
||||||
return nil, fmt.Errorf("notify: create provider on host %s for project %s: %w", host, projectID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Register from-address
|
|
||||||
if err := p.client.createFromAddress(ctx, host, from, slug); err != nil {
|
|
||||||
p.bestEffortDeleteHost(ctx, host, projectID)
|
|
||||||
return nil, fmt.Errorf("notify: create from-address %s for project %s: %w", from, projectID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Create account
|
|
||||||
acct, err := p.client.createAccount(ctx, accountName)
|
acct, err := p.client.createAccount(ctx, accountName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.bestEffortDeleteHost(ctx, host, projectID)
|
|
||||||
return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err)
|
return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create send key
|
// 2. Create send key
|
||||||
key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send")
|
key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
p.bestEffortDeleteAccount(ctx, acct.ID, projectID)
|
p.bestEffortDeleteAccount(ctx, acct.ID, projectID)
|
||||||
p.bestEffortDeleteHost(ctx, host, projectID)
|
|
||||||
return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err)
|
return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Grant host access (non-fatal — log warn and continue)
|
// 3. Grant shared host access (non-fatal — log warn and continue)
|
||||||
if err := p.client.grantHostAccess(ctx, host, acct.ID); err != nil {
|
if err := p.client.grantHostAccess(ctx, p.sharedHost, acct.ID); err != nil {
|
||||||
p.logger.Warn("failed to grant notify host access",
|
p.logger.Warn("failed to grant notify host access",
|
||||||
"host", host,
|
"host", p.sharedHost,
|
||||||
"account_id", acct.ID,
|
"account_id", acct.ID,
|
||||||
"project_id", projectID,
|
"project_id", projectID,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Create Resend domain (non-fatal — project still usable, email won't send until fixed)
|
|
||||||
var resendDomainID string
|
|
||||||
var dnsRecords []resendDNSRecord
|
|
||||||
if p.resend != nil {
|
|
||||||
var resendErr error
|
|
||||||
resendDomainID, dnsRecords, resendErr = p.resend.createDomain(ctx, host, "us-east-1")
|
|
||||||
if resendErr != nil {
|
|
||||||
p.logger.Warn("failed to create resend domain — email delivery will not work until resolved",
|
|
||||||
"host", host,
|
|
||||||
"project_id", projectID,
|
|
||||||
"error", resendErr,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
p.logger.Info("resend domain created", "host", host, "domain_id", resendDomainID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Add DNS records for DKIM/SPF (non-fatal).
|
|
||||||
// rec.Name is relative to the zone apex (e.g., "resend._domainkey.mail.slug").
|
|
||||||
// Cloudflare's normalizeName appends ".baseDomain" to build the FQDN.
|
|
||||||
if p.dns != nil && len(dnsRecords) > 0 {
|
|
||||||
for _, rec := range dnsRecords {
|
|
||||||
dnsRec := domain.DNSRecord{
|
|
||||||
Type: rec.DNSType,
|
|
||||||
Name: rec.Name,
|
|
||||||
Content: rec.Value,
|
|
||||||
TTL: 1,
|
|
||||||
Priority: rec.Priority,
|
|
||||||
}
|
|
||||||
if _, upsertErr := p.dns.UpsertRecord(ctx, dnsRec); upsertErr != nil {
|
|
||||||
p.logger.Warn("failed to upsert notify DNS record",
|
|
||||||
"name", rec.Name,
|
|
||||||
"type", rec.DNSType,
|
|
||||||
"project_id", projectID,
|
|
||||||
"error", upsertErr,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. Fire-and-forget async domain verification.
|
|
||||||
// Waits 60 seconds for DNS propagation, then retries verification up to 5 times with 30s backoff.
|
|
||||||
if p.resend != nil && resendDomainID != "" {
|
|
||||||
go func() {
|
|
||||||
verifyCtx := context.WithoutCancel(ctx)
|
|
||||||
p.verifyWithRetry(verifyCtx, resendDomainID, host, projectID)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
p.logger.Info("notify provisioned",
|
p.logger.Info("notify provisioned",
|
||||||
"project_id", projectID,
|
"project_id", projectID,
|
||||||
"host", host,
|
"shared_host", p.sharedHost,
|
||||||
"resend_domain_id", resendDomainID,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return &domain.NotifyCredentials{
|
return &domain.NotifyCredentials{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
AccountID: acct.ID,
|
AccountID: acct.ID,
|
||||||
APIKey: key.Key,
|
APIKey: key.Key,
|
||||||
Host: host,
|
CreatedAt: time.Now(),
|
||||||
From: from,
|
|
||||||
ResendDomainID: resendDomainID,
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,11 +153,11 @@ func (p *Provisioner) verifyWithRetry(ctx context.Context, resendDomainID, host,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteProjectNotify removes all notify resources for a project.
|
// DeleteProjectNotify removes notify resources for a project.
|
||||||
// Failures are logged as warnings — cleanup continues regardless.
|
// The notify account (and all cascaded keys and host grants) is always deleted.
|
||||||
func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug, resendDomainID string) error {
|
// If perProjectHost is non-empty, the custom sending host, Resend domain, and DNS
|
||||||
host := "mail." + slug + "." + p.baseDomain
|
// records are also deleted. Failures are logged as warnings — cleanup continues.
|
||||||
|
func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, perProjectHost, resendDomainID string) error {
|
||||||
// 1. Delete notify account (cascades keys + host grants)
|
// 1. Delete notify account (cascades keys + host grants)
|
||||||
acct, err := p.findAccountByProject(ctx, projectID)
|
acct, err := p.findAccountByProject(ctx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -250,59 +175,60 @@ func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID, slug,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Delete notify host
|
// 2. If a per-project custom host was provisioned, clean it up.
|
||||||
if err := p.client.deleteHost(ctx, host); err != nil {
|
if perProjectHost != "" {
|
||||||
p.logger.Warn("failed to delete notify host",
|
if err := p.client.deleteHost(ctx, perProjectHost); err != nil {
|
||||||
"host", host,
|
p.logger.Warn("failed to delete notify host",
|
||||||
"project_id", projectID,
|
"host", perProjectHost,
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Delete Resend domain
|
|
||||||
if p.resend != nil && resendDomainID != "" {
|
|
||||||
if err := p.resend.deleteDomain(ctx, resendDomainID); err != nil {
|
|
||||||
p.logger.Warn("failed to delete resend domain",
|
|
||||||
"domain_id", resendDomainID,
|
|
||||||
"project_id", projectID,
|
"project_id", projectID,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.resend != nil && resendDomainID != "" {
|
||||||
|
if err := p.resend.deleteDomain(ctx, resendDomainID); err != nil {
|
||||||
|
p.logger.Warn("failed to delete resend domain",
|
||||||
|
"domain_id", resendDomainID,
|
||||||
|
"project_id", projectID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Cloudflare DNS records for DKIM/SPF.
|
||||||
|
// Names follow Resend's standard format:
|
||||||
|
// DKIM: resend._domainkey.{host}
|
||||||
|
// SPF MX: send.{host}
|
||||||
|
// SPF TXT: send.{host}
|
||||||
|
// If Resend changes their record naming, manual cleanup may be needed.
|
||||||
|
if p.dns != nil {
|
||||||
|
dkimName := "resend._domainkey." + perProjectHost
|
||||||
|
if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil {
|
||||||
|
p.logger.Warn("failed to delete DKIM DNS record",
|
||||||
|
"name", dkimName,
|
||||||
|
"project_id", projectID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
spfSendName := "send." + perProjectHost
|
||||||
|
if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil {
|
||||||
|
p.logger.Warn("failed to delete 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 SPF TXT DNS record",
|
||||||
|
"name", spfSendName,
|
||||||
|
"project_id", projectID,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Delete Cloudflare DNS records for DKIM/SPF.
|
p.logger.Info("notify resources deleted", "project_id", projectID)
|
||||||
// Names follow Resend's standard format:
|
|
||||||
// DKIM: resend._domainkey.{host}
|
|
||||||
// SPF MX: send.{host}
|
|
||||||
// SPF TXT: send.{host}
|
|
||||||
// If Resend changes their record naming, manual cleanup may be needed.
|
|
||||||
if p.dns != nil {
|
|
||||||
dkimName := "resend._domainkey." + host
|
|
||||||
if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil {
|
|
||||||
p.logger.Warn("failed to delete DKIM DNS record",
|
|
||||||
"name", dkimName,
|
|
||||||
"project_id", projectID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
spfSendName := "send." + host
|
|
||||||
if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil {
|
|
||||||
p.logger.Warn("failed to delete 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 SPF TXT DNS record",
|
|
||||||
"name", spfSendName,
|
|
||||||
"project_id", projectID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.logger.Info("notify resources deleted", "project_id", projectID, "host", host)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -392,9 +318,8 @@ func (p *Provisioner) GetNotifyDomainStatus(ctx context.Context, host, resendDom
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectNotify returns notify credentials for the project, or nil if not provisioned.
|
// GetProjectNotify returns notify credentials for the project, or nil if not provisioned.
|
||||||
// Note: Only AccountID and CreatedAt are populated — APIKey, Host, and From are not
|
// Only AccountID and CreatedAt are recoverable after provisioning. Use this method
|
||||||
// recoverable after provisioning. Use this method solely to check whether provisioning
|
// solely to check whether provisioning has already occurred (non-nil = already provisioned).
|
||||||
// has already occurred (non-nil return = already provisioned).
|
|
||||||
func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) {
|
func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) {
|
||||||
acct, err := p.findAccountByProject(ctx, projectID)
|
acct, err := p.findAccountByProject(ctx, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import (
|
|||||||
// 10. Delete old DNS records for oldHost (non-fatal)
|
// 10. Delete old DNS records for oldHost (non-fatal)
|
||||||
// 11. Delete old notify host (non-fatal)
|
// 11. Delete old notify host (non-fatal)
|
||||||
// 12. Fire-and-forget async domain verification
|
// 12. Fire-and-forget async domain verification
|
||||||
func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyCredentials, error) {
|
func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyHostCredentials, error) {
|
||||||
newFrom := "noreply@" + newHost
|
newFrom := "noreply@" + newHost
|
||||||
|
|
||||||
// 1. Create new notify host.
|
// 1. Create new notify host.
|
||||||
@ -151,7 +151,7 @@ func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldH
|
|||||||
"resend_domain_id", newResendDomainID,
|
"resend_domain_id", newResendDomainID,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &domain.NotifyCredentials{
|
return &domain.NotifyHostCredentials{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
Host: newHost,
|
Host: newHost,
|
||||||
From: newFrom,
|
From: newFrom,
|
||||||
|
|||||||
@ -192,7 +192,8 @@ func testLogger() *slog.Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newProvisionerWithDeps creates a Provisioner with injected dependencies for testing.
|
// newProvisionerWithDeps creates a Provisioner with injected dependencies for testing.
|
||||||
func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKey string, dns port.DNSProvider, baseDomain string, logger *slog.Logger) *Provisioner {
|
// sharedHost is used as-is; pass a non-empty value for tests that need a working provisioner.
|
||||||
|
func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKey string, dns port.DNSProvider, baseDomain, sharedHost string, logger *slog.Logger) *Provisioner {
|
||||||
if baseDomain == "" {
|
if baseDomain == "" {
|
||||||
baseDomain = "threesix.ai"
|
baseDomain = "threesix.ai"
|
||||||
}
|
}
|
||||||
@ -202,6 +203,7 @@ func newProvisionerWithDeps(client notifyAdminAPI, resend resendAPI, resendAPIKe
|
|||||||
resendAPIKey: resendAPIKey,
|
resendAPIKey: resendAPIKey,
|
||||||
dns: dns,
|
dns: dns,
|
||||||
baseDomain: baseDomain,
|
baseDomain: baseDomain,
|
||||||
|
sharedHost: sharedHost,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -224,101 +226,60 @@ func newTestProvisioner(admin *mockAdminClient, resend *mockResendClient, dns *m
|
|||||||
if dns != nil {
|
if dns != nil {
|
||||||
d = dns
|
d = dns
|
||||||
}
|
}
|
||||||
return newProvisionerWithDeps(admin, r, "re_test_key", d, "test.example", testLogger())
|
return newProvisionerWithDeps(admin, r, "re_test_key", d, "test.example", "mail.test.example", testLogger())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- tests ---
|
// --- tests ---
|
||||||
|
|
||||||
func TestCreateProjectNotify_Success(t *testing.T) {
|
func TestCreateProjectNotify_Success(t *testing.T) {
|
||||||
admin := &mockAdminClient{}
|
admin := &mockAdminClient{}
|
||||||
resend := &mockResendClient{
|
p := newTestProvisioner(admin, nil, nil)
|
||||||
dnsRecords: []resendDNSRecord{
|
|
||||||
{Record: "TXT", Name: "resend._domainkey", Value: "v=DKIM1; p=..."},
|
|
||||||
{Record: "MX", Name: "send", Value: "feedback-smtp.us-east-1.amazonses.com", Priority: 10},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
dns := &mockDNS{}
|
|
||||||
p := newTestProvisioner(admin, resend, dns)
|
|
||||||
|
|
||||||
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if creds.Host != "mail.happy-fox.test.example" {
|
|
||||||
t.Errorf("expected host mail.happy-fox.test.example, got %s", creds.Host)
|
|
||||||
}
|
|
||||||
if creds.From != "noreply@mail.happy-fox.test.example" {
|
|
||||||
t.Errorf("expected from noreply@mail.happy-fox.test.example, got %s", creds.From)
|
|
||||||
}
|
|
||||||
if creds.APIKey != "notify_send_test_key" {
|
if creds.APIKey != "notify_send_test_key" {
|
||||||
t.Errorf("expected send key, got %s", creds.APIKey)
|
t.Errorf("expected send key notify_send_test_key, got %s", creds.APIKey)
|
||||||
}
|
|
||||||
if creds.ResendDomainID != "resend-domain-id-123" {
|
|
||||||
t.Errorf("expected resend domain id, got %s", creds.ResendDomainID)
|
|
||||||
}
|
}
|
||||||
if creds.ProjectID != "proj-123" {
|
if creds.ProjectID != "proj-123" {
|
||||||
t.Errorf("expected project id proj-123, got %s", creds.ProjectID)
|
t.Errorf("expected project id proj-123, got %s", creds.ProjectID)
|
||||||
}
|
}
|
||||||
|
if creds.AccountID == "" {
|
||||||
|
t.Error("expected non-empty AccountID")
|
||||||
|
}
|
||||||
|
|
||||||
// Verify all steps executed
|
// Shared-host model: only account + send key + grant access are called.
|
||||||
if admin.createHostCalls != 1 {
|
|
||||||
t.Errorf("expected 1 createHost call, got %d", admin.createHostCalls)
|
|
||||||
}
|
|
||||||
if admin.createProviderCalls != 1 {
|
|
||||||
t.Errorf("expected 1 createProvider call, got %d", admin.createProviderCalls)
|
|
||||||
}
|
|
||||||
if admin.createFromAddressCalls != 1 {
|
|
||||||
t.Errorf("expected 1 createFromAddress call, got %d", admin.createFromAddressCalls)
|
|
||||||
}
|
|
||||||
if admin.createAccountCalls != 1 {
|
if admin.createAccountCalls != 1 {
|
||||||
t.Errorf("expected 1 createAccount call, got %d", admin.createAccountCalls)
|
t.Errorf("expected 1 createAccount call, got %d", admin.createAccountCalls)
|
||||||
}
|
}
|
||||||
if admin.createSendKeyCalls != 1 {
|
if admin.createSendKeyCalls != 1 {
|
||||||
t.Errorf("expected 1 createSendKey call, got %d", admin.createSendKeyCalls)
|
t.Errorf("expected 1 createSendKey call, got %d", admin.createSendKeyCalls)
|
||||||
}
|
}
|
||||||
if resend.createDomainCalls != 1 {
|
if admin.grantHostAccessCalls != 1 {
|
||||||
t.Errorf("expected 1 createDomain call, got %d", resend.createDomainCalls)
|
t.Errorf("expected 1 grantHostAccess call, got %d", admin.grantHostAccessCalls)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify 2 DNS records were upserted
|
// No per-project host, provider, from-address, Resend domain, or DNS records.
|
||||||
if len(dns.upsertCalls) != 2 {
|
if admin.createHostCalls != 0 {
|
||||||
t.Errorf("expected 2 DNS upserts, got %d", len(dns.upsertCalls))
|
t.Errorf("expected 0 createHost calls, got %d", admin.createHostCalls)
|
||||||
|
}
|
||||||
|
if admin.createProviderCalls != 0 {
|
||||||
|
t.Errorf("expected 0 createProvider calls, got %d", admin.createProviderCalls)
|
||||||
|
}
|
||||||
|
if admin.createFromAddressCalls != 0 {
|
||||||
|
t.Errorf("expected 0 createFromAddress calls, got %d", admin.createFromAddressCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateProjectNotify_RollsBackOnProviderFailure(t *testing.T) {
|
func TestCreateProjectNotify_NoSharedHost_ReturnsError(t *testing.T) {
|
||||||
admin := &mockAdminClient{
|
admin := &mockAdminClient{}
|
||||||
createProviderErr: errors.New("provider setup failed"),
|
p := newProvisionerWithDeps(admin, nil, "", nil, "test.example", "", testLogger())
|
||||||
}
|
|
||||||
p := newTestProvisioner(admin, &mockResendClient{}, nil)
|
|
||||||
|
|
||||||
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error when sharedHost is not configured, got nil")
|
||||||
}
|
|
||||||
|
|
||||||
if admin.deleteHostCalls != 1 {
|
|
||||||
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateProjectNotify_RollsBackOnFromAddressFailure(t *testing.T) {
|
|
||||||
admin := &mockAdminClient{
|
|
||||||
createFromAddressErr: errors.New("from address failed"),
|
|
||||||
}
|
|
||||||
p := newTestProvisioner(admin, &mockResendClient{}, nil)
|
|
||||||
|
|
||||||
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
if admin.deleteHostCalls != 1 {
|
|
||||||
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
|
|
||||||
}
|
|
||||||
if admin.deleteAccountCalls != 0 {
|
|
||||||
t.Errorf("account not yet created, should not delete account")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,15 +287,23 @@ func TestCreateProjectNotify_RollsBackOnAccountFailure(t *testing.T) {
|
|||||||
admin := &mockAdminClient{
|
admin := &mockAdminClient{
|
||||||
createAccountErr: errors.New("account creation failed"),
|
createAccountErr: errors.New("account creation failed"),
|
||||||
}
|
}
|
||||||
p := newTestProvisioner(admin, &mockResendClient{}, nil)
|
p := newTestProvisioner(admin, nil, nil)
|
||||||
|
|
||||||
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if admin.deleteHostCalls != 1 {
|
// No send key or grant attempted when account creation fails.
|
||||||
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
|
if admin.createSendKeyCalls != 0 {
|
||||||
|
t.Errorf("expected 0 createSendKey calls, got %d", admin.createSendKeyCalls)
|
||||||
|
}
|
||||||
|
if admin.grantHostAccessCalls != 0 {
|
||||||
|
t.Errorf("expected 0 grantHostAccess calls, got %d", admin.grantHostAccessCalls)
|
||||||
|
}
|
||||||
|
// No host was ever created in the shared-host model.
|
||||||
|
if admin.createHostCalls != 0 {
|
||||||
|
t.Errorf("expected 0 createHost calls, got %d", admin.createHostCalls)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -342,69 +311,20 @@ func TestCreateProjectNotify_RollsBackOnSendKeyFailure(t *testing.T) {
|
|||||||
admin := &mockAdminClient{
|
admin := &mockAdminClient{
|
||||||
createSendKeyErr: errors.New("send key creation failed"),
|
createSendKeyErr: errors.New("send key creation failed"),
|
||||||
}
|
}
|
||||||
p := newTestProvisioner(admin, &mockResendClient{}, nil)
|
p := newTestProvisioner(admin, nil, nil)
|
||||||
|
|
||||||
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
_, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account was created, so it must be rolled back.
|
||||||
if admin.deleteAccountCalls != 1 {
|
if admin.deleteAccountCalls != 1 {
|
||||||
t.Errorf("expected account rollback, got %d deleteAccount calls", admin.deleteAccountCalls)
|
t.Errorf("expected 1 deleteAccount call, got %d", admin.deleteAccountCalls)
|
||||||
}
|
}
|
||||||
if admin.deleteHostCalls != 1 {
|
// No host was created, so no host rollback.
|
||||||
t.Errorf("expected host rollback, got %d deleteHost calls", admin.deleteHostCalls)
|
if admin.deleteHostCalls != 0 {
|
||||||
}
|
t.Errorf("expected 0 deleteHost calls, got %d", admin.deleteHostCalls)
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateProjectNotify_ResendFailureIsNonFatal(t *testing.T) {
|
|
||||||
admin := &mockAdminClient{}
|
|
||||||
resend := &mockResendClient{
|
|
||||||
createDomainErr: errors.New("resend API down"),
|
|
||||||
}
|
|
||||||
p := newTestProvisioner(admin, resend, nil)
|
|
||||||
|
|
||||||
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("resend failure should be non-fatal, got error: %v", err)
|
|
||||||
}
|
|
||||||
if creds.ResendDomainID != "" {
|
|
||||||
t.Errorf("expected empty resend domain id on failure, got %s", creds.ResendDomainID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateProjectNotify_WithoutResend_SkipsProviderAndDomain(t *testing.T) {
|
|
||||||
admin := &mockAdminClient{}
|
|
||||||
p := newProvisionerWithDeps(admin, nil, "", nil, "test.example", testLogger())
|
|
||||||
|
|
||||||
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error without resend, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if admin.createProviderCalls != 0 {
|
|
||||||
t.Errorf("createProvider should not be called without ResendAPIKey, got %d calls", admin.createProviderCalls)
|
|
||||||
}
|
|
||||||
if creds.ResendDomainID != "" {
|
|
||||||
t.Errorf("expected no resend domain id without ResendAPIKey, got %s", creds.ResendDomainID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateProjectNotify_DNSFailureIsNonFatal(t *testing.T) {
|
|
||||||
admin := &mockAdminClient{}
|
|
||||||
resend := &mockResendClient{
|
|
||||||
dnsRecords: []resendDNSRecord{{Record: "TXT", Name: "resend._domainkey", Value: "v=DKIM1"}},
|
|
||||||
}
|
|
||||||
dns := &mockDNS{upsertErr: errors.New("cloudflare down")}
|
|
||||||
p := newTestProvisioner(admin, resend, dns)
|
|
||||||
|
|
||||||
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "happy-fox")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("DNS failure should be non-fatal, got error: %v", err)
|
|
||||||
}
|
|
||||||
// Project still usable; DNS will need manual fix
|
|
||||||
if creds.Host != "mail.happy-fox.test.example" {
|
|
||||||
t.Errorf("creds should still be returned on DNS failure, got host %s", creds.Host)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,7 +338,8 @@ func TestDeleteProjectNotify_Success(t *testing.T) {
|
|||||||
dns := &mockDNS{}
|
dns := &mockDNS{}
|
||||||
p := newTestProvisioner(admin, resend, dns)
|
p := newTestProvisioner(admin, resend, dns)
|
||||||
|
|
||||||
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "resend-domain-id-123")
|
// perProjectHost is non-empty: delete account + host + Resend domain + DNS records.
|
||||||
|
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "resend-domain-id-123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
@ -447,26 +368,60 @@ func TestDeleteProjectNotify_NoResendDomainID_SkipsDomainDeletion(t *testing.T)
|
|||||||
resend := &mockResendClient{}
|
resend := &mockResendClient{}
|
||||||
p := newTestProvisioner(admin, resend, nil)
|
p := newTestProvisioner(admin, resend, nil)
|
||||||
|
|
||||||
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "")
|
// perProjectHost set but no resendDomainID: host deleted, Resend domain skipped.
|
||||||
|
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
}
|
}
|
||||||
if resend.deleteDomainCalls != 0 {
|
if resend.deleteDomainCalls != 0 {
|
||||||
t.Errorf("should skip domain deletion when resendDomainID is empty")
|
t.Errorf("should skip domain deletion when resendDomainID is empty")
|
||||||
}
|
}
|
||||||
|
if admin.deleteHostCalls != 1 {
|
||||||
|
t.Errorf("expected 1 deleteHost call when perProjectHost is set, got %d", admin.deleteHostCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteProjectNotify_NoPerProjectHost_OnlyDeletesAccount(t *testing.T) {
|
||||||
|
admin := &mockAdminClient{
|
||||||
|
accounts: []accountResponse{
|
||||||
|
{ID: "acct-001", Name: "project-proj-123"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resend := &mockResendClient{}
|
||||||
|
dns := &mockDNS{}
|
||||||
|
p := newTestProvisioner(admin, resend, dns)
|
||||||
|
|
||||||
|
// Empty perProjectHost: only the account is deleted (shared host, no custom resources).
|
||||||
|
err := p.DeleteProjectNotify(context.Background(), "proj-123", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if admin.deleteAccountCalls != 1 {
|
||||||
|
t.Errorf("expected 1 deleteAccount call, got %d", admin.deleteAccountCalls)
|
||||||
|
}
|
||||||
|
if admin.deleteHostCalls != 0 {
|
||||||
|
t.Errorf("expected 0 deleteHost calls when no perProjectHost, got %d", admin.deleteHostCalls)
|
||||||
|
}
|
||||||
|
if resend.deleteDomainCalls != 0 {
|
||||||
|
t.Errorf("expected 0 deleteDomain calls when no perProjectHost, got %d", resend.deleteDomainCalls)
|
||||||
|
}
|
||||||
|
if len(dns.deleteByNameCalls) != 0 {
|
||||||
|
t.Errorf("expected 0 DNS deleteByName calls when no perProjectHost, got %d", len(dns.deleteByNameCalls))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeleteProjectNotify_AccountNotFound_ContinuesCleanup(t *testing.T) {
|
func TestDeleteProjectNotify_AccountNotFound_ContinuesCleanup(t *testing.T) {
|
||||||
// Account doesn't exist (never provisioned or already deleted)
|
// Account doesn't exist (never provisioned or already deleted).
|
||||||
admin := &mockAdminClient{accounts: []accountResponse{}}
|
admin := &mockAdminClient{accounts: []accountResponse{}}
|
||||||
resend := &mockResendClient{}
|
resend := &mockResendClient{}
|
||||||
p := newTestProvisioner(admin, resend, nil)
|
p := newTestProvisioner(admin, resend, nil)
|
||||||
|
|
||||||
err := p.DeleteProjectNotify(context.Background(), "proj-123", "happy-fox", "resend-domain-id-123")
|
// Even without an account, host and Resend domain cleanup must proceed.
|
||||||
|
err := p.DeleteProjectNotify(context.Background(), "proj-123", "mail.custom.example", "resend-domain-id-123")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected no error when account not found, got %v", err)
|
t.Fatalf("expected no error when account not found, got %v", err)
|
||||||
}
|
}
|
||||||
// Should still attempt host and Resend domain deletion
|
|
||||||
if admin.deleteHostCalls != 1 {
|
if admin.deleteHostCalls != 1 {
|
||||||
t.Errorf("expected 1 deleteHost call, got %d", admin.deleteHostCalls)
|
t.Errorf("expected 1 deleteHost call, got %d", admin.deleteHostCalls)
|
||||||
}
|
}
|
||||||
@ -508,19 +463,6 @@ func TestGetProjectNotify_AlreadyProvisioned(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateProjectNotify_HostUsesBaseDomain(t *testing.T) {
|
|
||||||
admin := &mockAdminClient{}
|
|
||||||
p := newProvisionerWithDeps(admin, nil, "", nil, "staging.example.com", testLogger())
|
|
||||||
|
|
||||||
creds, err := p.CreateProjectNotify(context.Background(), "proj-123", "some-slug")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if creds.Host != "mail.some-slug.staging.example.com" {
|
|
||||||
t.Errorf("expected host to use baseDomain, got %s", creds.Host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReprovisionNotifyHost_Success(t *testing.T) {
|
func TestReprovisionNotifyHost_Success(t *testing.T) {
|
||||||
admin := &mockAdminClient{
|
admin := &mockAdminClient{
|
||||||
accounts: []accountResponse{
|
accounts: []accountResponse{
|
||||||
|
|||||||
@ -39,6 +39,7 @@ const (
|
|||||||
CredentialCategoryStorage = "storage"
|
CredentialCategoryStorage = "storage"
|
||||||
CredentialCategoryAI = "ai"
|
CredentialCategoryAI = "ai"
|
||||||
CredentialCategoryNotify = "notify"
|
CredentialCategoryNotify = "notify"
|
||||||
|
CredentialCategoryCache = "cache"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Known credential keys.
|
// Known credential keys.
|
||||||
@ -71,9 +72,11 @@ const (
|
|||||||
CredKeyNotifyURL = "NOTIFY_URL"
|
CredKeyNotifyURL = "NOTIFY_URL"
|
||||||
CredKeyNotifyAdminKey = "NOTIFY_ADMIN_KEY"
|
CredKeyNotifyAdminKey = "NOTIFY_ADMIN_KEY"
|
||||||
CredKeyNotifyAPIKey = "NOTIFY_API_KEY"
|
CredKeyNotifyAPIKey = "NOTIFY_API_KEY"
|
||||||
CredKeyNotifyHost = "NOTIFY_HOST"
|
CredKeyNotifyHost = "NOTIFY_HOST" // per-project: custom domain only
|
||||||
CredKeyNotifyFrom = "NOTIFY_FROM"
|
CredKeyNotifyFrom = "NOTIFY_FROM" // per-project: custom domain only
|
||||||
CredKeyNotifyResendDomainID = "NOTIFY_RESEND_DOMAIN_ID"
|
CredKeyNotifyResendDomainID = "NOTIFY_RESEND_DOMAIN_ID" // per-project: custom domain only
|
||||||
|
CredKeyNotifySharedHost = "NOTIFY_SHARED_HOST" // global: pre-provisioned platform sending host
|
||||||
|
CredKeyNotifySharedFrom = "NOTIFY_SHARED_FROM" // global: from-address for the shared host
|
||||||
|
|
||||||
// Resend (email provider for per-project domain provisioning)
|
// Resend (email provider for per-project domain provisioning)
|
||||||
CredKeyResendAPIKey = "RESEND_API_KEY"
|
CredKeyResendAPIKey = "RESEND_API_KEY"
|
||||||
|
|||||||
@ -4,6 +4,10 @@ package domain
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
// NotifyCredentials holds per-project email delivery credentials.
|
// NotifyCredentials holds per-project email delivery credentials.
|
||||||
|
// Under the shared-host model, all default projects send through a pre-provisioned
|
||||||
|
// platform host (e.g., "mail.threesix.ai"). Only the account, send key, and host
|
||||||
|
// grant are created per-project; no dedicated host, Resend domain, or DNS records
|
||||||
|
// are allocated unless a custom domain is configured via ReprovisionNotifyHost.
|
||||||
type NotifyCredentials struct {
|
type NotifyCredentials struct {
|
||||||
// ProjectID is the rdev project this credential set belongs to.
|
// ProjectID is the rdev project this credential set belongs to.
|
||||||
ProjectID string
|
ProjectID string
|
||||||
@ -14,22 +18,29 @@ type NotifyCredentials struct {
|
|||||||
// APIKey is the notify send key (notify_send_...) for sending emails.
|
// APIKey is the notify send key (notify_send_...) for sending emails.
|
||||||
APIKey string
|
APIKey string
|
||||||
|
|
||||||
// Host is the per-project sending host (e.g., "mail.{slug}.threesix.ai").
|
|
||||||
Host string
|
|
||||||
|
|
||||||
// From is the from-address for outgoing email (e.g., "noreply@mail.{slug}.threesix.ai").
|
|
||||||
From string
|
|
||||||
|
|
||||||
// ResendDomainID is the Resend domain UUID (used for deletion).
|
|
||||||
ResendDomainID string
|
|
||||||
|
|
||||||
// CreatedAt is when the credentials were provisioned.
|
// CreatedAt is when the credentials were provisioned.
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotifyHostCredentials holds the per-project host credentials returned by ReprovisionNotifyHost.
|
||||||
|
// Used only for custom-domain migrations; default projects use the shared platform host.
|
||||||
|
type NotifyHostCredentials struct {
|
||||||
|
// ProjectID is the rdev project this credential set belongs to.
|
||||||
|
ProjectID string
|
||||||
|
|
||||||
|
// Host is the custom sending host (e.g., "mail.myapp.threesix.ai").
|
||||||
|
Host string
|
||||||
|
|
||||||
|
// From is the from-address registered on the host (e.g., "noreply@mail.myapp.threesix.ai").
|
||||||
|
From string
|
||||||
|
|
||||||
|
// ResendDomainID is the Resend domain UUID for the new host.
|
||||||
|
ResendDomainID string
|
||||||
|
}
|
||||||
|
|
||||||
// NotifyDomainStatus holds the Resend verification status for a project's email domain.
|
// NotifyDomainStatus holds the Resend verification status for a project's email domain.
|
||||||
type NotifyDomainStatus struct {
|
type NotifyDomainStatus struct {
|
||||||
// Host is the per-project sending host (e.g., "mail.slug.threesix.ai").
|
// Host is the per-project sending host (only set for custom domains).
|
||||||
Host string
|
Host string
|
||||||
|
|
||||||
// ResendDomainID is the Resend domain UUID.
|
// ResendDomainID is the Resend domain UUID.
|
||||||
|
|||||||
134
internal/handlers/cache.go
Normal file
134
internal/handlers/cache.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
// Package handlers provides HTTP handlers for the rdev API.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/orchard9/rdev/internal/auth"
|
||||||
|
"github.com/orchard9/rdev/internal/domain"
|
||||||
|
"github.com/orchard9/rdev/internal/logging"
|
||||||
|
"github.com/orchard9/rdev/internal/port"
|
||||||
|
"github.com/orchard9/rdev/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CacheHandler handles cache reprovision endpoints.
|
||||||
|
type CacheHandler struct {
|
||||||
|
cacheProvisioner port.CacheProvisioner // may be nil if not configured
|
||||||
|
credStore port.CredentialStore // may be nil
|
||||||
|
deployer port.Deployer // may be nil
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCacheHandler creates a new cache handler.
|
||||||
|
func NewCacheHandler(
|
||||||
|
cacheProvisioner port.CacheProvisioner,
|
||||||
|
credStore port.CredentialStore,
|
||||||
|
deployer port.Deployer,
|
||||||
|
logger *slog.Logger,
|
||||||
|
) *CacheHandler {
|
||||||
|
return &CacheHandler{
|
||||||
|
cacheProvisioner: cacheProvisioner,
|
||||||
|
credStore: credStore,
|
||||||
|
deployer: deployer,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mount registers the cache routes.
|
||||||
|
func (h *CacheHandler) Mount(r api.Router) {
|
||||||
|
r.Route("/projects/{projectID}/cache", func(r chi.Router) {
|
||||||
|
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
|
||||||
|
Post("/reprovision", h.Reprovision)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reprovision recreates the Redis ACL user with a new password, updates the credential
|
||||||
|
// store, patches all K8s secrets for the project, and triggers rolling restarts so
|
||||||
|
// pods pick up the new credentials immediately.
|
||||||
|
// POST /projects/{projectID}/cache/reprovision
|
||||||
|
func (h *CacheHandler) Reprovision(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.cacheProvisioner == nil {
|
||||||
|
api.WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "cache provisioner 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("CacheReprovision")
|
||||||
|
|
||||||
|
// CreateProjectCache handles delete+recreate: if the ACL user already exists it
|
||||||
|
// is deleted first, then a new one is created with a fresh password.
|
||||||
|
creds, err := h.cacheProvisioner.CreateProjectCache(ctx, projectID)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to reprovision cache",
|
||||||
|
logging.FieldError, err,
|
||||||
|
logging.FieldProjectID, projectID,
|
||||||
|
)
|
||||||
|
api.WriteInternalError(w, r, "failed to reprovision cache")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist new credentials in the credential store.
|
||||||
|
if h.credStore != nil {
|
||||||
|
for _, kv := range []struct{ key, val string }{
|
||||||
|
{"REDIS_URL", creds.URL},
|
||||||
|
{"REDIS_URL_STAGING", creds.URLStaging},
|
||||||
|
{"REDIS_PREFIX", creds.Prefix},
|
||||||
|
} {
|
||||||
|
if err := h.credStore.Set(ctx, domain.Credential{
|
||||||
|
Key: projectID + ":" + kv.key,
|
||||||
|
Value: kv.val,
|
||||||
|
Category: domain.CredentialCategoryCache,
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("failed to store cache credential",
|
||||||
|
logging.FieldError, err,
|
||||||
|
logging.FieldProjectID, projectID,
|
||||||
|
"credential_key", kv.key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update K8s secrets and restart all project deployments so pods pick up the
|
||||||
|
// new credentials without waiting for the next CI deploy.
|
||||||
|
if h.deployer != nil {
|
||||||
|
patch := map[string]string{
|
||||||
|
"REDIS_URL": creds.URL,
|
||||||
|
"REDIS_URL_STAGING": creds.URLStaging,
|
||||||
|
"REDIS_PREFIX": creds.Prefix,
|
||||||
|
}
|
||||||
|
if err := h.deployer.PatchProjectSecrets(ctx, projectID, patch); err != nil {
|
||||||
|
log.Warn("failed to patch K8s secrets; pods will get new creds on next CI deploy",
|
||||||
|
logging.FieldError, err,
|
||||||
|
logging.FieldProjectID, projectID,
|
||||||
|
)
|
||||||
|
} else if err := h.deployer.RestartAll(ctx, projectID); err != nil {
|
||||||
|
log.Warn("failed to restart project deployments",
|
||||||
|
logging.FieldError, err,
|
||||||
|
logging.FieldProjectID, projectID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("cache reprovisioned",
|
||||||
|
logging.FieldProjectID, projectID,
|
||||||
|
"prefix", creds.Prefix,
|
||||||
|
"username", creds.Username,
|
||||||
|
)
|
||||||
|
|
||||||
|
api.WriteSuccess(w, r, map[string]string{
|
||||||
|
"prefix": creds.Prefix,
|
||||||
|
"username": creds.Username,
|
||||||
|
"status": "reprovisioned",
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -342,6 +342,14 @@ func (m *mockDeployer) RemoveIngressPath(_ context.Context, _, _, _ string) erro
|
|||||||
return m.err
|
return m.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *mockDeployer) PatchProjectSecrets(_ context.Context, _ string, _ map[string]string) error {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDeployer) RestartAll(_ context.Context, _ string) error {
|
||||||
|
return m.err
|
||||||
|
}
|
||||||
|
|
||||||
// mockPreviewManager implements port.PreviewManager for testing.
|
// mockPreviewManager implements port.PreviewManager for testing.
|
||||||
type mockPreviewManager struct {
|
type mockPreviewManager struct {
|
||||||
previews map[string]bool
|
previews map[string]bool
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import (
|
|||||||
type NotifyHandler struct {
|
type NotifyHandler struct {
|
||||||
notifyProvisioner port.NotifyProvisioner // may be nil if not configured
|
notifyProvisioner port.NotifyProvisioner // may be nil if not configured
|
||||||
credStore port.CredentialStore // may be nil
|
credStore port.CredentialStore // may be nil
|
||||||
|
sharedNotifyHost string // platform shared host (e.g., "mail.threesix.ai"); empty if not configured
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,6 +31,14 @@ func NewNotifyHandler(notifyProvisioner port.NotifyProvisioner, credStore port.C
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSharedNotifyHost sets the platform shared sending host. Used by the Reprovision
|
||||||
|
// handler to distinguish "shared-host project" (has API key, no per-project host) from
|
||||||
|
// "not provisioned at all" when currentHost is empty.
|
||||||
|
func (h *NotifyHandler) WithSharedNotifyHost(host string) *NotifyHandler {
|
||||||
|
h.sharedNotifyHost = host
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
// Mount registers the notify routes.
|
// Mount registers the notify routes.
|
||||||
func (h *NotifyHandler) Mount(r api.Router) {
|
func (h *NotifyHandler) Mount(r api.Router) {
|
||||||
r.Route("/projects/{projectID}/notify", func(r chi.Router) {
|
r.Route("/projects/{projectID}/notify", func(r chi.Router) {
|
||||||
@ -231,8 +240,15 @@ func (h *NotifyHandler) Reprovision(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
currentHost, currentResendDomainID := h.lookupNotifyCredentials(ctx, projectID)
|
currentHost, currentResendDomainID := h.lookupNotifyCredentials(ctx, projectID)
|
||||||
if currentHost == "" {
|
if currentHost == "" {
|
||||||
api.WriteBadRequest(w, r, "notify not provisioned for this project — run POST /notify/provision first")
|
// No per-project host stored. This is either a shared-host project (using platform default)
|
||||||
return
|
// or a project with no notify provisioned at all. Check the provisioner to distinguish.
|
||||||
|
existing, err := h.notifyProvisioner.GetProjectNotify(ctx, projectID)
|
||||||
|
if err != nil || existing == nil {
|
||||||
|
api.WriteBadRequest(w, r, "notify not provisioned for this project")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Shared-host project: allow migration. ReprovisionNotifyHost with oldHost="" skips
|
||||||
|
// revocation and deletion of the shared platform host (which must not be deleted).
|
||||||
}
|
}
|
||||||
if currentHost == req.Host {
|
if currentHost == req.Host {
|
||||||
api.WriteBadRequest(w, r, "new host is the same as the current host")
|
api.WriteBadRequest(w, r, "new host is the same as the current host")
|
||||||
|
|||||||
@ -74,4 +74,13 @@ type Deployer interface {
|
|||||||
// If no paths remain for a host, the host rule is removed.
|
// If no paths remain for a host, the host rule is removed.
|
||||||
// If no rules remain, the Ingress is deleted.
|
// If no rules remain, the Ingress is deleted.
|
||||||
RemoveIngressPath(ctx context.Context, projectName, host, path string) error
|
RemoveIngressPath(ctx context.Context, projectName, host, path string) error
|
||||||
|
|
||||||
|
// PatchProjectSecrets merges the given key-value pairs into all K8s secrets
|
||||||
|
// belonging to the project (labeled project={projectName}). Existing keys not
|
||||||
|
// present in patch are left untouched.
|
||||||
|
PatchProjectSecrets(ctx context.Context, projectName string, patch map[string]string) error
|
||||||
|
|
||||||
|
// RestartAll triggers a rolling restart of all deployments belonging to the project
|
||||||
|
// (labeled project={projectName}). Used after credential rotation to pick up new secrets.
|
||||||
|
RestartAll(ctx context.Context, projectName string) error
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,18 +7,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// NotifyProvisioner manages per-project email delivery on the notify service.
|
// NotifyProvisioner manages per-project email delivery on the notify service.
|
||||||
// Each project gets its own isolated sending host (mail.{slug}.threesix.ai),
|
// Under the shared-host model, default projects do not receive a dedicated sending
|
||||||
// Resend domain with DKIM/SPF, and a dedicated notify account with send key.
|
// host. Instead, a single pre-provisioned platform host (e.g., "mail.threesix.ai")
|
||||||
|
// is shared by all default projects. Per-project provisioning creates only an
|
||||||
|
// account, a send key, and a host grant (3 API calls). Custom domains still receive
|
||||||
|
// a dedicated host via ReprovisionNotifyHost.
|
||||||
type NotifyProvisioner interface {
|
type NotifyProvisioner interface {
|
||||||
// CreateProjectNotify provisions a notify host, Resend domain, DNS records,
|
// CreateProjectNotify creates a notify account, a send key, and grants the
|
||||||
// and account with send key for the project.
|
// account access to the shared platform sending host. No per-project host,
|
||||||
|
// Resend domain, or DNS records are created for default projects.
|
||||||
CreateProjectNotify(ctx context.Context, projectID, slug string) (*domain.NotifyCredentials, error)
|
CreateProjectNotify(ctx context.Context, projectID, slug string) (*domain.NotifyCredentials, error)
|
||||||
|
|
||||||
// DeleteProjectNotify removes all notify resources for a project:
|
// DeleteProjectNotify removes notify resources for a project.
|
||||||
// the notify account, the per-project host, the Resend domain, and DNS records.
|
// The notify account (and all its keys) is always deleted.
|
||||||
DeleteProjectNotify(ctx context.Context, projectID, slug, resendDomainID string) error
|
// If perProjectHost is non-empty, the custom sending host, Resend domain
|
||||||
|
// (when resendDomainID is non-empty), and DNS records are also deleted.
|
||||||
|
// For default projects where perProjectHost is empty, only the account is deleted.
|
||||||
|
DeleteProjectNotify(ctx context.Context, projectID, perProjectHost, resendDomainID string) error
|
||||||
|
|
||||||
// GetProjectNotify returns notify credentials for a project, or nil if not provisioned.
|
// GetProjectNotify returns notify credentials for a project, or nil if not provisioned.
|
||||||
|
// Only AccountID and CreatedAt are recoverable; use this solely to check whether
|
||||||
|
// provisioning has already occurred (non-nil return = already provisioned).
|
||||||
GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error)
|
GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error)
|
||||||
|
|
||||||
// TestConnection verifies the admin API key and notify service are reachable.
|
// TestConnection verifies the admin API key and notify service are reachable.
|
||||||
@ -40,6 +49,6 @@ type NotifyProvisioner interface {
|
|||||||
// ReprovisionNotifyHost migrates a project's notify setup to a new sending host.
|
// ReprovisionNotifyHost migrates a project's notify setup to a new sending host.
|
||||||
// Tears down oldHost's notify host entry, Resend domain, and DNS records, then
|
// Tears down oldHost's notify host entry, Resend domain, and DNS records, then
|
||||||
// creates new ones for newHost. The project's account and send key are preserved.
|
// creates new ones for newHost. The project's account and send key are preserved.
|
||||||
// Returns partial credentials (Host, From, ResendDomainID) for storage in the credential store.
|
// Returns host credentials (Host, From, ResendDomainID) for storage in the credential store.
|
||||||
ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyCredentials, error)
|
ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyHostCredentials, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,6 +47,10 @@ type ComponentService struct {
|
|||||||
cacheProvisioner port.CacheProvisioner
|
cacheProvisioner port.CacheProvisioner
|
||||||
storageProvisioner port.StorageProvisioner
|
storageProvisioner port.StorageProvisioner
|
||||||
credentialStore port.CredentialStore
|
credentialStore port.CredentialStore
|
||||||
|
|
||||||
|
// Notify shared-host defaults (injected into deployments when no per-project custom host is stored)
|
||||||
|
notifySharedHost string
|
||||||
|
notifySharedFrom string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ComponentServiceConfig configures the component service.
|
// ComponentServiceConfig configures the component service.
|
||||||
@ -100,6 +104,15 @@ func (s *ComponentService) WithCredentialStore(cs port.CredentialStore) *Compone
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithNotifyDefaults sets the platform shared notify host and from-address.
|
||||||
|
// These are injected into component deployments as NOTIFY_HOST and NOTIFY_FROM
|
||||||
|
// when the project has not provisioned a custom sending domain.
|
||||||
|
func (s *ComponentService) WithNotifyDefaults(host, from string) *ComponentService {
|
||||||
|
s.notifySharedHost = host
|
||||||
|
s.notifySharedFrom = from
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// AddComponent adds a new component to a project's monorepo.
|
// AddComponent adds a new component to a project's monorepo.
|
||||||
// For code components (service, worker, app-*, cli), this scaffolds template files.
|
// For code components (service, worker, app-*, cli), this scaffolds template files.
|
||||||
// For infrastructure components (postgres, redis), this provisions the resource.
|
// For infrastructure components (postgres, redis), this provisions the resource.
|
||||||
|
|||||||
@ -241,6 +241,17 @@ func (s *ComponentService) fetchProjectCredentials(ctx context.Context, projectI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For projects using the platform shared notify host, inject shared NOTIFY_HOST and NOTIFY_FROM
|
||||||
|
// defaults so deployed components get the correct env vars. Projects with a custom sending
|
||||||
|
// domain already have NOTIFY_HOST stored in the per-project credential store; those take
|
||||||
|
// precedence via the project-scoped fetch above.
|
||||||
|
if secrets[domain.CredKeyNotifyHost] == "" && s.notifySharedHost != "" {
|
||||||
|
secrets[domain.CredKeyNotifyHost] = s.notifySharedHost
|
||||||
|
if secrets[domain.CredKeyNotifyFrom] == "" {
|
||||||
|
secrets[domain.CredKeyNotifyFrom] = s.notifySharedFrom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if len(secrets) > 0 {
|
if len(secrets) > 0 {
|
||||||
log.Debug("fetched credentials for deployment",
|
log.Debug("fetched credentials for deployment",
|
||||||
logging.FieldProjectID, projectID,
|
logging.FieldProjectID, projectID,
|
||||||
|
|||||||
@ -113,7 +113,7 @@ func (s *ComponentService) provisionRedis(ctx context.Context, projectID, name s
|
|||||||
// Store credentials if credential store is available
|
// Store credentials if credential store is available
|
||||||
log := logging.FromContext(ctx).WithService("component")
|
log := logging.FromContext(ctx).WithService("component")
|
||||||
if s.credentialStore != nil {
|
if s.credentialStore != nil {
|
||||||
if err := s.storeCredential(ctx, projectID, "cache", "REDIS_URL", creds.URL); err != nil {
|
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryCache, "REDIS_URL", creds.URL); err != nil {
|
||||||
// Rollback on credential storage failure
|
// Rollback on credential storage failure
|
||||||
log.Error("failed to store REDIS_URL, rolling back", logging.FieldError, err)
|
log.Error("failed to store REDIS_URL, rolling back", logging.FieldError, err)
|
||||||
if rollbackErr := s.cacheProvisioner.DeleteProjectCache(ctx, projectID, false); rollbackErr != nil {
|
if rollbackErr := s.cacheProvisioner.DeleteProjectCache(ctx, projectID, false); rollbackErr != nil {
|
||||||
@ -121,10 +121,10 @@ func (s *ComponentService) provisionRedis(ctx context.Context, projectID, name s
|
|||||||
}
|
}
|
||||||
return nil, fmt.Errorf("failed to store credentials: %w", err)
|
return nil, fmt.Errorf("failed to store credentials: %w", err)
|
||||||
}
|
}
|
||||||
if err := s.storeCredential(ctx, projectID, "cache", "REDIS_URL_STAGING", creds.URLStaging); err != nil {
|
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryCache, "REDIS_URL_STAGING", creds.URLStaging); err != nil {
|
||||||
log.Warn("failed to store REDIS_URL_STAGING", logging.FieldError, err)
|
log.Warn("failed to store REDIS_URL_STAGING", logging.FieldError, err)
|
||||||
}
|
}
|
||||||
if err := s.storeCredential(ctx, projectID, "cache", "REDIS_PREFIX", creds.Prefix); err != nil {
|
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryCache, "REDIS_PREFIX", creds.Prefix); err != nil {
|
||||||
log.Warn("failed to store REDIS_PREFIX", logging.FieldError, err)
|
log.Warn("failed to store REDIS_PREFIX", logging.FieldError, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -478,36 +478,20 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr
|
|||||||
log.Error("failed to provision notify", logging.FieldProjectID, projectID, logging.FieldError, err)
|
log.Error("failed to provision notify", logging.FieldProjectID, projectID, logging.FieldError, err)
|
||||||
result.NextSteps = append(result.NextSteps, "Notify provisioning failed - contact admin")
|
result.NextSteps = append(result.NextSteps, "Notify provisioning failed - contact admin")
|
||||||
} else if s.credentialStore != nil {
|
} else if s.credentialStore != nil {
|
||||||
var storeErr error
|
// Under the shared-host model, CreateProjectNotify only returns APIKey.
|
||||||
|
// NOTIFY_HOST, NOTIFY_FROM, and NOTIFY_RESEND_DOMAIN_ID are not set here;
|
||||||
|
// they come from the platform's shared host configuration.
|
||||||
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyAPIKey, notifyCreds.APIKey); err != nil {
|
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyAPIKey, notifyCreds.APIKey); err != nil {
|
||||||
storeErr = err
|
|
||||||
log.Error("failed to store NOTIFY_API_KEY", logging.FieldProjectID, projectID, logging.FieldError, err)
|
log.Error("failed to store NOTIFY_API_KEY", logging.FieldProjectID, projectID, logging.FieldError, err)
|
||||||
}
|
|
||||||
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyHost, notifyCreds.Host); err != nil {
|
|
||||||
storeErr = err
|
|
||||||
log.Error("failed to store NOTIFY_HOST", logging.FieldProjectID, projectID, logging.FieldError, err)
|
|
||||||
}
|
|
||||||
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyFrom, notifyCreds.From); err != nil {
|
|
||||||
storeErr = err
|
|
||||||
log.Error("failed to store NOTIFY_FROM", logging.FieldProjectID, projectID, logging.FieldError, err)
|
|
||||||
}
|
|
||||||
if notifyCreds.ResendDomainID != "" {
|
|
||||||
if err := s.storeCredential(ctx, projectID, domain.CredentialCategoryNotify, domain.CredKeyNotifyResendDomainID, notifyCreds.ResendDomainID); err != nil {
|
|
||||||
storeErr = err
|
|
||||||
log.Error("failed to store NOTIFY_RESEND_DOMAIN_ID", logging.FieldProjectID, projectID, logging.FieldError, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if storeErr != nil {
|
|
||||||
log.Warn("rolling back notify due to credential storage failure", logging.FieldProjectID, projectID)
|
log.Warn("rolling back notify due to credential storage failure", logging.FieldProjectID, projectID)
|
||||||
if rollbackErr := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID, result.Slug, notifyCreds.ResendDomainID); rollbackErr != nil {
|
if rollbackErr := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID, "", ""); rollbackErr != nil {
|
||||||
log.Error("failed to rollback notify account", logging.FieldProjectID, projectID, logging.FieldError, rollbackErr)
|
log.Error("failed to rollback notify account", logging.FieldProjectID, projectID, logging.FieldError, rollbackErr)
|
||||||
result.NextSteps = append(result.NextSteps, "Notify created but credentials not stored - manual cleanup required")
|
result.NextSteps = append(result.NextSteps, "Notify created but credentials not stored - manual cleanup required")
|
||||||
} else {
|
} else {
|
||||||
result.NextSteps = append(result.NextSteps, "Notify provisioning rolled back due to credential storage failure")
|
result.NextSteps = append(result.NextSteps, "Notify provisioning rolled back due to credential storage failure")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log.Info("notify provisioned", logging.FieldProjectID, projectID, "host", notifyCreds.Host)
|
log.Info("notify provisioned", logging.FieldProjectID, projectID, "account_id", notifyCreds.AccountID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -890,15 +874,16 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Delete provisioned notify account (look up slug + resendDomainID from credential store)
|
// 5. Delete provisioned notify account.
|
||||||
|
// Under the shared-host model, perProjectHost is only set for custom-domain projects
|
||||||
|
// (stored as NOTIFY_HOST). Default projects pass empty string — only the account is deleted.
|
||||||
if s.notifyProvisioner != nil {
|
if s.notifyProvisioner != nil {
|
||||||
notifySlug := status.Slug
|
var perProjectHost, resendDomainID string
|
||||||
var resendDomainID string
|
|
||||||
if s.credentialStore != nil {
|
if s.credentialStore != nil {
|
||||||
cred, _ := s.credentialStore.Get(ctx, projectID+":"+domain.CredKeyNotifyResendDomainID)
|
perProjectHost, _ = s.credentialStore.Get(ctx, projectID+":"+domain.CredKeyNotifyHost)
|
||||||
resendDomainID = cred
|
resendDomainID, _ = s.credentialStore.Get(ctx, projectID+":"+domain.CredKeyNotifyResendDomainID)
|
||||||
}
|
}
|
||||||
if err := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID, notifySlug, resendDomainID); err != nil {
|
if err := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID, perProjectHost, resendDomainID); err != nil {
|
||||||
log.Warn("failed to delete project notify account", logging.FieldError, err)
|
log.Warn("failed to delete project notify account", logging.FieldError, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user