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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ It is useful when platform teams want to give application teams a safe DNS workf
dns-api is currently focused on:

- Amazon Route 53 public hosted zones, and adoption of existing private hosted zones
- Optional Route 53 negative-cache TTL management (SOA MINIMUM) for created and adopted hosted zones
- Cloudflare full zones
- External verification of customer-managed DNS (opt-in, credentialless)
- [Hosted authoritative DNS](./docs/design/hosted-provider.md) served from Kubernetes snapshots (opt-in, credentialless)
Expand Down
12 changes: 12 additions & 0 deletions app/operator/config/provider/route53_extensions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ spec:
enum:
- Allow
- Deny
negativeCacheTTLSeconds:
type: integer
format: int32
minimum: 0
maximum: 2147483647
description: Sets the SOA MINIMUM (RFC 2308 negative caching TTL) of hosted zones created or adopted by this class. Absent leaves the SOA untouched.
tags:
type: object
description: Additional Route 53 tags applied to hosted zones created by this ZoneClass.
Expand Down Expand Up @@ -93,6 +99,12 @@ spec:
enum:
- Public
- Private
negativeCacheTTLSeconds:
type: integer
format: int32
minimum: 0
maximum: 2147483647
description: Last observed SOA MINIMUM when managed by the ZoneClass; zero is omitted.
recordSet:
supportedTypes:
- A
Expand Down
12 changes: 12 additions & 0 deletions deploy/charts/dns-api/templates/provider-route53.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ spec:
enum:
- Allow
- Deny
negativeCacheTTLSeconds:
type: integer
format: int32
minimum: 0
maximum: 2147483647
description: Sets the SOA MINIMUM (RFC 2308 negative caching TTL) of hosted zones created or adopted by this class. Absent leaves the SOA untouched.
tags:
type: object
description: Additional Route 53 tags applied to hosted zones created by this ZoneClass.
Expand Down Expand Up @@ -94,6 +100,12 @@ spec:
enum:
- Public
- Private
negativeCacheTTLSeconds:
type: integer
format: int32
minimum: 0
maximum: 2147483647
description: Last observed SOA MINIMUM when managed by the ZoneClass; zero is omitted.
recordSet:
supportedTypes:
- A
Expand Down
20 changes: 19 additions & 1 deletion docs/design/route53-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ Route 53 `ZoneClass.spec.parameters` fields:
- `zoneCreationPolicy`: whether new public hosted zone creation is allowed. Values: `Create` or `Deny`. Default: `Create` for Public, `Deny` for Private. Private permits only absent or `Deny`.
- `zoneDeletionPolicy`: whether the hosted zone is deleted when the `Zone` is deleted. Values: `Delete` or `Retain`. Default: `Retain`. Private permits only absent or `Retain`.
- `sameNameZonePolicy`: how to handle existing same-name hosted zones in the Route 53 account. Values: `Allow` or `Deny`. Default: `Deny`.
- `negativeCacheTTLSeconds`: optional SOA MINIMUM (negative caching TTL), integer `0..2147483647` seconds. Applies to created and adopted public or private zones; absent leaves the SOA untouched.
- `tags`: Route 53 tags added to created hosted zones.

Route 53 `Provider` schema descriptions for `zoneClass.schemas.parameters`:
Expand All @@ -127,11 +128,26 @@ Route 53 `Provider` schema descriptions for `zoneClass.schemas.parameters`:
| `zoneCreationPolicy` | `Controls whether dns-api may create a Route 53 public hosted zone when a Zone does not adopt an existing hosted zone. Defaults to Create for Public and Deny for Private.` |
| `zoneDeletionPolicy` | `Controls whether dns-api deletes the Route 53 hosted zone when the Kubernetes Zone is deleted. Private requires Retain.` |
| `sameNameZonePolicy` | `Controls whether dns-api may create a new Route 53 hosted zone when another same-name hosted zone already exists.` |
| `negativeCacheTTLSeconds` | `Sets the SOA MINIMUM (RFC 2308 negative caching TTL) of hosted zones created or adopted by this class. Absent leaves the SOA untouched.` |
| `tags` | `Additional Route 53 tags applied to hosted zones created by this ZoneClass.` |
| `tags.<key>` | `One Route 53 tag value applied to hosted zones created by this ZoneClass.` |

AWS credentials themselves are not stored in `ZoneClass.spec.parameters`. `ZoneClass.spec.identityRef` selects the AWS account and assume role chain through `Route53Identity`.

### Negative cache TTL

Resolvers cache NXDOMAIN and NODATA answers using the SOA MINIMUM (bounded by the SOA RRset TTL under RFC 2308). A low negative-cache TTL lets records written by dns-api become visible promptly after a previously cached negative answer expires. Set the platform ZoneClass parameter, for example:

```yaml
spec:
parameters:
negativeCacheTTLSeconds: 60
```

When set, dns-api owns only the apex SOA MINIMUM, for both created and adopted hosted zones, including private zones. It reads the SOA once on each normal zone observation and silently corrects drift with one UPSERT, preserving the RRset TTL and MNAME, RNAME, SERIAL, REFRESH, RETRY, and EXPIRE. Omitting the parameter performs no SOA read or write and does not restore an earlier value. Already cached negative answers retain their original expiration; lowering MINIMUM does not flush resolver caches.

The submitted change is stored in `ZoneUnit.status.provider.state.pendingHostedZoneChange` with operation `NegativeCacheTTL`. Like other pending hosted-zone changes, it blocks child RecordSet reconciliation with `Programmed=False`, reason `ProviderChangePending`, until the existing change poll observes `INSYNC`, clears the pending state, and requeues for fresh observation. Public `negativeCacheTTLSeconds` is the last observed MINIMUM, not the submitted desired value; zero is omitted. Provider errors use the normal provider-error classification. A missing or malformed SOA sets `Programmed=False`, reason `ExternalResourceMismatch`, without exposing its raw value.

### Private hosted zones

With `zoneType: Private`, dns-api adopts an existing private hosted zone through `Zone.spec.adoption.hostedZoneId` or `Zone.spec.adoption.byName: true` and manages its RecordSets. dns-api never creates or deletes private hosted zones and never manages their VPC associations. Create the private hosted zone and configure its VPC associations outside dns-api before adoption.
Expand Down Expand Up @@ -228,13 +244,15 @@ Route 53 `Zone.status.provider.data` fields:

- `hostedZoneID`: Route 53 hosted zone ID in `Z...` form.
- `zoneType`: observed Route 53 hosted zone type, `Public` or `Private`.
- `negativeCacheTTLSeconds`: last observed SOA MINIMUM when managed by the ZoneClass, in seconds. Zero is omitted; while a change is pending this remains the previous observed value.

Route 53 `Provider` schema descriptions for `zone.schemas.statusProviderData`:

| Field | `description` |
| --- | --- |
| `hostedZoneID` | `Route 53 hosted zone ID managed for this Zone.` |
| `zoneType` | `Observed Route 53 hosted zone type.` |
| `negativeCacheTTLSeconds` | `Last observed SOA MINIMUM when managed by the ZoneClass; zero is omitted.` |

Pending Route 53 hosted-zone and record-set changes are stored in `ZoneUnit.status.provider.state`, not in public claim status. Pending fields are kept only while Route 53 reports `PENDING`. Completed `INSYNC` changes are not retained as history. `Programmed=True` is set after pending fields are removed and provider state is re-observed to match `ZoneUnit.spec`.

Expand All @@ -245,7 +263,7 @@ Route 53-specific Zone Events:
| type | reason | related | When emitted |
| --- | --- | --- | --- |
| `Normal` | `Route53HostedZoneChangeSubmitted` | none | `CreateHostedZone` or `DeleteHostedZone` change submitted. |
| `Normal` | `Route53HostedZoneChangeInSync` | none | Route 53 hosted zone create/delete change becomes `INSYNC`. |
| `Normal` | `Route53HostedZoneChangeInSync` | none | A hosted-zone change, including `NegativeCacheTTL`, becomes `INSYNC`. |

## Route 53 RecordSet Provider Schema

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ func route53ZoneClassParameters(zoneClass *dnsv1alpha1.ZoneClass) (*route53v1alp
if err := json.Unmarshal(zoneClass.Spec.Parameters.Raw, &params); err != nil {
return nil, fmt.Errorf("parameters must match Route 53 ZoneClass schema: %w", err)
}
if params.NegativeCacheTTLSeconds != nil && *params.NegativeCacheTTLSeconds < 0 {
return nil, errors.New("negativeCacheTTLSeconds must be between 0 and 2147483647")
}
return &params, nil
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package route53

import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
"text/template"

corewebhook "github.com/appthrust/dns-api/internal/go/core/webhook"
dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1"
admissionv1 "k8s.io/api/admission/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
"sigs.k8s.io/yaml"
)

func TestNegativeCacheTTLProviderSchemas(t *testing.T) {
root := filepath.Join("..", "..", "..", "..", "..", "..")
providers := map[string]*dnsv1alpha1.Provider{}
for name, path := range map[string]string{
"built-in": "app/operator/config/provider/route53_extensions.yaml",
"chart": "deploy/charts/dns-api/templates/provider-route53.yaml",
} {
data, err := os.ReadFile(filepath.Join(root, path))
if err != nil {
t.Fatal(err)
}
tmpl, err := template.New(name).Parse(string(data))
if err != nil {
t.Fatal(err)
}
var rendered bytes.Buffer
if err := tmpl.Execute(&rendered, map[string]any{
"Values": map[string]any{"providers": map[string]any{"route53": map[string]any{"enabled": true}}},
}); err != nil {
t.Fatal(err)
}
provider := &dnsv1alpha1.Provider{}
if err := yaml.Unmarshal(rendered.Bytes(), provider); err != nil {
t.Fatal(err)
}
providers[name] = provider
}
builtIn, chart := providers["built-in"].Spec.Versions[0], providers["chart"].Spec.Versions[0]
if !reflect.DeepEqual(builtIn.ZoneClass.Schemas.Parameters, chart.ZoneClass.Schemas.Parameters) ||
!reflect.DeepEqual(builtIn.Zone.Schemas.StatusProviderData, chart.Zone.Schemas.StatusProviderData) {
t.Fatal("built-in and chart ZoneClass parameters or Zone status schemas differ")
}
for name, provider := range providers {
t.Run(name, func(t *testing.T) {
t.Run("observed status", func(t *testing.T) {
scheme := testScheme(t)
k8sClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(provider.DeepCopy()).Build()
validator := corewebhook.NewCoreValidator(k8sClient, scheme)
for _, value := range []string{"0", "60", "2147483647", "-1", "2147483648"} {
zone := &dnsv1alpha1.Zone{
TypeMeta: metav1.TypeMeta{APIVersion: "dns.appthrust.io/v1alpha1", Kind: "Zone"},
Spec: dnsv1alpha1.ZoneSpec{Provider: dnsv1alpha1.ProviderReference{Name: provider.Name, Version: "v1alpha1"}},
Status: dnsv1alpha1.ZoneStatus{Provider: &dnsv1alpha1.ProviderStatus{
Data: runtime.RawExtension{Raw: []byte(`{"negativeCacheTTLSeconds":` + value + `}`)},
}},
}
body, err := json.Marshal(zone)
if err != nil {
t.Fatal(err)
}
response := validator.Handle(context.Background(), admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
Kind: metav1.GroupVersionKind{Group: "dns.appthrust.io", Version: "v1alpha1", Kind: "Zone"},
Operation: admissionv1.Update, SubResource: "status",
Object: runtime.RawExtension{Raw: body},
}})
valid := value != "-1" && value != "2147483648"
if response.Allowed != valid {
t.Fatalf("status %s admission = %v, want %v; result %#v", value, response.Allowed, valid, response.Result)
}
}
})
for _, test := range []struct {
name string
parameters string
valid bool
}{
{name: "absent", parameters: `{}`, valid: true},
{name: "sixty", parameters: `{"negativeCacheTTLSeconds":60}`, valid: true},
{name: "zero", parameters: `{"negativeCacheTTLSeconds":0}`, valid: true},
{name: "maximum", parameters: `{"negativeCacheTTLSeconds":2147483647}`, valid: true},
{name: "private", parameters: `{"zoneType":"Private","negativeCacheTTLSeconds":60}`, valid: true},
{name: "negative", parameters: `{"negativeCacheTTLSeconds":-1}`},
{name: "above maximum", parameters: `{"negativeCacheTTLSeconds":2147483648}`},
{name: "fractional", parameters: `{"negativeCacheTTLSeconds":0.5}`},
{name: "string", parameters: `{"negativeCacheTTLSeconds":"60"}`},
} {
t.Run(test.name, func(t *testing.T) {
ctx := context.Background()
class := route53ZoneClass("platform", "route53", nil)
class.TypeMeta = metav1.TypeMeta{APIVersion: "dns.appthrust.io/v1alpha1", Kind: "ZoneClass"}
class.Spec.Parameters = runtime.RawExtension{Raw: []byte(test.parameters)}
scheme := testScheme(t)
k8sClient := fake.NewClientBuilder().WithScheme(scheme).
WithObjects(provider.DeepCopy(), class, acceptedRoute53Identity("platform", "route53-dev")).
WithStatusSubresource(&dnsv1alpha1.ZoneClass{}).Build()
body, err := json.Marshal(class)
if err != nil {
t.Fatal(err)
}
response := corewebhook.NewCoreValidator(k8sClient, scheme).Handle(ctx, admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{
Kind: metav1.GroupVersionKind{Group: "dns.appthrust.io", Version: "v1alpha1", Kind: "ZoneClass"},
Operation: admissionv1.Create,
Object: runtime.RawExtension{Raw: body},
}})
if response.Allowed != test.valid {
t.Fatalf("admission allowed = %v, want %v; result %#v", response.Allowed, test.valid, response.Result)
}
if _, err := (&ZoneClassReconciler{Client: k8sClient}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(class)}); err != nil {
t.Fatal(err)
}
if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(class), class); err != nil {
t.Fatal(err)
}
if test.valid {
assertCondition(t, class.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted")
} else {
assertCondition(t, class.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "InvalidProviderPayload")
}
})
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ type Provider interface {
GetChange(ctx context.Context, id string) (*route53v1alpha1.Route53Change, error)
DeleteHostedZone(ctx context.Context, id string) (*route53v1alpha1.Route53Change, error)
TagHostedZone(ctx context.Context, id string, tags map[string]string) error
GetRecordSet(ctx context.Context, hostedZoneID, recordName string, recordType dnsv1alpha1.RecordType) (*RecordSetResource, error)
ListRecordSets(ctx context.Context, hostedZoneID string) ([]RecordSetResource, error)
ChangeRecordSets(ctx context.Context, hostedZoneID string, changes []RecordSetChange) (*route53v1alpha1.Route53Change, error)
}
Expand Down
80 changes: 80 additions & 0 deletions internal/go/providers/route53/controllers/zoneunit/soa.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package route53

import (
"context"
"errors"
"strconv"
"strings"

dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1"
route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
ctrl "sigs.k8s.io/controller-runtime"
)

// SOA is provider-owned zone configuration, not a supported RecordSet claim type.
const route53RecordTypeSOA dnsv1alpha1.RecordType = "SOA"

type soaRecord struct {
fields []string
minimum int32
}

func parseSOA(value string) (soaRecord, error) {
fields := strings.Fields(value)
if len(fields) != 7 {
return soaRecord{}, errors.New("Route 53 hosted zone apex SOA must contain exactly seven fields")
}
minimum, err := strconv.ParseInt(fields[6], 10, 32)
if err != nil || minimum < 0 {
return soaRecord{}, errors.New("Route 53 hosted zone apex SOA MINIMUM must be between 0 and 2147483647")
}
return soaRecord{fields: fields, minimum: int32(minimum)}, nil
}

func (soa soaRecord) withMinimum(minimum int32) string {
soa.fields[6] = strconv.FormatInt(int64(minimum), 10)
return strings.Join(soa.fields, " ")
}

func (r *ZoneReconciler) reconcileNegativeCacheTTL(ctx context.Context, provider Provider, zone *dnsv1alpha1.Zone, hostedZone HostedZone, params *route53v1alpha1.Route53ZoneClassParameters) (ctrl.Result, bool, error) {
if params.NegativeCacheTTLSeconds == nil {
return ctrl.Result{}, false, nil
}
record, err := provider.GetRecordSet(ctx, hostedZone.ID, hostedZone.Name, route53RecordTypeSOA)
if err != nil {
result, err := r.failProgrammedForProviderError(ctx, zone, err)
return result, true, err
}
if record == nil || record.TTL == nil || len(record.Values) != 1 {
return ctrl.Result{}, true, r.setProgrammed(ctx, zone, metav1.ConditionFalse, "ExternalResourceMismatch", "Route 53 hosted zone apex SOA must contain exactly one value and a TTL")
}
soa, err := parseSOA(record.Values[0])
if err != nil {
return ctrl.Result{}, true, r.setProgrammed(ctx, zone, metav1.ConditionFalse, "ExternalResourceMismatch", err.Error())
}
if err := r.patchRoute53ZoneStatus(ctx, zone, func(data *route53v1alpha1.Route53ZoneStatusData) {
data.NegativeCacheTTLSeconds = soa.minimum
}); err != nil {
return ctrl.Result{}, true, err
}
if soa.minimum == *params.NegativeCacheTTLSeconds {
return ctrl.Result{}, false, nil
}

// Preserve the RRset TTL and all six other SOA fields, including SERIAL.
record.Values[0] = soa.withMinimum(*params.NegativeCacheTTLSeconds)
change, err := provider.ChangeRecordSets(ctx, hostedZone.ID, []RecordSetChange{{Action: RecordSetChangeActionUpsert, RecordSet: *record}})
if err != nil {
result, err := r.failProgrammedForProviderError(ctx, zone, err)
return result, true, err
}
if err := r.patchRoute53ZoneStatus(ctx, zone, func(data *route53v1alpha1.Route53ZoneStatusData) {
data.PendingHostedZoneChange = pendingChangeFromChange(change, "NegativeCacheTTL")
}); err != nil {
return ctrl.Result{}, true, err
}
// Use the existing hosted-zone change barrier. Publish only observed MINIMUM,
// and re-read the SOA after INSYNC rather than reporting the submitted value.
return ctrl.Result{RequeueAfter: r.changeCheckAfter()}, true, r.setProgrammed(ctx, zone, metav1.ConditionFalse, "ProviderChangePending", "Route 53 hosted zone change is pending")
}
Loading
Loading