This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
// Package dns provides a provider-agnostic interface for DNS
|
||||
// record management, with a Cloudflare implementation.
|
||||
//
|
||||
// Adding a new provider (Route53, Porkbun, etc.):
|
||||
// 1. Implement the Provider interface below.
|
||||
// 2. Add a case in NewProvider().
|
||||
// 3. The rest of kforge uses Provider — no other changes needed.
|
||||
package dns
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"kforge/internal/config"
|
||||
)
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Provider interface
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// Provider is the DNS provider contract. Implementations must
|
||||
// be idempotent — calling EnsureARecord twice with the same
|
||||
// inputs must not error or create duplicates.
|
||||
type Provider interface {
|
||||
// EnsureARecord creates an A record for hostname pointing to
|
||||
// ip if one does not already exist. If a record exists with a
|
||||
// different IP, it is updated. No-ops if already correct.
|
||||
EnsureARecord(hostname, ip string) error
|
||||
|
||||
// DeleteARecord removes the A record for hostname if it exists.
|
||||
// No-ops if it does not exist.
|
||||
DeleteARecord(hostname string) error
|
||||
}
|
||||
|
||||
// NewProvider returns the configured DNS provider.
|
||||
func NewProvider(cfg config.DNSConfig) (Provider, error) {
|
||||
switch cfg.Provider {
|
||||
case "cloudflare":
|
||||
return newCloudflareProvider(cfg)
|
||||
case "":
|
||||
return &noopProvider{}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown dns provider %q (supported: cloudflare)", cfg.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// noopProvider satisfies the interface when DNS management is
|
||||
// disabled (skip_dns: true or no provider configured).
|
||||
type noopProvider struct{}
|
||||
|
||||
func (n *noopProvider) EnsureARecord(hostname, ip string) error { return nil }
|
||||
func (n *noopProvider) DeleteARecord(hostname string) error { return nil }
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Cloudflare implementation
|
||||
// ------------------------------------------------------------
|
||||
|
||||
const cfAPIBase = "https://api.cloudflare.com/client/v4"
|
||||
|
||||
type cloudflareProvider struct {
|
||||
apiToken string
|
||||
zones []config.ZoneEntry // sorted longest-first for matching
|
||||
proxied bool
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newCloudflareProvider(cfg config.DNSConfig) (*cloudflareProvider, error) {
|
||||
if cfg.Cloudflare.APIToken == "" {
|
||||
return nil, fmt.Errorf("cloudflare.api_token is required")
|
||||
}
|
||||
if len(cfg.Cloudflare.Zones) == 0 {
|
||||
return nil, fmt.Errorf("cloudflare.zones must have at least one entry")
|
||||
}
|
||||
return &cloudflareProvider{
|
||||
apiToken: cfg.Cloudflare.APIToken,
|
||||
zones: cfg.Cloudflare.Zones,
|
||||
proxied: cfg.Cloudflare.Proxied,
|
||||
client: &http.Client{Timeout: 15 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// zoneForHostname finds the zone whose name is the longest suffix
|
||||
// of hostname. This handles both "app.example.com" → "example.com"
|
||||
// and "app.sub.example.co.uk" → "example.co.uk" if that zone exists.
|
||||
func (c *cloudflareProvider) zoneForHostname(hostname string) (config.ZoneEntry, error) {
|
||||
var best config.ZoneEntry
|
||||
bestLen := 0
|
||||
for _, z := range c.zones {
|
||||
if strings.HasSuffix(hostname, z.Name) && len(z.Name) > bestLen {
|
||||
best = z
|
||||
bestLen = len(z.Name)
|
||||
}
|
||||
}
|
||||
if bestLen == 0 {
|
||||
return config.ZoneEntry{}, fmt.Errorf("no configured zone matches hostname %q", hostname)
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// EnsureARecord is idempotent: creates if absent, updates if IP
|
||||
// differs, no-ops if already correct.
|
||||
func (c *cloudflareProvider) EnsureARecord(hostname, ip string) error {
|
||||
zone, err := c.zoneForHostname(hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := c.getRecord(zone.ZoneID, hostname, "A")
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking existing record: %w", err)
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
if existing.Content == ip {
|
||||
fmt.Printf(" dns: A record %s → %s already correct, skipping\n", hostname, ip)
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" dns: updating A record %s → %s (was %s)\n", hostname, ip, existing.Content)
|
||||
return c.updateRecord(zone.ZoneID, existing.ID, hostname, ip)
|
||||
}
|
||||
|
||||
fmt.Printf(" dns: creating A record %s → %s\n", hostname, ip)
|
||||
return c.createRecord(zone.ZoneID, hostname, ip)
|
||||
}
|
||||
|
||||
// DeleteARecord removes the A record for hostname if it exists.
|
||||
func (c *cloudflareProvider) DeleteARecord(hostname string) error {
|
||||
zone, err := c.zoneForHostname(hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := c.getRecord(zone.ZoneID, hostname, "A")
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking existing record: %w", err)
|
||||
}
|
||||
if existing == nil {
|
||||
fmt.Printf(" dns: A record %s not found, skipping delete\n", hostname)
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" dns: deleting A record %s\n", hostname)
|
||||
return c.deleteRecord(zone.ZoneID, existing.ID)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Cloudflare API helpers
|
||||
// ------------------------------------------------------------
|
||||
|
||||
type cfRecord struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Proxied bool `json:"proxied"`
|
||||
TTL int `json:"ttl"`
|
||||
}
|
||||
|
||||
type cfListResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []cfError `json:"errors"`
|
||||
Result []cfRecord `json:"result"`
|
||||
}
|
||||
|
||||
type cfSingleResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Errors []cfError `json:"errors"`
|
||||
Result cfRecord `json:"result"`
|
||||
}
|
||||
|
||||
type cfError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e cfError) Error() string {
|
||||
return fmt.Sprintf("CF %d: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) getRecord(zoneID, name, recType string) (*cfRecord, error) {
|
||||
url := fmt.Sprintf("%s/zones/%s/dns_records?type=%s&name=%s", cfAPIBase, zoneID, recType, name)
|
||||
resp, err := c.do("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var list cfListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
|
||||
return nil, fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
if !list.Success {
|
||||
return nil, cfErrors(list.Errors)
|
||||
}
|
||||
if len(list.Result) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &list.Result[0], nil
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) createRecord(zoneID, name, ip string) error {
|
||||
body := fmt.Sprintf(`{"type":"A","name":%q,"content":%q,"ttl":1,"proxied":%v}`,
|
||||
name, ip, c.proxied)
|
||||
url := fmt.Sprintf("%s/zones/%s/dns_records", cfAPIBase, zoneID)
|
||||
resp, err := c.do("POST", url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result cfSingleResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
if !result.Success {
|
||||
return cfErrors(result.Errors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) updateRecord(zoneID, recordID, name, ip string) error {
|
||||
body := fmt.Sprintf(`{"type":"A","name":%q,"content":%q,"ttl":1,"proxied":%v}`,
|
||||
name, ip, c.proxied)
|
||||
url := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID)
|
||||
resp, err := c.do("PUT", url, strings.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result cfSingleResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decoding response: %w", err)
|
||||
}
|
||||
if !result.Success {
|
||||
return cfErrors(result.Errors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) deleteRecord(zoneID, recordID string) error {
|
||||
url := fmt.Sprintf("%s/zones/%s/dns_records/%s", cfAPIBase, zoneID, recordID)
|
||||
resp, err := c.do("DELETE", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// Cloudflare returns {"result":{"id":"..."}} on success — we
|
||||
// don't need to parse it, just check for HTTP errors.
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("delete failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cloudflareProvider) do(method, url string, body io.Reader) (*http.Response, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cloudflare API %s %s: %w", method, url, err)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func cfErrors(errs []cfError) error {
|
||||
msgs := make([]string, len(errs))
|
||||
for i, e := range errs {
|
||||
msgs[i] = e.Error()
|
||||
}
|
||||
return fmt.Errorf("cloudflare API errors: %s", strings.Join(msgs, "; "))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// EnsureRecordsForEnvironment — high-level helper used by
|
||||
// the apply command and generate pipeline.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// EnsureRecordsForEnvironment creates A records for all ingress
|
||||
// hosts in the environment that have dns_record: true.
|
||||
func EnsureRecordsForEnvironment(provider Provider, env *config.ResolvedEnvironment, nodeIP string) error {
|
||||
for _, host := range env.Ingress.Hosts {
|
||||
if !host.DNSRecord {
|
||||
continue
|
||||
}
|
||||
if err := provider.EnsureARecord(host.Hostname, nodeIP); err != nil {
|
||||
return fmt.Errorf("ensuring A record for %s: %w", host.Hostname, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user