mirror of
https://github.com/outbackdingo/cozystack.git
synced 2026-01-27 10:18:39 +00:00
- Refactor code for dashboard resources creation - Move dashboard-config helm chart to dynamic dashboard controller - Move white-label configuration to separate configmap Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
449 lines
13 KiB
Go
449 lines
13 KiB
Go
package dashboard
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
|
||
dashv1alpha1 "github.com/cozystack/cozystack/api/dashboard/v1alpha1"
|
||
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
||
|
||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||
"k8s.io/apimachinery/pkg/runtime"
|
||
|
||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||
)
|
||
|
||
const (
|
||
// Label keys for dashboard resource management
|
||
LabelManagedBy = "dashboard.cozystack.io/managed-by"
|
||
LabelResourceType = "dashboard.cozystack.io/resource-type"
|
||
LabelCRDName = "dashboard.cozystack.io/crd-name"
|
||
LabelCRDGroup = "dashboard.cozystack.io/crd-group"
|
||
LabelCRDVersion = "dashboard.cozystack.io/crd-version"
|
||
LabelCRDKind = "dashboard.cozystack.io/crd-kind"
|
||
LabelCRDPlural = "dashboard.cozystack.io/crd-plural"
|
||
|
||
// Label values
|
||
ManagedByValue = "cozystack-dashboard-controller"
|
||
ResourceTypeStatic = "static"
|
||
ResourceTypeDynamic = "dynamic"
|
||
)
|
||
|
||
// AddToScheme exposes dashboard types registration for controller setup.
|
||
func AddToScheme(s *runtime.Scheme) error {
|
||
return dashv1alpha1.AddToScheme(s)
|
||
}
|
||
|
||
// Manager owns logic for creating/updating dashboard resources derived from CRDs.
|
||
// It’s easy to extend: add new ensure* methods and wire them into EnsureForCRD.
|
||
type Manager struct {
|
||
client client.Client
|
||
scheme *runtime.Scheme
|
||
crdListFn func(context.Context) ([]cozyv1alpha1.CozystackResourceDefinition, error)
|
||
}
|
||
|
||
// Option pattern so callers can inject a custom lister.
|
||
type Option func(*Manager)
|
||
|
||
// WithCRDListFunc overrides how Manager lists all CozystackResourceDefinitions.
|
||
func WithCRDListFunc(fn func(context.Context) ([]cozyv1alpha1.CozystackResourceDefinition, error)) Option {
|
||
return func(m *Manager) { m.crdListFn = fn }
|
||
}
|
||
|
||
// NewManager constructs a dashboard Manager.
|
||
func NewManager(c client.Client, scheme *runtime.Scheme, opts ...Option) *Manager {
|
||
m := &Manager{client: c, scheme: scheme}
|
||
for _, o := range opts {
|
||
o(m)
|
||
}
|
||
return m
|
||
}
|
||
|
||
// EnsureForCRD is the single entry-point used by the controller.
|
||
// Add more ensure* calls here as you implement support for other resources:
|
||
//
|
||
// - ensureBreadcrumb (implemented)
|
||
// - ensureCustomColumnsOverride (implemented)
|
||
// - ensureCustomFormsOverride (implemented)
|
||
// - ensureCustomFormsPrefill (implemented)
|
||
// - ensureFactory
|
||
// - ensureMarketplacePanel (implemented)
|
||
// - ensureSidebar (implemented)
|
||
// - ensureTableUriMapping (implemented)
|
||
func (m *Manager) EnsureForCRD(ctx context.Context, crd *cozyv1alpha1.CozystackResourceDefinition) (reconcile.Result, error) {
|
||
// Early return if crd.Spec.Dashboard is nil to prevent oscillation
|
||
if crd.Spec.Dashboard == nil {
|
||
return reconcile.Result{}, nil
|
||
}
|
||
|
||
// MarketplacePanel
|
||
if res, err := m.ensureMarketplacePanel(ctx, crd); err != nil || res.Requeue || res.RequeueAfter > 0 {
|
||
return res, err
|
||
}
|
||
|
||
// CustomFormsPrefill
|
||
if res, err := m.ensureCustomFormsPrefill(ctx, crd); err != nil || res.Requeue || res.RequeueAfter > 0 {
|
||
return res, err
|
||
}
|
||
|
||
// CustomColumnsOverride
|
||
if _, err := m.ensureCustomColumnsOverride(ctx, crd); err != nil {
|
||
return reconcile.Result{}, err
|
||
}
|
||
|
||
if err := m.ensureTableUriMapping(ctx, crd); err != nil {
|
||
return reconcile.Result{}, err
|
||
}
|
||
|
||
if err := m.ensureBreadcrumb(ctx, crd); err != nil {
|
||
return reconcile.Result{}, err
|
||
}
|
||
|
||
if err := m.ensureCustomFormsOverride(ctx, crd); err != nil {
|
||
return reconcile.Result{}, err
|
||
}
|
||
|
||
if err := m.ensureSidebar(ctx, crd); err != nil {
|
||
return reconcile.Result{}, err
|
||
}
|
||
|
||
if err := m.ensureFactory(ctx, crd); err != nil {
|
||
return reconcile.Result{}, err
|
||
}
|
||
return reconcile.Result{}, nil
|
||
}
|
||
|
||
// InitializeStaticResources creates all static dashboard resources once during controller startup
|
||
func (m *Manager) InitializeStaticResources(ctx context.Context) error {
|
||
return m.ensureStaticResources(ctx)
|
||
}
|
||
|
||
// addDashboardLabels adds standard dashboard management labels to a resource
|
||
func (m *Manager) addDashboardLabels(obj client.Object, crd *cozyv1alpha1.CozystackResourceDefinition, resourceType string) {
|
||
labels := obj.GetLabels()
|
||
if labels == nil {
|
||
labels = make(map[string]string)
|
||
}
|
||
|
||
labels[LabelManagedBy] = ManagedByValue
|
||
labels[LabelResourceType] = resourceType
|
||
|
||
if crd != nil {
|
||
g, v, kind := pickGVK(crd)
|
||
plural := pickPlural(kind, crd)
|
||
|
||
labels[LabelCRDName] = crd.Name
|
||
labels[LabelCRDGroup] = g
|
||
labels[LabelCRDVersion] = v
|
||
labels[LabelCRDKind] = kind
|
||
labels[LabelCRDPlural] = plural
|
||
}
|
||
|
||
obj.SetLabels(labels)
|
||
}
|
||
|
||
// getDashboardResourceSelector returns a label selector for dashboard-managed resources
|
||
func (m *Manager) getDashboardResourceSelector() client.MatchingLabels {
|
||
return client.MatchingLabels{
|
||
LabelManagedBy: ManagedByValue,
|
||
}
|
||
}
|
||
|
||
// getDynamicResourceSelector returns a label selector for dynamic dashboard resources
|
||
func (m *Manager) getDynamicResourceSelector() client.MatchingLabels {
|
||
return client.MatchingLabels{
|
||
LabelManagedBy: ManagedByValue,
|
||
LabelResourceType: ResourceTypeDynamic,
|
||
}
|
||
}
|
||
|
||
// getStaticResourceSelector returns a label selector for static dashboard resources
|
||
func (m *Manager) getStaticResourceSelector() client.MatchingLabels {
|
||
return client.MatchingLabels{
|
||
LabelManagedBy: ManagedByValue,
|
||
LabelResourceType: ResourceTypeStatic,
|
||
}
|
||
}
|
||
|
||
// CleanupOrphanedResources removes dashboard resources that are no longer needed
|
||
// This should be called after cache warming to ensure all current resources are known
|
||
func (m *Manager) CleanupOrphanedResources(ctx context.Context) error {
|
||
// Get all current CRDs to determine which resources should exist
|
||
var allCRDs []cozyv1alpha1.CozystackResourceDefinition
|
||
if m.crdListFn != nil {
|
||
s, err := m.crdListFn(ctx)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
allCRDs = s
|
||
} else {
|
||
var crdList cozyv1alpha1.CozystackResourceDefinitionList
|
||
if err := m.client.List(ctx, &crdList, &client.ListOptions{}); err != nil {
|
||
return err
|
||
}
|
||
allCRDs = crdList.Items
|
||
}
|
||
|
||
// Build a set of expected resource names for each type
|
||
expectedResources := m.buildExpectedResourceSet(allCRDs)
|
||
|
||
// Clean up each resource type
|
||
resourceTypes := []client.Object{
|
||
&dashv1alpha1.CustomColumnsOverride{},
|
||
&dashv1alpha1.CustomFormsOverride{},
|
||
&dashv1alpha1.CustomFormsPrefill{},
|
||
&dashv1alpha1.MarketplacePanel{},
|
||
&dashv1alpha1.Sidebar{},
|
||
&dashv1alpha1.TableUriMapping{},
|
||
&dashv1alpha1.Breadcrumb{},
|
||
&dashv1alpha1.Factory{},
|
||
}
|
||
|
||
for _, resourceType := range resourceTypes {
|
||
if err := m.cleanupResourceType(ctx, resourceType, expectedResources); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// buildExpectedResourceSet creates a map of expected resource names by type
|
||
func (m *Manager) buildExpectedResourceSet(crds []cozyv1alpha1.CozystackResourceDefinition) map[string]map[string]bool {
|
||
expected := make(map[string]map[string]bool)
|
||
|
||
// Initialize maps for each resource type
|
||
resourceTypes := []string{
|
||
"CustomColumnsOverride",
|
||
"CustomFormsOverride",
|
||
"CustomFormsPrefill",
|
||
"MarketplacePanel",
|
||
"Sidebar",
|
||
"TableUriMapping",
|
||
"Breadcrumb",
|
||
"Factory",
|
||
}
|
||
|
||
for _, rt := range resourceTypes {
|
||
expected[rt] = make(map[string]bool)
|
||
}
|
||
|
||
// Add static resources (these should always exist)
|
||
staticResources := CreateAllStaticResources()
|
||
for _, resource := range staticResources {
|
||
resourceType := resource.GetObjectKind().GroupVersionKind().Kind
|
||
if expected[resourceType] != nil {
|
||
expected[resourceType][resource.GetName()] = true
|
||
}
|
||
}
|
||
|
||
// Add dynamic resources based on current CRDs
|
||
for _, crd := range crds {
|
||
if crd.Spec.Dashboard == nil {
|
||
continue
|
||
}
|
||
|
||
// Skip resources with non-empty spec.dashboard.name (tenant modules)
|
||
if strings.TrimSpace(crd.Spec.Dashboard.Name) != "" {
|
||
continue
|
||
}
|
||
|
||
g, v, kind := pickGVK(&crd)
|
||
plural := pickPlural(kind, &crd)
|
||
|
||
// CustomColumnsOverride
|
||
name := fmt.Sprintf("stock-namespace-%s.%s.%s", g, v, plural)
|
||
expected["CustomColumnsOverride"][name] = true
|
||
|
||
// CustomFormsOverride
|
||
name = fmt.Sprintf("%s.%s.%s", g, v, plural)
|
||
expected["CustomFormsOverride"][name] = true
|
||
|
||
// CustomFormsPrefill
|
||
expected["CustomFormsPrefill"][name] = true
|
||
|
||
// MarketplacePanel (name matches CRD name)
|
||
expected["MarketplacePanel"][crd.Name] = true
|
||
|
||
// Sidebar resources (multiple per CRD)
|
||
lowerKind := strings.ToLower(kind)
|
||
detailsID := fmt.Sprintf("stock-project-factory-%s-details", lowerKind)
|
||
expected["Sidebar"][detailsID] = true
|
||
|
||
// Add other stock sidebars that are created for each CRD
|
||
stockSidebars := []string{
|
||
"stock-instance-api-form",
|
||
"stock-instance-api-table",
|
||
"stock-instance-builtin-form",
|
||
"stock-instance-builtin-table",
|
||
"stock-project-factory-marketplace",
|
||
"stock-project-factory-workloadmonitor-details",
|
||
"stock-project-api-form",
|
||
"stock-project-api-table",
|
||
"stock-project-builtin-form",
|
||
"stock-project-builtin-table",
|
||
"stock-project-crd-form",
|
||
"stock-project-crd-table",
|
||
}
|
||
for _, sidebarID := range stockSidebars {
|
||
expected["Sidebar"][sidebarID] = true
|
||
}
|
||
|
||
// TableUriMapping
|
||
name = fmt.Sprintf("stock-namespace-%s.%s.%s", g, v, plural)
|
||
expected["TableUriMapping"][name] = true
|
||
|
||
// Breadcrumb
|
||
detailID := fmt.Sprintf("stock-project-factory-%s-details", lowerKind)
|
||
expected["Breadcrumb"][detailID] = true
|
||
|
||
// Factory
|
||
factoryName := fmt.Sprintf("%s-details", lowerKind)
|
||
expected["Factory"][factoryName] = true
|
||
}
|
||
|
||
return expected
|
||
}
|
||
|
||
// cleanupResourceType removes orphaned resources of a specific type
|
||
func (m *Manager) cleanupResourceType(ctx context.Context, resourceType client.Object, expectedResources map[string]map[string]bool) error {
|
||
var (
|
||
list client.ObjectList
|
||
resourceKind string
|
||
)
|
||
switch resourceType.(type) {
|
||
case *dashv1alpha1.CustomColumnsOverride:
|
||
list = &dashv1alpha1.CustomColumnsOverrideList{}
|
||
resourceKind = "CustomColumnsOverride"
|
||
case *dashv1alpha1.CustomFormsOverride:
|
||
list = &dashv1alpha1.CustomFormsOverrideList{}
|
||
resourceKind = "CustomFormsOverride"
|
||
case *dashv1alpha1.CustomFormsPrefill:
|
||
list = &dashv1alpha1.CustomFormsPrefillList{}
|
||
resourceKind = "CustomFormsPrefill"
|
||
case *dashv1alpha1.MarketplacePanel:
|
||
list = &dashv1alpha1.MarketplacePanelList{}
|
||
resourceKind = "MarketplacePanel"
|
||
case *dashv1alpha1.Sidebar:
|
||
list = &dashv1alpha1.SidebarList{}
|
||
resourceKind = "Sidebar"
|
||
case *dashv1alpha1.TableUriMapping:
|
||
list = &dashv1alpha1.TableUriMappingList{}
|
||
resourceKind = "TableUriMapping"
|
||
case *dashv1alpha1.Breadcrumb:
|
||
list = &dashv1alpha1.BreadcrumbList{}
|
||
resourceKind = "Breadcrumb"
|
||
case *dashv1alpha1.Factory:
|
||
list = &dashv1alpha1.FactoryList{}
|
||
resourceKind = "Factory"
|
||
default:
|
||
return nil // Unknown type
|
||
}
|
||
|
||
expected := expectedResources[resourceKind]
|
||
if expected == nil {
|
||
return nil // No expected resources for this type
|
||
}
|
||
|
||
// List with dashboard labels
|
||
if err := m.client.List(ctx, list, m.getDashboardResourceSelector()); err != nil {
|
||
return err
|
||
}
|
||
|
||
// Delete resources that are not in the expected set
|
||
switch l := list.(type) {
|
||
case *dashv1alpha1.CustomColumnsOverrideList:
|
||
for _, item := range l.Items {
|
||
if !expected[item.Name] {
|
||
if err := m.client.Delete(ctx, &item); err != nil {
|
||
if !apierrors.IsNotFound(err) {
|
||
return err
|
||
}
|
||
// Resource already deleted, continue
|
||
}
|
||
}
|
||
}
|
||
case *dashv1alpha1.CustomFormsOverrideList:
|
||
for _, item := range l.Items {
|
||
if !expected[item.Name] {
|
||
if err := m.client.Delete(ctx, &item); err != nil {
|
||
if !apierrors.IsNotFound(err) {
|
||
return err
|
||
}
|
||
// Resource already deleted, continue
|
||
}
|
||
}
|
||
}
|
||
case *dashv1alpha1.CustomFormsPrefillList:
|
||
for _, item := range l.Items {
|
||
if !expected[item.Name] {
|
||
if err := m.client.Delete(ctx, &item); err != nil {
|
||
if !apierrors.IsNotFound(err) {
|
||
return err
|
||
}
|
||
// Resource already deleted, continue
|
||
}
|
||
}
|
||
}
|
||
case *dashv1alpha1.MarketplacePanelList:
|
||
for _, item := range l.Items {
|
||
if !expected[item.Name] {
|
||
if err := m.client.Delete(ctx, &item); err != nil {
|
||
if !apierrors.IsNotFound(err) {
|
||
return err
|
||
}
|
||
// Resource already deleted, continue
|
||
}
|
||
}
|
||
}
|
||
case *dashv1alpha1.SidebarList:
|
||
for _, item := range l.Items {
|
||
if !expected[item.Name] {
|
||
if err := m.client.Delete(ctx, &item); err != nil {
|
||
if !apierrors.IsNotFound(err) {
|
||
return err
|
||
}
|
||
// Resource already deleted, continue
|
||
}
|
||
}
|
||
}
|
||
case *dashv1alpha1.TableUriMappingList:
|
||
for _, item := range l.Items {
|
||
if !expected[item.Name] {
|
||
if err := m.client.Delete(ctx, &item); err != nil {
|
||
if !apierrors.IsNotFound(err) {
|
||
return err
|
||
}
|
||
// Resource already deleted, continue
|
||
}
|
||
}
|
||
}
|
||
case *dashv1alpha1.BreadcrumbList:
|
||
for _, item := range l.Items {
|
||
if !expected[item.Name] {
|
||
if err := m.client.Delete(ctx, &item); err != nil {
|
||
if !apierrors.IsNotFound(err) {
|
||
return err
|
||
}
|
||
// Resource already deleted, continue
|
||
}
|
||
}
|
||
}
|
||
case *dashv1alpha1.FactoryList:
|
||
for _, item := range l.Items {
|
||
if !expected[item.Name] {
|
||
if err := m.client.Delete(ctx, &item); err != nil {
|
||
if !apierrors.IsNotFound(err) {
|
||
return err
|
||
}
|
||
// Resource already deleted, continue
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|