From 96219a647fa8bfcb3211d818cb502b2ca2815b9d Mon Sep 17 00:00:00 2001 From: jordan Date: Mon, 23 Feb 2026 21:28:59 -0700 Subject: [PATCH] feat: add POST /projects/{id}/notify/reprovision to migrate notify host 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 --- cmd/rdev-api/openapi.go | 8 +- cmd/rdev-api/openapi_ext.go | 35 +- deployments/k8s/base/kustomization.yaml | 6 +- deployments/k8s/base/rdev-logs-agent.yaml | 131 ++++ internal/adapter/notify/admin_client.go | 10 + .../adapter/notify/provisioner_reprovision.go | 160 +++++ internal/adapter/notify/provisioner_test.go | 114 ++++ internal/handlers/notify.go | 95 +++ internal/port/notify_provisioner.go | 6 + scripts/rdev-cli.sh | 559 ++++++++++++++++++ 10 files changed, 1110 insertions(+), 14 deletions(-) create mode 100644 deployments/k8s/base/rdev-logs-agent.yaml create mode 100644 internal/adapter/notify/provisioner_reprovision.go create mode 100755 scripts/rdev-cli.sh diff --git a/cmd/rdev-api/openapi.go b/cmd/rdev-api/openapi.go index 60e540d..8037ac1 100644 --- a/cmd/rdev-api/openapi.go +++ b/cmd/rdev-api/openapi.go @@ -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 -}`, +}`), }, }, }, diff --git a/cmd/rdev-api/openapi_ext.go b/cmd/rdev-api/openapi_ext.go index 33cff9a..e77d6e9 100644 --- a/cmd/rdev-api/openapi_ext.go +++ b/cmd/rdev-api/openapi_ext.go @@ -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= 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= 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" -}`, +}`), }, }, }, diff --git a/deployments/k8s/base/kustomization.yaml b/deployments/k8s/base/kustomization.yaml index 00fd58e..f439909 100644 --- a/deployments/k8s/base/kustomization.yaml +++ b/deployments/k8s/base/kustomization.yaml @@ -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 diff --git a/deployments/k8s/base/rdev-logs-agent.yaml b/deployments/k8s/base/rdev-logs-agent.yaml new file mode 100644 index 0000000..f3cb4e1 --- /dev/null +++ b/deployments/k8s/base/rdev-logs-agent.yaml @@ -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/__-.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: __-.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: {} diff --git a/internal/adapter/notify/admin_client.go b/internal/adapter/notify/admin_client.go index 6c26308..25924c9 100644 --- a/internal/adapter/notify/admin_client.go +++ b/internal/adapter/notify/admin_client.go @@ -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) diff --git a/internal/adapter/notify/provisioner_reprovision.go b/internal/adapter/notify/provisioner_reprovision.go new file mode 100644 index 0000000..06731e3 --- /dev/null +++ b/internal/adapter/notify/provisioner_reprovision.go @@ -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 +} diff --git a/internal/adapter/notify/provisioner_test.go b/internal/adapter/notify/provisioner_test.go index c22ad7e..c2636c9 100644 --- a/internal/adapter/notify/provisioner_test.go +++ b/internal/adapter/notify/provisioner_test.go @@ -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) + } +} diff --git a/internal/handlers/notify.go b/internal/handlers/notify.go index 416cf55..628af7c 100644 --- a/internal/handlers/notify.go +++ b/internal/handlers/notify.go @@ -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) { diff --git a/internal/port/notify_provisioner.go b/internal/port/notify_provisioner.go index 73c8e07..a4b12cd 100644 --- a/internal/port/notify_provisioner.go +++ b/internal/port/notify_provisioner.go @@ -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) } diff --git a/scripts/rdev-cli.sh b/scripts/rdev-cli.sh new file mode 100755 index 0000000..d9ba83e --- /dev/null +++ b/scripts/rdev-cli.sh @@ -0,0 +1,559 @@ +#!/usr/bin/env bash +# rdev-cli — credential management CLI for the rdev platform +# +# Usage: rdev-cli [subcommand] [flags] +# +# Commands: +# me Show current key identity & access +# keys list List all API keys (table format) +# keys get Get a specific key (JSON) +# keys create --name --scopes Create a new API key +# keys update [flags] Update a key +# keys revoke Revoke a key (prompts confirmation) +# access list List keys with access to a project +# access grant Grant a key access to a project +# access revoke 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=\"\"" >&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 " >&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 --scopes " >&2 + exit 1 + fi + if [[ -z "$scopes" ]]; then + echo -e "${RED}Error: --scopes is required${NC}" >&2 + echo "Usage: rdev-cli keys create --name --scopes " >&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 [--name ] [--scopes ] [--expires ] [--project-ids ] [--allowed-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 " >&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 " >&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 " >&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 " >&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 Get a specific key (JSON) + rdev-cli keys create --name --scopes Create a new API key + rdev-cli keys update [flags] Update a key + rdev-cli keys revoke Revoke a key (prompts confirmation) + rdev-cli access list List keys with access to a project + rdev-cli access grant Grant a key access to a project + rdev-cli access revoke Revoke a key's access to a project + +Create / Update flags: + --name Key name (required on create) + --scopes Comma-separated scopes (required on create) + --expires <30d|60d|90d|1y|never> Expiration (default: 90d on create) + --project-ids Restrict to projects (null = unrestricted) + --allowed-ips 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:-}${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:-}${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 "$@"