diff --git a/apis/v1alpha1/ack-generate-metadata.yaml b/apis/v1alpha1/ack-generate-metadata.yaml index 9f0e48a..b77174b 100755 --- a/apis/v1alpha1/ack-generate-metadata.yaml +++ b/apis/v1alpha1/ack-generate-metadata.yaml @@ -1,13 +1,13 @@ ack_generate_info: - build_date: "2026-03-11T22:05:02Z" - build_hash: 5ac6c79fbc941c426d8b70cba768820fc9296542 - go_version: go1.25.7 - version: v0.58.0 -api_directory_checksum: b612de0e7a18f2ad41155b5b645fb6676753f2ba + build_date: "2026-03-16T19:15:38Z" + build_hash: af2891ea8affbe25b5b1c4f397146e208e134603 + go_version: go1.26.1 + version: v0.58.0-2-gaf2891e +api_directory_checksum: 4f28c0f1bd2454828173a60002f54f7963f18c8e api_version: v1alpha1 aws_sdk_go_version: v1.32.6 generator_config_info: - file_checksum: c4156ee69d38bea32cd1ddac3f41da19ebcf2680 + file_checksum: 5ae421d838a4d0224a0630fee8446707b0902f14 original_file_name: generator.yaml last_modification: reason: API generation diff --git a/apis/v1alpha1/generator.yaml b/apis/v1alpha1/generator.yaml index 3461b8b..dde1f50 100644 --- a/apis/v1alpha1/generator.yaml +++ b/apis/v1alpha1/generator.yaml @@ -57,7 +57,6 @@ resources: from: operation: ListResourceRecordSets path: ResourceRecordSets.AliasTarget - # This field cannot be ignored, but values for this will be overriden and should not be used CidrRoutingConfig: from: operation: ListResourceRecordSets @@ -156,6 +155,9 @@ resources: update_operation: custom_method_name: customUpdateRecordSet HostedZone: + custom_cel_rules: + - rule: "!has(self.hostedZoneConfig) || !self.hostedZoneConfig.privateZone || has(self.vpc)" + message: "spec.vpc is required for private hosted zones" exceptions: terminal_codes: - DelegationSetNotReusable @@ -163,6 +165,11 @@ resources: - InvalidInput - InvalidVPCId fields: + AdditionalVPCs: + custom_field: + list_of: VPC + compare: + is_ignored: True DelegationSet: from: operation: CreateHostedZone @@ -176,7 +183,11 @@ resources: is_ignored: True hooks: delta_pre_compare: - code: compareTags(delta, a, b) + code: | + compareTags(delta, a, b) + compareAdditionalVPCs(delta, a, b) + sdk_delete_pre_build_request: + template_path: hooks/hosted_zone/sdk_delete_pre_build_request.go.tpl sdk_read_one_post_set_output: template_path: hooks/hosted_zone/sdk_read_one_post_set_output.go.tpl sdk_create_post_set_output: diff --git a/apis/v1alpha1/hosted_zone.go b/apis/v1alpha1/hosted_zone.go index 5b57ef5..65a30ac 100644 --- a/apis/v1alpha1/hosted_zone.go +++ b/apis/v1alpha1/hosted_zone.go @@ -23,8 +23,9 @@ import ( // HostedZoneSpec defines the desired state of HostedZone. // // A complex type that contains general information about the hosted zone. +// +kubebuilder:validation:XValidation:rule="!has(self.hostedZoneConfig) || !self.hostedZoneConfig.privateZone || has(self.vpc)",message="spec.vpc is required for private hosted zones" type HostedZoneSpec struct { - + AdditionalVPCs []*VPC `json:"additionalVPCs,omitempty"` // If you want to associate a reusable delegation set with this hosted zone, // the ID that Amazon Route 53 assigned to the reusable delegation set when // you created it. For more information about reusable delegation sets, see diff --git a/apis/v1alpha1/zz_generated.deepcopy.go b/apis/v1alpha1/zz_generated.deepcopy.go index 10b2d43..281db5c 100644 --- a/apis/v1alpha1/zz_generated.deepcopy.go +++ b/apis/v1alpha1/zz_generated.deepcopy.go @@ -815,6 +815,17 @@ func (in *HostedZoneList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HostedZoneSpec) DeepCopyInto(out *HostedZoneSpec) { *out = *in + if in.AdditionalVPCs != nil { + in, out := &in.AdditionalVPCs, &out.AdditionalVPCs + *out = make([]*VPC, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(VPC) + (*in).DeepCopyInto(*out) + } + } + } if in.DelegationSetID != nil { in, out := &in.DelegationSetID, &out.DelegationSetID *out = new(string) diff --git a/config/crd/bases/route53.services.k8s.aws_hostedzones.yaml b/config/crd/bases/route53.services.k8s.aws_hostedzones.yaml index cd118ea..b63aeba 100644 --- a/config/crd/bases/route53.services.k8s.aws_hostedzones.yaml +++ b/config/crd/bases/route53.services.k8s.aws_hostedzones.yaml @@ -42,6 +42,24 @@ spec: A complex type that contains general information about the hosted zone. properties: + additionalVPCs: + items: + description: |- + (Private hosted zones only) A complex type that contains information about + an Amazon VPC. + + If you associate a private hosted zone with an Amazon VPC when you make a + CreateHostedZone (https://docs.aws.amazon.com/Route53/latest/APIReference/API_CreateHostedZone.html) + request, the following parameters are also required. + properties: + vpcID: + description: (Private hosted zones only) The ID of an Amazon + VPC. + type: string + vpcRegion: + type: string + type: object + type: array delegationSetID: description: |- If you want to associate a reusable delegation set with this hosted zone, @@ -122,6 +140,10 @@ spec: required: - name type: object + x-kubernetes-validations: + - message: spec.vpc is required for private hosted zones + rule: '!has(self.hostedZoneConfig) || !self.hostedZoneConfig.privateZone + || has(self.vpc)' status: description: HostedZoneStatus defines the observed state of HostedZone properties: diff --git a/generator.yaml b/generator.yaml index 3461b8b..dde1f50 100644 --- a/generator.yaml +++ b/generator.yaml @@ -57,7 +57,6 @@ resources: from: operation: ListResourceRecordSets path: ResourceRecordSets.AliasTarget - # This field cannot be ignored, but values for this will be overriden and should not be used CidrRoutingConfig: from: operation: ListResourceRecordSets @@ -156,6 +155,9 @@ resources: update_operation: custom_method_name: customUpdateRecordSet HostedZone: + custom_cel_rules: + - rule: "!has(self.hostedZoneConfig) || !self.hostedZoneConfig.privateZone || has(self.vpc)" + message: "spec.vpc is required for private hosted zones" exceptions: terminal_codes: - DelegationSetNotReusable @@ -163,6 +165,11 @@ resources: - InvalidInput - InvalidVPCId fields: + AdditionalVPCs: + custom_field: + list_of: VPC + compare: + is_ignored: True DelegationSet: from: operation: CreateHostedZone @@ -176,7 +183,11 @@ resources: is_ignored: True hooks: delta_pre_compare: - code: compareTags(delta, a, b) + code: | + compareTags(delta, a, b) + compareAdditionalVPCs(delta, a, b) + sdk_delete_pre_build_request: + template_path: hooks/hosted_zone/sdk_delete_pre_build_request.go.tpl sdk_read_one_post_set_output: template_path: hooks/hosted_zone/sdk_read_one_post_set_output.go.tpl sdk_create_post_set_output: diff --git a/helm/crds/route53.services.k8s.aws_hostedzones.yaml b/helm/crds/route53.services.k8s.aws_hostedzones.yaml index 75e5f18..b63aeba 100644 --- a/helm/crds/route53.services.k8s.aws_hostedzones.yaml +++ b/helm/crds/route53.services.k8s.aws_hostedzones.yaml @@ -42,6 +42,24 @@ spec: A complex type that contains general information about the hosted zone. properties: + additionalVPCs: + items: + description: |- + (Private hosted zones only) A complex type that contains information about + an Amazon VPC. + + If you associate a private hosted zone with an Amazon VPC when you make a + CreateHostedZone (https://docs.aws.amazon.com/Route53/latest/APIReference/API_CreateHostedZone.html) + request, the following parameters are also required. + properties: + vpcID: + description: (Private hosted zones only) The ID of an Amazon + VPC. + type: string + vpcRegion: + type: string + type: object + type: array delegationSetID: description: |- If you want to associate a reusable delegation set with this hosted zone, @@ -58,9 +76,9 @@ spec: description: |- (Optional) A complex type that contains the following optional values: - - For public and private hosted zones, an optional comment + * For public and private hosted zones, an optional comment - - For private hosted zones, an optional PrivateZone element + * For private hosted zones, an optional PrivateZone element If you don't specify a comment or the PrivateZone element, omit HostedZoneConfig and the other elements. @@ -122,6 +140,10 @@ spec: required: - name type: object + x-kubernetes-validations: + - message: spec.vpc is required for private hosted zones + rule: '!has(self.hostedZoneConfig) || !self.hostedZoneConfig.privateZone + || has(self.vpc)' status: description: HostedZoneStatus defines the observed state of HostedZone properties: diff --git a/helm/crds/route53.services.k8s.aws_recordsets.yaml b/helm/crds/route53.services.k8s.aws_recordsets.yaml index 6940e4b..68895ba 100644 --- a/helm/crds/route53.services.k8s.aws_recordsets.yaml +++ b/helm/crds/route53.services.k8s.aws_recordsets.yaml @@ -52,12 +52,12 @@ spec: If you're creating resource records sets for a private hosted zone, note the following: - - You can't create an alias resource record set in a private hosted zone - to route traffic to a CloudFront distribution. + * You can't create an alias resource record set in a private hosted zone + to route traffic to a CloudFront distribution. - - For information about creating failover resource record sets in a private - hosted zone, see Configuring Failover in a Private Hosted Zone (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html) - in the Amazon Route 53 Developer Guide. + * For information about creating failover resource record sets in a private + hosted zone, see Configuring Failover in a Private Hosted Zone (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html) + in the Amazon Route 53 Developer Guide. properties: dnsName: type: string @@ -91,22 +91,22 @@ spec: Except where noted, the following failover behaviors assume that you have included the HealthCheckId element in both resource record sets: - - When the primary resource record set is healthy, Route 53 responds to - DNS queries with the applicable value from the primary resource record - set regardless of the health of the secondary resource record set. + * When the primary resource record set is healthy, Route 53 responds to + DNS queries with the applicable value from the primary resource record + set regardless of the health of the secondary resource record set. - - When the primary resource record set is unhealthy and the secondary - resource record set is healthy, Route 53 responds to DNS queries with - the applicable value from the secondary resource record set. + * When the primary resource record set is unhealthy and the secondary + resource record set is healthy, Route 53 responds to DNS queries with + the applicable value from the secondary resource record set. - - When the secondary resource record set is unhealthy, Route 53 responds - to DNS queries with the applicable value from the primary resource record - set regardless of the health of the primary resource record set. + * When the secondary resource record set is unhealthy, Route 53 responds + to DNS queries with the applicable value from the primary resource record + set regardless of the health of the primary resource record set. - - If you omit the HealthCheckId element for the secondary resource record - set, and if the primary resource record set is unhealthy, Route 53 always - responds to DNS queries with the applicable value from the secondary resource - record set. This is true regardless of the health of the associated endpoint. + * If you omit the HealthCheckId element for the secondary resource record + set, and if the primary resource record set is unhealthy, Route 53 always + responds to DNS queries with the applicable value from the secondary resource + record set. This is true regardless of the health of the associated endpoint. You can't create non-failover resource record sets that have the same values for the Name and Type elements as failover resource record sets. @@ -117,9 +117,9 @@ spec: For more information about configuring failover for Route 53, see the following topics in the Amazon Route 53 Developer Guide: - - Route 53 Health Checks and DNS Failover (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover.html) + * Route 53 Health Checks and DNS Failover (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover.html) - - Configuring Failover in a Private Hosted Zone (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html) + * Configuring Failover in a Private Hosted Zone (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html) type: string geoLocation: description: |- @@ -172,14 +172,14 @@ spec: Route 53 determines whether a resource record set is healthy based on one of the following: - - By periodically sending a request to the endpoint that is specified - in the health check + * By periodically sending a request to the endpoint that is specified + in the health check - - By aggregating the status of a specified group of health checks (calculated - health checks) + * By aggregating the status of a specified group of health checks (calculated + health checks) - - By determining the current state of a CloudWatch alarm (CloudWatch metric - health checks) + * By determining the current state of a CloudWatch alarm (CloudWatch metric + health checks) Route 53 doesn't check the health of the endpoint that is specified in the resource record set, for example, the endpoint specified by the IP address @@ -190,48 +190,48 @@ spec: For more information, see the following topics in the Amazon Route 53 Developer Guide: - - How Amazon Route 53 Determines Whether an Endpoint Is Healthy (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-determining-health-of-endpoints.html) + * How Amazon Route 53 Determines Whether an Endpoint Is Healthy (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-determining-health-of-endpoints.html) - - Route 53 Health Checks and DNS Failover (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover.html) + * Route 53 Health Checks and DNS Failover (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover.html) - - Configuring Failover in a Private Hosted Zone (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html) + * Configuring Failover in a Private Hosted Zone (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-private-hosted-zones.html) - # When to Specify HealthCheckId + When to Specify HealthCheckId Specifying a value for HealthCheckId is useful only when Route 53 is choosing between two or more resource record sets to respond to a DNS query, and you want Route 53 to base the choice in part on the status of a health check. Configuring health checks makes sense only in the following configurations: - - Non-alias resource record sets: You're checking the health of a group - of non-alias resource record sets that have the same routing policy, name, - and type (such as multiple weighted records named www.example.com with - a type of A) and you specify health check IDs for all the resource record - sets. If the health check status for a resource record set is healthy, - Route 53 includes the record among the records that it responds to DNS - queries with. If the health check status for a resource record set is - unhealthy, Route 53 stops responding to DNS queries using the value for - that resource record set. If the health check status for all resource - record sets in the group is unhealthy, Route 53 considers all resource - record sets in the group healthy and responds to DNS queries accordingly. - - - Alias resource record sets: You specify the following settings: You - set EvaluateTargetHealth to true for an alias resource record set in a - group of resource record sets that have the same routing policy, name, - and type (such as multiple weighted records named www.example.com with - a type of A). You configure the alias resource record set to route traffic - to a non-alias resource record set in the same hosted zone. You specify - a health check ID for the non-alias resource record set. If the health - check status is healthy, Route 53 considers the alias resource record - set to be healthy and includes the alias record among the records that - it responds to DNS queries with. If the health check status is unhealthy, - Route 53 stops responding to DNS queries using the alias resource record - set. The alias resource record set can also route traffic to a group of - non-alias resource record sets that have the same routing policy, name, - and type. In that configuration, associate health checks with all of the - resource record sets in the group of non-alias resource record sets. - - # Geolocation Routing + * Non-alias resource record sets: You're checking the health of a group + of non-alias resource record sets that have the same routing policy, name, + and type (such as multiple weighted records named www.example.com with + a type of A) and you specify health check IDs for all the resource record + sets. If the health check status for a resource record set is healthy, + Route 53 includes the record among the records that it responds to DNS + queries with. If the health check status for a resource record set is + unhealthy, Route 53 stops responding to DNS queries using the value for + that resource record set. If the health check status for all resource + record sets in the group is unhealthy, Route 53 considers all resource + record sets in the group healthy and responds to DNS queries accordingly. + + * Alias resource record sets: You specify the following settings: You + set EvaluateTargetHealth to true for an alias resource record set in a + group of resource record sets that have the same routing policy, name, + and type (such as multiple weighted records named www.example.com with + a type of A). You configure the alias resource record set to route traffic + to a non-alias resource record set in the same hosted zone. You specify + a health check ID for the non-alias resource record set. If the health + check status is healthy, Route 53 considers the alias resource record + set to be healthy and includes the alias record among the records that + it responds to DNS queries with. If the health check status is unhealthy, + Route 53 stops responding to DNS queries using the alias resource record + set. The alias resource record set can also route traffic to a group of + non-alias resource record sets that have the same routing policy, name, + and type. In that configuration, associate health checks with all of the + resource record sets in the group of non-alias resource record sets. + + Geolocation Routing For geolocation resource record sets, if an endpoint is unhealthy, Route 53 looks for a resource record set for the larger, associated geographic @@ -242,13 +242,13 @@ spec: Route 53 checks for healthy resource record sets in the following order until it finds a resource record set for which the endpoint is healthy: - - The United States + * The United States - - North America + * North America - - The default resource record set + * The default resource record set - # Specifying the Health Check Endpoint by Domain Name + Specifying the Health Check Endpoint by Domain Name If your health checks specify the endpoint only by domain name, we recommend that you create a separate health check for each endpoint. For example, create @@ -259,10 +259,10 @@ spec: Health check results will be unpredictable if you do the following: - - Create a health check that has the same value for FullyQualifiedDomainName - as the name of a resource record set. + * Create a health check that has the same value for FullyQualifiedDomainName + as the name of a resource record set. - - Associate that health check with the resource record set. + * Associate that health check with the resource record set. type: string healthCheckRef: description: "AWSResourceReferenceWrapper provides a wrapper around @@ -313,25 +313,25 @@ spec: answer record for each resource and specify true for MultiValueAnswer. Note the following: - - If you associate a health check with a multivalue answer resource record - set, Amazon Route 53 responds to DNS queries with the corresponding IP - address only when the health check is healthy. + * If you associate a health check with a multivalue answer resource record + set, Amazon Route 53 responds to DNS queries with the corresponding IP + address only when the health check is healthy. - - If you don't associate a health check with a multivalue answer record, - Route 53 always considers the record to be healthy. + * If you don't associate a health check with a multivalue answer record, + Route 53 always considers the record to be healthy. - - Route 53 responds to DNS queries with up to eight healthy records; if - you have eight or fewer healthy records, Route 53 responds to all DNS - queries with all the healthy records. + * Route 53 responds to DNS queries with up to eight healthy records; if + you have eight or fewer healthy records, Route 53 responds to all DNS + queries with all the healthy records. - - If you have more than eight healthy records, Route 53 responds to different - DNS resolvers with different combinations of healthy records. + * If you have more than eight healthy records, Route 53 responds to different + DNS resolvers with different combinations of healthy records. - - When all records are unhealthy, Route 53 responds to DNS queries with - up to eight unhealthy records. + * When all records are unhealthy, Route 53 responds to DNS queries with + up to eight unhealthy records. - - If a resource becomes unavailable after a resolver caches a response, - client software typically tries another of the IP addresses in the response. + * If a resource becomes unavailable after a resolver caches a response, + client software typically tries another of the IP addresses in the response. You can't create multivalue answer alias records. type: boolean @@ -341,7 +341,7 @@ spec: to create, update, or delete. For ListResourceRecordSets responses, the name of a record in the specified hosted zone. - # ChangeResourceRecordSets Only + ChangeResourceRecordSets Only Enter a fully qualified domain name, for example, www.example.com. You can optionally include a trailing dot. If you omit the trailing dot, Amazon Route @@ -357,15 +357,15 @@ spec: You can use the asterisk (*) wildcard to replace the leftmost label in a domain name, for example, *.example.com. Note the following: - - The * must replace the entire label. For example, you can't specify - *prod.example.com or prod*.example.com. + * The * must replace the entire label. For example, you can't specify + *prod.example.com or prod*.example.com. - - The * can't replace any of the middle labels, for example, marketing.*.example.com. + * The * can't replace any of the middle labels, for example, marketing.*.example.com. - - If you include * in any position other than the leftmost label in a - domain name, DNS treats it as an * character (ASCII 42), not as a wildcard. - You can't use the * wildcard for resource records sets that have a type - of NS. + * If you include * in any position other than the leftmost label in a + domain name, DNS treats it as an * character (ASCII 42), not as a wildcard. + You can't use the * wildcard for resource records sets that have a type + of NS. type: string x-kubernetes-validations: - message: Value is immutable once set @@ -399,28 +399,28 @@ spec: Values for alias resource record sets: - - Amazon API Gateway custom regional APIs and edge-optimized APIs: A + * Amazon API Gateway custom regional APIs and edge-optimized APIs: A - - CloudFront distributions: A If IPv6 is enabled for the distribution, - create two resource record sets to route traffic to your distribution, - one with a value of A and one with a value of AAAA. + * CloudFront distributions: A If IPv6 is enabled for the distribution, + create two resource record sets to route traffic to your distribution, + one with a value of A and one with a value of AAAA. - - Amazon API Gateway environment that has a regionalized subdomain: A + * Amazon API Gateway environment that has a regionalized subdomain: A - - ELB load balancers: A | AAAA + * ELB load balancers: A | AAAA - - Amazon S3 buckets: A + * Amazon S3 buckets: A - - Amazon Virtual Private Cloud interface VPC endpoints A + * Amazon Virtual Private Cloud interface VPC endpoints A - - Another resource record set in this hosted zone: Specify the type of - the resource record set that you're creating the alias for. All values - are supported except NS and SOA. If you're creating an alias record that - has the same name as the hosted zone (known as the zone apex), you can't - route traffic to a record for which the value of Type is CNAME. This is - because the alias record must have the same type as the record you're - routing traffic to, and creating a CNAME record for the zone apex isn't - supported even for an alias record. + * Another resource record set in this hosted zone: Specify the type of + the resource record set that you're creating the alias for. All values + are supported except NS and SOA. If you're creating an alias record that + has the same name as the hosted zone (known as the zone apex), you can't + route traffic to a record for which the value of Type is CNAME. This is + because the alias record must have the same type as the record you're + routing traffic to, and creating a CNAME record for the zone apex isn't + supported even for an alias record. type: string x-kubernetes-validations: - message: Value is immutable once set @@ -441,18 +441,18 @@ spec: Note the following: - - You can only specify one ResourceRecord per latency resource record - set. + * You can only specify one ResourceRecord per latency resource record + set. - - You can only create one latency resource record set for each Amazon - EC2 Region. + * You can only create one latency resource record set for each Amazon + EC2 Region. - - You aren't required to create latency resource record sets for all Amazon - EC2 Regions. Route 53 will choose the region with the best latency from - among the regions that you create latency resource record sets for. + * You aren't required to create latency resource record sets for all Amazon + EC2 Regions. Route 53 will choose the region with the best latency from + among the regions that you create latency resource record sets for. - - You can't create non-latency resource record sets that have the same - values for the Name and Type elements as latency resource record sets. + * You can't create non-latency resource record sets that have the same + values for the Name and Type elements as latency resource record sets. type: string resourceRecords: description: |- @@ -488,23 +488,23 @@ spec: description: |- The resource record cache time to live (TTL), in seconds. Note the following: - - If you're creating or updating an alias resource record set, omit TTL. - Amazon Route 53 uses the value of TTL for the alias target. + * If you're creating or updating an alias resource record set, omit TTL. + Amazon Route 53 uses the value of TTL for the alias target. - - If you're associating this resource record set with a health check (if - you're adding a HealthCheckId element), we recommend that you specify - a TTL of 60 seconds or less so clients respond quickly to changes in health - status. + * If you're associating this resource record set with a health check (if + you're adding a HealthCheckId element), we recommend that you specify + a TTL of 60 seconds or less so clients respond quickly to changes in health + status. - - All of the resource record sets in a group of weighted resource record - sets must have the same value for TTL. + * All of the resource record sets in a group of weighted resource record + sets must have the same value for TTL. - - If a group of weighted resource record sets includes one or more weighted - alias resource record sets for which the alias target is an ELB load balancer, - we recommend that you specify a TTL of 60 seconds for all of the non-alias - weighted resource record sets that have the same name and type. Values - other than 60 seconds (the TTL for load balancers) will change the effect - of the values that you specify for Weight. + * If a group of weighted resource record sets includes one or more weighted + alias resource record sets for which the alias target is an ELB load balancer, + we recommend that you specify a TTL of 60 seconds for all of the non-alias + weighted resource record sets that have the same name and type. Values + other than 60 seconds (the TTL for load balancers) will change the effect + of the values that you specify for Weight. format: int64 type: integer weight: @@ -517,28 +517,28 @@ spec: to queries based on the ratio of a resource's weight to the total. Note the following: - - You must specify a value for the Weight element for every weighted resource - record set. + * You must specify a value for the Weight element for every weighted resource + record set. - - You can only specify one ResourceRecord per weighted resource record - set. + * You can only specify one ResourceRecord per weighted resource record + set. - - You can't create latency, failover, or geolocation resource record sets - that have the same values for the Name and Type elements as weighted resource - record sets. + * You can't create latency, failover, or geolocation resource record sets + that have the same values for the Name and Type elements as weighted resource + record sets. - - You can create a maximum of 100 weighted resource record sets that have - the same values for the Name and Type elements. + * You can create a maximum of 100 weighted resource record sets that have + the same values for the Name and Type elements. - - For weighted (but not weighted alias) resource record sets, if you set - Weight to 0 for a resource record set, Route 53 never responds to queries - with the applicable value for that resource record set. However, if you - set Weight to 0 for all resource record sets that have the same combination - of DNS name and type, traffic is routed to all resources with equal probability. - The effect of setting Weight to 0 is different when you associate health - checks with weighted resource record sets. For more information, see Options - for Configuring Route 53 Active-Active and Active-Passive Failover (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-configuring-options.html) - in the Amazon Route 53 Developer Guide. + * For weighted (but not weighted alias) resource record sets, if you set + Weight to 0 for a resource record set, Route 53 never responds to queries + with the applicable value for that resource record set. However, if you + set Weight to 0 for all resource record sets that have the same combination + of DNS name and type, traffic is routed to all resources with equal probability. + The effect of setting Weight to 0 is different when you associate health + checks with weighted resource record sets. For more information, see Options + for Configuring Route 53 Active-Active and Active-Passive Failover (https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/dns-failover-configuring-options.html) + in the Amazon Route 53 Developer Guide. format: int64 type: integer required: diff --git a/pkg/resource/health_check/delta.go b/pkg/resource/health_check/delta.go index 379fdb3..2f78aec 100644 --- a/pkg/resource/health_check/delta.go +++ b/pkg/resource/health_check/delta.go @@ -20,12 +20,14 @@ import ( ackcompare "github.com/aws-controllers-k8s/runtime/pkg/compare" acktags "github.com/aws-controllers-k8s/runtime/pkg/tags" + "k8s.io/apimachinery/pkg/api/equality" ) // Hack to avoid import errors during build... var ( _ = &bytes.Buffer{} _ = &acktags.Tags{} + _ = equality.Semantic ) // newResourceDelta returns a new `ackcompare.Delta` used to compare two diff --git a/pkg/resource/health_check/references.go b/pkg/resource/health_check/references.go index 223bdf6..46505bd 100644 --- a/pkg/resource/health_check/references.go +++ b/pkg/resource/health_check/references.go @@ -17,7 +17,6 @@ package health_check import ( "context" - "sigs.k8s.io/controller-runtime/pkg/client" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" diff --git a/pkg/resource/health_check/resource.go b/pkg/resource/health_check/resource.go index 73f7302..7185cca 100644 --- a/pkg/resource/health_check/resource.go +++ b/pkg/resource/health_check/resource.go @@ -21,6 +21,7 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerrors "github.com/aws-controllers-k8s/runtime/pkg/errors" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" + "github.com/aws/aws-sdk-go-v2/aws" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" rtclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -30,6 +31,8 @@ import ( // Hack to avoid import errors during build... var ( _ = &ackerrors.MissingNameIdentifier + _ = fmt.Sprintf + _ = aws.String ) // resource implements the `aws-controller-k8s/runtime/pkg/types.AWSResource` diff --git a/pkg/resource/health_check/sdk.go b/pkg/resource/health_check/sdk.go index 6c5b397..1a7ffe1 100644 --- a/pkg/resource/health_check/sdk.go +++ b/pkg/resource/health_check/sdk.go @@ -52,6 +52,7 @@ var ( _ = fmt.Sprintf("") _ = &ackrequeue.NoRequeue{} _ = &aws.Config{} + _ = math.MaxInt32 ) // sdkFind returns SDK-specific information about a supplied resource diff --git a/pkg/resource/hosted_zone/delta.go b/pkg/resource/hosted_zone/delta.go index 2306b5a..203493c 100644 --- a/pkg/resource/hosted_zone/delta.go +++ b/pkg/resource/hosted_zone/delta.go @@ -20,12 +20,14 @@ import ( ackcompare "github.com/aws-controllers-k8s/runtime/pkg/compare" acktags "github.com/aws-controllers-k8s/runtime/pkg/tags" + "k8s.io/apimachinery/pkg/api/equality" ) // Hack to avoid import errors during build... var ( _ = &bytes.Buffer{} _ = &acktags.Tags{} + _ = equality.Semantic ) // newResourceDelta returns a new `ackcompare.Delta` used to compare two @@ -41,6 +43,7 @@ func newResourceDelta( return delta } compareTags(delta, a, b) + compareAdditionalVPCs(delta, a, b) if ackcompare.HasNilDifference(a.ko.Spec.DelegationSetID, b.ko.Spec.DelegationSetID) { delta.Add("Spec.DelegationSetID", a.ko.Spec.DelegationSetID, b.ko.Spec.DelegationSetID) diff --git a/pkg/resource/hosted_zone/hook.go b/pkg/resource/hosted_zone/hook.go index de4141a..9e0933f 100644 --- a/pkg/resource/hosted_zone/hook.go +++ b/pkg/resource/hosted_zone/hook.go @@ -16,6 +16,14 @@ import ( svcsdktypes "github.com/aws/aws-sdk-go-v2/service/route53/types" ) +// vpcAssociationClient is the subset of the Route53 API used by syncVPCAssociations. +// *svcsdk.Client satisfies this interface; it is defined separately to allow unit testing. +type vpcAssociationClient interface { + GetHostedZone(ctx context.Context, input *svcsdk.GetHostedZoneInput, opts ...func(*svcsdk.Options)) (*svcsdk.GetHostedZoneOutput, error) + AssociateVPCWithHostedZone(ctx context.Context, input *svcsdk.AssociateVPCWithHostedZoneInput, opts ...func(*svcsdk.Options)) (*svcsdk.AssociateVPCWithHostedZoneOutput, error) + DisassociateVPCFromHostedZone(ctx context.Context, input *svcsdk.DisassociateVPCFromHostedZoneInput, opts ...func(*svcsdk.Options)) (*svcsdk.DisassociateVPCFromHostedZoneOutput, error) +} + // getCallerReference will generate a CallerReference for a given hosted zone // using the name of the zone and the current timestamp, so that it produces a // unique value @@ -46,6 +54,12 @@ func (rm *resourceManager) customUpdateHostedZone( } } + if delta.DifferentAt("Spec.VPC") || delta.DifferentAt("Spec.AdditionalVPCs") { + if err := rm.syncVPCAssociations(ctx, rm.sdkapi, desired, latest); err != nil { + return nil, err + } + } + return updated, nil } @@ -178,3 +192,183 @@ func (rm *resourceManager) setResourceAdditionalFields( } return nil } + +// populateAdditionalVPCs fills ko.Spec.AdditionalVPCs from the AWS VPC list, +// excluding the primary VPC (Spec.VPC) to preserve the no-duplication invariant. +func populateAdditionalVPCs(ko *svcapitypes.HostedZone, vpcs []svcsdktypes.VPC) { + primaryVPCID := "" + if ko.Spec.VPC != nil && ko.Spec.VPC.VPCID != nil { + primaryVPCID = *ko.Spec.VPC.VPCID + } + var additionalVPCs []*svcapitypes.VPC + for _, v := range vpcs { + if v.VPCId == nil || *v.VPCId == primaryVPCID { + continue + } + region := string(v.VPCRegion) + additionalVPCs = append(additionalVPCs, &svcapitypes.VPC{ + VPCID: v.VPCId, + VPCRegion: ®ion, + }) + } + ko.Spec.AdditionalVPCs = additionalVPCs +} + +// compareAdditionalVPCs is a custom comparison function for comparing lists of +// VPC structs where the order of the structs in the list is not important. +func compareAdditionalVPCs( + delta *ackcompare.Delta, + a *resource, + b *resource, +) { + aVPCs := a.ko.Spec.AdditionalVPCs + bVPCs := b.ko.Spec.AdditionalVPCs + if len(aVPCs) != len(bVPCs) { + delta.Add("Spec.AdditionalVPCs", aVPCs, bVPCs) + return + } + if len(aVPCs) == 0 { + return + } + // If any entry in a has a nil VPCID or VPCRegion, we cannot build a + // reliable set — treat it as a difference. + for _, v := range aVPCs { + if v.VPCID == nil || v.VPCRegion == nil { + delta.Add("Spec.AdditionalVPCs", aVPCs, bVPCs) + return + } + } + setA := additionalVPCsToSet(aVPCs) + for _, v := range bVPCs { + if v.VPCID == nil || v.VPCRegion == nil { + delta.Add("Spec.AdditionalVPCs", aVPCs, bVPCs) + return + } + if region, ok := setA[*v.VPCID]; !ok || region != *v.VPCRegion { + delta.Add("Spec.AdditionalVPCs", aVPCs, bVPCs) + return + } + } +} + +// additionalVPCsToSet converts a slice of VPCs to a map of VPCID → VPCRegion. +func additionalVPCsToSet(vpcs []*svcapitypes.VPC) map[string]string { + set := make(map[string]string, len(vpcs)) + for _, v := range vpcs { + if v.VPCID != nil && v.VPCRegion != nil { + set[*v.VPCID] = *v.VPCRegion + } + } + return set +} + +// syncVPCAssociations reconciles VPC associations for a private hosted zone. +// It treats the union of Spec.VPC and Spec.AdditionalVPCs as the full desired +// set. All VPCs returned by GetHostedZone are the current set. VPCs in desired +// but not current are associated; VPCs in current but not desired are +// disassociated. LastVPCAssociation errors are surfaced as terminal conditions. +func (rm *resourceManager) syncVPCAssociations( + ctx context.Context, + client vpcAssociationClient, + desired *resource, + latest *resource, +) (err error) { + rlog := ackrtlog.FromContext(ctx) + exit := rlog.Trace("rm.syncVPCAssociations") + defer func() { + exit(err) + }() + + hostedZoneID := latest.ko.Status.ID + + // Fetch current VPC associations from AWS. + resp, err := client.GetHostedZone(ctx, &svcsdk.GetHostedZoneInput{Id: hostedZoneID}) + if rm.metrics != nil { + rm.metrics.RecordAPICall("READ_ONE", "GetHostedZone", err) + } + if err != nil { + return err + } + + // Build full desired set: Spec.VPC (if non-nil) + all Spec.AdditionalVPCs. + desiredSet := make(map[string]*svcapitypes.VPC) + if desired.ko.Spec.VPC != nil && desired.ko.Spec.VPC.VPCID != nil { + desiredSet[*desired.ko.Spec.VPC.VPCID] = desired.ko.Spec.VPC + } + for _, v := range desired.ko.Spec.AdditionalVPCs { + if v.VPCID == nil || v.VPCRegion == nil { + rlog.Debug("skipping AdditionalVPCs entry with nil VPCID or VPCRegion") + continue + } + desiredSet[*v.VPCID] = v + } + + // Build current set from ALL VPCs returned by AWS — no exclusions. + current := make(map[string]svcsdktypes.VPCRegion) + for _, v := range resp.VPCs { + if v.VPCId != nil { + current[*v.VPCId] = v.VPCRegion + } + } + + // Associate VPCs that are desired but not yet associated. + for id, v := range desiredSet { + if _, ok := current[id]; ok { + continue // already associated + } + rlog.Debug("associating VPC with hosted zone", "vpcID", id) + region := svcsdktypes.VPCRegion(*v.VPCRegion) + _, err = client.AssociateVPCWithHostedZone(ctx, &svcsdk.AssociateVPCWithHostedZoneInput{ + HostedZoneId: hostedZoneID, + VPC: &svcsdktypes.VPC{ + VPCId: v.VPCID, + VPCRegion: region, + }, + }) + if rm.metrics != nil { + rm.metrics.RecordAPICall("UPDATE", "AssociateVPCWithHostedZone", err) + } + if err != nil { + var conflict *svcsdktypes.ConflictingDomainExists + if errors.As(err, &conflict) { + continue // already associated — idempotent + } + return err + } + } + + // Disassociate VPCs that are associated but no longer desired. + for id, region := range current { + if _, ok := desiredSet[id]; ok { + continue + } + rlog.Debug("disassociating VPC from hosted zone", "vpcID", id) + vpcID := id + _, err = client.DisassociateVPCFromHostedZone(ctx, &svcsdk.DisassociateVPCFromHostedZoneInput{ + HostedZoneId: hostedZoneID, + VPC: &svcsdktypes.VPC{ + VPCId: &vpcID, + VPCRegion: region, + }, + }) + if rm.metrics != nil { + rm.metrics.RecordAPICall("UPDATE", "DisassociateVPCFromHostedZone", err) + } + if err != nil { + var notFound *svcsdktypes.VPCAssociationNotFound + if errors.As(err, ¬Found) { + continue // already disassociated — idempotent + } + var lastVPC *svcsdktypes.LastVPCAssociation + if errors.As(err, &lastVPC) { + // Route53 rejects disassociation of the last VPC. This is a + // permanent user error — the desired state is invalid. Surface + // it as a terminal condition so the operator is notified. + return ackerr.NewTerminalError(err) + } + return err + } + } + + return nil +} diff --git a/pkg/resource/hosted_zone/hook_test.go b/pkg/resource/hosted_zone/hook_test.go new file mode 100644 index 0000000..a08e902 --- /dev/null +++ b/pkg/resource/hosted_zone/hook_test.go @@ -0,0 +1,263 @@ +package hosted_zone + +import ( + "context" + "errors" + "testing" + + svcapitypes "github.com/aws-controllers-k8s/route53-controller/apis/v1alpha1" + ackcompare "github.com/aws-controllers-k8s/runtime/pkg/compare" + ackerr "github.com/aws-controllers-k8s/runtime/pkg/errors" + "github.com/aws/aws-sdk-go-v2/aws" + svcsdk "github.com/aws/aws-sdk-go-v2/service/route53" + svcsdktypes "github.com/aws/aws-sdk-go-v2/service/route53/types" +) + +// Test: populateAdditionalVPCs excludes the primary VPC (Spec.VPC) from AdditionalVPCs. +func TestPopulateAdditionalVPCs_ExcludesPrimaryVPC(t *testing.T) { + ko := &svcapitypes.HostedZone{} + ko.Spec.VPC = &svcapitypes.VPC{VPCID: aws.String("vpc-primary"), VPCRegion: aws.String("us-east-1")} + + awsVPCs := []svcsdktypes.VPC{ + {VPCId: aws.String("vpc-primary"), VPCRegion: svcsdktypes.VPCRegionUsEast1}, + {VPCId: aws.String("vpc-additional"), VPCRegion: svcsdktypes.VPCRegionUsEast1}, + } + + populateAdditionalVPCs(ko, awsVPCs) + + if len(ko.Spec.AdditionalVPCs) != 1 { + t.Fatalf("expected 1 additional VPC, got %d", len(ko.Spec.AdditionalVPCs)) + } + if *ko.Spec.AdditionalVPCs[0].VPCID != "vpc-additional" { + t.Errorf("expected vpc-additional, got %s", *ko.Spec.AdditionalVPCs[0].VPCID) + } +} + +// mockVPCClient records calls made to Associate/Disassociate. +type mockVPCClient struct { + getOutput *svcsdk.GetHostedZoneOutput + getErr error + associated []string // vpcIDs passed to Associate + disassociated []string // vpcIDs passed to Disassociate + assocErr error // optional error to return on every Associate call + disassocErr error // optional error to return on every Disassociate call +} + +func (m *mockVPCClient) GetHostedZone(_ context.Context, _ *svcsdk.GetHostedZoneInput, _ ...func(*svcsdk.Options)) (*svcsdk.GetHostedZoneOutput, error) { + return m.getOutput, m.getErr +} +func (m *mockVPCClient) AssociateVPCWithHostedZone(_ context.Context, in *svcsdk.AssociateVPCWithHostedZoneInput, _ ...func(*svcsdk.Options)) (*svcsdk.AssociateVPCWithHostedZoneOutput, error) { + if m.assocErr != nil { + return nil, m.assocErr + } + if in.VPC != nil && in.VPC.VPCId != nil { + m.associated = append(m.associated, *in.VPC.VPCId) + } + return &svcsdk.AssociateVPCWithHostedZoneOutput{}, nil +} +func (m *mockVPCClient) DisassociateVPCFromHostedZone(_ context.Context, in *svcsdk.DisassociateVPCFromHostedZoneInput, _ ...func(*svcsdk.Options)) (*svcsdk.DisassociateVPCFromHostedZoneOutput, error) { + if m.disassocErr != nil { + return nil, m.disassocErr + } + if in.VPC != nil && in.VPC.VPCId != nil { + m.disassociated = append(m.disassociated, *in.VPC.VPCId) + } + return &svcsdk.DisassociateVPCFromHostedZoneOutput{}, nil +} + +func makeResource(vpcID, vpcRegion string, additionalVPCIDs []string) *resource { + r := &resource{ko: &svcapitypes.HostedZone{}} + r.ko.Status.ID = aws.String("/hostedzone/Z123") + r.ko.Spec.VPC = &svcapitypes.VPC{ + VPCID: aws.String(vpcID), + VPCRegion: aws.String(vpcRegion), + } + for _, id := range additionalVPCIDs { + vid := id + r.ko.Spec.AdditionalVPCs = append(r.ko.Spec.AdditionalVPCs, &svcapitypes.VPC{ + VPCID: &vid, + VPCRegion: aws.String("us-east-1"), + }) + } + return r +} + +func makeGetOutput(vpcIDs ...string) *svcsdk.GetHostedZoneOutput { + out := &svcsdk.GetHostedZoneOutput{} + for _, id := range vpcIDs { + vpcID := id + out.VPCs = append(out.VPCs, svcsdktypes.VPC{ + VPCId: &vpcID, + VPCRegion: svcsdktypes.VPCRegionUsEast1, + }) + } + return out +} + +// Test: swapping Spec.VPC from vpc-A to vpc-B associates vpc-B and disassociates vpc-A. +func TestSyncVPCAssociations_SwapPrimaryVPC(t *testing.T) { + // desired: primary=vpc-B; AWS currently has vpc-A only + desired := makeResource("vpc-B", "us-east-1", nil) + latest := makeResource("vpc-A", "us-east-1", nil) + latest.ko.Status.ID = desired.ko.Status.ID + + mock := &mockVPCClient{getOutput: makeGetOutput("vpc-A")} + rm := &resourceManager{} + + err := rm.syncVPCAssociations(context.Background(), mock, desired, latest) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(mock.associated) != 1 || mock.associated[0] != "vpc-B" { + t.Errorf("expected vpc-B associated, got %v", mock.associated) + } + if len(mock.disassociated) != 1 || mock.disassociated[0] != "vpc-A" { + t.Errorf("expected vpc-A disassociated, got %v", mock.disassociated) + } +} + +// Test: LastVPCAssociation error from Disassociate is wrapped as a terminal error. +// Note: the mock returns the error unconditionally — this exercises the error-wrapping +// code path. In real AWS, LastVPCAssociation fires when disassociation would leave +// zero VPCs; the associate loop runs first, so this would only occur when the new +// primary VPC fails to associate before disassociation is attempted. +func TestSyncVPCAssociations_LastVPCTerminal(t *testing.T) { + // desired: primary=vpc-B; AWS currently has vpc-A only (vpc-B not yet associated). + desired := makeResource("vpc-B", "us-east-1", nil) + latest := makeResource("vpc-A", "us-east-1", nil) + latest.ko.Status.ID = desired.ko.Status.ID + + mock := &mockVPCClient{ + getOutput: makeGetOutput("vpc-A"), + disassocErr: &svcsdktypes.LastVPCAssociation{Message: aws.String("last VPC")}, + } + rm := &resourceManager{} + + err := rm.syncVPCAssociations(context.Background(), mock, desired, latest) + if err == nil { + t.Fatal("expected terminal error, got nil") + } + var termErr *ackerr.TerminalError + if !errors.As(err, &termErr) { + t.Errorf("expected *ackerr.TerminalError, got %T: %v", err, err) + } +} + +// Test: compareAdditionalVPCs detects a nil VPCID in desired list as a delta. +func TestCompareAdditionalVPCs_NilVPCIDInA(t *testing.T) { + a := &resource{ko: &svcapitypes.HostedZone{}} + a.ko.Spec.AdditionalVPCs = []*svcapitypes.VPC{ + {VPCID: nil, VPCRegion: aws.String("us-east-1")}, + } + b := &resource{ko: &svcapitypes.HostedZone{}} + b.ko.Spec.AdditionalVPCs = []*svcapitypes.VPC{ + {VPCID: aws.String("vpc-1"), VPCRegion: aws.String("us-east-1")}, + } + + delta := ackcompare.NewDelta() + compareAdditionalVPCs(delta, a, b) + + if !delta.DifferentAt("Spec.AdditionalVPCs") { + t.Error("expected delta for Spec.AdditionalVPCs when a has nil VPCID, got none") + } +} + +// Test: same VPCs in different order — no delta expected. +func TestCompareAdditionalVPCs_SameVPCsDifferentOrder(t *testing.T) { + a := &resource{ko: &svcapitypes.HostedZone{}} + a.ko.Spec.AdditionalVPCs = []*svcapitypes.VPC{ + {VPCID: aws.String("vpc-1"), VPCRegion: aws.String("us-east-1")}, + {VPCID: aws.String("vpc-2"), VPCRegion: aws.String("us-west-2")}, + } + b := &resource{ko: &svcapitypes.HostedZone{}} + b.ko.Spec.AdditionalVPCs = []*svcapitypes.VPC{ + {VPCID: aws.String("vpc-2"), VPCRegion: aws.String("us-west-2")}, + {VPCID: aws.String("vpc-1"), VPCRegion: aws.String("us-east-1")}, + } + delta := ackcompare.NewDelta() + compareAdditionalVPCs(delta, a, b) + if delta.DifferentAt("Spec.AdditionalVPCs") { + t.Error("expected no delta for same VPCs in different order") + } +} + +// Test: same VPC IDs but different regions — delta expected. +func TestCompareAdditionalVPCs_SameIDDifferentRegion(t *testing.T) { + a := &resource{ko: &svcapitypes.HostedZone{}} + a.ko.Spec.AdditionalVPCs = []*svcapitypes.VPC{ + {VPCID: aws.String("vpc-1"), VPCRegion: aws.String("us-east-1")}, + } + b := &resource{ko: &svcapitypes.HostedZone{}} + b.ko.Spec.AdditionalVPCs = []*svcapitypes.VPC{ + {VPCID: aws.String("vpc-1"), VPCRegion: aws.String("us-west-2")}, + } + delta := ackcompare.NewDelta() + compareAdditionalVPCs(delta, a, b) + if !delta.DifferentAt("Spec.AdditionalVPCs") { + t.Error("expected delta for same VPC ID with different region") + } +} + +// Test: both nil/empty lists — no delta expected. +func TestCompareAdditionalVPCs_BothEmpty(t *testing.T) { + a := &resource{ko: &svcapitypes.HostedZone{}} + b := &resource{ko: &svcapitypes.HostedZone{}} + delta := ackcompare.NewDelta() + compareAdditionalVPCs(delta, a, b) + if delta.DifferentAt("Spec.AdditionalVPCs") { + t.Error("expected no delta for both empty AdditionalVPCs") + } +} + +// Test: nil VPCID in b — delta expected (already handled by existing b-iteration check). +func TestCompareAdditionalVPCs_NilVPCIDInB(t *testing.T) { + a := &resource{ko: &svcapitypes.HostedZone{}} + a.ko.Spec.AdditionalVPCs = []*svcapitypes.VPC{ + {VPCID: aws.String("vpc-1"), VPCRegion: aws.String("us-east-1")}, + } + b := &resource{ko: &svcapitypes.HostedZone{}} + b.ko.Spec.AdditionalVPCs = []*svcapitypes.VPC{ + {VPCID: nil, VPCRegion: aws.String("us-east-1")}, + } + delta := ackcompare.NewDelta() + compareAdditionalVPCs(delta, a, b) + if !delta.DifferentAt("Spec.AdditionalVPCs") { + t.Error("expected delta when b has nil VPCID") + } +} + +// Test: GetHostedZone error is propagated as-is. +func TestSyncVPCAssociations_GetHostedZoneError(t *testing.T) { + desired := makeResource("vpc-A", "us-east-1", nil) + latest := makeResource("vpc-A", "us-east-1", nil) + + mock := &mockVPCClient{getErr: errors.New("iam permission denied")} + rm := &resourceManager{} + + err := rm.syncVPCAssociations(context.Background(), mock, desired, latest) + if err == nil { + t.Fatal("expected error from GetHostedZone, got nil") + } + if err.Error() != "iam permission denied" { + t.Errorf("expected 'iam permission denied', got %q", err.Error()) + } +} + +// Test: ConflictingDomainExists on AssociateVPC is treated as idempotent (no error returned). +func TestSyncVPCAssociations_ConflictingDomainExistsIdempotent(t *testing.T) { + // desired has vpc-B as additional; AWS currently has only vpc-A + desired := makeResource("vpc-A", "us-east-1", []string{"vpc-B"}) + latest := makeResource("vpc-A", "us-east-1", nil) + latest.ko.Status.ID = desired.ko.Status.ID + + mock := &mockVPCClient{ + getOutput: makeGetOutput("vpc-A"), + assocErr: &svcsdktypes.ConflictingDomainExists{Message: aws.String("already associated")}, + } + rm := &resourceManager{} + + err := rm.syncVPCAssociations(context.Background(), mock, desired, latest) + if err != nil { + t.Fatalf("expected nil error for ConflictingDomainExists, got: %v", err) + } +} diff --git a/pkg/resource/hosted_zone/manager.go b/pkg/resource/hosted_zone/manager.go index 6ddc94d..7a4490c 100644 --- a/pkg/resource/hosted_zone/manager.go +++ b/pkg/resource/hosted_zone/manager.go @@ -267,7 +267,6 @@ func (rm *resourceManager) IsSynced(ctx context.Context, res acktypes.AWSResourc // Should never happen... if it does, it's buggy code. panic("resource manager's IsSynced() method received resource with nil CR object") } - return true, nil } diff --git a/pkg/resource/hosted_zone/references.go b/pkg/resource/hosted_zone/references.go index 1a9c447..0997e5d 100644 --- a/pkg/resource/hosted_zone/references.go +++ b/pkg/resource/hosted_zone/references.go @@ -17,7 +17,6 @@ package hosted_zone import ( "context" - "sigs.k8s.io/controller-runtime/pkg/client" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" diff --git a/pkg/resource/hosted_zone/resource.go b/pkg/resource/hosted_zone/resource.go index 1a2ca64..3f7d0e5 100644 --- a/pkg/resource/hosted_zone/resource.go +++ b/pkg/resource/hosted_zone/resource.go @@ -21,6 +21,7 @@ import ( ackv1alpha1 "github.com/aws-controllers-k8s/runtime/apis/core/v1alpha1" ackerrors "github.com/aws-controllers-k8s/runtime/pkg/errors" acktypes "github.com/aws-controllers-k8s/runtime/pkg/types" + "github.com/aws/aws-sdk-go-v2/aws" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" rtclient "sigs.k8s.io/controller-runtime/pkg/client" @@ -30,6 +31,8 @@ import ( // Hack to avoid import errors during build... var ( _ = &ackerrors.MissingNameIdentifier + _ = fmt.Sprintf + _ = aws.String ) // resource implements the `aws-controller-k8s/runtime/pkg/types.AWSResource` diff --git a/pkg/resource/hosted_zone/sdk.go b/pkg/resource/hosted_zone/sdk.go index a290555..5f8ca7f 100644 --- a/pkg/resource/hosted_zone/sdk.go +++ b/pkg/resource/hosted_zone/sdk.go @@ -19,6 +19,7 @@ import ( "context" "errors" "fmt" + "math" "reflect" "strings" @@ -51,6 +52,7 @@ var ( _ = fmt.Sprintf("") _ = &ackrequeue.NoRequeue{} _ = &aws.Config{} + _ = math.MaxInt32 ) // sdkFind returns SDK-specific information about a supplied resource @@ -148,6 +150,8 @@ func (rm *resourceManager) sdkFind( ko.Status.DelegationSet = nil } + populateAdditionalVPCs(ko, resp.VPCs) + return &resource{ko}, nil } @@ -274,6 +278,13 @@ func (rm *resourceManager) sdkCreate( if err := rm.syncTags(ctx, desired, latest); err != nil { return nil, err } + + // Sync additional VPC associations during create. The new zone has no + // VPCs associated yet, so syncVPCAssociations will associate all desired + // VPCs. Return ko (not nil) on error so status.id is written to k8s. + if err := rm.syncVPCAssociations(ctx, rm.sdkapi, desired, latest); err != nil { + return &resource{ko}, err + } } return &resource{ko}, nil @@ -338,6 +349,16 @@ func (rm *resourceManager) sdkDelete( defer func() { exit(err) }() + // Disassociate additional VPCs before deletion. + // Route53 rejects DeleteHostedZone if >1 VPC is associated. + if len(r.ko.Spec.AdditionalVPCs) > 0 { + empty := rm.concreteResource(r.DeepCopy()) + empty.ko.Spec.AdditionalVPCs = nil + if err = rm.syncVPCAssociations(ctx, rm.sdkapi, empty, r); err != nil { + return nil, err + } + } + input, err := rm.newDeleteRequestPayload(r) if err != nil { return nil, err diff --git a/pkg/resource/record_set/delta.go b/pkg/resource/record_set/delta.go index a4ad626..3827482 100644 --- a/pkg/resource/record_set/delta.go +++ b/pkg/resource/record_set/delta.go @@ -27,6 +27,7 @@ import ( var ( _ = &bytes.Buffer{} _ = &acktags.Tags{} + _ = equality.Semantic ) // newResourceDelta returns a new `ackcompare.Delta` used to compare two diff --git a/pkg/resource/record_set/resource.go b/pkg/resource/record_set/resource.go index 7a0f7f7..e8869cb 100644 --- a/pkg/resource/record_set/resource.go +++ b/pkg/resource/record_set/resource.go @@ -31,6 +31,8 @@ import ( // Hack to avoid import errors during build... var ( _ = &ackerrors.MissingNameIdentifier + _ = fmt.Sprintf + _ = aws.String ) // resource implements the `aws-controller-k8s/runtime/pkg/types.AWSResource` diff --git a/pkg/resource/record_set/sdk.go b/pkg/resource/record_set/sdk.go index 1e34e4d..6df2feb 100644 --- a/pkg/resource/record_set/sdk.go +++ b/pkg/resource/record_set/sdk.go @@ -19,6 +19,7 @@ import ( "context" "errors" "fmt" + "math" "reflect" "strings" @@ -51,6 +52,7 @@ var ( _ = fmt.Sprintf("") _ = &ackrequeue.NoRequeue{} _ = &aws.Config{} + _ = math.MaxInt32 ) // sdkFind returns SDK-specific information about a supplied resource diff --git a/templates/hooks/hosted_zone/sdk_create_post_set_output.go.tpl b/templates/hooks/hosted_zone/sdk_create_post_set_output.go.tpl index 92b5399..7d2aec2 100644 --- a/templates/hooks/hosted_zone/sdk_create_post_set_output.go.tpl +++ b/templates/hooks/hosted_zone/sdk_create_post_set_output.go.tpl @@ -19,4 +19,11 @@ if err := rm.syncTags(ctx, desired, latest); err != nil { return nil, err } + + // Sync additional VPC associations during create. The new zone has no + // VPCs associated yet, so syncVPCAssociations will associate all desired + // VPCs. Return ko (not nil) on error so status.id is written to k8s. + if err := rm.syncVPCAssociations(ctx, rm.sdkapi, desired, latest); err != nil { + return &resource{ko}, err + } } diff --git a/templates/hooks/hosted_zone/sdk_delete_pre_build_request.go.tpl b/templates/hooks/hosted_zone/sdk_delete_pre_build_request.go.tpl new file mode 100644 index 0000000..9a7c75e --- /dev/null +++ b/templates/hooks/hosted_zone/sdk_delete_pre_build_request.go.tpl @@ -0,0 +1,9 @@ + // Disassociate additional VPCs before deletion. + // Route53 rejects DeleteHostedZone if >1 VPC is associated. + if len(r.ko.Spec.AdditionalVPCs) > 0 { + empty := rm.concreteResource(r.DeepCopy()) + empty.ko.Spec.AdditionalVPCs = nil + if err = rm.syncVPCAssociations(ctx, rm.sdkapi, empty, r); err != nil { + return nil, err + } + } diff --git a/templates/hooks/hosted_zone/sdk_read_one_post_set_output.go.tpl b/templates/hooks/hosted_zone/sdk_read_one_post_set_output.go.tpl index 855048f..47315d7 100644 --- a/templates/hooks/hosted_zone/sdk_read_one_post_set_output.go.tpl +++ b/templates/hooks/hosted_zone/sdk_read_one_post_set_output.go.tpl @@ -11,3 +11,6 @@ } else { ko.Status.DelegationSet = nil } + + populateAdditionalVPCs(ko, resp.VPCs) + diff --git a/test/e2e/bootstrap_resources.py b/test/e2e/bootstrap_resources.py index 54f4d80..f5e7119 100644 --- a/test/e2e/bootstrap_resources.py +++ b/test/e2e/bootstrap_resources.py @@ -23,6 +23,8 @@ @dataclass class BootstrapResources(Resources): HostedZoneVPC: VPC + AdditionalVPC: VPC + AdditionalVPC2: VPC _bootstrap_resources = None diff --git a/test/e2e/resources/hosted_zone_private_additional_vpcs.yaml b/test/e2e/resources/hosted_zone_private_additional_vpcs.yaml new file mode 100644 index 0000000..e40003b --- /dev/null +++ b/test/e2e/resources/hosted_zone_private_additional_vpcs.yaml @@ -0,0 +1,14 @@ +apiVersion: route53.services.k8s.aws/v1alpha1 +kind: HostedZone +metadata: + name: $ZONE_NAME +spec: + name: $ZONE_DOMAIN + vpc: + vpcID: $VPC_ID + vpcRegion: $AWS_REGION + additionalVPCs: + - vpcID: $ADDITIONAL_VPC_ID + vpcRegion: $AWS_REGION + - vpcID: $ADDITIONAL_VPC_ID_2 + vpcRegion: $AWS_REGION diff --git a/test/e2e/service_bootstrap.py b/test/e2e/service_bootstrap.py index 439d438..8049d44 100644 --- a/test/e2e/service_bootstrap.py +++ b/test/e2e/service_bootstrap.py @@ -23,7 +23,9 @@ def service_bootstrap() -> Resources: logging.getLogger().setLevel(logging.INFO) resources = BootstrapResources( - HostedZoneVPC=VPC("hosted-zone-vpc", num_public_subnet=2, num_private_subnet=2) + HostedZoneVPC=VPC("hosted-zone-vpc", num_public_subnet=2, num_private_subnet=2), + AdditionalVPC=VPC("additional-vpc", num_public_subnet=1, num_private_subnet=1), + AdditionalVPC2=VPC("additional-vpc-2", num_public_subnet=1, num_private_subnet=1), ) try: diff --git a/test/e2e/tests/helper.py b/test/e2e/tests/helper.py index 746eebc..63dabab 100644 --- a/test/e2e/tests/helper.py +++ b/test/e2e/tests/helper.py @@ -39,6 +39,18 @@ def assert_hosted_zone(self, hosted_zone_id: str, exists=True): except self.route53_client.exceptions.ClientError: pass assert found is exists + + def assert_vpc_association(self, hosted_zone_id: str, vpc_id: str, exists=True): + found = False + try: + res = self.route53_client.get_hosted_zone(Id=hosted_zone_id) + for vpc in res.get("VPCs", []): + if vpc.get("VPCId") == vpc_id: + found = True + break + except self.route53_client.exceptions.ClientError: + pass + assert found is exists def assert_health_check(self, cr, exists=True): res = None diff --git a/test/e2e/tests/test_hosted_zone.py b/test/e2e/tests/test_hosted_zone.py index a509993..5b24cae 100644 --- a/test/e2e/tests/test_hosted_zone.py +++ b/test/e2e/tests/test_hosted_zone.py @@ -83,6 +83,29 @@ def private_hosted_zone(): delete_route53_resource(ref) +@pytest.fixture +def private_hosted_zone_with_additional_vpc(): + zone_name = random_suffix_name("private-hosted-zone", 32) + + bootstrap = get_bootstrap_resources() + replacements = REPLACEMENT_VALUES.copy() + replacements["ZONE_NAME"] = zone_name + replacements["ZONE_DOMAIN"] = f"{zone_name}.ack.example.com." + replacements["VPC_ID"] = bootstrap.HostedZoneVPC.vpc_id + replacements["ADDITIONAL_VPC_ID"] = bootstrap.AdditionalVPC.vpc_id + replacements["ADDITIONAL_VPC_ID_2"] = bootstrap.AdditionalVPC2.vpc_id + + ref, cr = create_route53_resource( + "hostedzones", + zone_name, + "hosted_zone_private_additional_vpcs", + replacements, + ) + + yield ref, cr + + delete_route53_resource(ref) + @service_marker @pytest.mark.canary class TestHostedZone: @@ -229,3 +252,84 @@ def test_crud_tags(self, route53_client, public_hosted_zone): # Check hosted_zone no longer exists in AWS route53_validator.assert_hosted_zone(resource_id, exists=False) + + def test_delete_with_additional_vpcs(self, route53_client, private_hosted_zone_with_additional_vpc): + """Deleting a hosted zone with additional VPCs should succeed (pre-delete disassociation).""" + ref, cr = private_hosted_zone_with_additional_vpc + + zone_id = cr["status"]["id"] + bootstrap = get_bootstrap_resources() + + assert zone_id + + # VPC associations are synced during the create reconciliation cycle. + # Wait for the resource to be fully synced before checking associations. + time.sleep(MODIFY_WAIT_AFTER_SECONDS) + assert k8s.wait_on_condition(ref, "ACK.ResourceSynced", "True", wait_periods=10) + + # Verify both additional VPCs are associated before delete + route53_validator = Route53Validator(route53_client) + route53_validator.assert_vpc_association(zone_id, bootstrap.AdditionalVPC.vpc_id, exists=True) + route53_validator.assert_vpc_association(zone_id, bootstrap.AdditionalVPC2.vpc_id, exists=True) + + # Delete while additional VPCs are still associated — should not fail + _, deleted = k8s.delete_custom_resource(ref) + assert deleted is True + + time.sleep(DELETE_WAIT_AFTER_SECONDS) + + route53_validator.assert_hosted_zone(zone_id, exists=False) + + def test_crud_additional_vpcs(self, route53_client, private_hosted_zone_with_additional_vpc): + """Test CRUD operations for a private hosted zone with additional VPC associations.""" + ref, cr = private_hosted_zone_with_additional_vpc + + zone_id = cr["status"]["id"] + bootstrap = get_bootstrap_resources() + + assert zone_id + + # VPC associations are synced during the create reconciliation cycle. + # Wait for the resource to be fully synced before checking associations. + time.sleep(MODIFY_WAIT_AFTER_SECONDS) + assert k8s.wait_on_condition(ref, "ACK.ResourceSynced", "True", wait_periods=10) + + # Check hosted zone exists and both additional VPCs are associated + route53_validator = Route53Validator(route53_client) + route53_validator.assert_hosted_zone(zone_id) + route53_validator.assert_vpc_association(zone_id, bootstrap.AdditionalVPC.vpc_id, exists=True) + route53_validator.assert_vpc_association(zone_id, bootstrap.AdditionalVPC2.vpc_id, exists=True) + + # Remove one additional VPC, keep the other + updates = { + "spec": {"additionalVPCs": [{"vpcID": bootstrap.AdditionalVPC.vpc_id, "vpcRegion": bootstrap.AdditionalVPC.region}]}, + } + k8s.patch_custom_resource(ref, updates) + time.sleep(MODIFY_WAIT_AFTER_SECONDS) + + assert k8s.wait_on_condition(ref, "ACK.ResourceSynced", "True", wait_periods=5) + + # VPC 1 should remain, VPC 2 should be disassociated + route53_validator.assert_vpc_association(zone_id, bootstrap.AdditionalVPC.vpc_id, exists=True) + route53_validator.assert_vpc_association(zone_id, bootstrap.AdditionalVPC2.vpc_id, exists=False) + + # Remove all additional VPCs + updates = { + "spec": {"additionalVPCs": []}, + } + k8s.patch_custom_resource(ref, updates) + time.sleep(MODIFY_WAIT_AFTER_SECONDS) + + assert k8s.wait_on_condition(ref, "ACK.ResourceSynced", "True", wait_periods=5) + + # Both additional VPCs should now be disassociated + route53_validator.assert_vpc_association(zone_id, bootstrap.AdditionalVPC.vpc_id, exists=False) + route53_validator.assert_vpc_association(zone_id, bootstrap.AdditionalVPC2.vpc_id, exists=False) + + # Delete the zone + _, deleted = k8s.delete_custom_resource(ref) + assert deleted is True + + time.sleep(DELETE_WAIT_AFTER_SECONDS) + + route53_validator.assert_hosted_zone(zone_id, exists=False)