diff --git a/examples/single-file-app-example/hooks/main.go b/examples/single-file-app-example/hooks/main.go index c9994e6a..1e2c26d8 100644 --- a/examples/single-file-app-example/hooks/main.go +++ b/examples/single-file-app-example/hooks/main.go @@ -18,16 +18,14 @@ const ( var _ = registry.RegisterFunc(config, Handle) -var config = &pkg.HookConfig{ - Kubernetes: []pkg.KubernetesConfig{ +var config = &pkg.ApplicationHookConfig{ + Kubernetes: []pkg.ApplicationKubernetesConfig{ { Name: SnapshotKey, APIVersion: "v1", Kind: "Pod", - NamespaceSelector: &pkg.NamespaceSelector{ - NameSelector: &pkg.NameSelector{ - MatchNames: []string{"kube-system"}, - }, + NameSelector: &pkg.NameSelector{ + MatchNames: []string{"kube-apiserver"}, }, LabelSelector: &metav1.LabelSelector{ MatchLabels: map[string]string{"component": "kube-apiserver"}, diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 39ba143e..cdae25a9 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -68,7 +68,7 @@ func addReadinessHook(reg *execregistry.Registry, cfg *ReadinessConfig) { config.Metadata.Name = "readiness" config.Metadata.Path = "common-hooks/readiness" - reg.SetReadinessHook(pkg.Hook[*pkg.HookInput]{Config: config, HookFunc: f}) + reg.SetReadinessHook(pkg.Hook[pkg.HookConfig, *pkg.HookInput]{Config: *config, HookFunc: f}) } func (c *HookController) ListHooksMeta() []pkg.HookMetadata { @@ -76,7 +76,7 @@ func (c *HookController) ListHooksMeta() []pkg.HookMetadata { hooksmetas := make([]pkg.HookMetadata, 0, len(hooks)) for _, hook := range hooks { - hooksmetas = append(hooksmetas, hook.Config().Metadata) + hooksmetas = append(hooksmetas, hook.Config().GetMetadata()) } return hooksmetas @@ -94,7 +94,7 @@ func (c *HookController) RunHook(ctx context.Context, idx int) error { hook := hooks[idx] - transport := file.NewTransport(c.fConfig, hook.Config().Metadata.Name, c.dc, c.logger.Named("file-transport")) + transport := file.NewTransport(c.fConfig, hook.Config().GetMetadata().Name, c.dc, c.logger.Named("file-transport")) hookRes, err := hook.Execute(ctx, transport.NewRequest()) if err != nil { @@ -127,7 +127,7 @@ func (c *HookController) RunReadiness(ctx context.Context) error { return ErrReadinessHookDoesNotExists } - transport := file.NewTransport(c.fConfig, hook.Config().Metadata.Name, c.dc, c.logger.Named("file-transport")) + transport := file.NewTransport(c.fConfig, hook.Config().GetMetadata().Name, c.dc, c.logger.Named("file-transport")) hookRes, err := hook.Execute(ctx, transport.NewRequest()) if err != nil { @@ -175,7 +175,8 @@ func (c *HookController) PrintHookConfigs() error { configs := make([]gohook.HookConfig, 0, 1) for _, hook := range c.registry.Executors() { - configs = append(configs, *remapHookConfigToHookConfig(hook.Config())) + hookConfig := remapHookConfigToGohook(hook.Config()) + configs = append(configs, *hookConfig) } cfg := &gohook.BatchHookConfig{ @@ -184,7 +185,8 @@ func (c *HookController) PrintHookConfigs() error { } if c.registry.Readiness() != nil { - cfg.Readiness = remapHookConfigToHookConfig(c.registry.Readiness().Config()) + readinessConfig := remapHookConfigToGohook(c.registry.Readiness().Config()) + cfg.Readiness = readinessConfig } if c.settingsCheck != nil { @@ -229,7 +231,8 @@ func (c *HookController) WriteHookConfigsInFile() error { configs := make([]gohook.HookConfig, 0, 1) for _, hook := range c.registry.Executors() { - configs = append(configs, *remapHookConfigToHookConfig(hook.Config())) + hookConfig := remapHookConfigToGohook(hook.Config()) + configs = append(configs, *hookConfig) } cfg := &gohook.BatchHookConfig{ @@ -238,7 +241,8 @@ func (c *HookController) WriteHookConfigsInFile() error { } if c.registry.Readiness() != nil { - cfg.Readiness = remapHookConfigToHookConfig(c.registry.Readiness().Config()) + readinessConfig := remapHookConfigToGohook(c.registry.Readiness().Config()) + cfg.Readiness = readinessConfig } err = json.NewEncoder(f).Encode(cfg) @@ -249,86 +253,142 @@ func (c *HookController) WriteHookConfigsInFile() error { return nil } -func remapHookConfigToHookConfig(cfg *pkg.HookConfig) *gohook.HookConfig { - newHookConfig := &gohook.HookConfig{ +// remapHookConfigToGohook converts HookConfigLike to gohook.HookConfig for shell-operator. +func remapHookConfigToGohook(cfg pkg.HookConfigInterface) *gohook.HookConfig { + out := &gohook.HookConfig{ ConfigVersion: "v1", - Metadata: gohook.GoHookMetadata(cfg.Metadata), + Metadata: gohook.GoHookMetadata(cfg.GetMetadata()), } + if c, ok := cfg.AsHookConfig(); ok { + remapModuleHookConfig(c, out) + } else if c, ok := cfg.AsApplicationHookConfig(); ok { + remapApplicationHookConfig(c, out) + } + return out +} +func remapModuleHookConfig(cfg *pkg.HookConfig, out *gohook.HookConfig) { for _, scfg := range cfg.Schedule { - newHookConfig.Schedule = append(newHookConfig.Schedule, gohook.ScheduleConfig{ + out.Schedule = append(out.Schedule, gohook.ScheduleConfig{ Name: scfg.Name, Crontab: scfg.Crontab, Queue: cfg.Queue, }) } - for _, shcfg := range cfg.Kubernetes { - newShCfg := gohook.KubernetesConfig{ - APIVersion: shcfg.APIVersion, - Kind: shcfg.Kind, - Name: shcfg.Name, - NameSelector: (*gohook.NameSelector)(shcfg.NameSelector), - LabelSelector: shcfg.LabelSelector, - ExecuteHookOnEvents: shcfg.ExecuteHookOnEvents, - ExecuteHookOnSynchronization: shcfg.ExecuteHookOnSynchronization, - WaitForSynchronization: shcfg.WaitForSynchronization, - KeepFullObjectsInMemory: ptr.To(false), - JqFilter: shcfg.JqFilter, - AllowFailure: shcfg.AllowFailure, - ResynchronizationPeriod: shcfg.ResynchronizationPeriod, - Queue: cfg.Queue, - } + for i := range cfg.Kubernetes { + k := &cfg.Kubernetes[i] + out.Kubernetes = append(out.Kubernetes, convertKubernetesConfig(k, cfg.Queue)) + } - if shcfg.JqFilter == "" { - newShCfg.KeepFullObjectsInMemory = ptr.To(true) - } + if cfg.OnStartup != nil { + out.OnStartup = ptr.To(cfg.OnStartup.Order) + } + if cfg.OnBeforeHelm != nil { + out.OnBeforeHelm = ptr.To(cfg.OnBeforeHelm.Order) + } + if cfg.OnAfterHelm != nil { + out.OnAfterHelm = ptr.To(cfg.OnAfterHelm.Order) + } + if cfg.OnAfterDeleteHelm != nil { + out.OnAfterDeleteHelm = ptr.To(cfg.OnAfterDeleteHelm.Order) + } +} - if shcfg.NameSelector != nil { - newShCfg.NameSelector = &gohook.NameSelector{ - MatchNames: shcfg.NameSelector.MatchNames, - } - } +func remapApplicationHookConfig(cfg *pkg.ApplicationHookConfig, out *gohook.HookConfig) { + for _, scfg := range cfg.Schedule { + out.Schedule = append(out.Schedule, gohook.ScheduleConfig{ + Name: scfg.Name, + Crontab: scfg.Crontab, + Queue: cfg.Queue, + }) + } - if shcfg.NamespaceSelector != nil { - newShCfg.NamespaceSelector = &gohook.NamespaceSelector{ - NameSelector: &gohook.NameSelector{ - MatchNames: shcfg.NamespaceSelector.NameSelector.MatchNames, - }, - LabelSelector: shcfg.NamespaceSelector.LabelSelector, - } - } + for i := range cfg.Kubernetes { + k := &cfg.Kubernetes[i] + out.Kubernetes = append(out.Kubernetes, convertAppKubernetesConfig(k, cfg.Queue)) + } - if shcfg.FieldSelector != nil { - fs := &gohook.FieldSelector{ - MatchExpressions: make([]gohook.FieldSelectorRequirement, 0, len(shcfg.FieldSelector.MatchExpressions)), - } + if cfg.OnStartup != nil { + out.OnStartup = ptr.To(cfg.OnStartup.Order) + } + if cfg.OnBeforeHelm != nil { + out.OnBeforeHelm = ptr.To(cfg.OnBeforeHelm.Order) + } + if cfg.OnAfterHelm != nil { + out.OnAfterHelm = ptr.To(cfg.OnAfterHelm.Order) + } + if cfg.OnAfterDeleteHelm != nil { + out.OnAfterDeleteHelm = ptr.To(cfg.OnAfterDeleteHelm.Order) + } +} - for _, expr := range shcfg.FieldSelector.MatchExpressions { - fs.MatchExpressions = append(fs.MatchExpressions, gohook.FieldSelectorRequirement(expr)) - } +func convertKubernetesConfig(k *pkg.KubernetesConfig, queue string) gohook.KubernetesConfig { + cfg := gohook.KubernetesConfig{ + APIVersion: k.APIVersion, + Kind: k.Kind, + Name: k.Name, + LabelSelector: k.LabelSelector, + ExecuteHookOnEvents: k.ExecuteHookOnEvents, + ExecuteHookOnSynchronization: k.ExecuteHookOnSynchronization, + WaitForSynchronization: k.WaitForSynchronization, + KeepFullObjectsInMemory: ptr.To(k.JqFilter == ""), + JqFilter: k.JqFilter, + AllowFailure: k.AllowFailure, + ResynchronizationPeriod: k.ResynchronizationPeriod, + Queue: queue, + } - newShCfg.FieldSelector = fs + if k.NameSelector != nil { + cfg.NameSelector = &gohook.NameSelector{MatchNames: k.NameSelector.MatchNames} + } + if k.NamespaceSelector != nil { + cfg.NamespaceSelector = &gohook.NamespaceSelector{ + NameSelector: &gohook.NameSelector{MatchNames: k.NamespaceSelector.NameSelector.MatchNames}, + LabelSelector: k.NamespaceSelector.LabelSelector, } - - newHookConfig.Kubernetes = append(newHookConfig.Kubernetes, newShCfg) } - - if cfg.OnStartup != nil { - newHookConfig.OnStartup = ptr.To(cfg.OnStartup.Order) + if k.FieldSelector != nil { + fs := &gohook.FieldSelector{ + MatchExpressions: make([]gohook.FieldSelectorRequirement, 0, len(k.FieldSelector.MatchExpressions)), + } + for _, expr := range k.FieldSelector.MatchExpressions { + fs.MatchExpressions = append(fs.MatchExpressions, gohook.FieldSelectorRequirement(expr)) + } + cfg.FieldSelector = fs } - if cfg.OnBeforeHelm != nil { - newHookConfig.OnBeforeHelm = ptr.To(cfg.OnBeforeHelm.Order) - } + return cfg +} - if cfg.OnAfterHelm != nil { - newHookConfig.OnAfterHelm = ptr.To(cfg.OnAfterHelm.Order) +func convertAppKubernetesConfig(k *pkg.ApplicationKubernetesConfig, queue string) gohook.KubernetesConfig { + cfg := gohook.KubernetesConfig{ + APIVersion: k.APIVersion, + Kind: k.Kind, + Name: k.Name, + LabelSelector: k.LabelSelector, + ExecuteHookOnEvents: k.ExecuteHookOnEvents, + ExecuteHookOnSynchronization: k.ExecuteHookOnSynchronization, + WaitForSynchronization: k.WaitForSynchronization, + KeepFullObjectsInMemory: ptr.To(k.JqFilter == ""), + JqFilter: k.JqFilter, + AllowFailure: k.AllowFailure, + ResynchronizationPeriod: k.ResynchronizationPeriod, + Queue: queue, } - if cfg.OnAfterDeleteHelm != nil { - newHookConfig.OnAfterDeleteHelm = ptr.To(cfg.OnAfterDeleteHelm.Order) + if k.NameSelector != nil { + cfg.NameSelector = &gohook.NameSelector{MatchNames: k.NameSelector.MatchNames} + } + if k.FieldSelector != nil { + fs := &gohook.FieldSelector{ + MatchExpressions: make([]gohook.FieldSelectorRequirement, 0, len(k.FieldSelector.MatchExpressions)), + } + for _, expr := range k.FieldSelector.MatchExpressions { + fs.MatchExpressions = append(fs.MatchExpressions, gohook.FieldSelectorRequirement(expr)) + } + cfg.FieldSelector = fs } - return newHookConfig + return cfg } diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go new file mode 100644 index 00000000..3e9b7b2f --- /dev/null +++ b/internal/controller/controller_test.go @@ -0,0 +1,159 @@ +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/deckhouse/module-sdk/pkg" +) + +func TestConvertAppKubernetesConfig(t *testing.T) { + t.Run("minimal config copies required fields and sets queue", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + } + cfg := convertAppKubernetesConfig(k, "main") + assert.Equal(t, "pods", cfg.Name) + assert.Equal(t, "v1", cfg.APIVersion) + assert.Equal(t, "Pod", cfg.Kind) + assert.Equal(t, "main", cfg.Queue) + }) + + t.Run("NamespaceSelector is always nil for application hooks", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + } + cfg := convertAppKubernetesConfig(k, "main") + assert.Nil(t, cfg.NamespaceSelector, "NamespaceSelector must be omitted for application hooks; addon-operator injects APPLICATION_NAMESPACE externally") + }) + + t.Run("NameSelector is converted when set", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "my-pod", + APIVersion: "v1", + Kind: "Pod", + NameSelector: &pkg.NameSelector{MatchNames: []string{"pod-1", "pod-2"}}, + } + cfg := convertAppKubernetesConfig(k, "main") + require.NotNil(t, cfg.NameSelector) + assert.Equal(t, []string{"pod-1", "pod-2"}, cfg.NameSelector.MatchNames) + }) + + t.Run("NameSelector is nil when not set", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + } + cfg := convertAppKubernetesConfig(k, "main") + assert.Nil(t, cfg.NameSelector) + }) + + t.Run("FieldSelector is converted when set", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + FieldSelector: &pkg.FieldSelector{ + MatchExpressions: []pkg.FieldSelectorRequirement{ + {Field: "status.phase", Operator: "In", Value: "Running"}, + }, + }, + } + cfg := convertAppKubernetesConfig(k, "main") + require.NotNil(t, cfg.FieldSelector) + require.Len(t, cfg.FieldSelector.MatchExpressions, 1) + assert.Equal(t, "status.phase", cfg.FieldSelector.MatchExpressions[0].Field) + assert.Equal(t, "In", cfg.FieldSelector.MatchExpressions[0].Operator) + assert.Equal(t, "Running", cfg.FieldSelector.MatchExpressions[0].Value) + }) + + t.Run("FieldSelector is nil when not set", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + } + cfg := convertAppKubernetesConfig(k, "main") + assert.Nil(t, cfg.FieldSelector) + }) + + t.Run("KeepFullObjectsInMemory is true when JqFilter is empty", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + } + cfg := convertAppKubernetesConfig(k, "main") + require.NotNil(t, cfg.KeepFullObjectsInMemory) + assert.True(t, *cfg.KeepFullObjectsInMemory) + }) + + t.Run("KeepFullObjectsInMemory is false when JqFilter is set", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + JqFilter: ".items[]", + } + cfg := convertAppKubernetesConfig(k, "main") + require.NotNil(t, cfg.KeepFullObjectsInMemory) + assert.False(t, *cfg.KeepFullObjectsInMemory) + }) + + t.Run("optional fields are passed through", func(t *testing.T) { + executeHookOnEvents := false + executeHookOnSync := true + waitForSync := false + allowFailure := true + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + LabelSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "foo"}}, + ExecuteHookOnEvents: &executeHookOnEvents, + ExecuteHookOnSynchronization: &executeHookOnSync, + WaitForSynchronization: &waitForSync, + AllowFailure: &allowFailure, + ResynchronizationPeriod: "15m", + } + cfg := convertAppKubernetesConfig(k, "queue-a") + assert.Equal(t, "queue-a", cfg.Queue) + require.NotNil(t, cfg.LabelSelector) + assert.Equal(t, map[string]string{"app": "foo"}, cfg.LabelSelector.MatchLabels) + require.NotNil(t, cfg.ExecuteHookOnEvents) + assert.False(t, *cfg.ExecuteHookOnEvents) + require.NotNil(t, cfg.ExecuteHookOnSynchronization) + assert.True(t, *cfg.ExecuteHookOnSynchronization) + require.NotNil(t, cfg.WaitForSynchronization) + assert.False(t, *cfg.WaitForSynchronization) + require.NotNil(t, cfg.AllowFailure) + assert.True(t, *cfg.AllowFailure) + assert.Equal(t, "15m", cfg.ResynchronizationPeriod) + }) + + t.Run("full config with all selectors still has NamespaceSelector nil", func(t *testing.T) { + k := &pkg.ApplicationKubernetesConfig{ + Name: "pods", + APIVersion: "v1", + Kind: "Pod", + NameSelector: &pkg.NameSelector{MatchNames: []string{"x"}}, + FieldSelector: &pkg.FieldSelector{ + MatchExpressions: []pkg.FieldSelectorRequirement{ + {Field: "metadata.name", Operator: "Equals", Value: "y"}, + }, + }, + } + cfg := convertAppKubernetesConfig(k, "main") + require.NotNil(t, cfg.NameSelector) + require.NotNil(t, cfg.FieldSelector) + assert.Nil(t, cfg.NamespaceSelector, "application hook config must never set NamespaceSelector") + }) +} diff --git a/internal/executor/application.go b/internal/executor/application.go index bc99d988..089879fe 100644 --- a/internal/executor/application.go +++ b/internal/executor/application.go @@ -16,20 +16,20 @@ import ( ) type applicationExecutor struct { - hook pkg.Hook[*pkg.ApplicationHookInput] + hook pkg.Hook[pkg.ApplicationHookConfig, *pkg.ApplicationHookInput] logger *log.Logger } // NewApplicationExecutor creates a new application executor -func NewApplicationExecutor(h pkg.Hook[*pkg.ApplicationHookInput], logger *log.Logger) Executor { +func NewApplicationExecutor(h pkg.Hook[pkg.ApplicationHookConfig, *pkg.ApplicationHookInput], logger *log.Logger) Executor { return &applicationExecutor{ hook: h, logger: logger, } } -func (e *applicationExecutor) Config() *pkg.HookConfig { - return e.hook.Config +func (e *applicationExecutor) Config() pkg.HookConfigInterface { + return &e.hook.Config } func (e *applicationExecutor) Execute(ctx context.Context, req Request) (Result, error) { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index 7f2331e1..4cdf1118 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -8,21 +8,36 @@ import ( "github.com/deckhouse/module-sdk/pkg/utils" ) +// Executor runs a hook with the provided request and returns results. +// Implemented by moduleExecutor and applicationExecutor. type Executor interface { - Config() *pkg.HookConfig + // Config returns the hook's configuration as HookConfigLike (*HookConfig or *ApplicationHookConfig). + Config() pkg.HookConfigInterface + // Execute runs the hook logic and returns collected results. Execute(ctx context.Context, req Request) (Result, error) } +// Request provides input data for hook execution. +// Implemented by transport layer (e.g., file transport). type Request interface { + // GetValues returns the current module values. GetValues() (map[string]any, error) + // GetConfigValues returns the module's ConfigMap values. GetConfigValues() (map[string]any, error) + // GetBindingContexts returns Kubernetes binding contexts with snapshots. GetBindingContexts() ([]bctx.BindingContext, error) + // GetDependencyContainer returns the container with external dependencies. GetDependencyContainer() pkg.DependencyContainer } +// Result contains outputs collected during hook execution. +// Used by transport layer to send results back to shell-operator. type Result interface { + // MetricsCollector returns collected Prometheus metrics. MetricsCollector() pkg.Outputer + // ObjectPatchCollector returns collected Kubernetes object patches. ObjectPatchCollector() pkg.Outputer + // ValuesPatchCollector returns collected values patches by type. ValuesPatchCollector(key utils.ValuesPatchType) pkg.Outputer } diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 55cb2319..92ad22ab 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -382,8 +382,8 @@ func Test_Go_Hook_Execute(t *testing.T) { t.Run(tt.meta.name, func(t *testing.T) { t.Parallel() - h := pkg.Hook[*pkg.HookInput]{ - Config: new(pkg.HookConfig), + h := pkg.Hook[pkg.HookConfig, *pkg.HookInput]{ + Config: pkg.HookConfig{}, HookFunc: tt.fields.setupHookReconcileFunc(t), } diff --git a/internal/executor/module.go b/internal/executor/module.go index f54b7014..76c08458 100644 --- a/internal/executor/module.go +++ b/internal/executor/module.go @@ -15,20 +15,20 @@ import ( ) type moduleExecutor struct { - hook pkg.Hook[*pkg.HookInput] + hook pkg.Hook[pkg.HookConfig, *pkg.HookInput] logger *log.Logger } -// NewModuleExecutor creates a new application hook -func NewModuleExecutor(h pkg.Hook[*pkg.HookInput], logger *log.Logger) Executor { +// NewModuleExecutor creates a new module hook executor +func NewModuleExecutor(h pkg.Hook[pkg.HookConfig, *pkg.HookInput], logger *log.Logger) Executor { return &moduleExecutor{ hook: h, logger: logger, } } -func (e *moduleExecutor) Config() *pkg.HookConfig { - return e.hook.Config +func (e *moduleExecutor) Config() pkg.HookConfigInterface { + return &e.hook.Config } func (e *moduleExecutor) Execute(ctx context.Context, req Request) (Result, error) { diff --git a/internal/executor/registry/registry.go b/internal/executor/registry/registry.go index ac1c097d..977cfa4f 100644 --- a/internal/executor/registry/registry.go +++ b/internal/executor/registry/registry.go @@ -34,20 +34,20 @@ func (r *Registry) Readiness() executor.Executor { return r.readinessExecutor } -func (r *Registry) RegisterModuleHooks(hooks ...pkg.Hook[*pkg.HookInput]) { +func (r *Registry) RegisterModuleHooks(hooks ...pkg.Hook[pkg.HookConfig, *pkg.HookInput]) { for _, h := range hooks { exec := executor.NewModuleExecutor(h, r.logger.Named(h.Config.Metadata.Name)) r.executors = append(r.executors, exec) } } -func (r *Registry) RegisterAppHooks(hooks ...pkg.Hook[*pkg.ApplicationHookInput]) { +func (r *Registry) RegisterAppHooks(hooks ...pkg.Hook[pkg.ApplicationHookConfig, *pkg.ApplicationHookInput]) { for _, h := range hooks { exec := executor.NewApplicationExecutor(h, r.logger.Named(h.Config.Metadata.Name)) r.executors = append(r.executors, exec) } } -func (r *Registry) SetReadinessHook(h pkg.Hook[*pkg.HookInput]) { +func (r *Registry) SetReadinessHook(h pkg.Hook[pkg.HookConfig, *pkg.HookInput]) { r.readinessExecutor = executor.NewModuleExecutor(h, r.logger.Named(h.Config.Metadata.Name)) } diff --git a/pkg/hook.go b/pkg/hook.go index 28b0cde7..49f3c7cd 100644 --- a/pkg/hook.go +++ b/pkg/hook.go @@ -10,23 +10,48 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// ============================================================================= +// Constants and Variables +// ============================================================================= + const ( EnvApplicationName = "APPLICATION_NAME" EnvApplicationNamespace = "APPLICATION_NAMESPACE" ) +var ( + camelCaseRegexp = regexp.MustCompile(`^[a-zA-Z]*$`) + cronScheduleRegex = regexp.MustCompile(`^((((\d+,)+\d+|(\d+(\/|-|#)\d+)|\d+L?|\*(\/\d+)?|L(-\d+)?|\?|[A-Z]{3}(-[A-Z]{3})?) ?){5,7})|(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)$`) +) + +// ============================================================================= +// Core Types and Type Constraints +// ============================================================================= + +// Input is a type constraint for hook input types. type Input interface { *HookInput | *ApplicationHookInput } -type Hook[T Input] struct { - Config *HookConfig +// Config is a type constraint for hook configuration types (used in generics). +type Config interface { + HookConfig | ApplicationHookConfig | *HookConfig | *ApplicationHookConfig +} + +// Hook represents a generic hook with configuration and execution function. +type Hook[C Config, T Input] struct { + Config C HookFunc HookFunc[T] } -// HookFunc function which holds the main logic of the hook +// HookFunc is the function signature for hook execution logic. type HookFunc[T Input] func(ctx context.Context, input T) error +// ============================================================================= +// Hook Input Types +// ============================================================================= + +// HookInput provides context and utilities for module hook execution. type HookInput struct { Snapshots Snapshots @@ -40,6 +65,7 @@ type HookInput struct { Logger Logger } +// ApplicationHookInput provides context and utilities for application hook execution. type ApplicationHookInput struct { Snapshots Snapshots @@ -54,7 +80,7 @@ type ApplicationHookInput struct { Logger Logger } -// Instance in application instance getter +// Instance provides access to application instance metadata. type Instance interface { // Name returns application instance name Name() string @@ -62,17 +88,52 @@ type Instance interface { Namespace() string } +// ============================================================================= +// Hook Metadata and Settings +// ============================================================================= + +// HookMetadata contains identifying information for a hook. type HookMetadata struct { - // Hook name + // Name is the hook's unique identifier Name string - // Hook path + // Path is the file path where the hook is defined Path string } +// HookConfigInterface is implemented by *HookConfig and *ApplicationHookConfig. +// It provides type-safe access to common config fields and explicit conversion +// to concrete types, avoiding unsafe type assertions on any. +type HookConfigInterface interface { + GetMetadata() HookMetadata + GetQueue() string + // AsHookConfig returns the config as *HookConfig if it is a module hook config. + AsHookConfig() (*HookConfig, bool) + // AsApplicationHookConfig returns the config as *ApplicationHookConfig if it is an application hook config. + AsApplicationHookConfig() (*ApplicationHookConfig, bool) +} + +// OrderedConfig specifies execution order for lifecycle hooks. +type OrderedConfig struct { + Order uint +} + +// HookConfigSettings contains rate limiting settings for hook execution. +type HookConfigSettings struct { + ExecutionMinInterval time.Duration + ExecutionBurst int +} + +// ============================================================================= +// Module Hook Configuration +// ============================================================================= + +// HookConfig defines the configuration for a module hook. type HookConfig struct { - Metadata HookMetadata - Schedule []ScheduleConfig + Metadata HookMetadata + Schedule []ScheduleConfig + Kubernetes []KubernetesConfig + // OnStartup runs hook on module/global startup // Attention! During the startup you don't have snapshots available // use native KubeClient to fetch resources @@ -87,12 +148,7 @@ type HookConfig struct { Settings *HookConfigSettings } -var ( - kebabCaseRegexp = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) - camelCaseRegexp = regexp.MustCompile(`^[a-zA-Z]*$`) - cronScheduleRegex = regexp.MustCompile(`^((((\d+,)+\d+|(\d+(\/|-|#)\d+)|\d+L?|\*(\/\d+)?|L(-\d+)?|\?|[A-Z]{3}(-[A-Z]{3})?) ?){5,7})|(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)$`) -) - +// Validate checks the HookConfig for errors. func (cfg *HookConfig) Validate() error { var errs error // list of not validated fields: @@ -109,31 +165,93 @@ func (cfg *HookConfig) Validate() error { } } - return nil + return errs } -type OrderedConfig struct { - Order uint +// GetMetadata implements HookConfigLike. +func (cfg *HookConfig) GetMetadata() HookMetadata { return cfg.Metadata } + +// GetQueue implements HookConfigLike. +func (cfg *HookConfig) GetQueue() string { return cfg.Queue } + +// AsHookConfig implements HookConfigLike. +func (cfg *HookConfig) AsHookConfig() (*HookConfig, bool) { return cfg, true } + +// AsApplicationHookConfig implements HookConfigLike. +func (cfg *HookConfig) AsApplicationHookConfig() (*ApplicationHookConfig, bool) { return nil, false } + +// ============================================================================= +// Application Hook Configuration +// ============================================================================= + +// ApplicationHookConfig defines the configuration for an application hook. +type ApplicationHookConfig struct { + Metadata HookMetadata + Schedule []ScheduleConfig + + Kubernetes []ApplicationKubernetesConfig + + // OnStartup runs hook on application startup + // Attention! During the startup you don't have snapshots available + // use native KubeClient to fetch resources + OnStartup *OrderedConfig + OnBeforeHelm *OrderedConfig + OnAfterHelm *OrderedConfig + OnAfterDeleteHelm *OrderedConfig + + AllowFailure bool + Queue string + + Settings *HookConfigSettings } -type HookConfigSettings struct { - ExecutionMinInterval time.Duration - ExecutionBurst int +// Validate checks the ApplicationHookConfig for errors. +func (cfg *ApplicationHookConfig) Validate() error { + var errs error + for _, s := range cfg.Schedule { + if err := s.Validate(); err != nil { + errs = errors.Join(errs, fmt.Errorf("schedule with name '%s': %w", s.Name, err)) + } + } + + for _, k := range cfg.Kubernetes { + if err := k.Validate(); err != nil { + errs = errors.Join(errs, fmt.Errorf("kubernetes config with name '%s': %w", k.Name, err)) + } + } + + return errs +} + +// GetMetadata implements HookConfigLike. +func (cfg *ApplicationHookConfig) GetMetadata() HookMetadata { return cfg.Metadata } + +// GetQueue implements HookConfigLike. +func (cfg *ApplicationHookConfig) GetQueue() string { return cfg.Queue } + +// AsHookConfig implements HookConfigLike. +func (cfg *ApplicationHookConfig) AsHookConfig() (*HookConfig, bool) { return nil, false } + +// AsApplicationHookConfig implements HookConfigLike. +func (cfg *ApplicationHookConfig) AsApplicationHookConfig() (*ApplicationHookConfig, bool) { + return cfg, true } +// ============================================================================= +// Schedule Configuration +// ============================================================================= + +// ScheduleConfig defines a cron-based schedule for hook execution. type ScheduleConfig struct { Name string // Crontab is a schedule config in crontab format. (5 or 6 fields) Crontab string } +// Validate checks the ScheduleConfig for errors. func (cfg *ScheduleConfig) Validate() error { var errs error - if !camelCaseRegexp.Match([]byte(cfg.Name)) { - errs = errors.Join(errs, errors.New("name has not letter symbols")) - } - if !cronScheduleRegex.Match([]byte(cfg.Crontab)) { errs = errors.Join(errs, errors.New("crontab is not valid")) } @@ -141,6 +259,11 @@ func (cfg *ScheduleConfig) Validate() error { return errs } +// ============================================================================= +// Kubernetes Configuration +// ============================================================================= + +// KubernetesConfig defines a subscription to Kubernetes objects for module hooks. type KubernetesConfig struct { // Name is a key in snapshots map. Name string @@ -162,82 +285,89 @@ type KubernetesConfig struct { ExecuteHookOnSynchronization *bool // WaitForSynchronization is true by default. Set to false if beforeHelm is not required this snapshot on start. WaitForSynchronization *bool - // JQ filter to filter results from kubernetes objects + // JqFilter filters results from kubernetes objects. JqFilter string - // Allow to fail hook + // AllowFailure allows the hook to fail without stopping execution. AllowFailure *bool ResynchronizationPeriod string } -// you must test JqFilter by yourself +// Validate checks the KubernetesConfig for errors. func (cfg *KubernetesConfig) Validate() error { var errs error - if !kebabCaseRegexp.Match([]byte(cfg.Name)) { - errs = errors.Join(errs, errors.New("name is not kebab case")) - } - if !camelCaseRegexp.Match([]byte(cfg.Kind)) { errs = errors.Join(errs, errors.New("kind has not letter symbols")) } - if err := cfg.NameSelector.Validate(); err != nil { - errs = errors.Join(errs, fmt.Errorf("name selector: %w", err)) - } - - if err := cfg.NamespaceSelector.Validate(); err != nil { - errs = errors.Join(errs, fmt.Errorf("namespace selector: %w", err)) - } - return errs } -type NameSelector struct { - MatchNames []string -} +// ApplicationKubernetesConfig defines a subscription to Kubernetes objects for application hooks. +// Application hooks automatically work in the application's namespace, +// so NamespaceSelector is not allowed. +type ApplicationKubernetesConfig struct { + // Name is a key in snapshots map. + Name string + // APIVersion of objects. "v1" is used if not set. + APIVersion string + // Kind of objects. + Kind string + // NameSelector used to subscribe on object by its name. + NameSelector *NameSelector + // LabelSelector used to subscribe on objects by matching their labels. + LabelSelector *metav1.LabelSelector + // FieldSelector used to subscribe on objects by matching specific fields (the list of fields is narrow, see shell-operator documentation). + FieldSelector *FieldSelector + // ExecuteHookOnEvents is true by default. Set to false if only snapshot update is needed. + ExecuteHookOnEvents *bool + // ExecuteHookOnSynchronization is true by default. Set to false if only snapshot update is needed. + ExecuteHookOnSynchronization *bool + // WaitForSynchronization is true by default. Set to false if beforeHelm is not required this snapshot on start. + WaitForSynchronization *bool + // JqFilter filters results from kubernetes objects. + JqFilter string + // AllowFailure allows the hook to fail without stopping execution. + AllowFailure *bool -func (cfg *NameSelector) Validate() error { - if cfg == nil { - return nil - } + ResynchronizationPeriod string +} +// Validate checks the ApplicationKubernetesConfig for errors. +func (cfg *ApplicationKubernetesConfig) Validate() error { var errs error - for _, sel := range cfg.MatchNames { - if !kebabCaseRegexp.Match([]byte(sel)) { - errs = errors.Join(errs, fmt.Errorf("selector is not kebab case '%s'", sel)) - } + if !camelCaseRegexp.Match([]byte(cfg.Kind)) { + errs = errors.Join(errs, errors.New("kind has not letter symbols")) } return errs } +// ============================================================================= +// Selectors +// ============================================================================= + +// NameSelector filters objects by name. +type NameSelector struct { + MatchNames []string +} + +// FieldSelectorRequirement defines a single field selector condition. type FieldSelectorRequirement struct { Field string Operator string Value string } +// FieldSelector filters objects by field values. type FieldSelector struct { MatchExpressions []FieldSelectorRequirement } +// NamespaceSelector filters namespaces for object subscription. type NamespaceSelector struct { NameSelector *NameSelector LabelSelector *metav1.LabelSelector } - -func (cfg *NamespaceSelector) Validate() error { - if cfg == nil { - return nil - } - - var errs error - - if err := cfg.NameSelector.Validate(); err != nil { - errs = errors.Join(errs, fmt.Errorf("name selector: %w", err)) - } - - return errs -} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index 220bf883..ce11915d 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -1,6 +1,7 @@ package registry import ( + "fmt" "regexp" "runtime" "strings" @@ -9,7 +10,7 @@ import ( "github.com/deckhouse/module-sdk/pkg" ) -const bindingsPanicMsg = "OnStartup hook always has binding context without Kubernetes snapshots. To prevent logic errors, don't use OnStartup and Kubernetes bindings in the same Go hook configuration." +const BindingsPanicMsg = "OnStartup hook always has binding context without Kubernetes snapshots. To prevent logic errors, don't use OnStartup and Kubernetes bindings in the same Go hook configuration." var ( instance *HookRegistry @@ -20,60 +21,134 @@ var ( hookRe = regexp.MustCompile(`([^/]*)/hooks/(.*)$`) ) +// HookRegistry stores registered module and application hooks. +// It is a singleton accessed via Registry(). type HookRegistry struct { - mtx sync.Mutex - moduleHooks []pkg.Hook[*pkg.HookInput] - applicationHooks []pkg.Hook[*pkg.ApplicationHookInput] + mtx sync.Mutex + moduleHooks []pkg.Hook[pkg.HookConfig, *pkg.HookInput] + appHooks []pkg.Hook[pkg.ApplicationHookConfig, *pkg.ApplicationHookInput] } // Registry returns singleton instance, it is used it only in controller func Registry() *HookRegistry { once.Do(func() { instance = &HookRegistry{ - moduleHooks: make([]pkg.Hook[*pkg.HookInput], 0, 1), - applicationHooks: make([]pkg.Hook[*pkg.ApplicationHookInput], 0, 1), + moduleHooks: make([]pkg.Hook[pkg.HookConfig, *pkg.HookInput], 0, 1), + appHooks: make([]pkg.Hook[pkg.ApplicationHookConfig, *pkg.ApplicationHookInput], 0, 1), } }) return instance } -func (h *HookRegistry) ModuleHooks() []pkg.Hook[*pkg.HookInput] { +// ModuleHooks returns all registered module hooks. +func (h *HookRegistry) ModuleHooks() []pkg.Hook[pkg.HookConfig, *pkg.HookInput] { return h.moduleHooks } -func (h *HookRegistry) ApplicationHooks() []pkg.Hook[*pkg.ApplicationHookInput] { - return h.applicationHooks +// ApplicationHooks returns all registered application hooks. +func (h *HookRegistry) ApplicationHooks() []pkg.Hook[pkg.ApplicationHookConfig, *pkg.ApplicationHookInput] { + return h.appHooks } -func RegisterFunc[T pkg.Input](config *pkg.HookConfig, f pkg.HookFunc[T]) bool { +// RegisterFunc registers a hook with the global registry. +// It accepts both module and application hook configurations. +// Returns true to allow usage in var declarations: var _ = registry.RegisterFunc(...) +func RegisterFunc[C pkg.Config, T pkg.Input](config C, f pkg.HookFunc[T]) bool { registerHook(Registry(), config, f) return true } -func registerHook[T pkg.Input](r *HookRegistry, cfg *pkg.HookConfig, f pkg.HookFunc[T]) { - if cfg.OnStartup != nil && len(cfg.Kubernetes) > 0 { - panic(bindingsPanicMsg) +// registerHook validates and registers a hook with the given registry. +// It handles both pointer and value config types through type switching. +// Panics if validation fails or if OnStartup and Kubernetes bindings are mixed. +func registerHook[C pkg.Config, T pkg.Input](r *HookRegistry, cfg C, f pkg.HookFunc[T]) { + // Phase 1: Validate OnStartup + Kubernetes conflict before extracting metadata. + // This check must happen first to ensure proper panic ordering. + switch c := any(cfg).(type) { + case *pkg.HookConfig: + if c.OnStartup != nil && len(c.Kubernetes) > 0 { + panic(BindingsPanicMsg) + } + case pkg.HookConfig: + if c.OnStartup != nil && len(c.Kubernetes) > 0 { + panic(BindingsPanicMsg) + } + case *pkg.ApplicationHookConfig: + if c.OnStartup != nil && len(c.Kubernetes) > 0 { + panic(BindingsPanicMsg) + } + case pkg.ApplicationHookConfig: + if c.OnStartup != nil && len(c.Kubernetes) > 0 { + panic(BindingsPanicMsg) + } } - cfg.Metadata = extractHookMetadata() + // Phase 2: Extract hook metadata from call stack (hook name and path). + meta := extractHookMetadata() r.mtx.Lock() defer r.mtx.Unlock() - hook := pkg.Hook[T]{Config: cfg, HookFunc: f} + // Phase 3: Set metadata, validate config, and register the hook. + // Type switch is required because pkg.Config is a type constraint (union type), + // not a regular interface - we cannot call methods on it directly. + switch c := any(cfg).(type) { + case *pkg.HookConfig: + c.Metadata = meta + if err := c.Validate(); err != nil { + panic(fmt.Sprintf("hook validation failed for %q: %v", c.Metadata.Name, err)) + } + hook := pkg.Hook[pkg.HookConfig, *pkg.HookInput]{ + Config: *c, + HookFunc: any(f).(pkg.HookFunc[*pkg.HookInput]), + } + r.moduleHooks = append(r.moduleHooks, hook) + + case pkg.HookConfig: + c.Metadata = meta + if err := c.Validate(); err != nil { + panic(fmt.Sprintf("hook validation failed for %q: %v", c.Metadata.Name, err)) + } + hook := pkg.Hook[pkg.HookConfig, *pkg.HookInput]{ + Config: c, + HookFunc: any(f).(pkg.HookFunc[*pkg.HookInput]), + } + r.moduleHooks = append(r.moduleHooks, hook) + + case *pkg.ApplicationHookConfig: + c.Metadata = meta + if err := c.Validate(); err != nil { + panic(fmt.Sprintf("hook validation failed for %q: %v", c.Metadata.Name, err)) + } + hook := pkg.Hook[pkg.ApplicationHookConfig, *pkg.ApplicationHookInput]{ + Config: *c, + HookFunc: any(f).(pkg.HookFunc[*pkg.ApplicationHookInput]), + } + r.appHooks = append(r.appHooks, hook) + + case pkg.ApplicationHookConfig: + c.Metadata = meta + if err := c.Validate(); err != nil { + panic(fmt.Sprintf("hook validation failed for %q: %v", c.Metadata.Name, err)) + } + hook := pkg.Hook[pkg.ApplicationHookConfig, *pkg.ApplicationHookInput]{ + Config: c, + HookFunc: any(f).(pkg.HookFunc[*pkg.ApplicationHookInput]), + } + r.appHooks = append(r.appHooks, hook) - switch any(hook).(type) { - case pkg.Hook[*pkg.HookInput]: - r.moduleHooks = append(r.moduleHooks, any(hook).(pkg.Hook[*pkg.HookInput])) - case pkg.Hook[*pkg.ApplicationHookInput]: - r.applicationHooks = append(r.applicationHooks, any(hook).(pkg.Hook[*pkg.ApplicationHookInput])) default: - panic("unknown hook input type") + panic("unknown hook config type") } } +// extractHookMetadata walks the call stack to extract hook name and path. +// It looks for frames matching the pattern ".../hooks/..." to determine +// the hook's location in the module structure. +// Panics if no valid hook path is found in the call stack. func extractHookMetadata() pkg.HookMetadata { + // Capture call stack (up to 50 frames deep) pc := make([]uintptr, 50) n := runtime.Callers(0, pc) if n == 0 { @@ -85,9 +160,12 @@ func extractHookMetadata() pkg.HookMetadata { meta := pkg.HookMetadata{} for { frame, more := frames.Next() + // Look for frames with ".../hooks/..." pattern matches := hookRe.FindStringSubmatch(frame.File) if matches != nil { + // Extract hook name from path (e.g., "subfolder/my_hook" from ".../hooks/subfolder/my_hook.go") meta.Name = strings.TrimRight(matches[2], ".go") + // Extract directory path lastSlashIdx := strings.LastIndex(matches[0], "/") meta.Path = matches[0][:lastSlashIdx+1] } diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index 1763a5e9..2fe7c875 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -26,7 +26,7 @@ func TestRegister(t *testing.T) { defer func() { r := recover() require.NotEmpty(t, r) - assert.Equal(t, bindingsPanicMsg, r) + assert.Equal(t, BindingsPanicMsg, r) }() RegisterFunc(hook, func(_ context.Context, _ *pkg.HookInput) error { @@ -41,7 +41,7 @@ func TestRegister(t *testing.T) { defer func() { r := recover() - assert.NotEqual(t, bindingsPanicMsg, r) + assert.NotEqual(t, BindingsPanicMsg, r) }() RegisterFunc(hook, func(_ context.Context, _ *pkg.HookInput) error { @@ -63,7 +63,7 @@ func TestRegister(t *testing.T) { defer func() { r := recover() - assert.NotEqual(t, bindingsPanicMsg, r) + assert.NotEqual(t, BindingsPanicMsg, r) }() RegisterFunc(hook, func(_ context.Context, _ *pkg.HookInput) error { diff --git a/pkg/registry/test/simple_module/hooks/001-hook-one/application_hook_test.go b/pkg/registry/test/simple_module/hooks/001-hook-one/application_hook_test.go new file mode 100644 index 00000000..dc009209 --- /dev/null +++ b/pkg/registry/test/simple_module/hooks/001-hook-one/application_hook_test.go @@ -0,0 +1,59 @@ +package hooks + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/module-sdk/pkg" + "github.com/deckhouse/module-sdk/pkg/registry" +) + +// TestApplicationHookRegistration runs from a path containing ".../hooks/001-hook-one/", +// so the registry's extractHookMetadata() can extract Name and Path from the call stack. +func TestApplicationHookRegistration(t *testing.T) { + t.Run("Application hook with Kubernetes binding should not panic", func(t *testing.T) { + hook := &pkg.ApplicationHookConfig{ + Kubernetes: []pkg.ApplicationKubernetesConfig{ + { + Name: "test", + APIVersion: "v1", + Kind: "Pod", + }, + }, + } + + defer func() { + r := recover() + assert.NotEqual(t, registry.BindingsPanicMsg, r) + }() + + registry.RegisterFunc(hook, func(_ context.Context, _ *pkg.ApplicationHookInput) error { + return nil + }) + }) + + t.Run("Application hook metadata is extracted from call stack", func(t *testing.T) { + hook := &pkg.ApplicationHookConfig{ + Kubernetes: []pkg.ApplicationKubernetesConfig{ + { + Name: "test", + APIVersion: "v1", + Kind: "Pod", + }, + }, + } + + registry.RegisterFunc(hook, func(_ context.Context, _ *pkg.ApplicationHookInput) error { + return nil + }) + + appHooks := registry.Registry().ApplicationHooks() + require.GreaterOrEqual(t, len(appHooks), 1, "at least one application hook should be registered") + registered := appHooks[len(appHooks)-1] + assert.NotEmpty(t, registered.Config.Metadata.Name, "Metadata.Name should be set by extractHookMetadata from call stack") + assert.NotEmpty(t, registered.Config.Metadata.Path, "Metadata.Path should be set by extractHookMetadata from call stack") + }) +}