mirror of
https://github.com/lukaszraczylo/kubemirror.git
synced 2026-07-07 07:34:47 +00:00
initial commit
This commit is contained in:
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user