mirror of
https://github.com/hanzoai/dns.git
synced 2026-10-30 18:40:55 +00:00
Implement the stubbed CloudflareReconciler with real CF DNS sync (create/
adopt/update/delete via a finalizer) so DnsRecords with syncToCloudflare=true
land at the edge, and add a PagesProject CR + reconciler that deploys a CF
Pages site, attaches its custom domain, and wires DNS by composing the DNS
layer (emits an owned CNAME DnsRecord rather than a second CF-DNS code path).
- internal/cloudflare: minimal CF API v4 client (DNS CRUD + Pages), one do()
HTTP path, scoped Bearer auth, idempotent delete/add. httptest-covered.
- CloudflareReconciler: real upsert + finalizer cleanup; nil client => no-op.
- PagesProject{,Spec,Status} CR + reconciler (Owns child DnsRecord for GC).
- main.go: one shared cloudflare.Client injected into both reconcilers.
- CRDs, RBAC, DeepCopy regenerated; deployable manager manifest (CF_API_TOKEN
from KMS-synced secret, optional); hanzo.bot sample.
Tests: go test ./... green (CF client + both reconcilers, fake client + mock CF).
178 lines
5.6 KiB
Go
178 lines
5.6 KiB
Go
// Package cloudflare is a minimal Cloudflare API v4 client covering exactly the
|
|
// surface the operator needs: DNS record CRUD and Pages project management.
|
|
// Auth is a scoped API token (Bearer) — never the Global API Key.
|
|
package cloudflare
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// DefaultBaseURL is the Cloudflare API v4 root. Overridable for tests.
|
|
const DefaultBaseURL = "https://api.cloudflare.com/client/v4"
|
|
|
|
// Client talks to the Cloudflare API with a scoped Bearer token.
|
|
type Client struct {
|
|
Token string
|
|
BaseURL string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// New returns a Client with sensible defaults.
|
|
func New(token string) *Client {
|
|
return &Client{Token: token, BaseURL: DefaultBaseURL, HTTP: &http.Client{Timeout: 30 * time.Second}}
|
|
}
|
|
|
|
// Record is a Cloudflare DNS record (the fields the operator manages).
|
|
type Record struct {
|
|
ID string `json:"id,omitempty"`
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
Content string `json:"content"`
|
|
TTL int `json:"ttl,omitempty"`
|
|
Proxied bool `json:"proxied"`
|
|
Priority *int `json:"priority,omitempty"`
|
|
}
|
|
|
|
// PagesProject is a Cloudflare Pages project (the fields the operator manages).
|
|
type PagesProject struct {
|
|
Name string `json:"name"`
|
|
Subdomain string `json:"subdomain,omitempty"`
|
|
ProductionBranch string `json:"production_branch,omitempty"`
|
|
}
|
|
|
|
type apiError struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
func (e apiError) Error() string { return fmt.Sprintf("cloudflare: %d %s", e.Code, e.Message) }
|
|
|
|
type apiResponse struct {
|
|
Success bool `json:"success"`
|
|
Errors []apiError `json:"errors"`
|
|
Result json.RawMessage `json:"result"`
|
|
}
|
|
|
|
// do issues one API call and unmarshals result into out (which may be nil).
|
|
// It is the single HTTP path for the whole client — DRY by construction.
|
|
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var ar apiResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil {
|
|
return fmt.Errorf("cloudflare: decode %s %s: %w", method, path, err)
|
|
}
|
|
if !ar.Success {
|
|
if len(ar.Errors) > 0 {
|
|
return ar.Errors[0]
|
|
}
|
|
return fmt.Errorf("cloudflare: %s %s failed (HTTP %d)", method, path, resp.StatusCode)
|
|
}
|
|
if out != nil && len(ar.Result) > 0 {
|
|
return json.Unmarshal(ar.Result, out)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- DNS records -----------------------------------------------------------
|
|
|
|
// FindRecord returns the record matching name+type in the zone, or nil if absent.
|
|
func (c *Client) FindRecord(ctx context.Context, zoneID, name, recType string) (*Record, error) {
|
|
q := url.Values{"name": {name}, "type": {recType}}
|
|
var recs []Record
|
|
if err := c.do(ctx, http.MethodGet, fmt.Sprintf("/zones/%s/dns_records?%s", zoneID, q.Encode()), nil, &recs); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(recs) == 0 {
|
|
return nil, nil
|
|
}
|
|
return &recs[0], nil
|
|
}
|
|
|
|
// CreateRecord creates a record and returns its Cloudflare ID.
|
|
func (c *Client) CreateRecord(ctx context.Context, zoneID string, r Record) (string, error) {
|
|
var out Record
|
|
if err := c.do(ctx, http.MethodPost, "/zones/"+zoneID+"/dns_records", r, &out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.ID, nil
|
|
}
|
|
|
|
// UpdateRecord overwrites an existing record by ID.
|
|
func (c *Client) UpdateRecord(ctx context.Context, zoneID, id string, r Record) error {
|
|
return c.do(ctx, http.MethodPut, "/zones/"+zoneID+"/dns_records/"+id, r, nil)
|
|
}
|
|
|
|
// DeleteRecord removes a record by ID. A 404/already-gone is not an error.
|
|
func (c *Client) DeleteRecord(ctx context.Context, zoneID, id string) error {
|
|
err := c.do(ctx, http.MethodDelete, "/zones/"+zoneID+"/dns_records/"+id, nil, nil)
|
|
if ae, ok := err.(apiError); ok && ae.Code == 81044 { // record does not exist
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ---- Pages -----------------------------------------------------------------
|
|
|
|
// GetPagesProject returns the project, or nil if it does not exist.
|
|
func (c *Client) GetPagesProject(ctx context.Context, accountID, name string) (*PagesProject, error) {
|
|
var p PagesProject
|
|
err := c.do(ctx, http.MethodGet, fmt.Sprintf("/accounts/%s/pages/projects/%s", accountID, name), nil, &p)
|
|
if ae, ok := err.(apiError); ok && ae.Code == 8000007 { // project not found
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
// CreatePagesProject creates a Pages project and returns it (with its pages.dev subdomain).
|
|
func (c *Client) CreatePagesProject(ctx context.Context, accountID string, p PagesProject) (*PagesProject, error) {
|
|
if p.ProductionBranch == "" {
|
|
p.ProductionBranch = "main"
|
|
}
|
|
var out PagesProject
|
|
if err := c.do(ctx, http.MethodPost, "/accounts/"+accountID+"/pages/projects", p, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// AddPagesDomain attaches a custom domain to a Pages project. Already-attached is not an error.
|
|
func (c *Client) AddPagesDomain(ctx context.Context, accountID, project, domain string) error {
|
|
err := c.do(ctx, http.MethodPost,
|
|
fmt.Sprintf("/accounts/%s/pages/projects/%s/domains", accountID, project),
|
|
map[string]string{"name": domain}, nil)
|
|
if ae, ok := err.(apiError); ok && ae.Code == 8000038 { // domain already exists
|
|
return nil
|
|
}
|
|
return err
|
|
}
|