feat: add POST /projects/{id}/notify/reprovision to migrate notify host
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
ee1c214b7e
commit
96219a647f
@ -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
|
||||
}`,
|
||||
}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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"
|
||||
}`,
|
||||
}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
131
deployments/k8s/base/rdev-logs-agent.yaml
Normal file
131
deployments/k8s/base/rdev-logs-agent.yaml
Normal 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: {}
|
||||
@ -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)
|
||||
|
||||
160
internal/adapter/notify/provisioner_reprovision.go
Normal file
160
internal/adapter/notify/provisioner_reprovision.go
Normal 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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
559
scripts/rdev-cli.sh
Executable 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 "$@"
|
||||
Loading…
Reference in New Issue
Block a user