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
12 changes: 10 additions & 2 deletions app/operator/config/provider/route53_extensions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,22 @@ spec:
adoption:
openAPIV3Schema:
type: object
required:
- hostedZoneId
oneOf:
- required:
- hostedZoneId
- required:
- byName
additionalProperties: false
properties:
hostedZoneId:
type: string
description: Route 53 hosted zone ID of the existing hosted zone to adopt. The zone must be public or private as declared by the ZoneClass zoneType.
pattern: '^Z[A-Z0-9]+$'
byName:
type: boolean
description: Adopt exactly one existing hosted zone matching domainName and the ZoneClass zoneType. The resolved hosted zone ID is recorded in status and reused without further name lookups.
enum:
- true
statusProviderData:
openAPIV3Schema:
type: object
Expand Down
12 changes: 10 additions & 2 deletions deploy/charts/dns-api/templates/provider-route53.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,22 @@ spec:
adoption:
openAPIV3Schema:
type: object
required:
- hostedZoneId
oneOf:
- required:
- hostedZoneId
- required:
- byName
additionalProperties: false
properties:
hostedZoneId:
type: string
description: Route 53 hosted zone ID of the existing hosted zone to adopt. The zone must be public or private as declared by the ZoneClass zoneType.
pattern: '^Z[A-Z0-9]+$'
byName:
type: boolean
description: Adopt exactly one existing hosted zone matching domainName and the ZoneClass zoneType. The resolved hosted zone ID is recorded in status and reused without further name lookups.
enum:
- true
statusProviderData:
openAPIV3Schema:
type: object
Expand Down
24 changes: 19 additions & 5 deletions docs/design/route53-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ AWS credentials themselves are not stored in `ZoneClass.spec.parameters`. `ZoneC

### Private hosted zones

With `zoneType: Private`, dns-api adopts an existing private hosted zone through `Zone.spec.adoption.hostedZoneId` 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.
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.

The ZoneClass accepts `zoneCreationPolicy` only when absent or `Deny`, and `zoneDeletionPolicy` only when absent or `Retain`. Otherwise it sets `Accepted=False`, reason `InvalidParameters`, with `zoneType Private requires zoneCreationPolicy Deny` or `zoneType Private requires zoneDeletionPolicy Retain`. `sameNameZonePolicy` and `tags` are allowed, but apply only to hosted zone creation and do not cause private zone creation or tagging during adoption.

Expand Down Expand Up @@ -166,31 +166,45 @@ When `Zone.spec.adoption` is absent, the controller creates public hosted zones

Route 53 controller stores `CreateHostedZone` `CallerReference` only as provider-internal state in `ZoneUnit.status.provider.state`. It is not stored in `ZoneUnit.spec` and is not public `status.provider.data`. The controller may reuse a cached caller reference while status is present, but the caller reference is not the recovery source of truth.

If Route 53 status is lost after a hosted zone was created, recovery uses explicit adoption through `Zone.spec.adoption.hostedZoneId` / `ZoneUnit.spec.zone.adoption.hostedZoneId`. If status and adoption are both absent, the controller does not automatically adopt a same-name hosted zone. It reports `Programmed=False`, reason `ProviderConflict`, when provider state is ambiguous.
If Route 53 status is lost after a hosted zone was created, recovery uses explicit adoption through `Zone.spec.adoption` / `ZoneUnit.spec.zone.adoption`, either by hosted zone ID or by name. If status and adoption are both absent, the controller does not automatically adopt a same-name hosted zone. It reports `Programmed=False`, reason `ProviderConflict`, when provider state is ambiguous.

Temporary Route 53 ZoneUnit controller pseudocode is not part of the design source of truth. Hosted zone resolution order, finalizers, pending changes, Route 53 API errors, batch limits, and requeue behavior are defined in this document.

Route 53 controller re-observes hosted zones and child record sets even in stable state. If provider state differs from `ZoneUnit.spec`, it treats the difference as drift and self-heals owned items. Re-observation intervals, change check intervals, and temporary error retry intervals are controller configuration, not `Zone.spec` or `RecordSet.spec`.

Route 53 controller also enqueues a `ZoneUnit` when `ZoneUnit.metadata.annotations["dns.appthrust.io/reconcile-request"]` changes. This requests re-observation of the hosted zone and child record sets without waiting for periodic re-observation. It follows the same validation, pending change, batch serialization, and drift self-healing rules. Updating the annotation alone must not set `Programmed=False` or send no-op provider API changes when external state already matches `ZoneUnit.spec`.

Route 53 `Zone.spec.adoption` shape:
Route 53 `Zone.spec.adoption` accepts exactly one of these shapes:

```yaml
spec:
adoption:
hostedZoneId: Z10219583PPQMV0U1KGN2
```

`hostedZoneId` is a Route 53 hosted zone ID in `Z...` form. `/hostedzone/Z...` and hosted zone ARNs are not accepted. This shape is validated by Route 53 Provider version `zone.schemas.adoption`.
```yaml
spec:
domainName: hcp.example.internal
adoption:
byName: true
```

`hostedZoneId` is a Route 53 hosted zone ID in `Z...` form. `/hostedzone/Z...` and hosted zone ARNs are not accepted. Alternatively, `byName` must be the boolean `true`; `false`, an empty adoption object, or both fields together are invalid. These mutually exclusive shapes are validated by the `oneOf` in Route 53 Provider version `zone.schemas.adoption`. Invalid adoption sets `Accepted=False`, reason `InvalidAdoption`.

By-name adoption avoids passing an externally managed hosted zone ID (for example, an ID held only in Terraform state) through immutable installation configuration. It is explicit opt-in, not implicit same-name recovery.

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

| Field | `description` |
| --- | --- |
| `hostedZoneId` | `Route 53 hosted zone ID of the existing hosted zone to adopt. The zone must be public or private as declared by the ZoneClass zoneType.` |
| `byName` | `Adopt exactly one existing hosted zone matching domainName and the ZoneClass zoneType. The resolved hosted zone ID is recorded in status and reused without further name lookups.` |

For `byName: true` without a recorded hosted zone ID, the controller calls `ListHostedZonesByName` with `domainName` and retains only exact same-name zones after trailing-dot normalization whose public/private type matches the ZoneClass `zoneType` (`Public` by default). **By-name adoption requires exactly one same-name zone of the class's type.** A public and a private zone with the same name are not ambiguous when only one matches the class. Zero matches set `Programmed=False`, reason `ExternalResourceNotFound`. Multiple matches set `Programmed=False`, reason `ExternalResourceMismatch`; the message reports the matching count, never the candidate IDs.

For either adoption mode, the Route 53 controller gets the selected hosted zone, verifies its ID, verifies that the hosted zone name matches `ZoneUnit.spec.zone.domainName` after trailing-dot normalization, and verifies that its public/private type matches the ZoneClass `zoneType`. If it matches, the controller manages its RecordSets and stores the resolved ID in `ZoneUnit.status.zone.provider.data.hostedZoneID` and the observed type in `zoneType`, using the same status patch for both modes. If the hosted zone does not exist, set `Programmed=False`, reason `ExternalResourceNotFound`. If the ID, type, or name differs, set `Programmed=False`, reason `ExternalResourceMismatch`. A Public class adopting a private zone by ID keeps the message `Route 53 hosted zone is private; only public hosted zones are supported`. A Private class adopting a public zone by ID reports `Route 53 hosted zone is public; ZoneClass zoneType is Private`.

When adoption is specified, the Route 53 controller gets the hosted zone, verifies that the hosted zone name matches `ZoneUnit.spec.zone.domainName`, and verifies that its public/private type matches the ZoneClass `zoneType`. If it matches, the controller manages its RecordSets and stores `hostedZoneId` in `ZoneUnit.status.zone.provider.data.hostedZoneID` and the observed type in `zoneType`. If the hosted zone does not exist, set `Programmed=False`, reason `ExternalResourceNotFound`. If the type or name differs, set `Programmed=False`, reason `ExternalResourceMismatch`. A Public class adopting a private zone keeps the message `Route 53 hosted zone is private; only public hosted zones are supported`. A Private class adopting a public zone reports `Route 53 hosted zone is public; ZoneClass zoneType is Private`.
Once recorded, the status hosted zone ID is authoritative for subsequent reconciles and deletion. By-name adoption does not list again or switch to another same-name zone, even if the recorded zone disappears; that case reports `ExternalResourceNotFound`. An explicit `hostedZoneId` differing from recorded status still sets `Accepted=False`, reason `ManagedResourceMismatch`. Deletion never resolves a previously unresolved by-name adoption: without a recorded ID there is no managed hosted zone to delete. Private zones are always retained; public zones follow the class deletion policy.

Created hosted zones receive ownership tags:

Expand Down
83 changes: 72 additions & 11 deletions internal/go/core/webhook/validator_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package webhook

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

cloudflarev1alpha1 "github.com/appthrust/dns-api/pkg/go/api/cloudflare/v1alpha1"
dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1"
Expand Down Expand Up @@ -733,6 +735,75 @@ func TestZoneValidationChecksAdoptionSchema(t *testing.T) {
}
}

func TestRoute53ZoneAdoptionSchemas(t *testing.T) {
builtIn := builtInProviderManifest(t, "route53_extensions.yaml")
path := filepath.Join("..", "..", "..", "..", "deploy", "charts", "dns-api", "templates", "provider-route53.yaml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile(%s): %v", path, err)
}
tmpl, err := template.New("provider-route53").Parse(string(data))
if err != nil {
t.Fatalf("parse chart provider template: %v", 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.Fatalf("render chart provider template: %v", err)
}
var chart dnsv1alpha1.Provider
if err := yaml.Unmarshal(rendered.Bytes(), &chart); err != nil {
t.Fatalf("unmarshal chart provider: %v", err)
}
if !bytes.Equal(builtIn.Spec.Versions[0].Zone.Schemas.Adoption.OpenAPIV3Schema.Raw, chart.Spec.Versions[0].Zone.Schemas.Adoption.OpenAPIV3Schema.Raw) {
t.Fatal("built-in and chart adoption schemas differ")
}
for name, provider := range map[string]*dnsv1alpha1.Provider{"built-in": &builtIn, "chart": &chart} {
t.Run(name, func(t *testing.T) {
if err := validateProvider(provider); err != nil {
t.Fatalf("validateProvider: %v", err)
}
for _, test := range []struct {
name string
adoption string
valid bool
}{
{name: "absent", valid: true},
{name: "hosted zone ID", adoption: `{"hostedZoneId":"ZADOPT"}`, valid: true},
{name: "by name", adoption: `{"byName":true}`, valid: true},
{name: "both", adoption: `{"hostedZoneId":"ZADOPT","byName":true}`},
{name: "both with false", adoption: `{"hostedZoneId":"ZADOPT","byName":false}`},
{name: "both with null", adoption: `{"hostedZoneId":"ZADOPT","byName":null}`},
{name: "empty", adoption: `{}`},
{name: "false", adoption: `{"byName":false}`},
{name: "string", adoption: `{"byName":"true"}`},
{name: "null by name", adoption: `{"byName":null}`},
{name: "unknown field", adoption: `{"byName":true,"other":true}`},
{name: "path ID", adoption: `{"hostedZoneId":"/hostedzone/ZADOPT"}`},
{name: "empty ID", adoption: `{"hostedZoneId":""}`},
} {
t.Run(test.name, func(t *testing.T) {
zone := validZone()
zone.Spec.Adoption = runtime.RawExtension{Raw: []byte(test.adoption)}
validator := newTestValidator(t,
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "app"}},
provider, route53ZoneClass(t), zone,
)
err := validator.validateZone(context.Background(), zone, nil)
if test.valid {
if err != nil {
t.Fatalf("validateZone: %v", err)
}
} else if err == nil || !strings.Contains(err.Error(), "spec.adoption") {
t.Fatalf("validateZone error = %v, want adoption schema failure", err)
}
})
}
})
}
}

func TestAllowedRecordSetsRequireSelectorRecordsAndTypes(t *testing.T) {
err := validateAllowedRecordSets([]dnsv1alpha1.AllowedRecordSet{
{
Expand Down Expand Up @@ -1933,17 +2004,7 @@ func route53Provider(t *testing.T) *dnsv1alpha1.Provider {
},
Zone: dnsv1alpha1.ProviderZone{
Schemas: dnsv1alpha1.ProviderZoneSchemas{
Adoption: &dnsv1alpha1.ProviderOpenAPISchema{OpenAPIV3Schema: raw(t, map[string]any{
"type": "object",
"required": []any{"hostedZoneId"},
"properties": map[string]any{
"hostedZoneId": map[string]any{
"type": "string",
"pattern": "^Z[A-Z0-9]+$",
"description": "Route 53 public hosted zone ID of the existing hosted zone to adopt.",
},
},
})},
Adoption: builtInProviderManifest(t, "route53_extensions.yaml").Spec.Versions[0].Zone.Schemas.Adoption,
StatusProviderData: &dnsv1alpha1.ProviderOpenAPISchema{OpenAPIV3Schema: raw(t, map[string]any{
"type": "object",
"properties": map[string]any{
Expand Down
51 changes: 45 additions & 6 deletions internal/go/providers/route53/controllers/zoneunit/zone.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.
return r.createHostedZone(ctx, provider, &zone, zoneClass, params)
}

adoptionID, adopting, err := route53ZoneAdoptionID(&zone)
adoption, adopting, err := parseRoute53ZoneAdoption(&zone)
if err != nil {
return ctrl.Result{}, r.setAccepted(ctx, &zone, metav1.ConditionFalse, "InvalidAdoption", err.Error())
}
Expand All @@ -134,8 +134,12 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.
return r.failProgrammedForProviderError(ctx, &zone, err)
}
if adopting {
if hostedZone.ID != adoptionID {
return ctrl.Result{}, r.setProgrammed(ctx, &zone, metav1.ConditionFalse, "ExternalResourceMismatch", "adopted Route 53 hosted zone ID does not match spec.adoption.hostedZoneId")
if hostedZone.ID != selection.ID {
message := "adopted Route 53 hosted zone ID does not match spec.adoption.hostedZoneId"
if adoption.ByName {
message = "adopted Route 53 hosted zone ID does not match resolved adoption target"
}
return ctrl.Result{}, r.setProgrammed(ctx, &zone, metav1.ConditionFalse, "ExternalResourceMismatch", message)
}
if message := hostedZoneSpecMismatch(&zone, params, hostedZone); message != "" {
return ctrl.Result{}, r.setProgrammed(ctx, &zone, metav1.ConditionFalse, "ExternalResourceMismatch", message)
Expand Down Expand Up @@ -358,7 +362,7 @@ func (r *ZoneReconciler) acceptZone(ctx context.Context, zone *dnsv1alpha1.Zone)
if err != nil || !accepted {
return nil, nil, nil, false, err
}
_, adopting, err := route53ZoneAdoptionID(zone)
_, adopting, err := parseRoute53ZoneAdoption(zone)
if err != nil {
return nil, nil, nil, false, r.setAccepted(ctx, zone, metav1.ConditionFalse, "InvalidAdoption", err.Error())
}
Expand Down Expand Up @@ -579,10 +583,16 @@ func (r *ZoneReconciler) deleteContextForZone(ctx context.Context, zone *dnsv1al
}

func (r *ZoneReconciler) selectHostedZone(ctx context.Context, provider Provider, zone *dnsv1alpha1.Zone, statusData route53v1alpha1.Route53ZoneStatusData, params *route53v1alpha1.Route53ZoneClassParameters) (HostedZone, bool, ctrl.Result, error) {
if adoptionID, adopting, err := route53ZoneAdoptionID(zone); err != nil {
if adoption, adopting, err := parseRoute53ZoneAdoption(zone); err != nil {
return HostedZone{}, true, ctrl.Result{}, r.setAccepted(ctx, zone, metav1.ConditionFalse, "InvalidAdoption", err.Error())
} else if adopting {
return HostedZone{ID: adoptionID}, false, ctrl.Result{}, nil
if statusID := normalizeHostedZoneID(statusData.HostedZoneID); statusID != "" {
return HostedZone{ID: statusID}, false, ctrl.Result{}, nil
}
if !adoption.ByName {
return HostedZone{ID: adoption.HostedZoneID}, false, ctrl.Result{}, nil
}
return r.selectHostedZoneByName(ctx, provider, zone, params)
}

statusID := normalizeHostedZoneID(statusData.HostedZoneID)
Expand Down Expand Up @@ -635,6 +645,35 @@ func (r *ZoneReconciler) selectHostedZone(ctx context.Context, provider Provider
return selected, false, ctrl.Result{}, nil
}

func (r *ZoneReconciler) selectHostedZoneByName(ctx context.Context, provider Provider, zone *dnsv1alpha1.Zone, params *route53v1alpha1.Route53ZoneClassParameters) (HostedZone, bool, ctrl.Result, error) {
domainName := normalizeDomainName(zone.Spec.DomainName)
zones, err := provider.ListHostedZonesByName(ctx, domainName)
if err != nil {
result, err := r.failProgrammedForProviderError(ctx, zone, err)
return HostedZone{}, true, result, err
}

wantType := zoneType(params)
var selected HostedZone
matches := 0
for _, hostedZone := range zones {
if normalizeDomainName(hostedZone.Name) == domainName && hostedZone.Private == (wantType == route53v1alpha1.ZoneTypePrivate) {
selected = hostedZone
matches++
}
}
switch matches {
case 0:
message := fmt.Sprintf("no same-name Route 53 hosted zone matches ZoneClass zoneType %s", wantType)
return HostedZone{}, true, ctrl.Result{}, r.setProgrammed(ctx, zone, metav1.ConditionFalse, "ExternalResourceNotFound", message)
case 1:
return selected, false, ctrl.Result{}, nil
default:
message := fmt.Sprintf("%d same-name Route 53 hosted zones match ZoneClass zoneType %s; adoption.byName requires exactly one", matches, wantType)
return HostedZone{}, true, ctrl.Result{}, r.setProgrammed(ctx, zone, metav1.ConditionFalse, "ExternalResourceMismatch", message)
}
}

func (r *ZoneReconciler) clearHostedZoneProviderStatus(ctx context.Context, zone *dnsv1alpha1.Zone) error {
statusData, err := route53ZoneStatusData(zone)
if err != nil {
Expand Down
Loading
Loading