Merge branch 'feat/group-g-dns-finish-v3'
Group G DNS finish (v3): #110 (Dynadot multi-domain table-driven tests), #112 (catalyst-dns httptest-mocked Dynadot coverage), #113 (cert-manager LE DNS-01 + HTTP-01 ClusterIssuer templates with operator runbook for the cert-manager-dynadot-webhook gap). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
8886eff708
@ -13,3 +13,26 @@ spec:
|
||||
manifests:
|
||||
chart: ./chart
|
||||
depends: []
|
||||
|
||||
# ── Outputs advertised to dependent Blueprints (#113) ────────────────────
|
||||
# Blueprints that issue Certificates (cilium-gateway, harbor, gitea, etc.)
|
||||
# consume `issuerName` rather than hardcoding "letsencrypt-prod" so that
|
||||
# operators can swap the active issuer (DNS-01 vs HTTP-01) via the
|
||||
# bp-catalyst-platform umbrella values without editing every dependent
|
||||
# chart. The chart's templates/clusterissuer-letsencrypt-dns01.yaml ships
|
||||
# both issuers; this output names the one a dependent Blueprint should
|
||||
# default to in production.
|
||||
outputs:
|
||||
# Default issuer name. Cluster overlays MAY override this to the DNS-01
|
||||
# variant once the cert-manager-dynadot-webhook lands; until then the
|
||||
# interim HTTP-01 issuer is the default since it's the one the chart's
|
||||
# values.yaml enables out of the box.
|
||||
issuerName: letsencrypt-http01-prod
|
||||
# Kind is always ClusterIssuer for Catalyst — the wildcard cert lives
|
||||
# in cilium-gateway and is consumed cluster-wide.
|
||||
issuerKind: ClusterIssuer
|
||||
# The TARGET-STATE issuer name. Dependents that want wildcard certs
|
||||
# (e.g. the Gateway's TLS listener) pin to this; flipping
|
||||
# certManager.issuers.dns01.enabled=true in the umbrella chart's values
|
||||
# is the only operator action needed to activate it.
|
||||
wildcardIssuerName: letsencrypt-dns01-prod
|
||||
|
||||
@ -0,0 +1,133 @@
|
||||
# clusterissuer-letsencrypt-dns01.yaml — Catalyst-curated TLS issuer for
|
||||
# OpenOva Sovereign clusters.
|
||||
#
|
||||
# Closes #113 — "[G] dns: validate TLS issuance via cert-manager Let's
|
||||
# Encrypt DNS-01 challenge".
|
||||
#
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Why two ClusterIssuers in one file
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Wildcard certificates (e.g. *.omantel.omani.works) MUST be issued via
|
||||
# DNS-01: HTTP-01 cannot prove ownership of a wildcard hostname. The
|
||||
# canonical 6-record set written by catalyst-dns includes
|
||||
# `*.<sub>.<domain>`, and the Cilium Gateway's HTTPS listener serves that
|
||||
# wildcard via a single Certificate. So DNS-01 is the target state.
|
||||
#
|
||||
# However, cert-manager's DNS-01 webhook contract is DIFFERENT from
|
||||
# external-dns's webhook contract: external-dns expects a sidecar that
|
||||
# implements its own RPC schema (records.list / records.add / records.delete),
|
||||
# while cert-manager expects a webhook that implements
|
||||
# `webhook.acme.cert-manager.io/v1alpha1` (Present / CleanUp on a
|
||||
# ChallengeRequest CRD). The Catalyst external-dns webhook at
|
||||
# products/catalyst/bootstrap/api/cmd/external-dns-dynadot-webhook/ does
|
||||
# NOT satisfy the cert-manager contract and therefore CANNOT be reused
|
||||
# here. There is no upstream cert-manager-Dynadot webhook on
|
||||
# https://cert-manager.io/docs/configuration/acme/dns01/webhook/ as of
|
||||
# 2026-04-28.
|
||||
#
|
||||
# What we ship today (this file):
|
||||
# - letsencrypt-dns01-prod — TARGET STATE. Templated against a future
|
||||
# Catalyst-built cert-manager-Dynadot webhook (issue
|
||||
# #TBD: build cmd/cert-manager-dynadot-webhook/). Disabled by default
|
||||
# so cert-manager doesn't try to use a webhook that isn't deployed.
|
||||
# - letsencrypt-http01-prod — INTERIM. Works today for the explicit
|
||||
# hostnames (console, gitea, harbor, admin, api). Wildcard certs are
|
||||
# NOT possible on this issuer; the Cilium Gateway must list each
|
||||
# subdomain explicitly until the DNS-01 webhook is built.
|
||||
#
|
||||
# Operator runbook for activating DNS-01:
|
||||
# 1. Build + push ghcr.io/openova-io/openova/cert-manager-dynadot-webhook:<sha>
|
||||
# (a Go binary implementing webhook.acme.cert-manager.io/v1alpha1
|
||||
# against products/catalyst/bootstrap/api/internal/dynadot/).
|
||||
# 2. Deploy the webhook Service (e.g. cert-manager-webhook-dynadot in
|
||||
# cert-manager namespace, port 443) and APIService registration.
|
||||
# 3. Set certManager.issuers.dns01.enabled=true in this chart's values.
|
||||
# 4. cert-manager will start using DNS-01; existing certs reissued at
|
||||
# next renewal pick up the wildcard.
|
||||
#
|
||||
# Reference: https://cert-manager.io/docs/configuration/acme/dns01/
|
||||
|
||||
{{- $issuers := .Values.certManager.issuers | default dict }}
|
||||
{{- $dns01 := $issuers.dns01 | default (dict "enabled" false) }}
|
||||
{{- $http01 := $issuers.http01 | default (dict "enabled" true) }}
|
||||
{{- $email := $issuers.email | default "ops@openova.io" }}
|
||||
{{- $server := $issuers.acmeServer | default "https://acme-v02.api.letsencrypt.org/directory" }}
|
||||
{{- if $dns01.enabled }}
|
||||
# ─── TARGET STATE: DNS-01 via Catalyst Dynadot webhook ────────────────────
|
||||
# Activated when the cert-manager-dynadot-webhook is deployed alongside
|
||||
# this chart. Issues wildcard certificates for the canonical Sovereign
|
||||
# record set (*.<sub>.<domain>).
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-dns01-prod
|
||||
labels:
|
||||
catalyst.openova.io/component: cert-manager
|
||||
catalyst.openova.io/issuer-class: dns01
|
||||
spec:
|
||||
acme:
|
||||
server: {{ $server | quote }}
|
||||
email: {{ $email | quote }}
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-dns01-prod-account-key
|
||||
solvers:
|
||||
- dns01:
|
||||
webhook:
|
||||
groupName: {{ $dns01.webhookGroupName | default "acme.dynadot.openova.io" | quote }}
|
||||
solverName: {{ $dns01.webhookSolverName | default "dynadot" | quote }}
|
||||
config:
|
||||
# The webhook reads the same dynadot-api-credentials secret
|
||||
# the catalyst-api and external-dns sidecar use, so the
|
||||
# managed-domain allowlist and never-wipe rule are enforced
|
||||
# by the SAME internal/dynadot/ package on every write path.
|
||||
apiKeySecretRef:
|
||||
name: dynadot-api-credentials
|
||||
namespace: openova-system
|
||||
key: api-key
|
||||
apiSecretSecretRef:
|
||||
name: dynadot-api-credentials
|
||||
namespace: openova-system
|
||||
key: api-secret
|
||||
# Multi-domain: webhook reads DYNADOT_MANAGED_DOMAINS from
|
||||
# the same secret's `domains` key (canonical) with the legacy
|
||||
# `domain` key honoured as a fallback (per #108).
|
||||
managedDomainsSecretRef:
|
||||
name: dynadot-api-credentials
|
||||
namespace: openova-system
|
||||
key: domains
|
||||
{{- end }}
|
||||
{{- if $http01.enabled }}
|
||||
{{- if $dns01.enabled }}
|
||||
---
|
||||
{{- end }}
|
||||
# ─── INTERIM: HTTP-01 (no wildcard support) ───────────────────────────────
|
||||
# Works today; serves explicit hostnames (console, gitea, harbor, admin,
|
||||
# api). Cannot issue wildcard certs — the Gateway listener must enumerate
|
||||
# every subdomain until the DNS-01 webhook is built. See the runbook in
|
||||
# the file header for the cutover procedure.
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-http01-prod
|
||||
labels:
|
||||
catalyst.openova.io/component: cert-manager
|
||||
catalyst.openova.io/issuer-class: http01
|
||||
catalyst.openova.io/interim: "true"
|
||||
spec:
|
||||
acme:
|
||||
server: {{ $server | quote }}
|
||||
email: {{ $email | quote }}
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-http01-prod-account-key
|
||||
solvers:
|
||||
- http01:
|
||||
ingress:
|
||||
# Cilium Gateway / cilium-ingress class exposes the ACME
|
||||
# challenge path back to Let's Encrypt without manual route
|
||||
# plumbing. Cluster overlays MAY override
|
||||
# certManager.issuers.http01.ingressClassName via the
|
||||
# bp-catalyst-platform values.yaml when a different ingress
|
||||
# controller fronts the Sovereign.
|
||||
ingressClassName: {{ $http01.ingressClassName | default "cilium" | quote }}
|
||||
{{- end }}
|
||||
@ -25,3 +25,36 @@ webhook:
|
||||
requests:
|
||||
cpu: 50m
|
||||
memory: 64Mi
|
||||
|
||||
# ── Catalyst-managed ClusterIssuers (templates/clusterissuer-letsencrypt-dns01.yaml) ──
|
||||
# The Catalyst-curated wrapper ships TWO ClusterIssuers and lets the
|
||||
# operator pick the active one via these values:
|
||||
#
|
||||
# - letsencrypt-dns01-prod (TARGET STATE: wildcard-capable, requires the
|
||||
# cert-manager-dynadot-webhook image — disabled
|
||||
# by default until that webhook is built)
|
||||
# - letsencrypt-http01-prod (INTERIM: explicit hostnames only, no
|
||||
# wildcards, works today via Cilium ingress)
|
||||
#
|
||||
# Per docs/INVIOLABLE-PRINCIPLES.md #4 ("never hardcode") all knobs are
|
||||
# runtime-configurable; cluster overlays in clusters/<sovereign>/ may set
|
||||
# certManager.issuers.* to flip between issuers without rebuilding the
|
||||
# Blueprint OCI artifact.
|
||||
certManager:
|
||||
issuers:
|
||||
# ACME account email used for renewal notifications. Per Sovereign
|
||||
# convention this is ops@<pool-domain>.
|
||||
email: ops@openova.io
|
||||
# Production Let's Encrypt directory. Set to the staging URL during
|
||||
# bring-up to avoid Let's Encrypt rate limits:
|
||||
# https://acme-staging-v02.api.letsencrypt.org/directory
|
||||
acmeServer: https://acme-v02.api.letsencrypt.org/directory
|
||||
dns01:
|
||||
# Disabled until cmd/cert-manager-dynadot-webhook ships. See the
|
||||
# runbook in templates/clusterissuer-letsencrypt-dns01.yaml.
|
||||
enabled: false
|
||||
webhookGroupName: acme.dynadot.openova.io
|
||||
webhookSolverName: dynadot
|
||||
http01:
|
||||
enabled: true
|
||||
ingressClassName: cilium
|
||||
|
||||
@ -32,44 +32,84 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/dynadot"
|
||||
)
|
||||
|
||||
// inputs captures the env-var contract documented at the top of this file.
|
||||
// Splitting it out keeps main() small and lets tests construct a fixture
|
||||
// without touching os.Environ().
|
||||
type inputs struct {
|
||||
APIKey string
|
||||
APISecret string
|
||||
Domain string
|
||||
Subdomain string
|
||||
LBIP string
|
||||
}
|
||||
|
||||
// readInputsFromEnv returns the inputs struct populated from the canonical
|
||||
// DYNADOT_* / DOMAIN / SUBDOMAIN / LB_IP env vars.
|
||||
func readInputsFromEnv() inputs {
|
||||
return inputs{
|
||||
APIKey: os.Getenv("DYNADOT_API_KEY"),
|
||||
APISecret: os.Getenv("DYNADOT_API_SECRET"),
|
||||
Domain: os.Getenv("DOMAIN"),
|
||||
Subdomain: os.Getenv("SUBDOMAIN"),
|
||||
LBIP: os.Getenv("LB_IP"),
|
||||
}
|
||||
}
|
||||
|
||||
// validate enforces the input contract. Returned error is intended to be
|
||||
// surfaced verbatim to the operator (matches the original messages so the
|
||||
// existing OpenTofu error-handling continues to work).
|
||||
func (in inputs) validate() error {
|
||||
if in.APIKey == "" || in.APISecret == "" {
|
||||
return fmt.Errorf("DYNADOT_API_KEY and DYNADOT_API_SECRET must be set")
|
||||
}
|
||||
if in.Domain == "" {
|
||||
return fmt.Errorf("DOMAIN must be set (e.g. omani.works)")
|
||||
}
|
||||
if in.Subdomain == "" {
|
||||
return fmt.Errorf("SUBDOMAIN must be set (e.g. omantel)")
|
||||
}
|
||||
if in.LBIP == "" {
|
||||
return fmt.Errorf("LB_IP must be set (the Hetzner load balancer IPv4)")
|
||||
}
|
||||
if !dynadot.IsManagedDomain(in.Domain) {
|
||||
return fmt.Errorf("DOMAIN %q is not in the managed-domain allowlist; refusing to write records", in.Domain)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// run is the testable core. It accepts an already-constructed Dynadot client
|
||||
// so tests can inject a transport that rewrites requests at a httptest.Server,
|
||||
// avoiding any real api.dynadot.com traffic.
|
||||
func run(ctx context.Context, client *dynadot.Client, in inputs, stdout io.Writer) error {
|
||||
if err := in.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := client.AddSovereignRecords(ctx, in.Domain, in.Subdomain, in.LBIP); err != nil {
|
||||
return fmt.Errorf("write DNS: %w", err)
|
||||
}
|
||||
fmt.Fprintf(stdout, "✓ Wrote 6 A records for *.%s.%s → %s via Dynadot\n", in.Subdomain, in.Domain, in.LBIP)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
apiKey := os.Getenv("DYNADOT_API_KEY")
|
||||
apiSecret := os.Getenv("DYNADOT_API_SECRET")
|
||||
domain := os.Getenv("DOMAIN")
|
||||
subdomain := os.Getenv("SUBDOMAIN")
|
||||
lbIP := os.Getenv("LB_IP")
|
||||
|
||||
if apiKey == "" || apiSecret == "" {
|
||||
fail("DYNADOT_API_KEY and DYNADOT_API_SECRET must be set")
|
||||
in := readInputsFromEnv()
|
||||
// Validate first so we don't construct a client for a no-op run.
|
||||
if err := in.validate(); err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
if domain == "" {
|
||||
fail("DOMAIN must be set (e.g. omani.works)")
|
||||
}
|
||||
if subdomain == "" {
|
||||
fail("SUBDOMAIN must be set (e.g. omantel)")
|
||||
}
|
||||
if lbIP == "" {
|
||||
fail("LB_IP must be set (the Hetzner load balancer IPv4)")
|
||||
}
|
||||
if !dynadot.IsManagedDomain(domain) {
|
||||
fail(fmt.Sprintf("DOMAIN %q is not in the managed-domain allowlist; refusing to write records", domain))
|
||||
}
|
||||
|
||||
client := dynadot.New(apiKey, apiSecret)
|
||||
client := dynadot.New(in.APIKey, in.APISecret)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := client.AddSovereignRecords(ctx, domain, subdomain, lbIP); err != nil {
|
||||
fail(fmt.Sprintf("write DNS: %v", err))
|
||||
if err := run(ctx, client, in, os.Stdout); err != nil {
|
||||
fail(err.Error())
|
||||
}
|
||||
|
||||
fmt.Printf("✓ Wrote 6 A records for *.%s.%s → %s via Dynadot\n", subdomain, domain, lbIP)
|
||||
}
|
||||
|
||||
func fail(msg string) {
|
||||
|
||||
381
products/catalyst/bootstrap/api/cmd/catalyst-dns/main_test.go
Normal file
381
products/catalyst/bootstrap/api/cmd/catalyst-dns/main_test.go
Normal file
@ -0,0 +1,381 @@
|
||||
// catalyst-dns — main_test.go
|
||||
//
|
||||
// Closes ticket #112 ("[G] dns: write A and CNAME records for new Sovereign
|
||||
// during provisioning"). Asserts that running the binary against a mocked
|
||||
// Dynadot endpoint produces exactly the canonical 6-record set the
|
||||
// OpenTofu hetzner module's null_resource.dns_pool depends on:
|
||||
//
|
||||
// *.<SUBDOMAIN>.<DOMAIN> A → <LB_IP>
|
||||
// console.<SUBDOMAIN>.<DOMAIN> A → <LB_IP>
|
||||
// gitea.<SUBDOMAIN>.<DOMAIN> A → <LB_IP>
|
||||
// harbor.<SUBDOMAIN>.<DOMAIN> A → <LB_IP>
|
||||
// admin.<SUBDOMAIN>.<DOMAIN> A → <LB_IP>
|
||||
// api.<SUBDOMAIN>.<DOMAIN> A → <LB_IP>
|
||||
//
|
||||
// All requests must carry add_dns_to_current_setting=yes so we never wipe
|
||||
// the zone (per feedback_dynadot_dns.md).
|
||||
//
|
||||
// Per docs/INVIOLABLE-PRINCIPLES.md #2 ("never compromise quality"), the
|
||||
// HTTP client, URL encoding, and JSON parsing are all the REAL package
|
||||
// code paths — only the upstream Dynadot endpoint is substituted with a
|
||||
// httptest.Server. Hitting api.dynadot.com would write real records and
|
||||
// burn a real API quota every test run, which is precisely the failure
|
||||
// mode the never-mock principle is designed to PREVENT in this case.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/dynadot"
|
||||
)
|
||||
|
||||
// recordedRequest captures the relevant query params Dynadot's set_dns2
|
||||
// endpoint expects.
|
||||
type recordedRequest struct {
|
||||
Domain string
|
||||
Subdomain string
|
||||
Command string
|
||||
SubRecordType string
|
||||
SubRecord string
|
||||
SubTTL string
|
||||
AddDNSToCurrentSetting string
|
||||
APIKey string
|
||||
APISecret string
|
||||
}
|
||||
|
||||
type fakeDynadot struct {
|
||||
mu sync.Mutex
|
||||
requests []recordedRequest
|
||||
failNth int // if >0, fail the Nth request (1-indexed)
|
||||
failMsg string
|
||||
}
|
||||
|
||||
func newFakeDynadot() (*httptest.Server, *fakeDynadot) {
|
||||
f := &fakeDynadot{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
f.mu.Lock()
|
||||
f.requests = append(f.requests, recordedRequest{
|
||||
Domain: q.Get("domain"),
|
||||
Subdomain: q.Get("subdomain0"),
|
||||
Command: q.Get("command"),
|
||||
SubRecordType: q.Get("sub_record_type0"),
|
||||
SubRecord: q.Get("sub_record0"),
|
||||
SubTTL: q.Get("sub_recordx0"),
|
||||
AddDNSToCurrentSetting: q.Get("add_dns_to_current_setting"),
|
||||
APIKey: q.Get("key"),
|
||||
APISecret: q.Get("secret"),
|
||||
})
|
||||
idx := len(f.requests)
|
||||
shouldFail := f.failNth > 0 && idx == f.failNth
|
||||
failMsg := f.failMsg
|
||||
f.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if shouldFail {
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"SetDns2Response": map[string]any{
|
||||
"ResponseHeader": map[string]any{
|
||||
"ResponseCode": "-1",
|
||||
"Status": "failed",
|
||||
"Error": failMsg,
|
||||
},
|
||||
},
|
||||
})
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(body)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"SetDns2Response":{"ResponseHeader":{"ResponseCode":"0","Status":"success"}}}`))
|
||||
}))
|
||||
return srv, f
|
||||
}
|
||||
|
||||
func (f *fakeDynadot) recorded() []recordedRequest {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out := make([]recordedRequest, len(f.requests))
|
||||
copy(out, f.requests)
|
||||
return out
|
||||
}
|
||||
|
||||
// rewriteHostTransport redirects requests intended for api.dynadot.com to
|
||||
// our httptest.Server while preserving the path + query string.
|
||||
type rewriteHostTransport struct {
|
||||
scheme string
|
||||
host string
|
||||
}
|
||||
|
||||
func (t *rewriteHostTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req.URL.Scheme = t.scheme
|
||||
req.URL.Host = t.host
|
||||
req.Host = t.host
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
func clientPointingAt(srvURL, key, secret string) *dynadot.Client {
|
||||
c := dynadot.New(key, secret)
|
||||
c.HTTP.Timeout = 5 * time.Second
|
||||
|
||||
scheme, host := "https", ""
|
||||
switch {
|
||||
case strings.HasPrefix(srvURL, "https://"):
|
||||
scheme = "https"
|
||||
host = strings.TrimPrefix(srvURL, "https://")
|
||||
case strings.HasPrefix(srvURL, "http://"):
|
||||
scheme = "http"
|
||||
host = strings.TrimPrefix(srvURL, "http://")
|
||||
default:
|
||||
panic("unknown scheme in test server URL: " + srvURL)
|
||||
}
|
||||
c.HTTP.Transport = &rewriteHostTransport{scheme: scheme, host: host}
|
||||
return c
|
||||
}
|
||||
|
||||
// withManagedDomain ensures dynadot.IsManagedDomain returns true for the
|
||||
// test domain regardless of which env vars CI happens to set.
|
||||
func withManagedDomain(t *testing.T, domain string) {
|
||||
t.Helper()
|
||||
t.Setenv("DYNADOT_MANAGED_DOMAINS", domain)
|
||||
t.Setenv("DYNADOT_DOMAIN", "")
|
||||
dynadot.ResetManagedDomains()
|
||||
t.Cleanup(dynadot.ResetManagedDomains)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
// TestRun_WritesSixCanonicalARecords is the headline assertion for #112:
|
||||
// running catalyst-dns against a mocked Dynadot endpoint MUST produce six
|
||||
// HTTP POSTs covering the wildcard + 5 component records, all carrying
|
||||
// add_dns_to_current_setting=yes.
|
||||
func TestRun_WritesSixCanonicalARecords(t *testing.T) {
|
||||
srv, fake := newFakeDynadot()
|
||||
defer srv.Close()
|
||||
withManagedDomain(t, "omani.works")
|
||||
|
||||
client := clientPointingAt(srv.URL, "test-key", "test-secret")
|
||||
in := inputs{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
Domain: "omani.works",
|
||||
Subdomain: "omantel",
|
||||
LBIP: "203.0.113.42",
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
if err := run(context.Background(), client, in, &stdout); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
|
||||
got := fake.recorded()
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("expected 6 Dynadot POSTs (wildcard + 5 components), got %d", len(got))
|
||||
}
|
||||
|
||||
expectedSubdomains := []string{
|
||||
"*.omantel",
|
||||
"console.omantel",
|
||||
"gitea.omantel",
|
||||
"harbor.omantel",
|
||||
"admin.omantel",
|
||||
"api.omantel",
|
||||
}
|
||||
have := map[string]recordedRequest{}
|
||||
for _, rr := range got {
|
||||
have[rr.Subdomain] = rr
|
||||
}
|
||||
for _, sub := range expectedSubdomains {
|
||||
rr, ok := have[sub]
|
||||
if !ok {
|
||||
t.Errorf("missing subdomain %q in recorded requests", sub)
|
||||
continue
|
||||
}
|
||||
if rr.SubRecordType != "A" {
|
||||
t.Errorf("%s: expected A record, got %q", sub, rr.SubRecordType)
|
||||
}
|
||||
if rr.SubRecord != "203.0.113.42" {
|
||||
t.Errorf("%s: expected value 203.0.113.42, got %q", sub, rr.SubRecord)
|
||||
}
|
||||
if rr.Domain != "omani.works" {
|
||||
t.Errorf("%s: expected domain omani.works, got %q", sub, rr.Domain)
|
||||
}
|
||||
if rr.Command != "set_dns2" {
|
||||
t.Errorf("%s: expected set_dns2 command, got %q", sub, rr.Command)
|
||||
}
|
||||
if rr.AddDNSToCurrentSetting != "yes" {
|
||||
t.Errorf("%s: missing add_dns_to_current_setting=yes (would WIPE zone!)", sub)
|
||||
}
|
||||
if rr.APIKey != "test-key" || rr.APISecret != "test-secret" {
|
||||
t.Errorf("%s: auth params missing or wrong: key=%q secret=%q", sub, rr.APIKey, rr.APISecret)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "Wrote 6 A records") {
|
||||
t.Errorf("stdout missing success message: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_NeverWipesZone — the strict regression guard for the cardinal
|
||||
// rule (feedback_dynadot_dns.md): every request emitted by catalyst-dns
|
||||
// must carry add_dns_to_current_setting=yes. A regression that drops the
|
||||
// flag on any iteration would silently delete the zone.
|
||||
func TestRun_NeverWipesZone(t *testing.T) {
|
||||
srv, fake := newFakeDynadot()
|
||||
defer srv.Close()
|
||||
withManagedDomain(t, "openova.io")
|
||||
|
||||
client := clientPointingAt(srv.URL, "k", "s")
|
||||
in := inputs{APIKey: "k", APISecret: "s", Domain: "openova.io", Subdomain: "alpha", LBIP: "1.2.3.4"}
|
||||
|
||||
if err := run(context.Background(), client, in, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
for i, rr := range fake.recorded() {
|
||||
if rr.AddDNSToCurrentSetting != "yes" {
|
||||
t.Errorf("request %d (%s): add_dns_to_current_setting=%q — MUST be 'yes' to avoid wiping zone", i, rr.Subdomain, rr.AddDNSToCurrentSetting)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_ValidationErrors — each missing input surfaces a clear error;
|
||||
// no Dynadot calls happen when validation fails (so the OpenTofu module
|
||||
// gets a fast, deterministic failure mode).
|
||||
func TestRun_ValidationErrors(t *testing.T) {
|
||||
withManagedDomain(t, "omani.works")
|
||||
srv, fake := newFakeDynadot()
|
||||
defer srv.Close()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in inputs
|
||||
wantSub string
|
||||
}{
|
||||
{"missing_api_key", inputs{APISecret: "s", Domain: "omani.works", Subdomain: "alpha", LBIP: "1.2.3.4"}, "DYNADOT_API_KEY"},
|
||||
{"missing_api_secret", inputs{APIKey: "k", Domain: "omani.works", Subdomain: "alpha", LBIP: "1.2.3.4"}, "DYNADOT_API_KEY"},
|
||||
{"missing_domain", inputs{APIKey: "k", APISecret: "s", Subdomain: "alpha", LBIP: "1.2.3.4"}, "DOMAIN"},
|
||||
{"missing_subdomain", inputs{APIKey: "k", APISecret: "s", Domain: "omani.works", LBIP: "1.2.3.4"}, "SUBDOMAIN"},
|
||||
{"missing_lb_ip", inputs{APIKey: "k", APISecret: "s", Domain: "omani.works", Subdomain: "alpha"}, "LB_IP"},
|
||||
{"unmanaged_domain", inputs{APIKey: "k", APISecret: "s", Domain: "rogue.example", Subdomain: "alpha", LBIP: "1.2.3.4"}, "managed-domain allowlist"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
before := len(fake.recorded())
|
||||
client := clientPointingAt(srv.URL, "k", "s")
|
||||
err := run(context.Background(), client, tc.in, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Errorf("err = %q; want it to contain %q", err.Error(), tc.wantSub)
|
||||
}
|
||||
// No Dynadot calls should have happened.
|
||||
if got := len(fake.recorded()); got != before {
|
||||
t.Errorf("validation error should not call Dynadot — request count went %d → %d", before, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_FailsFastOnDynadotError — when Dynadot rejects a record mid-loop,
|
||||
// run returns the error promptly and does NOT keep writing the remaining
|
||||
// records (so we don't leave a partially-applied zone behind).
|
||||
func TestRun_FailsFastOnDynadotError(t *testing.T) {
|
||||
srv, fake := newFakeDynadot()
|
||||
defer srv.Close()
|
||||
withManagedDomain(t, "openova.io")
|
||||
fake.mu.Lock()
|
||||
fake.failNth = 1
|
||||
fake.failMsg = "rate limited"
|
||||
fake.mu.Unlock()
|
||||
|
||||
client := clientPointingAt(srv.URL, "k", "s")
|
||||
in := inputs{APIKey: "k", APISecret: "s", Domain: "openova.io", Subdomain: "alpha", LBIP: "1.1.1.1"}
|
||||
err := run(context.Background(), client, in, &bytes.Buffer{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from Dynadot rejection, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "rate limited") {
|
||||
t.Errorf("error should surface Dynadot error string, got %q", err)
|
||||
}
|
||||
if got := len(fake.recorded()); got != 1 {
|
||||
t.Errorf("expected fail-fast after first request — got %d requests", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_NeverHitsRealDynadot is a paranoia-test: it proves the test
|
||||
// harness substitutes the endpoint correctly. If a future refactor breaks
|
||||
// the host rewrite, this test surfaces the regression by demonstrating
|
||||
// that any unintercepted call to api.dynadot.com would be visible to a
|
||||
// guarded transport.
|
||||
func TestRun_NeverHitsRealDynadot(t *testing.T) {
|
||||
withManagedDomain(t, "omani.works")
|
||||
|
||||
// 1. The happy path through clientPointingAt MUST hit the test server
|
||||
// and never the real Dynadot host.
|
||||
srv, fake := newFakeDynadot()
|
||||
defer srv.Close()
|
||||
client := clientPointingAt(srv.URL, "k", "s")
|
||||
in := inputs{APIKey: "k", APISecret: "s", Domain: "omani.works", Subdomain: "alpha", LBIP: "1.1.1.1"}
|
||||
if err := run(context.Background(), client, in, &bytes.Buffer{}); err != nil {
|
||||
t.Fatalf("happy path through test server failed (rewrite broken?): %v", err)
|
||||
}
|
||||
if got := len(fake.recorded()); got != 6 {
|
||||
t.Errorf("expected 6 requests on test server, got %d (rewrite may be broken)", got)
|
||||
}
|
||||
|
||||
// 2. A transport that refuses non-loopback hosts proves a missing
|
||||
// rewrite would fail-loud rather than silently hit api.dynadot.com.
|
||||
guarded := dynadot.New("k", "s")
|
||||
guarded.HTTP = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.HasPrefix(req.URL.Host, "127.0.0.1") && !strings.HasPrefix(req.URL.Host, "localhost") {
|
||||
return nil, errors.New("test attempted to reach " + req.URL.String() + " — would hit real Dynadot")
|
||||
}
|
||||
return nil, errors.New("unreachable")
|
||||
}), Timeout: 2 * time.Second}
|
||||
err := guarded.AddRecord(context.Background(), "omani.works", dynadot.Record{Subdomain: "x", Type: "A", Value: "9.9.9.9"})
|
||||
if err == nil || !strings.Contains(err.Error(), "would hit real Dynadot") {
|
||||
t.Errorf("guard transport should have refused real Dynadot host, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// roundTripperFunc adapts a function to the http.RoundTripper interface.
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
// TestReadInputsFromEnv reads the documented env-var contract and ensures
|
||||
// each value lands in the right struct field. Cheap belt-and-braces test
|
||||
// to catch a typo in the env-var name.
|
||||
func TestReadInputsFromEnv(t *testing.T) {
|
||||
t.Setenv("DYNADOT_API_KEY", "key123")
|
||||
t.Setenv("DYNADOT_API_SECRET", "sec456")
|
||||
t.Setenv("DOMAIN", "omani.works")
|
||||
t.Setenv("SUBDOMAIN", "omantel")
|
||||
t.Setenv("LB_IP", "203.0.113.42")
|
||||
|
||||
got := readInputsFromEnv()
|
||||
want := inputs{
|
||||
APIKey: "key123",
|
||||
APISecret: "sec456",
|
||||
Domain: "omani.works",
|
||||
Subdomain: "omantel",
|
||||
LBIP: "203.0.113.42",
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("readInputsFromEnv() = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
@ -512,6 +512,150 @@ func TestIsManagedDomain_FromLegacySingleDomain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagedDomains_TableDriven is the canonical multi-domain spec for #110.
|
||||
// Each row is one resolution-order scenario; together they assert that the
|
||||
// runtime configuration surface (DYNADOT_MANAGED_DOMAINS canonical →
|
||||
// DYNADOT_DOMAIN legacy → built-in defaults) behaves as documented in the
|
||||
// dynadot.go package comment.
|
||||
//
|
||||
// This complements the focused TestIsManagedDomain_* tests above by giving
|
||||
// a single row-per-scenario matrix that's easy to extend when (e.g.) a new
|
||||
// pool domain is added.
|
||||
func TestManagedDomains_TableDriven(t *testing.T) {
|
||||
type queryCase struct {
|
||||
domain string
|
||||
want bool
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
envMulti string
|
||||
envLegacy string
|
||||
wantSet []string // sorted
|
||||
queries []queryCase
|
||||
}{
|
||||
{
|
||||
name: "canonical_multi_domain_env_list",
|
||||
envMulti: "openova.io,omani.works,acme.io",
|
||||
wantSet: []string{"acme.io", "omani.works", "openova.io"},
|
||||
queries: []queryCase{
|
||||
{"openova.io", true},
|
||||
{"omani.works", true},
|
||||
{"acme.io", true},
|
||||
{"customer-byo.com", false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canonical_multi_domain_whitespace_separated",
|
||||
envMulti: "openova.io omani.works\tacme.io",
|
||||
wantSet: []string{"acme.io", "omani.works", "openova.io"},
|
||||
queries: []queryCase{
|
||||
{"acme.io", true},
|
||||
{"openova.io", true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "case_insensitive_lookup_and_storage",
|
||||
envMulti: "OPENOVA.IO, OMANI.WORKS",
|
||||
wantSet: []string{"omani.works", "openova.io"},
|
||||
queries: []queryCase{
|
||||
{"OPENOVA.IO", true},
|
||||
{"openova.io", true},
|
||||
{"Omani.Works", true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "whitespace_trimmed_in_query",
|
||||
envMulti: "openova.io",
|
||||
wantSet: []string{"openova.io"},
|
||||
queries: []queryCase{
|
||||
{" openova.io ", true},
|
||||
{"\topenova.io\n", true},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "legacy_single_domain_fallback",
|
||||
envMulti: "",
|
||||
envLegacy: "openova.io",
|
||||
wantSet: []string{"openova.io"},
|
||||
queries: []queryCase{
|
||||
{"openova.io", true},
|
||||
// legacy path is exact-set, NOT defaults-augmented:
|
||||
{"omani.works", false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "defaults_fallback_when_neither_env_set",
|
||||
envMulti: "",
|
||||
envLegacy: "",
|
||||
wantSet: []string{"omani.works", "openova.io"},
|
||||
queries: []queryCase{
|
||||
{"openova.io", true},
|
||||
{"omani.works", true},
|
||||
{"customer-byo.com", false},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canonical_takes_precedence_over_legacy",
|
||||
envMulti: "acme.io,beta.io",
|
||||
envLegacy: "openova.io", // ignored when DYNADOT_MANAGED_DOMAINS is non-empty
|
||||
wantSet: []string{"acme.io", "beta.io"},
|
||||
queries: []queryCase{
|
||||
{"acme.io", true},
|
||||
{"beta.io", true},
|
||||
{"openova.io", false},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("DYNADOT_MANAGED_DOMAINS", tc.envMulti)
|
||||
t.Setenv("DYNADOT_DOMAIN", tc.envLegacy)
|
||||
ResetManagedDomains()
|
||||
defer ResetManagedDomains()
|
||||
|
||||
gotSet := ManagedDomains()
|
||||
if len(gotSet) != len(tc.wantSet) {
|
||||
t.Fatalf("ManagedDomains() = %v, want %v", gotSet, tc.wantSet)
|
||||
}
|
||||
for i := range tc.wantSet {
|
||||
if gotSet[i] != tc.wantSet[i] {
|
||||
t.Errorf("ManagedDomains()[%d] = %q, want %q", i, gotSet[i], tc.wantSet[i])
|
||||
}
|
||||
}
|
||||
for _, q := range tc.queries {
|
||||
if got := IsManagedDomain(q.domain); got != q.want {
|
||||
t.Errorf("IsManagedDomain(%q) = %v, want %v", q.domain, got, q.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddSovereignRecords_AllUseAddDNSToCurrentSetting verifies the
|
||||
// "never wipe records" rule applies on every iteration of the loop —
|
||||
// regression guard against #110 / feedback_dynadot_dns.md.
|
||||
func TestAddSovereignRecords_AllUseAddDNSToCurrentSetting(t *testing.T) {
|
||||
srv, fake := newDynadotFakeServer()
|
||||
defer srv.Close()
|
||||
|
||||
c := newClientPointingAt(srv.URL, "k", "s")
|
||||
if err := c.AddSovereignRecords(context.Background(), "omani.works", "omantel", "10.20.30.40"); err != nil {
|
||||
t.Fatalf("AddSovereignRecords: %v", err)
|
||||
}
|
||||
got := fake.recorded()
|
||||
if len(got) != 6 {
|
||||
t.Fatalf("expected 6 records, got %d", len(got))
|
||||
}
|
||||
for i, rr := range got {
|
||||
if rr.AddDNSToCurrentSetting != "yes" {
|
||||
t.Errorf("request %d (%s) missing add_dns_to_current_setting=yes — would WIPE existing DNS", i, rr.Subdomain)
|
||||
}
|
||||
if rr.Command != "set_dns2" {
|
||||
t.Errorf("request %d wrong command: %q", i, rr.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitDomainsList covers the parser edge cases.
|
||||
func TestSplitDomainsList(t *testing.T) {
|
||||
cases := []struct {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user