diff --git a/README.md b/README.md index 105d999..4de046f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,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 +- Amazon Route 53 public hosted zones, and adoption of existing private hosted zones - Cloudflare full zones - `A`, `AAAA`, `TXT`, `CNAME`, `MX`, `CAA`, and delegated `NS` records - Route 53 `ALIAS` records diff --git a/app/operator/config/provider/route53_extensions.yaml b/app/operator/config/provider/route53_extensions.yaml index 61036f4..c4002e4 100644 --- a/app/operator/config/provider/route53_extensions.yaml +++ b/app/operator/config/provider/route53_extensions.yaml @@ -5,7 +5,7 @@ metadata: spec: display: name: Amazon Route 53 - description: Public DNS zones and record sets managed through Route 53. + description: Public and adopted private DNS zones and record sets managed through Route 53. logo: url: https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/aws-amazon-route-53/default.svg versions: @@ -25,15 +25,21 @@ spec: type: object additionalProperties: false properties: + zoneType: + type: string + description: Route 53 hosted zone type. Defaults to Public. Private is adoption-only and requires zoneCreationPolicy Deny and zoneDeletionPolicy Retain; dns-api never creates, deletes, or manages VPC associations for private hosted zones. + enum: + - Public + - Private zoneCreationPolicy: type: string - description: Controls whether dns-api may create a Route 53 public hosted zone when a Zone does not adopt an existing hosted zone. + description: 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. enum: - Create - Deny zoneDeletionPolicy: type: string - description: Controls whether dns-api deletes the Route 53 hosted zone when the Kubernetes Zone is deleted. + description: Controls whether dns-api deletes the Route 53 hosted zone when the Kubernetes Zone is deleted. Private requires Retain. enum: - Delete - Retain @@ -60,7 +66,7 @@ spec: properties: hostedZoneId: type: string - description: Route 53 public hosted zone ID of the existing hosted zone to adopt. + 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]+$' statusProviderData: openAPIV3Schema: @@ -71,6 +77,12 @@ spec: type: string description: Route 53 hosted zone ID managed for this Zone. pattern: '^Z[A-Z0-9]+$' + zoneType: + type: string + description: Observed Route 53 hosted zone type. + enum: + - Public + - Private recordSet: supportedTypes: - A diff --git a/app/operator/config/samples/route53_private_zoneclass.yaml b/app/operator/config/samples/route53_private_zoneclass.yaml new file mode 100644 index 0000000..92812a2 --- /dev/null +++ b/app/operator/config/samples/route53_private_zoneclass.yaml @@ -0,0 +1,70 @@ +# Install the Route 53 Provider and create the tenant namespaces first. +# Label tenant-a-app with appthrust.io/tenant: tenant-a. +# Use an existing private hosted zone named internal.example.com with its VPC +# associations already configured, and replace hostedZoneId with that zone's ID. +apiVersion: route53.dns.appthrust.io/v1alpha1 +kind: Route53Identity +metadata: + namespace: tenant-a-platform + name: route53-dev +spec: + accountID: "123456789012" + region: ap-northeast-1 + credentials: + runtime: {} +--- +apiVersion: dns.appthrust.io/v1alpha1 +kind: ZoneClass +metadata: + namespace: tenant-a-platform + name: route53-private +spec: + allowedZones: + namespaces: + from: Selector + selector: + matchLabels: + appthrust.io/tenant: tenant-a + provider: + name: route53.dns.appthrust.io + version: v1alpha1 + controllerName: route53.dns.appthrust.io/controller + identityRef: + name: route53-dev + parameters: + zoneType: Private + zoneCreationPolicy: Deny + zoneDeletionPolicy: Retain +--- +apiVersion: dns.appthrust.io/v1alpha1 +kind: Zone +metadata: + namespace: tenant-a-app + name: internal-example-com +spec: + domainName: internal.example.com + provider: + name: route53.dns.appthrust.io + version: v1alpha1 + zoneClassRef: + namespace: tenant-a-platform + name: route53-private + adoption: + hostedZoneId: Z10219583PPQMV0U1KGN2 +--- +apiVersion: dns.appthrust.io/v1alpha1 +kind: RecordSet +metadata: + namespace: tenant-a-app + name: api +spec: + zoneRef: + name: internal-example-com + provider: + name: route53.dns.appthrust.io + version: v1alpha1 + type: CNAME + name: api + ttl: 300 + cname: + target: control-plane.internal.example.com diff --git a/app/operator/tests/e2e/recordset-api/route53-private-zone-adoption-lifecycle.test.ts b/app/operator/tests/e2e/recordset-api/route53-private-zone-adoption-lifecycle.test.ts new file mode 100644 index 0000000..e3f1538 --- /dev/null +++ b/app/operator/tests/e2e/recordset-api/route53-private-zone-adoption-lifecycle.test.ts @@ -0,0 +1,554 @@ +import { + ChangeResourceRecordSetsCommand, + CreateHostedZoneCommand, + DeleteHostedZoneCommand, + GetHostedZoneCommand, + ListResourceRecordSetsCommand, + Route53Client, + type VPCRegion, +} from "@aws-sdk/client-route-53"; +import { type K8sResource, test } from "@appthrust/kest"; +import { expect } from "bun:test"; + +if (!process.env.AWS_PROFILE && process.env.PROFILE) { + process.env.AWS_PROFILE = process.env.PROFILE; +} +process.env.AWS_REGION ??= "ap-northeast-1"; + +const awsRegion = process.env.AWS_REGION ?? "ap-northeast-1"; +const route53 = new Route53Client({ region: awsRegion }); +const route53AccountID = process.env.ROUTE53_ACCOUNT_ID; +const privateZoneVPCID = process.env.ROUTE53_PRIVATE_ZONE_VPC_ID; +const cnameTarget = "target.example.net"; +const waitForRoute53 = { + timeout: "5m", + interval: "5s", + stallTimeout: "0s", +}; + +interface Condition { + type: string; + status: "True" | "False" | "Unknown"; + reason?: string; + observedGeneration?: number; +} + +interface Route53Identity extends K8sResource { + apiVersion: "route53.dns.appthrust.io/v1alpha1"; + kind: "Route53Identity"; + spec: { + accountID: string; + region: string; + credentials: { + runtime: Record; + }; + }; +} + +interface ZoneClass extends K8sResource { + apiVersion: "dns.appthrust.io/v1alpha1"; + kind: "ZoneClass"; + spec: { + allowedZones: { + namespaces: { + from: "Selector"; + selector: { + matchLabels: Record; + }; + }; + }; + provider: { name: string; version: string }; + controllerName: string; + identityRef: { + name: string; + }; + parameters: { + zoneType: "Private"; + zoneCreationPolicy: "Deny" | "Create"; + zoneDeletionPolicy: "Retain"; + sameNameZonePolicy: "Deny"; + tags: Record; + }; + }; + status?: { + conditions?: Array; + }; +} + +interface Zone extends K8sResource { + apiVersion: "dns.appthrust.io/v1alpha1"; + kind: "Zone"; + spec: { + domainName: string; + provider: { name: string; version: string }; + zoneClassRef: { + namespace: string; + name: string; + }; + adoption: { + hostedZoneId: string; + }; + }; + status?: { + provider?: { + data?: { + hostedZoneID?: string; + zoneType?: "Public" | "Private"; + }; + }; + nameServers?: Array; + conditions?: Array; + }; +} + +interface RecordSet extends K8sResource { + apiVersion: "dns.appthrust.io/v1alpha1"; + kind: "RecordSet"; + spec: { + zoneRef: { + name: string; + }; + provider: { name: string; version: string }; + type: "CNAME"; + name: string; + ttl: number; + cname: { + target: string; + }; + }; + status?: { + conditions?: Array; + }; +} + +test( + "a Route 53 Zone adopts an existing private hosted zone, programs a CNAME, and retains it on deletion", + async (s) => { + if (!privateZoneVPCID) { + console.log( + "Skipping Route 53 private hosted zone adoption: ROUTE53_PRIVATE_ZONE_VPC_ID is unset", + ); + return; + } + if (!route53AccountID) { + throw new Error("ROUTE53_ACCOUNT_ID is required"); + } + + s.given("an existing Route 53 private hosted zone is outside Kubernetes"); + const testID = s.generateName("hz-"); + const domainName = `${testID}.dns-api.test`; + const hostedZoneID = await createHostedZone(domainName, testID, privateZoneVPCID); + try { + s.given("platform and application namespaces exist"); + const platform = await s.newNamespace({ + generateName: "dns-api-platform-", + }); + const app = await s.newNamespace({ generateName: "dns-api-app-" }); + + await s.label( + { + apiVersion: "v1", + kind: "Namespace", + name: app.name, + labels: { + "appthrust.io/tenant": testID, + }, + overwrite: true, + }, + { timeout: "30s" }, + ); + + s.given("an adoption-only private Route53Identity and ZoneClass allow the app"); + await platform.apply({ + apiVersion: "route53.dns.appthrust.io/v1alpha1", + kind: "Route53Identity", + metadata: { name: "route53-dev" }, + spec: { + accountID: route53AccountID, + region: awsRegion, + credentials: { + runtime: {}, + }, + }, + }); + await platform.apply({ + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "ZoneClass", + metadata: { name: "route53-private-adoption" }, + spec: { + allowedZones: { + namespaces: { + from: "Selector", + selector: { + matchLabels: { + "appthrust.io/tenant": testID, + }, + }, + }, + }, + provider: { name: "route53.dns.appthrust.io", version: "v1alpha1" }, + controllerName: "route53.dns.appthrust.io/controller", + identityRef: { + name: "route53-dev", + }, + parameters: { + zoneType: "Private", + zoneCreationPolicy: "Deny", + zoneDeletionPolicy: "Retain", + sameNameZonePolicy: "Deny", + tags: { + "appthrust.io/test-scope": "kest", + "appthrust.io/test-id": testID, + }, + }, + }, + }); + + s.when("a Zone explicitly adopts the private hosted zone"); + await app.apply({ + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "Zone", + metadata: { name: "apps-example-com" }, + spec: { + domainName, + provider: { name: "route53.dns.appthrust.io", version: "v1alpha1" }, + zoneClassRef: { + namespace: platform.name, + name: "route53-private-adoption", + }, + adoption: { + hostedZoneId: hostedZoneID, + }, + }, + }); + + s.then("the Zone reports the private hosted zone as programmed without name servers"); + await app.assert( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "Zone", + name: "apps-example-com", + test() { + expect(this.status?.conditions).toContainEqual( + expect.objectContaining({ + type: "Accepted", + status: "True", + }), + ); + expect(this.status?.conditions).toContainEqual( + expect.objectContaining({ + type: "Programmed", + status: "True", + }), + ); + expect(this.status?.provider?.data?.hostedZoneID).toBe( + hostedZoneID, + ); + expect(this.status?.provider?.data?.zoneType).toBe("Private"); + expect(this.status?.nameServers ?? []).toEqual([]); + }, + }, + waitForRoute53, + ); + + s.when("a CNAME RecordSet is created by the application"); + await app.apply({ + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "RecordSet", + metadata: { name: "www-cname" }, + spec: { + zoneRef: { + name: "apps-example-com", + }, + provider: { name: "route53.dns.appthrust.io", version: "v1alpha1" }, + type: "CNAME", + name: "www", + ttl: 300, + cname: { + target: cnameTarget, + }, + }, + }); + try { + s.then("the RecordSet reports Accepted=True and Programmed=True"); + await app.assert( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "RecordSet", + name: "www-cname", + test() { + expect(this.status?.conditions).toContainEqual( + expect.objectContaining({ + type: "Accepted", + status: "True", + }), + ); + expect(this.status?.conditions).toContainEqual( + expect.objectContaining({ + type: "Programmed", + status: "True", + }), + ); + }, + }, + waitForRoute53, + ); + + s.then("the Route 53 CNAME record set exists in the private hosted zone"); + await s.exec( + { + do: async () => { + await assertCNAMERecordSetExists(hostedZoneID, domainName); + }, + }, + waitForRoute53, + ); + } finally { + s.when("the RecordSet is deleted by the application"); + await app.delete( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "RecordSet", + name: "www-cname", + }, + waitForRoute53, + ); + await app.assertAbsence( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "RecordSet", + name: "www-cname", + }, + waitForRoute53, + ); + } + + s.then("the Route 53 CNAME record set is gone"); + await s.exec( + { + do: async () => { + await assertCNAMERecordSetAbsent(hostedZoneID, domainName); + }, + }, + waitForRoute53, + ); + + s.when("the adopted Zone is deleted"); + await app.delete( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "Zone", + name: "apps-example-com", + }, + waitForRoute53, + ); + await app.assertAbsence( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "Zone", + name: "apps-example-com", + }, + waitForRoute53, + ); + + s.then("the Route 53 private hosted zone is retained by policy"); + await s.exec( + { + do: async () => { + await assertHostedZoneExists(hostedZoneID, domainName); + }, + }, + waitForRoute53, + ); + } finally { + await s.exec( + { + do: async () => { + await deleteHostedZoneIfExists(hostedZoneID, domainName); + }, + }, + waitForRoute53, + ); + } + }, + { timeout: "20m" }, +); + +test( + "a Route 53 private ZoneClass rejects hosted zone creation", + async (s) => { + if (!privateZoneVPCID) { + console.log( + "Skipping Route 53 private ZoneClass validation: ROUTE53_PRIVATE_ZONE_VPC_ID is unset", + ); + return; + } + if (!route53AccountID) { + throw new Error("ROUTE53_ACCOUNT_ID is required"); + } + + s.given("a platform namespace and Route53Identity exist"); + const platform = await s.newNamespace({ + generateName: "dns-api-platform-", + }); + const testID = s.generateName("hz-"); + await platform.apply({ + apiVersion: "route53.dns.appthrust.io/v1alpha1", + kind: "Route53Identity", + metadata: { name: "route53-dev" }, + spec: { + accountID: route53AccountID, + region: awsRegion, + credentials: { + runtime: {}, + }, + }, + }); + + s.when("a private ZoneClass allows hosted zone creation"); + await platform.apply({ + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "ZoneClass", + metadata: { name: "route53-private-create" }, + spec: { + allowedZones: { + namespaces: { + from: "Selector", + selector: { + matchLabels: { + "appthrust.io/tenant": testID, + }, + }, + }, + }, + provider: { name: "route53.dns.appthrust.io", version: "v1alpha1" }, + controllerName: "route53.dns.appthrust.io/controller", + identityRef: { + name: "route53-dev", + }, + parameters: { + zoneType: "Private", + zoneCreationPolicy: "Create", + zoneDeletionPolicy: "Retain", + sameNameZonePolicy: "Deny", + tags: { + "appthrust.io/test-scope": "kest", + "appthrust.io/test-id": testID, + }, + }, + }, + }); + + s.then("the ZoneClass reports Accepted=False with InvalidParameters"); + await platform.assert( + { + apiVersion: "dns.appthrust.io/v1alpha1", + kind: "ZoneClass", + name: "route53-private-create", + test() { + expect(this.status?.conditions).toContainEqual( + expect.objectContaining({ + type: "Accepted", + status: "False", + reason: "InvalidParameters", + }), + ); + }, + }, + waitForRoute53, + ); + }, + { timeout: "20m" }, +); + +async function createHostedZone( + domainName: string, + callerReference: string, + vpcID: string, +) { + const output = await route53.send( + new CreateHostedZoneCommand({ + Name: domainName, + CallerReference: `dns-api-private-adoption:${callerReference}`, + HostedZoneConfig: { + PrivateZone: true, + }, + VPC: { + VPCRegion: awsRegion as VPCRegion, + VPCId: vpcID, + }, + }), + ); + return requireString(output.HostedZone?.Id).replace(/^\/hostedzone\//, ""); +} + +async function assertHostedZoneExists(hostedZoneID: string, domainName: string) { + const output = await route53.send(new GetHostedZoneCommand({ Id: hostedZoneID })); + expect(output.HostedZone?.Name).toBe(`${domainName}.`); + expect(output.HostedZone?.Config?.PrivateZone).toBe(true); +} + +async function assertCNAMERecordSetExists(hostedZoneID: string, domainName: string) { + const record = await getCNAMERecordSet(hostedZoneID, domainName); + expect(record?.Name).toBe(`www.${domainName}.`); + expect(record?.Type).toBe("CNAME"); + expect(record?.TTL).toBe(300); + expect(record?.ResourceRecords?.map((item) => item.Value)).toEqual([`${cnameTarget}.`]); +} + +async function assertCNAMERecordSetAbsent(hostedZoneID: string, domainName: string) { + const record = await getCNAMERecordSet(hostedZoneID, domainName); + expect(record).toBeUndefined(); +} + +async function getCNAMERecordSet(hostedZoneID: string, domainName: string) { + const recordName = `www.${domainName}.`; + const output = await route53.send( + new ListResourceRecordSetsCommand({ + HostedZoneId: hostedZoneID, + StartRecordName: recordName, + StartRecordType: "CNAME", + MaxItems: 1, + }), + ); + const record = output.ResourceRecordSets?.[0]; + if (record?.Name !== recordName || record.Type !== "CNAME") { + return undefined; + } + return record; +} + +async function deleteHostedZoneIfExists(hostedZoneID: string, domainName: string) { + try { + // A failed assertion or controller cleanup can leave the test CNAME behind. + const record = await getCNAMERecordSet(hostedZoneID, domainName); + if (record) { + await route53.send( + new ChangeResourceRecordSetsCommand({ + HostedZoneId: hostedZoneID, + ChangeBatch: { + Changes: [{ Action: "DELETE", ResourceRecordSet: record }], + }, + }), + ); + } + await route53.send(new DeleteHostedZoneCommand({ Id: hostedZoneID })); + } catch (error) { + if (awsErrorName(error) === "NoSuchHostedZone") { + return; + } + throw error; + } +} + +function awsErrorName(error: unknown): string | undefined { + if (typeof error !== "object" || error === null || !("name" in error)) { + return undefined; + } + return String(error.name); +} + +function requireString(value: unknown): string { + expect(value).toBeString(); + return value as string; +} diff --git a/deploy/charts/dns-api/templates/provider-route53.yaml b/deploy/charts/dns-api/templates/provider-route53.yaml index 64a0cf1..5b6e83f 100644 --- a/deploy/charts/dns-api/templates/provider-route53.yaml +++ b/deploy/charts/dns-api/templates/provider-route53.yaml @@ -6,7 +6,7 @@ metadata: spec: display: name: Amazon Route 53 - description: Public DNS zones and record sets managed through Route 53. + description: Public and adopted private DNS zones and record sets managed through Route 53. logo: url: https://cdn.jsdelivr.net/gh/glincker/thesvg@main/public/icons/aws-amazon-route-53/default.svg versions: @@ -26,15 +26,21 @@ spec: type: object additionalProperties: false properties: + zoneType: + type: string + description: Route 53 hosted zone type. Defaults to Public. Private is adoption-only and requires zoneCreationPolicy Deny and zoneDeletionPolicy Retain; dns-api never creates, deletes, or manages VPC associations for private hosted zones. + enum: + - Public + - Private zoneCreationPolicy: type: string - description: Controls whether dns-api may create a Route 53 public hosted zone when a Zone does not adopt an existing hosted zone. + description: 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. enum: - Create - Deny zoneDeletionPolicy: type: string - description: Controls whether dns-api deletes the Route 53 hosted zone when the Kubernetes Zone is deleted. + description: Controls whether dns-api deletes the Route 53 hosted zone when the Kubernetes Zone is deleted. Private requires Retain. enum: - Delete - Retain @@ -61,7 +67,7 @@ spec: properties: hostedZoneId: type: string - description: Route 53 public hosted zone ID of the existing hosted zone to adopt. + 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]+$' statusProviderData: openAPIV3Schema: @@ -72,6 +78,12 @@ spec: type: string description: Route 53 hosted zone ID managed for this Zone. pattern: '^Z[A-Z0-9]+$' + zoneType: + type: string + description: Observed Route 53 hosted zone type. + enum: + - Public + - Private recordSet: supportedTypes: - A diff --git a/docs/design/route53-provider.md b/docs/design/route53-provider.md index 1b13706..91f289e 100644 --- a/docs/design/route53-provider.md +++ b/docs/design/route53-provider.md @@ -7,7 +7,7 @@ `Route53Identity.spec` fields: - `accountID`: final AWS account ID that this identity operates. The controller resolves credentials and calls STS `GetCallerIdentity`; returned account ID must match. -- `region`: AWS region used for SDK config and STS endpoint resolution. Required. Route 53 public hosted zones are global, but WebIdentity credentials, `AssumeRole`, `GetCallerIdentity`, and Route 53 client endpoint resolution use this region. +- `region`: AWS region used for SDK config and STS endpoint resolution. Required. Route 53 hosted zones are global, but WebIdentity credentials, `AssumeRole`, `GetCallerIdentity`, and Route 53 client endpoint resolution use this region. - `credentials`: credential source union. Initially only `runtime` is accepted. - `credentials.runtime`: read the base credential from the AWS SDK default credential chain in the controller runtime environment. - `assumeRoleChain`: array of IAM roles to assume sequentially from the base credential. If omitted, use the base credential directly. @@ -107,14 +107,15 @@ Route 53 controller reconciles `Route53Identity`. It validates the spec, resolve Route 53 provider schema is represented by `Provider/route53.dns.appthrust.io` version `v1alpha1`. Route 53 `ZoneClass` configuration is stored in `ZoneClass.spec.parameters`. `ZoneClass.spec.provider.name` and claim `spec.provider.name` reference `route53.dns.appthrust.io`; their `version` fields reference the served Provider version. -Route 53 provider handles Public DNS only. Hosted zones created by the Route 53 controller are public hosted zones. VPC settings for private hosted zones are not stored in `ZoneClass` or `Zone`. +Route 53 provider handles public hosted zones and adoption of existing private hosted zones. `ZoneClass.spec.parameters.zoneType` selects `Public` or `Private`; absent means `Public` and preserves existing behavior. Hosted zones created by the Route 53 controller are always public. VPC settings for private hosted zones are not stored in `ZoneClass` or `Zone`, and dns-api never manages VPC associations. Route 53 identity selection uses `ZoneClass.spec.identityRef.name`. The selected identity resource is a `Route53Identity` in the same namespace as the `ZoneClass`. Route 53 `ZoneClass.spec.parameters` fields: -- `zoneCreationPolicy`: whether new hosted zone creation is allowed. Values: `Create` or `Deny`. Default: `Create`. -- `zoneDeletionPolicy`: whether the hosted zone is deleted when the `Zone` is deleted. Values: `Delete` or `Retain`. Default: `Retain`. +- `zoneType`: hosted zone type. Values: `Public` or `Private`. Default: `Public`. Private hosted zones are adoption-only. +- `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`. - `tags`: Route 53 tags added to created hosted zones. @@ -122,19 +123,30 @@ Route 53 `Provider` schema descriptions for `zoneClass.schemas.parameters`: | Field | `description` | | --- | --- | -| `zoneCreationPolicy` | `Controls whether dns-api may create a Route 53 public hosted zone when a Zone does not adopt an existing hosted zone.` | -| `zoneDeletionPolicy` | `Controls whether dns-api deletes the Route 53 hosted zone when the Kubernetes Zone is deleted.` | +| `zoneType` | `Route 53 hosted zone type. Defaults to Public. Private is adoption-only and requires zoneCreationPolicy Deny and zoneDeletionPolicy Retain; dns-api never creates, deletes, or manages VPC associations for private hosted zones.` | +| `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.` | | `tags` | `Additional Route 53 tags applied to hosted zones created by this ZoneClass.` | | `tags.` | `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`. +### 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. + +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. + +A Zone without `spec.adoption` under a Private class is rejected with `Accepted=False`, reason `DeniedByPolicy`, even when `zoneCreationPolicy` is omitted. Deleting a Zone retains the private hosted zone. Private hosted zones have no delegation set, so `Zone.status.nameServers` is empty; dns-api does not fabricate name servers. + +See [the private hosted zone sample](../../app/operator/config/samples/route53_private_zoneclass.yaml) for a Route53Identity, adoption-only ZoneClass, Zone, and CNAME RecordSet. + ### Controller Responsibility Route 53 ZoneClass controller reconciles `ZoneClass` resources whose `spec.controllerName` matches the Route 53 controller name and whose `spec.provider.name` is `route53.dns.appthrust.io`. It interprets `ZoneClass.spec.identityRef` as a `Route53Identity` reference, interprets `ZoneClass.spec.parameters` as Route 53 schema and policy, checks that the identity can be statically resolved, and updates `ZoneClass.status.conditions[Accepted]`. It does not call hosted zone APIs. -Route 53 ZoneUnit controller reconciles `ZoneUnit` resources whose `spec.zone.zoneClassRef` resolves to a matching Route 53 `ZoneClass`. After confirming the referenced `Route53Identity` is `Ready=True`, it creates, adopts, deletes, and updates hosted zones; reconciles child record sets to Route 53; and updates `ZoneUnit.status`. It does not read or write `Zone` or `RecordSet` claims. +Route 53 ZoneUnit controller reconciles `ZoneUnit` resources whose `spec.zone.zoneClassRef` resolves to a matching Route 53 `ZoneClass`. After confirming the referenced `Route53Identity` is `Ready=True`, it creates and deletes public hosted zones according to policy, adopts public or private hosted zones matching `zoneType`, reconciles child record sets to Route 53, and updates `ZoneUnit.status`. It does not read or write `Zone` or `RecordSet` claims. If `identityRef` cannot be resolved or the referenced `Route53Identity` is not resolved enough to decide, the Route 53 controller does not proceed with hosted zone reconciliation and sets `ZoneUnit.status.zone.conditions[Accepted]` to `Unknown`, reason `IdentityNotResolved`. If the referenced `Route53Identity` is `Accepted=False`, it sets `Accepted=False`, reason `InvalidIdentityRef`. @@ -144,13 +156,13 @@ If the referenced `Route53Identity` is `Accepted=True` but not `Ready=True`, the When `zoneDeletionPolicy=Retain`, deleting the core `Zone` does not delete the Route 53 hosted zone. The Route 53 ZoneUnit controller removes its provider `ZoneUnit` finalizer after recording cleanup completion. If policy changes from `Delete` to `Retain` while the `ZoneUnit` is not being deleted, the controller does not start provider deletion. -When `zoneDeletionPolicy=Delete`, the Route 53 ZoneUnit controller keeps a provider finalizer on `ZoneUnit`. When `ZoneUnit.metadata.deletionTimestamp` is set, it deletes the Route 53 hosted zone, then removes the provider finalizer after deletion completes. +When `zoneDeletionPolicy=Delete`, which is allowed only for Public classes, the Route 53 ZoneUnit controller keeps a provider finalizer on `ZoneUnit`. When `ZoneUnit.metadata.deletionTimestamp` is set, it deletes the Route 53 hosted zone, then removes the provider finalizer after deletion completes. A Private class never reaches `DeleteHostedZone`. If Route 53 rejects hosted zone deletion, the controller keeps the finalizer and sets `Programmed=False`, reason `ProviderConflict`. For example, non-default record sets remaining in the hosted zone are treated this way. -When `Zone.spec.adoption` is absent, the first implementation milestone creates public hosted zones only when `zoneCreationPolicy=Create`. If `Zone.spec.adoption` is absent and `zoneCreationPolicy=Deny`, the Route 53 controller sets `Zone Accepted=False`, reason `DeniedByPolicy`. `zoneCreationPolicy=Deny` is for adoption-only classes. +When `Zone.spec.adoption` is absent, the controller creates public hosted zones only when `zoneType=Public` and `zoneCreationPolicy=Create`. If `Zone.spec.adoption` is absent and the effective `zoneCreationPolicy` is `Deny`, the Route 53 controller sets `Zone Accepted=False`, reason `DeniedByPolicy`. `zoneCreationPolicy=Deny` is for adoption-only classes and is always effective for Private classes, including when the parameter is omitted. -`sameNameZonePolicy` applies only to creation when `Zone.spec.adoption` is absent. With `Deny`, an existing same-name public hosted zone results in `Programmed=False`, reason `ProviderConflict`. With `Allow`, a new hosted zone may be created even when a same-name zone exists. To manage an existing hosted zone, use `Zone.spec.adoption`. +`sameNameZonePolicy` applies only to public hosted zone creation when `Zone.spec.adoption` is absent. With `Deny`, an existing same-name public hosted zone results in `Programmed=False`, reason `ProviderConflict`. With `Allow`, a new public hosted zone may be created even when a same-name zone exists. It does not allow creation under a Private class. To manage an existing hosted zone, use `Zone.spec.adoption`. 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. @@ -176,9 +188,9 @@ Route 53 `Provider` schema descriptions for `zone.schemas.adoption`: | Field | `description` | | --- | --- | -| `hostedZoneId` | `Route 53 public hosted zone ID of the existing hosted zone to adopt.` | +| `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.` | -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 it is public. If it matches, the controller manages it and stores `hostedZoneId` in `ZoneUnit.status.zone.provider.data.hostedZoneID`. If the hosted zone does not exist, set `Programmed=False`, reason `ExternalResourceNotFound`. If it is private or the name differs, set `Programmed=False`, reason `ExternalResourceMismatch`. +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`. Created hosted zones receive ownership tags: @@ -188,7 +200,7 @@ Created hosted zones receive ownership tags: - `appthrust.io/zone-class-namespace`: `ZoneClass` namespace - `appthrust.io/zone-class-name`: `ZoneClass` name -`ZoneClass.spec.parameters.tags` are additional tags separate from ownership tags. Route 53 controller applies both ownership tags and `ZoneClass.spec.parameters.tags`. +`ZoneClass.spec.parameters.tags` are additional tags separate from ownership tags. Route 53 controller applies both ownership tags and `ZoneClass.spec.parameters.tags` to created public hosted zones; adoption does not apply these tags. Ownership tag keys are reserved and managed by the controller. `ZoneClass.spec.parameters.tags` must not use the same keys. If a reserved key is specified, the controller sets `Accepted=False`, reason `DeniedByPolicy`. @@ -196,17 +208,19 @@ Test tags are also specified in `ZoneClass.spec.parameters.tags`. kest uses `app The initial milestone does not support shared ownership of hosted zones. Ownership tag naming assumes one platform. Shared ownership will require a later design revision. -Route 53-specific public hosted zone data is stored in `ZoneUnit.status.zone.provider.data` and projected to `Zone.status.provider.data`, validated by Route 53 Provider version `zone.schemas.statusProviderData`. `hostedZoneID` is public because users can copy it into adoption settings, use it to open the hosted zone in the AWS dashboard, and include it in support or troubleshooting workflows. +Route 53-specific hosted zone data is stored in the user-visible `ZoneUnit.status.zone.provider.data` and projected to `Zone.status.provider.data`, validated by Route 53 Provider version `zone.schemas.statusProviderData`. `hostedZoneID` is public because users can copy it into adoption settings, use it to open the hosted zone in the AWS dashboard, and include it in support or troubleshooting workflows. `zoneType` records the observed hosted zone type alongside the ID for both public and private zones. 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`. 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.` | 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`. diff --git a/internal/go/providers/route53/controllers/zoneclass/controller.go b/internal/go/providers/route53/controllers/zoneclass/controller.go index d24a08d..1ff95f3 100644 --- a/internal/go/providers/route53/controllers/zoneclass/controller.go +++ b/internal/go/providers/route53/controllers/zoneclass/controller.go @@ -73,6 +73,14 @@ func (r *ZoneClassReconciler) acceptZoneClass(ctx context.Context, zoneClass *dn if err != nil { return metav1.ConditionFalse, "InvalidParameters", err.Error(), nil } + if params.ZoneType == route53v1alpha1.ZoneTypePrivate { + if params.ZoneCreationPolicy != "" && params.ZoneCreationPolicy != route53v1alpha1.ZoneCreationPolicyDeny { + return metav1.ConditionFalse, "InvalidParameters", "zoneType Private requires zoneCreationPolicy Deny", nil + } + if params.ZoneDeletionPolicy != "" && params.ZoneDeletionPolicy != route53v1alpha1.ZoneDeletionPolicyRetain { + return metav1.ConditionFalse, "InvalidParameters", "zoneType Private requires zoneDeletionPolicy Retain", nil + } + } if zoneClass.Spec.IdentityRef.Name == "" { return metav1.ConditionFalse, "InvalidIdentityRef", "spec.identityRef.name is required", nil } diff --git a/internal/go/providers/route53/controllers/zoneclass/controller_test.go b/internal/go/providers/route53/controllers/zoneclass/controller_test.go index 710489a..aa287a8 100644 --- a/internal/go/providers/route53/controllers/zoneclass/controller_test.go +++ b/internal/go/providers/route53/controllers/zoneclass/controller_test.go @@ -7,6 +7,7 @@ import ( 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" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -35,6 +36,71 @@ func TestZoneClassReconcilerAcceptsRoute53Parameters(t *testing.T) { assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") } +func TestZoneClassReconcilerValidatesPrivateZonePolicies(t *testing.T) { + for _, test := range []struct { + name string + parameters string + message string + }{ + { + name: "private defaults", + parameters: `{"zoneType":"Private"}`, + }, + { + name: "private explicit policies and tags", + parameters: `{"zoneType":"Private","zoneCreationPolicy":"Deny","zoneDeletionPolicy":"Retain","sameNameZonePolicy":"Allow","tags":{"project":"dns-api"}}`, + }, + { + name: "private cannot create", + parameters: `{"zoneType":"Private","zoneCreationPolicy":"Create"}`, + message: "zoneType Private requires zoneCreationPolicy Deny", + }, + { + name: "private cannot delete", + parameters: `{"zoneType":"Private","zoneDeletionPolicy":"Delete"}`, + message: "zoneType Private requires zoneDeletionPolicy Retain", + }, + { + name: "private creation policy checked first", + parameters: `{"zoneType":"Private","zoneCreationPolicy":"Create","zoneDeletionPolicy":"Delete"}`, + message: "zoneType Private requires zoneCreationPolicy Deny", + }, + { + name: "public lifecycle unchanged", + parameters: `{"zoneType":"Public","zoneCreationPolicy":"Create","zoneDeletionPolicy":"Delete"}`, + }, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + zoneClass := route53ZoneClass("platform", "route53-private", nil) + zoneClass.Spec.Parameters = runtime.RawExtension{Raw: []byte(test.parameters)} + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), acceptedRoute53Identity("platform", "route53-dev"), zoneClass). + WithStatusSubresource(&dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}). + Build() + reconciler := &ZoneClassReconciler{Client: k8sClient} + key := client.ObjectKeyFromObject(zoneClass) + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: key}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + + var got dnsv1alpha1.ZoneClass + if err := k8sClient.Get(ctx, key, &got); err != nil { + t.Fatalf("ZoneClass was not found: %v", err) + } + if test.message == "" { + assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") + return + } + condition := assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "InvalidParameters") + if condition.Message != test.message { + t.Fatalf("Accepted message = %q, want %q", condition.Message, test.message) + } + }) + } +} + func TestZoneClassReconcilerRejectsReservedTags(t *testing.T) { ctx := context.Background() scheme := testScheme(t) diff --git a/internal/go/providers/route53/controllers/zoneunit/status_recovery_test.go b/internal/go/providers/route53/controllers/zoneunit/status_recovery_test.go index 40bd091..1d6ddce 100644 --- a/internal/go/providers/route53/controllers/zoneunit/status_recovery_test.go +++ b/internal/go/providers/route53/controllers/zoneunit/status_recovery_test.go @@ -733,12 +733,16 @@ func setRoute53ReadyZoneUnitStatus(t *testing.T, unit *dnsv1alpha1.ZoneUnit) { t.Helper() state, err := json.Marshal(route53v1alpha1.Route53ZoneStatusData{ HostedZoneID: "Z000001", + ZoneType: route53v1alpha1.ZoneTypePublic, CallerReference: "dns-api:11111111-2222-3333-4444-555555555555", }) if err != nil { t.Fatal(err) } - public, err := json.Marshal(route53v1alpha1.Route53ZoneStatusData{HostedZoneID: "Z000001"}) + public, err := json.Marshal(route53v1alpha1.Route53ZoneStatusData{ + HostedZoneID: "Z000001", + ZoneType: route53v1alpha1.ZoneTypePublic, + }) if err != nil { t.Fatal(err) } diff --git a/internal/go/providers/route53/controllers/zoneunit/zone.go b/internal/go/providers/route53/controllers/zoneunit/zone.go index 43e86c3..1c549b6 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone.go @@ -41,7 +41,7 @@ var reservedHostedZoneTagKeys = map[string]struct{}{ "appthrust.io/zone-class-name": {}, } -// ZoneReconciler reconciles core Zone objects for Route 53 public hosted zones. +// ZoneReconciler reconciles core Zone objects for Route 53 hosted zones. type ZoneReconciler struct { client.Client @@ -149,7 +149,7 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl. } if err := r.patchRoute53ZoneStatus(ctx, &zone, func(data *route53v1alpha1.Route53ZoneStatusData) { - data.HostedZoneID = hostedZone.ID + setRoute53ZoneStatusData(data, hostedZone) if hostedZone.CallerReference == callerReferenceForZoneState(&zone, statusData) { data.CallerReference = hostedZone.CallerReference } @@ -466,12 +466,17 @@ func (r *ZoneReconciler) reconcileDelete(ctx context.Context, zone *dnsv1alpha1. return ctrl.Result{}, r.setAccepted(ctx, zone, metav1.ConditionFalse, "InvalidAdoption", err.Error()) } if hostedZoneID != "" { - if _, err := provider.GetHostedZone(ctx, hostedZoneID); err != nil { + hostedZone, err := provider.GetHostedZone(ctx, hostedZoneID) + if err != nil { if isProviderNotFound(err) { return ctrl.Result{}, r.removeFinalizer(ctx, unit) } return r.failProgrammedForProviderError(ctx, zone, err) } + if hostedZone.Private { + r.recordEvent(zone, corev1.EventTypeNormal, "ExternalResourceRetained", "Zone was deleted and the private DNS zone was retained") + return ctrl.Result{}, r.removeFinalizer(ctx, unit) + } change, err := provider.DeleteHostedZone(ctx, hostedZoneID) if err != nil { @@ -636,6 +641,7 @@ func (r *ZoneReconciler) clearHostedZoneProviderStatus(ctx context.Context, zone return err } statusData.HostedZoneID = "" + statusData.ZoneType = "" statusData.CallerReference = "" statusData.PendingHostedZoneChange = nil statusData.PendingRecordSetChange = nil @@ -676,7 +682,7 @@ func (r *ZoneReconciler) createHostedZone(ctx context.Context, provider Provider } if err := r.patchRoute53ZoneStatus(ctx, zone, func(data *route53v1alpha1.Route53ZoneStatusData) { - data.HostedZoneID = created.HostedZone.ID + setRoute53ZoneStatusData(data, created.HostedZone) data.CallerReference = callerReference if created.Change != nil && created.Change.Status == route53v1alpha1.Route53ChangeStatusPending { data.PendingHostedZoneChange = pendingChangeFromChange(created.Change, "CREATE") diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_private_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_private_test.go new file mode 100644 index 0000000..82ea65c --- /dev/null +++ b/internal/go/providers/route53/controllers/zoneunit/zone_private_test.go @@ -0,0 +1,273 @@ +package route53 + +import ( + "context" + "slices" + "testing" + + zoneclass "github.com/appthrust/dns-api/internal/go/providers/route53/controllers/zoneclass" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + 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" +) + +func TestZoneReconcilerAdoptionMatchesZoneType(t *testing.T) { + for _, test := range []struct { + name string + parameters string + private bool + zoneType route53v1alpha1.ZoneType + message string + }{ + { + name: "private adoption", + parameters: `{"zoneType":"Private"}`, + private: true, + zoneType: route53v1alpha1.ZoneTypePrivate, + }, + { + name: "default public adoption", + parameters: `{}`, + zoneType: route53v1alpha1.ZoneTypePublic, + }, + { + name: "explicit public adoption", + parameters: `{"zoneType":"Public"}`, + zoneType: route53v1alpha1.ZoneTypePublic, + }, + { + name: "private class rejects public zone", + parameters: `{"zoneType":"Private"}`, + message: "Route 53 hosted zone is public; ZoneClass zoneType is Private", + }, + { + name: "default public class rejects private zone", + parameters: `{}`, + private: true, + message: "Route 53 hosted zone is private; only public hosted zones are supported", + }, + { + name: "explicit public class rejects private zone", + parameters: `{"zoneType":"Public"}`, + private: true, + message: "Route 53 hosted zone is private; only public hosted zones are supported", + }, + } { + t.Run(test.name, func(t *testing.T) { + ctx := context.Background() + provider := newFakeProvider() + hostedZone := HostedZone{ID: "ZADOPT", Name: "apps.example.com", Private: test.private} + if !test.private { + hostedZone.NameServers = []string{"ns-1.awsdns.example", "ns-2.awsdns.example"} + } + provider.zones[hostedZone.ID] = hostedZone + objects := sameNamespaceClassObjects("app", nil) + zone := objects[len(objects)-1].(*dnsv1alpha1.Zone) + zone.Spec.Adoption = runtime.RawExtension{Raw: []byte(`{"hostedZoneId":"ZADOPT"}`)} + for _, object := range objects { + if class, ok := object.(*dnsv1alpha1.ZoneClass); ok { + class.Spec.Parameters = runtime.RawExtension{Raw: []byte(test.parameters)} + } + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + if _, err := (&zoneclass.ZoneClassReconciler{Client: k8sClient}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKey{Namespace: "app", Name: "route53-public"}}); err != nil { + t.Fatalf("ZoneClass reconcile returned error: %v", err) + } + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + key := client.ObjectKeyFromObject(zone) + if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: key}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.created) != 0 || len(provider.deleted) != 0 { + t.Fatalf("hosted zone mutations: created = %d, deleted = %v, want none", len(provider.created), provider.deleted) + } + + var got dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, key, &got); err != nil { + t.Fatalf("Zone was not found: %v", err) + } + assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") + var unit dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, key, &unit); err != nil { + t.Fatalf("ZoneUnit was not found: %v", err) + } + if unit.Status.Zone == nil { + t.Fatal("status.zone was nil") + } + if test.message != "" { + assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ExternalResourceMismatch") + condition := assertCondition(t, unit.Status.Zone.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionFalse, "ExternalResourceMismatch") + if condition.Message != test.message { + t.Fatalf("Programmed message = %q, want %q", condition.Message, test.message) + } + if len(provider.tags) != 0 { + t.Fatalf("mismatched zone was tagged: %#v", provider.tags) + } + return + } + assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") + if got.Status.Provider == nil { + t.Fatal("status.provider was nil") + } + assertRoute53RawEqual(t, got.Status.Provider.Data, map[string]any{"hostedZoneID": "ZADOPT", "zoneType": string(test.zoneType)}) + state := mustZoneUnitStatusData(t, ctx, k8sClient, key.Namespace, key.Name) + if state.HostedZoneID != "ZADOPT" || state.ZoneType != test.zoneType { + t.Fatalf("provider state = %#v, want ZADOPT and %s", state, test.zoneType) + } + if !slices.Equal(got.Status.NameServers, hostedZone.NameServers) { + t.Fatalf("nameServers = %#v, want %#v", got.Status.NameServers, hostedZone.NameServers) + } + if test.private && slices.Contains(unit.Finalizers, ZoneFinalizer) { + t.Fatalf("private ZoneUnit has hosted zone deletion finalizer: %v", unit.Finalizers) + } + }) + } +} + +func TestZoneReconcilerDeniesPrivateZoneWithoutAdoption(t *testing.T) { + for _, parameters := range []string{ + `{"zoneType":"Private"}`, + `{"zoneType":"Private","zoneCreationPolicy":"Deny"}`, + `{"zoneType":"Private","zoneCreationPolicy":"Create"}`, + } { + t.Run(parameters, func(t *testing.T) { + ctx := context.Background() + provider := newFakeProvider() + objects := sameNamespaceClassObjects("app", nil) + for _, object := range objects { + if class, ok := object.(*dnsv1alpha1.ZoneClass); ok { + class.Spec.Parameters = runtime.RawExtension{Raw: []byte(parameters)} + } + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + key := client.ObjectKey{Namespace: "app", Name: "apps-example-com"} + if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, ctrl.Request{NamespacedName: key}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + var got dnsv1alpha1.Zone + if err := k8sClient.Get(ctx, key, &got); err != nil { + t.Fatalf("Zone was not found: %v", err) + } + assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionFalse, "DeniedByPolicy") + if len(provider.created) != 0 { + t.Fatalf("created hosted zones = %d, want 0", len(provider.created)) + } + }) + } +} + +func TestZoneReconcilerRetainsPrivateHostedZoneOnDeletion(t *testing.T) { + for _, parameters := range []string{ + `{"zoneType":"Private"}`, + `{"zoneType":"Private","zoneDeletionPolicy":"Retain"}`, + `{"zoneType":"Private","zoneDeletionPolicy":"Delete"}`, + `{"zoneDeletionPolicy":"Delete"}`, + } { + t.Run(parameters, func(t *testing.T) { + ctx := context.Background() + provider := newFakeProvider() + provider.zones["ZPRIVATE"] = HostedZone{ID: "ZPRIVATE", Name: "apps.example.com", Private: true} + class := route53ZoneClass("app", "route53-private", nil) + class.Spec.Parameters = runtime.RawExtension{Raw: []byte(parameters)} + unit := route53ZoneUnit("app", "apps-example-com", "app", class.Name) + unit.Spec.Zone.Adoption = runtime.RawExtension{Raw: []byte(`{"hostedZoneId":"ZPRIVATE"}`)} + markRoute53ZoneUnitDeleting(unit, metav1.Now()) + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(route53Provider(), class, acceptedRoute53Identity("app", "route53-dev"), unit). + WithStatusSubresource(&dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + recorder := &capturingRecorder{} + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider, Recorder: recorder} + key := client.ObjectKeyFromObject(unit) + if _, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: key}); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.deleted) != 0 { + t.Fatalf("DeleteHostedZone calls = %v, want none", provider.deleted) + } + if _, ok := provider.zones["ZPRIVATE"]; !ok { + t.Fatal("private hosted zone was deleted") + } + if !recorder.has("Zone", "ExternalResourceRetained") { + t.Fatalf("events = %#v, want ExternalResourceRetained", recorder.events) + } + var got dnsv1alpha1.ZoneUnit + if err := k8sClient.Get(ctx, key, &got); err != nil { + if apierrors.IsNotFound(err) { + return + } + t.Fatalf("ZoneUnit get returned error: %v", err) + } + if slices.Contains(got.Finalizers, ZoneFinalizer) { + t.Fatalf("ZoneUnit finalizers = %v, want hosted zone deletion finalizer removed", got.Finalizers) + } + }) + } +} + +func TestZoneReconcilerWritesRecordSetIntoAdoptedPrivateZone(t *testing.T) { + ctx := context.Background() + provider := newFakeProvider() + provider.zones["ZPRIVATE"] = HostedZone{ID: "ZPRIVATE", Name: "apps.example.com", Private: true} + recordSet := route53ARecordSet("app", "www") + objects := route53RecordSetObjects(t, recordSet) + for _, object := range objects { + switch typed := object.(type) { + case *dnsv1alpha1.ZoneClass: + typed.Spec.Parameters = runtime.RawExtension{Raw: []byte(`{"zoneType":"Private"}`)} + case *dnsv1alpha1.Zone: + typed.Spec.Adoption = runtime.RawExtension{Raw: []byte(`{"hostedZoneId":"ZPRIVATE"}`)} + typed.Status = dnsv1alpha1.ZoneStatus{} + } + } + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme(t)). + WithObjects(objects...). + WithStatusSubresource(&dnsv1alpha1.Zone{}, &dnsv1alpha1.RecordSet{}, &dnsv1alpha1.ZoneClass{}, &route53v1alpha1.Route53Identity{}, &dnsv1alpha1.ZoneUnit{}). + Build() + reconciler := &ZoneReconciler{Client: k8sClient, Provider: provider} + request := ctrl.Request{NamespacedName: client.ObjectKey{Namespace: "app", Name: "apps-example-com"}} + if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, request); err != nil { + t.Fatalf("Reconcile returned error: %v", err) + } + if len(provider.upserted) != 1 { + t.Fatalf("upserted record sets = %d, want 1", len(provider.upserted)) + } + upserted := provider.upserted[0] + if upserted.HostedZoneID != "ZPRIVATE" || upserted.Name != "www.apps.example.com." || upserted.Type != dnsv1alpha1.RecordTypeA { + t.Fatalf("upserted identity = %#v", upserted) + } + if upserted.TTL == nil || *upserted.TTL != 300 || !slices.Equal(upserted.Values, []string{"192.0.2.10"}) { + t.Fatalf("upserted record = %#v, want TTL 300 and value 192.0.2.10", upserted) + } + for _, change := range provider.changes { + change.Status = route53v1alpha1.Route53ChangeStatusInSync + } + if _, err := reconcileRoute53AndProject(t, ctx, k8sClient, reconciler, request); err != nil { + t.Fatalf("second Reconcile returned error: %v", err) + } + var got dnsv1alpha1.RecordSet + if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(recordSet), &got); err != nil { + t.Fatalf("RecordSet was not found: %v", err) + } + assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionAccepted), metav1.ConditionTrue, "Accepted") + assertCondition(t, got.Status.Conditions, string(dnsv1alpha1.ConditionProgrammed), metav1.ConditionTrue, "Programmed") + if len(provider.created) != 0 || len(provider.deleted) != 0 { + t.Fatalf("hosted zone mutations: created = %d, deleted = %v, want none", len(provider.created), provider.deleted) + } +} diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_status.go b/internal/go/providers/route53/controllers/zoneunit/zone_status.go index 3f21286..8ed6e2f 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone_status.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone_status.go @@ -248,7 +248,10 @@ func (r *ZoneReconciler) patchRoute53ZoneStatus(ctx context.Context, zone *dnsv1 return err } return r.patchZoneStatus(ctx, zone, func(status *dnsv1alpha1.ZoneStatus) { - publicRaw, _ := json.Marshal(route53v1alpha1.Route53ZoneStatusData{HostedZoneID: statusData.HostedZoneID}) + publicRaw, _ := json.Marshal(route53v1alpha1.Route53ZoneStatusData{ + HostedZoneID: statusData.HostedZoneID, + ZoneType: statusData.ZoneType, + }) status.Provider = &dnsv1alpha1.ProviderStatus{ Data: runtime.RawExtension{Raw: publicRaw}, State: runtime.RawExtension{Raw: raw}, @@ -353,7 +356,17 @@ func route53ZoneUnitStatusData(unit *dnsv1alpha1.ZoneUnit) (route53v1alpha1.Rout return route53v1alpha1.Route53ZoneStatusData{}, nil } +func zoneType(params *route53v1alpha1.Route53ZoneClassParameters) route53v1alpha1.ZoneType { + if params.ZoneType == "" { + return route53v1alpha1.ZoneTypePublic + } + return params.ZoneType +} + func zoneCreationPolicy(params *route53v1alpha1.Route53ZoneClassParameters) route53v1alpha1.ZoneCreationPolicy { + if zoneType(params) == route53v1alpha1.ZoneTypePrivate { + return route53v1alpha1.ZoneCreationPolicyDeny + } if params.ZoneCreationPolicy == "" { return route53v1alpha1.ZoneCreationPolicyCreate } @@ -361,7 +374,7 @@ func zoneCreationPolicy(params *route53v1alpha1.Route53ZoneClassParameters) rout } func zoneDeletionPolicy(params *route53v1alpha1.Route53ZoneClassParameters) route53v1alpha1.ZoneDeletionPolicy { - if params.ZoneDeletionPolicy == "" { + if zoneType(params) == route53v1alpha1.ZoneTypePrivate || params.ZoneDeletionPolicy == "" { return route53v1alpha1.ZoneDeletionPolicyRetain } return params.ZoneDeletionPolicy @@ -469,12 +482,23 @@ func isRoute53HostedZoneExternalRefID(id string) bool { return true } +func setRoute53ZoneStatusData(data *route53v1alpha1.Route53ZoneStatusData, hostedZone HostedZone) { + data.HostedZoneID = hostedZone.ID + data.ZoneType = route53v1alpha1.ZoneTypePublic + if hostedZone.Private { + data.ZoneType = route53v1alpha1.ZoneTypePrivate + } +} + func hostedZoneSpecMismatch(zone *dnsv1alpha1.Zone, params *route53v1alpha1.Route53ZoneClassParameters, hostedZone HostedZone) string { if hostedZone.Name != zone.Spec.DomainName { return "Route 53 hosted zone name does not match Zone domainName" } - if hostedZone.Private { - return "Route 53 hosted zone is private; only public hosted zones are supported" + if hostedZone.Private != (zoneType(params) == route53v1alpha1.ZoneTypePrivate) { + if hostedZone.Private { + return "Route 53 hosted zone is private; only public hosted zones are supported" + } + return "Route 53 hosted zone is public; ZoneClass zoneType is Private" } return "" } diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_test.go index 2410a35..3fe9151 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone_test.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone_test.go @@ -106,6 +106,9 @@ func TestZoneReconcilerCreatesHostedZone(t *testing.T) { if data.HostedZoneID != "Z000001" { t.Fatalf("hostedZoneID = %q, want Z000001", data.HostedZoneID) } + if data.ZoneType != route53v1alpha1.ZoneTypePublic { + t.Fatalf("zoneType = %q, want Public", data.ZoneType) + } unitData := mustZoneUnitStatusData(t, ctx, k8sClient, zone.Namespace, zone.Name) if unitData.PendingHostedZoneChange == nil || unitData.PendingHostedZoneChange.Status != route53v1alpha1.Route53ChangeStatusPending { t.Fatalf("pendingHostedZoneChange = %#v, want PENDING", unitData.PendingHostedZoneChange) diff --git a/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go b/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go index 86a82f9..fc9c468 100644 --- a/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go +++ b/internal/go/providers/route53/controllers/zoneunit/zone_test_fixtures_test.go @@ -394,6 +394,7 @@ func setZoneStatusData(t *testing.T, zone *dnsv1alpha1.Zone, data route53v1alpha publicData := route53v1alpha1.Route53ZoneStatusData{} if data.HostedZoneID != "" { publicData.HostedZoneID = data.HostedZoneID + publicData.ZoneType = data.ZoneType } publicRaw, err := json.Marshal(publicData) if err != nil { @@ -545,6 +546,7 @@ type fakeProvider struct { changes map[string]*route53v1alpha1.Route53Change records map[string]RecordSetResource created []createHostedZoneRequest + deleted []string upserted []RecordSetResource deletedRRs []RecordSetResource @@ -606,6 +608,7 @@ func (p *fakeProvider) GetChange(_ context.Context, id string) (*route53v1alpha1 return change, nil } func (p *fakeProvider) DeleteHostedZone(_ context.Context, id string) (*route53v1alpha1.Route53Change, error) { + p.deleted = append(p.deleted, normalizeHostedZoneID(id)) delete(p.zones, normalizeHostedZoneID(id)) change := &route53v1alpha1.Route53Change{ ID: "CDELETE", diff --git a/pkg/go/api/route53/v1alpha1/route53zone_types.go b/pkg/go/api/route53/v1alpha1/route53zone_types.go index 71f7efa..a30654d 100644 --- a/pkg/go/api/route53/v1alpha1/route53zone_types.go +++ b/pkg/go/api/route53/v1alpha1/route53zone_types.go @@ -16,6 +16,9 @@ type Route53ZoneStatusData struct { // HostedZoneID is the normalized Route 53 hosted zone ID in Z... form. HostedZoneID string `json:"hostedZoneID,omitempty"` + // ZoneType is the observed Route 53 hosted zone type. + ZoneType ZoneType `json:"zoneType,omitempty"` + // CallerReference is the value passed to CreateHostedZone. CallerReference string `json:"callerReference,omitempty"` diff --git a/pkg/go/api/route53/v1alpha1/route53zoneclass_types.go b/pkg/go/api/route53/v1alpha1/route53zoneclass_types.go index ac57def..4f78cf2 100644 --- a/pkg/go/api/route53/v1alpha1/route53zoneclass_types.go +++ b/pkg/go/api/route53/v1alpha1/route53zoneclass_types.go @@ -2,6 +2,13 @@ package v1alpha1 import dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" +type ZoneType string + +const ( + ZoneTypePublic ZoneType = "Public" + ZoneTypePrivate ZoneType = "Private" +) + type ZoneCreationPolicy string const ( @@ -31,6 +38,10 @@ const ( var ProviderRef = dnsv1alpha1.ProviderReference{Name: ProviderName, Version: ProviderVersion} type Route53ZoneClassParameters struct { + // ZoneType selects public or private hosted zones; defaults to Public. + // Private hosted zones are adoption-only and are always retained. + ZoneType ZoneType `json:"zoneType,omitempty"` + // ZoneCreationPolicy controls whether the controller may create hosted zones. ZoneCreationPolicy ZoneCreationPolicy `json:"zoneCreationPolicy,omitempty"` diff --git a/pkg/ts/ui/src/resources.ts b/pkg/ts/ui/src/resources.ts index 677376f..789d7e1 100644 --- a/pkg/ts/ui/src/resources.ts +++ b/pkg/ts/ui/src/resources.ts @@ -76,6 +76,7 @@ export interface ZoneClass extends KubeObjectInterface { identityRef?: { name?: string; }; + zoneType?: 'Public' | 'Private'; zoneCreationPolicy?: 'Create' | 'Deny'; zoneDeletionPolicy?: 'Delete' | 'Retain'; sameNameZonePolicy?: 'Allow' | 'Deny'; diff --git a/pkg/ts/ui/src/views/common/ui.tsx b/pkg/ts/ui/src/views/common/ui.tsx index c769264..a3e5191 100644 --- a/pkg/ts/ui/src/views/common/ui.tsx +++ b/pkg/ts/ui/src/views/common/ui.tsx @@ -1616,7 +1616,7 @@ export function ZoneClassSummaryCard({ zoneClass }: { zoneClass: ZoneClass }) { {descriptionOf(zoneClass) || - 'Public DNS only. Private DNS and VPC selection are not part of this flow.'} + 'Public zones are created or adopted; private zones are adoption-only (ZoneClass zoneType: Private). VPC association is managed outside dns-api.'} diff --git a/pkg/ts/ui/src/views/platform/PlatformIntegrationDetailPage.tsx b/pkg/ts/ui/src/views/platform/PlatformIntegrationDetailPage.tsx index 8fdf785..1170fbc 100644 --- a/pkg/ts/ui/src/views/platform/PlatformIntegrationDetailPage.tsx +++ b/pkg/ts/ui/src/views/platform/PlatformIntegrationDetailPage.tsx @@ -684,7 +684,11 @@ function ZoneClassesTab({ - {zoneClass.spec.parameters.zoneCreationPolicy ?? 'Create'} + {zoneClass.spec.parameters.zoneCreationPolicy ?? + (zoneClass.spec.parameters.zoneType === 'Private' ? 'Deny' : 'Create')} + + + Zone type: {zoneClass.spec.parameters.zoneType ?? 'Public'} diff --git a/pkg/ts/ui/src/views/platform/ZoneClassForm.tsx b/pkg/ts/ui/src/views/platform/ZoneClassForm.tsx index 67dd9b3..58d3d6d 100644 --- a/pkg/ts/ui/src/views/platform/ZoneClassForm.tsx +++ b/pkg/ts/ui/src/views/platform/ZoneClassForm.tsx @@ -41,6 +41,7 @@ type ZoneClassForm = { allowedFrom: 'Same' | 'Selector' | 'All'; allowedLabelsText: string; tags: string; + zoneType: 'Public' | 'Private'; zoneCreationPolicy: 'Create' | 'Deny'; zoneDeletionPolicy: 'Delete' | 'Retain'; sameNameZonePolicy: 'Allow' | 'Deny'; @@ -61,6 +62,7 @@ const zoneClassFormSchema = z.object({ allowedFrom: z.enum(['Same', 'Selector', 'All']), allowedLabelsText: z.string(), tags: z.string(), + zoneType: z.enum(['Public', 'Private']), zoneCreationPolicy: z.enum(['Create', 'Deny']), zoneDeletionPolicy: z.enum(['Delete', 'Retain']), sameNameZonePolicy: z.enum(['Allow', 'Deny']), @@ -85,15 +87,21 @@ function zoneClassParametersFromForm( baseParameters: ZoneClass['spec']['parameters'] = {} ): ZoneClass['spec']['parameters'] { const tags = parseNamespaceLabelsYaml(form.tags); + const isPrivateZone = isRoute53ProviderRef(form.provider) && form.zoneType === 'Private'; const parameters: ZoneClass['spec']['parameters'] = { ...baseParameters, - zoneCreationPolicy: form.zoneCreationPolicy, - zoneDeletionPolicy: form.zoneDeletionPolicy, + zoneCreationPolicy: isPrivateZone ? 'Deny' : form.zoneCreationPolicy, + zoneDeletionPolicy: isPrivateZone ? 'Retain' : form.zoneDeletionPolicy, }; delete (parameters as Record).identityRef; + if (isPrivateZone) { + parameters.zoneType = 'Private'; + } else { + delete parameters.zoneType; + } if (isRoute53ProviderRef(form.provider)) { parameters.tags = tags; - if (form.zoneCreationPolicy === 'Create') { + if (parameters.zoneCreationPolicy === 'Create') { parameters.sameNameZonePolicy = form.sameNameZonePolicy; } else { delete parameters.sameNameZonePolicy; @@ -273,6 +281,7 @@ function zoneClassFormFromResource(zoneClass: ZoneClass): ZoneClassForm { allowedFrom, allowedLabelsText: namespaceLabelsYaml(selector?.matchLabels), tags: namespaceLabelsYaml(zoneClass.spec.parameters.tags), + zoneType: zoneClass.spec.parameters.zoneType ?? 'Public', zoneCreationPolicy: zoneClass.spec.parameters.zoneCreationPolicy ?? 'Create', zoneDeletionPolicy: zoneClass.spec.parameters.zoneDeletionPolicy ?? 'Retain', sameNameZonePolicy: zoneClass.spec.parameters.sameNameZonePolicy ?? 'Deny', @@ -409,6 +418,7 @@ export function ZoneClassFormPage({ allowedFrom: 'Selector', allowedLabelsText: '', tags: '', + zoneType: 'Public', zoneCreationPolicy: 'Create', zoneDeletionPolicy: 'Delete', sameNameZonePolicy: 'Deny', @@ -423,6 +433,7 @@ export function ZoneClassFormPage({ provider => providerRef(provider) === form.provider ) ?? selectedIdentityProvider; const providerObject = providerRefObject(form.provider); + const isPrivateZone = isRoute53ProviderRef(form.provider) && form.zoneType === 'Private'; const preview = zoneClass ? updatedZoneClassPreview(zoneClass, form) : selectedProvider @@ -535,11 +546,30 @@ export function ZoneClassFormPage({ Provider controller instance that reconciles ZoneUnits for this ZoneClass. + {isRoute53ProviderRef(form.provider) ? ( + + Zone type + + + ) : null} Zone creation policy - Controls whether the provider controller may create public DNS zones. Use Deny when - Zones must point to existing public hosted zones with adoption. + {isPrivateZone + ? 'Private zones are adoption-only. dns-api never creates, deletes, or VPC-associates them.' + : 'Controls whether the provider controller may create public DNS zones. Use Deny when Zones must point to existing public hosted zones with adoption.'} - {form.zoneCreationPolicy === 'Create' && isRoute53ProviderRef(form.provider) ? ( + {!isPrivateZone && form.zoneCreationPolicy === 'Create' && isRoute53ProviderRef(form.provider) ? ( <> Duplicate hosted zone policy @@ -583,7 +614,8 @@ export function ZoneClassFormPage({ Zone deletion policy