diff --git a/app/operator/config/crd/bases/dns.appthrust.io_providers.yaml b/app/operator/config/crd/bases/dns.appthrust.io_providers.yaml index 9ea89d1..04187a4 100644 --- a/app/operator/config/crd/bases/dns.appthrust.io_providers.yaml +++ b/app/operator/config/crd/bases/dns.appthrust.io_providers.yaml @@ -120,6 +120,9 @@ spec: enum: - Accepted - Programmed + - Withdrawn + - Ready + - OverrideAccepted type: string description: description: Description explains when the provider-specific @@ -304,6 +307,67 @@ spec: type: object type: array type: object + recordWithdrawal: + description: RecordWithdrawal declares support for independent + exclusion verification. + properties: + additionalConditionReasons: + items: + properties: + conditionType: + description: ConditionType is the status condition + type this reason applies to. + enum: + - Accepted + - Programmed + - Withdrawn + - Ready + - OverrideAccepted + type: string + description: + description: Description explains when the provider-specific + reason is used. + minLength: 1 + type: string + reason: + description: Reason is the provider-specific condition + reason stored in metav1.Condition.reason. + minLength: 1 + pattern: ^[A-Z][A-Za-z0-9]*$ + type: string + status: + description: Status is the condition status this reason + applies to. + enum: + - "True" + - "False" + - Unknown + type: string + required: + - conditionType + - description + - reason + - status + type: object + type: array + schemas: + properties: + statusProviderData: + description: StatusProviderData validates public withdrawal + provider evidence. + properties: + openAPIV3Schema: + description: OpenAPIV3Schema is the schema for the + corresponding inline object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + supported: + type: boolean + required: + - supported + type: object served: description: Served controls whether new or updated specs may reference this version. @@ -326,6 +390,9 @@ spec: enum: - Accepted - Programmed + - Withdrawn + - Ready + - OverrideAccepted type: string description: description: Description explains when the provider-specific diff --git a/app/operator/config/crd/bases/dns.appthrust.io_recordsets.yaml b/app/operator/config/crd/bases/dns.appthrust.io_recordsets.yaml index c8fd206..af5b0d7 100644 --- a/app/operator/config/crd/bases/dns.appthrust.io_recordsets.yaml +++ b/app/operator/config/crd/bases/dns.appthrust.io_recordsets.yaml @@ -223,11 +223,13 @@ spec: properties: name: description: Name is the Zone name. + maxLength: 253 minLength: 1 type: string namespace: description: Namespace defaults to the RecordSet namespace when omitted. + maxLength: 63 type: string required: - name diff --git a/app/operator/config/crd/bases/dns.appthrust.io_recordwithdrawals.yaml b/app/operator/config/crd/bases/dns.appthrust.io_recordwithdrawals.yaml new file mode 100644 index 0000000..cbf8abe --- /dev/null +++ b/app/operator/config/crd/bases/dns.appthrust.io_recordwithdrawals.yaml @@ -0,0 +1,313 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: recordwithdrawals.dns.appthrust.io +spec: + group: dns.appthrust.io + names: + kind: RecordWithdrawal + listKind: RecordWithdrawalList + plural: recordwithdrawals + singular: recordwithdrawal + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.address + name: Address + type: string + - jsonPath: .status.conditions[?(@.type=="Withdrawn")].status + name: Withdrawn + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + RecordWithdrawal is a retained request to verify exclusion of one DNS address. + It deliberately has no owner reference or automatic cleanup finalizer. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + address: + description: Address is a canonical, unscoped and unmapped IP literal. + maxLength: 39 + minLength: 1 + type: string + name: + description: Name is a canonical zone-relative owner, including @ + or a leftmost wildcard. + maxLength: 253 + minLength: 1 + type: string + zoneRef: + properties: + name: + description: Name is the Zone name. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: Namespace defaults to the RecordSet namespace when + omitted. + maxLength: 63 + type: string + required: + - name + type: object + required: + - address + - name + - zoneRef + type: object + x-kubernetes-validations: + - message: spec is immutable + rule: self == oldSelf + status: + properties: + absentSince: + format: date-time + type: string + checkedNames: + items: + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 4 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + lastProbeTime: + format: date-time + type: string + nextProbeTime: + format: date-time + type: string + notBefore: + format: date-time + type: string + observedGeneration: + format: int64 + type: integer + policyFingerprint: + type: string + provider: + description: Provider contains bounded public evidence, never private + provider state. + properties: + data: + description: Data contains public provider-specific structured + status. + type: object + x-kubernetes-preserve-unknown-fields: true + state: + description: State contains provider-controller internal state. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + providerRef: + properties: + name: + description: Name is the cluster-scoped Provider resource name. + minLength: 1 + type: string + version: + description: Version is the Provider version name. + minLength: 1 + type: string + required: + - name + - version + type: object + requiredViews: + items: + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set + resultDigest: + type: string + sampledWildcard: + type: boolean + unit: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + verifiedAt: + format: date-time + type: string + waiverRef: + properties: + digest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + name: + minLength: 1 + type: string + reservationRef: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + minLength: 1 + type: string + required: + - digest + - name + - reservationRef + - uid + type: object + zone: + description: Zone and Unit are the exact binding used to interpret + the provider result. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/app/operator/config/crd/bases/dns.appthrust.io_recordwithdrawalwaivers.yaml b/app/operator/config/crd/bases/dns.appthrust.io_recordwithdrawalwaivers.yaml new file mode 100644 index 0000000..9130362 --- /dev/null +++ b/app/operator/config/crd/bases/dns.appthrust.io_recordwithdrawalwaivers.yaml @@ -0,0 +1,198 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: recordwithdrawalwaivers.dns.appthrust.io +spec: + group: dns.appthrust.io + names: + kind: RecordWithdrawalWaiver + listKind: RecordWithdrawalWaiverList + plural: recordwithdrawalwaivers + singular: recordwithdrawalwaiver + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + RecordWithdrawalWaiver records separately RBAC-authorized acceptance of stale + DNS risk. Kubernetes audit, not client-supplied identity fields, is authority. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + acceptStaleDNSRisk: + type: boolean + x-kubernetes-validations: + - message: acceptStaleDNSRisk must be true + rule: self == true + address: + maxLength: 39 + minLength: 1 + type: string + claimSpecDigest: + maxLength: 71 + pattern: ^sha256:[0-9a-f]{64}$ + type: string + reason: + maxLength: 2048 + minLength: 1 + type: string + reservationRef: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + ticketRef: + maxLength: 1024 + minLength: 1 + type: string + withdrawalRef: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + required: + - acceptStaleDNSRisk + - address + - claimSpecDigest + - reason + - reservationRef + - ticketRef + - withdrawalRef + type: object + x-kubernetes-validations: + - message: spec is immutable + rule: self == oldSelf + status: + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/app/operator/config/crd/bases/dns.appthrust.io_zoneunits.yaml b/app/operator/config/crd/bases/dns.appthrust.io_zoneunits.yaml index 55811c1..98b73d7 100644 --- a/app/operator/config/crd/bases/dns.appthrust.io_zoneunits.yaml +++ b/app/operator/config/crd/bases/dns.appthrust.io_zoneunits.yaml @@ -255,6 +255,126 @@ spec: - recordSetNamespace - recordSetName x-kubernetes-list-type: map + withdrawals: + description: Withdrawals retains independently authorized address-exclusion + claims. + items: + description: |- + Scalar keys duplicate the nested identity because Kubernetes map-list keys + cannot address nested fields. CEL prevents either representation diverging. + properties: + address: + maxLength: 39 + minLength: 1 + type: string + allowed: + type: boolean + claim: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + claimName: + maxLength: 253 + minLength: 1 + type: string + claimNamespace: + maxLength: 63 + minLength: 1 + type: string + name: + maxLength: 253 + minLength: 1 + type: string + observedGeneration: + format: int64 + minimum: 1 + type: integer + waiver: + properties: + digest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + name: + minLength: 1 + type: string + reservationRef: + description: ReservationRef is immutable waiver scope, not + platform lookup authority. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + minLength: 1 + type: string + required: + - digest + - name + - reservationRef + - uid + type: object + required: + - address + - allowed + - claim + - claimName + - claimNamespace + - name + - observedGeneration + type: object + x-kubernetes-validations: + - message: scalar claim keys must match claim + rule: self.claimNamespace == self.claim.namespace && self.claimName + == self.claim.name + maxItems: 256 + type: array + x-kubernetes-list-map-keys: + - claimNamespace + - claimName + x-kubernetes-list-type: map zone: description: Zone is the accepted desired state for the owning Zone claim. @@ -287,6 +407,10 @@ spec: - name - namespace type: object + zoneClassObservedGeneration: + format: int64 + minimum: 1 + type: integer zoneClassRef: description: ZoneClassRef is the resolved ZoneClass reference. properties: @@ -302,6 +426,11 @@ spec: - name - namespace type: object + zoneClassUID: + description: |- + ZoneClassUID and ZoneClassObservedGeneration fence policy changes before + provider reconciliation. Missing legacy bindings cannot prove withdrawal. + type: string required: - domainName - ref @@ -514,6 +643,217 @@ spec: - recordSetNamespace - recordSetName x-kubernetes-list-type: map + withdrawals: + description: Withdrawals contains identity-bound exclusion verification + results. + items: + properties: + absentSince: + format: date-time + type: string + checkedNames: + items: + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set + claim: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + claimName: + maxLength: 253 + minLength: 1 + type: string + claimNamespace: + maxLength: 63 + minLength: 1 + type: string + conditions: + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 4 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + lastProbeTime: + format: date-time + type: string + nextProbeTime: + format: date-time + type: string + notBefore: + format: date-time + type: string + observedGeneration: + format: int64 + minimum: 1 + type: integer + policyFingerprint: + type: string + provider: + properties: + data: + description: Data contains public provider-specific structured + status. + type: object + x-kubernetes-preserve-unknown-fields: true + state: + description: State contains provider-controller internal + state. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + requiredViews: + items: + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set + resultDigest: + type: string + sampledWildcard: + type: boolean + verifiedAt: + format: date-time + type: string + waiverRef: + properties: + digest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + name: + minLength: 1 + type: string + reservationRef: + description: WithdrawalObjectReference binds one immutable + Kubernetes object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + minLength: 1 + type: string + required: + - digest + - name + - reservationRef + - uid + type: object + required: + - claim + - claimName + - claimNamespace + - observedGeneration + type: object + x-kubernetes-validations: + - message: scalar claim keys must match claim + rule: self.claimNamespace == self.claim.namespace && self.claimName + == self.claim.name + maxItems: 256 + type: array + x-kubernetes-list-map-keys: + - claimNamespace + - claimName + x-kubernetes-list-type: map zone: description: Zone contains provider result for the owning Zone claim. properties: diff --git a/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml b/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml index 9926a7c..ea1314f 100644 --- a/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml +++ b/app/operator/config/crd/bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml @@ -49,9 +49,78 @@ spec: description: Hostnames are fully qualified DNS hostnames to publish. items: type: string - minItems: 1 type: array x-kubernetes-list-type: set + retiredHostnames: + description: RetiredHostnames preserves removed names with their old + address snapshot. + items: + properties: + addresses: + items: + type: string + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + hostname: + maxLength: 253 + minLength: 1 + type: string + id: + maxLength: 128 + minLength: 1 + type: string + sourceGeneration: + format: int64 + minimum: 1 + type: integer + required: + - addresses + - hostname + - id + - sourceGeneration + type: object + maxItems: 256 + type: array + x-kubernetes-list-map-keys: + - id + x-kubernetes-list-type: map + retiredTargets: + description: RetiredTargets preserves removed addresses with their + old hostname snapshot. + items: + properties: + address: + maxLength: 39 + minLength: 1 + type: string + hostnames: + items: + type: string + maxItems: 256 + minItems: 1 + type: array + x-kubernetes-list-type: set + id: + maxLength: 128 + minLength: 1 + type: string + sourceGeneration: + format: int64 + minimum: 1 + type: integer + required: + - address + - hostnames + - id + - sourceGeneration + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - id + x-kubernetes-list-type: map targets: description: Targets are endpoint addresses that the hostnames should publish. @@ -72,13 +141,16 @@ spec: - type - value type: object - minItems: 1 type: array x-kubernetes-list-type: atomic - required: - - hostnames - - targets type: object + x-kubernetes-validations: + - message: nonempty hostname and target intent or retirement entries are + required + rule: (has(self.hostnames) && size(self.hostnames) > 0 && has(self.targets) + && size(self.targets) > 0) || (has(self.retiredTargets) && size(self.retiredTargets) + > 0) || (has(self.retiredHostnames) && size(self.retiredHostnames) + > 0) status: properties: conditions: diff --git a/app/operator/config/crd/kustomization.yaml b/app/operator/config/crd/kustomization.yaml index 8b8bcde..3986dc1 100644 --- a/app/operator/config/crd/kustomization.yaml +++ b/app/operator/config/crd/kustomization.yaml @@ -3,6 +3,8 @@ resources: - bases/dns.appthrust.io_zoneclasses.yaml - bases/dns.appthrust.io_zones.yaml - bases/dns.appthrust.io_recordsets.yaml + - bases/dns.appthrust.io_recordwithdrawals.yaml + - bases/dns.appthrust.io_recordwithdrawalwaivers.yaml - bases/dns.appthrust.io_zoneunits.yaml - bases/endpoint.dns.appthrust.io_endpointprovidercapabilities.yaml - bases/endpoint.dns.appthrust.io_endpointrecordsets.yaml diff --git a/app/operator/config/external/external_extensions.yaml b/app/operator/config/external/external_extensions.yaml index f24735f..9a7425e 100644 --- a/app/operator/config/external/external_extensions.yaml +++ b/app/operator/config/external/external_extensions.yaml @@ -160,3 +160,25 @@ spec: - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Required zone nameserver verification failed.} - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} + recordWithdrawal: + supported: true + schemas: + statusProviderData: + openAPIV3Schema: + type: object + description: Bounded withdrawal diagnostics; complete typed evidence drives verification. + additionalProperties: false + properties: + views: *preview + additionalConditionReasons: + - {conditionType: Withdrawn, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Ready, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Withdrawn, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Ready, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Withdrawn, status: 'Unknown', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Ready, status: 'False', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Withdrawn, status: 'Unknown', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Ready, status: 'False', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Withdrawn, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: Ready, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: OverrideAccepted, status: 'True', reason: OperatorAuthorizedRelease, description: An exact separately authorized waiver accepts stale DNS risk.} diff --git a/app/operator/config/provider/cloudflare_extensions.yaml b/app/operator/config/provider/cloudflare_extensions.yaml index 2420e7c..7a4cf9a 100644 --- a/app/operator/config/provider/cloudflare_extensions.yaml +++ b/app/operator/config/provider/cloudflare_extensions.yaml @@ -13,6 +13,8 @@ spec: served: true storage: true deprecated: false + recordWithdrawal: + supported: false identity: resource: group: cloudflare.dns.appthrust.io diff --git a/app/operator/config/provider/route53_extensions.yaml b/app/operator/config/provider/route53_extensions.yaml index efe3f20..0aa412a 100644 --- a/app/operator/config/provider/route53_extensions.yaml +++ b/app/operator/config/provider/route53_extensions.yaml @@ -13,6 +13,8 @@ spec: served: true storage: true deprecated: false + recordWithdrawal: + supported: false identity: resource: group: route53.dns.appthrust.io diff --git a/app/operator/config/webhook/validating_webhook_configuration.yaml b/app/operator/config/webhook/validating_webhook_configuration.yaml index 2722a9b..8d6756a 100644 --- a/app/operator/config/webhook/validating_webhook_configuration.yaml +++ b/app/operator/config/webhook/validating_webhook_configuration.yaml @@ -152,3 +152,31 @@ webhooks: resources: - zoneunits - zoneunits/status + - name: recordwithdrawals.dns.appthrust.io + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + clientConfig: + service: + namespace: dns-api-system + name: dns-api-webhook-service + path: /validate-dns-appthrust-io-v1alpha1-recordwithdrawal + rules: + - apiGroups: [dns.appthrust.io] + apiVersions: [v1alpha1] + operations: [CREATE, UPDATE] + resources: [recordwithdrawals, recordwithdrawals/status] + - name: recordwithdrawalwaivers.dns.appthrust.io + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: Fail + clientConfig: + service: + namespace: dns-api-system + name: dns-api-webhook-service + path: /validate-dns-appthrust-io-v1alpha1-recordwithdrawalwaiver + rules: + - apiGroups: [dns.appthrust.io] + apiVersions: [v1alpha1] + operations: [CREATE, UPDATE] + resources: [recordwithdrawalwaivers, recordwithdrawalwaivers/status] diff --git a/deploy/charts/dns-api/crds/dns.appthrust.io_providers.yaml b/deploy/charts/dns-api/crds/dns.appthrust.io_providers.yaml index 9ea89d1..04187a4 100644 --- a/deploy/charts/dns-api/crds/dns.appthrust.io_providers.yaml +++ b/deploy/charts/dns-api/crds/dns.appthrust.io_providers.yaml @@ -120,6 +120,9 @@ spec: enum: - Accepted - Programmed + - Withdrawn + - Ready + - OverrideAccepted type: string description: description: Description explains when the provider-specific @@ -304,6 +307,67 @@ spec: type: object type: array type: object + recordWithdrawal: + description: RecordWithdrawal declares support for independent + exclusion verification. + properties: + additionalConditionReasons: + items: + properties: + conditionType: + description: ConditionType is the status condition + type this reason applies to. + enum: + - Accepted + - Programmed + - Withdrawn + - Ready + - OverrideAccepted + type: string + description: + description: Description explains when the provider-specific + reason is used. + minLength: 1 + type: string + reason: + description: Reason is the provider-specific condition + reason stored in metav1.Condition.reason. + minLength: 1 + pattern: ^[A-Z][A-Za-z0-9]*$ + type: string + status: + description: Status is the condition status this reason + applies to. + enum: + - "True" + - "False" + - Unknown + type: string + required: + - conditionType + - description + - reason + - status + type: object + type: array + schemas: + properties: + statusProviderData: + description: StatusProviderData validates public withdrawal + provider evidence. + properties: + openAPIV3Schema: + description: OpenAPIV3Schema is the schema for the + corresponding inline object. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + type: object + supported: + type: boolean + required: + - supported + type: object served: description: Served controls whether new or updated specs may reference this version. @@ -326,6 +390,9 @@ spec: enum: - Accepted - Programmed + - Withdrawn + - Ready + - OverrideAccepted type: string description: description: Description explains when the provider-specific diff --git a/deploy/charts/dns-api/crds/dns.appthrust.io_recordsets.yaml b/deploy/charts/dns-api/crds/dns.appthrust.io_recordsets.yaml index c8fd206..af5b0d7 100644 --- a/deploy/charts/dns-api/crds/dns.appthrust.io_recordsets.yaml +++ b/deploy/charts/dns-api/crds/dns.appthrust.io_recordsets.yaml @@ -223,11 +223,13 @@ spec: properties: name: description: Name is the Zone name. + maxLength: 253 minLength: 1 type: string namespace: description: Namespace defaults to the RecordSet namespace when omitted. + maxLength: 63 type: string required: - name diff --git a/deploy/charts/dns-api/crds/dns.appthrust.io_recordwithdrawals.yaml b/deploy/charts/dns-api/crds/dns.appthrust.io_recordwithdrawals.yaml new file mode 100644 index 0000000..cbf8abe --- /dev/null +++ b/deploy/charts/dns-api/crds/dns.appthrust.io_recordwithdrawals.yaml @@ -0,0 +1,313 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: recordwithdrawals.dns.appthrust.io +spec: + group: dns.appthrust.io + names: + kind: RecordWithdrawal + listKind: RecordWithdrawalList + plural: recordwithdrawals + singular: recordwithdrawal + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.address + name: Address + type: string + - jsonPath: .status.conditions[?(@.type=="Withdrawn")].status + name: Withdrawn + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + RecordWithdrawal is a retained request to verify exclusion of one DNS address. + It deliberately has no owner reference or automatic cleanup finalizer. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + address: + description: Address is a canonical, unscoped and unmapped IP literal. + maxLength: 39 + minLength: 1 + type: string + name: + description: Name is a canonical zone-relative owner, including @ + or a leftmost wildcard. + maxLength: 253 + minLength: 1 + type: string + zoneRef: + properties: + name: + description: Name is the Zone name. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: Namespace defaults to the RecordSet namespace when + omitted. + maxLength: 63 + type: string + required: + - name + type: object + required: + - address + - name + - zoneRef + type: object + x-kubernetes-validations: + - message: spec is immutable + rule: self == oldSelf + status: + properties: + absentSince: + format: date-time + type: string + checkedNames: + items: + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 4 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + lastProbeTime: + format: date-time + type: string + nextProbeTime: + format: date-time + type: string + notBefore: + format: date-time + type: string + observedGeneration: + format: int64 + type: integer + policyFingerprint: + type: string + provider: + description: Provider contains bounded public evidence, never private + provider state. + properties: + data: + description: Data contains public provider-specific structured + status. + type: object + x-kubernetes-preserve-unknown-fields: true + state: + description: State contains provider-controller internal state. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + providerRef: + properties: + name: + description: Name is the cluster-scoped Provider resource name. + minLength: 1 + type: string + version: + description: Version is the Provider version name. + minLength: 1 + type: string + required: + - name + - version + type: object + requiredViews: + items: + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set + resultDigest: + type: string + sampledWildcard: + type: boolean + unit: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + verifiedAt: + format: date-time + type: string + waiverRef: + properties: + digest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + name: + minLength: 1 + type: string + reservationRef: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + minLength: 1 + type: string + required: + - digest + - name + - reservationRef + - uid + type: object + zone: + description: Zone and Unit are the exact binding used to interpret + the provider result. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/dns-api/crds/dns.appthrust.io_recordwithdrawalwaivers.yaml b/deploy/charts/dns-api/crds/dns.appthrust.io_recordwithdrawalwaivers.yaml new file mode 100644 index 0000000..9130362 --- /dev/null +++ b/deploy/charts/dns-api/crds/dns.appthrust.io_recordwithdrawalwaivers.yaml @@ -0,0 +1,198 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: recordwithdrawalwaivers.dns.appthrust.io +spec: + group: dns.appthrust.io + names: + kind: RecordWithdrawalWaiver + listKind: RecordWithdrawalWaiverList + plural: recordwithdrawalwaivers + singular: recordwithdrawalwaiver + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + RecordWithdrawalWaiver records separately RBAC-authorized acceptance of stale + DNS risk. Kubernetes audit, not client-supplied identity fields, is authority. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + properties: + acceptStaleDNSRisk: + type: boolean + x-kubernetes-validations: + - message: acceptStaleDNSRisk must be true + rule: self == true + address: + maxLength: 39 + minLength: 1 + type: string + claimSpecDigest: + maxLength: 71 + pattern: ^sha256:[0-9a-f]{64}$ + type: string + reason: + maxLength: 2048 + minLength: 1 + type: string + reservationRef: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + ticketRef: + maxLength: 1024 + minLength: 1 + type: string + withdrawalRef: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + required: + - acceptStaleDNSRisk + - address + - claimSpecDigest + - reason + - reservationRef + - ticketRef + - withdrawalRef + type: object + x-kubernetes-validations: + - message: spec is immutable + rule: self == oldSelf + status: + properties: + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 1 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + observedGeneration: + format: int64 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/deploy/charts/dns-api/crds/dns.appthrust.io_zoneunits.yaml b/deploy/charts/dns-api/crds/dns.appthrust.io_zoneunits.yaml index 55811c1..98b73d7 100644 --- a/deploy/charts/dns-api/crds/dns.appthrust.io_zoneunits.yaml +++ b/deploy/charts/dns-api/crds/dns.appthrust.io_zoneunits.yaml @@ -255,6 +255,126 @@ spec: - recordSetNamespace - recordSetName x-kubernetes-list-type: map + withdrawals: + description: Withdrawals retains independently authorized address-exclusion + claims. + items: + description: |- + Scalar keys duplicate the nested identity because Kubernetes map-list keys + cannot address nested fields. CEL prevents either representation diverging. + properties: + address: + maxLength: 39 + minLength: 1 + type: string + allowed: + type: boolean + claim: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + claimName: + maxLength: 253 + minLength: 1 + type: string + claimNamespace: + maxLength: 63 + minLength: 1 + type: string + name: + maxLength: 253 + minLength: 1 + type: string + observedGeneration: + format: int64 + minimum: 1 + type: integer + waiver: + properties: + digest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + name: + minLength: 1 + type: string + reservationRef: + description: ReservationRef is immutable waiver scope, not + platform lookup authority. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + minLength: 1 + type: string + required: + - digest + - name + - reservationRef + - uid + type: object + required: + - address + - allowed + - claim + - claimName + - claimNamespace + - name + - observedGeneration + type: object + x-kubernetes-validations: + - message: scalar claim keys must match claim + rule: self.claimNamespace == self.claim.namespace && self.claimName + == self.claim.name + maxItems: 256 + type: array + x-kubernetes-list-map-keys: + - claimNamespace + - claimName + x-kubernetes-list-type: map zone: description: Zone is the accepted desired state for the owning Zone claim. @@ -287,6 +407,10 @@ spec: - name - namespace type: object + zoneClassObservedGeneration: + format: int64 + minimum: 1 + type: integer zoneClassRef: description: ZoneClassRef is the resolved ZoneClass reference. properties: @@ -302,6 +426,11 @@ spec: - name - namespace type: object + zoneClassUID: + description: |- + ZoneClassUID and ZoneClassObservedGeneration fence policy changes before + provider reconciliation. Missing legacy bindings cannot prove withdrawal. + type: string required: - domainName - ref @@ -514,6 +643,217 @@ spec: - recordSetNamespace - recordSetName x-kubernetes-list-type: map + withdrawals: + description: Withdrawals contains identity-bound exclusion verification + results. + items: + properties: + absentSince: + format: date-time + type: string + checkedNames: + items: + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set + claim: + description: WithdrawalObjectReference binds one immutable Kubernetes + object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + claimName: + maxLength: 253 + minLength: 1 + type: string + claimNamespace: + maxLength: 63 + minLength: 1 + type: string + conditions: + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 4 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + lastProbeTime: + format: date-time + type: string + nextProbeTime: + format: date-time + type: string + notBefore: + format: date-time + type: string + observedGeneration: + format: int64 + minimum: 1 + type: integer + policyFingerprint: + type: string + provider: + properties: + data: + description: Data contains public provider-specific structured + status. + type: object + x-kubernetes-preserve-unknown-fields: true + state: + description: State contains provider-controller internal + state. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + requiredViews: + items: + type: string + maxItems: 256 + type: array + x-kubernetes-list-type: set + resultDigest: + type: string + sampledWildcard: + type: boolean + verifiedAt: + format: date-time + type: string + waiverRef: + properties: + digest: + pattern: ^sha256:[0-9a-f]{64}$ + type: string + name: + minLength: 1 + type: string + reservationRef: + description: WithdrawalObjectReference binds one immutable + Kubernetes object incarnation. + properties: + name: + maxLength: 253 + minLength: 1 + type: string + namespace: + maxLength: 63 + minLength: 1 + type: string + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + maxLength: 128 + minLength: 1 + type: string + required: + - name + - namespace + - uid + type: object + uid: + description: |- + UID is a type that holds unique ID values, including UUIDs. Because we + don't ONLY use UUIDs, this is an alias to string. Being a type captures + intent and helps make sure that UIDs and names do not get conflated. + minLength: 1 + type: string + required: + - digest + - name + - reservationRef + - uid + type: object + required: + - claim + - claimName + - claimNamespace + - observedGeneration + type: object + x-kubernetes-validations: + - message: scalar claim keys must match claim + rule: self.claimNamespace == self.claim.namespace && self.claimName + == self.claim.name + maxItems: 256 + type: array + x-kubernetes-list-map-keys: + - claimNamespace + - claimName + x-kubernetes-list-type: map zone: description: Zone contains provider result for the owning Zone claim. properties: diff --git a/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointrecordsets.yaml b/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointrecordsets.yaml index 9926a7c..ea1314f 100644 --- a/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointrecordsets.yaml +++ b/deploy/charts/dns-api/crds/endpoint.dns.appthrust.io_endpointrecordsets.yaml @@ -49,9 +49,78 @@ spec: description: Hostnames are fully qualified DNS hostnames to publish. items: type: string - minItems: 1 type: array x-kubernetes-list-type: set + retiredHostnames: + description: RetiredHostnames preserves removed names with their old + address snapshot. + items: + properties: + addresses: + items: + type: string + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + hostname: + maxLength: 253 + minLength: 1 + type: string + id: + maxLength: 128 + minLength: 1 + type: string + sourceGeneration: + format: int64 + minimum: 1 + type: integer + required: + - addresses + - hostname + - id + - sourceGeneration + type: object + maxItems: 256 + type: array + x-kubernetes-list-map-keys: + - id + x-kubernetes-list-type: map + retiredTargets: + description: RetiredTargets preserves removed addresses with their + old hostname snapshot. + items: + properties: + address: + maxLength: 39 + minLength: 1 + type: string + hostnames: + items: + type: string + maxItems: 256 + minItems: 1 + type: array + x-kubernetes-list-type: set + id: + maxLength: 128 + minLength: 1 + type: string + sourceGeneration: + format: int64 + minimum: 1 + type: integer + required: + - address + - hostnames + - id + - sourceGeneration + type: object + maxItems: 64 + type: array + x-kubernetes-list-map-keys: + - id + x-kubernetes-list-type: map targets: description: Targets are endpoint addresses that the hostnames should publish. @@ -72,13 +141,16 @@ spec: - type - value type: object - minItems: 1 type: array x-kubernetes-list-type: atomic - required: - - hostnames - - targets type: object + x-kubernetes-validations: + - message: nonempty hostname and target intent or retirement entries are + required + rule: (has(self.hostnames) && size(self.hostnames) > 0 && has(self.targets) + && size(self.targets) > 0) || (has(self.retiredTargets) && size(self.retiredTargets) + > 0) || (has(self.retiredHostnames) && size(self.retiredHostnames) + > 0) status: properties: conditions: diff --git a/deploy/charts/dns-api/templates/provider-cloudflare.yaml b/deploy/charts/dns-api/templates/provider-cloudflare.yaml index 996a5bf..d311b3b 100644 --- a/deploy/charts/dns-api/templates/provider-cloudflare.yaml +++ b/deploy/charts/dns-api/templates/provider-cloudflare.yaml @@ -14,6 +14,8 @@ spec: served: true storage: true deprecated: false + recordWithdrawal: + supported: false identity: resource: group: cloudflare.dns.appthrust.io diff --git a/deploy/charts/dns-api/templates/provider-external.yaml b/deploy/charts/dns-api/templates/provider-external.yaml index 5ddfea7..6c8f84f 100644 --- a/deploy/charts/dns-api/templates/provider-external.yaml +++ b/deploy/charts/dns-api/templates/provider-external.yaml @@ -161,4 +161,26 @@ spec: - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Required zone nameserver verification failed.} - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} + recordWithdrawal: + supported: true + schemas: + statusProviderData: + openAPIV3Schema: + type: object + description: Bounded withdrawal diagnostics; complete typed evidence drives verification. + additionalProperties: false + properties: + views: *preview + additionalConditionReasons: + - {conditionType: Withdrawn, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Ready, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Withdrawn, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Ready, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Withdrawn, status: 'Unknown', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Ready, status: 'False', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Withdrawn, status: 'Unknown', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Ready, status: 'False', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Withdrawn, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: Ready, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: OverrideAccepted, status: 'True', reason: OperatorAuthorizedRelease, description: An exact separately authorized waiver accepts stale DNS risk.} {{- end }} diff --git a/deploy/charts/dns-api/templates/provider-route53.yaml b/deploy/charts/dns-api/templates/provider-route53.yaml index 5b4c567..5bbc032 100644 --- a/deploy/charts/dns-api/templates/provider-route53.yaml +++ b/deploy/charts/dns-api/templates/provider-route53.yaml @@ -14,6 +14,8 @@ spec: served: true storage: true deprecated: false + recordWithdrawal: + supported: false identity: resource: group: route53.dns.appthrust.io diff --git a/deploy/charts/dns-api/templates/rbac.yaml b/deploy/charts/dns-api/templates/rbac.yaml index 0346733..7d3a9e4 100644 --- a/deploy/charts/dns-api/templates/rbac.yaml +++ b/deploy/charts/dns-api/templates/rbac.yaml @@ -91,6 +91,8 @@ rules: resources: - recordsets - zoneunits + - recordwithdrawals + - recordwithdrawalwaivers verbs: - create - delete @@ -113,6 +115,8 @@ rules: - recordsets/status - zoneclasses/status - zones/status + - recordwithdrawals/status + - recordwithdrawalwaivers/status verbs: - get - patch diff --git a/deploy/charts/dns-api/templates/validating-webhook-configuration.yaml b/deploy/charts/dns-api/templates/validating-webhook-configuration.yaml index c809831..2e99c09 100644 --- a/deploy/charts/dns-api/templates/validating-webhook-configuration.yaml +++ b/deploy/charts/dns-api/templates/validating-webhook-configuration.yaml @@ -161,5 +161,21 @@ webhooks: resources: - zoneunits - zoneunits/status + {{- range $kind := list "recordwithdrawal" "recordwithdrawalwaiver" }} + - name: {{ $kind }}s.dns.appthrust.io + admissionReviewVersions: [v1] + sideEffects: None + failurePolicy: {{ $.Values.webhook.failurePolicy }} + clientConfig: + service: + namespace: {{ $.Release.Namespace }} + name: {{ include "dns-api.webhookServiceName" $ }} + path: /validate-dns-appthrust-io-v1alpha1-{{ $kind }} + rules: + - apiGroups: [dns.appthrust.io] + apiVersions: [v1alpha1] + operations: [CREATE, UPDATE] + resources: [{{ $kind }}s, {{ $kind }}s/status] + {{- end }} {{- end }} diff --git a/deps/README.md b/deps/README.md index 44b55fe..d34278b 100644 --- a/deps/README.md +++ b/deps/README.md @@ -6,4 +6,4 @@ Use [the template](./dep-template.md) for a new proposal. Single Markdown files | DEP | Title | Status | | --- | --- | --- | -| [0003](./dep-0003-record-withdrawal.md) | Verified record withdrawal before address reuse | Draft | +| [0003](./dep-0003-record-withdrawal.md) | Verified record withdrawal before address reuse | Accepted | diff --git a/deps/dep-0003-record-withdrawal.md b/deps/dep-0003-record-withdrawal.md index e96fa08..f90479e 100644 --- a/deps/dep-0003-record-withdrawal.md +++ b/deps/dep-0003-record-withdrawal.md @@ -1,14 +1,17 @@ # DEP-0003: Verified record withdrawal before address reuse -- Status: Draft +- Status: Accepted - Owner: dns-api maintainers (Core, Endpoint App, and external provider) - Created: 2026-10-18 +- Accepted: 2026-10-18 + +The accepted contract is now carried by `docs/design/`; this DEP retains rationale, alternatives, and compatibility history. ## Summary Introduce independent Core `RecordWithdrawal` and `RecordWithdrawalWaiver` claims, Core composition/projection through `ZoneUnit`, and external-provider exclusion verification. Endpoint sources preserve retired hostname/address pairs until the Endpoint App durably creates claims. Platform consumers use verified results before member retirement and address reuse; exceptional operator risk acceptance remains distinct from DNS verification. -This is the dns-api part of the reviewed **Phase 4b: verified external-DNS withdrawal before VIP reuse** design, especially §§3.2–3.4, 4.1–4.3, 6–7, 8.2, and 9. It is a follow-up after PR #16, not additional stage-1 implementation scope. It is proposed behavior, not an implementation or runtime-verification report. Under the [DEP process](../docs/design/common.md#dep-process), committed `docs/design/` remains current specification until acceptance and reflection there. +This is the dns-api part of the reviewed **Phase 4b: verified external-DNS withdrawal before VIP reuse** design, especially §§3.2–3.4, 4.1–4.3, 6–7, 8.2, and 9. It follows PR #16 rather than expanding stage-1 scope. Acceptance is not a runtime-verification report. Under the [DEP process](../docs/design/common.md#dep-process), `docs/design/` carries the current contract. ## Motivation @@ -209,6 +212,8 @@ Two claim creators are intentional: platform CapacityReservation creates the man The dns-api Core follow-up release, reviewed platform import, and CRD delivery precede platform Phase 4b support. Missing CRD/watch/verifier, denied/truncated reads, missing/stale results, or a mode switch cannot erase held external obligations. Pre-4b retirements that already lost history are not reconstructed by epochs or synthetic success. +`ZoneUnit.spec.zone` binds the ZoneClass UID and observed generation; withdrawal projection requires current Unit status generation and current Class binding, and consumers compare the live Class UID/generation before consuming results. + ## Documentation On acceptance, reflect this contract in present tense into: diff --git a/docs/design/controllers-webhooks.md b/docs/design/controllers-webhooks.md index 2406b9a..e4505c9 100644 --- a/docs/design/controllers-webhooks.md +++ b/docs/design/controllers-webhooks.md @@ -91,6 +91,20 @@ If composition acceptance is `False`, the claim `Accepted` is `False` and the it `Accepted=False` means the controller has enough information to reject the claim. `Accepted=Unknown` means the validation material is incomplete or temporarily unavailable, such as missing `Zone`, missing `ZoneClass`, missing provider identity state, missing owner state during GitOps recovery, unavailable Provider storage version, unavailable Provider conversion webhook, or a transient core error. +## Withdrawal Composition and Projection + +Core composes independent `RecordWithdrawal` claims into `ZoneUnit.spec.withdrawals[]`; providers do not read those claims, waivers, EndpointRecordSets, or platform kinds. Core alone writes public withdrawal/waiver status. The Endpoint App only creates retained pair claims; source Apps only preserve endpoint retirement intent. + +Composition resolves the live Zone, Class UID/generation, served input and storage Provider versions, and exact current nondeleting Unit binding. Both input and storage versions must support `recordWithdrawal`. Core applies `allowedZones` and the corresponding A/AAAA `allowedRecordSets` grants based on address family. Cross-namespace access and wildcard access require explicit grants. Withdrawals reserve no DNS record ownership and do not conflict with replacement publication. + +Acceptance is `True/Accepted` or `False/InvalidWithdrawal`, `NotAllowed`, or `UnsupportedProvider`; unlike ordinary unresolved claim acceptance, an unavailable withdrawal binding is `False/InvalidWithdrawal`. Valid-shaped rejected claims remain in the Unit with `allowed=false`. If the Zone inventory exceeds 256 claims, Core rejects the inventory as a whole, retains previous Unit entries disallowed with waivers removed, and never truncates obligations. + +Core validates waiver claim UID, immutable-spec digest, canonical address, and namespace scope directly before attachment; deterministic namespace/name ordering selects the first matching waiver. An independent waiver reconciler maintains `Accepted` even if the claim or Zone disappears. No SubjectAccessReview, client-supplied audit identity, or receiving platform-resource lookup is involved. + +Projection requires current Unit `status.observedGeneration`, Class UID/generation, exact current desired item including authorization/waiver, matching claim keys/UID/generation, and current `Withdrawn`/`Ready` condition generations. It preserves provider messages and public evidence, never private provider state. Override projection additionally requires the exact composed waiver name/UID/digest/reservation reference and current `OverrideAccepted=True/OperatorAuthorizedRelease`. Missing or stale results yield `Withdrawn=Unknown`, `Ready=False`, reason `DNSProbeFailed`, message `No current identity-bound withdrawal observation is available`. Same-name recreation cannot inherit success. + +Core retains every withdrawal as a Zone deletion dependency, including verified claims, and continues composition/verification while the Zone waits for explicit evidence archival. Pending withdrawal status does not change unrelated Zone/RecordSet `Programmed`. + ## Admission Webhook Admission webhook is the API input boundary. It validates core schema, immutable fields, object-local references, object-local policy shape, and provider schema / CEL validation. It does not call DNS provider APIs, evaluate cross-object namespace / record access policy, or update status. @@ -103,7 +117,7 @@ The webhook server runs as part of the controller-runtime manager and listens on Webhook TLS certificates are issued by cert-manager. Tilt installs cert-manager as a Helm chart. dns-api manifests apply `Certificate`, `Issuer`, and `ValidatingWebhookConfiguration`. cert-manager CA injection sets the webhook `caBundle`. -Core validating webhook covers at least spec create/update for `Provider`, `ZoneClass`, `Zone`, `RecordSet`, and `ZoneUnit`, and status update for `ZoneClass`, `Zone`, `RecordSet`, and `ZoneUnit`. Provider-specific validating webhooks cover enabled provider-specific resources such as `Route53Identity` and `CloudflareIdentity`; external has no identity admission handler. The core webhook must not import provider API packages such as `api/route53` or `api/cloudflare`. +Core validating webhook covers spec create/update and status update for `RecordWithdrawal` and `RecordWithdrawalWaiver` in addition to `Provider`, `ZoneClass`, `Zone`, `RecordSet`, and `ZoneUnit` as applicable. Withdrawal admission validates canonical relative name/address, required address-label consistency, no ownerReferences/finalizers, immutable spec, reference/digest shapes, and bounded conditions/evidence. It performs no DNS queries or cross-object grant checks. Unit withdrawal map keys must match nested claim identity; same-incarnation immutable shape cannot change. Status validates generation, the closed withdrawal reason table, 2048-byte messages, and public provider data against `recordWithdrawal.schemas.statusProviderData`; claim `provider.state` is forbidden. Provider-specific validating webhooks cover enabled resources such as `Route53Identity` and `CloudflareIdentity`; external has no identity handler. Core does not import provider API packages. Provider controllers do not fully trust the webhook. The webhook is the primary user feedback boundary, but controllers keep minimal safety checks. Controller-side validation failure is returned as `Accepted=False` or `Programmed=False`. diff --git a/docs/design/core-api.md b/docs/design/core-api.md index 8c90e00..e294057 100644 --- a/docs/design/core-api.md +++ b/docs/design/core-api.md @@ -678,9 +678,27 @@ spec: - ns-222.example-dns.net ``` +## RecordWithdrawal + +`RecordWithdrawal` is a namespace-scoped retained request to exclude one address from customer-managed DNS. Its immutable `spec` contains `zoneRef` (omitted namespace means the claim namespace), canonical zone-relative `name` (`@`, exact name, or leftmost wildcard), and one canonical unscoped, unmapped IPv4 or IPv6 `address`. It has no provider selection, resolver policy, ownerReferences, or finalizers. Admission rejects spec changes with `spec is immutable`. + +The required `endpoint.dns.appthrust.io/address` label equals the canonical IPv4 string or lowercase unpadded base32 of the normalized IPv6 bytes. It is a discovery key, not authorization or proof. Source annotations carry provenance, not authority. Endpoint-created names bind ERS UID, pre-update retirement generation, hostname, and address; create-or-get checks immutable shape, lifecycle, label, and source annotations. + +Core owns public status: `observedGeneration`, resolved `zone` and `unit` namespace/name/UID references, `providerRef`, `policyFingerprint`, `checkedNames`, `requiredViews`, `sampledWildcard`, `absentSince`, `notBefore`, `lastProbeTime`, `nextProbeTime`, `verifiedAt`, `resultDigest`, public `provider.data`, and optional `waiverRef`. Checked names and views each have a 256-item bound. At most four generation-aware conditions describe `Accepted`, `Withdrawn`, `Ready`, and optional `OverrideAccepted`. Provider-private state is not projected. The [external verification contract](./external-provider.md#withdrawal-verification) defines the condition/reason/message table and cache proof. + +Claims remain after source deletion and after verification; there is no age-based garbage collection. Every claim referencing a Zone is a deletion dependency, regardless of readiness. Operators explicitly archive/remove evidence when it is no longer consumable. Claim or binding disappearance never proves withdrawal. + +## RecordWithdrawalWaiver + +`RecordWithdrawalWaiver` is a separate namespace-scoped immutable declaration of stale-DNS risk acceptance. Its spec contains `withdrawalRef` namespace/name/UID, `claimSpecDigest` (`sha256:` plus 64 lowercase hex digits over the immutable claim wire spec), canonical `address`, receiving `reservationRef` namespace/name/UID, nonempty `reason` (at most 2048 bytes), `ticketRef` (at most 1024 bytes), and `acceptStaleDNSRisk: true`. It has no ownerReferences or finalizers. + +Waiver creation is separately RBAC-authorized; Kubernetes audit supplies actor/time/provenance. Client-supplied identity fields and platform lookups are not authority. Core validates the exact claim UID/spec digest/address and same-namespace scope, then composes the waiver name/UID/spec digest/reservation reference into the Unit. Public waiver status contains `observedGeneration` and `Accepted=True/Accepted` or `Accepted=False/InvalidWithdrawal`, maintained independently of Zone existence. + +The provider reports `OverrideAccepted=True/OperatorAuthorizedRelease` for the exact Core-validated scope. A waiver does not set `Withdrawn=True` or make DNS ready, and another claim or reservation incarnation cannot consume it. Claims and waivers are retained independently of their sources. + ## ZoneUnit -`ZoneUnit` is the zone-scoped desired-state and ownership ledger built from accepted `Zone` and `RecordSet` claims. It is namespace-scoped and uses the same namespace and name as the owning `Zone`. Users normally create `Zone` and `RecordSet` claims; the Core ZoneUnit Controller creates and updates `ZoneUnit`. +`ZoneUnit` is the zone-scoped desired-state and ownership ledger built from `Zone`, `RecordSet`, and retained `RecordWithdrawal` claims. It is namespace-scoped and uses the same namespace and name as the owning `Zone`. Users normally create claims; the Core ZoneUnit Controller creates and updates `ZoneUnit`. `ZoneUnit.metadata.name` must match `spec.zone.ref.name`; the CRD validates this with CEL. `ZoneUnit.metadata.namespace` must match `spec.zone.ref.namespace`; Kubernetes CRD CEL does not expose `metadata.namespace`, so the core validating webhook rejects namespace mismatches on create and update. Provider controllers do not define an `InvalidZoneUnitName` condition reason and do not handle mismatched names defensively, because invalid `ZoneUnit` names are rejected before provider reconciliation. @@ -695,6 +713,7 @@ spec: - `zone.observedGeneration`: `Zone.metadata.generation` used to build this item. - `zone.domainName`: accepted zone domain name. - `zone.zoneClassRef`: resolved `ZoneClass` reference. +- `zone.zoneClassUID`, `zone.zoneClassObservedGeneration`: resolved Class incarnation and generation used for composition. These optional wire fields fence withdrawal policy changes; missing legacy bindings cannot establish withdrawal. - `zone.adoption`: provider-specific adoption payload after storage-version conversion. - `recordSets`: accepted desired state for owner `RecordSet` claims. - `recordSets[].recordSetNamespace`: owner `RecordSet` namespace. This is part of the list-map key. @@ -709,6 +728,10 @@ spec: `recordSets` is a Kubernetes `listType=map` keyed by scalar fields `recordSetNamespace` and `recordSetName`. `recordSetRef` is not used inside the list item because nested-object list-map keys are not a CRD-safe shape across Kubernetes versions. `recordSetUID` is an incarnation fence, not an additional list key: only one owner incarnation occupies a claim key at a time. Provider controllers do not read `RecordSet` claims; core supplies the UID from the actual claim. A pending predecessor remains its own cleanup item until its UID-bound cleanup completes. A recreated same-name claim cannot inherit the predecessor's provider payload or completion. +`spec.withdrawals` and `status.withdrawals` are map-lists of at most 256 items, keyed by scalar `claimNamespace` and `claimName`. Each item also carries `claim{namespace,name,uid}`; CEL requires its namespace/name to equal the scalar keys. UID is nonempty and is an incarnation fence, not a list key. Spec items contain claim `observedGeneration`, canonical relative `name`, `address`, `allowed`, and optional Core-validated `waiver{name,uid,digest,reservationRef}`. For the same nested claim identity, admission forbids changes to name, address, or observedGeneration; authorization and waiver attachment may change. + +Status items mirror claim identity/generation and carry the withdrawal policy, checked-name/view, cache-window, probe-time, digest, condition, public provider-data, and waiver-reference fields described above. Core projects only the current exact desired item with matching claim UID/generation, condition generations, current Unit status generation, and nonempty Class UID/generation. Withdrawals do not participate in active `Programmed` aggregation or claim ownership conflicts and never create empty A/AAAA RecordSets. + `ZoneUnit.spec.zone` does not contain `Zone.metadata.uid`. Kubernetes assigns `metadata.uid`, so a `ZoneUnit` pre-applied for GitOps recovery cannot know the UID of a `Zone` claim that will be restored later. Provider controllers must not require the `Zone` UID as desired input. DNS record identity is still `zone`, `type`, and zone-relative `name`. Same `type` and zone-relative `name` always conflicts. Core also applies composition validation `forbid-cname-coexistence` by default: a `type=CNAME` item owns the whole record name and cannot coexist with any other record type at the same `Zone` and name, and a non-CNAME item cannot coexist with a CNAME item at the same `Zone` and name. A Provider storage version may disable this validation for specific `self` / `other` record-set item pairs through `zoneUnit.disableValidations[]` when the provider intentionally supports that owner-name combination. Route 53 keeps the default CNAME exclusivity. Cloudflare disables `forbid-cname-coexistence` for `CNAME` with `TXT`, `MX`, and `CAA`, and keeps conflicts for `CNAME` with `A` or `AAAA`. @@ -770,17 +793,17 @@ Deletion uses both core and provider finalizers on `ZoneUnit`: - Core keeps a finalizer on `Zone` and `ZoneUnit` while the external cleanup result must be observed. - Provider controllers keep their own finalizer on `ZoneUnit` while provider cleanup is in progress. -- `RecordSet` is a restrict-style child of `Zone`. While any `RecordSet` still references a deleting `Zone`, core keeps the matching `ZoneUnit` active and does not send the provider cleanup signal. The deleting `Zone` reports `Programmed=False`, reason `ProviderChangePending`, with a message that the remaining `RecordSet` resources must be deleted or moved first. +- `RecordSet` and `RecordWithdrawal` are restrict-style dependent claims of `Zone`. While either kind still references a deleting Zone, core keeps the matching Unit active and does not send provider cleanup. The Zone reports `Programmed=False/ProviderChangePending`; when withdrawals exist, its message includes both dependency counts and asks operators to archive dependent claims. Verified withdrawals still count. - If a `RecordSet` that references a deleting `Zone` is itself deleting, core keeps or writes its `ZoneUnit.spec.recordSets[]` item with `deletionRequested=true` until provider record-set cleanup completes. -- After no `RecordSet` references the deleting `Zone`, core deletes the matching `ZoneUnit` before removing the `Zone` finalizer. -- If the deleting `Zone` has no remaining `RecordSet` references and the matching `ZoneUnit` does not exist, core removes the `Zone` finalizer because there is no provider-owned cleanup state to observe. +- After no `RecordSet` or `RecordWithdrawal` references the deleting Zone, core deletes the matching Unit before removing the Zone finalizer. +- If neither kind references the deleting Zone and the matching Unit does not exist, core removes the Zone finalizer because no provider-owned cleanup state remains. - `ZoneUnit.metadata.deletionTimestamp` is the provider cleanup signal. Provider controllers delete or retain external resources according to provider policy, update `ZoneUnit.status`, and remove their provider finalizer after cleanup completes. - Core removes its `ZoneUnit` finalizer after provider cleanup is complete, then removes the `Zone` finalizer. - Deleting a `RecordSet` sets `recordSets[].deletionRequested=true`. Provider controllers delete the external record target, then report completion. Core removes the `RecordSet` finalizer and later removes the item from `ZoneUnit.spec.recordSets[]`. -The validating webhook rejects manual `ZoneUnit` deletion while the owning `Zone` exists and is not deleting. The owning `Zone` is resolved from `ZoneUnit.spec.zone.ref`. This prevents a user or GitOps controller from sending the provider cleanup signal while the desired `Zone` claim is still active. +The validating webhook rejects manual `ZoneUnit` deletion while any `RecordWithdrawal` references its Zone, with `ZoneUnit cannot be deleted while RecordWithdrawal resources reference its Zone`. It also rejects deletion while the owning Zone exists and is not deleting. The owning Zone is resolved from `ZoneUnit.spec.zone.ref`. -`ZoneUnit` deletion is allowed when the owning `Zone` exists and has `metadata.deletionTimestamp`; this is the normal path started by the Core ZoneUnit Controller during `Zone` deletion. `ZoneUnit` deletion is also allowed when the owning `Zone` is missing, so operators can remove a pre-applied or orphaned GitOps recovery ledger. Core does not delete a `ZoneUnit` merely because the owning `Zone` is temporarily missing during GitOps restore. +With no withdrawal dependency, `ZoneUnit` deletion is allowed when the owning Zone is deleting or missing. Core does not delete a Unit merely because the Zone is temporarily missing during GitOps restore. Provider controllers perform provider cleanup only for `ZoneUnit` resources that are in their scope and have their provider finalizer or otherwise have provider-owned cleanup to complete. They do not add claim finalizers and do not remove core finalizers. If a deleting `ZoneUnit` has no provider finalizer and no provider-owned cleanup state, provider controllers may ignore it. diff --git a/docs/design/endpoint-api.md b/docs/design/endpoint-api.md index 65897b1..aa868d4 100644 --- a/docs/design/endpoint-api.md +++ b/docs/design/endpoint-api.md @@ -36,6 +36,12 @@ manifest has been rewritten. - `hostnames`: fully qualified DNS hostnames to publish. - `targets`: endpoint addresses. The initial target types are `Hostname` and `IPAddress`. +- `retiredTargets`: map-list keyed by `id`, at most 64 entries, each containing `sourceGeneration`, removed canonical IP `address`, and a nonempty snapshot of at most 256 old `hostnames`. +- `retiredHostnames`: map-list keyed by `id`, at most 256 entries, each containing `sourceGeneration`, removed `hostname`, and a nonempty snapshot of at most 64 old IP `addresses`. + +Retirement IDs bind the ERS UID, pre-update generation, entry kind, and removed value. Snapshots preserve old associations: simultaneous removal of H1 and replacement V1→V2 retains `(H1,V1)`, not `(H1,V2)`. Sources append and preserve existing entries with resourceVersion concurrency; only the endpoint controller prunes captured intent. + +CEL requires either nonempty live hostnames **and** targets, or a nonempty retirement collection (`nonempty hostname and target intent or retirement entries are required`). A retire-only object may omit either live side. There is no ERS admission webhook; trusted source writes enforce the retirement protocol. It does not contain: @@ -89,13 +95,15 @@ prior-generation child status is not ready. Each generated child status exposes both its current metadata `generation` and mirrored `observedGeneration` so a consumer can independently verify the aggregate. -The endpoint controller holds the -`endpoint.dns.appthrust.io/generated-recordsets` finalizer while generated -`RecordSet` resources exist. On deletion it requests deletion of every generated -child and removes the finalizer only after a label-selected read proves that no -child remains in the Kubernetes API. Provider cleanup failures therefore retain -both the child provider finalizer and the parent endpoint finalizer; parent -disappearance alone is never used as inferred provider cleanup evidence. +Sources install `endpoint.dns.appthrust.io/generated-recordsets` at ERS creation, before first endpoint reconciliation; the endpoint controller also ensures it on existing active objects. This closes the source-create/direct-delete capture gap. + +Before pruning retirement entries or deleting generated children, the endpoint controller resolves each retired hostname/IP pair to its retained publication Zone binding, or the ordinary single-pair publication resolution/conversion/fallback path. For a Provider version supporting withdrawal it durably creates a deterministic independent `RecordWithdrawal`; successful Create is persisted evidence, and AlreadyExists requires Get plus exact immutable shape, lifecycle, address label, and provenance checks. Duplicate pairs at the same retirement generation share a claim. Missing resolution, collisions, write failures, and collection overflow hold capture rather than discard intent. A known non-supporting Provider requires no withdrawal claim. + +Active ERSs clear captured retirement collections with an optimistic-lock patch; a concurrent append conflicts instead of being lost. Retire-only ERSs retain entries until DELETE and report current-generation `WithdrawalCaptured=True/Captured`, message `Every retired hostname/address pair has been durably captured.` Their `Resolved`, `Accepted`, `Programmed`, and `Ready` conditions are `False/Retiring`, message `EndpointRecordSet is retiring; no active records are published.` They issue no empty conversion request or RecordSet. Capture failure reports `WithdrawalCapturePending` and `Ready=False`; source overflow leaves the old spec unchanged and is never truncated. + +`WithdrawalProtected` defaults closed: only current-generation `False` after all hostname providers resolve without withdrawal support permits the old unprotected cleanup path. Once True, protection is sticky for that ERS incarnation. `WithdrawalCaptured=True` is accepted only at the current ERS generation. Capture acknowledgment proves independent obligation persistence, not DNS withdrawal. + +Direct ERS deletion captures every live IP/hostname pair and all retirement entries before normal child deletion. The endpoint finalizer is removed only after a label-selected read proves no generated RecordSet remains. Capture/provider cleanup failures retain finalizers. Independent withdrawal claims survive parent deletion and do not delay ordinary deletion while the customer changes DNS. ## EndpointProviderCapability diff --git a/docs/design/external-provider.md b/docs/design/external-provider.md index c5b4ade..8fefb44 100644 --- a/docs/design/external-provider.md +++ b/docs/design/external-provider.md @@ -6,7 +6,7 @@ The external provider verifies customer-managed DNS. The customer owns authorita This document defines the stage-1 design, not evidence that the implementation or a deployment has passed verification. Stage 1 preserves the normal EndpointRecordSet → generated RecordSet → ZoneUnit → Accepted/Programmed path. The platform's Phase 4 acceptance is a remote-member HTTPPublish becoming Ready through real resolution and HTTPS, not merely creating a Provider or reporting a configured Zone. Platform wiring and that acceptance proof are outside this provider's implementation boundary. -Verified-withdrawal evidence before retired VIP reuse is not part of this provider. Until a `Withdrawn` receipt contract exists, member replacement and deletion under the platform's `external` mode are unsupported. Kubernetes claim deletion described below is supported, but does not make member retirement or address reuse safe. +The provider verifies retired-address exclusion through independent Core `RecordWithdrawal` claims and `ZoneUnit.spec/status.withdrawals[]`. This evidence is separate from ordinary Kubernetes deletion bookkeeping. Platform retirement and address reuse require their own current-result consumption and allocation/drain gates; this document does not establish deployment acceptance or global cache eradication. ## Provider Declaration @@ -265,7 +265,45 @@ Process at most 32 due names per ZoneUnit reconcile. Resume fairly through priva External owns no DNS object. For a matching-UID ledger item with `DeletionRequested=true`, stop probes and mark `DeletionCompleted=true` after Kubernetes bookkeeping. Complete ZoneUnit finalization without a DNS delete call and without waiting for the customer to remove a wildcard. Existing core claim/endpoint finalizers still wait for the appropriate Kubernetes receipts and generated-child disappearance. Old-incarnation deletion completion must not finalize a replacement claim. -**Deletion completion is not verified DNS withdrawal.** Neither claim disappearance, observedGeneration, a completed verifier finalizer, nor a timer establishes that retired targets have disappeared from DNS or caches. A separate `Withdrawn` condition/receipt contract is required before retired VIP reuse; its schema, persistence, consumers, and cache-safety protocol are outside this provider's scope. The provider does not emit or accept `Withdrawn`. Platform member replacement/deletion under external remains unsupported until that contract exists; allocations must not be released based on this bookkeeping-only completion. +**Deletion completion is not verified DNS withdrawal.** Claim disappearance, observedGeneration, a completed verifier finalizer, and timer expiry do not prove address exclusion. Independent retained `RecordWithdrawal` claims continue verification after generated RecordSets and EndpointRecordSets disappear. Pending withdrawals do not affect unrelated active Zone/RecordSet `Programmed` aggregation. Every retained withdrawal, including a verified one, blocks Zone/ZoneUnit deletion until explicitly archived or removed. + +### Withdrawal Verification + +Withdrawal uses a separate exclusion evaluator, not the active exact-set evaluator: + +```text +excluded(address) := address ∉ normalize(terminal A ∪ terminal AAAA) +``` + +Every checked name is probed in both families through every effective Cluster/Recursive resolver and, with `expectedNameservers`, every discovered authority endpoint via every configured resolver. Required NS/SOA/delegation and authoritative-answer checks still apply. Replacement addresses may remain and differ between views. Complete NOERROR replacement answers prove current exclusion; NODATA/NXDOMAIN require valid negative SOA evidence. Missing views/families, failures, malformed or unresolved chains, loops, and CNAME/probe limits are Unknown, never partial success. A foreign CNAME boundary is not sufficient exclusion evidence: complete terminal A/AAAA evidence is required. + +A wildcard uses a stable concrete `dns-retire-` witness derived from the claim UID, Zone reference, and Unit UID, plus any retained publication witness for that address. Recreated claims use a different witness. `sampledWildcard=true` describes sampling, not exhaustive proof for descendants or explicit owners. + +The first complete all-view exclusion starts `absentSince=t0`. `notBefore` is the maximum of t0, retained positive-address/CNAME expiry bounds, and `t0 + negativeTTL` for every required negative observation; negative TTL is `min(SOA TTL, SOA.MINIMUM)`. Complete per-view/per-family evidence drives this calculation, not bounded diagnostic previews or minimum-TTL summaries. Repeated identical negatives do not slide t0; larger bounds extend but never shorten the window. Public horizons round up to a whole second. A separate complete probe at or after `notBefore`, later than the preceding attempt, is required even for zero TTL. Polling caps never shorten a cache horizon. + +Reappearance, failed/incomplete observations, changed policy/views, clock reversal, or a schedule stale beyond `nextProbeTime + 300s` invalidates success and the absence window while retaining known positive horizons. Restart preserves only matching persisted identity/policy/window evidence. Required resolver, expected-nameserver, and discovered-view history remains a floor: weakening or missing historical evidence reports `PolicyChanged` rather than proving equivalence. Retained claims are reverified; consumers also reject stale/future/missing timestamps and mismatched bindings, and require `verifiedAt >= notBefore`. + +`Accepted=True/Accepted` means valid authorized intent; rejection uses `InvalidWithdrawal` or `NotAllowed`. Core also reports `UnsupportedProvider`. Provider `Withdrawn` and `Ready` use: + +| Withdrawn | Ready | Reason | +| --- | --- | --- | +| False | False | `DNSStillResolves` | +| False | False | `NegativeTTLNotElapsed` | +| Unknown | False | `DNSProbeFailed` | +| Unknown | False | `PolicyChanged` | +| True | True | `WithdrawalVerified` | + +Messages retain these templates, with bounded 2048-byte diagnostics: + +- `DNS still resolves to retired address
in ().` +- `Retired address
is absent in every required view; waiting until for cache safety and a confirming probe.` +- `Retired address
was verified absent from in every required view at .` +- `External DNS withdrawal cannot be verified for in : .` +- `External DNS withdrawal policy changed for ; the prior required view policy remains a floor.` + +Core supplies a validated waiver's exact name, UID, immutable-spec digest, and receiving reservation namespace/name/UID. The provider echoes that scope and emits `OverrideAccepted=True/OperatorAuthorizedRelease` with message `Core-validated withdrawal waiver is bound to its exact UID and immutable scope.` This never changes `Withdrawn` or `Ready` into DNS success and performs no platform-object lookup. + +Withdrawal work shares the existing limiter, concurrency, transport, deadlines, jitter, and retry intervals. A separate persisted fair cursor schedules claims; each concrete checked name consumes one name-budget slot. When active and withdrawal work are both due, withdrawal selection receives half the pass budget and active work uses the remaining budget. Requeue uses the earlier active/withdrawal deadline (at least one second); a deleting Unit still verifies retained withdrawals. There is no timeout-based release. `dns_external_withdrawals_pending{reason}` counts nonverified claims with the closed labels `DNSStillResolves`, `NegativeTTLNotElapsed`, `DNSProbeFailed`, `PolicyChanged`, `InvalidWithdrawal`, `NotAllowed`, and `UnsupportedProvider`; other reasons fold into `DNSProbeFailed`. Existing probe, duration, and queue-delay metrics remain shared. ## Admission and Security diff --git a/docs/design/gateway-api-integration.md b/docs/design/gateway-api-integration.md index 5c49f54..80db7d9 100644 --- a/docs/design/gateway-api-integration.md +++ b/docs/design/gateway-api-integration.md @@ -57,6 +57,10 @@ Provider controllers still make final provider-side acceptance and programming d The Gateway controller creates one `EndpointRecordSet` per Gateway. Multiple routes and listeners on the same Gateway are deduplicated into a single endpoint intent so HTTP and HTTPS routes for the same hostname do not create competing Core `RecordSet` owners. +The source installs the endpoint-owned `endpoint.dns.appthrust.io/generated-recordsets` finalizer in the Create request. Updates are monotone for retirement intent: read the current ERS, preserve all existing retirement entries, append removed-address snapshots of old hostnames and removed-hostname snapshots of old IPs, then write desired live intent with resourceVersion concurrency. IDs use ERS UID and the pre-update generation; retries cannot overwrite concurrent obligations. The source never selects Zones/providers, creates Core withdrawal claims, prunes retirement entries, or treats capture as verified DNS absence. + +Protection defaults closed until a current-generation `WithdrawalProtected=False` establishes no withdrawal-capable binding. Collection/snapshot overflow reports `WithdrawalCapturePending`, preserves the old spec, and holds `Ready=False` rather than truncating. Successful source writes clear only their pending marker; the endpoint controller owns capture acknowledgment. + For a route: ```yaml @@ -190,6 +194,10 @@ authority or relax UID, generation, Gateway, listener, or hostname matching. ## Deletion and Recovery +Every source cleanup path uses the same retire-or-delete protocol: missing or deleting Gateway, current rejected Gateway admission, no remaining admitted hostname/usable target intent, removed generated intents, route disappearance/deletion or binding changes, and legacy route-labelled `cleanupForRoute` cleanup. Protected IP/hostname associations first move to retire-only ERS snapshots. DELETE waits for `WithdrawalCaptured=True` at the current ERS generation, then uses exact UID/resourceVersion preconditions. While capture is pending, cleanup returns `WithdrawalCapturePending`; it does not drop the ERS or strip finalizers. Hostname-only targets carry no address-withdrawal obligation; a current known-unprotected ERS follows ordinary deletion. + +The endpoint controller captures independent claims and performs generated-child cleanup. Gateway and Service source controllers share this protocol. Manual finalizer stripping or overwriting retained evidence is outside the supported contract. + An `EndpointRecordSet` may still be terminating when its route becomes accepted again. The Gateway controller must not update that terminating intent or remove its finalizers: provider cleanup belongs to the endpoint controller. diff --git a/docs/design/provider-extension.md b/docs/design/provider-extension.md index 187fe75..58bfc29 100644 --- a/docs/design/provider-extension.md +++ b/docs/design/provider-extension.md @@ -190,6 +190,14 @@ Only explicitly published core validations can be disabled. Initial disableable These validations cannot be disabled: `require-provider`, `require-zone-ref`, `require-type`, `require-name`, `require-cname-target`, `require-mx-records`, `require-caa-records`, `require-ns-name-servers`, record name grammar, lowercase canonical form, label length, wildcard placement, type-specific underscore allowance, delegated NS apex rejection, delegated NS wildcard rejection, CNAME target validation, type/body exclusivity, `supportedTypes` consistency, namespace / record access policy, and `ZoneUnit` ownership validation. +### Record Withdrawal Capability + +`spec.versions[].recordWithdrawal` is an optional `ProviderRecordWithdrawal` capability with `supported`, `schemas.statusProviderData.openAPIV3Schema`, and `additionalConditionReasons`. Omission or `supported=false` does not support independent withdrawal. Core checks both the served input and storage contracts before composing verification work. External advertises support; Route 53 and Cloudflare do not advertise or reconcile withdrawals. + +The status schema validates public `RecordWithdrawal.status.provider.data` and `ZoneUnit.status.withdrawals[].provider.data`, not provider-private scheduler state. External exposes only a bounded `views` preview there; typed claim/Unit evidence, never truncated previews, drives decisions. + +Core owns `Accepted=True/Accepted` and `Accepted=False` with `InvalidWithdrawal`, `NotAllowed`, or `UnsupportedProvider`. Withdrawal additional reasons use the closed contract: `Withdrawn=False` and `Ready=False` with `DNSStillResolves` or `NegativeTTLNotElapsed`; `Withdrawn=Unknown` and `Ready=False` with `DNSProbeFailed` or `PolicyChanged`; `Withdrawn=True` and `Ready=True` with `WithdrawalVerified`; and `OverrideAccepted=True/OperatorAuthorizedRelease`. External declares these eleven combinations. Admission validates schema fragments and rejects duplicate, core-acceptance, or out-of-contract reason combinations. + ### Controller Responsibility `Provider` is not reconciled by a controller. It is installed as a cluster-scoped resource by provider packages and used by the core validating webhook for provider-specific validation of `ZoneClass`, `Zone`, and `RecordSet`. It also publishes identity resource metadata used by UI and generic reference display. diff --git a/internal/go/apps/endpoint/controllers/endpointrecordset/controller.go b/internal/go/apps/endpoint/controllers/endpointrecordset/controller.go index 6651bc9..f4f88a0 100644 --- a/internal/go/apps/endpoint/controllers/endpointrecordset/controller.go +++ b/internal/go/apps/endpoint/controllers/endpointrecordset/controller.go @@ -15,6 +15,7 @@ import ( endpointconversionv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/conversion/v1alpha1" endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" route53v1alpha1 "github.com/appthrust/dns-api/pkg/go/api/route53/v1alpha1" + apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -40,7 +41,7 @@ const ( generatedLabelEndpointRecordSetName = "endpoint.dns.appthrust.io/endpointrecordset-name" route53RecordSetAdoptionAnnotation = "endpoint.dns.appthrust.io/route53-recordset-adoption" route53RecordTypeAnnotation = "endpoint.dns.appthrust.io/route53-record-type" - generatedRecordSetsFinalizer = "endpoint.dns.appthrust.io/generated-recordsets" + generatedRecordSetsFinalizer = endpointv1alpha1.GeneratedRecordSetsFinalizer ) type Reconciler struct { @@ -59,6 +60,8 @@ type Reconciler struct { // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zones,verbs=get;list;watch // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zoneunits,verbs=get;list;watch // +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordsets,verbs=create;delete;get;list;watch;patch;update +// +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordwithdrawals,verbs=create;get;list;watch +// +kubebuilder:rbac:groups=dns.appthrust.io,resources=providers,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { recordSetNamespace := r.RecordSetNamespace @@ -83,11 +86,21 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu } return ctrl.Result{}, nil } + if hasRetirements(&endpointRecordSet) { + if err := r.reconcileRetirements(ctx, &endpointRecordSet, recordSetNamespace); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: !retireOnly(&endpointRecordSet)}, nil + } status, desired, err := r.buildStatusAndRecordSets(ctx, &endpointRecordSet, recordSetNamespace) if err != nil { return ctrl.Result{}, err } + r.annotateWithdrawalStatus(ctx, &endpointRecordSet, &status) + if protectionNeedsBarrier(&endpointRecordSet, status) { + return ctrl.Result{Requeue: true}, r.patchStatus(ctx, &endpointRecordSet, status) + } if err := r.applyRecordSets(ctx, &endpointRecordSet, recordSetNamespace, desired); err != nil { return ctrl.Result{}, err } @@ -95,6 +108,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu return ctrl.Result{}, err } setAggregateConditions(&status, endpointRecordSet.Generation) + r.annotateWithdrawalStatus(ctx, &endpointRecordSet, &status) if err := r.patchStatus(ctx, &endpointRecordSet, status); err != nil { return ctrl.Result{}, err } @@ -109,6 +123,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { Named("endpoint-recordset"). For(&endpointv1alpha1.EndpointRecordSet{}). Watches(&dnsv1alpha1.Zone{}, handler.EnqueueRequestsFromMapFunc(r.mapAllEndpointRecordSets)). + Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(r.mapAllEndpointRecordSets)). Watches(&endpointv1alpha1.EndpointProviderCapability{}, handler.EnqueueRequestsFromMapFunc(r.mapAllEndpointRecordSets)). Watches(&dnsv1alpha1.RecordSet{}, handler.EnqueueRequestsFromMapFunc(r.mapRecordSetToEndpointRecordSet)). Complete(r) @@ -537,12 +552,15 @@ func (r *Reconciler) reconcileDeletion(ctx context.Context, endpointRecordSet *e if !slices.Contains(endpointRecordSet.Finalizers, generatedRecordSetsFinalizer) { return nil } + if err := r.captureWithdrawals(ctx, endpointRecordSet, recordSetNamespace, true); err != nil { + return r.captureFailed(ctx, endpointRecordSet, err) + } remaining, err := r.deleteGeneratedRecordSets(ctx, recordSetNamespace, endpointRecordSet.Namespace, endpointRecordSet.Name) if err != nil { return err } if remaining > 0 { - status := endpointRecordSet.Status + status := *endpointRecordSet.Status.DeepCopy() meta.SetStatusCondition(&status.Conditions, metav1.Condition{ Type: "Ready", Status: metav1.ConditionFalse, @@ -556,7 +574,7 @@ func (r *Reconciler) reconcileDeletion(ctx context.Context, endpointRecordSet *e endpointRecordSet.Finalizers = slices.DeleteFunc(endpointRecordSet.Finalizers, func(finalizer string) bool { return finalizer == generatedRecordSetsFinalizer }) - return r.Patch(ctx, endpointRecordSet, client.MergeFrom(base)) + return r.Patch(ctx, endpointRecordSet, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})) } func (r *Reconciler) deleteGeneratedRecordSets(ctx context.Context, recordSetNamespace, endpointRecordSetNamespace, endpointRecordSetName string) (int, error) { @@ -639,13 +657,22 @@ func setAggregateCondition(status *endpointv1alpha1.EndpointRecordSetStatus, con } func (r *Reconciler) patchStatus(ctx context.Context, endpointRecordSet *endpointv1alpha1.EndpointRecordSet, status endpointv1alpha1.EndpointRecordSetStatus) error { - var current endpointv1alpha1.EndpointRecordSet - if err := r.Get(ctx, client.ObjectKeyFromObject(endpointRecordSet), ¤t); err != nil { - return err + base := endpointRecordSet.DeepCopy() + // Normalize against the observed object, rather than reading a potentially + // older cache snapshot after a successful write. Preserve transition times. + conditions := append([]metav1.Condition(nil), endpointRecordSet.Status.Conditions...) + for _, condition := range status.Conditions { + meta.SetStatusCondition(&conditions, condition) + } + conditions = slices.DeleteFunc(conditions, func(condition metav1.Condition) bool { + return !slices.ContainsFunc(status.Conditions, func(desired metav1.Condition) bool { return desired.Type == condition.Type }) + }) + status.Conditions = conditions + if apiequality.Semantic.DeepEqual(endpointRecordSet.Status, status) { + return nil } - base := current.DeepCopy() - current.Status = status - return client.IgnoreNotFound(r.Status().Patch(ctx, ¤t, client.MergeFrom(base))) + endpointRecordSet.Status = status + return client.IgnoreNotFound(r.Status().Patch(ctx, endpointRecordSet, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{}))) } func relativeRecordName(hostname, zoneDomainName string) string { diff --git a/internal/go/apps/endpoint/controllers/endpointrecordset/withdrawal.go b/internal/go/apps/endpoint/controllers/endpointrecordset/withdrawal.go new file mode 100644 index 0000000..e0eb2e8 --- /dev/null +++ b/internal/go/apps/endpoint/controllers/endpointrecordset/withdrawal.go @@ -0,0 +1,295 @@ +package endpointrecordset + +import ( + "context" + "crypto/sha256" + "fmt" + "net/netip" + "slices" + "strconv" + + "github.com/appthrust/dns-api/internal/go/apps/endpoint/retirement" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + apiequality "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + withdrawalCreatorAnnotation = "withdrawal.dns.appthrust.io/source-kind" + withdrawalSourceNamespaceAnnotation = "withdrawal.dns.appthrust.io/source-namespace" + withdrawalSourceNameAnnotation = "withdrawal.dns.appthrust.io/source-name" + withdrawalSourceUIDAnnotation = "withdrawal.dns.appthrust.io/source-uid" + withdrawalSourceGenerationAnnotation = "withdrawal.dns.appthrust.io/source-generation" + withdrawalSourceHostnameAnnotation = "withdrawal.dns.appthrust.io/source-hostname" +) + +func hasRetirements(ers *endpointv1alpha1.EndpointRecordSet) bool { + return len(ers.Spec.RetiredTargets) != 0 || len(ers.Spec.RetiredHostnames) != 0 +} + +func retireOnly(ers *endpointv1alpha1.EndpointRecordSet) bool { + return len(ers.Spec.Hostnames) == 0 || len(ers.Spec.Targets) == 0 +} + +// captureWithdrawals performs create-before-prune. Successful Create returns +// the persisted object; AlreadyExists is followed by a shape-checked Get. A +// stale cache miss fails closed and retries without discarding source intent. +func (r *Reconciler) captureWithdrawals(ctx context.Context, ers *endpointv1alpha1.EndpointRecordSet, namespace string, includeLive bool) error { + if len(ers.Spec.RetiredTargets) > retirement.MaxRetiredTargets || len(ers.Spec.RetiredHostnames) > retirement.MaxRetiredHostnames { + return fmt.Errorf("%w: retirement collection limits exceeded", retirement.ErrCapturePending) + } + for _, entry := range ers.Spec.RetiredTargets { + if len(entry.Hostnames) > retirement.MaxRetiredHostnames { + return fmt.Errorf("%w: target hostname snapshot limit exceeded", retirement.ErrCapturePending) + } + } + for _, entry := range ers.Spec.RetiredHostnames { + if len(entry.Addresses) > retirement.MaxRetiredTargets { + return fmt.Errorf("%w: hostname address snapshot limit exceeded", retirement.ErrCapturePending) + } + } + type binding struct { + zone *dnsv1alpha1.Zone + protected bool + } + bindings := map[string]binding{} + captured := map[string]struct{}{} + capture := func(hostname, value string, generation int64) error { + address, err := netip.ParseAddr(value) + if err != nil || address.Zone() != "" { + return fmt.Errorf("%w: invalid retired address %q", retirement.ErrCapturePending, value) + } + address = address.Unmap() + hostname = retirement.CanonicalHostname(hostname) + key := hostname + "/" + address.String() + selected, found := bindings[key] + if !found { + zone, err := r.withdrawalZone(ctx, ers, namespace, hostname, address) + if err != nil { + return err + } + supported, err := r.withdrawalSupported(ctx, zone.Spec.Provider) + if err != nil { + return err + } + selected = binding{zone: zone, protected: supported} + bindings[key] = selected + } + if !selected.protected { + return nil + } + if ers.UID == "" || generation < 1 { + return fmt.Errorf("%w: EndpointRecordSet UID and retirement generation are required", retirement.ErrCapturePending) + } + claim := withdrawalForPair(ers, namespace, selected.zone, hostname, address, generation) + if _, found := captured[claim.Name]; found { + return nil + } + if err := r.ensureWithdrawal(ctx, claim); err != nil { + return err + } + captured[claim.Name] = struct{}{} + return nil + } + for _, entry := range ers.Spec.RetiredTargets { + for _, hostname := range entry.Hostnames { + if err := capture(hostname, entry.Address, entry.SourceGeneration); err != nil { + return err + } + } + } + for _, entry := range ers.Spec.RetiredHostnames { + for _, address := range entry.Addresses { + if err := capture(entry.Hostname, address, entry.SourceGeneration); err != nil { + return err + } + } + } + if includeLive && retirement.Protected(ers) { + for _, target := range ers.Spec.Targets { + if target.Type != endpointv1alpha1.EndpointTargetTypeIPAddress { + continue + } + for _, hostname := range ers.Spec.Hostnames { + if err := capture(hostname, target.Value, ers.Generation); err != nil { + return err + } + } + } + } + return nil +} + +// withdrawalZone prefers the retained publication binding. Without one, run the +// same publication resolver with a nonempty single-pair snapshot, including +// conversion rejection and Zone fallback. This never applies its RecordSets. +func (r *Reconciler) withdrawalZone(ctx context.Context, ers *endpointv1alpha1.EndpointRecordSet, namespace, hostname string, address netip.Addr) (*dnsv1alpha1.Zone, error) { + for _, published := range ers.Status.Hostnames { + if retirement.CanonicalHostname(published.Hostname) != hostname || published.Zone == nil { + continue + } + var zone dnsv1alpha1.Zone + if err := r.Get(ctx, client.ObjectKey{Namespace: published.Zone.Ref.Namespace, Name: published.Zone.Ref.Name}, &zone); err != nil { + return nil, err + } + if zone.Spec.DomainName != published.Zone.DomainName || zone.Spec.Provider != published.Zone.Provider { + return nil, fmt.Errorf("%w: published Zone binding changed for %s", retirement.ErrCapturePending, hostname) + } + return &zone, nil + } + snapshot := *ers + snapshot.Spec.Targets = []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: address.String()}} + resolved, _, err := r.recordSetsForHostname(ctx, &snapshot, hostname, namespace) + if err != nil { + return nil, err + } + if resolved.Zone == nil { + return nil, fmt.Errorf("%w: no publication Zone binding resolved for %s", retirement.ErrCapturePending, hostname) + } + return &dnsv1alpha1.Zone{ + ObjectMeta: metav1.ObjectMeta{Namespace: resolved.Zone.Ref.Namespace, Name: resolved.Zone.Ref.Name}, + Spec: dnsv1alpha1.ZoneSpec{DomainName: resolved.Zone.DomainName, Provider: resolved.Zone.Provider}, + }, nil +} + +func (r *Reconciler) withdrawalSupported(ctx context.Context, ref dnsv1alpha1.ProviderReference) (bool, error) { + var provider dnsv1alpha1.Provider + if err := r.Get(ctx, client.ObjectKey{Name: ref.Name}, &provider); err != nil { + return false, err + } + for _, version := range provider.Spec.Versions { + if version.Name == ref.Version { + return version.RecordWithdrawal != nil && version.RecordWithdrawal.Supported, nil + } + } + return false, fmt.Errorf("Provider %s does not declare version %s", ref.Name, ref.Version) +} + +func withdrawalForPair(ers *endpointv1alpha1.EndpointRecordSet, namespace string, zone *dnsv1alpha1.Zone, hostname string, address netip.Addr, generation int64) *dnsv1alpha1.RecordWithdrawal { + owner := relativeRecordName(hostname, zone.Spec.DomainName) + generationString := strconv.FormatInt(generation, 10) + sum := sha256.Sum256([]byte(string(ers.UID) + "\x00" + generationString + "\x00" + hostname + "\x00" + address.String())) + return &dnsv1alpha1.RecordWithdrawal{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, Name: fmt.Sprintf("ers-%x", sum[:])[:63], + Labels: map[string]string{dnsv1alpha1.AddressLabelKey: dnsv1alpha1.AddressLabelValue(address)}, + Annotations: map[string]string{ + withdrawalCreatorAnnotation: "EndpointRecordSet", + withdrawalSourceNamespaceAnnotation: ers.Namespace, + withdrawalSourceNameAnnotation: ers.Name, + withdrawalSourceUIDAnnotation: string(ers.UID), + withdrawalSourceGenerationAnnotation: generationString, + withdrawalSourceHostnameAnnotation: hostname, + }, + }, + Spec: dnsv1alpha1.RecordWithdrawalSpec{ZoneRef: dnsv1alpha1.ZoneReference{Namespace: &zone.Namespace, Name: zone.Name}, Name: owner, Address: address.String()}, + } +} + +func (r *Reconciler) ensureWithdrawal(ctx context.Context, desired *dnsv1alpha1.RecordWithdrawal) error { + if err := r.Create(ctx, desired, client.FieldOwner(fieldOwner)); err != nil { + if !apierrors.IsAlreadyExists(err) { + return err + } + var current dnsv1alpha1.RecordWithdrawal + if err := r.Get(ctx, client.ObjectKeyFromObject(desired), ¤t); err != nil { + return err + } + if !current.DeletionTimestamp.IsZero() || len(current.OwnerReferences) != 0 || len(current.Finalizers) != 0 || + !apiequality.Semantic.DeepEqual(current.Spec, desired.Spec) || current.Labels[dnsv1alpha1.AddressLabelKey] != desired.Labels[dnsv1alpha1.AddressLabelKey] { + return fmt.Errorf("RecordWithdrawal collision at %s/%s: immutable shape or lifecycle differs", desired.Namespace, desired.Name) + } + for key, value := range desired.Annotations { + if current.Annotations[key] != value { + return fmt.Errorf("RecordWithdrawal collision at %s/%s: source provenance differs", desired.Namespace, desired.Name) + } + } + } + return nil +} + +func (r *Reconciler) reconcileRetirements(ctx context.Context, ers *endpointv1alpha1.EndpointRecordSet, namespace string) error { + if err := r.captureWithdrawals(ctx, ers, namespace, false); err != nil { + return r.captureFailed(ctx, ers, err) + } + if !retireOnly(ers) { + base := ers.DeepCopy() + ers.Spec.RetiredTargets = nil + ers.Spec.RetiredHostnames = nil + return r.Patch(ctx, ers, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})) + } + // Keep captured entries until DELETE: admission requires live OR retired + // intent. Acknowledgment is only capture, never verified DNS withdrawal. + if _, err := r.deleteGeneratedRecordSets(ctx, namespace, ers.Namespace, ers.Name); err != nil { + return err + } + status := *ers.Status.DeepCopy() + status.ObservedGeneration = ers.Generation + setAggregateCondition(&status, retirement.CapturedCondition, metav1.ConditionTrue, ers.Generation, "Captured", "Every retired hostname/address pair has been durably captured.") + for _, conditionType := range []string{"Resolved", "Accepted", "Programmed", "Ready"} { + setAggregateCondition(&status, conditionType, metav1.ConditionFalse, ers.Generation, "Retiring", "EndpointRecordSet is retiring; no active records are published.") + } + return r.patchStatus(ctx, ers, status) +} + +func (r *Reconciler) captureFailed(ctx context.Context, ers *endpointv1alpha1.EndpointRecordSet, cause error) error { + status := *ers.Status.DeepCopy() + message := cause.Error() + if len(message) > 2048 { + message = message[:2048] + } + setAggregateCondition(&status, retirement.CapturedCondition, metav1.ConditionFalse, ers.Generation, retirement.PendingCondition, message) + setAggregateCondition(&status, "Ready", metav1.ConditionFalse, ers.Generation, retirement.PendingCondition, message) + if err := r.patchStatus(ctx, ers, status); err != nil { + return err + } + return cause +} + +// annotateWithdrawalStatus publishes the marker consumed by provider-neutral +// sources. True is sticky for an ERS incarnation. Unknown never permits loss. +func (r *Reconciler) annotateWithdrawalStatus(ctx context.Context, ers *endpointv1alpha1.EndpointRecordSet, status *endpointv1alpha1.EndpointRecordSetStatus) { + protection := metav1.ConditionFalse + if meta.IsStatusConditionTrue(ers.Status.Conditions, retirement.ProtectedCondition) { + protection = metav1.ConditionTrue + } else { + if len(status.Hostnames) != len(ers.Spec.Hostnames) || len(status.Hostnames) == 0 { + protection = metav1.ConditionUnknown + } + for _, hostname := range status.Hostnames { + if hostname.Zone == nil { + protection = metav1.ConditionUnknown + continue + } + supported, err := r.withdrawalSupported(ctx, hostname.Zone.Provider) + if err != nil { + protection = metav1.ConditionUnknown + continue + } + if supported { + protection = metav1.ConditionTrue + break + } + } + } + setAggregateCondition(status, retirement.ProtectedCondition, protection, ers.Generation, "ProviderCapability", "Withdrawal protection follows the resolved Provider recordWithdrawal capability; unknown bindings retain intent.") + setAggregateCondition(status, retirement.CapturedCondition, metav1.ConditionTrue, ers.Generation, "Captured", "No uncaptured retirement entries remain.") + if pending := meta.FindStatusCondition(ers.Status.Conditions, retirement.PendingCondition); pending != nil { + meta.SetStatusCondition(&status.Conditions, *pending) + if pending.Status == metav1.ConditionTrue { + setAggregateCondition(status, "Ready", metav1.ConditionFalse, ers.Generation, retirement.PendingCondition, pending.Message) + } + } +} + +func protectionNeedsBarrier(ers *endpointv1alpha1.EndpointRecordSet, status endpointv1alpha1.EndpointRecordSetStatus) bool { + old := meta.FindStatusCondition(ers.Status.Conditions, retirement.ProtectedCondition) + return old != nil && old.Status == metav1.ConditionFalse && old.ObservedGeneration == ers.Generation && + !slices.ContainsFunc(status.Conditions, func(condition metav1.Condition) bool { + return condition.Type == retirement.ProtectedCondition && condition.Status == metav1.ConditionFalse + }) +} diff --git a/internal/go/apps/endpoint/controllers/endpointrecordset/withdrawal_test.go b/internal/go/apps/endpoint/controllers/endpointrecordset/withdrawal_test.go new file mode 100644 index 0000000..7a3fb41 --- /dev/null +++ b/internal/go/apps/endpoint/controllers/endpointrecordset/withdrawal_test.go @@ -0,0 +1,244 @@ +package endpointrecordset + +import ( + "context" + "errors" + "fmt" + "net/netip" + "strings" + "testing" + + "github.com/appthrust/dns-api/internal/go/apps/endpoint/retirement" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func withdrawalFixture(t *testing.T, supported bool, intercept interceptor.Funcs) (*Reconciler, *endpointv1alpha1.EndpointRecordSet, *dnsv1alpha1.Zone) { + t.Helper() + scheme := runtime.NewScheme() + if err := endpointv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + ref := dnsv1alpha1.ProviderReference{Name: "test.dns.appthrust.io", Version: "v1alpha1"} + provider := &dnsv1alpha1.Provider{ObjectMeta: metav1.ObjectMeta{Name: ref.Name}, Spec: dnsv1alpha1.ProviderSpec{Versions: []dnsv1alpha1.ProviderVersion{{Name: ref.Version, RecordWithdrawal: &dnsv1alpha1.ProviderRecordWithdrawal{Supported: supported}}}}} + zone := &dnsv1alpha1.Zone{ObjectMeta: metav1.ObjectMeta{Namespace: "dns", Name: "example", UID: "zone-uid"}, Spec: dnsv1alpha1.ZoneSpec{DomainName: "example.com", Provider: ref}} + capability := &endpointv1alpha1.EndpointProviderCapability{ObjectMeta: metav1.ObjectMeta{Name: "test"}, Spec: endpointv1alpha1.EndpointProviderCapabilitySpec{Provider: ref}} + ers := &endpointv1alpha1.EndpointRecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: "dns", Name: "ers", UID: "ers-uid", Generation: 8, Finalizers: []string{generatedRecordSetsFinalizer}}, + Spec: endpointv1alpha1.EndpointRecordSetSpec{ + Hostnames: []string{"h2.example.com"}, + Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.2"}}, + RetiredTargets: []endpointv1alpha1.RetiredTarget{{ID: "target-old", SourceGeneration: 7, Address: "192.0.2.1", Hostnames: []string{"h1.example.com", "h2.example.com"}}}, + RetiredHostnames: []endpointv1alpha1.RetiredHostname{{ID: "hostname-old", SourceGeneration: 7, Hostname: "h1.example.com", Addresses: []string{"192.0.2.1"}}}, + }, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&endpointv1alpha1.EndpointRecordSet{}, &dnsv1alpha1.RecordWithdrawal{}). + WithObjects(provider, zone, capability, ers).WithInterceptorFuncs(intercept).Build() + return &Reconciler{Client: c, Scheme: scheme, conversionFunc: func(_ context.Context, _ *endpointv1alpha1.EndpointProviderCapability, input endpointv1alpha1.EndpointRecordSetConversionInput) ([]endpointv1alpha1.RecordSetSpecFragment, string, error) { + if len(input.Targets) != 1 { + t.Fatal("retirement invoked conversion without a nonempty pair snapshot") + } + return []endpointv1alpha1.RecordSetSpecFragment{{Type: endpointv1alpha1.EndpointRecordSetTypeA, Name: input.Name, A: &dnsv1alpha1.ARecordSet{Addresses: []string{input.Targets[0].Value}}}}, "", nil + }}, ers, zone +} + +func getWithdrawalERS(t *testing.T, c client.Client, key client.ObjectKey) *endpointv1alpha1.EndpointRecordSet { + t.Helper() + var ers endpointv1alpha1.EndpointRecordSet + if err := c.Get(context.Background(), key, &ers); err != nil { + t.Fatal(err) + } + return &ers +} + +func listWithdrawalClaims(t *testing.T, c client.Client) []dnsv1alpha1.RecordWithdrawal { + t.Helper() + var claims dnsv1alpha1.RecordWithdrawalList + if err := c.List(context.Background(), &claims); err != nil { + t.Fatal(err) + } + return claims.Items +} + +func TestWithdrawalCreateBeforePruneSurvivesCrash(t *testing.T) { + ctx := context.Background() + crash := true + r, ers, _ := withdrawalFixture(t, true, interceptor.Funcs{Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*endpointv1alpha1.EndpointRecordSet); ok && crash { + crash = false + return errors.New("simulated crash after claim persistence") + } + return c.Patch(ctx, obj, patch, opts...) + }}) + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); err == nil || !strings.Contains(err.Error(), "simulated crash") { + t.Fatalf("first reconcile = %v", err) + } + current := getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers)) + if !hasRetirements(current) { + t.Fatal("crash discarded source obligations") + } + claims := listWithdrawalClaims(t, r.Client) + if len(claims) != 2 { + t.Fatalf("claims = %d, want deduplicated (H1,V1), (H2,V1)", len(claims)) + } + for _, claim := range claims { + if claim.Spec.Address != "192.0.2.1" || claim.Labels[dnsv1alpha1.AddressLabelKey] != "192.0.2.1" || claim.Annotations[withdrawalSourceGenerationAnnotation] != "7" || len(claim.OwnerReferences) != 0 || len(claim.Finalizers) != 0 { + t.Fatalf("incorrect retained claim: %#v", claim) + } + } + restarted := &Reconciler{Client: r.Client, Scheme: r.Scheme, conversionFunc: r.conversionFunc} + if _, err := restarted.Reconcile(ctx, ctrlRequest(ers)); err != nil { + t.Fatal(err) + } + current = getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers)) + if hasRetirements(current) || len(listWithdrawalClaims(t, r.Client)) != 2 || current.Spec.Targets[0].Value != "192.0.2.2" { + t.Fatal("restart failed idempotent create-then-prune") + } +} + +func TestWithdrawalPruneConflictPreservesConcurrentEntry(t *testing.T) { + ctx := context.Background() + interleave := true + r, ers, _ := withdrawalFixture(t, true, interceptor.Funcs{Patch: func(ctx context.Context, c client.WithWatch, obj client.Object, patch client.Patch, opts ...client.PatchOption) error { + if _, ok := obj.(*endpointv1alpha1.EndpointRecordSet); ok && interleave { + interleave = false + var current endpointv1alpha1.EndpointRecordSet + if err := c.Get(ctx, client.ObjectKeyFromObject(obj), ¤t); err != nil { return err } + current.Spec.RetiredTargets = append(current.Spec.RetiredTargets, endpointv1alpha1.RetiredTarget{ID: "concurrent", SourceGeneration: 8, Address: "192.0.2.3", Hostnames: []string{"h2.example.com"}}) + if err := c.Update(ctx, ¤t); err != nil { return err } + } + return c.Patch(ctx, obj, patch, opts...) + }}) + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); !apierrors.IsConflict(err) { + t.Fatalf("prune = %v, want resourceVersion conflict", err) + } + current := getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers)) + if len(current.Spec.RetiredTargets) != 2 { + t.Fatal("concurrent entry was lost") + } + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); err != nil { t.Fatal(err) } + if len(listWithdrawalClaims(t, r.Client)) != 3 || hasRetirements(getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers))) { + t.Fatal("retry did not capture concurrent entry") + } +} + +func TestWithdrawalDirectDeleteCapturesLiveAndRetired(t *testing.T) { + ctx := context.Background() + r, ers, _ := withdrawalFixture(t, true, interceptor.Funcs{}) + if err := r.Delete(ctx, ers); err != nil { t.Fatal(err) } + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); err != nil { t.Fatal(err) } + var current endpointv1alpha1.EndpointRecordSet + if err := r.Get(ctx, client.ObjectKeyFromObject(ers), ¤t); !apierrors.IsNotFound(err) { + t.Fatalf("ERS after capture = %v", err) + } + claims := listWithdrawalClaims(t, r.Client) + if len(claims) != 3 { t.Fatalf("claims = %d, want old pairs plus (H2,V2)", len(claims)) } +} + +func TestWithdrawalRetireOnlyAcknowledgesWithoutEmptyPublication(t *testing.T) { + ctx := context.Background() + r, ers, _ := withdrawalFixture(t, true, interceptor.Funcs{}) + ers.Spec.Hostnames = nil + ers.Spec.Targets = nil + if err := r.Update(ctx, ers); err != nil { t.Fatal(err) } + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); err != nil { t.Fatal(err) } + current := getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers)) + ready := meta.FindStatusCondition(current.Status.Conditions, "Ready") + if !retirement.Captured(current) || !hasRetirements(current) || ready == nil || ready.Status != metav1.ConditionFalse || ready.Reason != "Retiring" { + t.Fatalf("retire-only status = %#v", current.Status) + } + before := current.ResourceVersion + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); err != nil { t.Fatal(err) } + if getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers)).ResourceVersion != before { + t.Fatal("stable capture acknowledgment caused status churn") + } + if err := retirement.RetireOrDelete(ctx, r.Client, current); err != nil { t.Fatal(err) } + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); err != nil { t.Fatal(err) } + if len(listWithdrawalClaims(t, r.Client)) != 2 { t.Fatal("deletion changed retirement identity") } +} + +func TestWithdrawalCollisionAndCreateFailureRetainIntent(t *testing.T) { + for _, failure := range []string{"shape", "source", "create"} { + t.Run(failure, func(t *testing.T) { + ctx := context.Background() + r, ers, zone := withdrawalFixture(t, true, interceptor.Funcs{Create: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.CreateOption) error { + if _, ok := obj.(*dnsv1alpha1.RecordWithdrawal); ok && failure == "create" { return fmt.Errorf("create denied") } + return c.Create(ctx, obj, opts...) + }}) + if failure != "create" { + claim := withdrawalForPair(ers, "dns", zone, "h1.example.com", netip.MustParseAddr("192.0.2.1"), 7) + if failure == "shape" { claim.Spec.Address = "192.0.2.99" } else { claim.Annotations[withdrawalSourceUIDAnnotation] = "foreign" } + if err := r.Create(ctx, claim); err != nil { t.Fatal(err) } + } + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); err == nil { t.Fatal("unsafe capture succeeded") } + current := getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers)) + ready := meta.FindStatusCondition(current.Status.Conditions, "Ready") + if !hasRetirements(current) || ready == nil || ready.Reason != retirement.PendingCondition { t.Fatal("failure discarded or hid capture obligation") } + }) + } +} + +func TestWithdrawalNonExternalPrunesWithoutClaims(t *testing.T) { + r, ers, _ := withdrawalFixture(t, false, interceptor.Funcs{}) + if _, err := r.Reconcile(context.Background(), ctrlRequest(ers)); err != nil { t.Fatal(err) } + if len(listWithdrawalClaims(t, r.Client)) != 0 || hasRetirements(getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers))) { + t.Fatal("non-external retirement gained an external wait") + } +} + +func TestWithdrawalOverflowReportsPending(t *testing.T) { + r, ers, _ := withdrawalFixture(t, true, interceptor.Funcs{}) + for len(ers.Spec.RetiredTargets) <= retirement.MaxRetiredTargets { + ers.Spec.RetiredTargets = append(ers.Spec.RetiredTargets, ers.Spec.RetiredTargets[0]) + } + if err := r.Update(context.Background(), ers); err != nil { t.Fatal(err) } + if _, err := r.Reconcile(context.Background(), ctrlRequest(ers)); !errors.Is(err, retirement.ErrCapturePending) { t.Fatalf("overflow = %v", err) } + current := getWithdrawalERS(t, r.Client, client.ObjectKeyFromObject(ers)) + if len(current.Spec.RetiredTargets) != 65 || len(listWithdrawalClaims(t, r.Client)) != 0 || meta.FindStatusCondition(current.Status.Conditions, "Ready").Reason != retirement.PendingCondition { + t.Fatal("overflow was truncated or acknowledged") + } +} + +func TestWithdrawalResolutionUsesPublicationFallback(t *testing.T) { + ctx := context.Background() + r, ers, zone := withdrawalFixture(t, true, interceptor.Funcs{}) + narrow := zone.DeepCopy() + narrow.Name = "narrow" + narrow.UID = "narrow-zone" + narrow.ResourceVersion = "" + narrow.Spec.DomainName = "h1.example.com" + if err := r.Create(ctx, narrow); err != nil { t.Fatal(err) } + baseConversion := r.conversionFunc + r.conversionFunc = func(ctx context.Context, capability *endpointv1alpha1.EndpointProviderCapability, input endpointv1alpha1.EndpointRecordSetConversionInput) ([]endpointv1alpha1.RecordSetSpecFragment, string, error) { + if input.Zone.DomainName == narrow.Spec.DomainName { return nil, "conversion rejected narrow zone", nil } + return baseConversion(ctx, capability, input) + } + if _, err := r.Reconcile(ctx, ctrlRequest(ers)); err != nil { t.Fatal(err) } + for _, claim := range listWithdrawalClaims(t, r.Client) { + if claim.Spec.ZoneRef.Name != zone.Name { t.Fatalf("retirement bypassed publication fallback: %#v", claim.Spec) } + } +} + +func TestWithdrawalProtectionMarkerFollowsCapability(t *testing.T) { + ctx := context.Background() + r, ers, zone := withdrawalFixture(t, true, interceptor.Funcs{}) + ers.Spec.RetiredTargets = nil + ers.Spec.RetiredHostnames = nil + ers.Status.Conditions = []metav1.Condition{{Type:retirement.ProtectedCondition, Status:metav1.ConditionFalse, ObservedGeneration:ers.Generation}} + status := endpointv1alpha1.EndpointRecordSetStatus{Hostnames:[]endpointv1alpha1.EndpointRecordSetHostnameStatus{{Hostname:"h2.example.com", Zone:&endpointv1alpha1.EndpointRecordSetZoneStatus{Ref:dnsv1alpha1.ObjectReference{Namespace:zone.Namespace, Name:zone.Name}, DomainName:zone.Spec.DomainName, Provider:zone.Spec.Provider}}}} + r.annotateWithdrawalStatus(ctx, ers, &status) + if !meta.IsStatusConditionTrue(status.Conditions, retirement.ProtectedCondition) || !protectionNeedsBarrier(ers, status) { t.Fatal("new protected publication lacks a source-retention barrier") } + ers.Status = status + r.annotateWithdrawalStatus(ctx, ers, &status) + if protectionNeedsBarrier(ers, status) { t.Fatal("stable protected marker does not converge") } +} diff --git a/internal/go/apps/endpoint/retirement/retirement.go b/internal/go/apps/endpoint/retirement/retirement.go new file mode 100644 index 0000000..70b33b7 --- /dev/null +++ b/internal/go/apps/endpoint/retirement/retirement.go @@ -0,0 +1,191 @@ +// Package retirement implements the provider-neutral source side of DEP-0003. +package retirement + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "net/netip" + "slices" + "strconv" + "strings" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + ProtectedCondition = "WithdrawalProtected" + CapturedCondition = "WithdrawalCaptured" + PendingCondition = "WithdrawalCapturePending" + MaxRetiredTargets = 64 + MaxRetiredHostnames = 256 +) + +var ErrCapturePending = errors.New("WithdrawalCapturePending") + +// Protected defaults closed until the endpoint controller has resolved every +// hostname to a Provider without withdrawal support at this exact generation. +func Protected(ers *endpointv1alpha1.EndpointRecordSet) bool { + condition := meta.FindStatusCondition(ers.Status.Conditions, ProtectedCondition) + return condition == nil || condition.Status != metav1.ConditionFalse || condition.ObservedGeneration != ers.Generation +} + +func Captured(ers *endpointv1alpha1.EndpointRecordSet) bool { + condition := meta.FindStatusCondition(ers.Status.Conditions, CapturedCondition) + return condition != nil && condition.Status == metav1.ConditionTrue && condition.ObservedGeneration == ers.Generation +} + +// Append keeps every existing retirement entry and snapshots old associations. +// It does not mutate current or desired, including on overflow. Generation is +// the pre-update ERS generation, so repeated writes and duplicate collections +// identify the same retirement while a later re-add/remove identifies a new one. +func Append(current *endpointv1alpha1.EndpointRecordSet, desired endpointv1alpha1.EndpointRecordSetSpec) (endpointv1alpha1.EndpointRecordSetSpec, error) { + out := desired + out.RetiredTargets = slices.Clone(current.Spec.RetiredTargets) + out.RetiredHostnames = slices.Clone(current.Spec.RetiredHostnames) + if !Protected(current) { + return out, nil + } + oldAddresses, err := addresses(current.Spec.Targets) + if err != nil { + return out, err + } + newAddresses, err := addresses(desired.Targets) + if err != nil { + return out, err + } + if len(oldAddresses) == 0 || len(current.Spec.Hostnames) == 0 { + return out, nil + } + removedAddresses := make([]string, 0) + for _, address := range oldAddresses { + if !slices.Contains(newAddresses, address) || len(desired.Hostnames) == 0 { + removedAddresses = append(removedAddresses, address) + } + } + removedHostnames := make([]string, 0) + for _, hostname := range current.Spec.Hostnames { + if !slices.Contains(desired.Hostnames, hostname) || len(newAddresses) == 0 { + removedHostnames = append(removedHostnames, hostname) + } + } + if len(removedAddresses) == 0 && len(removedHostnames) == 0 { + return out, nil + } + if current.UID == "" || current.Generation < 1 { + return out, fmt.Errorf("%w: EndpointRecordSet UID and generation are required", ErrCapturePending) + } + if len(current.Spec.Hostnames) > MaxRetiredHostnames || len(oldAddresses) > MaxRetiredTargets || + len(out.RetiredTargets)+len(removedAddresses) > MaxRetiredTargets || + len(out.RetiredHostnames)+len(removedHostnames) > MaxRetiredHostnames { + return out, fmt.Errorf("%w: retirement snapshot limits exceeded; old intent retained", ErrCapturePending) + } + for _, address := range removedAddresses { + out.RetiredTargets = append(out.RetiredTargets, endpointv1alpha1.RetiredTarget{ + ID: entryID(current, "target", address), SourceGeneration: current.Generation, + Address: address, Hostnames: slices.Clone(current.Spec.Hostnames), + }) + } + for _, hostname := range removedHostnames { + out.RetiredHostnames = append(out.RetiredHostnames, endpointv1alpha1.RetiredHostname{ + ID: entryID(current, "hostname", hostname), SourceGeneration: current.Generation, + Hostname: hostname, Addresses: slices.Clone(oldAddresses), + }) + } + return out, nil +} + +func entryID(ers *endpointv1alpha1.EndpointRecordSet, kind, value string) string { + sum := sha256.Sum256([]byte(string(ers.UID) + "\x00" + strconv.FormatInt(ers.Generation, 10) + "\x00" + kind + "\x00" + value)) + return fmt.Sprintf("%x", sum[:]) +} + +func addresses(targets []endpointv1alpha1.EndpointTarget) ([]string, error) { + var out []string + for _, target := range targets { + if target.Type != endpointv1alpha1.EndpointTargetTypeIPAddress { + continue + } + address, err := netip.ParseAddr(target.Value) + if err != nil || address.Zone() != "" { + return nil, fmt.Errorf("%w: invalid IP target %q", ErrCapturePending, target.Value) + } + value := address.Unmap().String() + if !slices.Contains(out, value) { + out = append(out, value) + } + } + return out, nil +} + +// ReportPending leaves the old spec untouched and makes overflow visible. The +// source owns only PendingCondition; Ready is also projected by endpoint. +func ReportPending(ctx context.Context, c client.Client, ers *endpointv1alpha1.EndpointRecordSet, cause error) error { + base := ers.DeepCopy() + message := cause.Error() + if len(message) > 2048 { + message = message[:2048] + } + for _, conditionType := range []string{PendingCondition, "Ready"} { + status := metav1.ConditionTrue + if conditionType == "Ready" { + status = metav1.ConditionFalse + } + meta.SetStatusCondition(&ers.Status.Conditions, metav1.Condition{Type: conditionType, Status: status, ObservedGeneration: ers.Generation, Reason: PendingCondition, Message: message}) + } + if err := c.Status().Patch(ctx, ers, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})); err != nil { + return err + } + return cause +} + +// ClearPending is called after a source can represent its complete desired +// transition. It never acknowledges capture or prunes retirement intent. +func ClearPending(ctx context.Context, c client.Client, ers *endpointv1alpha1.EndpointRecordSet) error { + if !meta.IsStatusConditionTrue(ers.Status.Conditions, PendingCondition) { + return nil + } + base := ers.DeepCopy() + meta.RemoveStatusCondition(&ers.Status.Conditions, PendingCondition) + return c.Status().Patch(ctx, ers, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})) +} + +// RetireOrDelete is shared by every source cleanup path. A protected ERS is +// deleted only after a current-generation endpoint capture acknowledgment. +func RetireOrDelete(ctx context.Context, c client.Client, ers *endpointv1alpha1.EndpointRecordSet) error { + if !ers.DeletionTimestamp.IsZero() { + return nil + } + if !Protected(ers) || (len(ers.Spec.Hostnames) == 0 || len(ers.Spec.Targets) == 0) && Captured(ers) { + return deleteExact(ctx, c, ers) + } + spec, err := Append(ers, endpointv1alpha1.EndpointRecordSetSpec{}) + if err != nil { + return ReportPending(ctx, c, ers, err) + } + if len(spec.RetiredTargets) == 0 && len(spec.RetiredHostnames) == 0 { + // Hostname targets do not carry address withdrawal obligations. + return deleteExact(ctx, c, ers) + } + if len(ers.Spec.Hostnames) != 0 || len(ers.Spec.Targets) != 0 { + base := ers.DeepCopy() + ers.Spec = spec + if err := c.Patch(ctx, ers, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})); err != nil { + return err + } + } + return fmt.Errorf("%w: waiting for EndpointRecordSet %s/%s capture", ErrCapturePending, ers.Namespace, ers.Name) +} + +func deleteExact(ctx context.Context, c client.Client, ers *endpointv1alpha1.EndpointRecordSet) error { + return client.IgnoreNotFound(c.Delete(ctx, ers, client.Preconditions{UID: &ers.UID, ResourceVersion: &ers.ResourceVersion})) +} + +// CanonicalHostname is shared by claim identity and source snapshot consumers. +func CanonicalHostname(hostname string) string { + return strings.ToLower(strings.TrimSuffix(hostname, ".")) +} diff --git a/internal/go/apps/endpoint/retirement/retirement_test.go b/internal/go/apps/endpoint/retirement/retirement_test.go new file mode 100644 index 0000000..68b2a55 --- /dev/null +++ b/internal/go/apps/endpoint/retirement/retirement_test.go @@ -0,0 +1,90 @@ +package retirement + +import ( + "errors" + "fmt" + "reflect" + "testing" + + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func retirementFixture() *endpointv1alpha1.EndpointRecordSet { + return &endpointv1alpha1.EndpointRecordSet{ + ObjectMeta: metav1.ObjectMeta{Name: "ers", Namespace: "dns", UID: "ers-uid", Generation: 5}, + Spec: endpointv1alpha1.EndpointRecordSetSpec{ + Hostnames: []string{"h1.example.com", "h2.example.com"}, + Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.1"}}, + }, + } +} + +func TestWithdrawalSnapshotPreservesCorrelatedPairs(t *testing.T) { + current := retirementFixture() + desired := endpointv1alpha1.EndpointRecordSetSpec{Hostnames: []string{"h2.example.com"}, Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.2"}}} + got, err := Append(current, desired) + if err != nil { + t.Fatal(err) + } + if len(got.RetiredTargets) != 1 || len(got.RetiredHostnames) != 1 { + t.Fatalf("snapshot = %#v", got) + } + if got.RetiredTargets[0].Address != "192.0.2.1" || !reflect.DeepEqual(got.RetiredTargets[0].Hostnames, current.Spec.Hostnames) || + got.RetiredHostnames[0].Hostname != "h1.example.com" || !reflect.DeepEqual(got.RetiredHostnames[0].Addresses, []string{"192.0.2.1"}) { + t.Fatalf("lost old pair or invented (H1,V2): %#v", got) + } + if got.RetiredTargets[0].SourceGeneration != 5 || got.RetiredHostnames[0].SourceGeneration != 5 { + t.Fatal("snapshot generation was not the pre-update ERS generation") + } + got.RetiredTargets[0].Hostnames[0] = "changed" + if current.Spec.Hostnames[0] != "h1.example.com" { + t.Fatal("snapshot aliases live source") + } + current.Generation++ + later, err := Append(current, desired) + if err != nil || later.RetiredTargets[0].ID == got.RetiredTargets[0].ID { + t.Fatalf("re-add/remove reused retirement identity: %v", err) + } +} + +func TestWithdrawalSnapshotOverflowRetainsOldIntent(t *testing.T) { + for _, kind := range []string{"targets", "hostnames", "hostname snapshot", "address snapshot"} { + t.Run(kind, func(t *testing.T) { + current := retirementFixture() + switch kind { + case "targets": + current.Spec.RetiredTargets = make([]endpointv1alpha1.RetiredTarget, MaxRetiredTargets) + case "hostnames": + current.Spec.RetiredHostnames = make([]endpointv1alpha1.RetiredHostname, MaxRetiredHostnames) + case "hostname snapshot": + for i := len(current.Spec.Hostnames); i <= MaxRetiredHostnames; i++ { + current.Spec.Hostnames = append(current.Spec.Hostnames, fmt.Sprintf("h%d.example.com", i)) + } + case "address snapshot": + for i := 2; i <= MaxRetiredTargets+1; i++ { + current.Spec.Targets = append(current.Spec.Targets, endpointv1alpha1.EndpointTarget{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: fmt.Sprintf("192.0.2.%d", i)}) + } + } + before := current.DeepCopy() + _, err := Append(current, endpointv1alpha1.EndpointRecordSetSpec{}) + if !errors.Is(err, ErrCapturePending) || !reflect.DeepEqual(current, before) { + t.Fatalf("overflow mutated intent or did not hold: %v", err) + } + }) + } +} + +func TestWithdrawalSnapshotNonExternalAndStaleMarker(t *testing.T) { + current := retirementFixture() + current.Status.Conditions = []metav1.Condition{{Type: ProtectedCondition, Status: metav1.ConditionFalse, ObservedGeneration: current.Generation}} + got, err := Append(current, endpointv1alpha1.EndpointRecordSetSpec{}) + if err != nil || len(got.RetiredTargets)+len(got.RetiredHostnames) != 0 { + t.Fatalf("non-external retirement changed: %#v, %v", got, err) + } + current.Generation++ + got, err = Append(current, endpointv1alpha1.EndpointRecordSetSpec{}) + if err != nil || len(got.RetiredTargets) != 1 { + t.Fatalf("stale marker dropped an obligation: %#v, %v", got, err) + } +} diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go b/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go index 9d099a6..a31a7fb 100644 --- a/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/controller.go @@ -8,17 +8,16 @@ import ( "sort" "strings" + "github.com/appthrust/dns-api/internal/go/apps/endpoint/retirement" endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" apiequality "k8s.io/apimachinery/pkg/api/equality" 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/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" - "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) @@ -46,6 +45,7 @@ type Reconciler struct { // +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;update // +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/finalizers,verbs=update // +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets,verbs=create;delete;get;list;watch;patch;update +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets/status,verbs=get;patch;update func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { namespace := r.EndpointRecordSetNamespace if namespace == "" { @@ -105,8 +105,7 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { Watches(&gatewayv1.Gateway{}, handler.EnqueueRequestsFromMapFunc(r.mapGatewayToHTTPRoutes)). // Cross-namespace projections cannot use owner references. The final // delete event must wake routes even when their own state has not changed. - Watches(&endpointv1alpha1.EndpointRecordSet{}, handler.EnqueueRequestsFromMapFunc(r.mapEndpointRecordSetToHTTPRoutes), - builder.WithPredicates(predicate.Or(predicate.GenerationChangedPredicate{}, predicate.LabelChangedPredicate{}))). + Watches(&endpointv1alpha1.EndpointRecordSet{}, handler.EnqueueRequestsFromMapFunc(r.mapEndpointRecordSetToHTTPRoutes)). Complete(r) } @@ -133,6 +132,9 @@ func (r *Reconciler) mapEndpointRecordSetToHTTPRoutes(ctx context.Context, obj c if labels[managedByLabel] != managedByValue { return nil } + if labels[generatedLabelRouteNamespace] != "" && labels[generatedLabelRouteName] != "" { + return []reconcile.Request{{NamespacedName: client.ObjectKey{Namespace: labels[generatedLabelRouteNamespace], Name: labels[generatedLabelRouteName]}}} + } key := client.ObjectKey{Namespace: labels[generatedLabelGatewayNamespace], Name: labels[generatedLabelGatewayName]} if key.Namespace == "" || key.Name == "" { return nil @@ -350,6 +352,7 @@ func (r *Reconciler) applyGatewayEndpointRecordSets(ctx context.Context, namespa var existing endpointv1alpha1.EndpointRecordSet err := r.Get(ctx, client.ObjectKey{Namespace: desired.Namespace, Name: desired.Name}, &existing) if apierrors.IsNotFound(err) { + controllerutil.AddFinalizer(&desired, endpointv1alpha1.GeneratedRecordSetsFinalizer) if err := r.Create(ctx, &desired, client.FieldOwner(fieldOwner)); err != nil { return err } @@ -363,6 +366,14 @@ func (r *Reconciler) applyGatewayEndpointRecordSets(ctx context.Context, namespa // provider cleanup; the EndpointRecordSet delete watch retries creation. continue } + spec, err := retirement.Append(&existing, desired.Spec) + if err != nil { + return retirement.ReportPending(ctx, r.Client, &existing, err) + } + desired.Spec = spec + if err := retirement.ClearPending(ctx, r.Client, &existing); err != nil { + return err + } desiredReceipt := desired.Annotations[admissionReceiptAnnotation] if apiequality.Semantic.DeepEqual(existing.Labels, desired.Labels) && apiequality.Semantic.DeepEqual(existing.Spec, desired.Spec) && @@ -396,7 +407,7 @@ func (r *Reconciler) applyGatewayEndpointRecordSets(ctx context.Context, namespa continue } item := item - if err := r.Delete(ctx, &item); err != nil && !apierrors.IsNotFound(err) { + if err := retirement.RetireOrDelete(ctx, r.Client, &item); err != nil && !apierrors.IsNotFound(err) { return err } } @@ -414,7 +425,7 @@ func (r *Reconciler) cleanupForRoute(ctx context.Context, endpointRecordSetNames } for _, item := range existing.Items { item := item - if err := r.Delete(ctx, &item); err != nil && !apierrors.IsNotFound(err) { + if err := retirement.RetireOrDelete(ctx, r.Client, &item); err != nil && !apierrors.IsNotFound(err) { return err } } diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/receipt_sync_test.go b/internal/go/apps/gatewayendpoint/controllers/gateway/receipt_sync_test.go index 357ebf4..283d402 100644 --- a/internal/go/apps/gatewayendpoint/controllers/gateway/receipt_sync_test.go +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/receipt_sync_test.go @@ -5,7 +5,6 @@ import ( "testing" endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" - apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -21,7 +20,7 @@ func TestGatewayEndpointRecordSetReconcilesReceiptGatewaysAfterParentChange(t *t reconcileRetentionRoute(t, r, routeKey) var removed endpointv1alpha1.EndpointRecordSet - if err := r.Get(ctx, gatewayARecordKey, &removed); !apierrors.IsNotFound(err) { + if err := r.Get(ctx, gatewayARecordKey, &removed); err != nil || removed.DeletionTimestamp.IsZero() { t.Fatalf("detached Gateway retained DNS intent: %v", err) } assertRetentionRecord(t, r, gatewayBRecordKey, []string{"shared.example.com"}, "192.0.2.20") @@ -34,7 +33,7 @@ func TestGatewayEndpointRecordSetReconcilesReceiptGatewaysAfterParentChange(t *t for _, key := range []client.ObjectKey{gatewayARecordKey, gatewayBRecordKey} { var record endpointv1alpha1.EndpointRecordSet - if err := r.Get(ctx, key, &record); !apierrors.IsNotFound(err) { + if err := r.Get(ctx, key, &record); err != nil || record.DeletionTimestamp.IsZero() { t.Fatalf("deleted route left prior Gateway DNS intent %s: %v", key, err) } } @@ -56,7 +55,7 @@ func TestGatewayEndpointRecordSetDoesNotMintReceiptWithoutCurrentAddresses(t *te deleteRoute(t, r, haKey) var record endpointv1alpha1.EndpointRecordSet - if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + if err := r.Get(ctx, recordKey, &record); err != nil || record.DeletionTimestamp.IsZero() { t.Fatalf("fresh route inherited a receipt while addresses were absent: %v", err) } } @@ -95,13 +94,15 @@ func receiptSyncFixture(t *testing.T) (*Reconciler, client.ObjectKey, client.Obj gatewayA := receiptSyncGateway("gateway-a", "gateway-a-uid", "192.0.2.10", section) gatewayB := receiptSyncGateway("gateway-b", "gateway-b-uid", "192.0.2.20", section) k8sClient := fake.NewClientBuilder().WithScheme(testScheme(t)). - WithStatusSubresource(route, gatewayA, gatewayB). + WithStatusSubresource(route, gatewayA, gatewayB, &endpointv1alpha1.EndpointRecordSet{}). WithObjects(route, gatewayA, gatewayB).Build() r := &Reconciler{Client: k8sClient, EndpointRecordSetNamespace: "dns-system"} routeKey := client.ObjectKeyFromObject(route) reconcileRetentionRoute(t, r, routeKey) gatewayARecordKey := client.ObjectKey{Namespace: "dns-system", Name: generatedGatewayEndpointRecordSetName(gatewayA.Namespace, gatewayA.Name)} gatewayBRecordKey := client.ObjectKey{Namespace: "dns-system", Name: generatedGatewayEndpointRecordSetName(gatewayB.Namespace, gatewayB.Name)} + markUnprotected(t, r, gatewayARecordKey) + markUnprotected(t, r, gatewayBRecordKey) assertRetentionRecord(t, r, gatewayARecordKey, []string{"shared.example.com"}, "192.0.2.10") assertRetentionRecord(t, r, gatewayBRecordKey, []string{"shared.example.com"}, "192.0.2.20") return r, routeKey, gatewayARecordKey, gatewayBRecordKey diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/retention_test.go b/internal/go/apps/gatewayendpoint/controllers/gateway/retention_test.go index 5556554..58a671c 100644 --- a/internal/go/apps/gatewayendpoint/controllers/gateway/retention_test.go +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/retention_test.go @@ -138,7 +138,7 @@ func TestGatewayEndpointRecordSetRevokesExplicitFalseAndForeignGateway(t *testin reconcileRetentionRoute(t, r, haKey) var record endpointv1alpha1.EndpointRecordSet - if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + if err := r.Get(ctx, recordKey, &record); err != nil || record.DeletionTimestamp.IsZero() { t.Fatalf("explicitly rejected route retained DNS intent: %v", err) } }) @@ -150,7 +150,7 @@ func TestGatewayEndpointRecordSetRevokesExplicitFalseAndForeignGateway(t *testin reconcileRetentionRoute(t, r, haKey) var record endpointv1alpha1.EndpointRecordSet - if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + if err := r.Get(ctx, recordKey, &record); err != nil || record.DeletionTimestamp.IsZero() { t.Fatalf("explicitly rejected Gateway retained DNS intent: %v", err) } }) @@ -198,7 +198,7 @@ func TestGatewayEndpointRecordSetRevokesExplicitFalseAndForeignGateway(t *testin reconcileRetentionRoute(t, r, haKey) var record endpointv1alpha1.EndpointRecordSet - if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + if err := r.Get(ctx, recordKey, &record); err != nil || record.DeletionTimestamp.IsZero() { t.Fatalf("changed listener ownership retained DNS intent: %v", err) } }) @@ -297,7 +297,7 @@ func TestGatewayEndpointRecordSetDoesNotRetainWithoutReceiptOrSelectorProof(t *t reconcileRetentionRoute(t, r, haKey) var record endpointv1alpha1.EndpointRecordSet - if err := r.Get(ctx, recordKey, &record); !apierrors.IsNotFound(err) { + if err := r.Get(ctx, recordKey, &record); err != nil || record.DeletionTimestamp.IsZero() { t.Fatalf("unproven retained DNS intent: %v", err) } }) @@ -333,15 +333,30 @@ func retentionFixtureWithNamespacePolicy(t *testing.T, includePayment bool, name objects = append(objects, payment) paymentKey = client.ObjectKeyFromObject(payment) } - k8sClient := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(ha, gateway).WithObjects(objects...).Build() + k8sClient := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(ha, gateway, &endpointv1alpha1.EndpointRecordSet{}).WithObjects(objects...).Build() r := &Reconciler{Client: k8sClient, EndpointRecordSetNamespace: "dns-system"} if includePayment { reconcileRetentionRoute(t, r, paymentKey) } reconcileRetentionRoute(t, r, client.ObjectKeyFromObject(ha)) + markUnprotected(t, r, client.ObjectKey{Namespace: "dns-system", Name: generatedGatewayEndpointRecordSetName("platform", "public")}) return r, paymentKey, client.ObjectKeyFromObject(ha), client.ObjectKeyFromObject(gateway), client.ObjectKey{Namespace: "dns-system", Name: generatedGatewayEndpointRecordSetName("platform", "public")} } +// These admission-receipt tests exercise ordinary, non-external publication. +// External protection and capture are covered by withdrawal_test.go. +func markUnprotected(t *testing.T, r *Reconciler, key client.ObjectKey) { + t.Helper() + var record endpointv1alpha1.EndpointRecordSet + if err := r.Get(context.Background(), key, &record); err != nil { + t.Fatal(err) + } + record.Status.Conditions = []metav1.Condition{{Type: "WithdrawalProtected", Status: metav1.ConditionFalse, ObservedGeneration: record.Generation, Reason: "ProviderCapability"}} + if err := r.Status().Update(context.Background(), &record); err != nil { + t.Fatal(err) + } +} + func retentionRoute(namespace, name, uid, hostname string, status metav1.ConditionStatus) *gatewayv1.HTTPRoute { section := gatewayv1.SectionName("https") gatewayNamespace := gatewayv1.Namespace("platform") diff --git a/internal/go/apps/gatewayendpoint/controllers/gateway/withdrawal_test.go b/internal/go/apps/gatewayendpoint/controllers/gateway/withdrawal_test.go new file mode 100644 index 0000000..64ed3ca --- /dev/null +++ b/internal/go/apps/gatewayendpoint/controllers/gateway/withdrawal_test.go @@ -0,0 +1,100 @@ +package gateway + +import ( + "context" + "errors" + "reflect" + "testing" + + "github.com/appthrust/dns-api/internal/go/apps/endpoint/retirement" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func gatewayWithdrawalERS() *endpointv1alpha1.EndpointRecordSet { + return &endpointv1alpha1.EndpointRecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace: "dns", Name: generatedGatewayEndpointRecordSetName("app", "public"), UID: "ers-uid", Generation: 3, + Finalizers: []string{endpointv1alpha1.GeneratedRecordSetsFinalizer}, Labels: map[string]string{ + managedByLabel: managedByValue, generatedLabelGatewayNamespace: "app", generatedLabelGatewayName: "public", + generatedLabelRouteNamespace: "app", generatedLabelRouteName: "route", + }}, + Spec: endpointv1alpha1.EndpointRecordSetSpec{Hostnames: []string{"h1.example.com", "h2.example.com"}, Targets: []endpointv1alpha1.EndpointTarget{{Type: endpointv1alpha1.EndpointTargetTypeIPAddress, Value: "192.0.2.1"}}}, + } +} + +func TestWithdrawalGatewayCleanupPathsRequireCapture(t *testing.T) { + for _, path := range []string{"missing gateway", "deleted gateway", "rejected admission", "cleanupForRoute"} { + t.Run(path, func(t *testing.T) { + ctx := context.Background() + ers := gatewayWithdrawalERS() + objects := []client.Object{ers} + if path == "deleted gateway" || path == "rejected admission" { + gateway := &gatewayv1.Gateway{ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "public"}, Status: gatewayv1.GatewayStatus{Conditions: []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionFalse}}}} + if path == "deleted gateway" { + now := metav1.Now() + gateway.DeletionTimestamp = &now + gateway.Finalizers = []string{"test.example/hold"} + } + objects = append(objects, gateway) + } + c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(&endpointv1alpha1.EndpointRecordSet{}).WithObjects(objects...).Build() + r := &Reconciler{Client: c} + cleanup := func() error { + if path == "cleanupForRoute" { return r.cleanupForRoute(ctx, "dns", "app", "route") } + return r.syncGatewayEndpointRecordSet(ctx, "dns", client.ObjectKey{Namespace: "app", Name: "public"}) + } + if err := cleanup(); !errors.Is(err, retirement.ErrCapturePending) { t.Fatalf("retire = %v", err) } + var current endpointv1alpha1.EndpointRecordSet + if err := c.Get(ctx, client.ObjectKeyFromObject(ers), ¤t); err != nil { t.Fatal(err) } + if !current.DeletionTimestamp.IsZero() || len(current.Spec.Hostnames) != 0 || len(current.Spec.Targets) != 0 || len(current.Spec.RetiredTargets) != 1 || len(current.Spec.RetiredHostnames) != 2 { + t.Fatalf("cleanup lost intent: %#v", current) + } + current.Status.Conditions = []metav1.Condition{{Type: retirement.CapturedCondition, Status: metav1.ConditionTrue, ObservedGeneration: current.Generation-1, Reason: "Captured"}} + if err := c.Status().Update(ctx, ¤t); err != nil { t.Fatal(err) } + if err := cleanup(); !errors.Is(err, retirement.ErrCapturePending) { t.Fatalf("stale acknowledgment permitted DELETE: %v", err) } + current.Status.Conditions[0].ObservedGeneration = current.Generation + if err := c.Status().Update(ctx, ¤t); err != nil { t.Fatal(err) } + if err := cleanup(); err != nil { t.Fatal(err) } + if err := c.Get(ctx, client.ObjectKeyFromObject(ers), ¤t); err != nil || current.DeletionTimestamp.IsZero() { t.Fatalf("capture did not permit deletion: %v", err) } + }) + } +} + +func TestWithdrawalGatewayUpdateSnapshotsAndOverflow(t *testing.T) { + for _, overflow := range []bool{false, true} { + t.Run(map[bool]string{false:"snapshot", true:"overflow"}[overflow], func(t *testing.T) { + ctx := context.Background() + ers := gatewayWithdrawalERS() + if overflow { ers.Spec.RetiredTargets = make([]endpointv1alpha1.RetiredTarget, retirement.MaxRetiredTargets) } + c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(&endpointv1alpha1.EndpointRecordSet{}).WithObjects(ers).Build() + r := &Reconciler{Client: c} + desired := ers.DeepCopy() + desired.Spec.Hostnames = []string{"h2.example.com"} + desired.Spec.Targets[0].Value = "192.0.2.2" + err := r.applyGatewayEndpointRecordSets(ctx, "dns", client.ObjectKey{Namespace:"app", Name:"public"}, []endpointv1alpha1.EndpointRecordSet{*desired}) + var current endpointv1alpha1.EndpointRecordSet + if getErr := c.Get(ctx, client.ObjectKeyFromObject(ers), ¤t); getErr != nil { t.Fatal(getErr) } + if overflow { + if !errors.Is(err, retirement.ErrCapturePending) || !reflect.DeepEqual(current.Spec, ers.Spec) || meta.FindStatusCondition(current.Status.Conditions, "Ready").Reason != retirement.PendingCondition { t.Fatalf("overflow = %#v, %v", current, err) } + return + } + if err != nil || len(current.Spec.RetiredTargets) != 1 || len(current.Spec.RetiredHostnames) != 1 || current.Spec.RetiredHostnames[0].Addresses[0] != "192.0.2.1" { t.Fatalf("snapshot = %#v, %v", current.Spec, err) } + }) + } +} + +func TestWithdrawalGatewayInstallsFinalizerAtCreate(t *testing.T) { + ctx := context.Background() + ers := gatewayWithdrawalERS() + ers.Finalizers = nil + c := fake.NewClientBuilder().WithScheme(testScheme(t)).Build() + r := &Reconciler{Client:c} + if err := r.applyGatewayEndpointRecordSets(ctx, "dns", client.ObjectKey{Namespace:"app", Name:"public"}, []endpointv1alpha1.EndpointRecordSet{*ers}); err != nil { t.Fatal(err) } + var current endpointv1alpha1.EndpointRecordSet + if err := c.Get(ctx, client.ObjectKeyFromObject(ers), ¤t); err != nil { t.Fatal(err) } + if !reflect.DeepEqual(current.Finalizers, []string{endpointv1alpha1.GeneratedRecordSetsFinalizer}) { t.Fatal("ERS create omitted endpoint finalizer") } +} diff --git a/internal/go/apps/serviceendpoint/controllers/service/controller.go b/internal/go/apps/serviceendpoint/controllers/service/controller.go index dda2e96..5628cdc 100644 --- a/internal/go/apps/serviceendpoint/controllers/service/controller.go +++ b/internal/go/apps/serviceendpoint/controllers/service/controller.go @@ -9,6 +9,7 @@ import ( "sort" "strings" + "github.com/appthrust/dns-api/internal/go/apps/endpoint/retirement" endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -17,6 +18,8 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" ) const ( @@ -39,6 +42,7 @@ type Reconciler struct { // +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;update // +kubebuilder:rbac:groups="",resources=services/finalizers,verbs=update // +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets,verbs=create;delete;get;list;watch;patch;update +// +kubebuilder:rbac:groups=endpoint.dns.appthrust.io,resources=endpointrecordsets/status,verbs=get;patch;update func (r *Reconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { namespace := r.EndpointRecordSetNamespace if namespace == "" { @@ -100,9 +104,18 @@ func (r *Reconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). Named("service-endpoint"). For(&corev1.Service{}). + Watches(&endpointv1alpha1.EndpointRecordSet{}, handler.EnqueueRequestsFromMapFunc(r.mapEndpointRecordSetToService)). Complete(r) } +func (r *Reconciler) mapEndpointRecordSetToService(_ context.Context, obj client.Object) []reconcile.Request { + labels := obj.GetLabels() + if labels[managedByLabel] != managedByValue || labels[generatedLabelServiceNamespace] == "" || labels[generatedLabelServiceName] == "" { + return nil + } + return []reconcile.Request{{NamespacedName: client.ObjectKey{Namespace: labels[generatedLabelServiceNamespace], Name: labels[generatedLabelServiceName]}}} +} + func (r *Reconciler) endpointRecordSetForService(service *corev1.Service, namespace string) *endpointv1alpha1.EndpointRecordSet { hostnames := hostnamesForService(service) targets := endpointTargets(service.Status.LoadBalancer.Ingress) @@ -130,11 +143,23 @@ func (r *Reconciler) applyEndpointRecordSet(ctx context.Context, desired *endpoi var existing endpointv1alpha1.EndpointRecordSet err := r.Get(ctx, client.ObjectKey{Namespace: desired.Namespace, Name: desired.Name}, &existing) if apierrors.IsNotFound(err) { + controllerutil.AddFinalizer(desired, endpointv1alpha1.GeneratedRecordSetsFinalizer) return r.Create(ctx, desired, client.FieldOwner(fieldOwner)) } if err != nil { return err } + if !existing.DeletionTimestamp.IsZero() { + return nil + } + spec, err := retirement.Append(&existing, desired.Spec) + if err != nil { + return retirement.ReportPending(ctx, r.Client, &existing, err) + } + desired.Spec = spec + if err := retirement.ClearPending(ctx, r.Client, &existing); err != nil { + return err + } if !endpointRecordSetNeedsUpdate(&existing, desired) { return nil } @@ -152,7 +177,7 @@ func (r *Reconciler) cleanupForService(ctx context.Context, endpointRecordSetNam return err } for i := range existing.Items { - if err := r.Delete(ctx, &existing.Items[i]); err != nil && !apierrors.IsNotFound(err) { + if err := retirement.RetireOrDelete(ctx, r.Client, &existing.Items[i]); err != nil && !apierrors.IsNotFound(err) { return err } } diff --git a/internal/go/apps/serviceendpoint/controllers/service/controller_test.go b/internal/go/apps/serviceendpoint/controllers/service/controller_test.go index d5ed13f..779c5b9 100644 --- a/internal/go/apps/serviceendpoint/controllers/service/controller_test.go +++ b/internal/go/apps/serviceendpoint/controllers/service/controller_test.go @@ -150,8 +150,8 @@ func TestReconcilerCleansEndpointRecordSetWhenServiceDNSIntentIsRemoved(t *testi if err := k8sClient.List(ctx, &list, client.InNamespace("app")); err != nil { t.Fatalf("list EndpointRecordSets: %v", err) } - if len(list.Items) != 0 { - t.Fatalf("EndpointRecordSets = %d, want 0", len(list.Items)) + if len(list.Items) != 1 || list.Items[0].DeletionTimestamp.IsZero() || !containsString(list.Items[0].Finalizers, endpointv1alpha1.GeneratedRecordSetsFinalizer) { + t.Fatalf("EndpointRecordSets = %#v, want deletion pending endpoint cleanup", list.Items) } if err := k8sClient.Get(ctx, client.ObjectKeyFromObject(service), &updated); err != nil { t.Fatalf("get cleaned Service: %v", err) diff --git a/internal/go/apps/serviceendpoint/controllers/service/withdrawal_test.go b/internal/go/apps/serviceendpoint/controllers/service/withdrawal_test.go new file mode 100644 index 0000000..218e4f4 --- /dev/null +++ b/internal/go/apps/serviceendpoint/controllers/service/withdrawal_test.go @@ -0,0 +1,57 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/appthrust/dns-api/internal/go/apps/endpoint/retirement" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestWithdrawalServiceUsesCreateRetireCaptureContract(t *testing.T) { + ctx := context.Background() + c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(&endpointv1alpha1.EndpointRecordSet{}).Build() + r := &Reconciler{Client:c} + ers := &endpointv1alpha1.EndpointRecordSet{ + ObjectMeta: metav1.ObjectMeta{Namespace:"dns", Name:"service", UID:"service-ers", Generation:4, Labels:map[string]string{managedByLabel:managedByValue, generatedLabelServiceNamespace:"app", generatedLabelServiceName:"web"}}, + Spec:endpointv1alpha1.EndpointRecordSetSpec{Hostnames:[]string{"h1.example.com", "h2.example.com"}, Targets:[]endpointv1alpha1.EndpointTarget{{Type:endpointv1alpha1.EndpointTargetTypeIPAddress, Value:"192.0.2.1"}}}, + } + if err := r.applyEndpointRecordSet(ctx, ers); err != nil { t.Fatal(err) } + if !containsString(ers.Finalizers, endpointv1alpha1.GeneratedRecordSetsFinalizer) { t.Fatal("create omitted finalizer") } + desired := ers.DeepCopy() + desired.Spec.Hostnames = []string{"h2.example.com"} + desired.Spec.Targets[0].Value = "192.0.2.2" + if err := r.applyEndpointRecordSet(ctx, desired); err != nil { t.Fatal(err) } + var current endpointv1alpha1.EndpointRecordSet + if err := c.Get(ctx, client.ObjectKeyFromObject(ers), ¤t); err != nil { t.Fatal(err) } + if len(current.Spec.RetiredTargets) != 1 || len(current.Spec.RetiredHostnames) != 1 || current.Spec.RetiredHostnames[0].Addresses[0] != "192.0.2.1" { t.Fatal("service lost old pair") } + // Real API updates advance generation; fake client does not. + current.Generation++ + if err := c.Update(ctx, ¤t); err != nil { t.Fatal(err) } + if err := r.cleanupForService(ctx, "dns", "app", "web"); !errors.Is(err, retirement.ErrCapturePending) { t.Fatalf("cleanup = %v", err) } + if err := c.Get(ctx, client.ObjectKeyFromObject(ers), ¤t); err != nil { t.Fatal(err) } + if !current.DeletionTimestamp.IsZero() || len(current.Spec.Hostnames) != 0 || len(current.Spec.RetiredTargets) != 2 { t.Fatal("cleanup discarded uncaptured state") } + current.Status.Conditions = []metav1.Condition{{Type:retirement.CapturedCondition, Status:metav1.ConditionTrue, ObservedGeneration:current.Generation, Reason:"Captured"}} + if err := c.Status().Update(ctx, ¤t); err != nil { t.Fatal(err) } + if err := r.cleanupForService(ctx, "dns", "app", "web"); err != nil { t.Fatal(err) } + if err := c.Get(ctx, client.ObjectKeyFromObject(ers), ¤t); err != nil || current.DeletionTimestamp.IsZero() { t.Fatalf("capture did not permit cleanup: %v", err) } +} + +func TestWithdrawalServiceNonExternalUpdateUnchanged(t *testing.T) { + ctx := context.Background() + ers := &endpointv1alpha1.EndpointRecordSet{ObjectMeta:metav1.ObjectMeta{Namespace:"dns", Name:"ers", Generation:1}, + Spec:endpointv1alpha1.EndpointRecordSetSpec{Hostnames:[]string{"h1.example.com"}, Targets:[]endpointv1alpha1.EndpointTarget{{Type:endpointv1alpha1.EndpointTargetTypeIPAddress, Value:"192.0.2.1"}}}, + Status:endpointv1alpha1.EndpointRecordSetStatus{Conditions:[]metav1.Condition{{Type:retirement.ProtectedCondition, Status:metav1.ConditionFalse, ObservedGeneration:1}}}, + } + c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(ers).WithObjects(ers).Build() + r := &Reconciler{Client:c} + desired := ers.DeepCopy() + desired.Spec.Targets[0].Value = "192.0.2.2" + if err := r.applyEndpointRecordSet(ctx, desired); err != nil { t.Fatal(err) } + if err := c.Get(ctx, client.ObjectKeyFromObject(ers), ers); err != nil { t.Fatal(err) } + if len(ers.Spec.RetiredTargets)+len(ers.Spec.RetiredHostnames) != 0 || ers.Spec.Targets[0].Value != "192.0.2.2" { t.Fatal("non-external source behavior changed") } +} diff --git a/internal/go/core/controllers/zoneunit/withdrawal_projection_test.go b/internal/go/core/controllers/zoneunit/withdrawal_projection_test.go new file mode 100644 index 0000000..fcd8e30 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/withdrawal_projection_test.go @@ -0,0 +1,56 @@ +package zoneunit + +import ( + "context" + "testing" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func TestWithdrawalProjectionRejectsStaleBindings(t *testing.T) { + _, _, _, claim := withdrawalFixture() + item := dnsv1alpha1.ZoneUnitWithdrawal{ClaimNamespace: claim.Namespace, ClaimName: claim.Name, Claim: withdrawalObjectRef(claim), ObservedGeneration: claim.Generation, Name: claim.Spec.Name, Address: claim.Spec.Address, Allowed: true} + for _, tc := range []struct { + name string + mutate func(*dnsv1alpha1.ZoneUnit) + want bool + }{ + {"current", func(*dnsv1alpha1.ZoneUnit) {}, true}, + {"claim UID", func(u *dnsv1alpha1.ZoneUnit) { u.Status.Withdrawals[0].Claim.UID = "other" }, false}, + {"claim generation", func(u *dnsv1alpha1.ZoneUnit) { u.Status.Withdrawals[0].ObservedGeneration++ }, false}, + {"condition generation", func(u *dnsv1alpha1.ZoneUnit) { u.Status.Withdrawals[0].Conditions[0].ObservedGeneration++ }, false}, + {"unit generation", func(u *dnsv1alpha1.ZoneUnit) { u.Status.ObservedGeneration-- }, false}, + {"missing class UID", func(u *dnsv1alpha1.ZoneUnit) { u.Spec.Zone.ZoneClassUID = "" }, false}, + {"missing class generation", func(u *dnsv1alpha1.ZoneUnit) { u.Spec.Zone.ZoneClassObservedGeneration = 0 }, false}, + {"authorization revoked", func(u *dnsv1alpha1.ZoneUnit) { u.Spec.Withdrawals[0].Allowed = false }, false}, + {"missing item", func(u *dnsv1alpha1.ZoneUnit) { u.Spec.Withdrawals = nil }, false}, + } { + t.Run(tc.name, func(t *testing.T) { + unit := &dnsv1alpha1.ZoneUnit{ObjectMeta: metav1.ObjectMeta{Generation: 10}, Spec: dnsv1alpha1.ZoneUnitSpec{Zone: dnsv1alpha1.ZoneUnitZoneSpec{ZoneClassUID: "class", ZoneClassObservedGeneration: 1}, Withdrawals: []dnsv1alpha1.ZoneUnitWithdrawal{item}}, Status: dnsv1alpha1.ZoneUnitStatus{ObservedGeneration: 10, Withdrawals: []dnsv1alpha1.ZoneUnitWithdrawalStatus{{ClaimNamespace: claim.Namespace, ClaimName: claim.Name, Claim: item.Claim, ObservedGeneration: claim.Generation, ResultDigest: "current", Conditions: []metav1.Condition{{Type: "Withdrawn", Status: metav1.ConditionTrue, Reason: "WithdrawalVerified", Message: "exact provider detail", ObservedGeneration: claim.Generation}, {Type: "Ready", Status: metav1.ConditionTrue, Reason: "WithdrawalVerified", ObservedGeneration: claim.Generation}}}}}} + tc.mutate(unit) + status := dnsv1alpha1.RecordWithdrawalStatus{} + projectWithdrawalResult(&status, claim, item, unit) + if (status.ResultDigest != "") == tc.want { + return + } + t.Fatalf("projected=%#v want=%v", status, tc.want) + }) + } +} + +func TestWithdrawalWaiverMissingClaimInvalidatesAcceptance(t *testing.T) { + waiver := &dnsv1alpha1.RecordWithdrawalWaiver{ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "waiver", Generation: 1}, Spec: dnsv1alpha1.RecordWithdrawalWaiverSpec{WithdrawalRef: dnsv1alpha1.WithdrawalObjectReference{Namespace: "app", Name: "missing", UID: "gone"}}, Status: dnsv1alpha1.RecordWithdrawalWaiverStatus{ObservedGeneration: 1, Conditions: []metav1.Condition{{Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: 1}}}} + c := fake.NewClientBuilder().WithScheme(testScheme(t)).WithStatusSubresource(waiver).WithObjects(waiver).Build() + r := &RecordWithdrawalWaiverReconciler{Client: c} + if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(waiver)}); err != nil { + t.Fatal(err) + } + if err := c.Get(context.Background(), client.ObjectKeyFromObject(waiver), waiver); err != nil { + t.Fatal(err) + } + assertCondition(t, waiver.Status.Conditions, "Accepted", metav1.ConditionFalse, "InvalidWithdrawal") +} diff --git a/internal/go/core/controllers/zoneunit/withdrawal_waivers.go b/internal/go/core/controllers/zoneunit/withdrawal_waivers.go new file mode 100644 index 0000000..b394222 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/withdrawal_waivers.go @@ -0,0 +1,65 @@ +package zoneunit + +import ( + "context" + + "github.com/appthrust/dns-api/internal/go/core/declaration" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// RecordWithdrawalWaiverReconciler owns waiver status independently of Zone +// existence, so missing or archived claims cannot leave Accepted=True behind. +// Composition still validates exact bindings directly before attaching a waiver. +type RecordWithdrawalWaiverReconciler struct{ client.Client } + +func (r *RecordWithdrawalWaiverReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + var waiver dnsv1alpha1.RecordWithdrawalWaiver + if err := r.Get(ctx, req.NamespacedName, &waiver); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + var claim dnsv1alpha1.RecordWithdrawal + err := r.Get(ctx, client.ObjectKey{Namespace: waiver.Spec.WithdrawalRef.Namespace, Name: waiver.Spec.WithdrawalRef.Name}, &claim) + if err != nil && client.IgnoreNotFound(err) != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, r.setWaiverStatus(ctx, &waiver, err == nil && declaration.WithdrawalWaiverMatches(&waiver, &claim)) +} + +func (r *RecordWithdrawalWaiverReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr).Named("recordwithdrawal-waiver").For(&dnsv1alpha1.RecordWithdrawalWaiver{}).Watches(&dnsv1alpha1.RecordWithdrawal{}, handler.EnqueueRequestsFromMapFunc(r.mapClaimToWaivers)).Complete(r) +} + +func (r *RecordWithdrawalWaiverReconciler) mapClaimToWaivers(ctx context.Context, obj client.Object) []reconcile.Request { + var waivers dnsv1alpha1.RecordWithdrawalWaiverList + if err := r.List(ctx, &waivers, client.InNamespace(obj.GetNamespace())); err != nil { + return nil + } + requests := make([]reconcile.Request, 0) + for i := range waivers.Items { + waiver := &waivers.Items[i] + if waiver.Spec.WithdrawalRef.Namespace == obj.GetNamespace() && waiver.Spec.WithdrawalRef.Name == obj.GetName() { + requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(waiver)}) + } + } + return requests +} +func (r *RecordWithdrawalWaiverReconciler) setWaiverStatus(ctx context.Context, waiver *dnsv1alpha1.RecordWithdrawalWaiver, valid bool) error { + base := waiver.DeepCopy() + condition := metav1.Condition{Type: string(dnsv1alpha1.ConditionAccepted), Status: metav1.ConditionFalse, Reason: "InvalidWithdrawal", Message: "Waiver does not match the exact claim UID, immutable spec digest and address", ObservedGeneration: waiver.Generation} + if valid { + condition.Status, condition.Reason, condition.Message = metav1.ConditionTrue, "Accepted", "Waiver is bound to the exact claim and receiving reservation" + } + waiver.Status.ObservedGeneration = waiver.Generation + meta.SetStatusCondition(&waiver.Status.Conditions, condition) + if equality.Semantic.DeepEqual(base.Status, waiver.Status) { + return nil + } + return client.IgnoreNotFound(r.Status().Patch(ctx, waiver, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{}))) +} diff --git a/internal/go/core/controllers/zoneunit/withdrawals.go b/internal/go/core/controllers/zoneunit/withdrawals.go new file mode 100644 index 0000000..30166ee --- /dev/null +++ b/internal/go/core/controllers/zoneunit/withdrawals.go @@ -0,0 +1,256 @@ +package zoneunit + +import ( + "context" + "slices" + "strings" + + "github.com/appthrust/dns-api/internal/go/core/declaration" + "github.com/appthrust/dns-api/internal/go/core/providercontract" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" +) + +// +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordwithdrawals;recordwithdrawalwaivers,verbs=create;delete;get;list;watch;patch;update +// +kubebuilder:rbac:groups=dns.appthrust.io,resources=recordwithdrawals/status;recordwithdrawalwaivers/status,verbs=get;patch;update +func (r *ZoneUnitCompositionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + claims, err := r.withdrawalsForZone(ctx, req.NamespacedName) + if err != nil { + return ctrl.Result{}, err + } + result, err := r.reconcileZone(ctx, req, claims) + if err != nil { + return result, err + } + return result, r.reconcileWithdrawals(ctx, req.NamespacedName, claims) +} + +func withdrawalZoneKey(claim *dnsv1alpha1.RecordWithdrawal) client.ObjectKey { + namespace := claim.Namespace + if claim.Spec.ZoneRef.Namespace != nil && *claim.Spec.ZoneRef.Namespace != "" { + namespace = *claim.Spec.ZoneRef.Namespace + } + return client.ObjectKey{Namespace: namespace, Name: claim.Spec.ZoneRef.Name} +} + +func (r *ZoneUnitCompositionReconciler) withdrawalsForZone(ctx context.Context, key client.ObjectKey) ([]dnsv1alpha1.RecordWithdrawal, error) { + var list dnsv1alpha1.RecordWithdrawalList + if err := r.List(ctx, &list); err != nil { + return nil, err + } + claims := make([]dnsv1alpha1.RecordWithdrawal, 0) + for _, claim := range list.Items { + if withdrawalZoneKey(&claim) == key { + claims = append(claims, claim) + } + } + slices.SortFunc(claims, func(a, b dnsv1alpha1.RecordWithdrawal) int { + return strings.Compare(recordSetClaimKey(a.Namespace, a.Name), recordSetClaimKey(b.Namespace, b.Name)) + }) + return claims, nil +} + +func (r *ZoneUnitCompositionReconciler) mapWithdrawalToZone(_ context.Context, obj client.Object) []reconcile.Request { + claim := obj.(*dnsv1alpha1.RecordWithdrawal) + return []reconcile.Request{{NamespacedName: withdrawalZoneKey(claim)}} +} + +func (r *ZoneUnitCompositionReconciler) mapWaiverToZone(ctx context.Context, obj client.Object) []reconcile.Request { + waiver := obj.(*dnsv1alpha1.RecordWithdrawalWaiver) + var claim dnsv1alpha1.RecordWithdrawal + if err := r.Get(ctx, client.ObjectKey{Namespace: waiver.Spec.WithdrawalRef.Namespace, Name: waiver.Spec.WithdrawalRef.Name}, &claim); err != nil { + return nil + } + return []reconcile.Request{{NamespacedName: withdrawalZoneKey(&claim)}} +} + +func (r *ZoneUnitCompositionReconciler) reconcileWithdrawals(ctx context.Context, key client.ObjectKey, claims []dnsv1alpha1.RecordWithdrawal) error { + var zone dnsv1alpha1.Zone + var unit dnsv1alpha1.ZoneUnit + zoneErr := r.Get(ctx, key, &zone) + if err := client.IgnoreNotFound(zoneErr); err != nil { + return err + } + unitErr := r.Get(ctx, key, &unit) + if err := client.IgnoreNotFound(unitErr); err != nil { + return err + } + var version *dnsv1alpha1.ProviderVersion + bindingValid := false + zoneAllowed := true + if zoneErr == nil { + class, found, err := r.zoneClassForZone(ctx, &zone) + if err != nil { + return err + } + if found && class.Spec.Provider == zone.Spec.Provider { + allowed, err := declaration.ZoneAllowedByClass(ctx, r.Client, class, zone.Namespace) + if err != nil { + return err + } + zoneAllowed = allowed + var provider dnsv1alpha1.Provider + if err := r.Get(ctx, client.ObjectKey{Name: class.Spec.Provider.Name}, &provider); err != nil { + if client.IgnoreNotFound(err) != nil { + return err + } + } else { + input, served := providercontract.ServedVersion(&provider, zone.Spec.Provider.Version) + storage, stored := providercontract.StorageVersion(&provider) + if served && stored && input.RecordWithdrawal != nil && input.RecordWithdrawal.Supported { + version = storage + } + bindingValid = allowed && served && stored && unitErr == nil && unit.DeletionTimestamp.IsZero() && + unit.Spec.Zone.Ref == (dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}) && + unit.Spec.Zone.ObservedGeneration == zone.Generation && unit.Spec.Zone.DomainName == zone.Spec.DomainName && + unit.Spec.Zone.ZoneClassRef == (dnsv1alpha1.ObjectReference{Namespace: class.Namespace, Name: class.Name}) && + unit.Spec.Zone.ZoneClassUID == class.UID && unit.Spec.Zone.ZoneClassObservedGeneration == class.Generation && + unit.Spec.Provider == (dnsv1alpha1.ProviderReference{Name: provider.Name, Version: storage.Name}) + } + } + } + var waivers dnsv1alpha1.RecordWithdrawalWaiverList + if len(claims) > 0 { + if err := r.List(ctx, &waivers); err != nil { + return err + } + slices.SortFunc(waivers.Items, func(a, b dnsv1alpha1.RecordWithdrawalWaiver) int { + return strings.Compare(recordSetClaimKey(a.Namespace, a.Name), recordSetClaimKey(b.Namespace, b.Name)) + }) + } + items := make([]dnsv1alpha1.ZoneUnitWithdrawal, 0, len(claims)) + if len(claims) > 256 { + // Refuse the new inventory as a whole, retaining previous identities and + // provider cache history instead of truncating obligations. + items = append(items, unit.Spec.Withdrawals...) + for i := range items { + items[i].Allowed = false + items[i].Waiver = nil + } + } + for i := range claims { + claim := &claims[i] + reason, message := "Accepted", "Withdrawal binding, grants and provider capability are accepted" + if !zoneAllowed { + reason, message = "NotAllowed", "Zone namespace is not allowed by the referenced ZoneClass" + } else if !bindingValid || len(claims) > 256 { + reason, message = "InvalidWithdrawal", "Withdrawal Zone binding is unavailable or exceeds the 256 claim limit" + } else if version == nil || version.RecordWithdrawal == nil || !version.RecordWithdrawal.Supported { + reason, message = "UnsupportedProvider", "The bound Provider version does not support record withdrawal" + } else if err := declaration.ValidateWithdrawal(claim); err != nil { + reason, message = "InvalidWithdrawal", err.Error() + } else if len(claim.Spec.Name)+len(zone.Spec.DomainName)+1 > 253 && claim.Spec.Name != "@" { + reason, message = "InvalidWithdrawal", "Withdrawal owner and Zone domain exceed the maximum DNS name length" + } else if allowed, detail := declaration.WithdrawalAllowedByZone(ctx, r.Client, claim, &zone); !allowed { + reason, message = "NotAllowed", detail + } + item := dnsv1alpha1.ZoneUnitWithdrawal{ClaimNamespace: claim.Namespace, ClaimName: claim.Name, Claim: withdrawalObjectRef(claim), ObservedGeneration: claim.Generation, Name: claim.Spec.Name, Address: claim.Spec.Address, Allowed: reason == "Accepted"} + for j := range waivers.Items { + waiver := &waivers.Items[j] + if waiver.Spec.WithdrawalRef.Namespace != claim.Namespace || waiver.Spec.WithdrawalRef.Name != claim.Name { + continue + } + valid := declaration.WithdrawalWaiverMatches(waiver, claim) + if valid && item.Allowed && item.Waiver == nil { + item.Waiver = &dnsv1alpha1.ZoneUnitWithdrawalWaiver{Name: waiver.Name, UID: waiver.UID, Digest: dnsv1alpha1.WithdrawalWaiverDigest(waiver.Spec), ReservationRef: waiver.Spec.ReservationRef} + } + } + if len(claims) <= 256 && declaration.ValidateWithdrawal(claim) == nil { + items = append(items, item) + } + status := dnsv1alpha1.RecordWithdrawalStatus{ObservedGeneration: claim.Generation} + if zoneErr == nil { + ref := withdrawalObjectRef(&zone) + status.Zone = &ref + } + if unitErr == nil { + ref := withdrawalObjectRef(&unit) + status.Unit = &ref + status.ProviderRef = &unit.Spec.Provider + } + accepted := metav1.ConditionFalse + if item.Allowed { + accepted = metav1.ConditionTrue + } + status.Conditions = append(status.Conditions, metav1.Condition{Type: string(dnsv1alpha1.ConditionAccepted), Status: accepted, Reason: reason, Message: message, ObservedGeneration: claim.Generation}) + if item.Allowed && unitErr == nil { + projectWithdrawalResult(&status, claim, item, &unit) + } + if meta.FindStatusCondition(status.Conditions, string(dnsv1alpha1.ConditionWithdrawn)) == nil { + status.Conditions = append(status.Conditions, + metav1.Condition{Type: string(dnsv1alpha1.ConditionWithdrawn), Status: metav1.ConditionUnknown, Reason: "DNSProbeFailed", Message: "No current identity-bound withdrawal observation is available", ObservedGeneration: claim.Generation}, + metav1.Condition{Type: string(dnsv1alpha1.ConditionReady), Status: metav1.ConditionFalse, Reason: "DNSProbeFailed", Message: "No current identity-bound withdrawal observation is available", ObservedGeneration: claim.Generation}) + } + if err := r.setWithdrawalStatus(ctx, claim, status); err != nil { + return err + } + } + if unitErr != nil || !unit.DeletionTimestamp.IsZero() || equality.Semantic.DeepEqual(unit.Spec.Withdrawals, items) || len(unit.Spec.Withdrawals) == 0 && len(items) == 0 { + return nil + } + base := unit.DeepCopy() + unit.Spec.Withdrawals = items + return r.Patch(ctx, &unit, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{})) +} + +func withdrawalObjectRef(obj client.Object) dnsv1alpha1.WithdrawalObjectReference { + return dnsv1alpha1.WithdrawalObjectReference{Namespace: obj.GetNamespace(), Name: obj.GetName(), UID: obj.GetUID()} +} + +func projectWithdrawalResult(status *dnsv1alpha1.RecordWithdrawalStatus, claim *dnsv1alpha1.RecordWithdrawal, desired dnsv1alpha1.ZoneUnitWithdrawal, unit *dnsv1alpha1.ZoneUnit) { + // Both spec and result must refer to this claim incarnation and current policy + // admission. A cached result may never cross a changed waiver or desired item. + if unit.Status.ObservedGeneration != unit.Generation || unit.Spec.Zone.ZoneClassUID == "" || unit.Spec.Zone.ZoneClassObservedGeneration < 1 { + return + } + if !slices.ContainsFunc(unit.Spec.Withdrawals, func(item dnsv1alpha1.ZoneUnitWithdrawal) bool { return equality.Semantic.DeepEqual(item, desired) }) { + return + } + for i := range unit.Status.Withdrawals { + result := &unit.Status.Withdrawals[i] + if result.Claim != desired.Claim || result.ClaimNamespace != claim.Namespace || result.ClaimName != claim.Name || result.ObservedGeneration != claim.Generation { + continue + } + withdrawn := conditionAtGeneration(result.Conditions, string(dnsv1alpha1.ConditionWithdrawn), claim.Generation) + ready := conditionAtGeneration(result.Conditions, string(dnsv1alpha1.ConditionReady), claim.Generation) + if withdrawn == nil || ready == nil { + return + } + status.PolicyFingerprint, status.CheckedNames, status.RequiredViews, status.SampledWildcard = result.PolicyFingerprint, slices.Clone(result.CheckedNames), slices.Clone(result.RequiredViews), result.SampledWildcard + status.AbsentSince, status.NotBefore, status.LastProbeTime, status.NextProbeTime, status.VerifiedAt = result.AbsentSince.DeepCopy(), result.NotBefore.DeepCopy(), result.LastProbeTime.DeepCopy(), result.NextProbeTime.DeepCopy(), result.VerifiedAt.DeepCopy() + status.ResultDigest = result.ResultDigest + if result.Provider != nil { + status.Provider = &dnsv1alpha1.ProviderStatus{Data: *result.Provider.Data.DeepCopy()} + } + status.Conditions = append(status.Conditions, *withdrawn, *ready) + if desired.Waiver != nil && result.WaiverRef != nil && result.WaiverRef.Name == desired.Waiver.Name && result.WaiverRef.UID == desired.Waiver.UID && result.WaiverRef.Digest == desired.Waiver.Digest && result.WaiverRef.ReservationRef == desired.Waiver.ReservationRef { + if override := conditionAtGeneration(result.Conditions, string(dnsv1alpha1.ConditionOverrideAccepted), claim.Generation); override != nil && override.Status == metav1.ConditionTrue && override.Reason == "OperatorAuthorizedRelease" { + status.WaiverRef = result.WaiverRef.DeepCopy() + status.Conditions = append(status.Conditions, *override) + } + } + return + } +} + +func (r *ZoneUnitCompositionReconciler) setWithdrawalStatus(ctx context.Context, claim *dnsv1alpha1.RecordWithdrawal, status dnsv1alpha1.RecordWithdrawalStatus) error { + base := claim.DeepCopy() + conditions := append([]metav1.Condition(nil), claim.Status.Conditions...) + for _, condition := range status.Conditions { + meta.SetStatusCondition(&conditions, condition) + } + if status.WaiverRef == nil { + meta.RemoveStatusCondition(&conditions, string(dnsv1alpha1.ConditionOverrideAccepted)) + } + status.Conditions = conditions + claim.Status = status + if equality.Semantic.DeepEqual(base.Status, status) { + return nil + } + return client.IgnoreNotFound(r.Status().Patch(ctx, claim, client.MergeFromWithOptions(base, client.MergeFromWithOptimisticLock{}))) +} diff --git a/internal/go/core/controllers/zoneunit/withdrawals_test.go b/internal/go/core/controllers/zoneunit/withdrawals_test.go new file mode 100644 index 0000000..1d45238 --- /dev/null +++ b/internal/go/core/controllers/zoneunit/withdrawals_test.go @@ -0,0 +1,266 @@ +package zoneunit + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + endpointv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/endpoint/v1alpha1" + corev1 "k8s.io/api/core/v1" + 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" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +func withdrawalFixture() (*dnsv1alpha1.Provider, *dnsv1alpha1.ZoneClass, *dnsv1alpha1.Zone, *dnsv1alpha1.RecordWithdrawal) { + provider := route53ProviderForZoneUnitTest() + provider.Spec.Versions[0].RecordWithdrawal = &dnsv1alpha1.ProviderRecordWithdrawal{Supported: true} + class := route53ZoneClassForZoneUnitTest() + class.Namespace = "app" + class.Spec.IdentityRef = nil + class.Spec.Parameters = runtime.RawExtension{Raw: []byte(`{}`)} + z := zone("app", "example", "example.com") + claim := &dnsv1alpha1.RecordWithdrawal{ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "retired", UID: "claim-uid", Generation: 1, Labels: map[string]string{dnsv1alpha1.AddressLabelKey: "192.0.2.1"}}, Spec: dnsv1alpha1.RecordWithdrawalSpec{ZoneRef: dnsv1alpha1.ZoneReference{Name: z.Name}, Name: "www", Address: "192.0.2.1"}} + return provider, class, z, claim +} + +func TestWithdrawalCompositionAuthorization(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*dnsv1alpha1.Provider, *dnsv1alpha1.Zone, *dnsv1alpha1.RecordWithdrawal) + reason string + }{ + {"accepted", func(*dnsv1alpha1.Provider, *dnsv1alpha1.Zone, *dnsv1alpha1.RecordWithdrawal) {}, "Accepted"}, + {"unsupported", func(p *dnsv1alpha1.Provider, _ *dnsv1alpha1.Zone, _ *dnsv1alpha1.RecordWithdrawal) { + p.Spec.Versions[0].RecordWithdrawal = nil + }, "UnsupportedProvider"}, + {"wildcard requires grant", func(_ *dnsv1alpha1.Provider, _ *dnsv1alpha1.Zone, c *dnsv1alpha1.RecordWithdrawal) { c.Spec.Name = "*" }, "NotAllowed"}, + {"invalid label", func(_ *dnsv1alpha1.Provider, _ *dnsv1alpha1.Zone, c *dnsv1alpha1.RecordWithdrawal) { c.Labels = nil }, "InvalidWithdrawal"}, + {"cross namespace denied", func(_ *dnsv1alpha1.Provider, _ *dnsv1alpha1.Zone, c *dnsv1alpha1.RecordWithdrawal) { + c.Namespace = "tenant" + c.Spec.ZoneRef.Namespace = ptr("app") + }, "NotAllowed"}, + {"wildcard grant", func(_ *dnsv1alpha1.Provider, z *dnsv1alpha1.Zone, c *dnsv1alpha1.RecordWithdrawal) { + c.Spec.Name = "*.apps" + z.Spec.AllowedRecordSets = withdrawalGrant() + }, "Accepted"}, + } { + t.Run(tc.name, func(t *testing.T) { + p, class, z, c := withdrawalFixture() + tc.mutate(p, z, c) + k8sClient := fake.NewClientBuilder().WithScheme(testScheme(t)).WithObjects(p, class, z, c, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "app", Labels: map[string]string{"dns": "allowed"}}}).WithStatusSubresource(&dnsv1alpha1.RecordWithdrawal{}, &dnsv1alpha1.Zone{}, &dnsv1alpha1.ZoneUnit{}, &dnsv1alpha1.RecordWithdrawalWaiver{}).Build() + r := &ZoneUnitCompositionReconciler{Client: k8sClient} + if _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(z)}); err != nil { + t.Fatal(err) + } + var got dnsv1alpha1.RecordWithdrawal + if err := k8sClient.Get(context.Background(), client.ObjectKeyFromObject(c), &got); err != nil { + t.Fatal(err) + } + status := metav1.ConditionFalse + if tc.reason == "Accepted" { + status = metav1.ConditionTrue + } + assertCondition(t, got.Status.Conditions, "Accepted", status, tc.reason) + }) + } +} + +func withdrawalGrant() []dnsv1alpha1.AllowedRecordSet { + return []dnsv1alpha1.AllowedRecordSet{{Namespaces: dnsv1alpha1.AllowedRecordSetNamespaces{Selector: metav1.LabelSelector{MatchLabels: map[string]string{"dns": "allowed"}}}, Records: []dnsv1alpha1.AllowedRecord{{Name: dnsv1alpha1.RecordNamePolicy{Pattern: `\*\.apps`}, Types: []dnsv1alpha1.RecordType{dnsv1alpha1.RecordTypeA}}}}} +} + +func TestWithdrawalEnvtestCompositionProjectionWaiverAndRetention(t *testing.T) { + assets := os.Getenv("KUBEBUILDER_ASSETS") + if assets == "" { + t.Skip("KUBEBUILDER_ASSETS is required for the real CRD integration test") + } + environment := &envtest.Environment{BinaryAssetsDirectory: assets, CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "..", "app", "operator", "config", "crd", "bases")}, ErrorIfCRDPathMissing: true, ControlPlaneStartTimeout: 30 * time.Second, ControlPlaneStopTimeout: 15 * time.Second} + t.Cleanup(func() { + if err := environment.Stop(); err != nil { + t.Errorf("stop envtest: %v", err) + } + }) + cfg, err := environment.Start() + if err != nil { + t.Fatal(err) + } + scheme := testScheme(t) + if err := endpointv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + c, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + p, class, z, claim := withdrawalFixture() + claim.UID = "" + claim.Generation = 0 + claim.Spec.Name = "*.apps" + z.Spec.AllowedRecordSets = withdrawalGrant() + for _, obj := range []client.Object{&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "app", Labels: map[string]string{"dns": "allowed"}}}, p, class, z, claim} { + if err := c.Create(ctx, obj); err != nil { + t.Fatalf("create %T: %v", obj, err) + } + } + r := &ZoneUnitCompositionReconciler{Client: c} + key := client.ObjectKeyFromObject(z) + reconcile := func() { + t.Helper() + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: key}); err != nil { + t.Fatal(err) + } + } + readClaim := func() { + t.Helper() + if err := c.Get(ctx, client.ObjectKeyFromObject(claim), claim); err != nil { + t.Fatal(err) + } + } + readUnit := func() *dnsv1alpha1.ZoneUnit { + t.Helper() + u := &dnsv1alpha1.ZoneUnit{} + if err := c.Get(ctx, key, u); err != nil { + t.Fatal(err) + } + return u + } + reconcile() + readClaim() + assertCondition(t, claim.Status.Conditions, "Accepted", metav1.ConditionTrue, "Accepted") + unit := readUnit() + if len(unit.Spec.Withdrawals) != 1 || unit.Spec.Withdrawals[0].Claim.UID != claim.UID || !unit.Spec.Withdrawals[0].Allowed { + t.Fatalf("composition=%#v", unit.Spec.Withdrawals) + } + oldSpec := claim.DeepCopy() + claim.Spec.Address = "192.0.2.2" + if err := c.Update(ctx, claim); !apierrors.IsInvalid(err) { + t.Fatalf("mutable claim admitted: %v", err) + } + claim = oldSpec + invalidUnit := unit.DeepCopy() + invalidUnit.Spec.Withdrawals[0].ClaimName = "different" + if err := c.Update(ctx, invalidUnit); !apierrors.IsInvalid(err) { + t.Fatalf("inconsistent scalar key admitted: %v", err) + } + waiver := &dnsv1alpha1.RecordWithdrawalWaiver{ObjectMeta: metav1.ObjectMeta{Namespace: claim.Namespace, Name: "risk"}, Spec: dnsv1alpha1.RecordWithdrawalWaiverSpec{WithdrawalRef: withdrawalObjectRef(claim), ClaimSpecDigest: dnsv1alpha1.WithdrawalSpecDigest(claim.Spec), Address: claim.Spec.Address, ReservationRef: dnsv1alpha1.WithdrawalObjectReference{Namespace: "mgmt", Name: "reservation", UID: "reservation-uid"}, Reason: "explicit operator risk acceptance", TicketRef: "change-1", AcceptStaleDNSRisk: true}} + if err := c.Create(ctx, waiver); err != nil { + t.Fatal(err) + } + if _, err := (&RecordWithdrawalWaiverReconciler{Client: c}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(waiver)}); err != nil { + t.Fatal(err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(waiver), waiver); err != nil { + t.Fatal(err) + } + assertCondition(t, waiver.Status.Conditions, "Accepted", metav1.ConditionTrue, "Accepted") + reconcile() + unit = readUnit() + if unit.Spec.Withdrawals[0].Waiver == nil { + t.Fatal("exact waiver not composed") + } + changedWaiver := waiver.DeepCopy() + changedWaiver.Spec.ReservationRef.UID = "replacement" + if err := c.Update(ctx, changedWaiver); !apierrors.IsInvalid(err) { + t.Fatalf("mutable waiver admitted: %v", err) + } + stamp := metav1.NewTime(time.Date(2026, 10, 18, 1, 0, 0, 0, time.UTC)) + item := unit.Spec.Withdrawals[0] + result := dnsv1alpha1.ZoneUnitWithdrawalStatus{ClaimNamespace: claim.Namespace, ClaimName: claim.Name, Claim: withdrawalObjectRef(claim), ObservedGeneration: claim.Generation, PolicyFingerprint: "policy", CheckedNames: []string{"witness.apps.example.com"}, RequiredViews: []string{"recursive"}, SampledWildcard: true, LastProbeTime: &stamp, NextProbeTime: &stamp, ResultDigest: "result", WaiverRef: &dnsv1alpha1.RecordWithdrawalWaiverReference{Name: waiver.Name, UID: waiver.UID, Digest: item.Waiver.Digest, ReservationRef: waiver.Spec.ReservationRef}, Conditions: []metav1.Condition{{Type: "Withdrawn", Status: metav1.ConditionFalse, Reason: "DNSStillResolves", Message: "DNS witness.apps.example.com still resolves to retired address 192.0.2.1 in recursive (A).", ObservedGeneration: claim.Generation, LastTransitionTime: stamp}, {Type: "Ready", Status: metav1.ConditionFalse, Reason: "DNSStillResolves", Message: "provider detail", ObservedGeneration: claim.Generation, LastTransitionTime: stamp}, {Type: "OverrideAccepted", Status: metav1.ConditionTrue, Reason: "OperatorAuthorizedRelease", Message: "explicit risk", ObservedGeneration: claim.Generation, LastTransitionTime: stamp}}} + unit.Status.ObservedGeneration = unit.Generation + unit.Status.Withdrawals = []dnsv1alpha1.ZoneUnitWithdrawalStatus{result} + unit.Status.Zone = &dnsv1alpha1.ZoneUnitZoneStatus{Conditions: []metav1.Condition{ + {Type: "Accepted", Status: metav1.ConditionTrue, Reason: "Accepted", ObservedGeneration: z.Generation, LastTransitionTime: stamp}, + {Type: "Programmed", Status: metav1.ConditionTrue, Reason: "Programmed", ObservedGeneration: z.Generation, LastTransitionTime: stamp}, + }} + if err := c.Status().Update(ctx, unit); err != nil { + t.Fatal(err) + } + reconcile() + readClaim() + condition := assertCondition(t, claim.Status.Conditions, "Withdrawn", metav1.ConditionFalse, "DNSStillResolves") + if condition.Message != result.Conditions[0].Message { + t.Fatalf("message lost: %s", condition.Message) + } + assertCondition(t, claim.Status.Conditions, "OverrideAccepted", metav1.ConditionTrue, "OperatorAuthorizedRelease") + if err := c.Get(ctx, key, z); err != nil { + t.Fatal(err) + } + assertCondition(t, z.Status.Conditions, "Programmed", metav1.ConditionTrue, "Programmed") + // A policy edit fences old observations before the verifier runs again. + class.Spec.Parameters = runtime.RawExtension{Raw: []byte(`{"policy":"changed"}`)} + if err := c.Update(ctx, class); err != nil { + t.Fatal(err) + } + reconcile() + readClaim() + assertCondition(t, claim.Status.Conditions, "Withdrawn", metav1.ConditionUnknown, "DNSProbeFailed") + unit = readUnit() + if unit.Spec.Zone.ZoneClassUID != class.UID || unit.Spec.Zone.ZoneClassObservedGeneration != class.Generation { + t.Fatal("Unit did not bind the new Class generation") + } + // Retained claims pin deletion without marking their provider Unit deleting. + if err := c.Get(ctx, key, z); err != nil { + t.Fatal(err) + } + if err := c.Delete(ctx, z); err != nil { + t.Fatal(err) + } + reconcile() + unit = readUnit() + if !unit.DeletionTimestamp.IsZero() { + t.Fatal("pending withdrawal allowed Unit deletion") + } + if err := c.Get(ctx, key, z); err != nil { + t.Fatal(err) + } + gate := assertCondition(t, z.Status.Conditions, "Programmed", metav1.ConditionFalse, "ProviderChangePending") + if gate.Message == "" { + t.Fatal("missing dependency detail") + } + // A same-name recreation cannot consume the old result or waiver. + if err := c.Delete(ctx, claim); err != nil { + t.Fatal(err) + } + replacement := oldSpec.DeepCopy() + replacement.ResourceVersion = "" + replacement.UID = "" + replacement.Generation = 0 + replacement.CreationTimestamp = metav1.Time{} + replacement.Status = dnsv1alpha1.RecordWithdrawalStatus{} + if err := c.Create(ctx, replacement); err != nil { + t.Fatal(err) + } + claim = replacement + reconcile() + readClaim() + assertCondition(t, claim.Status.Conditions, "Withdrawn", metav1.ConditionUnknown, "DNSProbeFailed") + unit = readUnit() + if unit.Spec.Withdrawals[0].Waiver != nil { + t.Fatal("old waiver attached to recreated claim") + } + if _, err := (&RecordWithdrawalWaiverReconciler{Client: c}).Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(waiver)}); err != nil { + t.Fatal(err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(waiver), waiver); err != nil { + t.Fatal(err) + } + assertCondition(t, waiver.Status.Conditions, "Accepted", metav1.ConditionFalse, "InvalidWithdrawal") + // CEL admits retire-only intent but never an empty unprotected ERS. + ers := &endpointv1alpha1.EndpointRecordSet{ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "retire-only"}, Spec: endpointv1alpha1.EndpointRecordSetSpec{RetiredTargets: []endpointv1alpha1.RetiredTarget{{ID: "capture", SourceGeneration: 1, Address: "192.0.2.1", Hostnames: []string{"www.example.com"}}}}} + if err := c.Create(ctx, ers); err != nil { + t.Fatal(err) + } + ers.Spec.RetiredTargets = nil + if err := c.Update(ctx, ers); !apierrors.IsInvalid(err) { + t.Fatalf("empty ERS admitted: %v", err) + } +} diff --git a/internal/go/core/controllers/zoneunit/zoneunit_controller.go b/internal/go/core/controllers/zoneunit/zoneunit_controller.go index 47c4047..cc42333 100644 --- a/internal/go/core/controllers/zoneunit/zoneunit_controller.go +++ b/internal/go/core/controllers/zoneunit/zoneunit_controller.go @@ -29,7 +29,7 @@ const ( recordSetNotAcceptedProgrammedMessage = "RecordSet programming is waiting for Accepted=True" ) -// ZoneUnitCompositionReconciler builds ZoneUnit desired state from Zone and RecordSet claims. +// ZoneUnitCompositionReconciler composes Zone, RecordSet and retained withdrawal claims. type ZoneUnitCompositionReconciler struct { client.Client } @@ -46,7 +46,7 @@ type ZoneUnitCompositionReconciler struct { // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zoneunits/finalizers,verbs=update // +kubebuilder:rbac:groups=dns.appthrust.io,resources=zoneunits/status,verbs=get;watch // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch -func (r *ZoneUnitCompositionReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *ZoneUnitCompositionReconciler) reconcileZone(ctx context.Context, req ctrl.Request, withdrawals []dnsv1alpha1.RecordWithdrawal) (ctrl.Result, error) { recordSets, err := r.recordSetsForZoneKey(ctx, req.NamespacedName) if err != nil { return ctrl.Result{}, err @@ -201,12 +201,16 @@ func (r *ZoneUnitCompositionReconciler) Reconcile(ctx context.Context, req ctrl. Spec: dnsv1alpha1.ZoneUnitSpec{ Provider: dnsv1alpha1.ProviderReference{Name: provider.Name, Version: storageVersion.Name}, Zone: dnsv1alpha1.ZoneUnitZoneSpec{ - Ref: dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}, - ObservedGeneration: zone.Generation, - DomainName: zone.Spec.DomainName, - ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: zoneClass.Namespace, Name: zoneClass.Name}, + Ref: dnsv1alpha1.ObjectReference{Namespace: zone.Namespace, Name: zone.Name}, + ObservedGeneration: zone.Generation, + DomainName: zone.Spec.DomainName, + ZoneClassRef: dnsv1alpha1.ObjectReference{Namespace: zoneClass.Namespace, Name: zoneClass.Name}, + ZoneClassUID: zoneClass.UID, + ZoneClassObservedGeneration: zoneClass.Generation, }, RecordSets: items, + // Preserve the independently converged withdrawal slice. + Withdrawals: existing.Spec.Withdrawals, }, } syncZoneUnitReconcileRequestAnnotation(&desired, &zone) @@ -215,7 +219,7 @@ func (r *ZoneUnitCompositionReconciler) Reconcile(ctx context.Context, req ctrl. } if !zone.DeletionTimestamp.IsZero() { - if len(recordSets) > 0 { + if len(recordSets) > 0 || len(withdrawals) > 0 { if !unitExists || existing.DeletionTimestamp.IsZero() { if err := r.applyZoneUnitDesired(ctx, &desired, !unitExists); err != nil { return ctrl.Result{}, err @@ -225,6 +229,10 @@ func (r *ZoneUnitCompositionReconciler) Reconcile(ctx context.Context, req ctrl. if unitExists { providerStatus = existing.Status.Zone } + dependencyMessage := fmt.Sprintf("%d RecordSet resources still reference this Zone; delete or move them before Zone cleanup can continue", len(recordSets)) + if len(withdrawals) > 0 { + dependencyMessage = fmt.Sprintf("%d RecordSet and %d RecordWithdrawal resources still reference this Zone; archive dependent claims before Zone cleanup can continue", len(recordSets), len(withdrawals)) + } return ctrl.Result{}, r.setZoneClaimStatusWithMessages( ctx, &zone, @@ -233,7 +241,7 @@ func (r *ZoneUnitCompositionReconciler) Reconcile(ctx context.Context, req ctrl. "Zone is accepted", metav1.ConditionFalse, "ProviderChangePending", - fmt.Sprintf("%d RecordSet resources still reference this Zone; delete or move them before Zone cleanup can continue", len(recordSets)), + dependencyMessage, providerStatus, ) } diff --git a/internal/go/core/controllers/zoneunit/zoneunit_watches.go b/internal/go/core/controllers/zoneunit/zoneunit_watches.go index 92365e2..9a018fe 100644 --- a/internal/go/core/controllers/zoneunit/zoneunit_watches.go +++ b/internal/go/core/controllers/zoneunit/zoneunit_watches.go @@ -12,10 +12,15 @@ import ( ) func (r *ZoneUnitCompositionReconciler) SetupWithManager(mgr ctrl.Manager) error { + if err := (&RecordWithdrawalWaiverReconciler{Client: r.Client}).SetupWithManager(mgr); err != nil { + return err + } return ctrl.NewControllerManagedBy(mgr). Named("zoneunit-composition"). For(&dnsv1alpha1.Zone{}). Watches(&dnsv1alpha1.RecordSet{}, handler.EnqueueRequestsFromMapFunc(r.mapRecordSetToZone)). + Watches(&dnsv1alpha1.RecordWithdrawal{}, handler.EnqueueRequestsFromMapFunc(r.mapWithdrawalToZone)). + Watches(&dnsv1alpha1.RecordWithdrawalWaiver{}, handler.EnqueueRequestsFromMapFunc(r.mapWaiverToZone)). Watches(&dnsv1alpha1.ZoneUnit{}, handler.EnqueueRequestsFromMapFunc(r.mapZoneUnitToZone)). Watches(&dnsv1alpha1.ZoneClass{}, handler.EnqueueRequestsFromMapFunc(r.mapZoneClassToZones)). Watches(&dnsv1alpha1.Provider{}, handler.EnqueueRequestsFromMapFunc(r.mapProviderToZones)). @@ -116,6 +121,13 @@ func (r *ZoneUnitCompositionReconciler) mapNamespaceToZones(ctx context.Context, zoneNamespace, zoneName := recordSetZoneKey(&recordSet) appendRequest(client.ObjectKey{Namespace: zoneNamespace, Name: zoneName}) } + var withdrawals dnsv1alpha1.RecordWithdrawalList + if err := r.List(ctx, &withdrawals, client.InNamespace(namespace.Name)); err != nil { + return requests + } + for i := range withdrawals.Items { + appendRequest(withdrawalZoneKey(&withdrawals.Items[i])) + } return requests } diff --git a/internal/go/core/declaration/policy.go b/internal/go/core/declaration/policy.go index 2fee995..0c6740e 100644 --- a/internal/go/core/declaration/policy.go +++ b/internal/go/core/declaration/policy.go @@ -38,7 +38,11 @@ func ZoneAllowedByClass(ctx context.Context, k8sClient client.Client, zoneClass } func RecordSetAllowedByZone(ctx context.Context, k8sClient client.Client, recordSet *dnsv1alpha1.RecordSet, zone *dnsv1alpha1.Zone) (bool, string) { - if recordSet.Namespace == zone.Namespace { + return recordSetAllowedByZone(ctx, k8sClient, recordSet, zone, true) +} + +func recordSetAllowedByZone(ctx context.Context, k8sClient client.Client, recordSet *dnsv1alpha1.RecordSet, zone *dnsv1alpha1.Zone, allowSameNamespace bool) (bool, string) { + if allowSameNamespace && recordSet.Namespace == zone.Namespace { return true, "" } namespaceMatched := false diff --git a/internal/go/core/declaration/withdrawal.go b/internal/go/core/declaration/withdrawal.go new file mode 100644 index 0000000..01006f3 --- /dev/null +++ b/internal/go/core/declaration/withdrawal.go @@ -0,0 +1,84 @@ +package declaration + +import ( + "context" + "fmt" + "net/netip" + "strings" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func ValidateWithdrawal(claim *dnsv1alpha1.RecordWithdrawal) error { + if len(claim.OwnerReferences) != 0 || len(claim.Finalizers) != 0 { + return fmt.Errorf("withdrawals must not have ownerReferences or finalizers") + } + if len(validation.IsDNS1123Subdomain(claim.Spec.ZoneRef.Name)) != 0 { + return fmt.Errorf("spec.zoneRef.name must be a valid object name") + } + if claim.Spec.ZoneRef.Namespace != nil && len(validation.IsDNS1123Label(*claim.Spec.ZoneRef.Namespace)) != 0 { + return fmt.Errorf("spec.zoneRef.namespace must be a valid namespace") + } + name := claim.Spec.Name + if name != "@" && name != "*" { + if strings.HasPrefix(name, "*.") { + name = strings.TrimPrefix(name, "*.") + } + if len(validation.IsDNS1123Subdomain(name)) != 0 || len(claim.Spec.Name) > 253 { + return fmt.Errorf("spec.name must be a canonical relative DNS owner") + } + } + address, err := netip.ParseAddr(claim.Spec.Address) + if err != nil || address.Zone() != "" || address.Is4In6() || address.String() != claim.Spec.Address { + return fmt.Errorf("spec.address must be a canonical unscoped and unmapped IP address") + } + if claim.Labels[dnsv1alpha1.AddressLabelKey] != dnsv1alpha1.AddressLabelValue(address) { + return fmt.Errorf("metadata.labels[%s] must match spec.address", dnsv1alpha1.AddressLabelKey) + } + return nil +} + +func ValidateWithdrawalWaiver(waiver *dnsv1alpha1.RecordWithdrawalWaiver) error { + if len(waiver.OwnerReferences) != 0 || len(waiver.Finalizers) != 0 { + return fmt.Errorf("waivers must not have ownerReferences or finalizers") + } + for _, ref := range []dnsv1alpha1.WithdrawalObjectReference{waiver.Spec.WithdrawalRef, waiver.Spec.ReservationRef} { + if len(validation.IsDNS1123Label(ref.Namespace)) != 0 || len(validation.IsDNS1123Subdomain(ref.Name)) != 0 || ref.UID == "" { + return fmt.Errorf("withdrawalRef and reservationRef require exact namespace, name and UID") + } + } + if waiver.Spec.WithdrawalRef.Namespace != waiver.Namespace { + return fmt.Errorf("spec.withdrawalRef.namespace must match waiver namespace") + } + ip, err := netip.ParseAddr(waiver.Spec.Address) + if err != nil || ip.Zone() != "" || ip.Is4In6() || ip.String() != waiver.Spec.Address { + return fmt.Errorf("spec.address must be a canonical unscoped and unmapped IP address") + } + if !waiver.Spec.AcceptStaleDNSRisk { + return fmt.Errorf("spec.acceptStaleDNSRisk must be true") + } + if strings.TrimSpace(waiver.Spec.Reason) == "" || len(waiver.Spec.Reason) > 2048 || strings.TrimSpace(waiver.Spec.TicketRef) == "" || len(waiver.Spec.TicketRef) > 1024 { + return fmt.Errorf("spec.reason and spec.ticketRef must be nonempty and bounded") + } + return nil +} + +func WithdrawalWaiverMatches(waiver *dnsv1alpha1.RecordWithdrawalWaiver, claim *dnsv1alpha1.RecordWithdrawal) bool { + return ValidateWithdrawalWaiver(waiver) == nil && waiver.DeletionTimestamp.IsZero() && waiver.UID != "" && waiver.Namespace == claim.Namespace && waiver.Spec.WithdrawalRef == (dnsv1alpha1.WithdrawalObjectReference{Namespace: claim.Namespace, Name: claim.Name, UID: claim.UID}) && waiver.Spec.ClaimSpecDigest == dnsv1alpha1.WithdrawalSpecDigest(claim.Spec) && waiver.Spec.Address == claim.Spec.Address +} + +func WithdrawalAllowedByZone(ctx context.Context, c client.Client, claim *dnsv1alpha1.RecordWithdrawal, zone *dnsv1alpha1.Zone) (bool, string) { + ip, err := netip.ParseAddr(claim.Spec.Address) + if err != nil { + return false, "Withdrawal address is invalid" + } + recordType := dnsv1alpha1.RecordTypeA + if ip.Is6() { + recordType = dnsv1alpha1.RecordTypeAAAA + } + record := dnsv1alpha1.RecordSet{ObjectMeta: metav1.ObjectMeta{Namespace: claim.Namespace}, Spec: dnsv1alpha1.RecordSetSpec{Name: claim.Spec.Name, Type: recordType}} + return recordSetAllowedByZone(ctx, c, &record, zone, !strings.HasPrefix(claim.Spec.Name, "*")) +} diff --git a/internal/go/core/webhook/validator.go b/internal/go/core/webhook/validator.go index 0fea100..937f3d8 100644 --- a/internal/go/core/webhook/validator.go +++ b/internal/go/core/webhook/validator.go @@ -66,6 +66,8 @@ func SetupCoreValidationWebhookWithManager(mgr ctrl.Manager) error { server.Register(zoneWebhookPath, &webhook.Admission{Handler: validator}) server.Register(recordSetWebhookPath, &webhook.Admission{Handler: validator}) server.Register(zoneUnitWebhookPath, &webhook.Admission{Handler: validator}) + server.Register(recordWithdrawalWebhookPath, &webhook.Admission{Handler: validator}) + server.Register(recordWithdrawalWaiverWebhookPath, &webhook.Admission{Handler: validator}) return nil } @@ -135,6 +137,10 @@ func (v *CoreValidator) Handle(ctx context.Context, req admission.Request) admis } else if err == nil { err = v.validateRecordSet(ctx, &recordSet) } + case "RecordWithdrawal": + err = v.validateWithdrawalRequest(ctx, req) + case "RecordWithdrawalWaiver": + err = v.validateWaiverRequest(req) case "ZoneUnit": var zoneUnit dnsv1alpha1.ZoneUnit err = v.decoder.Decode(req, &zoneUnit) @@ -243,6 +249,9 @@ func validateProvider(provider *dnsv1alpha1.Provider) error { if err := validateProviderConditionReasons(version.RecordSet.AdditionalConditionReasons, prefix+".recordSet.additionalConditionReasons", recordSetCoreConditionReasons()); err != nil { return err } + if err := validateProviderWithdrawal(version.RecordWithdrawal, prefix+".recordWithdrawal"); err != nil { + return err + } for index, toggle := range version.RecordSet.DisableValidations { path := fmt.Sprintf("%s.recordSet.disableValidations[%d].when", prefix, index) if err := compileObjectCELBoolRule(toggle.When, path); err != nil { @@ -752,10 +761,24 @@ func (v *CoreValidator) validateZoneUnit(ctx context.Context, zoneUnit *dnsv1alp } } } - return nil + return validateUnitWithdrawals(zoneUnit, oldZoneUnit) } func (v *CoreValidator) validateZoneUnitDelete(ctx context.Context, zoneUnit *dnsv1alpha1.ZoneUnit) error { + var claims dnsv1alpha1.RecordWithdrawalList + if err := v.List(ctx, &claims); err != nil { + return err + } + for i := range claims.Items { + claim := &claims.Items[i] + namespace := claim.Namespace + if claim.Spec.ZoneRef.Namespace != nil { + namespace = *claim.Spec.ZoneRef.Namespace + } + if namespace == zoneUnit.Spec.Zone.Ref.Namespace && claim.Spec.ZoneRef.Name == zoneUnit.Spec.Zone.Ref.Name { + return errors.New("ZoneUnit cannot be deleted while RecordWithdrawal resources reference its Zone") + } + } var zone dnsv1alpha1.Zone err := v.Client.Get(ctx, client.ObjectKey{Namespace: zoneUnit.Spec.Zone.Ref.Namespace, Name: zoneUnit.Spec.Zone.Ref.Name}, &zone) if apierrors.IsNotFound(err) { @@ -830,7 +853,7 @@ func (v *CoreValidator) validateZoneUnitStatus(ctx context.Context, zoneUnit *dn } } } - return nil + return v.validateUnitWithdrawalStatus(zoneUnit, providerVersion) } func (v *CoreValidator) zoneForRecordSet(ctx context.Context, recordSet *dnsv1alpha1.RecordSet) (*dnsv1alpha1.Zone, error) { @@ -1113,8 +1136,8 @@ func validateProviderConditionReasons(reasons []dnsv1alpha1.ProviderConditionRea seen := conditionReasonSet{} for index, reason := range reasons { itemPath := fmt.Sprintf("%s[%d]", path, index) - if !isKnownConditionType(reason.ConditionType) { - return fmt.Errorf("%s.conditionType must be Accepted or Programmed", itemPath) + if _, allowed := core[reason.ConditionType]; !allowed { + return fmt.Errorf("%s.conditionType is not allowed for this resource", itemPath) } if !isKnownConditionStatus(reason.Status) { return fmt.Errorf("%s.status must be True, False, or Unknown", itemPath) @@ -1373,10 +1396,6 @@ func recordSetCoreConditionReasons() conditionReasonSet { } } -func isKnownConditionType(conditionType dnsv1alpha1.ConditionType) bool { - return conditionType == dnsv1alpha1.ConditionAccepted || conditionType == dnsv1alpha1.ConditionProgrammed -} - func isKnownConditionStatus(status metav1.ConditionStatus) bool { return status == metav1.ConditionTrue || status == metav1.ConditionFalse || status == metav1.ConditionUnknown } diff --git a/internal/go/core/webhook/validator_test.go b/internal/go/core/webhook/validator_test.go index a1336b0..aebfc59 100644 --- a/internal/go/core/webhook/validator_test.go +++ b/internal/go/core/webhook/validator_test.go @@ -1838,6 +1838,10 @@ func TestBuiltInProviderManifestsPassValidation(t *testing.T) { if err := validateProvider(&provider); err != nil { t.Fatalf("validateProvider(%s) returned error: %v", name, err) } + capability := provider.Spec.Versions[0].RecordWithdrawal + if capability == nil || capability.Supported != (provider.Name == "external.dns.appthrust.io") { + t.Fatalf("provider %s has incorrect withdrawal capability: %#v", provider.Name, capability) + } }) } } diff --git a/internal/go/core/webhook/withdrawals.go b/internal/go/core/webhook/withdrawals.go new file mode 100644 index 0000000..bdcb92c --- /dev/null +++ b/internal/go/core/webhook/withdrawals.go @@ -0,0 +1,244 @@ +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "net/netip" + "reflect" + "regexp" + + "github.com/appthrust/dns-api/internal/go/core/declaration" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +const ( + recordWithdrawalWebhookPath = "/validate-dns-appthrust-io-v1alpha1-recordwithdrawal" + recordWithdrawalWaiverWebhookPath = "/validate-dns-appthrust-io-v1alpha1-recordwithdrawalwaiver" +) + +var withdrawalDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +func (v *CoreValidator) validateWithdrawalRequest(ctx context.Context, req admission.Request) error { + var claim dnsv1alpha1.RecordWithdrawal + if err := v.decoder.Decode(req, &claim); err != nil { + return err + } + if req.SubResource == "status" { + return v.validateWithdrawalStatus(ctx, &claim) + } + if err := declaration.ValidateWithdrawal(&claim); err != nil { + return err + } + if req.Operation == admissionv1.Update { + var old dnsv1alpha1.RecordWithdrawal + if err := json.Unmarshal(req.OldObject.Raw, &old); err != nil { + return fmt.Errorf("old RecordWithdrawal object could not be decoded: %w", err) + } + if !reflect.DeepEqual(old.Spec, claim.Spec) { + return fmt.Errorf("spec is immutable") + } + } + return nil +} + +func (v *CoreValidator) validateWaiverRequest(req admission.Request) error { + var waiver dnsv1alpha1.RecordWithdrawalWaiver + if err := v.decoder.Decode(req, &waiver); err != nil { + return err + } + if req.SubResource == "status" { + if err := validateObservedGeneration(waiver.Status.ObservedGeneration, waiver.Generation, "status.observedGeneration"); err != nil { + return err + } + if err := validateConditionObservedGenerations(waiver.Status.Conditions, waiver.Generation, "status.conditions"); err != nil { + return err + } + return validateStatusConditions(waiver.Status.Conditions, "status.conditions", withdrawalAcceptanceReasons()) + } + if err := declaration.ValidateWithdrawalWaiver(&waiver); err != nil { + return err + } + if !withdrawalDigestPattern.MatchString(waiver.Spec.ClaimSpecDigest) { + return fmt.Errorf("spec.claimSpecDigest must be a sha256 digest") + } + if req.Operation == admissionv1.Update { + var old dnsv1alpha1.RecordWithdrawalWaiver + if err := json.Unmarshal(req.OldObject.Raw, &old); err != nil { + return fmt.Errorf("old RecordWithdrawalWaiver object could not be decoded: %w", err) + } + if !reflect.DeepEqual(old.Spec, waiver.Spec) { + return fmt.Errorf("spec is immutable") + } + } + return nil +} + +func withdrawalAcceptanceReasons() conditionReasonSet { + return conditionReasonSet{dnsv1alpha1.ConditionAccepted: {metav1.ConditionTrue: {"Accepted": {}}, metav1.ConditionFalse: {"InvalidWithdrawal": {}, "NotAllowed": {}, "UnsupportedProvider": {}}}} +} + +func withdrawalConditionReasons() conditionReasonSet { + set := withdrawalAcceptanceReasons() + for _, reason := range []string{"DNSStillResolves", "NegativeTTLNotElapsed"} { + addConditionReason(set, dnsv1alpha1.ConditionWithdrawn, metav1.ConditionFalse, reason) + addConditionReason(set, dnsv1alpha1.ConditionReady, metav1.ConditionFalse, reason) + } + for _, reason := range []string{"DNSProbeFailed", "PolicyChanged"} { + addConditionReason(set, dnsv1alpha1.ConditionWithdrawn, metav1.ConditionUnknown, reason) + addConditionReason(set, dnsv1alpha1.ConditionReady, metav1.ConditionFalse, reason) + } + addConditionReason(set, dnsv1alpha1.ConditionWithdrawn, metav1.ConditionTrue, "WithdrawalVerified") + addConditionReason(set, dnsv1alpha1.ConditionReady, metav1.ConditionTrue, "WithdrawalVerified") + addConditionReason(set, dnsv1alpha1.ConditionOverrideAccepted, metav1.ConditionTrue, "OperatorAuthorizedRelease") + return set +} + +func validateWithdrawalConditions(conditions []metav1.Condition, generation int64, path string) error { + if err := validateStatusConditions(conditions, path, withdrawalConditionReasons()); err != nil { + return err + } + if err := validateConditionObservedGenerations(conditions, generation, path); err != nil { + return err + } + seen := make(map[string]bool, len(conditions)) + for _, c := range conditions { + if seen[c.Type] { + return fmt.Errorf("%s contains duplicate condition type %s", path, c.Type) + } + seen[c.Type] = true + if len(c.Message) > 2048 { + return fmt.Errorf("%s message exceeds 2048 bytes", path) + } + } + return nil +} + +func (v *CoreValidator) validateWithdrawalStatus(ctx context.Context, claim *dnsv1alpha1.RecordWithdrawal) error { + if err := validateObservedGeneration(claim.Status.ObservedGeneration, claim.Generation, "status.observedGeneration"); err != nil { + return err + } + if err := validateWithdrawalConditions(claim.Status.Conditions, claim.Generation, "status.conditions"); err != nil { + return err + } + if claim.Status.Zone != nil { + namespace := claim.Namespace + if claim.Spec.ZoneRef.Namespace != nil { + namespace = *claim.Spec.ZoneRef.Namespace + } + if claim.Status.Zone.Namespace != namespace || claim.Status.Zone.Name != claim.Spec.ZoneRef.Name || claim.Status.Zone.UID == "" { + return fmt.Errorf("status.zone must match spec.zoneRef with a nonempty UID") + } + if claim.Status.Unit != nil && (claim.Status.Unit.Namespace != namespace || claim.Status.Unit.Name != claim.Spec.ZoneRef.Name || claim.Status.Unit.UID == "") { + return fmt.Errorf("status.unit must match the resolved Zone with a nonempty UID") + } + } + if len(claim.Status.CheckedNames) > 256 || len(claim.Status.RequiredViews) > 256 { + return fmt.Errorf("withdrawal checkedNames and requiredViews must not exceed 256 items") + } + if claim.Status.Provider != nil { + if len(rawExtensionBytes(claim.Status.Provider.State)) > 0 { + return fmt.Errorf("status.provider.state is private to the ZoneUnit") + } + if len(rawExtensionBytes(claim.Status.Provider.Data)) > 0 { + if claim.Status.ProviderRef == nil { + return fmt.Errorf("status.providerRef is required for provider data") + } + _, version, err := v.resolveProviderVersion(ctx, *claim.Status.ProviderRef, false) + if err != nil { + return err + } + if version.RecordWithdrawal == nil || !version.RecordWithdrawal.Supported { + return fmt.Errorf("Provider does not support record withdrawal") + } + return validateRawExtensionBySchema(claim.Status.Provider.Data, version.RecordWithdrawal.Schemas.StatusProviderData, "status.provider.data") + } + } + return nil +} + +func validateProviderWithdrawal(capability *dnsv1alpha1.ProviderRecordWithdrawal, path string) error { + if capability == nil { + return nil + } + if err := validateExtensionOpenAPISchema(capability.Schemas.StatusProviderData, path+".schemas.statusProviderData.openAPIV3Schema"); err != nil { + return err + } + core := withdrawalAcceptanceReasons() + core[dnsv1alpha1.ConditionWithdrawn] = nil + core[dnsv1alpha1.ConditionReady] = nil + core[dnsv1alpha1.ConditionOverrideAccepted] = nil + if err := validateProviderConditionReasons(capability.AdditionalConditionReasons, path+".additionalConditionReasons", core); err != nil { + return err + } + for _, reason := range capability.AdditionalConditionReasons { + if !hasConditionReason(withdrawalConditionReasons(), reason.ConditionType, reason.Status, reason.Reason) { + return fmt.Errorf("%s condition reason %s is not part of the withdrawal contract", path, reason.Reason) + } + } + return nil +} + +func validateUnitWithdrawals(unit *dnsv1alpha1.ZoneUnit, old *dnsv1alpha1.ZoneUnit) error { + if len(unit.Spec.Withdrawals) > 256 { + return fmt.Errorf("spec.withdrawals exceeds 256 items") + } + seen := map[string]bool{} + for i, item := range unit.Spec.Withdrawals { + key := recordSetClaimKey(item.ClaimNamespace, item.ClaimName) + if item.ClaimNamespace == "" || item.ClaimName == "" || item.Claim.UID == "" || item.ClaimNamespace != item.Claim.Namespace || item.ClaimName != item.Claim.Name || item.ObservedGeneration < 1 || seen[key] { + return fmt.Errorf("spec.withdrawals[%d] has invalid or duplicate claim identity", i) + } + seen[key] = true + claim := dnsv1alpha1.RecordWithdrawal{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{dnsv1alpha1.AddressLabelKey: addressLabel(item.Address)}}, Spec: dnsv1alpha1.RecordWithdrawalSpec{ZoneRef: dnsv1alpha1.ZoneReference{Name: unit.Spec.Zone.Ref.Name}, Name: item.Name, Address: item.Address}} + if err := declaration.ValidateWithdrawal(&claim); err != nil { + return fmt.Errorf("spec.withdrawals[%d]: %w", i, err) + } + if item.Waiver != nil && (item.Waiver.Name == "" || item.Waiver.UID == "" || !withdrawalDigestPattern.MatchString(item.Waiver.Digest) || item.Waiver.ReservationRef.UID == "") { + return fmt.Errorf("spec.withdrawals[%d].waiver must include exact immutable scope", i) + } + if old != nil { + for _, previous := range old.Spec.Withdrawals { + if previous.Claim == item.Claim && (previous.Name != item.Name || previous.Address != item.Address || previous.ObservedGeneration != item.ObservedGeneration) { + return fmt.Errorf("spec.withdrawals[%d] immutable claim shape changed", i) + } + } + } + } + return nil +} + +func (v *CoreValidator) validateUnitWithdrawalStatus(unit *dnsv1alpha1.ZoneUnit, version *dnsv1alpha1.ProviderVersion) error { + if len(unit.Status.Withdrawals) > 256 { + return fmt.Errorf("status.withdrawals exceeds 256 items") + } + seen := map[string]bool{} + for i, item := range unit.Status.Withdrawals { + path := fmt.Sprintf("status.withdrawals[%d]", i) + key := recordSetClaimKey(item.ClaimNamespace, item.ClaimName) + if item.ClaimNamespace == "" || item.ClaimName == "" || item.Claim.UID == "" || item.ClaimNamespace != item.Claim.Namespace || item.ClaimName != item.Claim.Name || item.ObservedGeneration < 1 || seen[key] { + return fmt.Errorf("%s has invalid or duplicate claim identity", path) + } + seen[key] = true + if err := validateWithdrawalConditions(item.Conditions, item.ObservedGeneration, path+".conditions"); err != nil { + return err + } + if item.Provider != nil && len(rawExtensionBytes(item.Provider.Data)) > 0 { + if version.RecordWithdrawal == nil || !version.RecordWithdrawal.Supported { + return fmt.Errorf("Provider does not support record withdrawal") + } + if err := validateRawExtensionBySchema(item.Provider.Data, version.RecordWithdrawal.Schemas.StatusProviderData, path+".provider.data"); err != nil { + return err + } + } + } + return nil +} + +func addressLabel(address string) string { + ip, _ := netip.ParseAddr(address) + return dnsv1alpha1.AddressLabelValue(ip) +} diff --git a/internal/go/core/webhook/withdrawals_test.go b/internal/go/core/webhook/withdrawals_test.go new file mode 100644 index 0000000..d92dfae --- /dev/null +++ b/internal/go/core/webhook/withdrawals_test.go @@ -0,0 +1,173 @@ +package webhook + +import ( + "context" + "encoding/json" + "net/netip" + "strings" + "testing" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func validWithdrawal() *dnsv1alpha1.RecordWithdrawal { + return &dnsv1alpha1.RecordWithdrawal{TypeMeta: metav1.TypeMeta{APIVersion: dnsv1alpha1.SchemeGroupVersion.String(), Kind: "RecordWithdrawal"}, ObjectMeta: metav1.ObjectMeta{Namespace: "app", Name: "retired", UID: "claim-uid", Generation: 1, Labels: map[string]string{dnsv1alpha1.AddressLabelKey: "192.0.2.1"}}, Spec: dnsv1alpha1.RecordWithdrawalSpec{ZoneRef: dnsv1alpha1.ZoneReference{Name: "example"}, Name: "*.apps", Address: "192.0.2.1"}} +} + +func withdrawalRequest(t *testing.T, obj runtime.Object, old runtime.Object, kind, subresource string) admission.Request { + t.Helper() + data, err := json.Marshal(obj) + if err != nil { + t.Fatal(err) + } + req := admission.Request{AdmissionRequest: admissionv1.AdmissionRequest{Operation: admissionv1.Create, Kind: metav1.GroupVersionKind{Group: dnsv1alpha1.GroupName, Version: "v1alpha1", Kind: kind}, Object: runtime.RawExtension{Raw: data}, SubResource: subresource}} + if old != nil { + data, err = json.Marshal(old) + if err != nil { + t.Fatal(err) + } + req.Operation = admissionv1.Update + req.OldObject.Raw = data + } + return req +} + +func TestWithdrawalAdmissionShapeAndImmutability(t *testing.T) { + validator := newTestValidator(t) + cases := []struct { + name string + mutate func(*dnsv1alpha1.RecordWithdrawal) + want string + }{ + {"valid", func(*dnsv1alpha1.RecordWithdrawal) {}, ""}, + {"label mismatch", func(c *dnsv1alpha1.RecordWithdrawal) { c.Labels[dnsv1alpha1.AddressLabelKey] = "192.0.2.2" }, "must match spec.address"}, + {"hostname address", func(c *dnsv1alpha1.RecordWithdrawal) { c.Spec.Address = "vip.example.com" }, "canonical"}, + {"noncanonical IPv6", func(c *dnsv1alpha1.RecordWithdrawal) { c.Spec.Address = "2001:0db8::1" }, "canonical"}, + {"mapped IPv6", func(c *dnsv1alpha1.RecordWithdrawal) { c.Spec.Address = "::ffff:192.0.2.1" }, "canonical"}, + {"scoped IPv6", func(c *dnsv1alpha1.RecordWithdrawal) { c.Spec.Address = "fe80::1%eth0" }, "canonical"}, + {"IPv6", func(c *dnsv1alpha1.RecordWithdrawal) { + c.Spec.Address = "2001:db8::1" + c.Labels[dnsv1alpha1.AddressLabelKey] = dnsv1alpha1.AddressLabelValue(netip.MustParseAddr(c.Spec.Address)) + }, ""}, + {"wildcard position", func(c *dnsv1alpha1.RecordWithdrawal) { c.Spec.Name = "app.*" }, "canonical relative"}, + {"noncanonical owner", func(c *dnsv1alpha1.RecordWithdrawal) { c.Spec.Name = "APP." }, "canonical relative"}, + {"finalizer", func(c *dnsv1alpha1.RecordWithdrawal) { c.Finalizers = []string{"test"} }, "ownerReferences or finalizers"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := validWithdrawal() + tc.mutate(c) + response := validator.Handle(context.Background(), withdrawalRequest(t, c, nil, "RecordWithdrawal", "")) + if tc.want == "" { + if !response.Allowed { + t.Fatal(response.Result.Message) + } + } else if response.Allowed || !strings.Contains(response.Result.Message, tc.want) { + t.Fatalf("response=%#v want %s", response, tc.want) + } + }) + } + old := validWithdrawal() + changed := old.DeepCopy() + changed.Spec.Name = "other" + response := validator.Handle(context.Background(), withdrawalRequest(t, changed, old, "RecordWithdrawal", "")) + if response.Allowed || response.Result.Message != "spec is immutable" { + t.Fatalf("response=%#v", response) + } +} + +func validWaiver() *dnsv1alpha1.RecordWithdrawalWaiver { + claim := validWithdrawal() + return &dnsv1alpha1.RecordWithdrawalWaiver{TypeMeta: metav1.TypeMeta{APIVersion: dnsv1alpha1.SchemeGroupVersion.String(), Kind: "RecordWithdrawalWaiver"}, ObjectMeta: metav1.ObjectMeta{Namespace: claim.Namespace, Name: "override", UID: "waiver-uid", Generation: 1}, Spec: dnsv1alpha1.RecordWithdrawalWaiverSpec{WithdrawalRef: dnsv1alpha1.WithdrawalObjectReference{Namespace: claim.Namespace, Name: claim.Name, UID: claim.UID}, ClaimSpecDigest: dnsv1alpha1.WithdrawalSpecDigest(claim.Spec), Address: claim.Spec.Address, ReservationRef: dnsv1alpha1.WithdrawalObjectReference{Namespace: "mgmt", Name: "reservation", UID: "reservation-uid"}, Reason: "operator accepted risk", TicketRef: "change-123", AcceptStaleDNSRisk: true}} +} + +func TestWithdrawalWaiverAdmission(t *testing.T) { + validator := newTestValidator(t) + for _, tc := range []struct { + name string + mutate func(*dnsv1alpha1.RecordWithdrawalWaiver) + want string + }{ + {"valid", func(*dnsv1alpha1.RecordWithdrawalWaiver) {}, ""}, + {"missing UID", func(w *dnsv1alpha1.RecordWithdrawalWaiver) { w.Spec.ReservationRef.UID = "" }, "exact namespace, name and UID"}, + {"cross namespace", func(w *dnsv1alpha1.RecordWithdrawalWaiver) { w.Spec.WithdrawalRef.Namespace = "tenant" }, "must match waiver namespace"}, + {"missing acceptance", func(w *dnsv1alpha1.RecordWithdrawalWaiver) { w.Spec.AcceptStaleDNSRisk = false }, "must be true"}, + {"invalid digest", func(w *dnsv1alpha1.RecordWithdrawalWaiver) { w.Spec.ClaimSpecDigest = "bad" }, "sha256 digest"}, + } { + t.Run(tc.name, func(t *testing.T) { + w := validWaiver() + tc.mutate(w) + response := validator.Handle(context.Background(), withdrawalRequest(t, w, nil, "RecordWithdrawalWaiver", "")) + if tc.want == "" { + if !response.Allowed { + t.Fatal(response.Result.Message) + } + } else if response.Allowed || !strings.Contains(response.Result.Message, tc.want) { + t.Fatalf("response=%#v", response) + } + }) + } + old := validWaiver() + changed := old.DeepCopy() + changed.Spec.ReservationRef.UID = "recreated" + response := validator.Handle(context.Background(), withdrawalRequest(t, changed, old, "RecordWithdrawalWaiver", "")) + if response.Allowed || response.Result.Message != "spec is immutable" { + t.Fatalf("response=%#v", response) + } +} + +func TestWithdrawalStatusReasonsAndGeneration(t *testing.T) { + validator := newTestValidator(t) + for condition, statuses := range withdrawalConditionReasons() { + for status, reasons := range statuses { + for reason := range reasons { + t.Run(string(condition)+"/"+reason, func(t *testing.T) { + c := validWithdrawal() + c.Status.ObservedGeneration = 1 + c.Status.Conditions = []metav1.Condition{{Type: string(condition), Status: status, Reason: reason, Message: "provider message", ObservedGeneration: 1}} + response := validator.Handle(context.Background(), withdrawalRequest(t, c, nil, "RecordWithdrawal", "status")) + if !response.Allowed { + t.Fatal(response.Result.Message) + } + }) + } + } + } + for _, tc := range []struct { + name string + generation int64 + reason, message string + }{{"future", 2, "WithdrawalVerified", "ok"}, {"invalid reason", 1, "TimerElapsed", "ok"}, {"oversized message", 1, "WithdrawalVerified", strings.Repeat("x", 2049)}} { + t.Run(tc.name, func(t *testing.T) { + c := validWithdrawal() + c.Status.Conditions = []metav1.Condition{{Type: "Withdrawn", Status: metav1.ConditionTrue, Reason: tc.reason, Message: tc.message, ObservedGeneration: tc.generation}} + if response := validator.Handle(context.Background(), withdrawalRequest(t, c, nil, "RecordWithdrawal", "status")); response.Allowed { + t.Fatal("invalid status admitted") + } + }) + } +} + +func TestWithdrawalBlocksUnitDeletionEvenWhenZoneDeleting(t *testing.T) { + claim := validWithdrawal() + claim.Spec.ZoneRef.Name = "example-com" + unit := validZoneUnit() + claim.Namespace = unit.Spec.Zone.Ref.Namespace + claim.Spec.ZoneRef.Name = unit.Spec.Zone.Ref.Name + validator := newTestValidator(t, claim) + if err := validator.validateZoneUnitDelete(context.Background(), unit); err == nil || !strings.Contains(err.Error(), "RecordWithdrawal") { + t.Fatalf("delete error=%v", err) + } +} + +func TestWithdrawalProviderReasonsRemainResourceScoped(t *testing.T) { + provider := route53Provider(t) + provider.Spec.Versions[0].Zone.AdditionalConditionReasons = []dnsv1alpha1.ProviderConditionReason{{ConditionType: dnsv1alpha1.ConditionReady, Status: metav1.ConditionTrue, Reason: "WithdrawalVerified", Description: "Wrong resource."}} + if err := validateProvider(provider); err == nil || !strings.Contains(err.Error(), "conditionType") { + t.Fatalf("withdrawal condition leaked into Zone declarations: %v", err) + } +} diff --git a/internal/go/providers/external/controllers/zoneunit/controller.go b/internal/go/providers/external/controllers/zoneunit/controller.go index 2672fee..b3bfc9b 100644 --- a/internal/go/providers/external/controllers/zoneunit/controller.go +++ b/internal/go/providers/external/controllers/zoneunit/controller.go @@ -135,6 +135,9 @@ func (r *ZoneReconciler) observe(ctx context.Context, c client.Client, unit *dns for _, item := range unit.Spec.RecordSets { namespaceNames = append(namespaceNames, item.RecordSetNamespace) } + for _, item := range unit.Spec.Withdrawals { + namespaceNames = append(namespaceNames, item.ClaimNamespace) + } slices.Sort(namespaceNames) namespaceNames = slices.Compact(namespaceNames) namespacePolicies := make(map[string]map[string]string, len(namespaceNames)) @@ -163,6 +166,7 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c if err := r.Get(ctx, request.NamespacedName, &unit); err != nil { if apierrors.IsNotFound(err) { probe.SetPending(request.NamespacedName.String(), nil) + probe.SetWithdrawalsPending(request.NamespacedName.String(), nil) } return ctrl.Result{}, client.IgnoreNotFound(err) } @@ -184,7 +188,10 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c unit.Status.RecordSets[i].DeletionCompleted = unit.Spec.RecordSets[i].RecordSetUID != "" } probe.SetPending(request.NamespacedName.String(), nil) - return ctrl.Result{}, r.patch(ctx, base, &unit) + if len(unit.Spec.Withdrawals) == 0 { + probe.SetWithdrawalsPending(request.NamespacedName.String(), nil) + return ctrl.Result{}, r.patch(ctx, base, &unit) + } } if rejected != nil { r.reject(&unit, *rejected) @@ -193,6 +200,7 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c now := r.Options.Clock() state := loadState(&unit, env.fingerprint) groups := groupNames(&unit, env.fingerprint) + withdrawalsChanged := prepareWithdrawals(&unit, &state, env, now) active := make(map[string]bool, len(groups)) for _, group := range groups { active[group.Name] = true @@ -222,11 +230,17 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c } } due := selectDue(groups, state, now) + withdrawalDue := selectWithdrawalDue(&unit, state, env.fingerprint, now, MaxNamesPerPass) + if len(due) != 0 && len(withdrawalDue) != 0 { + // Reserve half of a busy pass for each class of work; both have fair cursors. + withdrawalDue = selectWithdrawalDue(&unit, state, env.fingerprint, now, MaxNamesPerPass/2) + due = due[:min(len(due), MaxNamesPerPass-withdrawalProbeCost(&unit, state, withdrawalDue))] + } zoneCondition := meta.FindStatusCondition(unit.Status.Zone.Conditions, "Programmed") zoneDue := state.Zone.Fingerprint != env.fingerprint || !state.Zone.Next.After(now) || zoneCondition == nil || zoneCondition.ObservedGeneration != unit.Spec.Zone.ObservedGeneration var evidence *probe.ZoneEvidence zoneDecision := currentDecision(unit.Status.Zone.Conditions) - zoneProbed := zoneDue || (len(due) != 0 && zoneDecision.Status == metav1.ConditionTrue) + zoneProbed := zoneDue || ((len(due) != 0 || len(withdrawalDue) != 0) && zoneDecision.Status == metav1.ConditionTrue) if zoneProbed { zoneRequest := probe.ProbeRequest{Zone: unit.Spec.Zone.DomainName, Name: unit.Spec.Zone.DomainName, Policy: env.policy, ZoneOnly: true} switch { @@ -267,6 +281,7 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c probeRequest := probe.ProbeRequest{Name: group.CheckedName, Zone: unit.Spec.Zone.DomainName, Expected: group.Expected, Policy: env.policy, Evidence: evidence, DueTime: dueTime} observed, probeErr := r.Options.Resolver.Probe(ctx, probeRequest) decision = probe.Evaluate(probeRequest, observed) + retainPublication(&state, group, probeRequest, observed, r.Options.Clock()) if probeErr != nil { decision = probe.Failed(group.CheckedName, "required-views", failureCode(probeErr)) } @@ -292,13 +307,24 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c } } } + r.probeWithdrawals(ctx, &unit, &state, env, evidence, zoneDecision, withdrawalDue) + if zoneProbed && zoneDecision.Status != metav1.ConditionTrue { + invalidateWithdrawalZone(&unit, &state, r.Options.Clock()) + withdrawalsChanged = true + } + for name, publication := range state.Publications { + if !active[name] && !publicationUnexpired(publication, r.Options.Clock()) { + delete(state.Publications, name) + prunedSchedule = true + } + } // DNS I/O is an asynchronous observation boundary. Live inputs must still agree. reader := readerClient{Client: r.Client, reader: r.Reader} var current dnsv1alpha1.ZoneUnit if err := reader.Get(ctx, request.NamespacedName, ¤t); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - if current.UID != unit.UID || current.Generation != unit.Generation || !equality.Semantic.DeepEqual(current.Spec, unit.Spec) || !current.DeletionTimestamp.IsZero() { + if current.UID != unit.UID || current.Generation != unit.Generation || !equality.Semantic.DeepEqual(current.Spec, unit.Spec) || !current.DeletionTimestamp.Equal(unit.DeletionTimestamp) { return ctrl.Result{RequeueAfter: time.Second}, nil } fresh, blocked, err := r.observe(ctx, reader, ¤t) @@ -311,7 +337,7 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c // Preserve the stored RawExtension unless scheduling actually changed. // Re-encoding unchanged state can reorder API-normalized JSON keys and // make the status equality guard issue an otherwise empty patch. - if zoneProbed || len(due) != 0 || prunedSchedule { + if zoneProbed || len(due) != 0 || len(withdrawalDue) != 0 || prunedSchedule || withdrawalsChanged { stateBytes, err := json.Marshal(state) if err != nil { return ctrl.Result{}, err @@ -320,7 +346,8 @@ func (r *ZoneReconciler) Reconcile(ctx context.Context, request ctrl.Request) (c } setAggregate(&unit, r.Options.Clock()) r.updatePending(&unit) - return ctrl.Result{RequeueAfter: nextRequeue(groups, state, r.Options.Clock())}, r.patch(ctx, base, &unit) + next := min(nextRequeue(groups, state, r.Options.Clock()), nextWithdrawalRequeue(&unit, state, env.fingerprint, r.Options.Clock())) + return ctrl.Result{RequeueAfter: next}, r.patch(ctx, base, &unit) } func validateGroup(unit *dnsv1alpha1.ZoneUnit, group nameGroup) (bool, string, string) { diff --git a/internal/go/providers/external/controllers/zoneunit/model.go b/internal/go/providers/external/controllers/zoneunit/model.go index a0a81eb..12672e0 100644 --- a/internal/go/providers/external/controllers/zoneunit/model.go +++ b/internal/go/providers/external/controllers/zoneunit/model.go @@ -20,10 +20,13 @@ type schedule struct { } type schedulerState struct { - Policy string `json:"policy"` - Cursor string `json:"cursor,omitempty"` - Zone schedule `json:"zone"` - Names map[string]schedule `json:"names"` + Policy string `json:"policy"` + Cursor string `json:"cursor,omitempty"` + Zone schedule `json:"zone"` + Names map[string]schedule `json:"names"` + Withdrawals map[string]withdrawalState `json:"withdrawals,omitempty"` + WithdrawalCursor string `json:"withdrawalCursor,omitempty"` + Publications map[string]publicationEvidence `json:"publications,omitempty"` } type nameGroup struct { @@ -38,7 +41,7 @@ type nameGroup struct { func groupNames(unit *dnsv1alpha1.ZoneUnit, policy string) []nameGroup { byName := map[string][]int{} for i, item := range unit.Spec.RecordSets { - if item.IsAllowed() && !item.DeletionRequested && item.RecordSetUID != "" { + if unit.DeletionTimestamp.IsZero() && item.IsAllowed() && !item.DeletionRequested && item.RecordSetUID != "" { byName[item.Name] = append(byName[item.Name], i) } } @@ -105,11 +108,19 @@ func loadState(unit *dnsv1alpha1.ZoneUnit, policy string) schedulerState { _ = json.Unmarshal(unit.Status.Provider.State.Raw, &state) } if state.Policy != policy { - state = schedulerState{Policy: policy} + state.Policy = policy + state.Zone = schedule{} + state.Names = nil } if state.Names == nil { state.Names = map[string]schedule{} } + if state.Withdrawals == nil { + state.Withdrawals = map[string]withdrawalState{} + } + if state.Publications == nil { + state.Publications = map[string]publicationEvidence{} + } return state } diff --git a/internal/go/providers/external/controllers/zoneunit/status.go b/internal/go/providers/external/controllers/zoneunit/status.go index 8c0de7c..21f129e 100644 --- a/internal/go/providers/external/controllers/zoneunit/status.go +++ b/internal/go/providers/external/controllers/zoneunit/status.go @@ -124,7 +124,6 @@ func setAggregate(unit *dnsv1alpha1.ZoneUnit, now time.Time) { func (r *ZoneReconciler) reject(unit *dnsv1alpha1.ZoneUnit, rejected acceptance) { now := r.Options.Clock() - unit.Status.Provider = nil unit.Status.Zone.Provider = nil unit.Status.Zone.NameServers = nil setCondition(&unit.Status.Zone.Conditions, "Accepted", rejected.Status, rejected.Reason, rejected.Message, unit.Spec.Zone.ObservedGeneration, now) @@ -136,6 +135,7 @@ func (r *ZoneReconciler) reject(unit *dnsv1alpha1.ZoneUnit, rejected acceptance) setCondition(&unit.Status.RecordSets[i].Conditions, "Accepted", metav1.ConditionFalse, "ZoneNotAccepted", rejected.Message, unit.Status.RecordSets[i].ObservedGeneration, now) resetProgrammed(&unit.Status.RecordSets[i], now) } + rejectWithdrawals(unit, now) setAggregate(unit, now) r.updatePending(unit) } @@ -153,4 +153,16 @@ func (r *ZoneReconciler) updatePending(unit *dnsv1alpha1.ZoneUnit) { } } probe.SetPending(unit.Namespace+"/"+unit.Name, counts) + withdrawals := map[string]int{} + for _, status := range unit.Status.Withdrawals { + condition := meta.FindStatusCondition(status.Conditions, "Withdrawn") + if accepted := meta.FindStatusCondition(status.Conditions, "Accepted"); accepted != nil && accepted.Status != metav1.ConditionTrue { + withdrawals[accepted.Reason]++ + } else if condition == nil { + withdrawals["DNSProbeFailed"]++ + } else if condition.Status != metav1.ConditionTrue { + withdrawals[condition.Reason]++ + } + } + probe.SetWithdrawalsPending(unit.Namespace+"/"+unit.Name, withdrawals) } diff --git a/internal/go/providers/external/controllers/zoneunit/watches.go b/internal/go/providers/external/controllers/zoneunit/watches.go index 288941b..e856ab2 100644 --- a/internal/go/providers/external/controllers/zoneunit/watches.go +++ b/internal/go/providers/external/controllers/zoneunit/watches.go @@ -41,6 +41,11 @@ func (r *ZoneReconciler) SetupWithManager(mgr ctrl.Manager) error { return true } } + for _, item := range unit.Spec.Withdrawals { + if item.ClaimNamespace == obj.GetName() { + return true + } + } return false }) }), builder.WithPredicates(predicate.LabelChangedPredicate{})). diff --git a/internal/go/providers/external/controllers/zoneunit/withdrawal.go b/internal/go/providers/external/controllers/zoneunit/withdrawal.go new file mode 100644 index 0000000..2fbff2a --- /dev/null +++ b/internal/go/providers/external/controllers/zoneunit/withdrawal.go @@ -0,0 +1,318 @@ +package zoneunit + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "slices" + "strings" + "time" + + "github.com/appthrust/dns-api/internal/go/providers/external/probe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func prepareWithdrawals(unit *dnsv1alpha1.ZoneUnit, state *schedulerState, env *environment, now time.Time) bool { + old := make(map[string]dnsv1alpha1.ZoneUnitWithdrawalStatus, len(unit.Status.Withdrawals)) + for _, status := range unit.Status.Withdrawals { + old[status.ClaimNamespace+"/"+status.ClaimName] = status + } + var statuses []dnsv1alpha1.ZoneUnitWithdrawalStatus + active := make(map[string]bool, len(unit.Spec.Withdrawals)) + changed := false + for _, item := range unit.Spec.Withdrawals { + key := withdrawalKey(item) + active[key] = true + status := old[key] + if status.Claim != item.Claim || status.ObservedGeneration != item.ObservedGeneration { + status = dnsv1alpha1.ZoneUnitWithdrawalStatus{} + } + status.ClaimNamespace, status.ClaimName, status.Claim, status.ObservedGeneration = item.ClaimNamespace, item.ClaimName, item.Claim, item.ObservedGeneration + stored, exists := state.Withdrawals[key] + identity := withdrawalIdentity(unit, item) + if !exists || stored.Identity != identity { + publication := state.Publications[item.Name] + stored = withdrawalState{Identity: identity, CheckedNames: withdrawalNames(unit, item, publication), MissingHistory: status.LastProbeTime != nil} + if positive := publication.Positive[item.Address]; len(positive) != 0 { + stored.Positive = make(map[string]time.Time, len(positive)) + for key, expires := range positive { + stored.Positive[key] = expires + } + } + invalidateWithdrawal(&status, &stored) + changed = true + } + status.CheckedNames = slices.Clone(stored.CheckedNames) + status.SampledWildcard = item.Name == "*" || strings.HasPrefix(item.Name, "*.") + decision := probe.WithdrawalFailed(item.Name, "required-views", "invalid-response").Decision + switch { + case !item.Allowed || !validWithdrawal(unit, item): + invalidateWithdrawal(&status, &stored) + reason := "InvalidWithdrawal" + if !item.Allowed { + reason = "NotAllowed" + } + setCondition(&status.Conditions, "Accepted", metav1.ConditionFalse, reason, "External DNS withdrawal intent is not accepted.", item.ObservedGeneration, now) + setWithdrawalConditions(&status, decision, now) + status.WaiverRef = nil + meta.RemoveStatusCondition(&status.Conditions, "OverrideAccepted") + default: + if env != nil { + setCondition(&status.Conditions, "Accepted", metav1.ConditionTrue, "Accepted", "External DNS withdrawal intent is accepted.", item.ObservedGeneration, now) + } + if meta.FindStatusCondition(status.Conditions, "Withdrawn") == nil { + setWithdrawalConditions(&status, decision, now) + } + if env != nil && stored.Policy != "" && stored.Policy != env.fingerprint { + invalidateWithdrawal(&status, &stored) + setWithdrawalConditions(&status, policyChanged(item.Name), now) + changed = true + } + if status.LastProbeTime != nil && (now.Before(status.LastProbeTime.Time) || status.NextProbeTime == nil || now.After(status.NextProbeTime.Add(300*time.Second))) { + invalidateWithdrawal(&status, &stored) + stored.Schedule.Next = time.Time{} + setWithdrawalConditions(&status, decision, now) + changed = true + } + bindWithdrawalWaiver(&status, item, now) + if status.ResultDigest != "" { + status.ResultDigest = withdrawalResultDigest(unit, item, status) + } + } + state.Withdrawals[key] = stored + statuses = append(statuses, status) + } + for key := range state.Withdrawals { + if !active[key] { + delete(state.Withdrawals, key) + changed = true + } + } + unit.Status.Withdrawals = statuses + return changed +} + +func selectWithdrawalDue(unit *dnsv1alpha1.ZoneUnit, state schedulerState, policy string, now time.Time, budget int) []int { + indices := make([]int, 0, len(unit.Spec.Withdrawals)) + for i, item := range unit.Spec.Withdrawals { + if item.Allowed && validWithdrawal(unit, item) { + indices = append(indices, i) + } + } + slices.SortFunc(indices, func(a, b int) int { + return strings.Compare(withdrawalKey(unit.Spec.Withdrawals[a]), withdrawalKey(unit.Spec.Withdrawals[b])) + }) + start := 0 + for start < len(indices) && withdrawalKey(unit.Spec.Withdrawals[indices[start]]) <= state.WithdrawalCursor { + start++ + } + var due []int + for offset := range len(indices) { + index := indices[(start+offset)%len(indices)] + stored := state.Withdrawals[withdrawalKey(unit.Spec.Withdrawals[index])] + if stored.Policy == policy && stored.Schedule.Next.After(now) { + continue + } + cost := len(stored.CheckedNames) + if cost > budget { + break + } + budget -= cost + due = append(due, index) + } + return due +} + +func withdrawalProbeCost(unit *dnsv1alpha1.ZoneUnit, state schedulerState, indices []int) int { + cost := 0 + for _, i := range indices { + cost += len(state.Withdrawals[withdrawalKey(unit.Spec.Withdrawals[i])].CheckedNames) + } + return cost +} + +func (r *ZoneReconciler) probeWithdrawals(ctx context.Context, unit *dnsv1alpha1.ZoneUnit, state *schedulerState, env *environment, evidence *probe.ZoneEvidence, zoneDecision probe.Decision, due []int) { + for _, index := range due { + item := unit.Spec.Withdrawals[index] + key := withdrawalKey(item) + stored := state.Withdrawals[key] + status := &unit.Status.Withdrawals[index] + started := r.Options.Clock() + views := requiredWithdrawalViews(env.policy, evidence) + decision := probe.WithdrawalDecision{Decision: probe.Decision{Status: metav1.ConditionTrue, Reason: "WithdrawalVerified"}} + if zoneDecision.Status != metav1.ConditionTrue { + decision = probe.WithdrawalFailed(item.Name, "required-authorities", "invalid-response") + if env.policyError != nil { + decision = probe.WithdrawalFailed(item.Name, "configured-resolvers", "resolver-config") + } + } else { + for _, name := range stored.CheckedNames { + request := probe.ProbeRequest{Name: name, Zone: unit.Spec.Zone.DomainName, Policy: env.policy, Evidence: evidence, DueTime: stored.Schedule.Next, WithdrawalAddress: item.Address} + observed, err := r.Options.Resolver.Probe(ctx, request) + one := probe.EvaluateWithdrawal(request, observed) + retainPositive(&stored, name, one, r.Options.Clock()) + if err != nil { + one = probe.WithdrawalFailed(name, "required-views", failureCode(err)) + } + decision.Cache = append(decision.Cache, one.Cache...) + if one.Status == metav1.ConditionUnknown || (one.Status == metav1.ConditionFalse && decision.Status == metav1.ConditionTrue) { + decision.Decision = one.Decision + } + } + } + now := r.Options.Clock() + if now.Before(started) || now.Sub(started) > 300*time.Second { + decision.Decision = probe.WithdrawalFailed(item.Name, "required-views", "timeout").Decision + } + if status.LastProbeTime != nil && (now.Before(status.LastProbeTime.Time) || status.NextProbeTime == nil || now.After(status.NextProbeTime.Add(300*time.Second))) { + invalidateWithdrawal(status, &stored) + } + fingerprint := probe.Fingerprint(struct { + Policy string + Views []string + }{env.fingerprint, views}) + changed := stored.Policy != "" && (stored.Policy != env.fingerprint || !slices.Equal(status.RequiredViews, views)) + weakened := policyWeakened(stored, env.policy, views) + if changed || weakened { + invalidateWithdrawal(status, &stored) + decision.Decision = policyChanged(item.Name) + } + // A failed authority discovery cannot lower the persisted policy floor. + if zoneDecision.Status == metav1.ConditionTrue && !weakened { + addFloor(&stored, env.policy, views) + } + stored.Policy = env.fingerprint + status.PolicyFingerprint = fingerprint + status.RequiredViews = views + result := withdrawalWindow(status, &stored, decision, now) + if result.Reason == "NegativeTTLNotElapsed" { + result.Message = fmt.Sprintf("Retired address %s is absent in every required view; waiting until %s for cache safety and a confirming probe.", item.Address, status.NotBefore.UTC().Format(time.RFC3339)) + } + if result.Reason == "WithdrawalVerified" { + result.Message = fmt.Sprintf("Retired address %s was verified absent from %s in every required view at %s.", item.Address, item.Name, now.UTC().Format(time.RFC3339)) + } + stored.Schedule = advance(stored.Schedule, stored.Identity+fingerprint, result, now, r.Options.Jitter()) + status.LastProbeTime, status.NextProbeTime = timePointer(now), timePointer(stored.Schedule.Next) + setWithdrawalConditions(status, result, now) + data, _ := json.Marshal(struct { + Views externalv1alpha1.VerificationValues `json:"views"` + }{Views: probe.Preview(views)}) + status.Provider = &dnsv1alpha1.ProviderStatus{Data: runtime.RawExtension{Raw: data}} + bindWithdrawalWaiver(status, item, now) + status.ResultDigest = withdrawalResultDigest(unit, item, *status) + state.Withdrawals[key] = stored + state.WithdrawalCursor = key + } +} + +func policyChanged(name string) probe.Decision { + return probe.Decision{Status: metav1.ConditionUnknown, Reason: "PolicyChanged", Message: fmt.Sprintf("External DNS withdrawal policy changed for %s; the prior required view policy remains a floor.", name)} +} + +func setWithdrawalConditions(status *dnsv1alpha1.ZoneUnitWithdrawalStatus, decision probe.Decision, now time.Time) { + setCondition(&status.Conditions, "Withdrawn", decision.Status, decision.Reason, decision.Message, status.ObservedGeneration, now) + ready := metav1.ConditionFalse + if decision.Status == metav1.ConditionTrue { + ready = metav1.ConditionTrue + } + setCondition(&status.Conditions, "Ready", ready, decision.Reason, decision.Message, status.ObservedGeneration, now) +} + +func bindWithdrawalWaiver(status *dnsv1alpha1.ZoneUnitWithdrawalStatus, item dnsv1alpha1.ZoneUnitWithdrawal, now time.Time) { + status.WaiverRef = nil + waiver := item.Waiver + if waiver == nil || waiver.Name == "" || waiver.UID == "" || waiver.Digest == "" || waiver.ReservationRef.UID == "" { + meta.RemoveStatusCondition(&status.Conditions, "OverrideAccepted") + return + } + status.WaiverRef = &dnsv1alpha1.RecordWithdrawalWaiverReference{Name: waiver.Name, UID: waiver.UID, Digest: waiver.Digest, ReservationRef: waiver.ReservationRef} + setCondition(&status.Conditions, "OverrideAccepted", metav1.ConditionTrue, "OperatorAuthorizedRelease", "Core-validated withdrawal waiver is bound to its exact UID and immutable scope.", item.ObservedGeneration, now) +} + +func withdrawalResultDigest(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitWithdrawal, status dnsv1alpha1.ZoneUnitWithdrawalStatus) string { + status.ResultDigest = "" + status.Provider = nil // Bounded diagnostic previews are never decision inputs. + // Transition timestamps do not alter the observation/result identity. + status.Conditions = slices.Clone(status.Conditions) + for i := range status.Conditions { + status.Conditions[i].LastTransitionTime = metav1.Time{} + } + slices.SortFunc(status.Conditions, func(a, b metav1.Condition) int { return strings.Compare(a.Type, b.Type) }) + return "sha256:" + probe.Fingerprint(struct { + Identity string + Provider dnsv1alpha1.ProviderReference + Zone dnsv1alpha1.ZoneUnitZoneSpec + Status dnsv1alpha1.ZoneUnitWithdrawalStatus + }{withdrawalIdentity(unit, item), unit.Spec.Provider, unit.Spec.Zone, status}) +} + +func nextWithdrawalRequeue(unit *dnsv1alpha1.ZoneUnit, state schedulerState, policy string, now time.Time) time.Duration { + next := 300 * time.Second + for _, item := range unit.Spec.Withdrawals { + if !item.Allowed || !validWithdrawal(unit, item) { + continue + } + stored := state.Withdrawals[withdrawalKey(item)] + if stored.Policy != policy || !stored.Schedule.Next.After(now) { + return time.Second + } + next = min(next, stored.Schedule.Next.Sub(now)) + } + return max(time.Second, next) +} + +func invalidateWithdrawalZone(unit *dnsv1alpha1.ZoneUnit, state *schedulerState, now time.Time) { + for i, item := range unit.Spec.Withdrawals { + if !item.Allowed { + continue + } + status := &unit.Status.Withdrawals[i] + stored := state.Withdrawals[withdrawalKey(item)] + invalidateWithdrawal(status, &stored) + if condition := meta.FindStatusCondition(status.Conditions, "Withdrawn"); condition == nil || condition.Reason != "PolicyChanged" { + setWithdrawalConditions(status, probe.WithdrawalFailed(item.Name, "required-authorities", "invalid-response").Decision, now) + } + state.Withdrawals[withdrawalKey(item)] = stored + } +} + +func rejectWithdrawals(unit *dnsv1alpha1.ZoneUnit, now time.Time) { + var prior schedulerState + if unit.Status.Provider != nil { + _ = json.Unmarshal(unit.Status.Provider.State.Raw, &prior) + } + state := loadState(unit, prior.Policy) + // Invalidate reconstructible active schedules, but never discard retained + // publication horizons or a withdrawal's prior policy floor on rejection. + state.Zone = schedule{} + state.Names = map[string]schedule{} + prepareWithdrawals(unit, &state, nil, now) + for i, item := range unit.Spec.Withdrawals { + status := &unit.Status.Withdrawals[i] + stored := state.Withdrawals[withdrawalKey(item)] + invalidateWithdrawal(status, &stored) + stored.Schedule.Next = time.Time{} + state.Withdrawals[withdrawalKey(item)] = stored + setWithdrawalConditions(status, probe.WithdrawalFailed(item.Name, "binding", "invalid-response").Decision, now) + setCondition(&status.Conditions, "Accepted", metav1.ConditionFalse, "InvalidWithdrawal", "External DNS withdrawal binding is not accepted.", item.ObservedGeneration, now) + status.WaiverRef = nil + meta.RemoveStatusCondition(&status.Conditions, "OverrideAccepted") + } + // These maps are omitted from private JSON when empty. Normalize them before + // comparing the decoded state, which contains native (not metav1) times. + if len(state.Publications) == 0 { + state.Publications = nil + } + if len(state.Withdrawals) == 0 { + state.Withdrawals = nil + } + if (unit.Status.Provider != nil || len(unit.Spec.Withdrawals) != 0) && !reflect.DeepEqual(prior, state) { + raw, _ := json.Marshal(state) + unit.Status.Provider = &dnsv1alpha1.ProviderStatus{State: runtime.RawExtension{Raw: raw}} + } +} diff --git a/internal/go/providers/external/controllers/zoneunit/withdrawal_model.go b/internal/go/providers/external/controllers/zoneunit/withdrawal_model.go new file mode 100644 index 0000000..197ee0d --- /dev/null +++ b/internal/go/providers/external/controllers/zoneunit/withdrawal_model.go @@ -0,0 +1,257 @@ +package zoneunit + +import ( + "net/netip" + "slices" + "strings" + "time" + + "github.com/appthrust/dns-api/internal/go/providers/external/config" + "github.com/appthrust/dns-api/internal/go/providers/external/probe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// The independent claims are the obligation inventory. This private state only +// retains cache bounds, policy floors and witnesses for those current claims. +type withdrawalState struct { + Identity string `json:"identity"` + Policy string `json:"policy"` + Floor externalv1alpha1.ExternalZoneClassParameters `json:"floor"` + Views []string `json:"views,omitempty"` + CheckedNames []string `json:"checkedNames"` + Positive map[string]time.Time `json:"positive,omitempty"` + Negative map[string]uint32 `json:"negative,omitempty"` + // Preserve subsecond t0: metav1.Time's wire encoding has only second precision. + WindowStart time.Time `json:"windowStart,omitempty"` + Schedule schedule `json:"schedule"` + MissingHistory bool `json:"missingHistory,omitempty"` +} + +type publicationEvidence struct { + Witnesses map[string]string `json:"witnesses,omitempty"` + Positive map[string]map[string]time.Time `json:"positive,omitempty"` +} + +func withdrawalKey(item dnsv1alpha1.ZoneUnitWithdrawal) string { + return item.ClaimNamespace + "/" + item.ClaimName +} + +func withdrawalIdentity(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitWithdrawal) string { + return probe.Fingerprint(struct { + UnitUID string + Zone dnsv1alpha1.ObjectReference + Claim dnsv1alpha1.WithdrawalObjectReference + Generation int64 + Name, Address string + }{string(unit.UID), unit.Spec.Zone.Ref, item.Claim, item.ObservedGeneration, item.Name, item.Address}) +} + +func withdrawalNames(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitWithdrawal, publication publicationEvidence) []string { + name := unit.Spec.Zone.DomainName + if item.Name != "@" { + name = item.Name + "." + name + } + if item.Name != "*" && !strings.HasPrefix(item.Name, "*.") { + return []string{name} + } + witness := probe.Fingerprint(struct { + ClaimUID string + Zone dnsv1alpha1.ObjectReference + UnitUID string + }{string(item.Claim.UID), unit.Spec.Zone.Ref, string(unit.UID)}) + names := []string{"dns-retire-" + witness[:24] + strings.TrimPrefix(name, "*")} + if witness := publication.Witnesses[item.Address]; witness != "" { + names = append(names, witness) + } + slices.Sort(names) + return slices.Compact(names) +} + +func validWithdrawal(unit *dnsv1alpha1.ZoneUnit, item dnsv1alpha1.ZoneUnitWithdrawal) bool { + ip, err := netip.ParseAddr(item.Address) + if err != nil || ip.Zone() != "" || ip.Unmap().String() != item.Address || item.Claim.UID == "" || item.ClaimNamespace == "" || item.ClaimName == "" || item.Claim.Namespace != item.ClaimNamespace || item.Claim.Name != item.ClaimName || item.ObservedGeneration < 1 { + return false + } + name := strings.TrimPrefix(item.Name, "*.") + if item.Name == "@" || item.Name == "*" { + name = unit.Spec.Zone.DomainName + } else { + name += "." + unit.Spec.Zone.DomainName + } + return config.ValidName(name) +} + +func cacheKey(checked string, evidence probe.WithdrawalCacheEvidence) string { + // A chain's maximum is sufficient for each checked-name/view/family bound; + // retaining terminal owner keys would grow without bound as CNAMEs change. + return checked + "/" + evidence.View + "/" + evidence.QType +} + +func retainPositive(state *withdrawalState, checked string, decision probe.WithdrawalDecision, now time.Time) { + for _, item := range decision.Cache { + if item.PositiveTTL == nil { + continue + } + if state.Positive == nil { + state.Positive = map[string]time.Time{} + } + key := cacheKey(checked, item) + expires := now.Add(time.Duration(*item.PositiveTTL) * time.Second) + if expires.After(state.Positive[key]) { + state.Positive[key] = expires + } + } +} + +func retainPublication(state *schedulerState, group nameGroup, request probe.ProbeRequest, result probe.ProbeResult, now time.Time) { + old := state.Publications[group.Name] + if old.Witnesses == nil { + old.Witnesses = map[string]string{} + } + if old.Positive == nil { + old.Positive = map[string]map[string]time.Time{} + } + for address, horizons := range old.Positive { + for key, expires := range horizons { + if !expires.After(now) { + delete(horizons, key) + } + } + if len(horizons) == 0 { + delete(old.Positive, address) + delete(old.Witnesses, address) + } + } + addresses := append(slices.Clone(group.Expected.A), group.Expected.AAAA...) + for address := range old.Positive { + addresses = append(addresses, address) + } + slices.Sort(addresses) + // Retain any renewed observation of an already-known retired address, even + // while the active publication intent has moved to its replacement. + for _, address := range slices.Compact(addresses) { + request.WithdrawalAddress = address + decision := probe.EvaluateWithdrawal(request, result) + temporary := withdrawalState{Positive: old.Positive[address]} + retainPositive(&temporary, group.CheckedName, decision, now) + if slices.ContainsFunc(decision.Cache, func(evidence probe.WithdrawalCacheEvidence) bool { return evidence.PositiveTTL != nil }) { + old.Witnesses[address] = group.CheckedName + } + if len(temporary.Positive) != 0 { + old.Positive[address] = temporary.Positive + } + } + state.Publications[group.Name] = old +} + +func publicationUnexpired(publication publicationEvidence, now time.Time) bool { + for _, horizons := range publication.Positive { + for _, expires := range horizons { + if expires.After(now) { + return true + } + } + } + return false +} + +func requiredWithdrawalViews(policy externalv1alpha1.ExternalZoneClassParameters, evidence *probe.ZoneEvidence) []string { + var views []string + for _, server := range policy.Resolver.Servers { + views = append(views, "recursive/"+server) + } + if len(policy.ExpectedNameservers) != 0 && evidence != nil { + for _, authority := range evidence.Authorities { + for _, server := range policy.Resolver.Servers { + views = append(views, "authority/"+authority+"/via/"+server) + } + } + } + slices.Sort(views) + return slices.Compact(views) +} + +func containsAll(values, floor []string) bool { + for _, value := range floor { + if !slices.Contains(values, value) { + return false + } + } + return true +} + +func policyWeakened(state withdrawalState, policy externalv1alpha1.ExternalZoneClassParameters, views []string) bool { + return state.MissingHistory || !containsAll(policy.Resolver.Servers, state.Floor.Resolver.Servers) || !containsAll(policy.ExpectedNameservers, state.Floor.ExpectedNameservers) || !containsAll(views, state.Views) +} + +func addFloor(state *withdrawalState, policy externalv1alpha1.ExternalZoneClassParameters, views []string) { + state.Floor.Resolver.Servers = sortedUnion(state.Floor.Resolver.Servers, policy.Resolver.Servers) + state.Floor.ExpectedNameservers = sortedUnion(state.Floor.ExpectedNameservers, policy.ExpectedNameservers) + state.Views = sortedUnion(state.Views, views) +} + +func sortedUnion(a, b []string) []string { + out := append(slices.Clone(a), b...) + slices.Sort(out) + return slices.Compact(out) +} + +func invalidateWithdrawal(status *dnsv1alpha1.ZoneUnitWithdrawalStatus, state *withdrawalState) { + status.AbsentSince, status.NotBefore, status.VerifiedAt = nil, nil, nil + status.ResultDigest = "" + state.Negative = nil + state.WindowStart = time.Time{} +} + +func withdrawalWindow(status *dnsv1alpha1.ZoneUnitWithdrawalStatus, state *withdrawalState, decision probe.WithdrawalDecision, now time.Time) probe.Decision { + if decision.Status != metav1.ConditionTrue { + invalidateWithdrawal(status, state) + return decision.Decision + } + if status.AbsentSince != nil && state.WindowStart.IsZero() { + invalidateWithdrawal(status, state) + } + confirming := status.AbsentSince != nil && status.LastProbeTime != nil && now.After(status.LastProbeTime.Time) + if status.AbsentSince == nil { + status.AbsentSince = timePointer(now) + state.WindowStart = now + } + notBefore := state.WindowStart + if status.NotBefore != nil && status.NotBefore.After(notBefore) { + notBefore = status.NotBefore.Time + } + for _, expires := range state.Positive { + if expires.After(notBefore) { + notBefore = expires + } + } + if state.Negative == nil { + state.Negative = map[string]uint32{} + } + for _, item := range decision.Cache { + if item.NegativeTTL == nil { + continue + } + key := item.View + "/" + item.QType + state.Negative[key] = max(state.Negative[key], *item.NegativeTTL) + } + for _, ttl := range state.Negative { + expires := state.WindowStart.Add(time.Duration(ttl) * time.Second) + if expires.After(notBefore) { + notBefore = expires + } + } + // Round the public horizon up, never down across a status round-trip. + if notBefore.Nanosecond() != 0 { + notBefore = notBefore.Truncate(time.Second).Add(time.Second) + } + status.NotBefore = timePointer(notBefore) + if !confirming || now.Before(notBefore) { + status.VerifiedAt = nil + return probe.Decision{Status: metav1.ConditionFalse, Reason: "NegativeTTLNotElapsed"} + } + status.VerifiedAt = timePointer(now) + return probe.Decision{Status: metav1.ConditionTrue, Reason: "WithdrawalVerified"} +} diff --git a/internal/go/providers/external/controllers/zoneunit/withdrawal_test.go b/internal/go/providers/external/controllers/zoneunit/withdrawal_test.go new file mode 100644 index 0000000..0d151dc --- /dev/null +++ b/internal/go/providers/external/controllers/zoneunit/withdrawal_test.go @@ -0,0 +1,464 @@ +package zoneunit + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + "time" + + "github.com/appthrust/dns-api/internal/go/providers/external/probe" + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func withdrawalItem(name, address string) dnsv1alpha1.ZoneUnitWithdrawal { + return dnsv1alpha1.ZoneUnitWithdrawal{ClaimNamespace: "app", ClaimName: "retired", Claim: dnsv1alpha1.WithdrawalObjectReference{Namespace: "app", Name: "retired", UID: "claim-1"}, ObservedGeneration: 1, Name: name, Address: address, Allowed: true} +} + +func withdrawalAnswer(request probe.ProbeRequest, address string, ttl uint32) (probe.ProbeResult, error) { + result := probe.ProbeResult{} + for _, server := range request.Policy.Resolver.Servers { + view := probe.View{ID: "recursive/" + server} + for _, family := range []string{"A", "AAAA"} { + answer := probe.AnswerEvidence{Name: request.Name, QType: family, NegativeTTL: &ttl} + if family == "A" && address != "" { + answer.Addresses = []string{address} + answer.PositiveTTL = &ttl + view.A = []string{address} + } + view.Answers = append(view.Answers, answer) + } + result.Views = append(result.Views, view) + } + return result, nil +} + +func addWithdrawal(t *testing.T, c client.Client, request ctrl.Request, item dnsv1alpha1.ZoneUnitWithdrawal) { + t.Helper() + var unit dnsv1alpha1.ZoneUnit + if err := c.Get(context.Background(), request.NamespacedName, &unit); err != nil { + t.Fatal(err) + } + unit.Spec.Withdrawals = append(unit.Spec.Withdrawals, item) + unit.Generation++ + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } +} + +func assertWithdrawal(t *testing.T, unit dnsv1alpha1.ZoneUnit, status metav1.ConditionStatus, reason string) dnsv1alpha1.ZoneUnitWithdrawalStatus { + t.Helper() + if len(unit.Status.Withdrawals) != 1 { + t.Fatalf("withdrawals=%#v", unit.Status.Withdrawals) + } + got := unit.Status.Withdrawals[0] + condition := meta.FindStatusCondition(got.Conditions, "Withdrawn") + if condition == nil || condition.Status != status || condition.Reason != reason { + t.Fatalf("Withdrawn=%#v, want %s/%s", condition, status, reason) + } + return got +} + +func TestWithdrawalHorizonConfirmationAndNoStatusLoop(t *testing.T) { + resolver := &fakeResolver{answer: func(request probe.ProbeRequest) (probe.ProbeResult, error) { + return withdrawalAnswer(request, "192.0.2.20", 60) + }} + r, c, now, request := newFixture(t, 0, resolver) + addWithdrawal(t, c, request, withdrawalItem("www", "192.0.2.10")) + _, first := reconcileOnce(t, r, request) + pending := assertWithdrawal(t, first, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if pending.AbsentSince == nil || !pending.AbsentSince.Equal(timePointer(*now)) || pending.NotBefore == nil || !pending.NotBefore.Equal(timePointer(now.Add(time.Minute))) || pending.VerifiedAt != nil { + t.Fatalf("window=%#v", pending) + } + _, same := reconcileOnce(t, r, request) + if same.ResourceVersion != first.ResourceVersion || len(resolver.requests) != 1 { + t.Fatal("unchanged status was patched or reprobed") + } + *now = now.Add(30 * time.Second) + _, waiting := reconcileOnce(t, r, request) + still := assertWithdrawal(t, waiting, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if !still.NotBefore.Equal(pending.NotBefore) { + t.Fatal("identical negatives slid the horizon") + } + // A new reconciler restores the still-fresh identity-bound window. + r2, err := NewReconciler(c, r.Options) + if err != nil { + t.Fatal(err) + } + *now = now.Add(30 * time.Second) + _, verified := reconcileOnce(t, r2, request) + good := assertWithdrawal(t, verified, metav1.ConditionTrue, "WithdrawalVerified") + if good.VerifiedAt == nil || good.VerifiedAt.Before(good.NotBefore) || good.ResultDigest == "" { + t.Fatalf("unbound success=%#v", good) + } + assertProgrammed(t, verified.Status.Conditions, metav1.ConditionTrue, "Programmed") + resolver.answer = func(req probe.ProbeRequest) (probe.ProbeResult, error) { + return withdrawalAnswer(req, "192.0.2.10", 600) + } + *now = now.Add(30 * time.Second) + _, reappeared := reconcileOnce(t, r2, request) + old := assertWithdrawal(t, reappeared, metav1.ConditionFalse, "DNSStillResolves") + if old.AbsentSince != nil || old.VerifiedAt != nil { + t.Fatal("reappearance preserved success") + } + resolver.answer = func(probe.ProbeRequest) (probe.ProbeResult, error) { + return probe.ProbeResult{}, errors.New("secret transport diagnostic") + } + *now = now.Add(30 * time.Second) + _, failed := reconcileOnce(t, r2, request) + assertWithdrawal(t, failed, metav1.ConditionUnknown, "DNSProbeFailed") + resolver.answer = func(req probe.ProbeRequest) (probe.ProbeResult, error) { return withdrawalAnswer(req, "", 0) } + *now = now.Add(30 * time.Second) + _, afterFailure := reconcileOnce(t, r2, request) + fresh := assertWithdrawal(t, afterFailure, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if !fresh.NotBefore.Equal(timePointer(old.LastProbeTime.Add(600 * time.Second))) { + t.Fatal("failure discarded known positive horizon") + } +} + +func TestWithdrawalPolicyFloorAndWaiver(t *testing.T) { + resolver := &fakeResolver{answer: func(req probe.ProbeRequest) (probe.ProbeResult, error) { return withdrawalAnswer(req, "", 0) }} + r, c, now, request := newFixture(t, 0, resolver) + addWithdrawal(t, c, request, withdrawalItem("www", "192.0.2.10")) + reconcileOnce(t, r, request) + *now = now.Add(30 * time.Second) + reconcileOnce(t, r, request) + var class dnsv1alpha1.ZoneClass + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "app", Name: "external"}, &class); err != nil { + t.Fatal(err) + } + class.Spec.Parameters.Raw = []byte(`{"resolver":{"mode":"Recursive","servers":["192.0.2.54:53"]}}`) + if err := c.Update(context.Background(), &class); err != nil { + t.Fatal(err) + } + _, changed := reconcileOnce(t, r, request) + assertWithdrawal(t, changed, metav1.ConditionUnknown, "PolicyChanged") + *now = now.Add(30 * time.Second) + _, weaker := reconcileOnce(t, r, request) + assertWithdrawal(t, weaker, metav1.ConditionUnknown, "PolicyChanged") + class.Spec.Parameters.Raw = []byte(`{"resolver":{"mode":"Recursive","servers":["192.0.2.53:53"]}}`) + if err := c.Update(context.Background(), &class); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r, request) + *now = now.Add(30 * time.Second) + _, restored := reconcileOnce(t, r, request) + assertWithdrawal(t, restored, metav1.ConditionFalse, "NegativeTTLNotElapsed") + resolver.answer = func(req probe.ProbeRequest) (probe.ProbeResult, error) { + return withdrawalAnswer(req, "192.0.2.10", 60) + } + *now = now.Add(30 * time.Second) + _, old := reconcileOnce(t, r, request) + old.Spec.Withdrawals[0].Waiver = &dnsv1alpha1.ZoneUnitWithdrawalWaiver{Name: "emergency", UID: "waiver-1", Digest: "sha256:scope", ReservationRef: dnsv1alpha1.WithdrawalObjectReference{Namespace: "mgmt", Name: "reservation", UID: "reservation-1"}} + if err := c.Update(context.Background(), &old); err != nil { + t.Fatal(err) + } + _, waived := reconcileOnce(t, r, request) + got := assertWithdrawal(t, waived, metav1.ConditionFalse, "DNSStillResolves") + if !meta.IsStatusConditionTrue(got.Conditions, "OverrideAccepted") || got.WaiverRef == nil || got.WaiverRef.UID != "waiver-1" { + t.Fatalf("waiver=%#v", got) + } + waived.Spec.Withdrawals[0].Waiver = nil + if err := c.Update(context.Background(), &waived); err != nil { + t.Fatal(err) + } + _, revoked := reconcileOnce(t, r, request) + if revoked.Status.Withdrawals[0].WaiverRef != nil || meta.IsStatusConditionTrue(revoked.Status.Withdrawals[0].Conditions, "OverrideAccepted") { + t.Fatal("deleted waiver retained") + } +} + +func TestWithdrawalWildcardRetainsPublicationWitness(t *testing.T) { + resolver := &fakeResolver{answer: func(req probe.ProbeRequest) (probe.ProbeResult, error) { + return withdrawalAnswer(req, "192.0.2.10", 3600) + }} + r, c, now, request := newFixture(t, 1, resolver) + var unit dnsv1alpha1.ZoneUnit + if err := c.Get(context.Background(), request.NamespacedName, &unit); err != nil { + t.Fatal(err) + } + unit.Spec.RecordSets[0].Name = "*.project" + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + _, published := reconcileOnce(t, r, request) + witness := resolver.requests[0].Name + published.Spec.RecordSets = nil + if err := c.Update(context.Background(), &published); err != nil { + t.Fatal(err) + } + reconcileOnce(t, r, request) // Core may remove the record before composing its independent claim. + addWithdrawal(t, c, request, withdrawalItem("*.project", "192.0.2.10")) + resolver.answer = func(req probe.ProbeRequest) (probe.ProbeResult, error) { + if req.Name == witness { + return withdrawalAnswer(req, "192.0.2.10", 3600) + } + return withdrawalAnswer(req, "", 0) + } + _, retiring := reconcileOnce(t, r, request) + got := assertWithdrawal(t, retiring, metav1.ConditionFalse, "DNSStillResolves") + if !got.SampledWildcard || len(got.CheckedNames) != 2 || !slices.Contains(got.CheckedNames, witness) { + t.Fatalf("lost witness=%#v", got) + } + resolver.answer = func(req probe.ProbeRequest) (probe.ProbeResult, error) { return withdrawalAnswer(req, "", 0) } + *now = now.Add(30 * time.Second) + _, absent := reconcileOnce(t, r, request) + pending := assertWithdrawal(t, absent, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if !pending.NotBefore.Equal(timePointer(got.LastProbeTime.Add(time.Hour))) { + t.Fatal("3600-second horizon was capped") + } + recreated := withdrawalItem("*.project", "192.0.2.10") + recreated.Claim.UID = "claim-2" + if slices.Equal(withdrawalNames(&unit, recreated, publicationEvidence{}), withdrawalNames(&unit, withdrawalItem("*.project", "192.0.2.10"), publicationEvidence{})) { + t.Fatal("recreated claim reused retirement witness") + } +} + +func TestWithdrawalFreshnessAndDigest(t *testing.T) { + for _, mode := range []string{"stale", "clock reversal", "missing private state", "UID recreation"} { + t.Run(mode, func(t *testing.T) { + resolver := &fakeResolver{answer: func(req probe.ProbeRequest) (probe.ProbeResult, error) { return withdrawalAnswer(req, "", 0) }} + r, c, now, request := newFixture(t, 0, resolver) + addWithdrawal(t, c, request, withdrawalItem("www", "192.0.2.10")) + reconcileOnce(t, r, request) + *now = now.Add(30 * time.Second) + _, unit := reconcileOnce(t, r, request) + good := assertWithdrawal(t, unit, metav1.ConditionTrue, "WithdrawalVerified") + copy := good + copy.Conditions = slices.Clone(good.Conditions) + slices.Reverse(copy.Conditions) + for i := range copy.Conditions { + copy.Conditions[i].LastTransitionTime = metav1.Now() + } + if withdrawalResultDigest(&unit, unit.Spec.Withdrawals[0], copy) != good.ResultDigest { + t.Fatal("digest depends on condition order/transition times") + } + switch mode { + case "stale": + *now = now.Add(331 * time.Second) + case "clock reversal": + *now = now.Add(-time.Minute) + case "missing private state": + unit.Status.Provider = nil + if err := c.Status().Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + case "UID recreation": + unit.Spec.Withdrawals[0].Claim.UID = "claim-2" + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + } + _, got := reconcileOnce(t, r, request) + reason := "NegativeTTLNotElapsed" + status := metav1.ConditionFalse + if mode == "missing private state" { + reason = "PolicyChanged" + status = metav1.ConditionUnknown + } + assertWithdrawal(t, got, status, reason) + }) + } +} + +func TestWithdrawalFairBudgetAndDenied(t *testing.T) { + resolver := &fakeResolver{answer: func(req probe.ProbeRequest) (probe.ProbeResult, error) { return withdrawalAnswer(req, "", 0) }} + r, c, now, request := newFixture(t, 40, resolver) + var unit dnsv1alpha1.ZoneUnit + if err := c.Get(context.Background(), request.NamespacedName, &unit); err != nil { + t.Fatal(err) + } + for i := range 40 { + item := withdrawalItem("www", "192.0.2.10") + item.ClaimName = fmt.Sprintf("claim-%02d", i) + item.Claim.Name = item.ClaimName + item.Claim.UID = types.UID(item.ClaimName) + unit.Spec.Withdrawals = append(unit.Spec.Withdrawals, item) + } + unit.Spec.Withdrawals[0].Allowed = false + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + _, first := reconcileOnce(t, r, request) + if len(resolver.requests) != MaxNamesPerPass || first.Status.Withdrawals[0].LastProbeTime != nil { + t.Fatal("budget/denied boundary") + } + for range 2 { + *now = now.Add(time.Second) + reconcileOnce(t, r, request) + } + if len(resolver.requests) != 79 { + t.Fatalf("starved work: probes=%d", len(resolver.requests)) + } +} + +func TestWithdrawalLateObservationAndInputMutation(t *testing.T) { + for _, mode := range []string{"late", "changed"} { + t.Run(mode, func(t *testing.T) { + resolver := &fakeResolver{answer: func(req probe.ProbeRequest) (probe.ProbeResult, error) { return withdrawalAnswer(req, "", 0) }} + r, c, now, request := newFixture(t, 0, resolver) + addWithdrawal(t, c, request, withdrawalItem("www", "192.0.2.10")) + reconcileOnce(t, r, request) + *now = now.Add(30 * time.Second) + resolver.answer = func(req probe.ProbeRequest) (probe.ProbeResult, error) { + if mode == "late" { + *now = now.Add(301 * time.Second) + } else { + var unit dnsv1alpha1.ZoneUnit + if err := c.Get(context.Background(), request.NamespacedName, &unit); err != nil { + t.Fatal(err) + } + unit.Spec.Withdrawals[0].Claim.UID = "replacement" + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + } + return withdrawalAnswer(req, "", 0) + } + result, unit := reconcileOnce(t, r, request) + if mode == "late" { + assertWithdrawal(t, unit, metav1.ConditionUnknown, "DNSProbeFailed") + } else if result.RequeueAfter != time.Second || unit.Status.Withdrawals[0].VerifiedAt != nil { + t.Fatal("stale result committed") + } + }) + } +} + +func TestWithdrawalContinuesDuringUnitDeletion(t *testing.T) { + resolver := &fakeResolver{answer: func(req probe.ProbeRequest) (probe.ProbeResult, error) { + return withdrawalAnswer(req, "192.0.2.10", 60) + }} + r, c, _, request := newFixture(t, 0, resolver) + addWithdrawal(t, c, request, withdrawalItem("www", "192.0.2.10")) + var unit dnsv1alpha1.ZoneUnit + if err := c.Get(context.Background(), request.NamespacedName, &unit); err != nil { + t.Fatal(err) + } + unit.Finalizers = []string{"core-retention"} + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + if err := c.Delete(context.Background(), &unit); err != nil { + t.Fatal(err) + } + _, got := reconcileOnce(t, r, request) + assertWithdrawal(t, got, metav1.ConditionFalse, "DNSStillResolves") +} + +func TestWithdrawalWindowExtendsNeverShortens(t *testing.T) { + now := time.Date(2026, 10, 18, 0, 0, 0, 0, time.UTC) + status := dnsv1alpha1.ZoneUnitWithdrawalStatus{} + stored := withdrawalState{} + for _, ttl := range []uint32{60, 120, 30} { + decision := probe.WithdrawalDecision{Decision: probe.Decision{Status: metav1.ConditionTrue}, Cache: []probe.WithdrawalCacheEvidence{{Name: "www.example.test", View: "recursive/a", QType: "AAAA", NegativeTTL: &ttl}}} + got := withdrawalWindow(&status, &stored, decision, now) + if got.Reason != "NegativeTTLNotElapsed" { + t.Fatalf("unexpected decision %#v", got) + } + status.LastProbeTime = timePointer(now) + now = now.Add(30 * time.Second) + } + if !status.NotBefore.Equal(timePointer(status.AbsentSince.Add(120 * time.Second))) { + t.Fatal("changed answer shortened or slid the horizon") + } +} + +func TestWithdrawalSubsecondHorizonSurvivesStatusPrecision(t *testing.T) { + start := time.Date(2026, 10, 18, 0, 0, 0, 900000000, time.UTC) + ttl := uint32(60) + decision := probe.WithdrawalDecision{Decision: probe.Decision{Status: metav1.ConditionTrue}, Cache: []probe.WithdrawalCacheEvidence{{Name: "www.example.test", View: "recursive/a", QType: "A", NegativeTTL: &ttl}}} + status := dnsv1alpha1.ZoneUnitWithdrawalStatus{} + state := withdrawalState{} + withdrawalWindow(&status, &state, decision, start) + // metav1.Time JSON drops fractional seconds; the private anchor must not. + status.AbsentSince = timePointer(start.Truncate(time.Second)) + status.LastProbeTime = timePointer(start.Truncate(time.Second)) + if status.NotBefore.Nanosecond() != 0 || !status.NotBefore.Equal(timePointer(start.Truncate(time.Second).Add(61*time.Second))) { + t.Fatal("public horizon rounded down") + } + if got := withdrawalWindow(&status, &state, decision, start.Truncate(time.Second).Add(60*time.Second)); got.Status == metav1.ConditionTrue { + t.Fatal("subsecond expiry released early") + } + if got := withdrawalWindow(&status, &state, decision, start.Truncate(time.Second).Add(61*time.Second)); got.Status != metav1.ConditionTrue { + t.Fatal("post-horizon confirming probe did not verify") + } +} + +func TestWithdrawalDeniedHistoryAndIPv6(t *testing.T) { + const address = "2001:db8::1" + resolver := &fakeResolver{answer: func(req probe.ProbeRequest) (probe.ProbeResult, error) { + result, err := withdrawalAnswer(req, "", 3600) + for i := range result.Views { + result.Views[i].AAAA = []string{address} + result.Views[i].Answers[1].Addresses = []string{address} + ttl := uint32(3600) + result.Views[i].Answers[1].PositiveTTL = &ttl + } + return result, err + }} + r, c, now, request := newFixture(t, 0, resolver) + addWithdrawal(t, c, request, withdrawalItem("www", address)) + _, unit := reconcileOnce(t, r, request) + old := assertWithdrawal(t, unit, metav1.ConditionFalse, "DNSStillResolves") + unit.Spec.Withdrawals[0].Allowed = false + if err := c.Update(context.Background(), &unit); err != nil { + t.Fatal(err) + } + _, denied := reconcileOnce(t, r, request) + if len(resolver.requests) != 1 || !meta.IsStatusConditionFalse(denied.Status.Withdrawals[0].Conditions, "Accepted") { + t.Fatal("denied withdrawal was probed or accepted") + } + denied.Spec.Withdrawals[0].Allowed = true + if err := c.Update(context.Background(), &denied); err != nil { + t.Fatal(err) + } + resolver.answer = func(req probe.ProbeRequest) (probe.ProbeResult, error) { return withdrawalAnswer(req, "", 0) } + *now = now.Add(30 * time.Second) + _, restored := reconcileOnce(t, r, request) + got := assertWithdrawal(t, restored, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if !got.NotBefore.Equal(timePointer(old.LastProbeTime.Add(time.Hour))) { + t.Fatal("denial discarded IPv6 positive horizon") + } +} + +func TestWithdrawalBindingRejectionNoStatusLoop(t *testing.T) { + resolver := &fakeResolver{answer: func(req probe.ProbeRequest) (probe.ProbeResult, error) { + return withdrawalAnswer(req, "192.0.2.10", 3600) + }} + r, c, now, request := newFixture(t, 0, resolver) + addWithdrawal(t, c, request, withdrawalItem("www", "192.0.2.10")) + _, unit := reconcileOnce(t, r, request) + old := assertWithdrawal(t, unit, metav1.ConditionFalse, "DNSStillResolves") + var class dnsv1alpha1.ZoneClass + if err := c.Get(context.Background(), client.ObjectKey{Namespace: "app", Name: "external"}, &class); err != nil { + t.Fatal(err) + } + class.Status.Conditions[0].Status = metav1.ConditionFalse + if err := c.Status().Update(context.Background(), &class); err != nil { + t.Fatal(err) + } + _, rejected := reconcileOnce(t, r, request) + assertWithdrawal(t, rejected, metav1.ConditionUnknown, "DNSProbeFailed") + *now = now.Add(time.Second) + _, same := reconcileOnce(t, r, request) + if same.ResourceVersion != rejected.ResourceVersion { + t.Fatal("unchanged rejected binding patched status") + } + class.Status.Conditions[0].Status = metav1.ConditionTrue + if err := c.Status().Update(context.Background(), &class); err != nil { + t.Fatal(err) + } + resolver.answer = func(req probe.ProbeRequest) (probe.ProbeResult, error) { return withdrawalAnswer(req, "", 0) } + _, restored := reconcileOnce(t, r, request) + got := assertWithdrawal(t, restored, metav1.ConditionFalse, "NegativeTTLNotElapsed") + if !got.NotBefore.Equal(timePointer(old.LastProbeTime.Add(time.Hour))) { + t.Fatal("binding rejection discarded known positive horizon") + } +} diff --git a/internal/go/providers/external/manifest/manifest_test.go b/internal/go/providers/external/manifest/manifest_test.go index 5c77755..9d02403 100644 --- a/internal/go/providers/external/manifest/manifest_test.go +++ b/internal/go/providers/external/manifest/manifest_test.go @@ -23,6 +23,9 @@ func TestExternalDeclarationAndCEL(t *testing.T) { if len(version.Zone.AdditionalConditionReasons) != 5 || len(version.RecordSet.AdditionalConditionReasons) != 8 { t.Fatal("condition reason tables differ from design") } + if version.RecordWithdrawal == nil || !version.RecordWithdrawal.Supported || version.RecordWithdrawal.Schemas.StatusProviderData == nil || len(version.RecordWithdrawal.AdditionalConditionReasons) != 11 { + t.Fatal("withdrawal capability, schema and condition table must ship together") + } for _, rule := range version.ZoneClass.ValidationRules { if err := celruntime.CompileBool(rule.Rule, cel.Variable("self", cel.DynType)); err != nil { t.Fatalf("compile %q: %v", rule.Rule, err) diff --git a/internal/go/providers/external/manifest/provider.yaml b/internal/go/providers/external/manifest/provider.yaml index f24735f..9a7425e 100644 --- a/internal/go/providers/external/manifest/provider.yaml +++ b/internal/go/providers/external/manifest/provider.yaml @@ -160,3 +160,25 @@ spec: - {conditionType: Programmed, status: 'False', reason: ResolutionInconsistent, description: Required resolver or authority observations disagree.} - {conditionType: Programmed, status: 'False', reason: NameserverMismatch, description: Required zone nameserver verification failed.} - {conditionType: Programmed, status: Unknown, reason: DNSProbeFailed, description: A required DNS observation could not complete.} + recordWithdrawal: + supported: true + schemas: + statusProviderData: + openAPIV3Schema: + type: object + description: Bounded withdrawal diagnostics; complete typed evidence drives verification. + additionalProperties: false + properties: + views: *preview + additionalConditionReasons: + - {conditionType: Withdrawn, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Ready, status: 'False', reason: DNSStillResolves, description: A required observation still returns the retired address.} + - {conditionType: Withdrawn, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Ready, status: 'False', reason: NegativeTTLNotElapsed, description: Cache safety and a separate confirming attempt are pending.} + - {conditionType: Withdrawn, status: 'Unknown', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Ready, status: 'False', reason: DNSProbeFailed, description: Required exclusion evidence is incomplete.} + - {conditionType: Withdrawn, status: 'Unknown', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Ready, status: 'False', reason: PolicyChanged, description: The binding or required view policy changed.} + - {conditionType: Withdrawn, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: Ready, status: 'True', reason: WithdrawalVerified, description: Complete exclusion verified after the cache horizon.} + - {conditionType: OverrideAccepted, status: 'True', reason: OperatorAuthorizedRelease, description: An exact separately authorized waiver accepts stale DNS risk.} diff --git a/internal/go/providers/external/probe/metrics.go b/internal/go/providers/external/probe/metrics.go index 937b3de..830d510 100644 --- a/internal/go/providers/external/probe/metrics.go +++ b/internal/go/providers/external/probe/metrics.go @@ -9,15 +9,17 @@ import ( ) var ( - probes = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "dns_external_probes_total", Help: "Completed external DNS observations."}, []string{"outcome", "qtype"}) - duration = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "dns_external_probe_duration_seconds", Help: "Admitted external DNS probe duration."}, []string{"outcome"}) - queueDelay = prometheus.NewHistogram(prometheus.HistogramOpts{Name: "dns_external_probe_queue_delay_seconds", Help: "Scheduling and limiter admission delay for external DNS probes."}) - pending = prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "dns_external_pending_records", Help: "Active unverified external record claims."}, []string{"reason"}) - pendingMu sync.Mutex - pendingUnits = map[string]map[string]int{} + probes = prometheus.NewCounterVec(prometheus.CounterOpts{Name: "dns_external_probes_total", Help: "Completed external DNS observations."}, []string{"outcome", "qtype"}) + duration = prometheus.NewHistogramVec(prometheus.HistogramOpts{Name: "dns_external_probe_duration_seconds", Help: "Admitted external DNS probe duration."}, []string{"outcome"}) + queueDelay = prometheus.NewHistogram(prometheus.HistogramOpts{Name: "dns_external_probe_queue_delay_seconds", Help: "Scheduling and limiter admission delay for external DNS probes."}) + pending = prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "dns_external_pending_records", Help: "Active unverified external record claims."}, []string{"reason"}) + pendingMu sync.Mutex + pendingUnits = map[string]map[string]int{} + withdrawalsPending = prometheus.NewGaugeVec(prometheus.GaugeOpts{Name: "dns_external_withdrawals_pending", Help: "Retained external DNS withdrawals awaiting verified exclusion."}, []string{"reason"}) + withdrawalUnits = map[string]map[string]int{} ) -func init() { metrics.Registry.MustRegister(probes, duration, queueDelay, pending) } +func init() { metrics.Registry.MustRegister(probes, duration, queueDelay, pending, withdrawalsPending) } func ObserveQueueDelay(delay time.Duration) { queueDelay.Observe(max(0, delay.Seconds())) } @@ -45,3 +47,28 @@ func SetPending(unit string, counts map[string]int) { pending.WithLabelValues(reason).Add(float64(count)) } } + +// SetWithdrawalsPending uses only DEP's closed pending reasons as labels. +func SetWithdrawalsPending(unit string, counts map[string]int) { + pendingMu.Lock() + defer pendingMu.Unlock() + for reason, count := range withdrawalUnits[unit] { + withdrawalsPending.WithLabelValues(reason).Sub(float64(count)) + } + delete(withdrawalUnits, unit) + next := map[string]int{} + for reason, count := range counts { + switch reason { + case "DNSStillResolves", "NegativeTTLNotElapsed", "DNSProbeFailed", "PolicyChanged", "InvalidWithdrawal", "NotAllowed", "UnsupportedProvider": + default: + reason = "DNSProbeFailed" + } + next[reason] += count + } + if len(next) != 0 { + withdrawalUnits[unit] = next + } + for reason, count := range next { + withdrawalsPending.WithLabelValues(reason).Add(float64(count)) + } +} diff --git a/internal/go/providers/external/probe/types.go b/internal/go/providers/external/probe/types.go index 3d110b2..440a2b2 100644 --- a/internal/go/providers/external/probe/types.go +++ b/internal/go/providers/external/probe/types.go @@ -35,6 +35,8 @@ type ProbeRequest struct { ZoneOnly bool // Evidence is cycle-local, never a process-wide DNS answer cache. Evidence *ZoneEvidence + // WithdrawalAddress selects exclusion evaluation, never exact-set publication. + WithdrawalAddress string } type ProbeResult struct { @@ -54,6 +56,9 @@ type View struct { // Failure is a closed diagnostic code; raw transport errors never enter status. Failure string Mismatch bool + // Answers retains each chain query's complete cache evidence. Publication's + // minimum-TTL summaries are not sufficient to authorize address reuse. + Answers []AnswerEvidence } type AuthorityObservation struct { diff --git a/internal/go/providers/external/probe/wire.go b/internal/go/providers/external/probe/wire.go index ee4dd16..abc1288 100644 --- a/internal/go/providers/external/probe/wire.go +++ b/internal/go/providers/external/probe/wire.go @@ -120,7 +120,7 @@ func (r *WireResolver) Probe(ctx context.Context, request ProbeRequest) (ProbeRe for _, authority := range request.Evidence.Authorities { view := session.resolve(ctx, request.Name, request.Policy.Resolver.Servers[0], authority, request.Zone) result.Views = append(result.Views, view) - foreign := false + foreign := request.WithdrawalAddress != "" for _, target := range view.Chain { if target != request.Zone && !strings.HasSuffix(target, "."+request.Zone) { foreign = true @@ -138,6 +138,8 @@ func (r *WireResolver) Probe(ctx context.Context, request ProbeRequest) (ProbeRe decision := Evaluate(request, result) if request.ZoneOnly { decision = EvaluateZone(request, result.Zone) + } else if request.WithdrawalAddress != "" { + decision = EvaluateWithdrawal(request, result).Decision } outcome := decision.Outcome() for qtype, count := range session.qtypes { @@ -245,6 +247,7 @@ func (s *wireSession) resolve(ctx context.Context, name, recursive, authority, z view.Authoritative = true } rcodes[i] = reply.Rcode + view.Answers = append(view.Answers, answerEvidence(reply, current, qtype)) ownerAnswers := 0 for _, rr := range reply.Answer { if CanonicalName(rr.Header().Name) != current { diff --git a/internal/go/providers/external/probe/withdrawal.go b/internal/go/providers/external/probe/withdrawal.go new file mode 100644 index 0000000..8ddf88d --- /dev/null +++ b/internal/go/providers/external/probe/withdrawal.go @@ -0,0 +1,325 @@ +package probe + +import ( + "fmt" + "net/netip" + "slices" + "strings" + + "github.com/appthrust/dns-api/internal/go/providers/external/config" + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// AnswerEvidence describes one complete query, including CNAME links. TTLs are +// maxima of relevant positive records, or RFC 2308 negative TTLs, not poll caps. +type AnswerEvidence struct { + Name string `json:"name"` + QType string `json:"qtype"` + RCode int `json:"rcode"` + Addresses []string `json:"addresses,omitempty"` + CNAME string `json:"cname,omitempty"` + PositiveTTL *uint32 `json:"positiveTTL,omitempty"` + NegativeTTL *uint32 `json:"negativeTTL,omitempty"` + Failure string `json:"failure,omitempty"` +} + +// WithdrawalCacheEvidence is complete decision evidence, not a diagnostic preview. +// PositiveTTL is retained only where this observation saw the retired address. +type WithdrawalCacheEvidence struct { + Name string `json:"name"` + View string `json:"view"` + QType string `json:"qtype"` + PositiveTTL *uint32 `json:"positiveTTL,omitempty"` + NegativeTTL *uint32 `json:"negativeTTL,omitempty"` +} + +type WithdrawalDecision struct { + Decision + Cache []WithdrawalCacheEvidence +} + +func WithdrawalFailed(name, view, failure string) WithdrawalDecision { + switch failure { + case "timeout", "SERVFAIL", "REFUSED", "transport", "invalid-response", "resolver-config", "chain-limit", "probe-limit", "negative-soa-missing": + default: + failure = "transport" + } + return WithdrawalDecision{Decision: Decision{Status: metav1.ConditionUnknown, Reason: "DNSProbeFailed", Message: bounded(fmt.Sprintf("External DNS withdrawal cannot be verified for %s in %s: %s.", name, view, failure))}} +} + +// EvaluateWithdrawal proves only current exclusion. The reconciler additionally +// requires a continuous absence window, retained cache horizons and a fresh +// confirming attempt before it may publish Withdrawn=True. +func EvaluateWithdrawal(request ProbeRequest, result ProbeResult) WithdrawalDecision { + address, err := netip.ParseAddr(request.WithdrawalAddress) + if err != nil || address.Zone() != "" { + return WithdrawalFailed(request.Name, "required-views", "invalid-response") + } + address = address.Unmap() + fail := func(view, code string) WithdrawalDecision { + decision := WithdrawalFailed(request.Name, view, code) + decision.Cache = positiveWithdrawalEvidence(result, address) + return decision + } + if len(request.Policy.Resolver.Servers) == 0 || len(result.Views) == 0 { + return fail("required-views", "invalid-response") + } + for _, server := range request.Policy.Resolver.Servers { + if !slices.ContainsFunc(result.Views, func(v View) bool { return v.ID == "recursive/"+server }) { + return fail("recursive/"+server, "invalid-response") + } + } + if len(request.Policy.ExpectedNameservers) != 0 { + if request.Evidence == nil || request.Evidence.Zone != request.Zone || request.Evidence.PolicyFingerprint != Fingerprint(request.Policy) || EvaluateZone(request, request.Evidence).Status != metav1.ConditionTrue { + return fail("required-authorities", "invalid-response") + } + for _, authority := range request.Evidence.Authorities { + prefix := "authority/" + authority + "/via/" + for _, server := range request.Policy.Resolver.Servers { + if !slices.ContainsFunc(result.Views, func(v View) bool { return v.ID == prefix+server && v.Authoritative }) { + return fail(prefix+server, "invalid-response") + } + } + } + } + views := slices.Clone(result.Views) + slices.SortFunc(views, func(a, b View) int { return strings.Compare(a.ID, b.ID) }) + decision := WithdrawalDecision{Decision: Decision{Status: metav1.ConditionTrue, Reason: "WithdrawalVerified"}} + for i, view := range views { + if i > 0 && views[i-1].ID == view.ID { + return fail(view.ID, "invalid-response") + } + decision.Views = append(decision.Views, view.ID) + cache, qtype, failure := withdrawalViewEvidence(request.Name, address, view) + decision.Cache = append(decision.Cache, cache...) + if failure != "" { + return fail(view.ID, failure) + } + for _, item := range cache { + decision.NegativeTTL = maxTTL(decision.NegativeTTL, item.NegativeTTL) + } + if qtype != "" && decision.Status == metav1.ConditionTrue { + decision.Status, decision.Reason = metav1.ConditionFalse, "DNSStillResolves" + decision.Message = bounded(fmt.Sprintf("DNS %s still resolves to retired address %s in %s (%s).", request.Name, address, view.ID, qtype)) + } + } + return decision +} + +func withdrawalViewEvidence(name string, address netip.Addr, view View) ([]WithdrawalCacheEvidence, string, string) { + if view.Failure != "" { + return nil, "", view.Failure + } + if view.RCode != dns.RcodeSuccess && view.RCode != dns.RcodeNameError { + code := "invalid-response" + if view.RCode == dns.RcodeServerFailure { + code = "SERVFAIL" + } + if view.RCode == dns.RcodeRefused { + code = "REFUSED" + } + return nil, "", code + } + if view.Mismatch || len(view.Chain) > MaxCNAMEs { + return nil, "", "chain-limit" + } + owners := append([]string{CanonicalName(name)}, namesInOrder(view.Chain)...) + seen := map[string]bool{} + for _, owner := range owners { + if seen[owner] || !config.ValidName(owner) { + return nil, "", "invalid-response" + } + seen[owner] = true + } + if len(view.Answers) != 2*len(owners) { + return nil, "", "invalid-response" + } + answers := slices.Clone(view.Answers) + slices.SortFunc(answers, func(a, b AnswerEvidence) int { + if c := strings.Compare(a.Name, b.Name); c != 0 { + return c + } + return strings.Compare(a.QType, b.QType) + }) + qtype := "" + presentA, presentAAAA := false, false + for _, answer := range answers { + for _, value := range answer.Addresses { + ip, err := netip.ParseAddr(value) + if err != nil || ip.Zone() != "" || (answer.QType == "A" && !ip.Is4()) || (answer.QType == "AAAA" && !ip.Is6()) { + return nil, "", "invalid-response" + } + if ip.Unmap() == address { + qtype = answer.QType + presentA = presentA || answer.QType == "A" + presentAAAA = presentAAAA || answer.QType == "AAAA" + } + } + } + cache := make([]WithdrawalCacheEvidence, 0, len(answers)) + for i, owner := range owners { + for _, family := range []string{"A", "AAAA"} { + var found *AnswerEvidence + for j := range answers { + if CanonicalName(answers[j].Name) == owner && answers[j].QType == family { + if found != nil { + return cache, qtype, "invalid-response" + } + found = &answers[j] + } + } + if found == nil { + return cache, qtype, "invalid-response" + } + if found.Failure != "" { + return cache, qtype, found.Failure + } + if found.RCode != dns.RcodeSuccess && found.RCode != dns.RcodeNameError { + return cache, qtype, "invalid-response" + } + item := WithdrawalCacheEvidence{Name: owner, View: view.ID, QType: family} + if i < len(owners)-1 { + if found.RCode != dns.RcodeSuccess || CanonicalName(found.CNAME) != owners[i+1] || len(found.Addresses) != 0 || found.PositiveTTL == nil { + return cache, qtype, "invalid-response" + } + } else { + terminal := view.A + if family == "AAAA" { + terminal = view.AAAA + } + if found.RCode != view.RCode || !slices.Equal(Addresses(found.Addresses), Addresses(terminal)) { + return cache, qtype, "invalid-response" + } + if found.CNAME != "" || (found.RCode == dns.RcodeNameError && len(found.Addresses) != 0) { + return cache, qtype, "invalid-response" + } + if len(found.Addresses) == 0 { + if found.NegativeTTL == nil { + return cache, qtype, "negative-soa-missing" + } + item.NegativeTTL = found.NegativeTTL + } else if found.PositiveTTL == nil { + return cache, qtype, "invalid-response" + } + } + if qtype != "" && (found.CNAME != "" || (family == "A" && presentA) || (family == "AAAA" && presentAAAA)) { + item.PositiveTTL = found.PositiveTTL + } + cache = append(cache, item) + } + } + slices.SortFunc(cache, func(a, b WithdrawalCacheEvidence) int { + if c := strings.Compare(a.Name, b.Name); c != 0 { + return c + } + return strings.Compare(a.QType, b.QType) + }) + return cache, qtype, "" +} + +func namesInOrder(values []string) []string { + out := make([]string, len(values)) + for i, value := range values { + out[i] = CanonicalName(value) + } + return out +} + +// Failed/incomplete observations still retain any positively observed address +// and CNAME horizon. An earlier failed view must not hide a later positive one. +func positiveWithdrawalEvidence(result ProbeResult, address netip.Addr) []WithdrawalCacheEvidence { + var cache []WithdrawalCacheEvidence + for _, view := range result.Views { + presentA, presentAAAA := false, false + for _, answer := range view.Answers { + for _, value := range answer.Addresses { + if ip, err := netip.ParseAddr(value); err == nil && ip.Unmap() == address { + presentA = presentA || answer.QType == "A" + presentAAAA = presentAAAA || answer.QType == "AAAA" + } + } + } + if !presentA && !presentAAAA { + continue + } + for _, answer := range view.Answers { + if answer.PositiveTTL != nil && (answer.CNAME != "" || (answer.QType == "A" && presentA) || (answer.QType == "AAAA" && presentAAAA)) { + cache = append(cache, WithdrawalCacheEvidence{Name: answer.Name, View: view.ID, QType: answer.QType, PositiveTTL: answer.PositiveTTL}) + } + } + } + slices.SortFunc(cache, func(a, b WithdrawalCacheEvidence) int { + if c := strings.Compare(a.View, b.View); c != 0 { + return c + } + if c := strings.Compare(a.Name, b.Name); c != 0 { + return c + } + return strings.Compare(a.QType, b.QType) + }) + return cache +} + +func maxTTL(current, next *uint32) *uint32 { + if next != nil && (current == nil || *next > *current) { + value := *next + return &value + } + return current +} + +// answerEvidence validates query-local records without changing publication's +// existing evaluation semantics. Missing negative SOA is never invented as zero. +func answerEvidence(reply *dns.Msg, name string, qtype uint16) AnswerEvidence { + answer := AnswerEvidence{Name: name, QType: dns.TypeToString[qtype], RCode: reply.Rcode} + for _, rr := range reply.Answer { + if CanonicalName(rr.Header().Name) != name { + continue + } + ttl := rr.Header().Ttl + switch value := rr.(type) { + case *dns.CNAME: + target := CanonicalName(value.Target) + if answer.CNAME != "" && answer.CNAME != target { + answer.Failure = "invalid-response" + } + answer.CNAME = target + case *dns.A: + if qtype != dns.TypeA { + answer.Failure = "invalid-response" + } + answer.Addresses = append(answer.Addresses, value.A.String()) + case *dns.AAAA: + if qtype != dns.TypeAAAA { + answer.Failure = "invalid-response" + } + answer.Addresses = append(answer.Addresses, value.AAAA.String()) + default: + continue + } + if rr.Header().Class != dns.ClassINET { + answer.Failure = "invalid-response" + } + answer.PositiveTTL = maxTTL(answer.PositiveTTL, &ttl) + } + if answer.CNAME != "" && len(answer.Addresses) != 0 { + answer.Failure = "invalid-response" + } + if reply.Rcode == dns.RcodeNameError || (answer.CNAME == "" && len(answer.Addresses) == 0) { + for _, rr := range reply.Ns { + soa, ok := rr.(*dns.SOA) + if !ok { + continue + } + owner := CanonicalName(soa.Hdr.Name) + if (owner != "" && owner != name && !strings.HasSuffix(name, "."+owner)) || soa.Hdr.Class != dns.ClassINET || !config.ValidName(CanonicalName(soa.Ns)) || !config.ValidName(CanonicalName(soa.Mbox)) { + answer.Failure = "invalid-response" + continue + } + ttl := min(soa.Hdr.Ttl, soa.Minttl) + answer.NegativeTTL = maxTTL(answer.NegativeTTL, &ttl) + } + } + return answer +} diff --git a/internal/go/providers/external/probe/withdrawal_test.go b/internal/go/providers/external/probe/withdrawal_test.go new file mode 100644 index 0000000..ecbe8d0 --- /dev/null +++ b/internal/go/providers/external/probe/withdrawal_test.go @@ -0,0 +1,190 @@ +package probe + +import ( + "fmt" + "net" + "slices" + "testing" + + externalv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/external/v1alpha1" + "github.com/miekg/dns" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func withdrawalView(id string, a, aaaa []string, ttl uint32) View { + return View{ID: id, A: a, AAAA: aaaa, Answers: []AnswerEvidence{ + {Name: "www.example.test", QType: "A", Addresses: a, PositiveTTL: &ttl, NegativeTTL: &ttl}, + {Name: "www.example.test", QType: "AAAA", Addresses: aaaa, PositiveTTL: &ttl, NegativeTTL: &ttl}, + }} +} + +func TestEvaluateWithdrawal(t *testing.T) { + request := ProbeRequest{Name: "www.example.test", Zone: "example.test", WithdrawalAddress: "192.0.2.1", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"192.0.2.53:53", "192.0.2.54:53"}}}} + cases := []struct { + name string + mutate func(*ProbeRequest, *[]View) + status metav1.ConditionStatus + reason string + }{ + {"replacement views differ", func(_ *ProbeRequest, v *[]View) { + (*v)[1] = withdrawalView((*v)[1].ID, []string{"192.0.2.3"}, nil, 600) + }, metav1.ConditionTrue, "WithdrawalVerified"}, + {"old address", func(_ *ProbeRequest, v *[]View) { + (*v)[1] = withdrawalView((*v)[1].ID, []string{"192.0.2.1"}, nil, 600) + }, metav1.ConditionFalse, "DNSStillResolves"}, + {"opposite family mapped", func(_ *ProbeRequest, v *[]View) { + (*v)[1] = withdrawalView((*v)[1].ID, nil, []string{"::ffff:192.0.2.1"}, 600) + }, metav1.ConditionFalse, "DNSStillResolves"}, + {"IPv6", func(r *ProbeRequest, v *[]View) { + r.WithdrawalAddress = "2001:db8::1" + (*v)[1] = withdrawalView((*v)[1].ID, nil, []string{"2001:0DB8:0:0::1"}, 600) + }, metav1.ConditionFalse, "DNSStillResolves"}, + {"missing view", func(_ *ProbeRequest, v *[]View) { *v = (*v)[:1] }, metav1.ConditionUnknown, "DNSProbeFailed"}, + {"missing family", func(_ *ProbeRequest, v *[]View) { (*v)[0].Answers = (*v)[0].Answers[:1] }, metav1.ConditionUnknown, "DNSProbeFailed"}, + {"missing SOA", func(_ *ProbeRequest, v *[]View) { (*v)[0].Answers[1].NegativeTTL = nil }, metav1.ConditionUnknown, "DNSProbeFailed"}, + {"failed view", func(_ *ProbeRequest, v *[]View) { (*v)[1].Failure = "timeout" }, metav1.ConditionUnknown, "DNSProbeFailed"}, + {"CNAME loop", func(_ *ProbeRequest, v *[]View) { (*v)[0].Chain = []string{"www.example.test"} }, metav1.ConditionUnknown, "DNSProbeFailed"}, + {"malformed address", func(_ *ProbeRequest, v *[]View) { (*v)[0].Answers[0].Addresses = []string{"not-an-address"} }, metav1.ConditionUnknown, "DNSProbeFailed"}, + {"NXDOMAIN", func(_ *ProbeRequest, v *[]View) { + for i := range *v { + (*v)[i] = withdrawalView((*v)[i].ID, nil, nil, 600) + (*v)[i].RCode = dns.RcodeNameError + for j := range (*v)[i].Answers { + (*v)[i].Answers[j].RCode = dns.RcodeNameError + } + } + }, metav1.ConditionTrue, "WithdrawalVerified"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := request + views := []View{withdrawalView("recursive/192.0.2.53:53", []string{"192.0.2.2"}, nil, 120), withdrawalView("recursive/192.0.2.54:53", []string{"192.0.2.2"}, nil, 600)} + tc.mutate(&r, &views) + decision := EvaluateWithdrawal(r, ProbeResult{Views: views}) + if decision.Status != tc.status || decision.Reason != tc.reason { + t.Fatalf("decision = %#v", decision) + } + if decision.Status == metav1.ConditionTrue && (decision.NegativeTTL == nil || *decision.NegativeTTL != 600) { + t.Fatalf("maximum negative horizon lost: %#v", decision) + } + if len(decision.Message) > 2048 { + t.Fatal("unbounded message") + } + }) + } +} + +func TestWithdrawalEvidenceOrderStable(t *testing.T) { + r := ProbeRequest{Name: "www.example.test", WithdrawalAddress: "192.0.2.1", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"a", "b"}}}} + result := ProbeResult{Views: []View{withdrawalView("recursive/a", nil, nil, 0), withdrawalView("recursive/b", []string{"192.0.2.3", "192.0.2.2"}, nil, 60)}} + first := EvaluateWithdrawal(r, result) + slices.Reverse(result.Views) + for i := range result.Views { + slices.Reverse(result.Views[i].Answers) + } + second := EvaluateWithdrawal(r, result) + if Fingerprint(first) != Fingerprint(second) { + t.Fatal("resolver order changed withdrawal evidence") + } +} + +func TestWithdrawalAuthorityAndChainEvidence(t *testing.T) { + request := ProbeRequest{Name: "www.example.test", Zone: "example.test", WithdrawalAddress: "192.0.2.1", + Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"recursive"}}, ExpectedNameservers: []string{"ns.example.test"}}} + request.Evidence = &ZoneEvidence{Zone: request.Zone, PolicyFingerprint: Fingerprint(request.Policy), Authorities: []string{"192.0.2.53:53"}, Views: []AuthorityObservation{ + {ID: "recursive/recursive", Nameservers: []string{"ns.example.test"}, SOAOwner: request.Zone}, + {ID: "authority/192.0.2.53:53", Nameservers: []string{"ns.example.test"}, SOAOwner: request.Zone, Authoritative: true}, + }} + for _, links := range []int{0, 8, 9} { + result := ProbeResult{Views: []View{withdrawalView("recursive/recursive", nil, nil, 60), withdrawalView("authority/192.0.2.53:53/via/recursive", nil, nil, 3600)}} + result.Views[1].Authoritative = true + for i := range result.Views { + view := &result.Views[i] + terminal := view.Answers + view.Answers = nil + owner := request.Name + for n := range links { + target := fmt.Sprintf("link-%d.foreign.test", n) + view.Chain = append(view.Chain, target) + ttl := uint32(7200) + for _, family := range []string{"A", "AAAA"} { + view.Answers = append(view.Answers, AnswerEvidence{Name: owner, QType: family, CNAME: target, PositiveTTL: &ttl}) + } + owner = target + } + for j := range terminal { + terminal[j].Name = owner + } + view.Answers = append(view.Answers, terminal...) + } + decision := EvaluateWithdrawal(request, result) + if links <= 8 && (decision.Status != metav1.ConditionTrue || decision.NegativeTTL == nil || *decision.NegativeTTL != 3600) { + t.Fatalf("links=%d decision=%#v", links, decision) + } + if links > 8 && decision.Status != metav1.ConditionUnknown { + t.Fatal("accepted overlong chain") + } + result.Views = result.Views[:1] + if EvaluateWithdrawal(request, result).Status != metav1.ConditionUnknown { + t.Fatal("missing authority authorized withdrawal") + } + } +} + +func TestWithdrawalQueryCacheEvidence(t *testing.T) { + for _, family := range []uint16{dns.TypeA, dns.TypeAAAA} { + soa := &dns.SOA{Hdr: dns.RR_Header{Name: "example.test.", Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 3600}, Ns: "ns.example.test.", Mbox: "hostmaster.example.test.", Minttl: 7200} + reply := &dns.Msg{MsgHdr: dns.MsgHdr{Rcode: dns.RcodeNameError}, Ns: []dns.RR{soa}} + evidence := answerEvidence(reply, "www.example.test", family) + if evidence.NegativeTTL == nil || *evidence.NegativeTTL != 3600 || evidence.Failure != "" { + t.Fatalf("negative evidence=%#v", evidence) + } + soa.Minttl = 0 + if got := answerEvidence(reply, "www.example.test", family); got.NegativeTTL == nil || *got.NegativeTTL != 0 { + t.Fatal("zero negative TTL was not retained") + } + soa.Hdr.Name = "unrelated.test." + if got := answerEvidence(reply, "www.example.test", family); got.Failure != "invalid-response" { + t.Fatal("foreign SOA accepted") + } + reply.Ns = nil + if got := answerEvidence(reply, "www.example.test", family); got.NegativeTTL != nil { + t.Fatal("missing SOA invented zero TTL") + } + } + reply := &dns.Msg{Answer: []dns.RR{ + &dns.A{Hdr: dns.RR_Header{Name: "www.example.test.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, A: net.ParseIP("192.0.2.1")}, + &dns.A{Hdr: dns.RR_Header{Name: "www.example.test.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 3600}, A: net.ParseIP("192.0.2.2")}, + }} + if got := answerEvidence(reply, "www.example.test", dns.TypeA); got.PositiveTTL == nil || *got.PositiveTTL != 3600 { + t.Fatal("positive cache horizon used minimum TTL") + } +} + +func TestWithdrawalFailureRetainsLaterPositiveHorizon(t *testing.T) { + request := ProbeRequest{Name: "www.example.test", WithdrawalAddress: "192.0.2.1", Policy: externalv1alpha1.ExternalZoneClassParameters{Resolver: externalv1alpha1.ResolverPolicy{Servers: []string{"a", "b"}}}} + result := ProbeResult{Views: []View{{ID: "recursive/a", Failure: "timeout"}, withdrawalView("recursive/b", []string{"192.0.2.1"}, nil, 3600)}} + decision := EvaluateWithdrawal(request, result) + if decision.Status != metav1.ConditionUnknown || len(decision.Cache) == 0 || decision.Cache[0].PositiveTTL == nil || *decision.Cache[0].PositiveTTL != 3600 { + t.Fatalf("lost positive evidence=%#v", decision) + } +} + +func TestWithdrawalPendingMetricClosedReasons(t *testing.T) { + const unit = "test-withdrawal-metric" + t.Cleanup(func() { SetWithdrawalsPending(unit, nil) }) + SetWithdrawalsPending(unit, map[string]int{"private/unbounded": 1, "DNSStillResolves": 2}) + pendingMu.Lock() + counts := withdrawalUnits[unit] + if len(counts) != 2 || counts["DNSProbeFailed"] != 1 || counts["DNSStillResolves"] != 2 { + t.Errorf("unbounded reason labels: %#v", counts) + } + pendingMu.Unlock() + SetWithdrawalsPending(unit, nil) + pendingMu.Lock() + _, exists := withdrawalUnits[unit] + pendingMu.Unlock() + if exists { + t.Fatal("deleted unit retained pending metrics") + } +} diff --git a/pkg/go/api/dns/v1alpha1/common_types.go b/pkg/go/api/dns/v1alpha1/common_types.go index cc59cfb..9631bf0 100644 --- a/pkg/go/api/dns/v1alpha1/common_types.go +++ b/pkg/go/api/dns/v1alpha1/common_types.go @@ -18,10 +18,12 @@ type ZoneClassReference struct { type ZoneReference struct { // Namespace defaults to the RecordSet namespace when omitted. // +optional + // +kubebuilder:validation:MaxLength=63 Namespace *string `json:"namespace,omitempty"` // Name is the Zone name. // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 Name string `json:"name"` } diff --git a/pkg/go/api/dns/v1alpha1/extension_types.go b/pkg/go/api/dns/v1alpha1/extension_types.go index be0d085..43e609b 100644 --- a/pkg/go/api/dns/v1alpha1/extension_types.go +++ b/pkg/go/api/dns/v1alpha1/extension_types.go @@ -78,6 +78,10 @@ type ProviderVersion struct { // +optional RecordSet ProviderRecordSet `json:"recordSet,omitempty"` + // RecordWithdrawal declares support for independent exclusion verification. + // +optional + RecordWithdrawal *ProviderRecordWithdrawal `json:"recordWithdrawal,omitempty"` + // ZoneUnit defines provider-specific composition validations evaluated when core builds ZoneUnit. // +optional ZoneUnit ProviderZoneUnit `json:"zoneUnit,omitempty"` @@ -177,6 +181,20 @@ type ProviderRecordSet struct { Conversion ProviderConversion `json:"conversion,omitempty"` } +type ProviderRecordWithdrawal struct { + Supported bool `json:"supported"` + // +optional + Schemas ProviderRecordWithdrawalSchemas `json:"schemas,omitempty"` + // +optional + AdditionalConditionReasons []ProviderConditionReason `json:"additionalConditionReasons,omitempty"` +} + +type ProviderRecordWithdrawalSchemas struct { + // StatusProviderData validates public withdrawal provider evidence. + // +optional + StatusProviderData *ProviderOpenAPISchema `json:"statusProviderData,omitempty"` +} + type ProviderZoneUnit struct { // DisableValidations declares named core ZoneUnit composition validations disabled by a CEL condition. // +optional @@ -278,7 +296,7 @@ type ProviderValidationRule struct { type ProviderConditionReason struct { // ConditionType is the status condition type this reason applies to. - // +kubebuilder:validation:Enum=Accepted;Programmed + // +kubebuilder:validation:Enum=Accepted;Programmed;Withdrawn;Ready;OverrideAccepted ConditionType ConditionType `json:"conditionType"` // Status is the condition status this reason applies to. diff --git a/pkg/go/api/dns/v1alpha1/recordwithdrawal_test.go b/pkg/go/api/dns/v1alpha1/recordwithdrawal_test.go new file mode 100644 index 0000000..29bad01 --- /dev/null +++ b/pkg/go/api/dns/v1alpha1/recordwithdrawal_test.go @@ -0,0 +1,65 @@ +package v1alpha1_test + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/netip" + "testing" + + dnsv1alpha1 "github.com/appthrust/dns-api/pkg/go/api/dns/v1alpha1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestWithdrawalAddressLabelsAndDigests(t *testing.T) { + for _, tc := range []struct{ address, want string }{ + {"192.0.2.1", "192.0.2.1"}, + {"::ffff:192.0.2.1", "192.0.2.1"}, + {"::1", "aaaaaaaaaaaaaaaaaaaaaaaaae"}, + {"2001:db8::1", "eaaq3oaaaaaaaaaaaaaaaaaaae"}, + {"fe80::1%eth0", ""}, + } { + t.Run(tc.address, func(t *testing.T) { + if got := dnsv1alpha1.AddressLabelValue(netip.MustParseAddr(tc.address)); got != tc.want { + t.Fatalf("label=%s want=%s", got, tc.want) + } + }) + } + if got := dnsv1alpha1.AddressLabelValue(netip.Addr{}); got != "" { + t.Fatalf("invalid label=%s", got) + } + spec := dnsv1alpha1.RecordWithdrawalSpec{ZoneRef: dnsv1alpha1.ZoneReference{Name: "example"}, Name: "*.apps", Address: "192.0.2.1"} + data, err := json.Marshal(spec) + if err != nil { + t.Fatal(err) + } + digest := sha256.Sum256(data) + if got := dnsv1alpha1.WithdrawalSpecDigest(spec); got != "sha256:"+hex.EncodeToString(digest[:]) { + t.Fatal("digest does not bind exact serialized spec") + } + before := dnsv1alpha1.WithdrawalSpecDigest(spec) + namespace := "app" + spec.ZoneRef.Namespace = &namespace + if before == dnsv1alpha1.WithdrawalSpecDigest(spec) { + t.Fatal("digest ignored explicit reference namespace") + } +} + +func TestWithdrawalKindsRegisteredAndDeepCopy(t *testing.T) { + scheme := runtime.NewScheme() + if err := dnsv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"RecordWithdrawal", "RecordWithdrawalList", "RecordWithdrawalWaiver", "RecordWithdrawalWaiverList"} { + if _, err := scheme.New(dnsv1alpha1.SchemeGroupVersion.WithKind(kind)); err != nil { + t.Fatal(err) + } + } + claim := &dnsv1alpha1.RecordWithdrawal{Status: dnsv1alpha1.RecordWithdrawalStatus{RequiredViews: []string{"first"}, WaiverRef: &dnsv1alpha1.RecordWithdrawalWaiverReference{Name: "waiver"}}} + copied := claim.DeepCopy() + copied.Status.RequiredViews[0] = "changed" + copied.Status.WaiverRef.Name = "changed" + if claim.Status.RequiredViews[0] != "first" || claim.Status.WaiverRef.Name != "waiver" { + t.Fatal("deepcopy aliased public evidence") + } +} diff --git a/pkg/go/api/dns/v1alpha1/recordwithdrawal_types.go b/pkg/go/api/dns/v1alpha1/recordwithdrawal_types.go new file mode 100644 index 0000000..59d2c33 --- /dev/null +++ b/pkg/go/api/dns/v1alpha1/recordwithdrawal_types.go @@ -0,0 +1,283 @@ +package v1alpha1 + +import ( + "crypto/sha256" + "encoding/base32" + "encoding/hex" + "encoding/json" + "net/netip" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +const ( + AddressLabelKey = "endpoint.dns.appthrust.io/address" + ConditionWithdrawn ConditionType = "Withdrawn" + ConditionOverrideAccepted ConditionType = "OverrideAccepted" +) + +// AddressLabelValue encodes a normalized address as a Kubernetes label value. +// Invalid addresses return an empty value; callers must validate before creation. +func AddressLabelValue(ip netip.Addr) string { + if !ip.IsValid() || ip.Zone() != "" { + return "" + } + ip = ip.Unmap() + if ip.Is4() { + return ip.String() + } + bytes := ip.As16() + return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(bytes[:])) +} + +// WithdrawalSpecDigest binds the exact immutable wire spec. The digest is SHA-256 +// of encoding/json's struct encoding, prefixed with sha256:. +func WithdrawalSpecDigest(spec RecordWithdrawalSpec) string { + data, _ := json.Marshal(spec) + digest := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +// RecordWithdrawal is a retained request to verify exclusion of one DNS address. +// It deliberately has no owner reference or automatic cleanup finalizer. +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Address",type=string,JSONPath=`.spec.address` +// +kubebuilder:printcolumn:name="Withdrawn",type=string,JSONPath=`.status.conditions[?(@.type=="Withdrawn")].status` +type RecordWithdrawal struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec RecordWithdrawalSpec `json:"spec"` + // +optional + Status RecordWithdrawalStatus `json:"status,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec is immutable" +type RecordWithdrawalSpec struct { + ZoneRef ZoneReference `json:"zoneRef"` + // Name is a canonical zone-relative owner, including @ or a leftmost wildcard. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + // Address is a canonical, unscoped and unmapped IP literal. + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=39 + Address string `json:"address"` +} + +// WithdrawalObjectReference binds one immutable Kubernetes object incarnation. +type WithdrawalObjectReference struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + Namespace string `json:"namespace"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + // +kubebuilder:validation:Type=string + UID types.UID `json:"uid"` +} + +type RecordWithdrawalStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Zone and Unit are the exact binding used to interpret the provider result. + // +optional + Zone *WithdrawalObjectReference `json:"zone,omitempty"` + // +optional + Unit *WithdrawalObjectReference `json:"unit,omitempty"` + // +optional + ProviderRef *ProviderReference `json:"providerRef,omitempty"` + // +optional + PolicyFingerprint string `json:"policyFingerprint,omitempty"` + // +optional + // +kubebuilder:validation:MaxItems=256 + // +listType=set + CheckedNames []string `json:"checkedNames,omitempty"` + // +optional + // +kubebuilder:validation:MaxItems=256 + // +listType=set + RequiredViews []string `json:"requiredViews,omitempty"` + // +optional + SampledWildcard bool `json:"sampledWildcard,omitempty"` + // +optional + AbsentSince *metav1.Time `json:"absentSince,omitempty"` + // +optional + NotBefore *metav1.Time `json:"notBefore,omitempty"` + // +optional + LastProbeTime *metav1.Time `json:"lastProbeTime,omitempty"` + // +optional + NextProbeTime *metav1.Time `json:"nextProbeTime,omitempty"` + // +optional + VerifiedAt *metav1.Time `json:"verifiedAt,omitempty"` + // +optional + ResultDigest string `json:"resultDigest,omitempty"` + // Provider contains bounded public evidence, never private provider state. + // +optional + Provider *ProviderStatus `json:"provider,omitempty"` + // +optional + WaiverRef *RecordWithdrawalWaiverReference `json:"waiverRef,omitempty"` + // +optional + // +kubebuilder:validation:MaxItems=4 + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +type RecordWithdrawalList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []RecordWithdrawal `json:"items"` +} + +// RecordWithdrawalWaiver records separately RBAC-authorized acceptance of stale +// DNS risk. Kubernetes audit, not client-supplied identity fields, is authority. +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +type RecordWithdrawalWaiver struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec RecordWithdrawalWaiverSpec `json:"spec"` + // +optional + Status RecordWithdrawalWaiverStatus `json:"status,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec is immutable" +type RecordWithdrawalWaiverSpec struct { + WithdrawalRef WithdrawalObjectReference `json:"withdrawalRef"` + // +kubebuilder:validation:Pattern=`^sha256:[0-9a-f]{64}$` + // +kubebuilder:validation:MaxLength=71 + ClaimSpecDigest string `json:"claimSpecDigest"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=39 + Address string `json:"address"` + ReservationRef WithdrawalObjectReference `json:"reservationRef"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=2048 + Reason string `json:"reason"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=1024 + TicketRef string `json:"ticketRef"` + // +kubebuilder:validation:XValidation:rule="self == true",message="acceptStaleDNSRisk must be true" + AcceptStaleDNSRisk bool `json:"acceptStaleDNSRisk"` +} + +func WithdrawalWaiverDigest(spec RecordWithdrawalWaiverSpec) string { + data, _ := json.Marshal(spec) + digest := sha256.Sum256(data) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +type RecordWithdrawalWaiverStatus struct { + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // +optional + // +kubebuilder:validation:MaxItems=1 + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +type RecordWithdrawalWaiverReference struct { + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Type=string + UID types.UID `json:"uid"` + // +kubebuilder:validation:Pattern=`^sha256:[0-9a-f]{64}$` + Digest string `json:"digest"` + ReservationRef WithdrawalObjectReference `json:"reservationRef"` +} + +// +kubebuilder:object:root=true +type RecordWithdrawalWaiverList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []RecordWithdrawalWaiver `json:"items"` +} + +// Scalar keys duplicate the nested identity because Kubernetes map-list keys +// cannot address nested fields. CEL prevents either representation diverging. +// +kubebuilder:validation:XValidation:rule="self.claimNamespace == self.claim.namespace && self.claimName == self.claim.name",message="scalar claim keys must match claim" +type ZoneUnitWithdrawal struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + ClaimNamespace string `json:"claimNamespace"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + ClaimName string `json:"claimName"` + Claim WithdrawalObjectReference `json:"claim"` + // +kubebuilder:validation:Minimum=1 + ObservedGeneration int64 `json:"observedGeneration"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string `json:"name"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=39 + Address string `json:"address"` + Allowed bool `json:"allowed"` + // +optional + Waiver *ZoneUnitWithdrawalWaiver `json:"waiver,omitempty"` +} + +type ZoneUnitWithdrawalWaiver struct { + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:Type=string + UID types.UID `json:"uid"` + // +kubebuilder:validation:Pattern=`^sha256:[0-9a-f]{64}$` + Digest string `json:"digest"` + // ReservationRef is immutable waiver scope, not platform lookup authority. + ReservationRef WithdrawalObjectReference `json:"reservationRef"` +} + +// +kubebuilder:validation:XValidation:rule="self.claimNamespace == self.claim.namespace && self.claimName == self.claim.name",message="scalar claim keys must match claim" +type ZoneUnitWithdrawalStatus struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + ClaimNamespace string `json:"claimNamespace"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + ClaimName string `json:"claimName"` + Claim WithdrawalObjectReference `json:"claim"` + // +kubebuilder:validation:Minimum=1 + ObservedGeneration int64 `json:"observedGeneration"` + // +optional + PolicyFingerprint string `json:"policyFingerprint,omitempty"` + // +optional + // +kubebuilder:validation:MaxItems=256 + // +listType=set + CheckedNames []string `json:"checkedNames,omitempty"` + // +optional + // +kubebuilder:validation:MaxItems=256 + // +listType=set + RequiredViews []string `json:"requiredViews,omitempty"` + // +optional + SampledWildcard bool `json:"sampledWildcard,omitempty"` + // +optional + AbsentSince *metav1.Time `json:"absentSince,omitempty"` + // +optional + NotBefore *metav1.Time `json:"notBefore,omitempty"` + // +optional + LastProbeTime *metav1.Time `json:"lastProbeTime,omitempty"` + // +optional + NextProbeTime *metav1.Time `json:"nextProbeTime,omitempty"` + // +optional + VerifiedAt *metav1.Time `json:"verifiedAt,omitempty"` + // +optional + ResultDigest string `json:"resultDigest,omitempty"` + // +optional + Provider *ProviderStatus `json:"provider,omitempty"` + // +optional + WaiverRef *RecordWithdrawalWaiverReference `json:"waiverRef,omitempty"` + // +optional + // +kubebuilder:validation:MaxItems=4 + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} diff --git a/pkg/go/api/dns/v1alpha1/register.go b/pkg/go/api/dns/v1alpha1/register.go index 389425f..8d44a55 100644 --- a/pkg/go/api/dns/v1alpha1/register.go +++ b/pkg/go/api/dns/v1alpha1/register.go @@ -18,6 +18,10 @@ func addKnownTypes(scheme *runtime.Scheme) error { &ZoneUnitList{}, &RecordSet{}, &RecordSetList{}, + &RecordWithdrawal{}, + &RecordWithdrawalList{}, + &RecordWithdrawalWaiver{}, + &RecordWithdrawalWaiverList{}, ) metav1.AddToGroupVersion(scheme, SchemeGroupVersion) return nil diff --git a/pkg/go/api/dns/v1alpha1/zoneunit_types.go b/pkg/go/api/dns/v1alpha1/zoneunit_types.go index 1dc379a..21e8fda 100644 --- a/pkg/go/api/dns/v1alpha1/zoneunit_types.go +++ b/pkg/go/api/dns/v1alpha1/zoneunit_types.go @@ -36,6 +36,14 @@ type ZoneUnitSpec struct { // +listMapKey=recordSetNamespace // +listMapKey=recordSetName RecordSets []ZoneUnitRecordSetSpec `json:"recordSets,omitempty"` + + // Withdrawals retains independently authorized address-exclusion claims. + // +optional + // +kubebuilder:validation:MaxItems=256 + // +listType=map + // +listMapKey=claimNamespace + // +listMapKey=claimName + Withdrawals []ZoneUnitWithdrawal `json:"withdrawals,omitempty"` } type ZoneUnitZoneSpec struct { @@ -51,6 +59,15 @@ type ZoneUnitZoneSpec struct { // ZoneClassRef is the resolved ZoneClass reference. ZoneClassRef ObjectReference `json:"zoneClassRef"` + // ZoneClassUID and ZoneClassObservedGeneration fence policy changes before + // provider reconciliation. Missing legacy bindings cannot prove withdrawal. + // +optional + // +kubebuilder:validation:Type=string + ZoneClassUID types.UID `json:"zoneClassUID,omitempty"` + // +optional + // +kubebuilder:validation:Minimum=1 + ZoneClassObservedGeneration int64 `json:"zoneClassObservedGeneration,omitempty"` + // Adoption is provider-specific adoption payload in storage-version form. // +kubebuilder:pruning:PreserveUnknownFields // +optional @@ -163,6 +180,14 @@ type ZoneUnitStatus struct { // +listMapKey=recordSetNamespace // +listMapKey=recordSetName RecordSets []ZoneUnitRecordSetStatus `json:"recordSets,omitempty"` + + // Withdrawals contains identity-bound exclusion verification results. + // +optional + // +kubebuilder:validation:MaxItems=256 + // +listType=map + // +listMapKey=claimNamespace + // +listMapKey=claimName + Withdrawals []ZoneUnitWithdrawalStatus `json:"withdrawals,omitempty"` } type ZoneUnitZoneStatus struct { diff --git a/pkg/go/api/dns/v1alpha1/zz_generated.deepcopy.go b/pkg/go/api/dns/v1alpha1/zz_generated.deepcopy.go index b1164db..618112a 100644 --- a/pkg/go/api/dns/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/go/api/dns/v1alpha1/zz_generated.deepcopy.go @@ -584,6 +584,47 @@ func (in *ProviderRecordSetSchemas) DeepCopy() *ProviderRecordSetSchemas { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderRecordWithdrawal) DeepCopyInto(out *ProviderRecordWithdrawal) { + *out = *in + in.Schemas.DeepCopyInto(&out.Schemas) + if in.AdditionalConditionReasons != nil { + in, out := &in.AdditionalConditionReasons, &out.AdditionalConditionReasons + *out = make([]ProviderConditionReason, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderRecordWithdrawal. +func (in *ProviderRecordWithdrawal) DeepCopy() *ProviderRecordWithdrawal { + if in == nil { + return nil + } + out := new(ProviderRecordWithdrawal) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProviderRecordWithdrawalSchemas) DeepCopyInto(out *ProviderRecordWithdrawalSchemas) { + *out = *in + if in.StatusProviderData != nil { + in, out := &in.StatusProviderData, &out.StatusProviderData + *out = new(ProviderOpenAPISchema) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProviderRecordWithdrawalSchemas. +func (in *ProviderRecordWithdrawalSchemas) DeepCopy() *ProviderRecordWithdrawalSchemas { + if in == nil { + return nil + } + out := new(ProviderRecordWithdrawalSchemas) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProviderReference) DeepCopyInto(out *ProviderReference) { *out = *in @@ -665,6 +706,11 @@ func (in *ProviderVersion) DeepCopyInto(out *ProviderVersion) { in.ZoneClass.DeepCopyInto(&out.ZoneClass) in.Zone.DeepCopyInto(&out.Zone) in.RecordSet.DeepCopyInto(&out.RecordSet) + if in.RecordWithdrawal != nil { + in, out := &in.RecordWithdrawal, &out.RecordWithdrawal + *out = new(ProviderRecordWithdrawal) + (*in).DeepCopyInto(*out) + } in.ZoneUnit.DeepCopyInto(&out.ZoneUnit) } @@ -993,6 +1039,272 @@ func (in *RecordSetZoneStatus) DeepCopy() *RecordSetZoneStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawal) DeepCopyInto(out *RecordWithdrawal) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawal. +func (in *RecordWithdrawal) DeepCopy() *RecordWithdrawal { + if in == nil { + return nil + } + out := new(RecordWithdrawal) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RecordWithdrawal) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawalList) DeepCopyInto(out *RecordWithdrawalList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RecordWithdrawal, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawalList. +func (in *RecordWithdrawalList) DeepCopy() *RecordWithdrawalList { + if in == nil { + return nil + } + out := new(RecordWithdrawalList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RecordWithdrawalList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawalSpec) DeepCopyInto(out *RecordWithdrawalSpec) { + *out = *in + in.ZoneRef.DeepCopyInto(&out.ZoneRef) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawalSpec. +func (in *RecordWithdrawalSpec) DeepCopy() *RecordWithdrawalSpec { + if in == nil { + return nil + } + out := new(RecordWithdrawalSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawalStatus) DeepCopyInto(out *RecordWithdrawalStatus) { + *out = *in + if in.Zone != nil { + in, out := &in.Zone, &out.Zone + *out = new(WithdrawalObjectReference) + **out = **in + } + if in.Unit != nil { + in, out := &in.Unit, &out.Unit + *out = new(WithdrawalObjectReference) + **out = **in + } + if in.ProviderRef != nil { + in, out := &in.ProviderRef, &out.ProviderRef + *out = new(ProviderReference) + **out = **in + } + if in.CheckedNames != nil { + in, out := &in.CheckedNames, &out.CheckedNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RequiredViews != nil { + in, out := &in.RequiredViews, &out.RequiredViews + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AbsentSince != nil { + in, out := &in.AbsentSince, &out.AbsentSince + *out = (*in).DeepCopy() + } + if in.NotBefore != nil { + in, out := &in.NotBefore, &out.NotBefore + *out = (*in).DeepCopy() + } + if in.LastProbeTime != nil { + in, out := &in.LastProbeTime, &out.LastProbeTime + *out = (*in).DeepCopy() + } + if in.NextProbeTime != nil { + in, out := &in.NextProbeTime, &out.NextProbeTime + *out = (*in).DeepCopy() + } + if in.VerifiedAt != nil { + in, out := &in.VerifiedAt, &out.VerifiedAt + *out = (*in).DeepCopy() + } + if in.Provider != nil { + in, out := &in.Provider, &out.Provider + *out = new(ProviderStatus) + (*in).DeepCopyInto(*out) + } + if in.WaiverRef != nil { + in, out := &in.WaiverRef, &out.WaiverRef + *out = new(RecordWithdrawalWaiverReference) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawalStatus. +func (in *RecordWithdrawalStatus) DeepCopy() *RecordWithdrawalStatus { + if in == nil { + return nil + } + out := new(RecordWithdrawalStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawalWaiver) DeepCopyInto(out *RecordWithdrawalWaiver) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawalWaiver. +func (in *RecordWithdrawalWaiver) DeepCopy() *RecordWithdrawalWaiver { + if in == nil { + return nil + } + out := new(RecordWithdrawalWaiver) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RecordWithdrawalWaiver) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawalWaiverList) DeepCopyInto(out *RecordWithdrawalWaiverList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RecordWithdrawalWaiver, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawalWaiverList. +func (in *RecordWithdrawalWaiverList) DeepCopy() *RecordWithdrawalWaiverList { + if in == nil { + return nil + } + out := new(RecordWithdrawalWaiverList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RecordWithdrawalWaiverList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawalWaiverReference) DeepCopyInto(out *RecordWithdrawalWaiverReference) { + *out = *in + out.ReservationRef = in.ReservationRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawalWaiverReference. +func (in *RecordWithdrawalWaiverReference) DeepCopy() *RecordWithdrawalWaiverReference { + if in == nil { + return nil + } + out := new(RecordWithdrawalWaiverReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawalWaiverSpec) DeepCopyInto(out *RecordWithdrawalWaiverSpec) { + *out = *in + out.WithdrawalRef = in.WithdrawalRef + out.ReservationRef = in.ReservationRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawalWaiverSpec. +func (in *RecordWithdrawalWaiverSpec) DeepCopy() *RecordWithdrawalWaiverSpec { + if in == nil { + return nil + } + out := new(RecordWithdrawalWaiverSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RecordWithdrawalWaiverStatus) DeepCopyInto(out *RecordWithdrawalWaiverStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RecordWithdrawalWaiverStatus. +func (in *RecordWithdrawalWaiverStatus) DeepCopy() *RecordWithdrawalWaiverStatus { + if in == nil { + return nil + } + out := new(RecordWithdrawalWaiverStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TXTRecordSet) DeepCopyInto(out *TXTRecordSet) { *out = *in @@ -1028,6 +1340,21 @@ func (in *TypedLocalObjectReference) DeepCopy() *TypedLocalObjectReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WithdrawalObjectReference) DeepCopyInto(out *WithdrawalObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WithdrawalObjectReference. +func (in *WithdrawalObjectReference) DeepCopy() *WithdrawalObjectReference { + if in == nil { + return nil + } + out := new(WithdrawalObjectReference) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Zone) DeepCopyInto(out *Zone) { *out = *in @@ -1464,6 +1791,13 @@ func (in *ZoneUnitSpec) DeepCopyInto(out *ZoneUnitSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Withdrawals != nil { + in, out := &in.Withdrawals, &out.Withdrawals + *out = make([]ZoneUnitWithdrawal, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneUnitSpec. @@ -1503,6 +1837,13 @@ func (in *ZoneUnitStatus) DeepCopyInto(out *ZoneUnitStatus) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.Withdrawals != nil { + in, out := &in.Withdrawals, &out.Withdrawals + *out = make([]ZoneUnitWithdrawalStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneUnitStatus. @@ -1530,6 +1871,106 @@ func (in *ZoneUnitValidationToggle) DeepCopy() *ZoneUnitValidationToggle { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ZoneUnitWithdrawal) DeepCopyInto(out *ZoneUnitWithdrawal) { + *out = *in + out.Claim = in.Claim + if in.Waiver != nil { + in, out := &in.Waiver, &out.Waiver + *out = new(ZoneUnitWithdrawalWaiver) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneUnitWithdrawal. +func (in *ZoneUnitWithdrawal) DeepCopy() *ZoneUnitWithdrawal { + if in == nil { + return nil + } + out := new(ZoneUnitWithdrawal) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ZoneUnitWithdrawalStatus) DeepCopyInto(out *ZoneUnitWithdrawalStatus) { + *out = *in + out.Claim = in.Claim + if in.CheckedNames != nil { + in, out := &in.CheckedNames, &out.CheckedNames + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.RequiredViews != nil { + in, out := &in.RequiredViews, &out.RequiredViews + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AbsentSince != nil { + in, out := &in.AbsentSince, &out.AbsentSince + *out = (*in).DeepCopy() + } + if in.NotBefore != nil { + in, out := &in.NotBefore, &out.NotBefore + *out = (*in).DeepCopy() + } + if in.LastProbeTime != nil { + in, out := &in.LastProbeTime, &out.LastProbeTime + *out = (*in).DeepCopy() + } + if in.NextProbeTime != nil { + in, out := &in.NextProbeTime, &out.NextProbeTime + *out = (*in).DeepCopy() + } + if in.VerifiedAt != nil { + in, out := &in.VerifiedAt, &out.VerifiedAt + *out = (*in).DeepCopy() + } + if in.Provider != nil { + in, out := &in.Provider, &out.Provider + *out = new(ProviderStatus) + (*in).DeepCopyInto(*out) + } + if in.WaiverRef != nil { + in, out := &in.WaiverRef, &out.WaiverRef + *out = new(RecordWithdrawalWaiverReference) + **out = **in + } + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneUnitWithdrawalStatus. +func (in *ZoneUnitWithdrawalStatus) DeepCopy() *ZoneUnitWithdrawalStatus { + if in == nil { + return nil + } + out := new(ZoneUnitWithdrawalStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ZoneUnitWithdrawalWaiver) DeepCopyInto(out *ZoneUnitWithdrawalWaiver) { + *out = *in + out.ReservationRef = in.ReservationRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ZoneUnitWithdrawalWaiver. +func (in *ZoneUnitWithdrawalWaiver) DeepCopy() *ZoneUnitWithdrawalWaiver { + if in == nil { + return nil + } + out := new(ZoneUnitWithdrawalWaiver) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ZoneUnitZoneSpec) DeepCopyInto(out *ZoneUnitZoneSpec) { *out = *in diff --git a/pkg/go/api/endpoint/v1alpha1/endpointrecordset_types.go b/pkg/go/api/endpoint/v1alpha1/endpointrecordset_types.go index 50d185b..bc9de83 100644 --- a/pkg/go/api/endpoint/v1alpha1/endpointrecordset_types.go +++ b/pkg/go/api/endpoint/v1alpha1/endpointrecordset_types.go @@ -11,8 +11,11 @@ import ( type EndpointTargetType string const ( - EndpointTargetTypeHostname EndpointTargetType = "Hostname" - EndpointTargetTypeIPAddress EndpointTargetType = "IPAddress" + EndpointTargetTypeHostname EndpointTargetType = "Hostname" + EndpointTargetTypeIPAddress EndpointTargetType = "IPAddress" + GeneratedRecordSetsFinalizer = "endpoint.dns.appthrust.io/generated-recordsets" + ReasonRetiring = "Retiring" + ReasonWithdrawalCapturePending = "WithdrawalCapturePending" ) // EndpointTarget is one provider-neutral endpoint address. @@ -38,16 +41,61 @@ type EndpointRecordSet struct { Status EndpointRecordSetStatus `json:"status,omitempty"` } +// +kubebuilder:validation:XValidation:rule="(has(self.hostnames) && size(self.hostnames) > 0 && has(self.targets) && size(self.targets) > 0) || (has(self.retiredTargets) && size(self.retiredTargets) > 0) || (has(self.retiredHostnames) && size(self.retiredHostnames) > 0)",message="nonempty hostname and target intent or retirement entries are required" type EndpointRecordSetSpec struct { // Hostnames are fully qualified DNS hostnames to publish. + // +optional + // +listType=set + Hostnames []string `json:"hostnames,omitempty"` + + // Targets are endpoint addresses that the hostnames should publish. + // +optional + // +listType=atomic + Targets []EndpointTarget `json:"targets,omitempty"` + + // RetiredTargets preserves removed addresses with their old hostname snapshot. + // +optional + // +kubebuilder:validation:MaxItems=64 + // +listType=map + // +listMapKey=id + RetiredTargets []RetiredTarget `json:"retiredTargets,omitempty"` + + // RetiredHostnames preserves removed names with their old address snapshot. + // +optional + // +kubebuilder:validation:MaxItems=256 + // +listType=map + // +listMapKey=id + RetiredHostnames []RetiredHostname `json:"retiredHostnames,omitempty"` +} + +type RetiredTarget struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + ID string `json:"id"` + // +kubebuilder:validation:Minimum=1 + SourceGeneration int64 `json:"sourceGeneration"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=39 + Address string `json:"address"` // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=256 // +listType=set Hostnames []string `json:"hostnames"` +} - // Targets are endpoint addresses that the hostnames should publish. +type RetiredHostname struct { + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=128 + ID string `json:"id"` + // +kubebuilder:validation:Minimum=1 + SourceGeneration int64 `json:"sourceGeneration"` + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Hostname string `json:"hostname"` // +kubebuilder:validation:MinItems=1 - // +listType=atomic - Targets []EndpointTarget `json:"targets"` + // +kubebuilder:validation:MaxItems=64 + // +listType=set + Addresses []string `json:"addresses"` } type EndpointRecordSetStatus struct { diff --git a/pkg/go/api/endpoint/v1alpha1/zz_generated.deepcopy.go b/pkg/go/api/endpoint/v1alpha1/zz_generated.deepcopy.go index 1717560..44f5ff1 100644 --- a/pkg/go/api/endpoint/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/go/api/endpoint/v1alpha1/zz_generated.deepcopy.go @@ -292,6 +292,20 @@ func (in *EndpointRecordSetSpec) DeepCopyInto(out *EndpointRecordSetSpec) { *out = make([]EndpointTarget, len(*in)) copy(*out, *in) } + if in.RetiredTargets != nil { + in, out := &in.RetiredTargets, &out.RetiredTargets + *out = make([]RetiredTarget, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.RetiredHostnames != nil { + in, out := &in.RetiredHostnames, &out.RetiredHostnames + *out = make([]RetiredHostname, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EndpointRecordSetSpec. @@ -400,3 +414,43 @@ func (in *RecordSetSpecFragment) DeepCopy() *RecordSetSpecFragment { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RetiredHostname) DeepCopyInto(out *RetiredHostname) { + *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RetiredHostname. +func (in *RetiredHostname) DeepCopy() *RetiredHostname { + if in == nil { + return nil + } + out := new(RetiredHostname) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RetiredTarget) DeepCopyInto(out *RetiredTarget) { + *out = *in + if in.Hostnames != nil { + in, out := &in.Hostnames, &out.Hostnames + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RetiredTarget. +func (in *RetiredTarget) DeepCopy() *RetiredTarget { + if in == nil { + return nil + } + out := new(RetiredTarget) + in.DeepCopyInto(out) + return out +}