Skip to content

Commit a6ce1f7

Browse files
dilyevskyclaude
andcommitted
[api] add DomainRecord type to core/v1alpha3
Introduce a new cluster-scoped DomainRecord resource that represents a single DNS record, replacing the monolithic Domain type which bundled all record types into one object. Each DomainRecord carries one DNS record type (derived from the populated target.dns field) or a ref target, making per-record status tracking and UI display straightforward. Key design decisions: - metadata.name is auto-generated as <spec.name>.<field-key> via PrepareForCreate (e.g. "example.com.ips", "api.example.com.ref") - spec.name, spec.zone, and target field key are immutable after creation - Exactly one DNS field must be populated when target.dns is set - tls is only valid with target.ref - Field selectors on spec.zone, spec.name, status.type - ConvertToTable with NAME, TYPE, ZONE, TTL, TARGET, READY, AGE columns Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bc82435 commit a6ce1f7

File tree

17 files changed

+1557
-0
lines changed

17 files changed

+1557
-0
lines changed

api/core/v1alpha3/domainrecord_types.go

Lines changed: 488 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
package v1alpha3
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
"k8s.io/apimachinery/pkg/runtime"
9+
"k8s.io/apimachinery/pkg/util/validation/field"
10+
"sigs.k8s.io/apiserver-runtime/pkg/builder/resource/resourcestrategy"
11+
)
12+
13+
var _ resourcestrategy.Defaulter = &DomainRecord{}
14+
15+
// Default sets the default values for a DomainRecord.
16+
func (r *DomainRecord) Default() {
17+
if r.Spec.TTL == nil {
18+
defaultTTL := int32(300)
19+
r.Spec.TTL = &defaultTTL
20+
}
21+
}
22+
23+
var _ resourcestrategy.PrepareForCreater = &DomainRecord{}
24+
25+
// PrepareForCreate generates metadata.name from spec.name and the target field key.
26+
func (r *DomainRecord) PrepareForCreate(ctx context.Context) {
27+
suffix := "ref"
28+
if r.Spec.Target.DNS != nil {
29+
if key := r.Spec.Target.DNS.DNSFieldKey(); key != "" {
30+
suffix = key
31+
}
32+
}
33+
r.Name = fmt.Sprintf("%s.%s", r.Spec.Name, suffix)
34+
r.GenerateName = ""
35+
}
36+
37+
var _ resourcestrategy.Validater = &DomainRecord{}
38+
var _ resourcestrategy.ValidateUpdater = &DomainRecord{}
39+
40+
func (r *DomainRecord) Validate(ctx context.Context) field.ErrorList {
41+
return r.validate()
42+
}
43+
44+
func (r *DomainRecord) ValidateUpdate(ctx context.Context, obj runtime.Object) field.ErrorList {
45+
old := obj.(*DomainRecord)
46+
errs := r.validate()
47+
48+
// Reject changes to spec.name.
49+
if r.Spec.Name != old.Spec.Name {
50+
errs = append(errs, field.Forbidden(
51+
field.NewPath("spec", "name"),
52+
"field is immutable after creation",
53+
))
54+
}
55+
56+
// Reject changes to spec.zone.
57+
if r.Spec.Zone != old.Spec.Zone {
58+
errs = append(errs, field.Forbidden(
59+
field.NewPath("spec", "zone"),
60+
"field is immutable after creation",
61+
))
62+
}
63+
64+
// Reject changes to the target field key (e.g. switching from ips to txt).
65+
oldKey := targetFieldKey(old)
66+
newKey := targetFieldKey(r)
67+
if oldKey != newKey {
68+
errs = append(errs, field.Forbidden(
69+
field.NewPath("spec", "target"),
70+
fmt.Sprintf("cannot change target field key from %q to %q", oldKey, newKey),
71+
))
72+
}
73+
74+
return errs
75+
}
76+
77+
// targetFieldKey returns the target field key for a DomainRecord.
78+
func targetFieldKey(r *DomainRecord) string {
79+
if r.Spec.Target.Ref != nil {
80+
return "ref"
81+
}
82+
if r.Spec.Target.DNS != nil {
83+
return r.Spec.Target.DNS.DNSFieldKey()
84+
}
85+
return ""
86+
}
87+
88+
func (r *DomainRecord) validate() field.ErrorList {
89+
errs := field.ErrorList{}
90+
specPath := field.NewPath("spec")
91+
92+
// spec.name is required.
93+
if r.Spec.Name == "" {
94+
errs = append(errs, field.Required(specPath.Child("name"), "DNS record name is required"))
95+
} else {
96+
// Basic DNS name validation: must be <= 253 chars, segments <= 63 chars.
97+
if len(r.Spec.Name) > 253 {
98+
errs = append(errs, field.Invalid(specPath.Child("name"), r.Spec.Name, "must be no more than 253 characters"))
99+
}
100+
for _, label := range strings.Split(r.Spec.Name, ".") {
101+
if len(label) > 63 {
102+
errs = append(errs, field.Invalid(specPath.Child("name"), r.Spec.Name, "each label must be no more than 63 characters"))
103+
break
104+
}
105+
}
106+
}
107+
108+
// TTL range validation.
109+
if r.Spec.TTL != nil {
110+
if *r.Spec.TTL < 0 || *r.Spec.TTL > 86400 {
111+
errs = append(errs, field.Invalid(specPath.Child("ttl"), *r.Spec.TTL, "must be between 0 and 86400"))
112+
}
113+
}
114+
115+
targetPath := specPath.Child("target")
116+
117+
// Target: exactly one of DNS or Ref must be set.
118+
hasDNS := r.Spec.Target.DNS != nil
119+
hasRef := r.Spec.Target.Ref != nil
120+
if !hasDNS && !hasRef {
121+
errs = append(errs, field.Required(targetPath, "exactly one of dns or ref must be set"))
122+
}
123+
if hasDNS && hasRef {
124+
errs = append(errs, field.Forbidden(targetPath, "cannot set both dns and ref"))
125+
}
126+
127+
// When DNS is set, exactly one field must be populated.
128+
if hasDNS {
129+
count := r.Spec.Target.DNS.PopulatedFieldCount()
130+
if count == 0 {
131+
errs = append(errs, field.Required(targetPath.Child("dns"), "exactly one DNS field must be populated"))
132+
}
133+
if count > 1 {
134+
errs = append(errs, field.Forbidden(targetPath.Child("dns"), "exactly one DNS field must be populated, found multiple"))
135+
}
136+
}
137+
138+
// tls is only valid when target.ref is set.
139+
if r.Spec.TLS != nil && !hasRef {
140+
errs = append(errs, field.Forbidden(specPath.Child("tls"), "tls is only valid when target.ref is set"))
141+
}
142+
143+
// Validate TLS certificate authority if set.
144+
if r.Spec.TLS != nil {
145+
ca := r.Spec.TLS.CertificateAuthority
146+
if ca != "" && ca != "letsencrypt" {
147+
errs = append(errs, field.Forbidden(specPath.Child("tls", "certificateAuthority"), "unsupported certificate authority"))
148+
}
149+
}
150+
151+
return errs
152+
}

api/core/v1alpha3/zz_generated.deepcopy.go

Lines changed: 218 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/core/v1alpha3/zz_generated.register.go

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)