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>
294 lines
7.8 KiB
Go
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
|
|
}
|