From e2bd67a6032618902339481d53c38b7a1412459d Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 28 Apr 2026 23:04:34 +0300 Subject: [PATCH 1/5] feat(config): add GoMemLimitPercentage to NodeAgentAutoscalerConfig Signed-off-by: Ben --- config/config.go | 2 ++ config/config_test.go | 1 + 2 files changed, 3 insertions(+) diff --git a/config/config.go b/config/config.go index c68979e..8232e2a 100644 --- a/config/config.go +++ b/config/config.go @@ -74,6 +74,7 @@ type NodeAgentAutoscalerConfig struct { ReconcileInterval time.Duration `json:"reconcileInterval" mapstructure:"reconcileInterval"` TemplatePath string `json:"templatePath" mapstructure:"templatePath"` OperatorDeploymentName string `json:"operatorDeploymentName" mapstructure:"operatorDeploymentName"` + GoMemLimitPercentage float64 `json:"goMemLimitPercentage" mapstructure:"goMemLimitPercentage"` } type Server struct { @@ -316,6 +317,7 @@ func LoadConfig(path string) (Config, error) { viper.SetDefault("nodeAgentAutoscaler.reconcileInterval", 5*time.Minute) viper.SetDefault("nodeAgentAutoscaler.templatePath", "/etc/templates/daemonset-template.yaml") viper.SetDefault("nodeAgentAutoscaler.operatorDeploymentName", "operator") + viper.SetDefault("nodeAgentAutoscaler.goMemLimitPercentage", 0.8) viper.AutomaticEnv() diff --git a/config/config_test.go b/config/config_test.go index d9f8b69..f58a64b 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -122,6 +122,7 @@ func TestLoadConfig(t *testing.T) { ReconcileInterval: 5 * time.Minute, TemplatePath: "/etc/templates/daemonset-template.yaml", OperatorDeploymentName: "operator", + GoMemLimitPercentage: 0.8, }, }, }, From 81ffeb3bc7dce6d629877d29fc00c0772465d8b3 Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 28 Apr 2026 23:13:14 +0300 Subject: [PATCH 2/5] feat(autoscaler): compute GoMemLimit per node group in TemplateRenderer Signed-off-by: Ben --- nodeagentautoscaler/autoscaler.go | 2 +- nodeagentautoscaler/templaterenderer.go | 29 ++-- nodeagentautoscaler/templaterenderer_test.go | 131 ++++++++++++++++++- 3 files changed, 150 insertions(+), 12 deletions(-) diff --git a/nodeagentautoscaler/autoscaler.go b/nodeagentautoscaler/autoscaler.go index 50e1032..4bb575c 100644 --- a/nodeagentautoscaler/autoscaler.go +++ b/nodeagentautoscaler/autoscaler.go @@ -54,7 +54,7 @@ type Autoscaler struct { // NewAutoscaler creates a new Autoscaler instance func NewAutoscaler(client kubernetes.Interface, cfg config.NodeAgentAutoscalerConfig, namespace string, operatorDeploymentName string) (*Autoscaler, error) { - templateRenderer, err := NewTemplateRenderer(cfg.TemplatePath) + templateRenderer, err := NewTemplateRenderer(cfg.TemplatePath, cfg.GoMemLimitPercentage) if err != nil { return nil, err } diff --git a/nodeagentautoscaler/templaterenderer.go b/nodeagentautoscaler/templaterenderer.go index 4f0a011..25a7006 100644 --- a/nodeagentautoscaler/templaterenderer.go +++ b/nodeagentautoscaler/templaterenderer.go @@ -25,6 +25,8 @@ type TemplateData struct { NodeGroupLabel string // Resources contains the calculated resource requests and limits Resources TemplateResources + // GoMemLimit is the GOMEMLIMIT value derived from the memory limit and percentage (e.g., "360MiB") + GoMemLimit string } // TemplateResources holds the resource values for template rendering @@ -41,17 +43,23 @@ type TemplateResourcePair struct { // TemplateRenderer loads and renders DaemonSet templates type TemplateRenderer struct { - templatePath string - template *template.Template - mu sync.RWMutex // Protects template during reload - watcher *fsnotify.Watcher - stopCh chan struct{} + templatePath string + template *template.Template + mu sync.RWMutex // Protects template during reload + watcher *fsnotify.Watcher + stopCh chan struct{} + goMemLimitPercentage float64 } // NewTemplateRenderer creates a new TemplateRenderer -func NewTemplateRenderer(templatePath string) (*TemplateRenderer, error) { +func NewTemplateRenderer(templatePath string, goMemLimitPercentage float64) (*TemplateRenderer, error) { + if goMemLimitPercentage <= 0 || goMemLimitPercentage > 1.0 { + return nil, fmt.Errorf("goMemLimitPercentage %v is out of valid range (0, 1.0]", goMemLimitPercentage) + } + tr := &TemplateRenderer{ - templatePath: templatePath, + templatePath: templatePath, + goMemLimitPercentage: goMemLimitPercentage, } if err := tr.loadTemplate(); err != nil { @@ -200,6 +208,9 @@ func formatMemory(q resource.Quantity) string { // RenderDaemonSet renders a DaemonSet for the given node group and resources func (tr *TemplateRenderer) RenderDaemonSet(group NodeGroup, resources CalculatedResources) (*appsv1.DaemonSet, error) { + limitBytes := resources.Limits.Memory.Value() + goMemLimitMiB := int64(float64(limitBytes) * tr.goMemLimitPercentage / (1024 * 1024)) + data := TemplateData{ Name: fmt.Sprintf("node-agent-%s", group.SanitizedName), NodeGroupLabel: group.LabelValue, @@ -213,6 +224,7 @@ func (tr *TemplateRenderer) RenderDaemonSet(group NodeGroup, resources Calculate Memory: formatMemory(resources.Limits.Memory), }, }, + GoMemLimit: fmt.Sprintf("%dMiB", goMemLimitMiB), } tr.mu.RLock() @@ -236,7 +248,8 @@ func (tr *TemplateRenderer) RenderDaemonSet(group NodeGroup, resources Calculate helpers.String("requestCPU", data.Resources.Requests.CPU), helpers.String("requestMemory", data.Resources.Requests.Memory), helpers.String("limitCPU", data.Resources.Limits.CPU), - helpers.String("limitMemory", data.Resources.Limits.Memory)) + helpers.String("limitMemory", data.Resources.Limits.Memory), + helpers.String("goMemLimit", data.GoMemLimit)) return ds, nil } diff --git a/nodeagentautoscaler/templaterenderer_test.go b/nodeagentautoscaler/templaterenderer_test.go index 7b48a6e..441cec1 100644 --- a/nodeagentautoscaler/templaterenderer_test.go +++ b/nodeagentautoscaler/templaterenderer_test.go @@ -110,7 +110,7 @@ spec: require.NoError(t, err) // Create renderer - renderer, err := NewTemplateRenderer(templatePath) + renderer, err := NewTemplateRenderer(templatePath, 0.8) require.NoError(t, err) // Test data @@ -160,11 +160,39 @@ func TestTemplateRenderer_RenderDaemonSet_InvalidTemplate(t *testing.T) { require.NoError(t, err) // Should fail to create renderer - _, err = NewTemplateRenderer(templatePath) + _, err = NewTemplateRenderer(templatePath, 0.8) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to parse template") } +func TestTemplateRenderer_NewTemplateRenderer_InvalidPercentage(t *testing.T) { + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "daemonset-template.yaml") + err := os.WriteFile(templatePath, []byte("apiVersion: apps/v1\nkind: DaemonSet\nmetadata:\n name: test\nspec:\n selector:\n matchLabels:\n app: test\n template:\n metadata:\n labels:\n app: test\n spec:\n containers:\n - name: test\n image: test\n"), 0644) + require.NoError(t, err) + + tests := []struct { + name string + percentage float64 + }{ + {name: "zero", percentage: 0}, + {name: "negative", percentage: -0.5}, + {name: "greater than 1.0", percentage: 1.5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewTemplateRenderer(templatePath, tt.percentage) + assert.Error(t, err) + assert.Contains(t, err.Error(), "out of valid range") + }) + } + + // Boundary: exactly 1.0 should be valid + _, err = NewTemplateRenderer(templatePath, 1.0) + assert.NoError(t, err) +} + func TestTemplateRenderer_ReloadTemplate(t *testing.T) { templateContent1 := `apiVersion: apps/v1 kind: DaemonSet @@ -225,7 +253,7 @@ spec: require.NoError(t, err) // Create renderer - renderer, err := NewTemplateRenderer(templatePath) + renderer, err := NewTemplateRenderer(templatePath, 0.8) require.NoError(t, err) group := NodeGroup{ @@ -262,4 +290,101 @@ spec: assert.Equal(t, "node-agent-test-v2", ds2.Name) } +func TestTemplateRenderer_RenderDaemonSet_GoMemLimit(t *testing.T) { + templateContent := `apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: "{{ .Name }}" + namespace: kubescape +spec: + selector: + matchLabels: + app: node-agent + template: + metadata: + labels: + app: node-agent + spec: + containers: + - name: node-agent + image: "quay.io/kubescape/node-agent:v0.3.3" + resources: + requests: + cpu: "{{ .Resources.Requests.CPU }}" + memory: "{{ .Resources.Requests.Memory }}" + limits: + cpu: "{{ .Resources.Limits.CPU }}" + memory: "{{ .Resources.Limits.Memory }}" + env: + - name: GOMEMLIMIT + value: "{{ .GoMemLimit }}" +` + + tmpDir := t.TempDir() + templatePath := filepath.Join(tmpDir, "daemonset-template.yaml") + err := os.WriteFile(templatePath, []byte(templateContent), 0644) + require.NoError(t, err) + + tests := []struct { + name string + limitMemory string + percentage float64 + expectedGoMemLimit string + }{ + { + name: "450Mi at 80% = 360MiB", + limitMemory: "450Mi", + percentage: 0.8, + expectedGoMemLimit: "360MiB", + }, + { + name: "1Gi at 80% = 819MiB", + limitMemory: "1Gi", + percentage: 0.8, + expectedGoMemLimit: "819MiB", + }, + { + name: "1800Mi at 80% = 1440MiB", + limitMemory: "1800Mi", + percentage: 0.8, + expectedGoMemLimit: "1440MiB", + }, + { + name: "900Mi at 80% = 720MiB", + limitMemory: "900Mi", + percentage: 0.8, + expectedGoMemLimit: "720MiB", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + renderer, err := NewTemplateRenderer(templatePath, tt.percentage) + require.NoError(t, err) + + group := NodeGroup{LabelValue: "m5.large", SanitizedName: "m5-large"} + resources := CalculatedResources{ + Requests: ResourcePair{ + CPU: resource.MustParse("100m"), + Memory: resource.MustParse("200Mi"), + }, + Limits: ResourcePair{ + CPU: resource.MustParse("500m"), + Memory: resource.MustParse(tt.limitMemory), + }, + } + + ds, err := renderer.RenderDaemonSet(group, resources) + require.NoError(t, err) + + var goMemLimitValue string + for _, env := range ds.Spec.Template.Spec.Containers[0].Env { + if env.Name == "GOMEMLIMIT" { + goMemLimitValue = env.Value + break + } + } + assert.Equal(t, tt.expectedGoMemLimit, goMemLimitValue) + }) + } +} From 77c892b551086d7a5b9b9fe52959496196825f70 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 29 Apr 2026 08:57:47 +0300 Subject: [PATCH 3/5] fix: update integration test to pass goMemLimitPercentage to NewTemplateRenderer Signed-off-by: Ben --- nodeagentautoscaler/integration_test.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/nodeagentautoscaler/integration_test.go b/nodeagentautoscaler/integration_test.go index 1cfceb9..7552bd5 100644 --- a/nodeagentautoscaler/integration_test.go +++ b/nodeagentautoscaler/integration_test.go @@ -14,10 +14,11 @@ import ( // TestIntegration_HelmGeneratedTemplate tests rendering with the actual Helm-generated template // Run with: go test -tags=integration -v -run TestIntegration_HelmGeneratedTemplate // Requires the template file to be extracted first from Helm: -// helm template test ../../helm-charts/charts/kubescape-operator \ -// --set nodeAgent.autoscaler.enabled=true --set clusterName=test \ -// | grep -A 300 "daemonset-template.yaml:" | tail -n +2 | sed 's/^ //' \ -// | awk '/^---/{exit} {print}' > /tmp/test-daemonset-template.yaml +// +// helm template test ../../helm-charts/charts/kubescape-operator \ +// --set nodeAgent.autoscaler.enabled=true --set clusterName=test \ +// | grep -A 300 "daemonset-template.yaml:" | tail -n +2 | sed 's/^ //' \ +// | awk '/^---/{exit} {print}' > /tmp/test-daemonset-template.yaml func TestIntegration_HelmGeneratedTemplate(t *testing.T) { templatePath := "/tmp/test-daemonset-template.yaml" @@ -27,7 +28,7 @@ func TestIntegration_HelmGeneratedTemplate(t *testing.T) { } // Create renderer - renderer, err := NewTemplateRenderer(templatePath) + renderer, err := NewTemplateRenderer(templatePath, 0.8) require.NoError(t, err) // Test data simulating a node group @@ -76,4 +77,3 @@ func TestIntegration_HelmGeneratedTemplate(t *testing.T) { container.Resources.Limits.Cpu().String(), container.Resources.Limits.Memory().String()) } - From ba4c063a1b6ffb8594a4858003d924be917dd96b Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 29 Apr 2026 09:00:53 +0300 Subject: [PATCH 4/5] ci: add integration build check to PR workflow Signed-off-by: Ben --- .github/workflows/pr-created.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-created.yaml b/.github/workflows/pr-created.yaml index 82f856f..0f5bf69 100644 --- a/.github/workflows/pr-created.yaml +++ b/.github/workflows/pr-created.yaml @@ -5,7 +5,6 @@ on: types: [opened, reopened, synchronize, ready_for_review] paths-ignore: - '*.md' - - '*.yaml' - '.github/workflows/*' concurrency: @@ -22,3 +21,13 @@ jobs: CGO_ENABLED: 0 GO_VERSION: "1.25" secrets: inherit + + integration-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + - name: Build with integration tag + run: go test -tags=integration -run=^$ ./... From 57b3b9dcff56cc46b688b67636f3c5d56ddb1eac Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 29 Apr 2026 10:06:03 +0300 Subject: [PATCH 5/5] fix: set GoMemLimitPercentage in TestNewAutoscaler Signed-off-by: Ben --- nodeagentautoscaler/autoscaler_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nodeagentautoscaler/autoscaler_test.go b/nodeagentautoscaler/autoscaler_test.go index 2d0fb79..bec5033 100644 --- a/nodeagentautoscaler/autoscaler_test.go +++ b/nodeagentautoscaler/autoscaler_test.go @@ -423,14 +423,14 @@ func TestGenerateDaemonSetName(t *testing.T) { func TestNewAutoscaler(t *testing.T) { client := fake.NewSimpleClientset() cfg := config.NodeAgentAutoscalerConfig{ - Enabled: true, - NodeGroupLabel: "node.kubernetes.io/instance-type", - ReconcileInterval: 5 * time.Minute, - TemplatePath: "/tmp/nonexistent-template.yaml", // Will fail + Enabled: true, + GoMemLimitPercentage: 0.8, + NodeGroupLabel: "node.kubernetes.io/instance-type", + ReconcileInterval: 5 * time.Minute, + TemplatePath: "/tmp/nonexistent-template.yaml", // Will fail } // Should fail because template doesn't exist _, err := NewAutoscaler(client, cfg, "kubescape", "operator") assert.Error(t, err) } -