Skip to content

Commit 87aa1dc

Browse files
authored
feat(route53): adopt an existing hosted zone by name (Zone.spec.adoption.byName) (#14)
Route 53 Zone adoption becomes a oneOf: {hostedZoneId: Z...} (unchanged) or {byName: true}. By-name resolution lists same-name hosted zones, keeps those whose private/public type matches the ZoneClass zoneType, requires exactly one match, and records its ID in status through the existing adoption patch; later reconciles and deletion use the recorded ID and never re-list. Zero matches -> Programmed=False ExternalResourceNotFound; several -> ExternalResourceMismatch with the count; both fields set -> Accepted=False InvalidAdoption. No implicit adoption without spec.adoption; record-set adoption, Cloudflare and VPC-association behaviour unchanged. Motivation: appthrust/platform hosting-substrate-design.md S2 — the hosting bundle renders Zone hcp for hcp.<installation>.internal under a Private class; the hosted zone ID lives only in Terraform state and the installation spec is immutable, so adoption by name removes the need to thread the ID.
1 parent 638bb64 commit 87aa1dc

8 files changed

Lines changed: 441 additions & 42 deletions

File tree

‎app/operator/config/provider/route53_extensions.yaml‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,22 @@ spec:
6060
adoption:
6161
openAPIV3Schema:
6262
type: object
63-
required:
64-
- hostedZoneId
63+
oneOf:
64+
- required:
65+
- hostedZoneId
66+
- required:
67+
- byName
6568
additionalProperties: false
6669
properties:
6770
hostedZoneId:
6871
type: string
6972
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.
7073
pattern: '^Z[A-Z0-9]+$'
74+
byName:
75+
type: boolean
76+
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.
77+
enum:
78+
- true
7179
statusProviderData:
7280
openAPIV3Schema:
7381
type: object

‎deploy/charts/dns-api/templates/provider-route53.yaml‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,22 @@ spec:
6161
adoption:
6262
openAPIV3Schema:
6363
type: object
64-
required:
65-
- hostedZoneId
64+
oneOf:
65+
- required:
66+
- hostedZoneId
67+
- required:
68+
- byName
6669
additionalProperties: false
6770
properties:
6871
hostedZoneId:
6972
type: string
7073
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.
7174
pattern: '^Z[A-Z0-9]+$'
75+
byName:
76+
type: boolean
77+
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.
78+
enum:
79+
- true
7280
statusProviderData:
7381
openAPIV3Schema:
7482
type: object

‎docs/design/route53-provider.md‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ AWS credentials themselves are not stored in `ZoneClass.spec.parameters`. `ZoneC
134134

135135
### Private hosted zones
136136

137-
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.
137+
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.
138138

139139
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.
140140

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

167167
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.
168168

169-
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.
169+
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.
170170

171171
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.
172172

173173
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`.
174174

175175
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`.
176176

177-
Route 53 `Zone.spec.adoption` shape:
177+
Route 53 `Zone.spec.adoption` accepts exactly one of these shapes:
178178

179179
```yaml
180180
spec:
181181
adoption:
182182
hostedZoneId: Z10219583PPQMV0U1KGN2
183183
```
184184

185-
`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`.
185+
```yaml
186+
spec:
187+
domainName: hcp.example.internal
188+
adoption:
189+
byName: true
190+
```
191+
192+
`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`.
193+
194+
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.
186195

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

189198
| Field | `description` |
190199
| --- | --- |
191200
| `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.` |
201+
| `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.` |
202+
203+
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.
204+
205+
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`.
192206

193-
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`.
207+
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.
194208

195209
Created hosted zones receive ownership tags:
196210

‎internal/go/core/webhook/validator_test.go‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package webhook
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"os"
78
"path/filepath"
89
"strings"
910
"testing"
11+
"text/template"
1012

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

738+
func TestRoute53ZoneAdoptionSchemas(t *testing.T) {
739+
builtIn := builtInProviderManifest(t, "route53_extensions.yaml")
740+
path := filepath.Join("..", "..", "..", "..", "deploy", "charts", "dns-api", "templates", "provider-route53.yaml")
741+
data, err := os.ReadFile(path)
742+
if err != nil {
743+
t.Fatalf("ReadFile(%s): %v", path, err)
744+
}
745+
tmpl, err := template.New("provider-route53").Parse(string(data))
746+
if err != nil {
747+
t.Fatalf("parse chart provider template: %v", err)
748+
}
749+
var rendered bytes.Buffer
750+
if err := tmpl.Execute(&rendered, map[string]any{
751+
"Values": map[string]any{"providers": map[string]any{"route53": map[string]any{"enabled": true}}},
752+
}); err != nil {
753+
t.Fatalf("render chart provider template: %v", err)
754+
}
755+
var chart dnsv1alpha1.Provider
756+
if err := yaml.Unmarshal(rendered.Bytes(), &chart); err != nil {
757+
t.Fatalf("unmarshal chart provider: %v", err)
758+
}
759+
if !bytes.Equal(builtIn.Spec.Versions[0].Zone.Schemas.Adoption.OpenAPIV3Schema.Raw, chart.Spec.Versions[0].Zone.Schemas.Adoption.OpenAPIV3Schema.Raw) {
760+
t.Fatal("built-in and chart adoption schemas differ")
761+
}
762+
for name, provider := range map[string]*dnsv1alpha1.Provider{"built-in": &builtIn, "chart": &chart} {
763+
t.Run(name, func(t *testing.T) {
764+
if err := validateProvider(provider); err != nil {
765+
t.Fatalf("validateProvider: %v", err)
766+
}
767+
for _, test := range []struct {
768+
name string
769+
adoption string
770+
valid bool
771+
}{
772+
{name: "absent", valid: true},
773+
{name: "hosted zone ID", adoption: `{"hostedZoneId":"ZADOPT"}`, valid: true},
774+
{name: "by name", adoption: `{"byName":true}`, valid: true},
775+
{name: "both", adoption: `{"hostedZoneId":"ZADOPT","byName":true}`},
776+
{name: "both with false", adoption: `{"hostedZoneId":"ZADOPT","byName":false}`},
777+
{name: "both with null", adoption: `{"hostedZoneId":"ZADOPT","byName":null}`},
778+
{name: "empty", adoption: `{}`},
779+
{name: "false", adoption: `{"byName":false}`},
780+
{name: "string", adoption: `{"byName":"true"}`},
781+
{name: "null by name", adoption: `{"byName":null}`},
782+
{name: "unknown field", adoption: `{"byName":true,"other":true}`},
783+
{name: "path ID", adoption: `{"hostedZoneId":"/hostedzone/ZADOPT"}`},
784+
{name: "empty ID", adoption: `{"hostedZoneId":""}`},
785+
} {
786+
t.Run(test.name, func(t *testing.T) {
787+
zone := validZone()
788+
zone.Spec.Adoption = runtime.RawExtension{Raw: []byte(test.adoption)}
789+
validator := newTestValidator(t,
790+
&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "app"}},
791+
provider, route53ZoneClass(t), zone,
792+
)
793+
err := validator.validateZone(context.Background(), zone, nil)
794+
if test.valid {
795+
if err != nil {
796+
t.Fatalf("validateZone: %v", err)
797+
}
798+
} else if err == nil || !strings.Contains(err.Error(), "spec.adoption") {
799+
t.Fatalf("validateZone error = %v, want adoption schema failure", err)
800+
}
801+
})
802+
}
803+
})
804+
}
805+
}
806+
736807
func TestAllowedRecordSetsRequireSelectorRecordsAndTypes(t *testing.T) {
737808
err := validateAllowedRecordSets([]dnsv1alpha1.AllowedRecordSet{
738809
{
@@ -1933,17 +2004,7 @@ func route53Provider(t *testing.T) *dnsv1alpha1.Provider {
19332004
},
19342005
Zone: dnsv1alpha1.ProviderZone{
19352006
Schemas: dnsv1alpha1.ProviderZoneSchemas{
1936-
Adoption: &dnsv1alpha1.ProviderOpenAPISchema{OpenAPIV3Schema: raw(t, map[string]any{
1937-
"type": "object",
1938-
"required": []any{"hostedZoneId"},
1939-
"properties": map[string]any{
1940-
"hostedZoneId": map[string]any{
1941-
"type": "string",
1942-
"pattern": "^Z[A-Z0-9]+$",
1943-
"description": "Route 53 public hosted zone ID of the existing hosted zone to adopt.",
1944-
},
1945-
},
1946-
})},
2007+
Adoption: builtInProviderManifest(t, "route53_extensions.yaml").Spec.Versions[0].Zone.Schemas.Adoption,
19472008
StatusProviderData: &dnsv1alpha1.ProviderOpenAPISchema{OpenAPIV3Schema: raw(t, map[string]any{
19482009
"type": "object",
19492010
"properties": map[string]any{

‎internal/go/providers/route53/controllers/zoneunit/zone.go‎

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.
122122
return r.createHostedZone(ctx, provider, &zone, zoneClass, params)
123123
}
124124

125-
adoptionID, adopting, err := route53ZoneAdoptionID(&zone)
125+
adoption, adopting, err := parseRoute53ZoneAdoption(&zone)
126126
if err != nil {
127127
return ctrl.Result{}, r.setAccepted(ctx, &zone, metav1.ConditionFalse, "InvalidAdoption", err.Error())
128128
}
@@ -134,8 +134,12 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.
134134
return r.failProgrammedForProviderError(ctx, &zone, err)
135135
}
136136
if adopting {
137-
if hostedZone.ID != adoptionID {
138-
return ctrl.Result{}, r.setProgrammed(ctx, &zone, metav1.ConditionFalse, "ExternalResourceMismatch", "adopted Route 53 hosted zone ID does not match spec.adoption.hostedZoneId")
137+
if hostedZone.ID != selection.ID {
138+
message := "adopted Route 53 hosted zone ID does not match spec.adoption.hostedZoneId"
139+
if adoption.ByName {
140+
message = "adopted Route 53 hosted zone ID does not match resolved adoption target"
141+
}
142+
return ctrl.Result{}, r.setProgrammed(ctx, &zone, metav1.ConditionFalse, "ExternalResourceMismatch", message)
139143
}
140144
if message := hostedZoneSpecMismatch(&zone, params, hostedZone); message != "" {
141145
return ctrl.Result{}, r.setProgrammed(ctx, &zone, metav1.ConditionFalse, "ExternalResourceMismatch", message)
@@ -358,7 +362,7 @@ func (r *ZoneReconciler) acceptZone(ctx context.Context, zone *dnsv1alpha1.Zone)
358362
if err != nil || !accepted {
359363
return nil, nil, nil, false, err
360364
}
361-
_, adopting, err := route53ZoneAdoptionID(zone)
365+
_, adopting, err := parseRoute53ZoneAdoption(zone)
362366
if err != nil {
363367
return nil, nil, nil, false, r.setAccepted(ctx, zone, metav1.ConditionFalse, "InvalidAdoption", err.Error())
364368
}
@@ -579,10 +583,16 @@ func (r *ZoneReconciler) deleteContextForZone(ctx context.Context, zone *dnsv1al
579583
}
580584

581585
func (r *ZoneReconciler) selectHostedZone(ctx context.Context, provider Provider, zone *dnsv1alpha1.Zone, statusData route53v1alpha1.Route53ZoneStatusData, params *route53v1alpha1.Route53ZoneClassParameters) (HostedZone, bool, ctrl.Result, error) {
582-
if adoptionID, adopting, err := route53ZoneAdoptionID(zone); err != nil {
586+
if adoption, adopting, err := parseRoute53ZoneAdoption(zone); err != nil {
583587
return HostedZone{}, true, ctrl.Result{}, r.setAccepted(ctx, zone, metav1.ConditionFalse, "InvalidAdoption", err.Error())
584588
} else if adopting {
585-
return HostedZone{ID: adoptionID}, false, ctrl.Result{}, nil
589+
if statusID := normalizeHostedZoneID(statusData.HostedZoneID); statusID != "" {
590+
return HostedZone{ID: statusID}, false, ctrl.Result{}, nil
591+
}
592+
if !adoption.ByName {
593+
return HostedZone{ID: adoption.HostedZoneID}, false, ctrl.Result{}, nil
594+
}
595+
return r.selectHostedZoneByName(ctx, provider, zone, params)
586596
}
587597

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

648+
func (r *ZoneReconciler) selectHostedZoneByName(ctx context.Context, provider Provider, zone *dnsv1alpha1.Zone, params *route53v1alpha1.Route53ZoneClassParameters) (HostedZone, bool, ctrl.Result, error) {
649+
domainName := normalizeDomainName(zone.Spec.DomainName)
650+
zones, err := provider.ListHostedZonesByName(ctx, domainName)
651+
if err != nil {
652+
result, err := r.failProgrammedForProviderError(ctx, zone, err)
653+
return HostedZone{}, true, result, err
654+
}
655+
656+
wantType := zoneType(params)
657+
var selected HostedZone
658+
matches := 0
659+
for _, hostedZone := range zones {
660+
if normalizeDomainName(hostedZone.Name) == domainName && hostedZone.Private == (wantType == route53v1alpha1.ZoneTypePrivate) {
661+
selected = hostedZone
662+
matches++
663+
}
664+
}
665+
switch matches {
666+
case 0:
667+
message := fmt.Sprintf("no same-name Route 53 hosted zone matches ZoneClass zoneType %s", wantType)
668+
return HostedZone{}, true, ctrl.Result{}, r.setProgrammed(ctx, zone, metav1.ConditionFalse, "ExternalResourceNotFound", message)
669+
case 1:
670+
return selected, false, ctrl.Result{}, nil
671+
default:
672+
message := fmt.Sprintf("%d same-name Route 53 hosted zones match ZoneClass zoneType %s; adoption.byName requires exactly one", matches, wantType)
673+
return HostedZone{}, true, ctrl.Result{}, r.setProgrammed(ctx, zone, metav1.ConditionFalse, "ExternalResourceMismatch", message)
674+
}
675+
}
676+
638677
func (r *ZoneReconciler) clearHostedZoneProviderStatus(ctx context.Context, zone *dnsv1alpha1.Zone) error {
639678
statusData, err := route53ZoneStatusData(zone)
640679
if err != nil {

0 commit comments

Comments
 (0)