From 4d5debb36fe559c8c3b731e7b624bfbe773c1bf9 Mon Sep 17 00:00:00 2001 From: Stepan Paksashvili <81509933+ipaqsa@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:12:26 +0300 Subject: [PATCH] [feat] 1.76 ensure conversion hooks (#783) Signed-off-by: Stepan Paksashvili (cherry picked from commit 04ede3b4c21a2865e8bf84b1ead27ccb9b0c4b38) --- pkg/addon-operator/converge/converge.go | 2 +- pkg/addon-operator/operator.go | 2 +- pkg/module_manager/models/modules/basic.go | 58 +++++++++++++-- pkg/module_manager/models/modules/helm.go | 31 ++++++++ pkg/module_manager/module_manager.go | 65 ++++++++++++++++ pkg/task/helpers/helpers.go | 2 +- pkg/task/service/service.go | 2 + pkg/task/task.go | 2 + pkg/task/tasks/converge-modules/task.go | 28 +++++++ pkg/task/tasks/module-ensure-hooks/task.go | 87 ++++++++++++++++++++++ 10 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 pkg/task/tasks/module-ensure-hooks/task.go diff --git a/pkg/addon-operator/converge/converge.go b/pkg/addon-operator/converge/converge.go index 8aa373c12..85c8a6ad9 100644 --- a/pkg/addon-operator/converge/converge.go +++ b/pkg/addon-operator/converge/converge.go @@ -121,7 +121,7 @@ func IsConvergeTask(t sh_task.Task) bool { hm := task.HookMetadataAccessor(t) switch taskType { - case task.ModuleDelete, task.ConvergeModules, task.ModuleEnsureCRDs: + case task.ModuleDelete, task.ConvergeModules, task.ModuleEnsureCRDs, task.ModuleEnsureHooks: return true case task.ModuleRun, task.ParallelModuleRun: return hm.IsReloadAll diff --git a/pkg/addon-operator/operator.go b/pkg/addon-operator/operator.go index 164d8dbc5..0f7d43e25 100644 --- a/pkg/addon-operator/operator.go +++ b/pkg/addon-operator/operator.go @@ -924,7 +924,7 @@ func formatTaskDetails(tsk sh_task.Task, hm task.HookMetadata, phase string) str case task.ParallelModuleRun: return fmt.Sprintf(" for modules '%s'", hm.ModuleName) - case task.ModulePurge, task.ModuleDelete, task.ModuleEnsureCRDs: + case task.ModulePurge, task.ModuleDelete, task.ModuleEnsureCRDs, task.ModuleEnsureHooks: return fmt.Sprintf(" for module '%s'", hm.ModuleName) case task.GlobalHookEnableKubernetesBindings, diff --git a/pkg/module_manager/models/modules/basic.go b/pkg/module_manager/models/modules/basic.go index 08718facd..6bf33ef31 100644 --- a/pkg/module_manager/models/modules/basic.go +++ b/pkg/module_manager/models/modules/basic.go @@ -61,6 +61,8 @@ type BasicModule struct { crdsExist bool crdFilesPaths []string + conversionWebhooksExist bool + valuesStorage *ValuesStorage hooks *HooksStorage @@ -88,12 +90,13 @@ func NewBasicModule(name, path string, order uint32, staticValues utils.Values, crdsFromPath := getCRDsFromPath(path, app.CRDsFilters) bmodule := &BasicModule{ - Name: name, - Order: order, - Path: path, - crdsExist: len(crdsFromPath) > 0, - crdFilesPaths: crdsFromPath, - valuesStorage: valuesStorage, + Name: name, + Order: order, + Path: path, + crdsExist: len(crdsFromPath) > 0, + crdFilesPaths: crdsFromPath, + conversionWebhooksExist: templatesHaveConversionWebhook(path), + valuesStorage: valuesStorage, state: &moduleState{ Phase: Startup, hookErrors: make(map[string]error), @@ -155,6 +158,43 @@ func getCRDsFromPath(path string, crdsFilters string) []string { return crdFilesPaths } +// templatesHaveConversionWebhook reports whether any template file under +// path/templates mentions a ConversionWebhook resource. It is a cheap static +// heuristic (the kind is virtually always a literal in manifests) used to avoid +// rendering charts that have no conversion webhooks. A false positive only +// results in a render that finds nothing, so it is safe. +func templatesHaveConversionWebhook(path string) bool { + var found bool + + err := filepath.Walk( + filepath.Join(path, "templates"), + func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if found || info.IsDir() { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + if strings.Contains(string(data), ConversionWebhookKind) { + found = true + } + + return nil + }) + if err != nil { + return false + } + + return found +} + func matchPrefix(path string, crdsFilters string) bool { filters := strings.Split(crdsFilters, ",") for _, filter := range filters { @@ -1467,6 +1507,12 @@ func (bm *BasicModule) CRDExist() bool { return bm.crdsExist } +// ConversionWebhookExist reports whether the module's templates contain at least +// one ConversionWebhook resource, computed once when the module is loaded. +func (bm *BasicModule) ConversionWebhookExist() bool { + return bm.conversionWebhooksExist +} + type ModuleRunPhase string const ( diff --git a/pkg/module_manager/models/modules/helm.go b/pkg/module_manager/models/modules/helm.go index 3257ab94b..06dbea7c8 100644 --- a/pkg/module_manager/models/modules/helm.go +++ b/pkg/module_manager/models/modules/helm.go @@ -455,3 +455,34 @@ func (hm *HelmModule) Render(namespace string, debug bool, state MaintenanceStat return hm.dependencies.HelmClientFactory.NewClient(hm.logger.Named("helm-client"), helmClientOptions...).Render(hm.name, hm.path, []string{valuesPath}, nil, releaseLabels, namespace, debug) } + +// ConversionWebhookKind is the kind of the rendered resource that carries a CRD +// conversion webhook configuration. Such resources must be applied to the cluster +// before the main helm release, so the release can adopt them afterwards. +const ConversionWebhookKind = "ConversionWebhook" + +// RenderConversionWebhooks renders the module templates and returns only the +// manifests of kind ConversionWebhook. They are extracted from the chart so the +// caller can apply them ahead of the main helm install, letting the subsequent +// release adopt the already-present resources. +func (hm *HelmModule) RenderConversionWebhooks(namespace string, state MaintenanceState) ([]manifest.Manifest, error) { + rendered, err := hm.Render(namespace, false, state) + if err != nil { + return nil, fmt.Errorf("render templates: %w", err) + } + + all, err := manifest.ListFromYamlDocs(rendered) + if err != nil { + return nil, fmt.Errorf("split rendered manifests: %w", err) + } + + webhooks := make([]manifest.Manifest, 0) + + for _, m := range all { + if m.Kind() == ConversionWebhookKind { + webhooks = append(webhooks, m) + } + } + + return webhooks, nil +} diff --git a/pkg/module_manager/module_manager.go b/pkg/module_manager/module_manager.go index f3b04f5cf..d87a68be1 100644 --- a/pkg/module_manager/module_manager.go +++ b/pkg/module_manager/module_manager.go @@ -13,6 +13,7 @@ import ( "github.com/deckhouse/deckhouse/pkg/log" metricsstorage "github.com/deckhouse/deckhouse/pkg/metrics-storage" + sdkpkg "github.com/deckhouse/module-sdk/pkg" "github.com/hashicorp/go-multierror" "go.opentelemetry.io/otel" @@ -853,6 +854,70 @@ func (mm *ModuleManager) RunModule(ctx context.Context, moduleName string, logLa return oldValuesChecksum != newValuesChecksum, nil } +// EnsureConversionWebhooks renders the module's ConversionWebhook resources and +// applies them to the cluster before the main helm install. The subsequent +// release adopts the already-present resources. It is a no-op for non-helm +// modules and for modules without ConversionWebhook templates. +func (mm *ModuleManager) EnsureConversionWebhooks(moduleName string, logLabels map[string]string) error { + bm := mm.GetModule(moduleName) + if bm == nil { + return fmt.Errorf("module %q not found", moduleName) + } + + schemaStorage := bm.GetValuesStorage().GetSchemaStorage() + deps := &modules.HelmModuleDependencies{ + HelmClientFactory: mm.dependencies.Helm, + HelmResourceManager: mm.dependencies.HelmResourcesManager, + MetricsStorage: mm.dependencies.MetricStorage, + HelmValuesValidator: schemaStorage, + } + + helmModule, err := modules.NewHelmModule(bm, mm.defaultNamespace, mm.TempDir, deps, schemaStorage, modules.WithLogger(mm.logger.Named("helm-module"))) + if err != nil { + if errors.Is(err, modules.ErrModuleIsNotHelm) { + return nil + } + + return fmt.Errorf("create helm module: %w", err) + } + + webhooks, err := helmModule.RenderConversionWebhooks(mm.defaultNamespace, bm.GetMaintenanceState()) + if err != nil { + return fmt.Errorf("render conversion webhooks: %w", err) + } + + if len(webhooks) == 0 { + return nil + } + + ops := make([]sdkpkg.PatchCollectorOperation, 0, len(webhooks)) + for _, m := range webhooks { + ops = append(ops, objectpatch.NewCreateOrUpdateOperation(map[string]any(m))) + } + + if err := mm.dependencies.KubeObjectPatcher.ExecuteOperations(ops); err != nil { + return fmt.Errorf("apply conversion webhooks: %w", err) + } + + utils.EnrichLoggerWithLabels(mm.logger, logLabels).Debug("applied conversion webhooks", + slog.String(pkg.LogKeyModule, moduleName), + slog.Int(pkg.LogKeyCount, len(webhooks))) + + return nil +} + +// ModuleHasConversionWebhooks reports whether the module's templates contain at +// least one ConversionWebhook resource. It is a cheap static check used to skip +// rendering for modules that have no conversion webhooks. +func (mm *ModuleManager) ModuleHasConversionWebhooks(moduleName string) bool { + bm := mm.GetModule(moduleName) + if bm == nil { + return false + } + + return bm.ConversionWebhookExist() +} + func (mm *ModuleManager) RunGlobalHook(ctx context.Context, hookName string, binding BindingType, bindingContext []BindingContext, logLabels map[string]string) (string, string, error) { return mm.global.RunHookByName(ctx, hookName, binding, bindingContext, logLabels) } diff --git a/pkg/task/helpers/helpers.go b/pkg/task/helpers/helpers.go index dea09a7b2..b55eac8bf 100644 --- a/pkg/task/helpers/helpers.go +++ b/pkg/task/helpers/helpers.go @@ -75,7 +75,7 @@ func formatTaskDetails(tsk sh_task.Task, hm task.HookMetadata, phase string) str case task.ParallelModuleRun: return fmt.Sprintf(" for modules '%s'", hm.ModuleName) - case task.ModulePurge, task.ModuleDelete, task.ModuleEnsureCRDs: + case task.ModulePurge, task.ModuleDelete, task.ModuleEnsureCRDs, task.ModuleEnsureHooks: return fmt.Sprintf(" for module '%s'", hm.ModuleName) case task.GlobalHookEnableKubernetesBindings, diff --git a/pkg/task/service/service.go b/pkg/task/service/service.go index 8958902b2..9bbc2473e 100644 --- a/pkg/task/service/service.go +++ b/pkg/task/service/service.go @@ -29,6 +29,7 @@ import ( globalhookwaitkubernetessynchronization "github.com/flant/addon-operator/pkg/task/tasks/global-hook-wait-kubernetes-synchronization" moduledelete "github.com/flant/addon-operator/pkg/task/tasks/module-delete" moduleensurecrds "github.com/flant/addon-operator/pkg/task/tasks/module-ensure-crds" + moduleensurehooks "github.com/flant/addon-operator/pkg/task/tasks/module-ensure-hooks" modulehookrun "github.com/flant/addon-operator/pkg/task/tasks/module-hook-run" modulepurge "github.com/flant/addon-operator/pkg/task/tasks/module-purge" modulerun "github.com/flant/addon-operator/pkg/task/tasks/module-run" @@ -238,6 +239,7 @@ func (s *TaskHandlerService) initFactory() { task.ModuleHookRun: modulehookrun.RegisterTaskHandler(s), task.ModulePurge: modulepurge.RegisterTaskHandler(s), task.ModuleEnsureCRDs: moduleensurecrds.RegisterTaskHandler(s), + task.ModuleEnsureHooks: moduleensurehooks.RegisterTaskHandler(s), task.ModuleRun: modulerun.RegisterTaskHandler(s), task.ConvergeModules: convergemodules.RegisterTaskHandler(s), task.ParallelModuleRun: parallelmodulerun.RegisterTaskHandler(s), diff --git a/pkg/task/task.go b/pkg/task/task.go index dcd54c373..434ec160c 100644 --- a/pkg/task/task.go +++ b/pkg/task/task.go @@ -23,6 +23,8 @@ const ( ModulePurge task.TaskType = "ModulePurge" // ModuleEnsureCRDs runs ensureCRDs task for enabled module ModuleEnsureCRDs task.TaskType = "ModuleEnsureCRDs" + // ModuleEnsureHooks applies a module's ConversionWebhook resources before its helm release. + ModuleEnsureHooks task.TaskType = "ModuleEnsureHooks" // DiscoverHelmReleases lists helm releases to detect unknown modules and initiate enabled modules list. DiscoverHelmReleases task.TaskType = "DiscoverHelmReleases" diff --git a/pkg/task/tasks/converge-modules/task.go b/pkg/task/tasks/converge-modules/task.go index 3298d1422..32a86c782 100644 --- a/pkg/task/tasks/converge-modules/task.go +++ b/pkg/task/tasks/converge-modules/task.go @@ -445,6 +445,11 @@ func (s *Task) CreateConvergeModulesTasks(state *module_manager.ModulesState, lo doModuleStartup = true } + // add EnsureHooks task for every enabled module, right after EnsureCRDs + if s.moduleManager.ModuleHasConversionWebhooks(moduleName) { + resultingTasks = append(resultingTasks, s.newEnsureHooksTask(moduleName, newLogLabels, queuedAt)) + } + parallelRunMetadata.SetModuleMetadata(moduleName, task.ParallelRunModuleMetadata{ DoModuleStartup: doModuleStartup, }) @@ -490,6 +495,11 @@ func (s *Task) CreateConvergeModulesTasks(state *module_manager.ModulesState, lo doModuleStartup = true } + // add EnsureHooks task for every enabled module, right after EnsureCRDs + if s.moduleManager.ModuleHasConversionWebhooks(modules[0]) { + resultingTasks = append(resultingTasks, s.newEnsureHooksTask(modules[0], newLogLabels, queuedAt)) + } + newTask := sh_task.NewTask(task.ModuleRun). WithLogLabels(newLogLabels). WithQueueName("main"). @@ -541,6 +551,11 @@ func (s *Task) CreateConvergeModulesTasks(state *module_manager.ModulesState, lo doModuleStartup = true } + // add EnsureHooks task for every enabled module, right after EnsureCRDs + if s.moduleManager.ModuleHasConversionWebhooks(module) { + resultingTasks = append(resultingTasks, s.newEnsureHooksTask(module, newLogLabels, queuedAt)) + } + schedulerRequests[idx] = &functional.Request{ Name: module, Dependencies: deps[module], @@ -570,6 +585,19 @@ func (s *Task) CreateConvergeModulesTasks(state *module_manager.ModulesState, lo return resultingTasks } +// newEnsureHooksTask builds a ModuleEnsureHooks task that applies the module's +// ConversionWebhook resources before its helm release. +func (s *Task) newEnsureHooksTask(moduleName string, logLabels map[string]string, queuedAt time.Time) sh_task.Task { + return sh_task.NewTask(task.ModuleEnsureHooks). + WithLogLabels(logLabels). + WithQueueName("main"). + WithMetadata(task.HookMetadata{ + EventDescription: "EnsureHooks", + ModuleName: moduleName, + IsReloadAll: true, + }).WithQueuedAt(queuedAt) +} + func (s *Task) IsStartupConvergeDone() bool { return s.convergeState.GetFirstRunPhase() == converge.FirstDone } diff --git a/pkg/task/tasks/module-ensure-hooks/task.go b/pkg/task/tasks/module-ensure-hooks/task.go new file mode 100644 index 000000000..9a310140b --- /dev/null +++ b/pkg/task/tasks/module-ensure-hooks/task.go @@ -0,0 +1,87 @@ +package moduleensurehooks + +import ( + "context" + "log/slog" + "time" + + "github.com/deckhouse/deckhouse/pkg/log" + "go.opentelemetry.io/otel" + + "github.com/flant/addon-operator/pkg" + "github.com/flant/addon-operator/pkg/module_manager" + "github.com/flant/addon-operator/pkg/task" + sh_task "github.com/flant/shell-operator/pkg/task" + "github.com/flant/shell-operator/pkg/task/queue" +) + +const ( + taskName = "module-ensure-hooks" +) + +// TaskDependencies defines the interface for accessing necessary components. +type TaskDependencies interface { + GetModuleManager() *module_manager.ModuleManager +} + +// RegisterTaskHandler creates a factory function for ModuleEnsureHooks tasks. +func RegisterTaskHandler(svc TaskDependencies) func(t sh_task.Task, logger *log.Logger) task.Task { + return func(t sh_task.Task, logger *log.Logger) task.Task { + return NewTask( + t, + svc.GetModuleManager(), + logger.Named("module-ensure-hooks"), + ) + } +} + +// Task applies a module's ConversionWebhook resources before its helm release. +type Task struct { + shellTask sh_task.Task + + moduleManager *module_manager.ModuleManager + + logger *log.Logger +} + +// NewTask creates a new task handler for ensuring module conversion webhooks. +func NewTask( + shellTask sh_task.Task, + moduleManager *module_manager.ModuleManager, + logger *log.Logger, +) *Task { + return &Task{ + shellTask: shellTask, + moduleManager: moduleManager, + logger: logger, + } +} + +// Handle renders and applies the module's ConversionWebhook resources. +func (s *Task) Handle(ctx context.Context) queue.TaskResult { + _, span := otel.Tracer(taskName).Start(ctx, "handle") + defer span.End() + + hm := task.HookMetadataAccessor(s.shellTask) + + res := queue.TaskResult{ + Status: queue.Success, + } + + baseModule := s.moduleManager.GetModule(hm.ModuleName) + + s.logger.Debug("Module ensureHooks", slog.String(pkg.LogKeyName, hm.ModuleName)) + + if err := s.moduleManager.EnsureConversionWebhooks(hm.ModuleName, s.shellTask.GetLogLabels()); err != nil { + s.moduleManager.UpdateModuleLastErrorAndNotify(baseModule, err) + + s.logger.Error("ModuleEnsureHooks failed.", log.Err(err)) + + s.shellTask.UpdateFailureMessage(err.Error()) + s.shellTask.WithQueuedAt(time.Now()) + + res.Status = queue.Fail + } + + return res +}