rdev/internal/adapter/cloudflare/client.go
jordan 0fd4e32073 feat: Add infrastructure adapters for threesix.ai
Add Gitea, Cloudflare DNS, and Kubernetes deployer adapters following
hexagonal architecture. These enable automated project provisioning:
- Git repository creation/management via Gitea
- DNS record management via Cloudflare
- Container deployment to Kubernetes

Includes domain models, ports, handlers, and Woodpecker CI webhook
integration for automated deployments on push.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:49:58 -07:00

294 lines
7.8 KiB
Go

// Package cloudflare provides a Cloudflare DNS adapter implementing port.DNSProvider.
package cloudflare
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"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, fmt.Errorf("cloudflare error: %v", 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, fmt.Errorf("cloudflare error: %v", 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, fmt.Errorf("cloudflare error: %v", 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, fmt.Errorf("cloudflare error: %v", 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, fmt.Errorf("cloudflare error: %v", result.Errors)
}
if len(result.Result) == 0 {
return nil, nil
}
return recordFromCFMap(result.Result[0]), nil
}
// 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"`
}
// 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
}