From 2786fbb93aaac59d0612b3447929b55ee1c338fb Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Fri, 2 Oct 2026 16:57:31 -0500 Subject: [PATCH] feat: show records to publish in alb describe The plugin shared the operator's hostname check, so it refused wildcards before the platform could judge them, and describe gave no way to see the DNS records a hostname still waits on. hostname add now accepts a single leading wildcard label, and describe prints the records still to publish from the load balancer's status. Key changes: - accept "*." hostnames locally; the platform decides whether they are enabled and proven - print each refused hostname's reason beneath it - list missing user records once each, with the certificate CNAME, and platform records still waiting on Datum DNS apart - point the "DNS not delegated" hint at the certificate record, so the certificate can issue at the provider that serves the domain today - document wildcards and the records list --- docs/cli/datumctl-alb.md | 18 +++++ internal/cmd/alb/describe.go | 4 + internal/cmd/alb/dnsrecords.go | 105 ++++++++++++++++++++++++++ internal/cmd/alb/dnsrecords_test.go | 113 ++++++++++++++++++++++++++++ internal/cmd/alb/spec/proxy.go | 4 +- 5 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/alb/dnsrecords.go create mode 100644 internal/cmd/alb/dnsrecords_test.go diff --git a/docs/cli/datumctl-alb.md b/docs/cli/datumctl-alb.md index 6ca3aa69..175f337f 100644 --- a/docs/cli/datumctl-alb.md +++ b/docs/cli/datumctl-alb.md @@ -137,6 +137,24 @@ datumctl alb hostname remove my-app app.example.com The generated hostname is assigned by the platform and shown by `describe`. Custom hostnames must be unique on the platform, and ownership of their domain is verified separately — this plugin does not create or read the domain, so use `datumctl get domains` to see verification state. `hostname remove` does not ask for confirmation. `list` shows the generated hostname and a `CUSTOM` summary (first attached name, `+N` when there are more); `describe` prints each custom hostname with available / DNS / cert status. +### Wildcards + +```sh +datumctl alb hostname add my-app '*.s3.example.com' +datumctl alb describe my-app +``` + +Quote the wildcard so the shell leaves the `*` alone. A wildcard serves every name beneath its base and reserves them for your project; its certificate covers names one label down. The platform admits it only where wildcards are enabled and only once `s3.example.com`, or a parent, is verified by its DNS TXT record — a domain verified over HTTP or through a Datum DNS zone does not count. `hostname add` does not wait: a refused wildcard shows its reason under the hostname in `describe`. + +`describe` ends with the DNS records still to publish, read from the load balancer's status: the routing CNAME, the `_acme-challenge` certificate CNAME, and the domain's ownership TXT, each once. Records the platform publishes in a Datum DNS zone are listed apart, and a domain whose registry does not yet delegate to Datum DNS gets a `DNS not delegated` hint that points at the certificate record. + +``` +DNS records to publish: + NAME TYPE CONTENT PURPOSE + *.s3.example.com CNAME ruth-fourth-hrkgk.datumproxy.net Routing + _acme-challenge.s3.example.com CNAME k3f9q2x7.acme-dns.example.net Certificate +``` + ## Access logs ```sh diff --git a/internal/cmd/alb/describe.go b/internal/cmd/alb/describe.go index ba241520..5e3e85ee 100644 --- a/internal/cmd/alb/describe.go +++ b/internal/cmd/alb/describe.go @@ -81,10 +81,14 @@ func runDescribe(cmd *cobra.Command, args []string) error { util.ConditionStatus(hs.Conditions, networkingv1alpha.HostnameConditionDNSRecordProgrammed), util.ConditionStatus(hs.Conditions, networkingv1alpha.HostnameConditionCertificateReady), ) + if problem := hostnameProblem(hs); problem != "" { + _, _ = fmt.Fprintf(out, " %s\n", problem) + } continue } _, _ = fmt.Fprintf(out, " %s\n", name) } + writePendingRecords(out, collectPendingRecords(proxy, hostnames)) } if connector := spec.ConnectorName(proxy); connector != "" { _, _ = fmt.Fprintf(out, "Connector: %s\n", connector) diff --git a/internal/cmd/alb/dnsrecords.go b/internal/cmd/alb/dnsrecords.go new file mode 100644 index 00000000..6b9b68d7 --- /dev/null +++ b/internal/cmd/alb/dnsrecords.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package alb + +import ( + "fmt" + "io" + "strings" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/cmd/alb/util" +) + +type pendingRecords struct { + toPublish []networkingv1alpha.HostnameDNSRecord + awaitingDatum []networkingv1alpha.HostnameDNSRecord + undelegated []string +} + +// collectPendingRecords reads the records each custom hostname still needs +// from its status, each record once however many hostnames share it. +func collectPendingRecords(proxy *networkingv1alpha.HTTPProxy, hostnames []string) pendingRecords { + var pending pendingRecords + statusByName := map[string]networkingv1alpha.HostnameStatus{} + for _, hs := range proxy.Status.HostnameStatuses { + statusByName[hs.Hostname] = hs + } + + seen := map[string]bool{} + for _, name := range hostnames { + hs, ok := statusByName[name] + if !ok { + continue + } + if c := apimeta.FindStatusCondition(hs.Conditions, networkingv1alpha.HostnameConditionDNSRecordProgrammed); c != nil && + c.Status == metav1.ConditionFalse && c.Reason == networkingv1alpha.DNSRecordReasonDNSAuthorityMissing { + pending.undelegated = append(pending.undelegated, name) + } + for _, record := range hs.DNSRecords { + if record.State != networkingv1alpha.HostnameDNSRecordMissing { + continue + } + key := record.Name + "|" + record.Type + "|" + record.Content + if seen[key] { + continue + } + seen[key] = true + if record.ManagedBy == networkingv1alpha.HostnameDNSRecordManagedByPlatform { + pending.awaitingDatum = append(pending.awaitingDatum, record) + } else { + pending.toPublish = append(pending.toPublish, record) + } + } + } + return pending +} + +func writePendingRecords(out io.Writer, pending pendingRecords) { + if len(pending.toPublish) > 0 { + _, _ = fmt.Fprintln(out, "DNS records to publish:") + tw := util.NewTabWriter(out) + _, _ = fmt.Fprintln(tw, " NAME\tTYPE\tCONTENT\tPURPOSE") + for _, r := range pending.toPublish { + _, _ = fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", r.Name, r.Type, r.Content, r.Purpose) + } + _ = tw.Flush() + } + + for _, r := range pending.awaitingDatum { + _, _ = fmt.Fprintf(out, "Waiting on Datum DNS: %s %s %s (%s)\n", r.Name, r.Type, r.Content, r.Purpose) + } + + if len(pending.undelegated) == 0 { + return + } + var certificate *networkingv1alpha.HostnameDNSRecord + for i := range pending.toPublish { + if pending.toPublish[i].Purpose == networkingv1alpha.HostnameDNSRecordPurposeCertificate { + certificate = &pending.toPublish[i] + break + } + } + names := strings.Join(pending.undelegated, ", ") + if certificate != nil { + _, _ = fmt.Fprintf(out, "DNS not delegated: Datum DNS does not serve %s yet. Publish the certificate record %s at the DNS provider that does serve it, so the certificate issues before traffic moves.\n", + names, certificate.Name) + return + } + _, _ = fmt.Fprintf(out, "DNS not delegated: Datum DNS does not serve %s yet. Point the domain's NS records at its Datum DNS zone (see `datumctl dns`), or publish the routing record at the DNS provider that serves it.\n", + names) +} + +// hostnameProblem returns why a custom hostname is held back, when its +// ownership or claim was refused. +func hostnameProblem(hs networkingv1alpha.HostnameStatus) string { + for _, condType := range []string{networkingv1alpha.HostnameConditionVerified, networkingv1alpha.HostnameConditionAvailable} { + if c := apimeta.FindStatusCondition(hs.Conditions, condType); c != nil && c.Status == metav1.ConditionFalse && c.Message != "" { + return c.Message + } + } + return "" +} diff --git a/internal/cmd/alb/dnsrecords_test.go b/internal/cmd/alb/dnsrecords_test.go new file mode 100644 index 00000000..41e5fe68 --- /dev/null +++ b/internal/cmd/alb/dnsrecords_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +package alb + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha" + "go.datum.net/network-services-operator/internal/cmd/alb/spec" +) + +func record(name, rrType, content string, purpose networkingv1alpha.HostnameDNSRecordPurpose, by networkingv1alpha.HostnameDNSRecordManager, state networkingv1alpha.HostnameDNSRecordState) networkingv1alpha.HostnameDNSRecord { + return networkingv1alpha.HostnameDNSRecord{Name: name, Type: rrType, Content: content, Purpose: purpose, ManagedBy: by, State: state} +} + +func wildcardProxy() *networkingv1alpha.HTTPProxy { + ownership := record("datum-custom-hostname.example.com", "TXT", "4f1c-token", networkingv1alpha.HostnameDNSRecordPurposeOwnership, networkingv1alpha.HostnameDNSRecordManagedByUser, networkingv1alpha.HostnameDNSRecordMissing) + return &networkingv1alpha.HTTPProxy{ + Spec: networkingv1alpha.HTTPProxySpec{Hostnames: []gatewayv1.Hostname{"*.s3.example.com", "www.example.com"}}, + Status: networkingv1alpha.HTTPProxyStatus{HostnameStatuses: []networkingv1alpha.HostnameStatus{ + { + Hostname: "*.s3.example.com", + Conditions: []metav1.Condition{{ + Type: networkingv1alpha.HostnameConditionDNSRecordProgrammed, Status: metav1.ConditionFalse, Reason: networkingv1alpha.DNSRecordReasonDNSAuthorityMissing, + }}, + DNSRecords: []networkingv1alpha.HostnameDNSRecord{ + record("*.s3.example.com", "CNAME", "ruth-fourth-hrkgk.datumproxy.net", networkingv1alpha.HostnameDNSRecordPurposeRouting, networkingv1alpha.HostnameDNSRecordManagedByUser, networkingv1alpha.HostnameDNSRecordMissing), + record("_acme-challenge.s3.example.com", "CNAME", "k3f9q2x7.acme-dns.example.net", networkingv1alpha.HostnameDNSRecordPurposeCertificate, networkingv1alpha.HostnameDNSRecordManagedByUser, networkingv1alpha.HostnameDNSRecordMissing), + ownership, + }, + }, + { + Hostname: "www.example.com", + DNSRecords: []networkingv1alpha.HostnameDNSRecord{ + record("www.example.com", "CNAME", "ruth-fourth-hrkgk.datumproxy.net", networkingv1alpha.HostnameDNSRecordPurposeRouting, networkingv1alpha.HostnameDNSRecordManagedByUser, networkingv1alpha.HostnameDNSRecordPresent), + ownership, + }, + }, + }}, + } +} + +func TestPendingRecordsListsWhatIsLeftOnce(t *testing.T) { + proxy := wildcardProxy() + var out bytes.Buffer + writePendingRecords(&out, collectPendingRecords(proxy, spec.Hostnames(proxy))) + got := out.String() + + assert.Contains(t, got, "DNS records to publish:") + assert.Contains(t, got, "_acme-challenge.s3.example.com") + assert.Contains(t, got, "k3f9q2x7.acme-dns.example.net") + assert.Contains(t, got, "Certificate") + assert.Equal(t, 1, bytes.Count(out.Bytes(), []byte("datum-custom-hostname.example.com")), "a record shared by two hostnames is listed once") + assert.NotContains(t, got, "www.example.com ", "a present record is not listed") +} + +func TestDNSNotDelegatedPointsAtTheCertificateRecord(t *testing.T) { + proxy := wildcardProxy() + var out bytes.Buffer + writePendingRecords(&out, collectPendingRecords(proxy, spec.Hostnames(proxy))) + + assert.Contains(t, out.String(), "DNS not delegated: Datum DNS does not serve *.s3.example.com yet. Publish the certificate record _acme-challenge.s3.example.com") +} + +func TestDNSNotDelegatedWithoutACertificateRecord(t *testing.T) { + proxy := wildcardProxy() + proxy.Status.HostnameStatuses[0].DNSRecords = proxy.Status.HostnameStatuses[0].DNSRecords[:1] + var out bytes.Buffer + writePendingRecords(&out, collectPendingRecords(proxy, spec.Hostnames(proxy))) + + assert.Contains(t, out.String(), "see `datumctl dns`") +} + +func TestPlatformRecordsAreNotTheUsersToPublish(t *testing.T) { + proxy := &networkingv1alpha.HTTPProxy{ + Spec: networkingv1alpha.HTTPProxySpec{Hostnames: []gatewayv1.Hostname{"www.example.com"}}, + Status: networkingv1alpha.HTTPProxyStatus{HostnameStatuses: []networkingv1alpha.HostnameStatus{{ + Hostname: "www.example.com", + DNSRecords: []networkingv1alpha.HostnameDNSRecord{ + record("www.example.com", "CNAME", "ruth-fourth-hrkgk.datumproxy.net", networkingv1alpha.HostnameDNSRecordPurposeRouting, networkingv1alpha.HostnameDNSRecordManagedByPlatform, networkingv1alpha.HostnameDNSRecordMissing), + }, + }}}, + } + var out bytes.Buffer + writePendingRecords(&out, collectPendingRecords(proxy, spec.Hostnames(proxy))) + + assert.NotContains(t, out.String(), "DNS records to publish") + assert.Contains(t, out.String(), "Waiting on Datum DNS: www.example.com CNAME") +} + +func TestHostnameProblemExplainsARefusedWildcard(t *testing.T) { + hs := networkingv1alpha.HostnameStatus{Conditions: []metav1.Condition{{ + Type: networkingv1alpha.HostnameConditionVerified, Status: metav1.ConditionFalse, + Reason: networkingv1alpha.HostnameVerifiedReasonDNSVerificationRequired, Message: `The wildcard "*.s3.example.com" needs DNS proof`, + }}} + assert.Equal(t, `The wildcard "*.s3.example.com" needs DNS proof`, hostnameProblem(hs)) + assert.Empty(t, hostnameProblem(networkingv1alpha.HostnameStatus{})) +} + +func TestHostnameAddAcceptsWildcards(t *testing.T) { + updated, err := spec.AddHostname(&networkingv1alpha.HTTPProxy{ObjectMeta: metav1.ObjectMeta{Name: "s3"}}, "*.S3.example.com") + if assert.NoError(t, err) { + assert.Equal(t, []gatewayv1.Hostname{"*.s3.example.com"}, updated.Spec.Hostnames) + } + + _, err = spec.AddHostname(&networkingv1alpha.HTTPProxy{ObjectMeta: metav1.ObjectMeta{Name: "s3"}}, "*.*.example.com") + assert.Error(t, err) +} diff --git a/internal/cmd/alb/spec/proxy.go b/internal/cmd/alb/spec/proxy.go index e43b6b04..c3c2c8a9 100644 --- a/internal/cmd/alb/spec/proxy.go +++ b/internal/cmd/alb/spec/proxy.go @@ -224,7 +224,9 @@ func toHostnames(hostnames []string) []gatewayv1.Hostname { } func validateProxy(proxy *networkingv1alpha.HTTPProxy) error { - errs := validation.ValidateHTTPProxy(proxy, validation.HTTPProxyValidationOptions{}) + errs := validation.ValidateHTTPProxy(proxy, validation.HTTPProxyValidationOptions{ + Hostnames: validation.HostnameOptions{AllowWildcards: true}, + }) if len(errs) == 0 { return nil }