initial commit

This commit is contained in:
2025-12-25 22:10:57 +00:00
commit 8adb52608f
46 changed files with 7570 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
// Package discovery provides automatic resource type discovery for Kubernetes clusters.
package discovery
import (
"context"
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/discovery"
"k8s.io/client-go/rest"
"github.com/lukaszraczylo/kubemirror/pkg/config"
)
// ResourceDiscovery discovers all mirrorable resource types in a cluster.
type ResourceDiscovery struct {
discoveryClient discovery.DiscoveryInterface
}
// NewResourceDiscovery creates a new resource discovery client.
func NewResourceDiscovery(cfg *rest.Config) (*ResourceDiscovery, error) {
dc, err := discovery.NewDiscoveryClientForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create discovery client: %w", err)
}
return &ResourceDiscovery{
discoveryClient: dc,
}, nil
}
// DiscoverMirrorableResources discovers all resource types that can be mirrored.
// It filters out resources that shouldn't be mirrored based on a deny list.
func (d *ResourceDiscovery) DiscoverMirrorableResources(ctx context.Context) ([]config.ResourceType, error) {
// Get all API resources in the cluster
_, apiResourceLists, err := d.discoveryClient.ServerGroupsAndResources()
if err != nil {
// Partial errors are common (some APIs might not be fully available)
// Continue with what we have
if !discovery.IsGroupDiscoveryFailedError(err) {
return nil, fmt.Errorf("failed to discover API resources: %w", err)
}
}
var resources []config.ResourceType
seen := make(map[string]bool) // Deduplicate
for _, apiResourceList := range apiResourceLists {
gv, err := schema.ParseGroupVersion(apiResourceList.GroupVersion)
if err != nil {
continue
}
for _, apiResource := range apiResourceList.APIResources {
// Skip subresources (status, scale, etc.)
if strings.Contains(apiResource.Name, "/") {
continue
}
// Skip if not namespaced (we only mirror namespaced resources)
if !apiResource.Namespaced {
continue
}
// Skip if resource doesn't support required verbs
if !supportsRequiredVerbs(apiResource.Verbs) {
continue
}
// Skip denied resource types
if isDeniedResourceType(apiResource.Kind) {
continue
}
rt := config.ResourceType{
Group: gv.Group,
Version: gv.Version,
Kind: apiResource.Kind,
}
// Deduplicate by string representation
key := rt.String()
if seen[key] {
continue
}
seen[key] = true
resources = append(resources, rt)
}
}
return resources, nil
}
// supportsRequiredVerbs checks if a resource supports the verbs needed for mirroring.
func supportsRequiredVerbs(verbs metav1.Verbs) bool {
required := []string{"get", "list", "watch", "create", "update", "delete"}
verbSet := make(map[string]bool)
for _, v := range verbs {
verbSet[v] = true
}
for _, req := range required {
if !verbSet[req] {
return false
}
}
return true
}
// isDeniedResourceType checks if a resource type should never be mirrored.
var deniedKinds = map[string]bool{
// Kubernetes core resources that shouldn't be mirrored
"Pod": true,
"Node": true,
"Event": true,
"Endpoints": true,
"EndpointSlice": true,
"ComponentStatus": true,
"Binding": true,
"ReplicationController": true, // Deprecated, use Deployment
// Resources that are auto-generated or managed
"ControllerRevision": true,
"PodMetrics": true,
"NodeMetrics": true,
// Lease resources (used for leader election)
"Lease": true,
// CSI and storage resources
"CSIDriver": true,
"CSINode": true,
"CSIStorageCapacity": true,
"VolumeAttachment": true,
// Cluster-scoped resources that we filtered out but double-check
"Namespace": true,
"PersistentVolume": true,
"ClusterRole": true,
"ClusterRoleBinding": true,
"CustomResourceDefinition": true,
"APIService": true,
"ValidatingWebhookConfiguration": true,
"MutatingWebhookConfiguration": true,
}
func isDeniedResourceType(kind string) bool {
return deniedKinds[kind]
}
+88
View File
@@ -0,0 +1,88 @@
package discovery
import (
"testing"
"github.com/stretchr/testify/assert"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func TestSupportsRequiredVerbs(t *testing.T) {
tests := []struct {
name string
verbs metav1.Verbs
want bool
}{
{
name: "all required verbs present",
verbs: metav1.Verbs{"get", "list", "watch", "create", "update", "patch", "delete"},
want: true,
},
{
name: "exact required verbs",
verbs: metav1.Verbs{"get", "list", "watch", "create", "update", "delete"},
want: true,
},
{
name: "missing create verb",
verbs: metav1.Verbs{"get", "list", "watch", "update", "delete"},
want: false,
},
{
name: "missing watch verb",
verbs: metav1.Verbs{"get", "list", "create", "update", "delete"},
want: false,
},
{
name: "read-only resource",
verbs: metav1.Verbs{"get", "list", "watch"},
want: false,
},
{
name: "empty verbs",
verbs: metav1.Verbs{},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := supportsRequiredVerbs(tt.verbs)
assert.Equal(t, tt.want, got)
})
}
}
func TestIsDeniedResourceType(t *testing.T) {
tests := []struct {
name string
kind string
want bool
}{
// Should be denied
{name: "Pod", kind: "Pod", want: true},
{name: "Event", kind: "Event", want: true},
{name: "Endpoints", kind: "Endpoints", want: true},
{name: "Node", kind: "Node", want: true},
{name: "Lease", kind: "Lease", want: true},
{name: "Namespace", kind: "Namespace", want: true},
{name: "ClusterRole", kind: "ClusterRole", want: true},
// Should NOT be denied
{name: "Secret", kind: "Secret", want: false},
{name: "ConfigMap", kind: "ConfigMap", want: false},
{name: "Service", kind: "Service", want: false},
{name: "Ingress", kind: "Ingress", want: false},
{name: "Deployment", kind: "Deployment", want: false},
{name: "StatefulSet", kind: "StatefulSet", want: false},
{name: "Middleware", kind: "Middleware", want: false},
{name: "Certificate", kind: "Certificate", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isDeniedResourceType(tt.kind)
assert.Equal(t, tt.want, got, "isDeniedResourceType(%s) = %v, want %v", tt.kind, got, tt.want)
})
}
}
+191
View File
@@ -0,0 +1,191 @@
package discovery
import (
"context"
"fmt"
"sync"
"time"
"github.com/go-logr/logr"
"sigs.k8s.io/controller-runtime/pkg/log"
"github.com/lukaszraczylo/kubemirror/pkg/config"
)
// Manager handles periodic resource discovery and controller registration.
type Manager struct {
discovery *ResourceDiscovery
logger logr.Logger
currentResources []config.ResourceType
interval time.Duration
mu sync.RWMutex
}
// NewManager creates a new discovery manager.
func NewManager(discovery *ResourceDiscovery, interval time.Duration) *Manager {
return &Manager{
discovery: discovery,
interval: interval,
currentResources: []config.ResourceType{},
}
}
// Start begins periodic resource discovery.
// It performs an initial discovery immediately, then rediscovers on the specified interval.
func (m *Manager) Start(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("discovery-manager")
m.logger = logger
// Initial discovery
if err := m.discover(ctx); err != nil {
return fmt.Errorf("initial resource discovery failed: %w", err)
}
// Start periodic rediscovery
go m.run(ctx)
return nil
}
// GetCurrentResources returns the currently discovered resource types.
func (m *Manager) GetCurrentResources() []config.ResourceType {
m.mu.RLock()
defer m.mu.RUnlock()
// Return a copy to prevent concurrent modification
result := make([]config.ResourceType, len(m.currentResources))
copy(result, m.currentResources)
return result
}
// run is the main discovery loop.
func (m *Manager) run(ctx context.Context) {
ticker := time.NewTicker(m.interval)
defer ticker.Stop()
logger := log.FromContext(ctx).WithName("discovery-manager")
for {
select {
case <-ctx.Done():
logger.Info("discovery manager stopped due to context cancellation")
return
case <-ticker.C:
if err := m.discover(ctx); err != nil {
logger.Error(err, "periodic resource discovery failed")
}
}
}
}
// discover performs resource discovery and detects changes.
func (m *Manager) discover(ctx context.Context) error {
logger := log.FromContext(ctx).WithName("discovery-manager")
// Discover current resources
discovered, err := m.discovery.DiscoverMirrorableResources(ctx)
if err != nil {
return fmt.Errorf("failed to discover resources: %w", err)
}
m.mu.Lock()
defer m.mu.Unlock()
// Detect changes
added, removed := m.detectChanges(m.currentResources, discovered)
if len(added) > 0 {
logger.Info("new resource types discovered",
"count", len(added),
"resources", resourceTypesToStrings(added),
)
}
if len(removed) > 0 {
logger.Info("resource types removed",
"count", len(removed),
"resources", resourceTypesToStrings(removed),
)
}
if len(added) == 0 && len(removed) == 0 {
logger.V(1).Info("no changes in discovered resources",
"total", len(discovered),
)
}
// Update current resources
m.currentResources = discovered
logger.Info("resource discovery completed",
"total", len(discovered),
"added", len(added),
"removed", len(removed),
)
return nil
}
// detectChanges compares old and new resource lists to find additions and removals.
func (m *Manager) detectChanges(old, new []config.ResourceType) (added, removed []config.ResourceType) {
oldMap := make(map[string]config.ResourceType)
newMap := make(map[string]config.ResourceType)
for _, rt := range old {
oldMap[rt.String()] = rt
}
for _, rt := range new {
newMap[rt.String()] = rt
}
// Find added resources
for key, rt := range newMap {
if _, exists := oldMap[key]; !exists {
added = append(added, rt)
}
}
// Find removed resources
for key, rt := range oldMap {
if _, exists := newMap[key]; !exists {
removed = append(removed, rt)
}
}
return added, removed
}
// resourceTypesToStrings converts a slice of ResourceType to strings for logging.
func resourceTypesToStrings(resources []config.ResourceType) []string {
result := make([]string, len(resources))
for i, rt := range resources {
result[i] = rt.String()
}
return result
}
// WaitForInitialDiscovery blocks until the first discovery completes.
// Useful for ensuring resources are discovered before starting controllers.
func (m *Manager) WaitForInitialDiscovery(ctx context.Context, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return fmt.Errorf("timeout waiting for initial discovery")
case <-ticker.C:
m.mu.RLock()
hasResources := len(m.currentResources) > 0
m.mu.RUnlock()
if hasResources {
return nil
}
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package discovery
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/lukaszraczylo/kubemirror/pkg/config"
)
func TestDetectChanges(t *testing.T) {
m := &Manager{}
tests := []struct {
name string
old []config.ResourceType
new []config.ResourceType
wantAdded []config.ResourceType
wantRemoved []config.ResourceType
}{
{
name: "no changes",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
wantAdded: nil,
wantRemoved: nil,
},
{
name: "new resource added",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "Service", Version: "v1", Group: ""},
},
wantAdded: []config.ResourceType{
{Kind: "Service", Version: "v1", Group: ""},
},
wantRemoved: nil,
},
{
name: "resource removed",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
},
wantAdded: nil,
wantRemoved: []config.ResourceType{
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
{
name: "multiple changes",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "Service", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
wantAdded: []config.ResourceType{
{Kind: "Service", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
wantRemoved: []config.ResourceType{
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
{
name: "complete replacement",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{
{Kind: "Service", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
wantAdded: []config.ResourceType{
{Kind: "Service", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
},
wantRemoved: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
{
name: "from empty to populated",
old: []config.ResourceType{},
new: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
wantAdded: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
wantRemoved: nil,
},
{
name: "from populated to empty",
old: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
new: []config.ResourceType{},
wantAdded: nil,
wantRemoved: []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "ConfigMap", Version: "v1", Group: ""},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotAdded, gotRemoved := m.detectChanges(tt.old, tt.new)
// Sort for consistent comparison
assert.ElementsMatch(t, tt.wantAdded, gotAdded, "added resources mismatch")
assert.ElementsMatch(t, tt.wantRemoved, gotRemoved, "removed resources mismatch")
})
}
}
func TestResourceTypesToStrings(t *testing.T) {
resources := []config.ResourceType{
{Kind: "Secret", Version: "v1", Group: ""},
{Kind: "Ingress", Version: "v1", Group: "networking.k8s.io"},
{Kind: "Middleware", Version: "v1alpha1", Group: "traefik.io"},
}
want := []string{
"Secret.v1",
"Ingress.v1.networking.k8s.io",
"Middleware.v1alpha1.traefik.io",
}
got := resourceTypesToStrings(resources)
assert.Equal(t, want, got)
}