go-devops/infra/cloudns.go
Snider 50ad540241 feat(infra): Phase 2 — API client abstraction, retry logic, rate limiting
Extract shared APIClient from HCloudClient/HRobotClient/CloudNSClient with
configurable retry (exponential backoff + jitter), 429 rate-limit handling
(Retry-After header), and functional options (WithHTTPClient, WithRetry,
WithAuth, WithPrefix). 30 new client_test.go tests covering retry exhaustion,
rate-limit queuing, context cancellation, and integration with all 3 providers.
DigitalOcean: no code existed, removed stale doc references. 66 infra tests
pass, race-clean.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 04:11:08 +00:00

255 lines
6.8 KiB
Go

package infra
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
)
const cloudnsBaseURL = "https://api.cloudns.net"
// CloudNSClient is an HTTP client for the CloudNS DNS API.
type CloudNSClient struct {
authID string
password string
baseURL string
api *APIClient
}
// NewCloudNSClient creates a new CloudNS API client.
// Uses sub-auth-user (auth-id) authentication.
func NewCloudNSClient(authID, password string) *CloudNSClient {
return &CloudNSClient{
authID: authID,
password: password,
baseURL: cloudnsBaseURL,
api: NewAPIClient(WithPrefix("cloudns API")),
}
}
// CloudNSZone represents a DNS zone.
type CloudNSZone struct {
Name string `json:"name"`
Type string `json:"type"`
Zone string `json:"zone"`
Status string `json:"status"`
}
// CloudNSRecord represents a DNS record.
type CloudNSRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Host string `json:"host"`
Record string `json:"record"`
TTL string `json:"ttl"`
Priority string `json:"priority,omitempty"`
Status int `json:"status"`
}
// ListZones returns all DNS zones.
func (c *CloudNSClient) ListZones(ctx context.Context) ([]CloudNSZone, error) {
params := c.authParams()
params.Set("page", "1")
params.Set("rows-per-page", "100")
params.Set("search", "")
data, err := c.get(ctx, "/dns/list-zones.json", params)
if err != nil {
return nil, err
}
var zones []CloudNSZone
if err := json.Unmarshal(data, &zones); err != nil {
// CloudNS returns an empty object {} for no results instead of []
return nil, nil
}
return zones, nil
}
// ListRecords returns all DNS records for a zone.
func (c *CloudNSClient) ListRecords(ctx context.Context, domain string) (map[string]CloudNSRecord, error) {
params := c.authParams()
params.Set("domain-name", domain)
data, err := c.get(ctx, "/dns/records.json", params)
if err != nil {
return nil, err
}
var records map[string]CloudNSRecord
if err := json.Unmarshal(data, &records); err != nil {
return nil, fmt.Errorf("parse records: %w", err)
}
return records, nil
}
// CreateRecord creates a DNS record. Returns the record ID.
func (c *CloudNSClient) CreateRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (string, error) {
params := c.authParams()
params.Set("domain-name", domain)
params.Set("host", host)
params.Set("record-type", recordType)
params.Set("record", value)
params.Set("ttl", strconv.Itoa(ttl))
data, err := c.post(ctx, "/dns/add-record.json", params)
if err != nil {
return "", err
}
var result struct {
Status string `json:"status"`
StatusDescription string `json:"statusDescription"`
Data struct {
ID int `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(data, &result); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
if result.Status != "Success" {
return "", fmt.Errorf("cloudns: %s", result.StatusDescription)
}
return strconv.Itoa(result.Data.ID), nil
}
// UpdateRecord updates an existing DNS record.
func (c *CloudNSClient) UpdateRecord(ctx context.Context, domain, recordID, host, recordType, value string, ttl int) error {
params := c.authParams()
params.Set("domain-name", domain)
params.Set("record-id", recordID)
params.Set("host", host)
params.Set("record-type", recordType)
params.Set("record", value)
params.Set("ttl", strconv.Itoa(ttl))
data, err := c.post(ctx, "/dns/mod-record.json", params)
if err != nil {
return err
}
var result struct {
Status string `json:"status"`
StatusDescription string `json:"statusDescription"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
if result.Status != "Success" {
return fmt.Errorf("cloudns: %s", result.StatusDescription)
}
return nil
}
// DeleteRecord deletes a DNS record by ID.
func (c *CloudNSClient) DeleteRecord(ctx context.Context, domain, recordID string) error {
params := c.authParams()
params.Set("domain-name", domain)
params.Set("record-id", recordID)
data, err := c.post(ctx, "/dns/delete-record.json", params)
if err != nil {
return err
}
var result struct {
Status string `json:"status"`
StatusDescription string `json:"statusDescription"`
}
if err := json.Unmarshal(data, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
if result.Status != "Success" {
return fmt.Errorf("cloudns: %s", result.StatusDescription)
}
return nil
}
// EnsureRecord creates or updates a DNS record to match the desired state.
// Returns true if a change was made.
func (c *CloudNSClient) EnsureRecord(ctx context.Context, domain, host, recordType, value string, ttl int) (bool, error) {
records, err := c.ListRecords(ctx, domain)
if err != nil {
return false, fmt.Errorf("list records: %w", err)
}
// Check if record already exists
for id, r := range records {
if r.Host == host && r.Type == recordType {
if r.Record == value {
return false, nil // Already correct
}
// Update existing record
if err := c.UpdateRecord(ctx, domain, id, host, recordType, value, ttl); err != nil {
return false, fmt.Errorf("update record: %w", err)
}
return true, nil
}
}
// Create new record
if _, err := c.CreateRecord(ctx, domain, host, recordType, value, ttl); err != nil {
return false, fmt.Errorf("create record: %w", err)
}
return true, nil
}
// SetACMEChallenge creates a DNS-01 ACME challenge TXT record.
func (c *CloudNSClient) SetACMEChallenge(ctx context.Context, domain, value string) (string, error) {
return c.CreateRecord(ctx, domain, "_acme-challenge", "TXT", value, 60)
}
// ClearACMEChallenge removes the DNS-01 ACME challenge TXT record.
func (c *CloudNSClient) ClearACMEChallenge(ctx context.Context, domain string) error {
records, err := c.ListRecords(ctx, domain)
if err != nil {
return err
}
for id, r := range records {
if r.Host == "_acme-challenge" && r.Type == "TXT" {
if err := c.DeleteRecord(ctx, domain, id); err != nil {
return err
}
}
}
return nil
}
func (c *CloudNSClient) authParams() url.Values {
params := url.Values{}
params.Set("auth-id", c.authID)
params.Set("auth-password", c.password)
return params
}
func (c *CloudNSClient) get(ctx context.Context, path string, params url.Values) ([]byte, error) {
u := c.baseURL + path + "?" + params.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
return c.doRaw(req)
}
func (c *CloudNSClient) post(ctx context.Context, path string, params url.Values) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, nil)
if err != nil {
return nil, err
}
req.URL.RawQuery = params.Encode()
return c.doRaw(req)
}
func (c *CloudNSClient) doRaw(req *http.Request) ([]byte, error) {
return c.api.DoRaw(req)
}