Files
Josh Siviour c10b6732d5 feat: publish AWS service endpoint names in DNS and match gateway cert SANs (#1068)
* feat: publish AWS service endpoint names in DNS and match gateway cert SANs

ec2.<region>.spinifex.internal and its sibling AWS service names (sts,
elasticloadbalancing, ecs, eks, acm) NXDOMAINed because the internal
suffix was only ever a cert SAN and Host-header match, never a
northstar zone. BootstrapBaseZone now seeds it as a third zone, guarded
three-way so a suffix colliding with the default or internal domain
seeds no duplicate.

The reconciler publishes each name as a set-valued A record spanning
every cluster node's own gateway address, since a service endpoint's
natural target is every node rather than one resource address that
answers the same from anywhere. ConfigArr.SetRecordSet backs this in
northstar; UpsertRecord's single-record semantics are untouched for
EC2/ELB/EKS/RDS. A non-IP gateway host is skipped rather than written
as a malformed record, and prune authority for the new class is scoped
to its own zone so it can never act on the base or private zone.

admin.AWSGWServiceDNSNames and handlers/dns.ServiceEndpointNames both
consume config.AWSGWServiceNames, so the cert SANs and the DNS records
they must match can never drift apart.

Depends on github.com/mulgadc/northstar#31 (ConfigArr.SetRecordSet);
go.mod pins that branch's commit until it merges and tags.

* refactor(config): rename AWS.InternalSuffix to AWS.ServicesDomain

ServicesDomain names the value IMDS serves at
/latest/meta-data/services/domain, the slot an AWS SDK substitutes
into {service}.{region}.{domain} for default endpoint resolution.
The old name read as a sibling of Northstar's compute.internal while
meaning something entirely different, which invited confusion.

The default changes from spinifex.internal to services.internal, role-
named like compute.internal (per-instance hostnames) now that
services.internal holds service endpoints instead.

Deployed clusters have internal_suffix baked into spinifex.toml and
their gateway cert SANs built from it, so LoadConfig now reads
services_domain, falls back to the legacy internal_suffix key when
that is unset, and only then applies the new default. Every consumer
of the field and the renamed DefaultAWSServicesDomain constant is
updated accordingly, including the spx admin init template and the
e2e test harness's own config loader.

* chore: pin northstar to the merged set-valued write path
2026-09-17 13:46:33 +10:00

135 lines
4.1 KiB
Go

//go:build e2e
package harness
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/config"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go/jetstream"
"github.com/spf13/viper"
)
// ControlPlaneMgmtIP resolves the EKS control-plane VM's br-mgmt address, read
// directly from the NATS KV record. Tests reach the apiserver via the published
// cluster endpoint; this is the out-of-band path to the VM itself.
func ControlPlaneMgmtIP(t *testing.T, env *Env, accountID, clusterName string) string {
t.Helper()
host, token, ca := natsConn(t, env)
nc, err := utils.ConnectNATS(host, token, ca)
if err != nil {
t.Fatalf("connect NATS %s: %v", host, err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatalf("jetstream: %v", err)
}
kv, err := js.KeyValue(t.Context(), handlers_eks.AccountBucketName(accountID))
if err != nil {
t.Fatalf("open EKS account KV for %s: %v", accountID, err)
}
meta, err := handlers_eks.GetClusterMeta(t.Context(), kv, clusterName)
if err != nil {
t.Fatalf("get cluster meta %s: %v", clusterName, err)
}
if meta.ControlPlaneMgmtIP == "" {
t.Fatalf("cluster %s has no ControlPlaneMgmtIP recorded", clusterName)
}
t.Logf("control-plane mgmt IP: %s", meta.ControlPlaneMgmtIP)
return meta.ControlPlaneMgmtIP
}
// natsConn resolves NATS host/token/CA. SPINIFEX_NATS_* env vars win; otherwise
// reads from spinifex.toml.
func natsConn(t *testing.T, env *Env) (host, token, ca string) {
t.Helper()
host = os.Getenv("SPINIFEX_NATS_URL")
token = os.Getenv("SPINIFEX_NATS_TOKEN")
ca = os.Getenv("SPINIFEX_NATS_CA")
if host != "" {
return dialableNATSHost(host), token, ca
}
cfgPath := filepath.Join(env.ConfigDir, "spinifex.toml")
cc, err := loadClusterConfig(cfgPath)
if err != nil {
t.Fatalf("load %s for NATS settings: %v", cfgPath, err)
}
node := nodeConfig(cc)
if node == nil {
t.Fatalf("no node stanza with a NATS host in %s", cfgPath)
}
return dialableNATSHost(node.NATS.Host), node.NATS.ACL.Token, node.NATS.CACert
}
// loadClusterConfig reads spinifex.toml via a private viper instance with no
// AutomaticEnv, so the harness's own SPINIFEX_* vars (the multinode workflow
// exports SPINIFEX_NODES) cannot shadow the [nodes.*] table and blank the parse.
func loadClusterConfig(path string) (*config.ClusterConfig, error) {
v := viper.New()
v.SetConfigFile(path)
v.SetConfigType("toml")
if err := v.ReadInConfig(); err != nil {
return nil, err
}
return unmarshalClusterConfig(v)
}
func loadClusterConfigBytes(data []byte) (*config.ClusterConfig, error) {
v := viper.New()
v.SetConfigType("toml")
if err := v.ReadConfig(bytes.NewReader(data)); err != nil {
return nil, err
}
return unmarshalClusterConfig(v)
}
func unmarshalClusterConfig(v *viper.Viper) (*config.ClusterConfig, error) {
var cc config.ClusterConfig
if err := v.Unmarshal(&cc); err != nil {
return nil, err
}
// Mirror config.LoadConfig's legacy fallback: a cluster upgraded from an
// older release may still carry internal_suffix rather than
// services_domain, and this harness has to see the same value the daemon
// resolved rather than silently disagreeing with it.
if cc.AWS.ServicesDomain == "" {
if legacy := strings.TrimSpace(v.GetString("aws.internal_suffix")); legacy != "" {
cc.AWS.ServicesDomain = legacy
}
}
return &cc, nil
}
// dialableNATSHost rewrites a wildcard bind address to loopback. NATS.Host is a
// bind address (commonly 0.0.0.0:4222); the TLS serving cert SANs the loopback
// and real node IPs but not 0.0.0.0, so dial 127.0.0.1 instead.
func dialableNATSHost(host string) string {
return strings.Replace(host, "0.0.0.0", "127.0.0.1", 1)
}
// nodeConfig returns the local node's Config (cc.Node), falling back to the
// first node carrying a NATS host.
func nodeConfig(cc *config.ClusterConfig) *config.Config {
if cc == nil {
return nil
}
if n, ok := cc.Nodes[cc.Node]; ok && n.NATS.Host != "" {
return &n
}
for _, n := range cc.Nodes {
if n.NATS.Host != "" {
return &n
}
}
return nil
}