package controller import ( "context" "fmt" "time" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" rigv1 "vanderlande.com/ittp/appstack/rig-operator/api/v1alpha1" "vanderlande.com/ittp/appstack/rig-operator/internal/builder" "vanderlande.com/ittp/appstack/rig-operator/internal/helm" "vanderlande.com/ittp/appstack/rig-operator/internal/provider" "vanderlande.com/ittp/appstack/rig-operator/internal/provider/harvester" harvesterTemplate "vanderlande.com/ittp/appstack/rig-operator/internal/templates/harvester" "vanderlande.com/ittp/appstack/rig-operator/internal/provider/vsphere" vsphereTemplate "vanderlande.com/ittp/appstack/rig-operator/internal/templates/vsphere" ) const ( rigFinalizer = "rig.appstack.io/finalizer" ) // ClusterBlueprintReconciler reconciles a ClusterBlueprint object type ClusterBlueprintReconciler struct { client.Client Scheme *runtime.Scheme Recorder record.EventRecorder } // +kubebuilder:rbac:groups=rig.appstack.io,resources=clusterblueprints,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=rig.appstack.io,resources=clusterblueprints/status,verbs=get;update;patch // +kubebuilder:rbac:groups=rig.appstack.io,resources=clusterblueprints/finalizers,verbs=update // +kubebuilder:rbac:groups=rig.appstack.io,resources=infrablueprints,verbs=get;list;watch // +kubebuilder:rbac:groups=rig.appstack.io,resources=harvesterblueprints,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete func (r *ClusterBlueprintReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { l := log.FromContext(ctx) // 1. Fetch ClusterBlueprint (CBP) cbp := &rigv1.ClusterBlueprint{} if err := r.Get(ctx, req.NamespacedName, cbp); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } // 2. Handle Deletion ... (Same as before) if !cbp.ObjectMeta.DeletionTimestamp.IsZero() { return r.handleDelete(ctx, cbp) } // 3. Ensure Finalizer ... (Same as before) if !controllerutil.ContainsFinalizer(cbp, rigFinalizer) { controllerutil.AddFinalizer(cbp, rigFinalizer) if err := r.Update(ctx, cbp); err != nil { return ctrl.Result{}, err } } // 4. Fetch InfraBlueprint (IBP) ibp := &rigv1.InfraBlueprint{} if err := r.Get(ctx, types.NamespacedName{Name: cbp.Spec.InfraBlueprintRef, Namespace: cbp.Namespace}, ibp); err != nil { l.Error(err, "InfraBlueprint not found", "Infra", cbp.Spec.InfraBlueprintRef) r.updateStatus(ctx, cbp, "PendingInfra", false) return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } // ===================================================================== // 4.5. QUOTA CHECK (The Gatekeeper) // Only check quota if we are NOT already deployed. // (Existing clusters keep running even if quota shrinks later) // ===================================================================== if cbp.Status.Phase != "Deployed" { if err := r.checkQuota(cbp, ibp); err != nil { l.Error(err, "Quota Exceeded") // We stop here! Helm Apply will NOT run. r.updateStatus(ctx, cbp, "QuotaExceeded", false) // Requeue slowly to check if resources freed up later return ctrl.Result{RequeueAfter: 5 * time.Minute}, nil } } // 5. Select Strategy based on Infra ProviderRef var selectedStrategy provider.Strategy var baseTemplate []byte var credentialSecret string switch ibp.Spec.ProviderRef.Kind { case "HarvesterBlueprint": // A. Fetch the specific Harvester Config (HBP) hbp := &rigv1.HarvesterBlueprint{} hbpName := types.NamespacedName{Name: ibp.Spec.ProviderRef.Name, Namespace: cbp.Namespace} if err := r.Get(ctx, hbpName, hbp); err != nil { return ctrl.Result{}, fmt.Errorf("failed to load HarvesterBlueprint: %w", err) } // B. Ensure Identity (Mint ServiceAccount/Secret) idMgr := harvester.NewIdentityManager(r.Client, r.Scheme) secretName, err := idMgr.Ensure(ctx, cbp, ibp, hbp) if err != nil { l.Error(err, "Failed to ensure identity") r.updateStatus(ctx, cbp, "ProvisioningFailed", false) return ctrl.Result{RequeueAfter: 30 * time.Second}, nil } credentialSecret = secretName // C. Load Defaults & Init Strategy defaults, err := harvesterTemplate.GetDefaults() if err != nil { return ctrl.Result{}, err } baseTemplate = harvesterTemplate.GetBaseValues() // [UPDATED] Pass ibp.Spec.RancherURL to the factory selectedStrategy = harvester.NewStrategy( hbp, ibp.Spec.UserData, ibp.Spec.RancherURL, // <--- Passing the URL here defaults, ) case "VsphereBlueprint": // A. Fetch the specific vSphere Config (VBP) vbp := &rigv1.VsphereBlueprint{} vbpName := types.NamespacedName{Name: ibp.Spec.ProviderRef.Name, Namespace: cbp.Namespace} if err := r.Get(ctx, vbpName, vbp); err != nil { return ctrl.Result{}, fmt.Errorf("failed to load VsphereBlueprint: %w", err) } // B. Load Defaults (CPU/RAM sizing safety nets) defaults, err := vsphereTemplate.GetDefaults() if err != nil { return ctrl.Result{}, err } baseTemplate = vsphereTemplate.GetBaseValues() // C. Init Strategy // Note: vSphere typically uses the global 'cloudCredentialSecret' defined in InfraBlueprint // rather than minting dynamic tokens per cluster like Harvester does. credentialSecret = ibp.Spec.CloudCredentialSecret selectedStrategy = vsphere.NewStrategy( vbp, ibp.Spec.UserData, ibp.Spec.RancherURL, defaults, ) default: return ctrl.Result{}, fmt.Errorf("unsupported provider kind: %s", ibp.Spec.ProviderRef.Kind) } // 6. Build Helm Values (Generic Engine) masterBuilder := builder.NewMasterBuilder(selectedStrategy, baseTemplate) values, err := masterBuilder.Build(ctx, cbp, credentialSecret) if err != nil { l.Error(err, "Failed to build helm values") r.updateStatus(ctx, cbp, "ConfigGenerationFailed", false) return ctrl.Result{}, nil // Fatal error, don't retry until config changes } // 7. Apply Helm Chart // We use the ChartConfig extracted by the MasterBuilder (from the YAML defaults) chartCfg := masterBuilder.GetChartConfig() helmConfig := helm.Config{ Namespace: cbp.Namespace, ReleaseName: cbp.Name, // We use the Cluster name as the Release name RepoURL: chartCfg.Repo, ChartName: chartCfg.Name, Version: chartCfg.Version, Values: values, } l.Info("Applying Helm Release", "Release", cbp.Name, "Chart", chartCfg.Name) if err := helm.Apply(helmConfig); err != nil { l.Error(err, "Helm Install/Upgrade failed") r.updateStatus(ctx, cbp, "HelmApplyFailed", false) return ctrl.Result{RequeueAfter: 1 * time.Minute}, nil } // 8. Success! r.updateStatus(ctx, cbp, "Deployed", true) return ctrl.Result{RequeueAfter: 10 * time.Minute}, nil // Re-sync periodically } func (r *ClusterBlueprintReconciler) handleDelete(ctx context.Context, cbp *rigv1.ClusterBlueprint) (ctrl.Result, error) { if controllerutil.ContainsFinalizer(cbp, rigFinalizer) { // 1. Uninstall Helm Release helmCfg := helm.Config{ Namespace: cbp.Namespace, ReleaseName: cbp.Name, } // Best effort uninstall if err := helm.Uninstall(helmCfg); err != nil { log.FromContext(ctx).Error(err, "Failed to uninstall helm release during cleanup") } // 2. Cleanup Identity (Harvester SA) // We need to look up IBP -> HBP again to know WHERE to clean up // This is a simplified lookup; in production we might need to handle missing IBP gracefully ibp := &rigv1.InfraBlueprint{} if err := r.Get(ctx, types.NamespacedName{Name: cbp.Spec.InfraBlueprintRef, Namespace: cbp.Namespace}, ibp); err == nil { if ibp.Spec.ProviderRef.Kind == "HarvesterBlueprint" { hbp := &rigv1.HarvesterBlueprint{} if err := r.Get(ctx, types.NamespacedName{Name: ibp.Spec.ProviderRef.Name, Namespace: cbp.Namespace}, hbp); err == nil { idMgr := harvester.NewIdentityManager(r.Client, r.Scheme) idMgr.Cleanup(ctx, cbp, ibp, hbp) } } } // 3. Remove Finalizer controllerutil.RemoveFinalizer(cbp, rigFinalizer) if err := r.Update(ctx, cbp); err != nil { return ctrl.Result{}, err } } return ctrl.Result{}, nil } func (r *ClusterBlueprintReconciler) updateStatus(ctx context.Context, cbp *rigv1.ClusterBlueprint, phase string, ready bool) { cbp.Status.Phase = phase cbp.Status.Ready = ready if err := r.Status().Update(ctx, cbp); err != nil { log.FromContext(ctx).Error(err, "Failed to update status") } } // SetupWithManager sets up the controller with the Manager. func (r *ClusterBlueprintReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&rigv1.ClusterBlueprint{}). Complete(r) } // Helper function to calculate required resources vs available func (r *ClusterBlueprintReconciler) checkQuota(cbp *rigv1.ClusterBlueprint, ibp *rigv1.InfraBlueprint) error { // 1. Calculate what this cluster needs var reqCpu, reqMem, reqDisk int // Control Plane Sizing (Using safe defaults or template logic) // Ideally, this should match the defaults in your template/strategy cpCount := 1 if cbp.Spec.ControlPlaneHA { cpCount = 3 } reqCpu += cpCount * 4 reqMem += cpCount * 8 reqDisk += cpCount * 40 // Worker Pools Sizing for _, pool := range cbp.Spec.WorkerPools { reqCpu += pool.Quantity * pool.CpuCores reqMem += pool.Quantity * pool.MemoryGB reqDisk += pool.Quantity * pool.DiskGB } // 2. Check against Limits // Note: We use the Status.Usage which is calculated by the InfraController. // This includes "other" clusters, but might include "this" cluster if it was already counted. // For strict "Admission Control", usually we check: // (CurrentUsage + Request) > MaxLimit // However, since InfraController runs asynchronously, 'Status.Usage' might NOT yet include this new cluster. // So (Usage + Request) > Max is the safest check for a new provisioning. q := ibp.Spec.Quota u := ibp.Status.Usage if q.MaxCPU > 0 && (u.UsedCPU+reqCpu) > q.MaxCPU { return fmt.Errorf("requested CPU %d exceeds remaining quota (Max: %d, Used: %d)", reqCpu, q.MaxCPU, u.UsedCPU) } if q.MaxMemoryGB > 0 && (u.UsedMemoryGB+reqMem) > q.MaxMemoryGB { return fmt.Errorf("requested Mem %dGB exceeds remaining quota (Max: %d, Used: %d)", reqMem, q.MaxMemoryGB, u.UsedMemoryGB) } if q.MaxDiskGB > 0 && (u.UsedDiskGB+reqDisk) > q.MaxDiskGB { return fmt.Errorf("requested Disk %dGB exceeds remaining quota (Max: %d, Used: %d)", reqDisk, q.MaxDiskGB, u.UsedDiskGB) } return nil }