feat: hook in notify service for per-project email delivery
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Add NotifyProvisioner (port + adapter) using real notify admin API - Create notify account + send key + host grant per project - Inject NOTIFY_API_KEY/HOST/FROM into component deployments - Store NOTIFY_URL, NOTIFY_ADMIN_KEY, RESEND_API_KEY in credential store - Add setup-notify.sh for one-time host/provider/domain setup - Add NOTIFY_ADMIN_KEY constant to domain/credential.go - Wire provisioner in main.go with connection test guard - Add .claude/guides/services/notify.md and CLAUDE.md entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bc77504b35
commit
0f25bd8dbe
@ -67,6 +67,7 @@ Both use `lib/pq` driver. The `type: postgres` component API provisions **Cockro
|
||||
| **Traefik v3 ingress & middleware** | [ops/traefik-v3.md](.claude/guides/ops/traefik-v3.md) |
|
||||
| **Zot container registry** | [ops/zot-registry.md](.claude/guides/ops/zot-registry.md) |
|
||||
| **cert-manager / TLS certificates** | [ops/cert-manager.md](.claude/guides/ops/cert-manager.md) |
|
||||
| **Notify / email delivery** | [services/notify.md](.claude/guides/services/notify.md) |
|
||||
| **Structured logging** | `internal/logging/` - field constants, context propagation, redaction |
|
||||
|
||||
## Critical Rules
|
||||
|
||||
@ -87,6 +87,12 @@ type InfraConfig struct {
|
||||
GCSProjectID string // e.g., "threesix-prod"
|
||||
GCSCredentialsPath string // Path to service account JSON (empty = ADC)
|
||||
GCSLocation string // Bucket location (default: "US")
|
||||
|
||||
// Notify provisioner (for project email delivery)
|
||||
NotifyURL string // e.g., "https://notify.orchard9.ai"
|
||||
NotifyAdminKey string // notify_admin_... admin API key
|
||||
NotifyHost string // shared host (e.g., "threesix.ai")
|
||||
NotifyFrom string // from-address (e.g., "noreply@threesix.ai")
|
||||
}
|
||||
|
||||
func loadConfig() Config {
|
||||
@ -147,6 +153,8 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
||||
domain.CredKeyWoodpeckerAPIToken,
|
||||
domain.CredKeyWoodpeckerWebhookSecret,
|
||||
domain.CredKeyRegistryURL,
|
||||
domain.CredKeyNotifyURL,
|
||||
domain.CredKeyNotifyAdminKey,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn("failed to load credentials from store, using env vars", "error", err)
|
||||
@ -189,6 +197,12 @@ func loadInfraConfig(ctx context.Context, store port.CredentialStore, cfg Config
|
||||
GCSProjectID: os.Getenv("GCS_PROJECT_ID"),
|
||||
GCSCredentialsPath: os.Getenv("GCS_CREDENTIALS_PATH"),
|
||||
GCSLocation: envutil.GetEnv("GCS_LOCATION", "US"),
|
||||
|
||||
// Notify provisioner (credential store with env fallback)
|
||||
NotifyURL: getOrFallback(domain.CredKeyNotifyURL, os.Getenv("NOTIFY_URL")),
|
||||
NotifyAdminKey: getOrFallback(domain.CredKeyNotifyAdminKey, os.Getenv("NOTIFY_ADMIN_KEY")),
|
||||
NotifyHost: envutil.GetEnv("NOTIFY_HOST", "threesix.ai"),
|
||||
NotifyFrom: envutil.GetEnv("NOTIFY_FROM", "noreply@threesix.ai"),
|
||||
}
|
||||
|
||||
// Log which credentials were loaded from store vs env
|
||||
|
||||
@ -18,6 +18,7 @@ import (
|
||||
"github.com/orchard9/rdev/internal/adapter/gitea"
|
||||
"github.com/orchard9/rdev/internal/adapter/kubernetes"
|
||||
"github.com/orchard9/rdev/internal/adapter/memory"
|
||||
notifyadapter "github.com/orchard9/rdev/internal/adapter/notify"
|
||||
"github.com/orchard9/rdev/internal/adapter/postgres"
|
||||
redisadapter "github.com/orchard9/rdev/internal/adapter/redis"
|
||||
sdlcadapter "github.com/orchard9/rdev/internal/adapter/sdlc"
|
||||
@ -240,6 +241,23 @@ func main() {
|
||||
}
|
||||
defer closeProvisioner(storageProvisioner, "gcs", logger)
|
||||
|
||||
// Initialize notify provisioner (optional - for project email delivery)
|
||||
var notifyProvisioner port.NotifyProvisioner
|
||||
if infraCfg.NotifyURL != "" && infraCfg.NotifyAdminKey != "" {
|
||||
np := notifyadapter.NewProvisioner(notifyadapter.Config{
|
||||
BaseURL: infraCfg.NotifyURL,
|
||||
AdminKey: infraCfg.NotifyAdminKey,
|
||||
Host: infraCfg.NotifyHost,
|
||||
From: infraCfg.NotifyFrom,
|
||||
}, logger)
|
||||
if err := np.TestConnection(context.Background()); err != nil {
|
||||
logger.Warn("notify provisioner connection test failed, disabling", "error", err)
|
||||
} else {
|
||||
notifyProvisioner = np
|
||||
logger.Info("notify provisioner initialized", "url", infraCfg.NotifyURL, "host", infraCfg.NotifyHost)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize registry client (for monitoring and image cleanup on project teardown)
|
||||
var registryClient *zot.Client
|
||||
if infraCfg.RegistryURL != "" {
|
||||
@ -482,6 +500,9 @@ func main() {
|
||||
if citadelClient != nil {
|
||||
projectInfraService = projectInfraService.WithCitadelClient(citadelClient)
|
||||
}
|
||||
if notifyProvisioner != nil {
|
||||
projectInfraService = projectInfraService.WithNotifyProvisioner(notifyProvisioner)
|
||||
}
|
||||
|
||||
// Create domain service adapter for infrastructure handler
|
||||
domainServiceAdapter := handlers.NewDomainServiceAdapter(projectInfraService)
|
||||
|
||||
165
internal/adapter/notify/admin_client.go
Normal file
165
internal/adapter/notify/admin_client.go
Normal file
@ -0,0 +1,165 @@
|
||||
// Package notify provides a notify service admin client for rdev.
|
||||
// It manages accounts and send keys on behalf of projects.
|
||||
package notify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// adminClient calls the notify admin API to manage accounts and keys.
|
||||
type adminClient struct {
|
||||
baseURL string
|
||||
adminKey string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newAdminClient(baseURL, adminKey string) *adminClient {
|
||||
return &adminClient{
|
||||
baseURL: baseURL,
|
||||
adminKey: adminKey,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// accountResponse is the shape returned by POST /admin/accounts.
|
||||
type accountResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// apiKeyResponse is the shape returned by POST /admin/api-keys (full key only on creation).
|
||||
type apiKeyResponse struct {
|
||||
ID int `json:"id"`
|
||||
Key string `json:"key"` // plaintext — only present on creation
|
||||
KeyPrefix string `json:"key_prefix"` // e.g. "notify_send"
|
||||
AccountID string `json:"account_id"`
|
||||
KeyType string `json:"key_type"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// listAccountsResponse is the shape returned by GET /admin/accounts.
|
||||
type listAccountsResponse struct {
|
||||
Items []accountResponse `json:"items"`
|
||||
}
|
||||
|
||||
// createAccount creates a new notify account with the given name.
|
||||
func (c *adminClient) createAccount(ctx context.Context, name string) (*accountResponse, error) {
|
||||
payload := map[string]string{"name": name}
|
||||
respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/accounts", payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create account: %w", err)
|
||||
}
|
||||
|
||||
var acct accountResponse
|
||||
if err := json.Unmarshal(respBody, &acct); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal account response: %w", err)
|
||||
}
|
||||
return &acct, nil
|
||||
}
|
||||
|
||||
// createSendKey creates a send API key for the given account.
|
||||
// The plaintext key is only present in the response at creation time.
|
||||
func (c *adminClient) createSendKey(ctx context.Context, accountID, name string) (*apiKeyResponse, error) {
|
||||
payload := map[string]string{
|
||||
"account_id": accountID,
|
||||
"key_type": "send",
|
||||
"name": name,
|
||||
}
|
||||
respBody, err := c.doRequest(ctx, http.MethodPost, "/admin/api-keys", payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create send key: %w", err)
|
||||
}
|
||||
|
||||
var key apiKeyResponse
|
||||
if err := json.Unmarshal(respBody, &key); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal key response: %w", err)
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// grantHostAccess grants the given account access to send from the specified host slug.
|
||||
func (c *adminClient) grantHostAccess(ctx context.Context, hostSlug, accountID string) error {
|
||||
payload := map[string]string{"account_id": accountID}
|
||||
_, err := c.doRequest(ctx, http.MethodPost, "/admin/hosts/"+hostSlug+"/accounts", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("grant host access: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteAccount removes the notify account and all its keys.
|
||||
func (c *adminClient) deleteAccount(ctx context.Context, accountID string) error {
|
||||
_, err := c.doRequest(ctx, http.MethodDelete, "/admin/accounts/"+accountID, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete account: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listAccounts returns all accounts in the notify service.
|
||||
func (c *adminClient) listAccounts(ctx context.Context) ([]accountResponse, error) {
|
||||
respBody, err := c.doRequest(ctx, http.MethodGet, "/admin/accounts", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list accounts: %w", err)
|
||||
}
|
||||
|
||||
var resp listAccountsResponse
|
||||
if err := json.Unmarshal(respBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal accounts list: %w", err)
|
||||
}
|
||||
return resp.Items, nil
|
||||
}
|
||||
|
||||
// doRequest executes an HTTP request against the notify admin API.
|
||||
func (c *adminClient) doRequest(ctx context.Context, method, path string, bodyData any) ([]byte, error) {
|
||||
var reqBody io.Reader
|
||||
if bodyData != nil {
|
||||
jsonBody, err := json.Marshal(bodyData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
reqBody = bytes.NewReader(jsonBody)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+c.adminKey)
|
||||
if bodyData != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http do: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// 204 No Content — success with no body (e.g., grant host access, delete)
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response body: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("notify admin API error (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
155
internal/adapter/notify/provisioner.go
Normal file
155
internal/adapter/notify/provisioner.go
Normal file
@ -0,0 +1,155 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
)
|
||||
|
||||
// Provisioner implements port.NotifyProvisioner using the notify admin API.
|
||||
// Each project gets an isolated notify account and send key scoped to the
|
||||
// shared sending host (e.g., "threesix.ai").
|
||||
type Provisioner struct {
|
||||
client *adminClient
|
||||
host string // shared sending host slug (e.g., "threesix.ai")
|
||||
from string // from-address (e.g., "noreply@threesix.ai")
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// Config holds configuration for the notify provisioner.
|
||||
type Config struct {
|
||||
BaseURL string // Required: notify service URL (e.g., "https://notify.orchard9.ai")
|
||||
AdminKey string // Required: admin API key (notify_admin_...)
|
||||
Host string // Shared host slug for all projects (e.g., "threesix.ai")
|
||||
From string // Default from-address (e.g., "noreply@threesix.ai")
|
||||
}
|
||||
|
||||
// NewProvisioner creates a new notify provisioner.
|
||||
func NewProvisioner(cfg Config, logger *slog.Logger) *Provisioner {
|
||||
host := cfg.Host
|
||||
if host == "" {
|
||||
host = "threesix.ai"
|
||||
}
|
||||
from := cfg.From
|
||||
if from == "" {
|
||||
from = "noreply@threesix.ai"
|
||||
}
|
||||
return &Provisioner{
|
||||
client: newAdminClient(cfg.BaseURL, cfg.AdminKey),
|
||||
host: host,
|
||||
from: from,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProjectNotify provisions a notify account and send key for the project.
|
||||
// Steps:
|
||||
// 1. Create account named "project-{projectID}"
|
||||
// 2. Create send API key via POST /admin/api-keys
|
||||
// 3. Grant account access to the shared host
|
||||
func (p *Provisioner) CreateProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) {
|
||||
accountName := "project-" + projectID
|
||||
|
||||
// 1. Create account
|
||||
acct, err := p.client.createAccount(ctx, accountName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: create account for project %s: %w", projectID, err)
|
||||
}
|
||||
|
||||
// 2. Create send key (plaintext key only returned here)
|
||||
key, err := p.client.createSendKey(ctx, acct.ID, accountName+"-send")
|
||||
if err != nil {
|
||||
// Best-effort cleanup
|
||||
if delErr := p.client.deleteAccount(ctx, acct.ID); delErr != nil {
|
||||
p.logger.Warn("failed to clean up notify account after key creation failure",
|
||||
"account_id", acct.ID,
|
||||
"project_id", projectID,
|
||||
"error", delErr,
|
||||
)
|
||||
}
|
||||
return nil, fmt.Errorf("notify: create send key for project %s: %w", projectID, err)
|
||||
}
|
||||
|
||||
// 3. Grant host access
|
||||
if err := p.client.grantHostAccess(ctx, p.host, acct.ID); err != nil {
|
||||
p.logger.Warn("failed to grant notify host access",
|
||||
"host", p.host,
|
||||
"account_id", acct.ID,
|
||||
"project_id", projectID,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
|
||||
return &domain.NotifyCredentials{
|
||||
ProjectID: projectID,
|
||||
AccountID: acct.ID,
|
||||
APIKey: key.Key,
|
||||
Host: p.host,
|
||||
From: p.from,
|
||||
CreatedAt: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteProjectNotify removes the notify account for the project.
|
||||
func (p *Provisioner) DeleteProjectNotify(ctx context.Context, projectID string) error {
|
||||
acct, err := p.findAccountByProject(ctx, projectID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify: find account for project %s: %w", projectID, err)
|
||||
}
|
||||
if acct == nil {
|
||||
return nil // Already deleted or never provisioned
|
||||
}
|
||||
|
||||
if err := p.client.deleteAccount(ctx, acct.ID); err != nil {
|
||||
return fmt.Errorf("notify: delete account %s for project %s: %w", acct.ID, projectID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProjectNotify returns notify credentials for the project, or nil if not provisioned.
|
||||
// Note: APIKey cannot be retrieved after creation — returns empty string.
|
||||
func (p *Provisioner) GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error) {
|
||||
acct, err := p.findAccountByProject(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: find account for project %s: %w", projectID, err)
|
||||
}
|
||||
if acct == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return &domain.NotifyCredentials{
|
||||
ProjectID: projectID,
|
||||
AccountID: acct.ID,
|
||||
Host: p.host,
|
||||
From: p.from,
|
||||
CreatedAt: acct.CreatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TestConnection verifies the notify admin API is reachable.
|
||||
func (p *Provisioner) TestConnection(ctx context.Context) error {
|
||||
_, err := p.client.listAccounts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("notify admin API unreachable: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findAccountByProject looks up the account named "project-{projectID}".
|
||||
func (p *Provisioner) findAccountByProject(ctx context.Context, projectID string) (*accountResponse, error) {
|
||||
accounts, err := p.client.listAccounts(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
targetName := "project-" + projectID
|
||||
for i := range accounts {
|
||||
if accounts[i].Name == targetName {
|
||||
return &accounts[i], nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@ -38,6 +38,7 @@ const (
|
||||
CredentialCategoryWorker = "worker"
|
||||
CredentialCategoryStorage = "storage"
|
||||
CredentialCategoryAI = "ai"
|
||||
CredentialCategoryNotify = "notify"
|
||||
)
|
||||
|
||||
// Known credential keys.
|
||||
@ -65,4 +66,11 @@ const (
|
||||
// AI Providers
|
||||
CredKeyLaozhangAPIKey = "LAOZHANG_API_KEY"
|
||||
CredKeyGeminiAPIKey = "GEMINI_API_KEY"
|
||||
|
||||
// Notify service (email delivery)
|
||||
CredKeyNotifyURL = "NOTIFY_URL"
|
||||
CredKeyNotifyAdminKey = "NOTIFY_ADMIN_KEY"
|
||||
CredKeyNotifyAPIKey = "NOTIFY_API_KEY"
|
||||
CredKeyNotifyHost = "NOTIFY_HOST"
|
||||
CredKeyNotifyFrom = "NOTIFY_FROM"
|
||||
)
|
||||
|
||||
25
internal/domain/notify.go
Normal file
25
internal/domain/notify.go
Normal file
@ -0,0 +1,25 @@
|
||||
// Package domain contains core business entities.
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// NotifyCredentials holds per-project email delivery credentials.
|
||||
type NotifyCredentials struct {
|
||||
// ProjectID is the rdev project this credential set belongs to.
|
||||
ProjectID string
|
||||
|
||||
// AccountID is the notify service account UUID (used for deletion).
|
||||
AccountID string
|
||||
|
||||
// APIKey is the notify send key (notify_send_...) for sending emails.
|
||||
APIKey string
|
||||
|
||||
// Host is the shared sending host (e.g., "threesix.ai").
|
||||
Host string
|
||||
|
||||
// From is the from-address for outgoing email (e.g., "noreply@threesix.ai").
|
||||
From string
|
||||
|
||||
// CreatedAt is when the credentials were provisioned.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
23
internal/port/notify_provisioner.go
Normal file
23
internal/port/notify_provisioner.go
Normal file
@ -0,0 +1,23 @@
|
||||
package port
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/orchard9/rdev/internal/domain"
|
||||
)
|
||||
|
||||
// NotifyProvisioner manages per-project email delivery accounts on the notify service.
|
||||
type NotifyProvisioner interface {
|
||||
// CreateProjectNotify creates a notify account and send key for a project.
|
||||
// Grants the account access to the shared host and returns credentials.
|
||||
CreateProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error)
|
||||
|
||||
// DeleteProjectNotify removes the notify account for a project.
|
||||
DeleteProjectNotify(ctx context.Context, projectID string) error
|
||||
|
||||
// GetProjectNotify returns notify credentials for a project, or nil if not provisioned.
|
||||
GetProjectNotify(ctx context.Context, projectID string) (*domain.NotifyCredentials, error)
|
||||
|
||||
// TestConnection verifies the admin API key and notify service are reachable.
|
||||
TestConnection(ctx context.Context) error
|
||||
}
|
||||
@ -35,6 +35,7 @@ type ProjectInfraService struct {
|
||||
dbProvisioner port.DatabaseProvisioner
|
||||
cacheProvisioner port.CacheProvisioner
|
||||
storageProvisioner port.StorageProvisioner
|
||||
notifyProvisioner port.NotifyProvisioner
|
||||
registryProvider port.RegistryProvider
|
||||
citadelClient port.CitadelClient
|
||||
|
||||
@ -109,6 +110,12 @@ func (s *ProjectInfraService) WithStorageProvisioner(sp port.StorageProvisioner)
|
||||
return s
|
||||
}
|
||||
|
||||
// WithNotifyProvisioner sets the notify provisioner for project email delivery.
|
||||
func (s *ProjectInfraService) WithNotifyProvisioner(np port.NotifyProvisioner) *ProjectInfraService {
|
||||
s.notifyProvisioner = np
|
||||
return s
|
||||
}
|
||||
|
||||
// WithRegistryProvider sets the container registry provider for image cleanup.
|
||||
func (s *ProjectInfraService) WithRegistryProvider(rp port.RegistryProvider) *ProjectInfraService {
|
||||
s.registryProvider = rp
|
||||
|
||||
@ -467,6 +467,46 @@ func (s *ProjectInfraService) provisionResources(ctx context.Context, result *Cr
|
||||
}
|
||||
}
|
||||
|
||||
// Provision notify email delivery (idempotent)
|
||||
if s.notifyProvisioner != nil {
|
||||
existing, _ := s.notifyProvisioner.GetProjectNotify(ctx, projectID)
|
||||
if existing != nil {
|
||||
log.Info("notify already provisioned, skipping", logging.FieldProjectID, projectID)
|
||||
} else {
|
||||
notifyCreds, err := s.notifyProvisioner.CreateProjectNotify(ctx, projectID)
|
||||
if err != nil {
|
||||
log.Error("failed to provision notify", logging.FieldProjectID, projectID, logging.FieldError, err)
|
||||
result.NextSteps = append(result.NextSteps, "Notify provisioning failed - contact admin")
|
||||
} else if s.credentialStore != nil {
|
||||
var storeErr error
|
||||
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)
|
||||
}
|
||||
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 storeErr != nil {
|
||||
log.Warn("rolling back notify due to credential storage failure", logging.FieldProjectID, projectID)
|
||||
if rollbackErr := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID); rollbackErr != nil {
|
||||
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")
|
||||
} else {
|
||||
result.NextSteps = append(result.NextSteps, "Notify provisioning rolled back due to credential storage failure")
|
||||
}
|
||||
} else {
|
||||
log.Info("notify provisioned", logging.FieldProjectID, projectID, "host", notifyCreds.Host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provision storage (idempotent)
|
||||
if s.storageProvisioner != nil {
|
||||
existing, _ := s.storageProvisioner.GetProjectBucket(ctx, projectID)
|
||||
@ -844,6 +884,13 @@ func (s *ProjectInfraService) DeleteProject(ctx context.Context, projectID strin
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Delete provisioned notify account
|
||||
if s.notifyProvisioner != nil {
|
||||
if err := s.notifyProvisioner.DeleteProjectNotify(ctx, projectID); err != nil {
|
||||
log.Warn("failed to delete project notify account", logging.FieldError, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 5b. Delete Citadel log environment
|
||||
s.deleteCitadelEnvironment(ctx, projectID)
|
||||
|
||||
|
||||
@ -70,6 +70,7 @@ get_category() {
|
||||
WOODPECKER_URL|WOODPECKER_API_TOKEN|WOODPECKER_WEBHOOK_SECRET) echo "woodpecker" ;;
|
||||
REGISTRY_URL) echo "registry" ;;
|
||||
LAOZHANG_API_KEY|GEMINI_API_KEY) echo "ai" ;;
|
||||
NOTIFY_URL|NOTIFY_ADMIN_KEY) echo "notify" ;;
|
||||
*) echo "other" ;;
|
||||
esac
|
||||
}
|
||||
@ -87,6 +88,8 @@ get_description() {
|
||||
REGISTRY_URL) echo "Container registry URL" ;;
|
||||
LAOZHANG_API_KEY) echo "LaoZhang API key for text/image generation (also proxies Grok)" ;;
|
||||
GEMINI_API_KEY) echo "Google Gemini API key for text/image generation" ;;
|
||||
NOTIFY_URL) echo "Notify service base URL for email delivery" ;;
|
||||
NOTIFY_ADMIN_KEY) echo "Notify admin API key for provisioning per-project accounts" ;;
|
||||
*) echo "$1 credential" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
169
scripts/setup-notify.sh
Executable file
169
scripts/setup-notify.sh
Executable file
@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-notify.sh - One-time host and provider setup for the notify service.
|
||||
#
|
||||
# Creates the threesix.ai host, adds Resend as provider, registers noreply@threesix.ai,
|
||||
# and adds Resend DNS records to Cloudflare for domain verification.
|
||||
#
|
||||
# Idempotent: safe to run multiple times.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/setup-notify.sh
|
||||
# NOTIFY_URL=... NOTIFY_ADMIN_KEY=... RESEND_API_KEY=... ./scripts/setup-notify.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
||||
|
||||
# ─── Load secrets ────────────────────────────────────────────────────────────
|
||||
|
||||
SECRETS_FILE="${SECRETS_FILE:-.secrets}"
|
||||
if [[ -f "$SECRETS_FILE" ]]; then
|
||||
while IFS='=' read -r key val || [[ -n "$key" ]]; do
|
||||
[[ -z "$key" || "$key" == \#* ]] && continue
|
||||
export "$key"="${val}"
|
||||
done < "$SECRETS_FILE"
|
||||
fi
|
||||
|
||||
NOTIFY_URL="${NOTIFY_URL:-}"
|
||||
NOTIFY_ADMIN_KEY="${NOTIFY_ADMIN_KEY:-}"
|
||||
RESEND_API_KEY="${RESEND_API_KEY:-}"
|
||||
CF_TOKEN="${CLOUDFLARE_API_TOKEN:-}"
|
||||
CF_ZONE="${CLOUDFLARE_ZONE_ID:-}"
|
||||
|
||||
if [[ -z "$NOTIFY_URL" ]]; then log_error "NOTIFY_URL required"; exit 1; fi
|
||||
if [[ -z "$NOTIFY_ADMIN_KEY" ]]; then log_error "NOTIFY_ADMIN_KEY required"; exit 1; fi
|
||||
if [[ -z "$RESEND_API_KEY" ]]; then log_error "RESEND_API_KEY required"; exit 1; fi
|
||||
|
||||
HOST=threesix.ai
|
||||
FROM=noreply@threesix.ai
|
||||
|
||||
log_info "Notify URL: $NOTIFY_URL"
|
||||
log_info "Host: $HOST"
|
||||
log_info "From: $FROM"
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
notify() {
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
local args=(-s -X "$method" "$NOTIFY_URL$path"
|
||||
-H "Authorization: Bearer $NOTIFY_ADMIN_KEY"
|
||||
-H "Content-Type: application/json")
|
||||
[[ -n "$body" ]] && args+=(-d "$body")
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
resend_api() {
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
local args=(-s -X "$method" "https://api.resend.com$path"
|
||||
-H "Authorization: Bearer $RESEND_API_KEY"
|
||||
-H "Content-Type: application/json")
|
||||
[[ -n "$body" ]] && args+=(-d "$body")
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
cf_dns() {
|
||||
local method="$1" path="$2" body="${3:-}"
|
||||
local args=(-s -X "$method" "https://api.cloudflare.com/client/v4/zones/$CF_ZONE$path"
|
||||
-H "Authorization: Bearer $CF_TOKEN"
|
||||
-H "Content-Type: application/json")
|
||||
[[ -n "$body" ]] && args+=(-d "$body")
|
||||
curl "${args[@]}"
|
||||
}
|
||||
|
||||
# ─── Step 1: Create host ──────────────────────────────────────────────────────
|
||||
|
||||
log_step "1. Setting up notify host: $HOST"
|
||||
existing=$(notify GET "/admin/hosts" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(next((x['host'] for x in items if x['host']=='$HOST'),''))" 2>/dev/null || true)
|
||||
if [[ "$existing" == "$HOST" ]]; then
|
||||
log_info " Host already exists — skipping"
|
||||
else
|
||||
notify POST "/admin/hosts" "{\"host\":\"$HOST\",\"strategy\":\"failover\"}" | python3 -m json.tool
|
||||
log_info " Host created"
|
||||
fi
|
||||
|
||||
# ─── Step 2: Add Resend provider ─────────────────────────────────────────────
|
||||
|
||||
log_step "2. Adding Resend provider"
|
||||
providers=$(notify GET "/admin/hosts/$HOST/providers" | python3 -c "import sys,json; items=json.load(sys.stdin); print(next((str(x['id']) for x in items if x['provider']=='resend'),''))" 2>/dev/null || true)
|
||||
if [[ -n "$providers" ]]; then
|
||||
log_info " Resend provider already configured (id: $providers) — skipping"
|
||||
else
|
||||
notify POST "/admin/hosts/$HOST/providers" \
|
||||
"{\"provider\":\"resend\",\"config\":{\"api_key\":\"$RESEND_API_KEY\"},\"priority\":1,\"retry_attempts\":3,\"retry_backoff_ms\":1000}" | python3 -m json.tool
|
||||
log_info " Resend provider added"
|
||||
fi
|
||||
|
||||
# ─── Step 3: Register from-address ───────────────────────────────────────────
|
||||
|
||||
log_step "3. Registering from-address: $FROM"
|
||||
addrs=$(notify GET "/admin/hosts/$HOST/from-addresses" | python3 -c "import sys,json; items=json.load(sys.stdin).get('items',[]); print(next((x['email'] for x in items if x['email']=='$FROM'),''))" 2>/dev/null || true)
|
||||
if [[ "$addrs" == "$FROM" ]]; then
|
||||
log_info " From-address already registered — skipping"
|
||||
else
|
||||
notify POST "/admin/hosts/$HOST/from-addresses" \
|
||||
"{\"email\":\"$FROM\",\"display_name\":\"threesix.ai\"}" | python3 -m json.tool
|
||||
log_info " From-address registered"
|
||||
fi
|
||||
|
||||
# ─── Step 4: Resend domain + Cloudflare DNS ───────────────────────────────────
|
||||
|
||||
log_step "4. Setting up Resend domain for $HOST"
|
||||
existing_domain=$(resend_api GET "/domains" | python3 -c "import sys,json; data=json.load(sys.stdin); print(next((x['id'] for x in data.get('data',[]) if x['name']=='$HOST'),''))" 2>/dev/null || true)
|
||||
|
||||
if [[ -n "$existing_domain" ]]; then
|
||||
log_info " Resend domain already exists (id: $existing_domain)"
|
||||
DOMAIN_ID="$existing_domain"
|
||||
DOMAIN_RECORDS=$(resend_api GET "/domains/$DOMAIN_ID" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('records',[])))")
|
||||
else
|
||||
log_info " Creating Resend domain..."
|
||||
domain_resp=$(resend_api POST "/domains" "{\"name\":\"$HOST\",\"region\":\"us-east-1\"}")
|
||||
DOMAIN_ID=$(echo "$domain_resp" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
DOMAIN_RECORDS=$(echo "$domain_resp" | python3 -c "import sys,json; print(json.dumps(json.load(sys.stdin).get('records',[])))")
|
||||
log_info " Domain created (id: $DOMAIN_ID)"
|
||||
fi
|
||||
|
||||
# Add DNS records if Cloudflare is configured
|
||||
if [[ -n "$CF_TOKEN" && -n "$CF_ZONE" ]]; then
|
||||
log_step "5. Adding Resend DNS records to Cloudflare"
|
||||
echo "$DOMAIN_RECORDS" | python3 -c "
|
||||
import sys, json
|
||||
records = json.load(sys.stdin)
|
||||
for r in records:
|
||||
print(r['type'], r['name'], r.get('value',''), r.get('priority',''))
|
||||
" | while read -r rtype rname rvalue rpriority; do
|
||||
# Check if record already exists
|
||||
existing_rec=$(cf_dns GET "/dns_records?type=$rtype&name=$rname.$HOST" | python3 -c "import sys,json; result=json.load(sys.stdin).get('result',[]); print(result[0]['id'] if result else '')" 2>/dev/null || true)
|
||||
if [[ -n "$existing_rec" ]]; then
|
||||
log_info " $rtype $rname already exists — skipping"
|
||||
else
|
||||
if [[ "$rtype" == "MX" ]]; then
|
||||
cf_dns POST "/dns_records" "{\"type\":\"MX\",\"name\":\"$rname\",\"content\":\"$rvalue\",\"priority\":$rpriority,\"ttl\":1}" > /dev/null
|
||||
else
|
||||
cf_dns POST "/dns_records" "{\"type\":\"$rtype\",\"name\":\"$rname\",\"content\":\"$rvalue\",\"ttl\":1,\"proxied\":false}" > /dev/null
|
||||
fi
|
||||
log_info " Added $rtype $rname"
|
||||
fi
|
||||
done
|
||||
|
||||
# Trigger verification
|
||||
log_step "6. Triggering Resend domain verification"
|
||||
resend_api POST "/domains/$DOMAIN_ID/verify" > /dev/null
|
||||
log_info " Verification triggered (DNS propagation takes ~60s)"
|
||||
else
|
||||
log_warn " CLOUDFLARE_API_TOKEN or CLOUDFLARE_ZONE_ID not set — add DNS records manually:"
|
||||
echo "$DOMAIN_RECORDS" | python3 -m json.tool
|
||||
fi
|
||||
|
||||
echo ""
|
||||
log_info "Setup complete."
|
||||
log_info "Check Resend domain status: curl -s https://api.resend.com/domains/$DOMAIN_ID -H 'Authorization: Bearer \$RESEND_API_KEY' | python3 -m json.tool"
|
||||
Loading…
Reference in New Issue
Block a user