rdev/internal/adapter/cloudflare/client.go
jordan 56e3f83955 feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements
- Add auth.RequireScope() to all handler routes for proper authorization
- Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator)
- Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog)
- Add artifact_test.go for SDLC artifact coverage
- Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w
- Fix error wrapping to use %w instead of %v throughout codebase
- Improve CLI merge command with conflict detection and resolution
- Fix handler tests to include auth middleware for RequireScope
- Add cookbook tree runner scripts for automated testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 13:55:50 -07:00

370 lines
10 KiB
Go

// Package cloudflare provides a Cloudflare DNS adapter implementing port.DNSProvider.
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// Ensure Client implements DNSProvider.
var _ port.DNSProvider = (*Client)(nil)
const apiBase = "https://api.cloudflare.com/client/v4"
// Client is a Cloudflare DNS API client adapter.
type Client struct {
apiToken string
zoneID string
zoneName string // e.g., "threesix.ai"
http *http.Client
}
// NewClient creates a new Cloudflare DNS client.
// apiToken is a Cloudflare API token with DNS edit permissions
// zoneID is the Cloudflare zone ID for the domain
// zoneName is the domain name (e.g., "threesix.ai")
func NewClient(apiToken, zoneID, zoneName string) *Client {
return &Client{
apiToken: apiToken,
zoneID: zoneID,
zoneName: zoneName,
http: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// CreateRecord creates a DNS record.
func (c *Client) CreateRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) {
// Normalize name: if just subdomain, append zone name
name := c.normalizeName(record.Name)
body := map[string]interface{}{
"type": record.Type,
"name": name,
"content": record.Content,
"ttl": record.TTL,
"proxied": record.Proxied,
}
resp, err := c.doRequest(ctx, "POST", fmt.Sprintf("/zones/%s/dns_records", c.zoneID), body)
if err != nil {
return nil, fmt.Errorf("failed to create DNS record: %w", err)
}
var result cfResponse
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !result.Success {
return nil, &CloudflareError{Errors: result.Errors}
}
return recordFromCF(result.Result), nil
}
// UpdateRecord updates an existing DNS record by ID.
func (c *Client) UpdateRecord(ctx context.Context, recordID string, record domain.DNSRecord) (*domain.DNSRecord, error) {
name := c.normalizeName(record.Name)
body := map[string]interface{}{
"type": record.Type,
"name": name,
"content": record.Content,
"ttl": record.TTL,
"proxied": record.Proxied,
}
resp, err := c.doRequest(ctx, "PUT", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), body)
if err != nil {
return nil, fmt.Errorf("failed to update DNS record: %w", err)
}
var result cfResponse
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !result.Success {
return nil, &CloudflareError{Errors: result.Errors}
}
return recordFromCF(result.Result), nil
}
// DeleteRecord removes a DNS record by ID.
func (c *Client) DeleteRecord(ctx context.Context, recordID string) error {
_, err := c.doRequest(ctx, "DELETE", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), nil)
if err != nil {
return fmt.Errorf("failed to delete DNS record: %w", err)
}
return nil
}
// DeleteRecordByName removes a DNS record by type and name.
func (c *Client) DeleteRecordByName(ctx context.Context, recordType, name string) error {
record, err := c.FindRecord(ctx, recordType, name)
if err != nil {
return err
}
if record == nil {
return nil // Already doesn't exist
}
return c.DeleteRecord(ctx, record.ID)
}
// GetRecord returns a single record by ID.
func (c *Client) GetRecord(ctx context.Context, recordID string) (*domain.DNSRecord, error) {
resp, err := c.doRequest(ctx, "GET", fmt.Sprintf("/zones/%s/dns_records/%s", c.zoneID, recordID), nil)
if err != nil {
return nil, fmt.Errorf("failed to get DNS record: %w", err)
}
var result cfResponse
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !result.Success {
return nil, &CloudflareError{Errors: result.Errors}
}
return recordFromCF(result.Result), nil
}
// ListRecords returns all records in the zone.
func (c *Client) ListRecords(ctx context.Context, recordType string) ([]*domain.DNSRecord, error) {
path := fmt.Sprintf("/zones/%s/dns_records?per_page=100", c.zoneID)
if recordType != "" {
path += "&type=" + recordType
}
resp, err := c.doRequest(ctx, "GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to list DNS records: %w", err)
}
var result cfListResponse
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !result.Success {
return nil, &CloudflareError{Errors: result.Errors}
}
records := make([]*domain.DNSRecord, len(result.Result))
for i, r := range result.Result {
records[i] = recordFromCFMap(r)
}
return records, nil
}
// FindRecord finds a record by type and name.
func (c *Client) FindRecord(ctx context.Context, recordType, name string) (*domain.DNSRecord, error) {
normalizedName := c.normalizeName(name)
path := fmt.Sprintf("/zones/%s/dns_records?type=%s&name=%s", c.zoneID, recordType, normalizedName)
resp, err := c.doRequest(ctx, "GET", path, nil)
if err != nil {
return nil, fmt.Errorf("failed to find DNS record: %w", err)
}
var result cfListResponse
if err := json.Unmarshal(resp, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
if !result.Success {
return nil, &CloudflareError{Errors: result.Errors}
}
if len(result.Result) == 0 {
return nil, nil
}
return recordFromCFMap(result.Result[0]), nil
}
// UpsertRecord creates or updates a DNS record.
// If a record with the same type and name exists, it updates it.
// Otherwise, it creates a new record.
// Returns the created or updated record.
// Handles race conditions with retry logic.
func (c *Client) UpsertRecord(ctx context.Context, record domain.DNSRecord) (*domain.DNSRecord, error) {
const maxRetries = 3
for attempt := 0; attempt < maxRetries; attempt++ {
// Try to find existing record
existing, err := c.FindRecord(ctx, record.Type, record.Name)
if err != nil {
return nil, fmt.Errorf("failed to check for existing record: %w", err)
}
if existing != nil {
// Update existing record
updated, err := c.UpdateRecord(ctx, existing.ID, record)
if err != nil {
return nil, fmt.Errorf("failed to update existing record: %w", err)
}
return updated, nil
}
// Create new record
created, err := c.CreateRecord(ctx, record)
if err == nil {
return created, nil
}
// Handle race condition: record was created between Find and Create
if !isRecordExistsError(err) {
return nil, fmt.Errorf("failed to create record: %w", err)
}
// Race condition detected - retry the whole find-or-create loop
// This handles the case where another process created the record
}
return nil, fmt.Errorf("failed to upsert record after %d attempts due to concurrent modifications", maxRetries)
}
// isRecordExistsError checks if the error indicates a duplicate record.
func isRecordExistsError(err error) bool {
if err == nil {
return false
}
// Cloudflare returns "A record with that host already exists" or similar
errStr := err.Error()
return strings.Contains(errStr, "already exists") || strings.Contains(errStr, "duplicate")
}
// normalizeName converts a subdomain to full domain name.
func (c *Client) normalizeName(name string) string {
if name == "@" || name == "" {
return c.zoneName
}
// If already has zone suffix, return as-is
if len(name) > len(c.zoneName) && name[len(name)-len(c.zoneName):] == c.zoneName {
return name
}
return name + "." + c.zoneName
}
// doRequest performs an HTTP request to the Cloudflare API.
func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}) ([]byte, error) {
var bodyReader io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
bodyReader = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, method, apiBase+path, bodyReader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
return respBody, nil
}
// Cloudflare API response types
type cfResponse struct {
Success bool `json:"success"`
Errors []cfError `json:"errors"`
Result map[string]interface{} `json:"result"`
}
type cfListResponse struct {
Success bool `json:"success"`
Errors []cfError `json:"errors"`
Result []map[string]interface{} `json:"result"`
}
type cfError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// CloudflareError wraps Cloudflare API errors with structured data.
type CloudflareError struct {
Errors []cfError
}
func (e *CloudflareError) Error() string {
msgs := make([]string, len(e.Errors))
for i, ce := range e.Errors {
msgs[i] = fmt.Sprintf("[%d] %s", ce.Code, ce.Message)
}
return "cloudflare API: " + strings.Join(msgs, "; ")
}
// HasCode returns true if any of the errors has the given code.
func (e *CloudflareError) HasCode(code int) bool {
for _, ce := range e.Errors {
if ce.Code == code {
return true
}
}
return false
}
// recordFromCF converts a Cloudflare record response to domain.DNSRecord.
func recordFromCF(r map[string]interface{}) *domain.DNSRecord {
return recordFromCFMap(r)
}
func recordFromCFMap(r map[string]interface{}) *domain.DNSRecord {
record := &domain.DNSRecord{}
if id, ok := r["id"].(string); ok {
record.ID = id
}
if t, ok := r["type"].(string); ok {
record.Type = t
}
if name, ok := r["name"].(string); ok {
record.Name = name
}
if content, ok := r["content"].(string); ok {
record.Content = content
}
if ttl, ok := r["ttl"].(float64); ok {
record.TTL = int(ttl)
}
if proxied, ok := r["proxied"].(bool); ok {
record.Proxied = proxied
}
return record
}