Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions api/v1alpha2/dnsrecord_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ type DNSRecordEntry struct {
// +optional
Group string `json:"group,omitempty"`

// groups are the UI groups this entry belongs to (the sreportal.io/groups
// annotation, comma-separated). Supports multiple groups, unlike the single
// group field. Set by the DNS controller for origin=auto entries from the
// source resource annotation; may be set directly on manual entries.
// +optional
Groups []string `json:"groups,omitempty"`

// +optional
Description string `json:"description,omitempty"`

Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions config/crd/bases/sreportal.io_dnsrecords.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,15 @@ spec:
type: string
group:
type: string
groups:
description: |-
groups are the UI groups this entry belongs to (the sreportal.io/groups
annotation, comma-separated). Supports multiple groups, unlike the single
group field. Set by the DNS controller for origin=auto entries from the
source resource annotation; may be set directly on manual entries.
items:
type: string
type: array
originRef:
description: |-
originRef identifies the source Kubernetes resource that produced this
Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/api/crds.md
Original file line number Diff line number Diff line change
Expand Up @@ -1452,6 +1452,7 @@ _Appears in:_
| --- | --- | --- | --- |
| `fqdn` _string_ | | | Pattern: `^([a-zA-Z0-9]([a-zA-Z0-9-]\{0,61\}[a-zA-Z0-9])?\.)+[a-zA-Z]\{2,\}$` |
| `group` _string_ | | | |
| `groups` _string array_ | groups are the UI groups this entry belongs to (the sreportal.io/groups annotation, comma-separated). Supports multiple groups, unlike the single group field. Set by the DNS controller for origin=auto entries from the source resource annotation; may be set directly on manual entries. | | |
| `description` _string_ | | | |
| `recordType` _string_ | | | Enum: [A AAAA CNAME TXT] |
| `targets` _string array_ | | | |
Expand Down
9 changes: 9 additions & 0 deletions helm/templates/dnsrecord-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,15 @@ spec:
type: string
group:
type: string
groups:
description: |-
groups are the UI groups this entry belongs to (the sreportal.io/groups
annotation, comma-separated). Supports multiple groups, unlike the single
group field. Set by the DNS controller for origin=auto entries from the
source resource annotation; may be set directly on manual entries.
items:
type: string
type: array
originRef:
description: |-
originRef identifies the source Kubernetes resource that produced this
Expand Down
55 changes: 55 additions & 0 deletions internal/controller/dns/chain/groups_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package dns_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/external-dns/endpoint"

sreportalv1alpha2 "github.com/golgoth31/sreportal/api/v1alpha2"
dnschain "github.com/golgoth31/sreportal/internal/controller/dns/chain"
"github.com/golgoth31/sreportal/internal/reconciler"
"github.com/golgoth31/sreportal/internal/source/registry"
"github.com/golgoth31/sreportal/internal/source/service"
)

// TestUpsertDNSRecordsHandler_PropagatesGroups verifies the multi-group
// sreportal.io/groups label is parsed into DNSRecordEntry.Groups.
func TestUpsertDNSRecordsHandler_PropagatesGroups(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, sreportalv1alpha2.AddToScheme(scheme))

dns := &sreportalv1alpha2.DNS{
ObjectMeta: metav1.ObjectMeta{Name: "d", Namespace: upsertTestNS1, UID: "u1"},
Spec: sreportalv1alpha2.DNSSpec{PortalRef: "p"},
}
c := fake.NewClientBuilder().
WithScheme(scheme).
WithStatusSubresource(&sreportalv1alpha2.DNSRecord{}).
WithObjects(dns).
Build()

ep := endpoint.NewEndpoint("a.example.com", "A", upsertTestTargetA).
WithLabel("sreportal.io/groups", "Team A, Shared")

h := &dnschain.UpsertDNSRecordsHandler{Client: c}
rc := &reconciler.ReconcileContext[*sreportalv1alpha2.DNS, dnschain.ChainData]{
Resource: dns,
Data: dnschain.ChainData{
KeptEndpointsByKind: map[registry.SourceType][]*endpoint.Endpoint{
service.SourceTypeService: {ep},
},
},
}
require.NoError(t, h.Handle(context.Background(), rc))

var created sreportalv1alpha2.DNSRecord
require.NoError(t, c.Get(context.Background(), types.NamespacedName{Namespace: upsertTestNS1, Name: upsertTestRecord}, &created))
require.Len(t, created.Spec.Entries, 1)
require.Equal(t, []string{"Team A", "Shared"}, created.Spec.Entries[0].Groups)
}
7 changes: 7 additions & 0 deletions internal/controller/dns/chain/upsert_dnsrecords.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"sigs.k8s.io/external-dns/endpoint"

sreportalv1alpha2 "github.com/golgoth31/sreportal/api/v1alpha2"
domaindns "github.com/golgoth31/sreportal/internal/domain/dns"
"github.com/golgoth31/sreportal/internal/reconciler"
"github.com/golgoth31/sreportal/internal/source/registry"
)
Expand Down Expand Up @@ -119,6 +120,12 @@ func endpointsToEntries(eps []*endpoint.Endpoint) []sreportalv1alpha2.DNSRecordE
if g, gok := e.Labels["sreportal.io/group"]; gok {
entry.Group = g
}
// Carry the multi-group sreportal.io/groups annotation (folded onto
// the endpoint labels by the source cycle) so the UI grouping sees
// it after materialisation.
if g := domaindns.SplitGroups(e.Labels[domaindns.GroupsAnnotationKey]); len(g) > 0 {
entry.Groups = g
}
// Carry the external-dns "resource" label (kind/namespace/name) so
// the origin survives the spec.entries hop and the FQDN card can
// display it. The upstream IntraDNSDedupHandler already collapsed
Expand Down
34 changes: 34 additions & 0 deletions internal/controller/dnsrecords/chain/groups_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package chain_test

import (
"context"
"testing"

. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

v1alpha2 "github.com/golgoth31/sreportal/api/v1alpha2"
"github.com/golgoth31/sreportal/internal/controller/dnsrecords/chain"
"github.com/golgoth31/sreportal/internal/reconciler"
)

// TestMaterialiseEntriesHandler_ReinjectsGroups verifies entry.Groups is
// re-injected into the status endpoint's sreportal.io/groups label so the
// read-side group mapping projects the FQDN into all its groups.
func TestMaterialiseEntriesHandler_ReinjectsGroups(t *testing.T) {
g := NewWithT(t)
record := &v1alpha2.DNSRecord{
ObjectMeta: metav1.ObjectMeta{Name: "auto-groups", Namespace: tNsDefault},
Spec: v1alpha2.DNSRecordSpec{
Origin: v1alpha2.DNSRecordOriginAuto,
PortalRef: tPortalMain,
Entries: []v1alpha2.DNSRecordEntry{
{FQDN: "a.example.com", RecordType: "A", Targets: []string{tIP1234}, Groups: []string{"Team A", "Shared"}},
},
},
}
rc := &reconciler.ReconcileContext[*v1alpha2.DNSRecord, chain.ChainData]{Resource: record}
g.Expect(chain.NewMaterialiseEntriesHandler(nil).Handle(context.Background(), rc)).To(Succeed())
g.Expect(record.Status.Endpoints).To(HaveLen(1))
g.Expect(record.Status.Endpoints[0].Labels["sreportal.io/groups"]).To(Equal("Team A,Shared"))
}
11 changes: 11 additions & 0 deletions internal/controller/dnsrecords/chain/materialise_entries.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ package chain
import (
"context"
"fmt"
"strings"

metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/external-dns/endpoint"

v1alpha2 "github.com/golgoth31/sreportal/api/v1alpha2"
"github.com/golgoth31/sreportal/internal/adapter"
domaindns "github.com/golgoth31/sreportal/internal/domain/dns"
"github.com/golgoth31/sreportal/internal/reconciler"
)

Expand Down Expand Up @@ -55,6 +57,15 @@ func (h *MaterialiseEntriesHandler) Handle(ctx context.Context, rc *reconciler.R
if e.Group != "" {
labels = map[string]string{"sreportal.io/group": e.Group}
}
// Re-inject the multi-group annotation so the read-side group mapping
// (GroupMappingStrategy.Resolve, priority 1) projects the entry into all
// its groups in the UI.
if len(e.Groups) > 0 {
if labels == nil {
labels = map[string]string{}
}
labels[domaindns.GroupsAnnotationKey] = strings.Join(e.Groups, ",")
}
// Re-inject the source resource (kind/namespace/name) into the external-dns
// "resource" label so the adapter can derive FQDNView.OriginRef. Excluded
// from the endpoints hash, so it never causes reconcile churn.
Expand Down
9 changes: 9 additions & 0 deletions internal/controller/source/cycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
"sigs.k8s.io/external-dns/endpoint"

sreportalv1alpha2 "github.com/golgoth31/sreportal/api/v1alpha2"
"github.com/golgoth31/sreportal/internal/adapter"
domainsource "github.com/golgoth31/sreportal/internal/domain/source"
"github.com/golgoth31/sreportal/internal/metrics"
sourcepkg "github.com/golgoth31/sreportal/internal/source"
Expand Down Expand Up @@ -105,6 +106,14 @@ func Cycle(
if _, ok := ep.Labels[endpoint.ResourceLabelKey]; !ok {
ep.Labels[endpoint.ResourceLabelKey] = fmt.Sprintf("%s/%s/%s", kind, obj.GetNamespace(), obj.GetName())
}
// Fold the allowlisted sreportal annotations onto the endpoint
// labels via the shared enrichment helper. On the auto DNS path
// only sreportal.io/groups is consumed downstream (carried into
// spec.entries -> status -> UI grouping); the other allowlisted
// annotations ride along but are inert here. ep is freshly
// resolved (owned here, not yet shared via the store), so
// mutating it is safe.
adapter.EnrichEndpointLabels(ep, obj.GetAnnotations())
entries = append(entries, domainsource.EnrichedEndpoint{
Endpoint: ep,
Kind: kind,
Expand Down
62 changes: 62 additions & 0 deletions internal/controller/source/groups_enrich_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package source_test

import (
"context"
"testing"

"github.com/stretchr/testify/require"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

sreportalv1alpha2 "github.com/golgoth31/sreportal/api/v1alpha2"
srccontrol "github.com/golgoth31/sreportal/internal/controller/source"
rsource "github.com/golgoth31/sreportal/internal/readstore/source"
"github.com/golgoth31/sreportal/internal/source/registry"
svcsrc "github.com/golgoth31/sreportal/internal/source/service"
)

// TestCycle_FoldsGroupsAnnotationOntoEndpoint verifies the source cycle copies
// the sreportal.io/groups annotation from the resource onto the endpoint labels
// (via adapter.EnrichEndpointLabels), so downstream grouping can see it.
func TestCycle_FoldsGroupsAnnotationOntoEndpoint(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, sreportalv1alpha2.AddToScheme(scheme))
require.NoError(t, corev1.AddToScheme(scheme))

dns := &sreportalv1alpha2.DNS{
ObjectMeta: metav1.ObjectMeta{Name: "d", Namespace: tTeamA},
Spec: sreportalv1alpha2.DNSSpec{
PortalRef: tTeamA,
Sources: sreportalv1alpha2.SourcesSpec{
Service: &sreportalv1alpha2.ServiceSourceSpec{
CommonSourceSpec: sreportalv1alpha2.CommonSourceSpec{Enabled: true},
},
},
},
}
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: "grpsvc", Namespace: tTeamA,
Annotations: map[string]string{
"external-dns.alpha.kubernetes.io/hostname": "grpsvc.example.com",

Check failure on line 43 in internal/controller/source/groups_enrich_test.go

View workflow job for this annotation

GitHub Actions / Run on Ubuntu

string `external-dns.alpha.kubernetes.io/hostname` has 3 occurrences, make it a constant (goconst)
"sreportal.io/groups": "Team A, Shared",
},
},
Status: corev1.ServiceStatus{LoadBalancer: corev1.LoadBalancerStatus{
Ingress: []corev1.LoadBalancerIngress{{IP: tLBIP}},
}},
}
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(dns, svc).Build()
reg := registry.NewRegistry(svcsrc.NewResolver())
store := rsource.NewStore()

_ = srccontrol.Cycle(context.Background(), c, reg, store, nil)

got, err := store.Lookup(svcsrc.SourceTypeService, tTeamA, "")
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, "Team A, Shared", got[0].Endpoint.Labels["sreportal.io/groups"],
"sreportal.io/groups annotation must be folded onto the endpoint labels")
}
32 changes: 21 additions & 11 deletions internal/domain/dns/group_mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,31 @@ type GroupMappingStrategy struct {
ByNamespace map[string]string
}

// SplitGroups parses a comma-separated sreportal.io/groups value into trimmed,
// non-empty group names. Returns nil when the input is empty or whitespace-only.
func SplitGroups(csv string) []string {
if csv == "" {
return nil
}
parts := strings.Split(csv, ",")
groups := make([]string, 0, len(parts))
for _, p := range parts {
if g := strings.TrimSpace(p); g != "" {
groups = append(groups, g)
}
}
if len(groups) == 0 {
return nil
}
return groups
}

// Resolve returns the group names for an endpoint identified by its labels and
// namespace. It always returns at least one element.
func (s GroupMappingStrategy) Resolve(labels map[string]string, namespace string) []string {
// 1. sreportal.io/groups annotation — highest priority, comma-separated.
if val := labels[GroupsAnnotationKey]; val != "" {
parts := strings.Split(val, ",")
groups := make([]string, 0, len(parts))
for _, p := range parts {
if g := strings.TrimSpace(p); g != "" {
groups = append(groups, g)
}
}
if len(groups) > 0 {
return groups
}
if groups := SplitGroups(labels[GroupsAnnotationKey]); len(groups) > 0 {
return groups
}

// 2. Configured label key.
Expand Down
26 changes: 26 additions & 0 deletions internal/domain/dns/splitgroups_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package dns_test

import (
"reflect"
"testing"

dns "github.com/golgoth31/sreportal/internal/domain/dns"
)

func TestSplitGroups(t *testing.T) {
cases := []struct {
in string
want []string
}{
{"", nil},
{" ", nil},
{",, ,", nil},
{"a", []string{"a"}},
{"a,b , c ", []string{"a", "b", "c"}},
}
for _, tc := range cases {
if got := dns.SplitGroups(tc.in); !reflect.DeepEqual(got, tc.want) {
t.Errorf("SplitGroups(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
Loading