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>
166 lines
4.9 KiB
Go
166 lines
4.9 KiB
Go
// 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))
|
|
}
|