feat: add POST /projects/{id}/notify/reprovision to migrate notify host
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Implements ReprovisionNotifyHost to migrate a project's email sending
from an old notify host to a new one (e.g., from project-name-based to
slug-based host). Preserves the project's notify account and send key.

- Adds ReprovisionNotifyHost to port.NotifyProvisioner interface
- Implements revokeHostAccess on notifyAdminAPI + adminClient
- Implements Provisioner.ReprovisionNotifyHost (12-step migration)
  in provisioner_reprovision.go (split to keep provisioner.go < 500 lines)
- Adds NotifyHandler.Reprovision handler (POST /notify/reprovision)
- Updates OpenAPI spec with reprovision endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jordan 2026-02-23 21:28:59 -07:00
parent ee1c214b7e
commit 96219a647f
10 changed files with 1110 additions and 14 deletions

View File

@ -261,11 +261,11 @@ Patterns support shell-style globs with ` + "`*`" + ` wildcards:
"description": "Success",
"content": map[string]any{
"application/json": map[string]any{
"example": `{
"example": wrapResponseExample(`{
"deleted": ["tree-test-1706889600", "landing-test-1706889601"],
"count": 2,
"dry_run": true
}`,
}`),
},
},
},
@ -526,7 +526,7 @@ func registerAuditPaths(spec *api.OpenAPISpec) {
"description": "Success",
"content": map[string]any{
"application/json": map[string]any{
"example": `{
"example": wrapResponseExample(`{
"entries": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
@ -549,7 +549,7 @@ func registerAuditPaths(spec *api.OpenAPISpec) {
"total": 1,
"limit": 100,
"offset": 0
}`,
}`),
},
},
},

View File

@ -1,11 +1,24 @@
package main
import (
"fmt"
"strings"
"github.com/orchard9/rdev/pkg/api"
)
// wrapResponseExample wraps a bare JSON data value in the standard
// { "data": ..., "meta": {...} } response envelope used by all API endpoints.
func wrapResponseExample(dataExample string) string {
return fmt.Sprintf(`{
"data": %s,
"meta": {
"request_id": "req-550e8400-e29b",
"timestamp": "2026-01-27T12:00:00Z"
}
}`, dataExample)
}
// summaryToOperationID converts a human-readable summary to a camelCase operationId.
// "List API keys" → "listAPIKeys", "Health check" → "healthCheck"
func summaryToOperationID(summary string) string {
@ -132,6 +145,8 @@ type param struct {
}
// withAuth creates an operation that requires authentication.
// The example parameter is the bare data value (array or object); it is
// automatically wrapped in the standard { "data": ..., "meta": {...} } envelope.
func withAuth(summary, description, tag, scope, example string) map[string]any {
return map[string]any{
"operationId": summaryToOperationID(summary),
@ -146,7 +161,7 @@ func withAuth(summary, description, tag, scope, example string) map[string]any {
"description": "Success",
"content": map[string]any{
"application/json": map[string]any{
"example": example,
"example": wrapResponseExample(example),
},
},
},
@ -157,6 +172,8 @@ func withAuth(summary, description, tag, scope, example string) map[string]any {
}
// withAuthAndBody creates an operation with auth and request body.
// The responseExample parameter is the bare data value; it is automatically
// wrapped in the standard { "data": ..., "meta": {...} } envelope.
func withAuthAndBody(summary, description, tag, scope, requestExample, responseExample string) map[string]any {
return map[string]any{
"operationId": summaryToOperationID(summary),
@ -179,7 +196,7 @@ func withAuthAndBody(summary, description, tag, scope, requestExample, responseE
"description": "Created",
"content": map[string]any{
"application/json": map[string]any{
"example": responseExample,
"example": wrapResponseExample(responseExample),
},
},
},
@ -221,6 +238,8 @@ func withAuthAndParams(summary, description, tag, scope string, params []param)
}
// withAuthBodyAndParams creates an operation with auth, body, and params.
// The responseExample parameter is the bare data value; it is automatically
// wrapped in the standard { "data": ..., "meta": {...} } envelope.
func withAuthBodyAndParams(summary, description, tag, scope string, params []param, requestExample, responseExample string) map[string]any {
parameters := make([]map[string]any, len(params))
for i, p := range params {
@ -254,7 +273,7 @@ func withAuthBodyAndParams(summary, description, tag, scope string, params []par
"description": "Created",
"content": map[string]any{
"application/json": map[string]any{
"example": responseExample,
"example": wrapResponseExample(responseExample),
},
},
},
@ -835,7 +854,7 @@ Optionally filter by category using ?category=<name> query parameter.
"description": "Success",
"content": map[string]any{
"application/json": map[string]any{
"example": `[
"example": wrapResponseExample(`[
{
"key": "GITEA_TOKEN",
"value": "***",
@ -845,7 +864,7 @@ Optionally filter by category using ?category=<name> query parameter.
"updated_at": "2026-01-15T10:30:00Z",
"updated_by": "admin"
}
]`,
]`),
},
},
},
@ -925,7 +944,7 @@ Useful for bulk credential loading from configuration files.`,
"description": "Success",
"content": map[string]any{
"application/json": map[string]any{
"example": `{"status": "deleted", "key": "GITEA_TOKEN"}`,
"example": wrapResponseExample(`{"status": "deleted", "key": "GITEA_TOKEN"}`),
},
},
},
@ -1393,13 +1412,13 @@ Processes successful builds on main/master branches to trigger deployments and D
"description": "Success - Event processed",
"content": map[string]any{
"application/json": map[string]any{
"example": `{
"example": wrapResponseExample(`{
"status": "success",
"project": "my-landing-page",
"image": "registry.threesix.ai/my-landing-page:abc123de",
"commit": "abc123def456",
"note": "component deployments managed by CI pipeline"
}`,
}`),
},
},
},

View File

@ -36,6 +36,8 @@ resources:
# Wildcard TLS for session preview URLs
- preview-cert.yaml
# Citadel log agent (ships container logs to partner-hosted Citadel)
- citadel-agent/
# Citadel log agent: ships rdev/projects namespace container logs to rdev Citadel environment.
# Uses namespace-filtered globs (*_rdev_*, *_projects_*) so only rdev platform logs
# land in the rdev tenant. The Citadel Helm chart's DaemonSet handles infra/k3s logs separately.
- rdev-logs-agent.yaml

View File

@ -0,0 +1,131 @@
# rdev-logs-agent DaemonSet
#
# Collects stdout/stderr from rdev and projects namespace pods and ships them
# to the rdev Citadel environment (tenant bf874fbf-6150-4aa9-b7bc-db531791bde1).
#
# The Citadel Helm chart's DaemonSet uses a single static tenant ID (k3s infra).
# This dedicated agent uses namespace-filtered glob patterns to route only
# rdev/projects container logs to the correct tenant:
#
# /var/log/containers/<pod>_<namespace>_<container>-<id>.log
#
# Glob patterns *_rdev_* and *_projects_* match exactly those namespaces.
#
# CITADEL_API_KEY is read from the existing rdev-credentials secret.
# Tenant ID and Citadel URL are hardcoded — update if rdev environment is recreated.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: rdev-logs-agent
namespace: rdev
labels:
app.kubernetes.io/name: rdev-logs-agent
app.kubernetes.io/part-of: rdev
spec:
selector:
matchLabels:
app: rdev-logs-agent
updateStrategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
template:
metadata:
labels:
app: rdev-logs-agent
app.kubernetes.io/name: rdev-logs-agent
app.kubernetes.io/part-of: rdev
spec:
serviceAccountName: rdev-api
imagePullSecrets:
- name: ghcr-secret
terminationGracePeriodSeconds: 30
dnsPolicy: ClusterFirst
containers:
- name: agent
image: gcr.io/orchard9/citadel-agent:v0.4.7
imagePullPolicy: IfNotPresent
command:
- citadel-agent
args:
- tail
- --tenant
- "bf874fbf-6150-4aa9-b7bc-db531791bde1"
- --http
- --http-url
- "http://citadel-community.citadel.svc.cluster.local"
- --insecure
- --admin-port
- "9191"
# Namespace-filtered globs: only rdev and projects namespace container logs
# Filename pattern: <pod>_<namespace>_<container>-<id>.log
- "/var/log/containers/*_rdev_*.log"
- "/var/log/containers/*_projects_*.log"
env:
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: CITADEL_API_KEY
valueFrom:
secretKeyRef:
name: rdev-credentials
key: CITADEL_API_KEY
ports:
- name: admin
containerPort: 9191
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: admin
scheme: HTTP
initialDelaySeconds: 10
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 128Mi
volumeMounts:
- name: varlog
mountPath: /var/log
readOnly: true
- name: tmp
mountPath: /tmp
securityContext:
runAsNonRoot: false
runAsUser: 0
readOnlyRootFilesystem: false
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
volumes:
- name: varlog
hostPath:
path: /var/log
type: Directory
- name: tmp
emptyDir: {}

View File

@ -22,6 +22,7 @@ type notifyAdminAPI interface {
createAccount(ctx context.Context, name string) (*accountResponse, error)
createSendKey(ctx context.Context, accountID, name string) (*apiKeyResponse, error)
grantHostAccess(ctx context.Context, hostSlug, accountID string) error
revokeHostAccess(ctx context.Context, hostSlug, accountID string) error
deleteAccount(ctx context.Context, accountID string) error
listAccounts(ctx context.Context) ([]accountResponse, error)
}
@ -156,6 +157,15 @@ func (c *adminClient) grantHostAccess(ctx context.Context, hostSlug, accountID s
return nil
}
// revokeHostAccess removes the given account's access to send from the specified host slug.
func (c *adminClient) revokeHostAccess(ctx context.Context, hostSlug, accountID string) error {
_, err := c.doRequest(ctx, http.MethodDelete, "/admin/hosts/"+hostSlug+"/accounts/"+accountID, nil)
if err != nil {
return fmt.Errorf("revoke 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)

View File

@ -0,0 +1,160 @@
package notify
import (
"context"
"fmt"
"github.com/orchard9/rdev/internal/domain"
)
// ReprovisionNotifyHost migrates a project's notify setup from oldHost to newHost.
// The project's notify account and send key are preserved; only the sending host,
// Resend domain, and DNS records are replaced.
//
// Steps:
// 1. Create new notify host newHost
// 2. Add Resend provider to newHost
// 3. Register noreply@newHost as from-address on newHost
// 4. Find the project's existing notify account
// 5. Revoke account access to oldHost (non-fatal)
// 6. Grant account access to newHost (non-fatal)
// 7. Create Resend domain for newHost
// 8. Add DKIM/SPF DNS records for newHost (non-fatal)
// 9. Delete old Resend domain (non-fatal)
//
// 10. Delete old DNS records for oldHost (non-fatal)
// 11. Delete old notify host (non-fatal)
// 12. Fire-and-forget async domain verification
func (p *Provisioner) ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyCredentials, error) {
newFrom := "noreply@" + newHost
// 1. Create new notify host.
if err := p.client.createHost(ctx, newHost, "failover"); err != nil {
return nil, fmt.Errorf("notify: create host %s for project %s: %w", newHost, projectID, err)
}
// 2. Add Resend provider to new host.
if p.resend != nil {
if err := p.client.createProvider(ctx, newHost, "resend", map[string]string{"api_key": p.resendAPIKey}, 1, 3, 1000); err != nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: create provider on host %s for project %s: %w", newHost, projectID, err)
}
}
// 3. Register from-address on new host.
slug := newHost
if err := p.client.createFromAddress(ctx, newHost, newFrom, slug); err != nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: create from-address %s for project %s: %w", newFrom, projectID, err)
}
// 4. Find existing account.
acct, err := p.findAccountByProject(ctx, projectID)
if err != nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: find account for project %s: %w", projectID, err)
}
if acct == nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: no account found for project %s", projectID)
}
// 5. Revoke old host access (non-fatal).
if oldHost != "" {
if err := p.client.revokeHostAccess(ctx, oldHost, acct.ID); err != nil {
p.logger.Warn("failed to revoke old notify host access",
"old_host", oldHost, "account_id", acct.ID, "project_id", projectID, "error", err)
}
}
// 6. Grant new host access (non-fatal).
if err := p.client.grantHostAccess(ctx, newHost, acct.ID); err != nil {
p.logger.Warn("failed to grant new notify host access",
"new_host", newHost, "account_id", acct.ID, "project_id", projectID, "error", err)
}
// 7. Create Resend domain for new host.
var newResendDomainID string
var dnsRecords []resendDNSRecord
if p.resend != nil {
var resendErr error
newResendDomainID, dnsRecords, resendErr = p.resend.createDomain(ctx, newHost, "us-east-1")
if resendErr != nil {
p.bestEffortDeleteHost(ctx, newHost, projectID)
return nil, fmt.Errorf("notify: create resend domain for %s: %w", newHost, resendErr)
}
p.logger.Info("resend domain created for new host", "host", newHost, "domain_id", newResendDomainID, "project_id", projectID)
}
// 8. Add DKIM/SPF DNS records for new host (non-fatal).
if p.dns != nil && len(dnsRecords) > 0 {
for _, rec := range dnsRecords {
if _, upsertErr := p.dns.UpsertRecord(ctx, domain.DNSRecord{
Type: rec.DNSType,
Name: rec.Name,
Content: rec.Value,
TTL: 1,
Priority: rec.Priority,
}); upsertErr != nil {
p.logger.Warn("failed to upsert notify DNS record for new host",
"name", rec.Name, "type", rec.DNSType, "project_id", projectID, "error", upsertErr)
}
}
}
// 9. Delete old Resend domain (non-fatal).
if p.resend != nil && oldResendDomainID != "" {
if err := p.resend.deleteDomain(ctx, oldResendDomainID); err != nil {
p.logger.Warn("failed to delete old resend domain",
"domain_id", oldResendDomainID, "project_id", projectID, "error", err)
}
}
// 10. Delete old DNS records for oldHost (non-fatal).
if p.dns != nil && oldHost != "" {
dkimName := "resend._domainkey." + oldHost
if err := p.dns.DeleteRecordByName(ctx, "TXT", dkimName); err != nil {
p.logger.Warn("failed to delete old DKIM DNS record",
"name", dkimName, "project_id", projectID, "error", err)
}
spfSendName := "send." + oldHost
if err := p.dns.DeleteRecordByName(ctx, "MX", spfSendName); err != nil {
p.logger.Warn("failed to delete old SPF MX DNS record",
"name", spfSendName, "project_id", projectID, "error", err)
}
if err := p.dns.DeleteRecordByName(ctx, "TXT", spfSendName); err != nil {
p.logger.Warn("failed to delete old SPF TXT DNS record",
"name", spfSendName, "project_id", projectID, "error", err)
}
}
// 11. Delete old notify host (non-fatal).
if oldHost != "" {
if err := p.client.deleteHost(ctx, oldHost); err != nil {
p.logger.Warn("failed to delete old notify host",
"host", oldHost, "project_id", projectID, "error", err)
}
}
// 12. Fire-and-forget async domain verification.
if p.resend != nil && newResendDomainID != "" {
go func() {
verifyCtx := context.WithoutCancel(ctx)
p.verifyWithRetry(verifyCtx, newResendDomainID, newHost, projectID)
}()
}
p.logger.Info("notify host reprovisioned",
"project_id", projectID,
"old_host", oldHost,
"new_host", newHost,
"resend_domain_id", newResendDomainID,
)
return &domain.NotifyCredentials{
ProjectID: projectID,
Host: newHost,
From: newFrom,
ResendDomainID: newResendDomainID,
}, nil
}

View File

@ -27,6 +27,7 @@ type mockAdminClient struct {
createAccountErr error
createSendKeyErr error
grantHostAccessErr error
revokeHostAccessErr error
deleteAccountErr error
listAccountsErr error
@ -38,6 +39,7 @@ type mockAdminClient struct {
createAccountCalls int
createSendKeyCalls int
grantHostAccessCalls int
revokeHostAccessCalls int
deleteAccountCalls int
}
@ -90,6 +92,11 @@ func (m *mockAdminClient) grantHostAccess(_ context.Context, _, _ string) error
return m.grantHostAccessErr
}
func (m *mockAdminClient) revokeHostAccess(_ context.Context, _, _ string) error {
m.revokeHostAccessCalls++
return m.revokeHostAccessErr
}
func (m *mockAdminClient) deleteAccount(_ context.Context, _ string) error {
m.deleteAccountCalls++
return m.deleteAccountErr
@ -513,3 +520,110 @@ func TestCreateProjectNotify_HostUsesBaseDomain(t *testing.T) {
t.Errorf("expected host to use baseDomain, got %s", creds.Host)
}
}
func TestReprovisionNotifyHost_Success(t *testing.T) {
admin := &mockAdminClient{
accounts: []accountResponse{
{ID: "acct-001", Name: "project-proj-123", CreatedAt: time.Now()},
},
}
resend := &mockResendClient{
domainID: "new-domain-id-456",
dnsRecords: []resendDNSRecord{
{Record: "TXT", DNSType: "TXT", Name: "resend._domainkey.mail.myapp", Value: "v=DKIM1;"},
},
}
dns := &mockDNS{}
p := newTestProvisioner(admin, resend, dns)
creds, err := p.ReprovisionNotifyHost(context.Background(), "proj-123",
"mail.old-slug.test.example", "old-domain-id-123", "mail.myapp.test.example")
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if creds.Host != "mail.myapp.test.example" {
t.Errorf("expected host mail.myapp.test.example, got %s", creds.Host)
}
if creds.From != "noreply@mail.myapp.test.example" {
t.Errorf("expected from noreply@mail.myapp.test.example, got %s", creds.From)
}
if creds.ResendDomainID != "new-domain-id-456" {
t.Errorf("expected resend domain id new-domain-id-456, got %s", creds.ResendDomainID)
}
if creds.ProjectID != "proj-123" {
t.Errorf("expected project id proj-123, got %s", creds.ProjectID)
}
// Verify new host was created and old was deleted.
if admin.createHostCalls != 1 {
t.Errorf("expected 1 createHost call, got %d", admin.createHostCalls)
}
if admin.deleteHostCalls != 1 {
t.Errorf("expected 1 deleteHost call for old host, got %d", admin.deleteHostCalls)
}
if admin.revokeHostAccessCalls != 1 {
t.Errorf("expected 1 revokeHostAccess call, got %d", admin.revokeHostAccessCalls)
}
if admin.grantHostAccessCalls != 1 {
t.Errorf("expected 1 grantHostAccess call for new host, got %d", admin.grantHostAccessCalls)
}
if resend.createDomainCalls != 1 {
t.Errorf("expected 1 createDomain call, got %d", resend.createDomainCalls)
}
if resend.deleteDomainCalls != 1 {
t.Errorf("expected 1 deleteDomain call for old domain, got %d", resend.deleteDomainCalls)
}
// Old DNS records should have been deleted (DKIM TXT + SPF MX + SPF TXT = 3 calls).
if len(dns.deleteByNameCalls) != 3 {
t.Errorf("expected 3 deleteByName calls for old DNS records, got %d", len(dns.deleteByNameCalls))
}
}
func TestReprovisionNotifyHost_NewHostCreateFails(t *testing.T) {
admin := &mockAdminClient{
createHostErr: errors.New("host already exists"),
}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
_, err := p.ReprovisionNotifyHost(context.Background(), "proj-123",
"mail.old.test.example", "old-domain-id", "mail.new.test.example")
if err == nil {
t.Fatal("expected error when createHost fails")
}
}
func TestReprovisionNotifyHost_AccountNotFound(t *testing.T) {
// No accounts in the registry.
admin := &mockAdminClient{}
p := newTestProvisioner(admin, &mockResendClient{}, nil)
_, err := p.ReprovisionNotifyHost(context.Background(), "proj-123",
"mail.old.test.example", "old-domain-id", "mail.new.test.example")
if err == nil {
t.Fatal("expected error when account not found")
}
}
func TestReprovisionNotifyHost_OldDomainDeleteFails_StillSucceeds(t *testing.T) {
admin := &mockAdminClient{
accounts: []accountResponse{
{ID: "acct-001", Name: "project-proj-123", CreatedAt: time.Now()},
},
}
resend := &mockResendClient{
domainID: "new-domain-id",
deleteDomainErr: errors.New("delete failed"),
}
p := newTestProvisioner(admin, resend, nil)
// Old domain delete failure is non-fatal — reprovision should still succeed.
creds, err := p.ReprovisionNotifyHost(context.Background(), "proj-123",
"mail.old.test.example", "old-domain-id", "mail.new.test.example")
if err != nil {
t.Fatalf("expected success despite old domain delete failure, got %v", err)
}
if creds.Host != "mail.new.test.example" {
t.Errorf("expected new host, got %s", creds.Host)
}
}

View File

@ -39,6 +39,8 @@ func (h *NotifyHandler) Mount(r api.Router) {
Post("/verify", h.Verify)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/provision", h.Provision)
r.With(auth.RequireScope(auth.ScopeProjectsExecute, auth.ScopeAdmin)).
Post("/reprovision", h.Reprovision)
})
}
@ -192,6 +194,99 @@ func (h *NotifyHandler) Provision(w http.ResponseWriter, r *http.Request) {
})
}
// notifyReprovisionRequest is the request body for POST /projects/{projectID}/notify/reprovision.
type notifyReprovisionRequest struct {
Host string `json:"host"` // new sending host, e.g. "mail.myapp.threesix.ai"
}
// Reprovision migrates a project's notify setup to a new sending host.
// Use after adding a custom named domain to update email to send from that domain.
// POST /projects/{projectID}/notify/reprovision
func (h *NotifyHandler) Reprovision(w http.ResponseWriter, r *http.Request) {
if h.notifyProvisioner == nil {
api.WriteError(w, r, http.StatusServiceUnavailable, "SERVICE_UNAVAILABLE", "notify not configured")
return
}
projectID := chi.URLParam(r, "projectID")
if projectID == "" {
api.WriteBadRequest(w, r, "project ID is required")
return
}
var req notifyReprovisionRequest
if err := api.DecodeJSON(r, &req); err != nil {
api.WriteBadRequest(w, r, "invalid request body")
return
}
if req.Host == "" {
api.WriteBadRequest(w, r, "host is required")
return
}
ctx, cancel := context.WithTimeout(r.Context(), TimeoutStandard)
defer cancel()
log := logging.FromContext(ctx).WithHandler("NotifyReprovision")
currentHost, currentResendDomainID := h.lookupNotifyCredentials(ctx, projectID)
if currentHost == "" {
api.WriteBadRequest(w, r, "notify not provisioned for this project — run POST /notify/provision first")
return
}
if currentHost == req.Host {
api.WriteBadRequest(w, r, "new host is the same as the current host")
return
}
creds, err := h.notifyProvisioner.ReprovisionNotifyHost(ctx, projectID, currentHost, currentResendDomainID, req.Host)
if err != nil {
log.Error("failed to reprovision notify host",
logging.FieldError, err,
logging.FieldProjectID, projectID,
"old_host", currentHost,
"new_host", req.Host,
)
api.WriteInternalError(w, r, "failed to reprovision notify host")
return
}
// Store updated credentials.
if h.credStore != nil {
for _, kv := range []struct{ key, val string }{
{domain.CredKeyNotifyHost, creds.Host},
{domain.CredKeyNotifyFrom, creds.From},
{domain.CredKeyNotifyResendDomainID, creds.ResendDomainID},
} {
if err := h.credStore.Set(ctx, domain.Credential{
Key: projectID + ":" + kv.key,
Value: kv.val,
Category: domain.CredentialCategoryNotify,
}); err != nil {
log.Error("reprovisioned notify host but failed to store credential",
logging.FieldError, err,
logging.FieldProjectID, projectID,
"credential_key", kv.key,
)
}
}
}
log.Info("notify host reprovisioned",
logging.FieldProjectID, projectID,
"old_host", currentHost,
"new_host", creds.Host,
"resend_domain_id", creds.ResendDomainID,
)
api.WriteSuccess(w, r, map[string]string{
"host": creds.Host,
"from": creds.From,
"resend_domain_id": creds.ResendDomainID,
"status": "verifying",
})
}
// lookupNotifyCredentials fetches NOTIFY_HOST and NOTIFY_RESEND_DOMAIN_ID from the credential store.
// Returns empty strings if credStore is nil or the credentials are not found.
func (h *NotifyHandler) lookupNotifyCredentials(ctx context.Context, projectID string) (host, resendDomainID string) {

View File

@ -36,4 +36,10 @@ type NotifyProvisioner interface {
// Use this to repair projects where steps 7-9 of CreateProjectNotify failed.
// Returns the Resend domain ID for storage in the credential store.
ProvisionNotifyDomain(ctx context.Context, projectID, host string) (resendDomainID string, err error)
// 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
// 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.
ReprovisionNotifyHost(ctx context.Context, projectID, oldHost, oldResendDomainID, newHost string) (*domain.NotifyCredentials, error)
}

559
scripts/rdev-cli.sh Executable file
View File

@ -0,0 +1,559 @@
#!/usr/bin/env bash
# rdev-cli — credential management CLI for the rdev platform
#
# Usage: rdev-cli <command> [subcommand] [flags]
#
# Commands:
# me Show current key identity & access
# keys list List all API keys (table format)
# keys get <id> Get a specific key (JSON)
# keys create --name <n> --scopes <s> Create a new API key
# keys update <id> [flags] Update a key
# keys revoke <id> Revoke a key (prompts confirmation)
# access list <project-id> List keys with access to a project
# access grant <project-id> <key-id> Grant a key access to a project
# access revoke <project-id> <key-id> Revoke a key's access to a project
#
# Required env vars:
# RDEV_API_URL — e.g. https://rdev.masq-ops.orchard9.ai
# RDEV_API_KEY — base64 or rdev_sk_ format
set -euo pipefail
# ─── colours ────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
# ─── env check ──────────────────────────────────────────────────────────────
preflight_check() {
local missing=0
if [[ -z "${RDEV_API_URL:-}" ]]; then
echo -e "${RED}Error: RDEV_API_URL is not set${NC}" >&2
missing=1
fi
if [[ -z "${RDEV_API_KEY:-}" ]]; then
echo -e "${RED}Error: RDEV_API_KEY is not set${NC}" >&2
missing=1
fi
if [[ $missing -ne 0 ]]; then
echo "" >&2
echo "Set the required environment variables:" >&2
echo " export RDEV_API_URL=\"https://rdev.masq-ops.orchard9.ai\"" >&2
echo " export RDEV_API_KEY=\"<your-api-key>\"" >&2
echo "" >&2
echo "Or source your secrets file:" >&2
echo " source ~/.zshrc" >&2
exit 1
fi
}
# ─── api helper ─────────────────────────────────────────────────────────────
# api_call METHOD /path [body]
# Prints JSON response body; exits non-zero on HTTP error.
api_call() {
local method="$1"
local path="$2"
local body="${3:-}"
local tmpfile status_code response_body
tmpfile=$(mktemp)
if [[ -n "$body" ]]; then
status_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \
--max-time 30 \
-X "$method" \
-H "X-API-Key: $RDEV_API_KEY" \
-H "Content-Type: application/json" \
-d "$body" \
"${RDEV_API_URL}${path}")
else
status_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \
--max-time 30 \
-X "$method" \
-H "X-API-Key: $RDEV_API_KEY" \
"${RDEV_API_URL}${path}")
fi
response_body=$(cat "$tmpfile")
rm -f "$tmpfile"
if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then
echo -e "${RED}Error: HTTP $status_code${NC}" >&2
if [[ -n "$response_body" ]]; then
echo "$response_body" | jq -r '.message // .error // .' 2>/dev/null >&2 || echo "$response_body" >&2
fi
exit 1
fi
echo "$response_body"
}
# ─── me ─────────────────────────────────────────────────────────────────────
cmd_me() {
local resp
resp=$(api_call GET "/me")
local id name prefix scopes project_access expires_at active
id=$(echo "$resp" | jq -r '.data.id // .id')
name=$(echo "$resp" | jq -r '.data.name // .name')
prefix=$(echo "$resp" | jq -r '.data.key_prefix // .key_prefix')
scopes=$(echo "$resp" | jq -r '(.data.scopes // .scopes) | join(", ")')
project_access=$(echo "$resp" | jq -r '.data.project_access // .project_access')
expires_at=$(echo "$resp" | jq -r '.data.expires_at // .expires_at // "never"')
active=$(echo "$resp" | jq -r '.data.active // .active')
echo ""
echo -e "${BOLD}Current key identity${NC}"
echo "────────────────────────────────────"
printf " %-16s %s\n" "ID:" "$id"
printf " %-16s %s\n" "Name:" "$name"
printf " %-16s %s\n" "Prefix:" "$prefix"
printf " %-16s %s\n" "Scopes:" "$scopes"
printf " %-16s %s\n" "Access:" "$project_access"
printf " %-16s %s\n" "Expires:" "$expires_at"
printf " %-16s %s\n" "Active:" "$active"
# Show project list if restricted
local projects
projects=$(echo "$resp" | jq -r '(.data.projects // .projects) // []')
local project_count
project_count=$(echo "$projects" | jq 'length')
if [[ "$project_count" -gt 0 ]]; then
echo ""
echo " Projects:"
echo "$projects" | jq -r '.[] | " - \(.id) \(.name) [\(.status)]"'
fi
# Show allowed IPs if set
local allowed_ips
allowed_ips=$(echo "$resp" | jq -r '(.data.allowed_ips // .allowed_ips) // []')
local ip_count
ip_count=$(echo "$allowed_ips" | jq 'length')
if [[ "$ip_count" -gt 0 ]]; then
echo ""
echo " Allowed IPs:"
echo "$allowed_ips" | jq -r '.[] | " - \(.)"'
fi
echo ""
}
# ─── keys ───────────────────────────────────────────────────────────────────
cmd_keys_list() {
local resp
resp=$(api_call GET "/keys")
local keys
keys=$(echo "$resp" | jq -r '.data // .')
local count
count=$(echo "$keys" | jq 'length')
if [[ "$count" -eq 0 ]]; then
echo "No API keys found."
return
fi
echo ""
printf "${BOLD}%-8s %-30s %-36s %-30s %-12s %-6s${NC}\n" \
"PREFIX" "NAME" "ID" "SCOPES" "EXPIRES" "ACTIVE"
printf '%s\n' "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────"
echo "$keys" | jq -r '.[] |
[
.key_prefix,
.name,
.id,
(.scopes | join(",")),
(.expires_at // "never"),
(if .active then "yes" else "no" end)
] | @tsv' | while IFS=$'\t' read -r prefix name id scopes expires active; do
# Truncate long fields for readability
local name_trunc="${name:0:29}"
local scopes_trunc="${scopes:0:29}"
local active_color="$GREEN"
[[ "$active" == "no" ]] && active_color="$RED"
printf "%-8s %-30s %-36s %-30s %-12s ${active_color}%-6s${NC}\n" \
"$prefix" "$name_trunc" "$id" "$scopes_trunc" "${expires:0:10}" "$active"
done
echo ""
}
cmd_keys_get() {
local id="${1:-}"
if [[ -z "$id" ]]; then
echo -e "${RED}Error: key id required${NC}" >&2
echo "Usage: rdev-cli keys get <id>" >&2
exit 1
fi
api_call GET "/keys/$id" | jq .
}
cmd_keys_create() {
local name="" scopes="" expires_in="90d" project_ids_raw="" allowed_ips_raw=""
# Parse flags
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--scopes) scopes="$2"; shift 2 ;;
--expires) expires_in="$2"; shift 2 ;;
--project-ids) project_ids_raw="$2"; shift 2 ;;
--allowed-ips) allowed_ips_raw="$2"; shift 2 ;;
*) echo -e "${RED}Error: unknown flag: $1${NC}" >&2; exit 1 ;;
esac
done
if [[ -z "$name" ]]; then
echo -e "${RED}Error: --name is required${NC}" >&2
echo "Usage: rdev-cli keys create --name <name> --scopes <scope1,scope2,...>" >&2
exit 1
fi
if [[ -z "$scopes" ]]; then
echo -e "${RED}Error: --scopes is required${NC}" >&2
echo "Usage: rdev-cli keys create --name <name> --scopes <scope1,scope2,...>" >&2
exit 1
fi
# Convert comma-separated scopes → JSON array
local scopes_json
scopes_json=$(echo "$scopes" | tr ',' '\n' | jq -R . | jq -s .)
# Convert comma-separated project IDs → JSON array or null
local project_ids_json="null"
if [[ -n "$project_ids_raw" && "$project_ids_raw" != "null" && "$project_ids_raw" != '""' ]]; then
project_ids_json=$(echo "$project_ids_raw" | tr ',' '\n' | jq -R . | jq -s .)
fi
# Convert comma-separated allowed IPs → JSON array
local allowed_ips_json="[]"
if [[ -n "$allowed_ips_raw" ]]; then
allowed_ips_json=$(echo "$allowed_ips_raw" | tr ',' '\n' | jq -R . | jq -s .)
fi
# Build request body
local body
body=$(jq -n \
--arg name "$name" \
--argjson scopes "$scopes_json" \
--argjson pids "$project_ids_json" \
--arg expires "$expires_in" \
--argjson ips "$allowed_ips_json" \
'{
name: $name,
scopes: $scopes,
project_ids: $pids,
expires_in: $expires,
allowed_ips: $ips
}')
local resp
resp=$(api_call POST "/keys" "$body")
# Extract fields (handles both wrapped .data and flat response)
local key_id key_name key_scopes key_secret
key_id=$(echo "$resp" | jq -r '(.data.key.id // .key.id // .id)')
key_name=$(echo "$resp" | jq -r '(.data.key.name // .key.name // .name)')
key_scopes=$(echo "$resp" | jq -r '((.data.key.scopes // .key.scopes // .scopes) | join(", "))')
key_secret=$(echo "$resp" | jq -r '(.data.secret // .secret)')
# Secret box
echo ""
echo -e "${YELLOW}╔══════════════════════════════════════════════════════════════╗"
echo "║ NEW API KEY — SAVE THIS SECRET NOW — SHOWN ONCE ║"
echo "║ ║"
printf "║ %-10s %-49s║\n" "ID:" "$key_id"
printf "║ %-10s %-49s║\n" "Name:" "$key_name"
printf "║ %-10s %-49s║\n" "Scopes:" "${key_scopes:0:48}"
printf "║ %-10s %-49s║\n" "Secret:" "$key_secret"
echo "║ ║"
echo "║ Add to ~/.zshrc or secrets manager before continuing. ║"
echo -e "╚══════════════════════════════════════════════════════════════╝${NC}"
echo ""
read -r -p "Press [enter] when you have saved the secret..."
echo ""
echo -e "${GREEN}✓ Key created — ${key_name} (${key_id:0:8}...)${NC}"
echo ""
}
cmd_keys_update() {
local id="${1:-}"
if [[ -z "$id" ]]; then
echo -e "${RED}Error: key id required${NC}" >&2
echo "Usage: rdev-cli keys update <id> [--name <n>] [--scopes <s>] [--expires <e>] [--project-ids <ids>] [--allowed-ips <ips>]" >&2
exit 1
fi
shift
local name="" scopes="" expires_in="" project_ids_raw="" allowed_ips_raw=""
local has_name=0 has_scopes=0 has_expires=0 has_pids=0 has_ips=0
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; has_name=1; shift 2 ;;
--scopes) scopes="$2"; has_scopes=1; shift 2 ;;
--expires) expires_in="$2"; has_expires=1; shift 2 ;;
--project-ids) project_ids_raw="$2"; has_pids=1; shift 2 ;;
--allowed-ips) allowed_ips_raw="$2"; has_ips=1; shift 2 ;;
*) echo -e "${RED}Error: unknown flag: $1${NC}" >&2; exit 1 ;;
esac
done
# Build partial update body using jq null-safe approach
local body="{}"
if [[ $has_name -eq 1 ]]; then
body=$(echo "$body" | jq --arg v "$name" '. + {name: $v}')
fi
if [[ $has_scopes -eq 1 ]]; then
local scopes_json
scopes_json=$(echo "$scopes" | tr ',' '\n' | jq -R . | jq -s .)
body=$(echo "$body" | jq --argjson v "$scopes_json" '. + {scopes: $v}')
fi
if [[ $has_expires -eq 1 ]]; then
body=$(echo "$body" | jq --arg v "$expires_in" '. + {expires_in: $v}')
fi
if [[ $has_pids -eq 1 ]]; then
if [[ "$project_ids_raw" == "null" || -z "$project_ids_raw" ]]; then
body=$(echo "$body" | jq '. + {project_ids: null}')
else
local pids_json
pids_json=$(echo "$project_ids_raw" | tr ',' '\n' | jq -R . | jq -s .)
body=$(echo "$body" | jq --argjson v "$pids_json" '. + {project_ids: $v}')
fi
fi
if [[ $has_ips -eq 1 ]]; then
if [[ "$allowed_ips_raw" == "null" || -z "$allowed_ips_raw" ]]; then
body=$(echo "$body" | jq '. + {allowed_ips: null}')
else
local ips_json
ips_json=$(echo "$allowed_ips_raw" | tr ',' '\n' | jq -R . | jq -s .)
body=$(echo "$body" | jq --argjson v "$ips_json" '. + {allowed_ips: $v}')
fi
fi
local resp
resp=$(api_call PATCH "/keys/$id" "$body")
local key_name key_prefix
key_name=$(echo "$resp" | jq -r '.data.name // .name')
key_prefix=$(echo "$resp" | jq -r '.data.key_prefix // .key_prefix')
echo -e "${GREEN}✓ Updated — ${key_name} (${key_prefix}...)${NC}"
}
cmd_keys_revoke() {
local id="${1:-}"
if [[ -z "$id" ]]; then
echo -e "${RED}Error: key id required${NC}" >&2
echo "Usage: rdev-cli keys revoke <id>" >&2
exit 1
fi
# Fetch key details for confirmation
local key_resp
key_resp=$(api_call GET "/keys/$id")
local key_name key_prefix active
key_name=$(echo "$key_resp" | jq -r '.data.name // .name')
key_prefix=$(echo "$key_resp" | jq -r '.data.key_prefix // .key_prefix')
active=$(echo "$key_resp" | jq -r '.data.active // .active')
echo ""
echo -e "${YELLOW}About to revoke:${NC}"
echo " Name: $key_name"
echo " ID: $id"
echo " Prefix: $key_prefix"
echo " Active: $active"
echo ""
local confirm
read -r -p "Revoke? [y/N] " confirm
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
echo "Aborted."
exit 0
fi
api_call DELETE "/keys/$id" > /dev/null
echo -e "${GREEN}✓ Revoked — ${key_name} (${key_prefix}...)${NC}"
echo ""
}
# ─── access ─────────────────────────────────────────────────────────────────
cmd_access_list() {
local project_id="${1:-}"
if [[ -z "$project_id" ]]; then
echo -e "${RED}Error: project-id required${NC}" >&2
echo "Usage: rdev-cli access list <project-id>" >&2
exit 1
fi
local resp
resp=$(api_call GET "/projects/$project_id/access")
local unrestricted_count
unrestricted_count=$(echo "$resp" | jq -r '.data.unrestricted_keys // .unrestricted_keys // 0')
echo ""
echo -e "${BOLD}Access for project: $project_id${NC}"
echo " Unrestricted keys (access all projects): $unrestricted_count"
echo ""
local keys
keys=$(echo "$resp" | jq -r '.data.keys // .keys // []')
local key_count
key_count=$(echo "$keys" | jq 'length')
if [[ "$key_count" -eq 0 ]]; then
echo " No keys explicitly granted access to this project."
else
echo " Explicitly granted keys:"
printf " ${BOLD}%-8s %-30s %-36s %-6s${NC}\n" "PREFIX" "NAME" "ID" "ACTIVE"
printf ' %s\n' "────────────────────────────────────────────────────────────────────────"
echo "$keys" | jq -r '.[] | [.key_prefix, .name, .id, (if .active then "yes" else "no" end)] | @tsv' \
| while IFS=$'\t' read -r prefix name id active; do
local active_color="$GREEN"
[[ "$active" == "no" ]] && active_color="$RED"
printf " %-8s %-30s %-36s ${active_color}%-6s${NC}\n" \
"$prefix" "${name:0:29}" "$id" "$active"
done
fi
echo ""
}
cmd_access_grant() {
local project_id="${1:-}" key_id="${2:-}"
if [[ -z "$project_id" || -z "$key_id" ]]; then
echo -e "${RED}Error: project-id and key-id required${NC}" >&2
echo "Usage: rdev-cli access grant <project-id> <key-id>" >&2
exit 1
fi
local body
body=$(jq -n --arg kid "$key_id" '{key_id: $kid}')
local resp
resp=$(api_call POST "/projects/$project_id/access" "$body")
local status
status=$(echo "$resp" | jq -r '.data.status // .status')
if [[ "$status" == "already_granted" ]]; then
echo -e "${YELLOW}✓ Already granted — key ${key_id:0:8}... already has access to $project_id${NC}"
else
echo -e "${GREEN}✓ Granted — key ${key_id:0:8}... now has access to $project_id${NC}"
fi
}
cmd_access_revoke() {
local project_id="${1:-}" key_id="${2:-}"
if [[ -z "$project_id" || -z "$key_id" ]]; then
echo -e "${RED}Error: project-id and key-id required${NC}" >&2
echo "Usage: rdev-cli access revoke <project-id> <key-id>" >&2
exit 1
fi
api_call DELETE "/projects/$project_id/access/$key_id" > /dev/null
echo -e "${GREEN}✓ Revoked — key ${key_id:0:8}... no longer has access to $project_id${NC}"
}
# ─── help ───────────────────────────────────────────────────────────────────
cmd_help() {
cat <<'EOF'
rdev-cli — rdev credential management CLI
Usage:
rdev-cli me Show current key identity & access
rdev-cli keys list List all API keys (table format)
rdev-cli keys get <id> Get a specific key (JSON)
rdev-cli keys create --name <n> --scopes <s> Create a new API key
rdev-cli keys update <id> [flags] Update a key
rdev-cli keys revoke <id> Revoke a key (prompts confirmation)
rdev-cli access list <project-id> List keys with access to a project
rdev-cli access grant <project-id> <key-id> Grant a key access to a project
rdev-cli access revoke <project-id> <key-id> Revoke a key's access to a project
Create / Update flags:
--name <name> Key name (required on create)
--scopes <s1,s2,...> Comma-separated scopes (required on create)
--expires <30d|60d|90d|1y|never> Expiration (default: 90d on create)
--project-ids <id1,id2,...> Restrict to projects (null = unrestricted)
--allowed-ips <cidr1,...> Restrict to IP ranges (empty = unrestricted)
Scopes:
projects:read projects:execute keys:read keys:write
audit:read queue:read queue:write webhooks:read
webhooks:write workers:read workers:write builds:read
builds:write verify:read verify:write sessions:read
sessions:execute admin
Required env vars:
RDEV_API_URL e.g. https://rdev.masq-ops.orchard9.ai
RDEV_API_KEY your API key (base64 or rdev_sk_ format)
EOF
}
# ─── router ─────────────────────────────────────────────────────────────────
main() {
local cmd="${1:-}"
case "$cmd" in
me)
preflight_check
cmd_me
;;
keys)
preflight_check
local sub="${2:-}"
shift 2 2>/dev/null || shift 1 2>/dev/null || true
case "$sub" in
list) cmd_keys_list ;;
get) cmd_keys_get "$@" ;;
create) cmd_keys_create "$@" ;;
update) cmd_keys_update "$@" ;;
revoke) cmd_keys_revoke "$@" ;;
*)
echo -e "${RED}Error: unknown keys subcommand: ${sub:-<missing>}${NC}" >&2
echo "Run 'rdev-cli --help' for usage." >&2
exit 1
;;
esac
;;
access)
preflight_check
local sub="${2:-}"
shift 2 2>/dev/null || shift 1 2>/dev/null || true
case "$sub" in
list) cmd_access_list "$@" ;;
grant) cmd_access_grant "$@" ;;
revoke) cmd_access_revoke "$@" ;;
*)
echo -e "${RED}Error: unknown access subcommand: ${sub:-<missing>}${NC}" >&2
echo "Run 'rdev-cli --help' for usage." >&2
exit 1
;;
esac
;;
help|--help|-h|"")
cmd_help
;;
*)
echo -e "${RED}Error: unknown command: $cmd${NC}" >&2
echo "Run 'rdev-cli --help' for usage." >&2
exit 1
;;
esac
}
main "$@"