add tool to gitea
Build and Deploy / build-and-deploy (push) Failing after 20s

This commit is contained in:
2026-06-05 02:34:00 +10:00
commit 532b912ffb
25 changed files with 4981 additions and 0 deletions
+301
View File
@@ -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
}