Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/cli/datumctl-alb.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/cmd/alb/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
105 changes: 105 additions & 0 deletions internal/cmd/alb/dnsrecords.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
113 changes: 113 additions & 0 deletions internal/cmd/alb/dnsrecords_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion internal/cmd/alb/spec/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading