cli/pkg/infra/cloudns.go
Snider 1b861494f1 feat(prod): add production infrastructure management
Add `core prod` command with full production infrastructure tooling:

- `core prod status` — parallel SSH health checks across all hosts,
  Galera cluster state, Redis sentinel, Docker, LB health
- `core prod setup` — Phase 1 foundation: Hetzner topology discovery,
  managed LB creation, CloudNS DNS record management
- `core prod dns` — CloudNS record CRUD with idempotent EnsureRecord
- `core prod lb` — Hetzner Cloud LB status and creation
- `core prod ssh <host>` — SSH into hosts defined in infra.yaml

New packages:
- pkg/infra: config parsing, Hetzner Cloud/Robot API, CloudNS DNS API
- infra.yaml: declarative production topology (hosts, LB, DNS, SSL,
  Galera, Redis, containers, S3, CDN, CI/CD, monitoring, backups)

Docker:
- Dockerfile.app (PHP 8.3-FPM, multi-stage)
- Dockerfile.web (Nginx + security headers)
- docker-compose.prod.yml (app, web, horizon, scheduler, mcp, redis, galera)

Ansible playbooks (runnable via `core deploy ansible`):
- galera-deploy.yml, redis-deploy.yml, galera-backup.yml
- inventory.yml with all production hosts

CI/CD:
- .forgejo/workflows/deploy.yml for Forgejo Actions pipeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 03:03:29 +00:00

272 lines
7.1 KiB
Go

package infra
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"time"
)
const cloudnsBaseURL = "https://api.cloudns.net"
// CloudNSClient is an HTTP client for the CloudNS DNS API.
type CloudNSClient struct {
authID string
password string
client *http.Client
}
// 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,
client: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// 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 := cloudnsBaseURL + 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, cloudnsBaseURL+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) {
resp, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("cloudns API: %w", err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("cloudns API %d: %s", resp.StatusCode, string(data))
}
return data, nil
}