From e47a207cf59e47b04ecb640b396075501eccf70b Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 10 Jun 2026 00:10:32 +0530 Subject: [PATCH 1/8] workloadpolicy some field get updated --- internal/provider/workload_policy.go | 42 ++++++++++++ internal/provider/workload_policy_test.go | 78 +++++++++++++++++++++-- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/internal/provider/workload_policy.go b/internal/provider/workload_policy.go index a503f36..8cc5f08 100644 --- a/internal/provider/workload_policy.go +++ b/internal/provider/workload_policy.go @@ -65,6 +65,8 @@ type WorkloadPolicyResourceModel struct { DriftDeltaPercent types.Float32 `tfsdk:"drift_delta_percent"` MinVpaWindowDataPoints types.Int32 `tfsdk:"min_vpa_window_data_points"` CooldownMinutes types.Int32 `tfsdk:"cooldown_minutes"` + EnablePmaxProtection types.Bool `tfsdk:"enable_pmax_protection"` + PmaxRatioThreshold types.Float32 `tfsdk:"pmax_ratio_threshold"` } type VerticalScalingOptions struct { @@ -78,6 +80,8 @@ type VerticalScalingOptions struct { MaxScaleDownPercent types.Float32 `tfsdk:"max_scale_down_percent"` LimitMultiplier types.Float32 `tfsdk:"limit_multiplier"` MinDataPoints types.Int32 `tfsdk:"min_data_points"` + AdjustReqEvenIfNotSet types.Bool `tfsdk:"adjust_req_even_if_not_set"` + LimitsRemovalEnabled types.Bool `tfsdk:"limits_removal_enabled"` } type HorizontalScalingOptions struct { @@ -156,6 +160,20 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Computed: true, Default: int32default.StaticInt32(15), }, + "adjust_req_even_if_not_set": schema.BoolAttribute{ + Description: "Recommend requests even when the workload has no existing requests set", + MarkdownDescription: "When true, the recommender will suggest resource requests even if the workload currently has none set. Default: false.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "limits_removal_enabled": schema.BoolAttribute{ + Description: "Actively remove resource limits from workloads", + MarkdownDescription: "When true, the recommender will remove resource limits from workloads (CPU axis only — memory limits removal is not supported). Default: false.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, } } @@ -382,6 +400,20 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Computed: true, Default: int32default.StaticInt32(30), }, + "enable_pmax_protection": schema.BoolAttribute{ + Description: "Raise requests to cover observed peak usage when the peak/recommendation ratio exceeds pmax_ratio_threshold", + MarkdownDescription: "When true, the recommender raises requests to cover observed peak usage when the peak-to-recommendation ratio exceeds `pmax_ratio_threshold`. Default: false.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "pmax_ratio_threshold": schema.Float32Attribute{ + Description: "Peak-to-recommendation ratio above which pmax protection activates", + MarkdownDescription: "Peak-to-recommendation ratio above which pmax protection activates. Example: 3.0 — triggers when peak is 3× the recommendation. Default: 3.0.", + Optional: true, + Computed: true, + Default: float32default.StaticFloat32(3.0), + }, }, } } @@ -593,6 +625,8 @@ func (m *WorkloadPolicyResourceModel) toProto(ctx context.Context, diags *diag.D DriftDeltaPercent: m.DriftDeltaPercent.ValueFloat32Pointer(), MinVpaWindowDataPoints: m.MinVpaWindowDataPoints.ValueInt32Pointer(), CooldownMinutes: m.CooldownMinutes.ValueInt32Pointer(), + EnablePmaxProtection: m.EnablePmaxProtection.ValueBool(), + PmaxRatioThreshold: m.PmaxRatioThreshold.ValueFloat32Pointer(), } } @@ -676,6 +710,10 @@ func (m *WorkloadPolicyResourceModel) fromProto(policy *apiv1.WorkloadRecommenda if policy.CooldownMinutes != nil { m.CooldownMinutes = types.Int32Value(*policy.CooldownMinutes) } + m.EnablePmaxProtection = types.BoolValue(policy.EnablePmaxProtection) + if policy.PmaxRatioThreshold != nil { + m.PmaxRatioThreshold = types.Float32Value(*policy.PmaxRatioThreshold) + } } func (o *VerticalScalingOptions) toProto() *apiv1.VerticalScalingOptimizationTarget { @@ -693,6 +731,8 @@ func (o *VerticalScalingOptions) toProto() *apiv1.VerticalScalingOptimizationTar MaxScaleDownPercent: o.MaxScaleDownPercent.ValueFloat32Pointer(), LimitMultiplier: o.LimitMultiplier.ValueFloat32Pointer(), MinDataPoints: o.MinDataPoints.ValueInt32Pointer(), + AdjustReqEvenIfNotSet: o.AdjustReqEvenIfNotSet.ValueBool(), + LimitsRemovalEnabled: o.LimitsRemovalEnabled.ValueBool(), } } @@ -733,6 +773,8 @@ func (o *VerticalScalingOptions) fromProto(target *apiv1.VerticalScalingOptimiza if target.MinDataPoints != nil { o.MinDataPoints = types.Int32Value(*target.MinDataPoints) } + o.AdjustReqEvenIfNotSet = types.BoolValue(target.AdjustReqEvenIfNotSet) + o.LimitsRemovalEnabled = types.BoolValue(target.LimitsRemovalEnabled) } func (o *HorizontalScalingOptions) toProto() *apiv1.HorizontalScalingOptimizationTarget { diff --git a/internal/provider/workload_policy_test.go b/internal/provider/workload_policy_test.go index 41872a5..bc1246c 100644 --- a/internal/provider/workload_policy_test.go +++ b/internal/provider/workload_policy_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" @@ -44,6 +45,8 @@ func TestWorkloadPolicyResourceModel(t *testing.T) { MaxScaleDownPercent: types.Float32Value(25.0), LimitMultiplier: types.Float32Value(2.0), MinDataPoints: types.Int32Value(10), + AdjustReqEvenIfNotSet: types.BoolValue(true), + LimitsRemovalEnabled: types.BoolValue(true), } // Test toProto @@ -81,6 +84,26 @@ func TestWorkloadPolicyResourceModel(t *testing.T) { if *proto.MinDataPoints != 10 { t.Errorf("Expected MinDataPoints to be 10, got %d", *proto.MinDataPoints) } + if !proto.AdjustReqEvenIfNotSet { + t.Error("Expected AdjustReqEvenIfNotSet to be true") + } + if !proto.LimitsRemovalEnabled { + t.Error("Expected LimitsRemovalEnabled to be true") + } + }) + + // Test VerticalScalingOptions new fields default to false + t.Run("VerticalScalingOptionsDefaults", func(t *testing.T) { + opts := &VerticalScalingOptions{ + Enabled: types.BoolValue(true), + } + proto := opts.toProto() + if proto.AdjustReqEvenIfNotSet { + t.Error("Expected AdjustReqEvenIfNotSet to default to false") + } + if proto.LimitsRemovalEnabled { + t.Error("Expected LimitsRemovalEnabled to default to false") + } }) // Test HorizontalScalingOptions @@ -123,6 +146,35 @@ func TestWorkloadPolicyResourceModel(t *testing.T) { } }) + // Test pmax protection fields on WorkloadPolicyResourceModel + t.Run("PmaxProtection", func(t *testing.T) { + ctx := context.Background() + var diagnostics diag.Diagnostics + + model := &WorkloadPolicyResourceModel{ + Name: types.StringValue("test"), + Description: types.StringValue(""), + CronSchedule: types.StringValue("*/15 * * * *"), + DefragmentationSchedule: types.StringValue("*/15 * * * *"), + ActionTriggers: types.ListValueMust(types.StringType, nil), + DetectionTriggers: types.ListValueMust(types.StringType, nil), + SchedulerPlugins: types.ListValueMust(types.StringType, nil), + EnablePmaxProtection: types.BoolValue(true), + PmaxRatioThreshold: types.Float32Value(3.0), + } + + proto := model.toProto(ctx, &diagnostics, "team-123") + if proto == nil { + t.Fatal("Expected non-nil proto") + } + if !proto.EnablePmaxProtection { + t.Error("Expected EnablePmaxProtection to be true") + } + if proto.PmaxRatioThreshold == nil || *proto.PmaxRatioThreshold != 3.0 { + t.Errorf("Expected PmaxRatioThreshold to be 3.0, got %v", proto.PmaxRatioThreshold) + } + }) + // Test HPAMetric conversions t.Run("HPAMetricConversions", func(t *testing.T) { opts := &HorizontalScalingOptions{} @@ -160,11 +212,11 @@ func TestWorkloadPolicyResourceModel(t *testing.T) { }) } -func validateSchema(t *testing.T, schema schema.Schema) { +func validateSchema(t *testing.T, s schema.Schema) { // Validate required attributes requiredAttrs := []string{"name", "action_triggers"} for _, attr := range requiredAttrs { - if _, exists := schema.Attributes[attr]; !exists { + if _, exists := s.Attributes[attr]; !exists { t.Errorf("Required attribute %s not found in schema", attr) } } @@ -172,7 +224,7 @@ func validateSchema(t *testing.T, schema schema.Schema) { // Validate computed attributes computedAttrs := []string{"id"} for _, attr := range computedAttrs { - if attrSchema, exists := schema.Attributes[attr]; exists { + if attrSchema, exists := s.Attributes[attr]; exists { if !attrSchema.IsComputed() { t.Errorf("Attribute %s should be computed", attr) } @@ -180,11 +232,27 @@ func validateSchema(t *testing.T, schema schema.Schema) { } // Validate nested attributes exist - if _, exists := schema.Attributes["cpu_vertical_scaling"]; !exists { + if _, exists := s.Attributes["cpu_vertical_scaling"]; !exists { t.Error("cpu_vertical_scaling attribute not found") } - if _, exists := schema.Attributes["horizontal_scaling"]; !exists { + if _, exists := s.Attributes["horizontal_scaling"]; !exists { t.Error("horizontal_scaling attribute not found") } + + // Validate pmax fields exist at top level + for _, attr := range []string{"enable_pmax_protection", "pmax_ratio_threshold"} { + if _, exists := s.Attributes[attr]; !exists { + t.Errorf("Expected top-level attribute %q not found in schema", attr) + } + } + + // Validate new vertical scaling fields exist inside cpu_vertical_scaling + if cpuVs, ok := s.Attributes["cpu_vertical_scaling"].(schema.SingleNestedAttribute); ok { + for _, attr := range []string{"adjust_req_even_if_not_set", "limits_removal_enabled"} { + if _, exists := cpuVs.Attributes[attr]; !exists { + t.Errorf("Expected vertical_scaling attribute %q not found in cpu_vertical_scaling", attr) + } + } + } } From 8777d7e31db8b0915a2ad9dbbf1da3e973e6242c Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 10 Jun 2026 00:57:18 +0530 Subject: [PATCH 2/8] workloadpolicy some field get updated --- .../devzero_workload_policy/resource.tf | 83 ++++++++----------- 1 file changed, 35 insertions(+), 48 deletions(-) diff --git a/examples/resources/devzero_workload_policy/resource.tf b/examples/resources/devzero_workload_policy/resource.tf index 92699c7..1c1b1ff 100644 --- a/examples/resources/devzero_workload_policy/resource.tf +++ b/examples/resources/devzero_workload_policy/resource.tf @@ -1,59 +1,46 @@ -# Only required attributes -resource "devzero_workload_policy" "workload_policy" { - name = "terraform-example" +# Minimal — only required attributes +resource "devzero_workload_policy" "minimal" { + name = "cost-saving-policy" action_triggers = ["on_detection", "on_schedule"] } -# All attributes -resource "devzero_workload_policy" "workload_policy" { - name = "terraform-example" - description = "some description" - action_triggers = ["on_detection", "on_schedule"] - cron_schedule = "*/15 * * * *" # Every 15th minute - detection_triggers = ["pod_creation", "pod_update"] - loopback_period_seconds = 3600 # 1 hour - startup_period_seconds = 60 # 1 minute - live_migration_enabled = true - scheduler_plugins = ["dz-scheduler"] - defragmentation_schedule = "*/15 * * * *" +# Full example — values kept in sync with the Pulumi provider +resource "devzero_workload_policy" "cost_saving" { + name = "cost-saving-policy" + description = "Rightsize non-critical workloads" + action_triggers = ["on_detection", "on_schedule"] + cron_schedule = "*/15 * * * *" # every 15 min; required when "on_schedule" is set + detection_triggers = ["pod_creation", "pod_update"] # used when "on_detection" is set + loopback_period_seconds = 86400 # 1 day — lookback window for usage data + cooldown_minutes = 300 # 5 hours between successive scale-down actions + min_data_points = 20 # min samples before any recommendation + min_change_percent = 0.2 # apply only if change > 20% cpu_vertical_scaling = { - enabled = true - min_request = 1000 - max_request = 2000 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = true + enabled = true + target_percentile = 0.75 # P75 of observed usage + min_request = 25 # millicores; hard floor + max_scale_up_percent = 1000 # max % increase per step + max_scale_down_percent = 1 # max % decrease per step + min_data_points = 20 # min CPU samples before recommendation + adjust_req_even_if_not_set = true # set requests even if workload has none + limits_removal_enabled = true # strip CPU limits (cycles compress safely) } memory_vertical_scaling = { - enabled = true - min_request = 1000 - max_request = 2000 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = true + enabled = true + target_percentile = 1 # P100 — guard against OOMKills + min_request = 134217728 # 128 MiB in bytes; hard floor + max_scale_up_percent = 1000 # max % increase per step + max_scale_down_percent = 1 # max % decrease per step + overhead_multiplier = 0.3 # extra headroom over the recommendation + limits_adjustment_enabled = true # adjust limits alongside requests + limit_multiplier = 1 # limits = request × this + min_data_points = 20 # min memory samples before recommendation + adjust_req_even_if_not_set = true # set requests even if workload has none + limits_removal_enabled = false # memory limits removal not supported } - gpu_vertical_scaling = { - enabled = true - min_request = 1000 - max_request = 2000 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = false - } - - gpu_vram_vertical_scaling = { - enabled = true - min_request = 1000 - max_request = 2000 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = false - } - - horizontal_scaling = { - enabled = true - min_replicas = 1 - max_replicas = 2 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = true - } + enable_pmax_protection = true # guard against spike-induced OOMKills + pmax_ratio_threshold = 3 # raise requests when peak is 3× the recommendation } \ No newline at end of file From a27bfb9b0c8f28ceda993bc13ebc98459f9639e2 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 10 Jun 2026 01:04:57 +0530 Subject: [PATCH 3/8] workloadpolicy some field get updated --- docs/resources/workload_policy.md | 93 +++++++++---------- .../devzero_workload_policy/resource.tf | 44 ++++----- 2 files changed, 67 insertions(+), 70 deletions(-) diff --git a/docs/resources/workload_policy.md b/docs/resources/workload_policy.md index 9560170..ab7c417 100644 --- a/docs/resources/workload_policy.md +++ b/docs/resources/workload_policy.md @@ -13,64 +13,51 @@ Configures DevZero workload recommendation policies, including triggers, scaling ## Example Usage ```terraform -# Only required attributes -resource "devzero_workload_policy" "workload_policy" { - name = "terraform-example" +# Minimal — only required attributes +resource "devzero_workload_policy" "minimal" { + name = "cost-saving-policy" action_triggers = ["on_detection", "on_schedule"] } -# All attributes -resource "devzero_workload_policy" "workload_policy" { - name = "terraform-example" - description = "some description" - action_triggers = ["on_detection", "on_schedule"] - cron_schedule = "*/15 * * * *" # Every 15th minute - detection_triggers = ["pod_creation", "pod_update"] - loopback_period_seconds = 3600 # 1 hour - startup_period_seconds = 60 # 1 minute - live_migration_enabled = true - scheduler_plugins = ["dz-scheduler"] - defragmentation_schedule = "*/15 * * * *" +# Full example — values kept in sync with the Pulumi provider +resource "devzero_workload_policy" "cost_saving" { + name = "cost-saving-policy" + description = "Rightsize non-critical workloads" + action_triggers = ["on_detection", "on_schedule"] + cron_schedule = "*/15 * * * *" # every 15 min; required when "on_schedule" is set + detection_triggers = ["pod_creation", "pod_update"] # used when "on_detection" is set + loopback_period_seconds = 86400 # 1 day — lookback window for usage data + cooldown_minutes = 300 # 5 hours between successive scale-down actions + min_data_points = 20 # min samples before any recommendation + min_change_percent = 0.2 # apply only if change > 20% cpu_vertical_scaling = { - enabled = true - min_request = 1000 - max_request = 2000 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = true + enabled = true + target_percentile = 0.75 # P75 of observed usage + min_request = 25 # millicores; hard floor + max_scale_up_percent = 1000 # max % increase per step + max_scale_down_percent = 1 # max % decrease per step + min_data_points = 20 # min CPU samples before recommendation + adjust_req_even_if_not_set = true # set requests even if workload has none + limits_removal_enabled = true # strip CPU limits (cycles compress safely) } memory_vertical_scaling = { - enabled = true - min_request = 1000 - max_request = 2000 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = true + enabled = true + target_percentile = 1 # P100 — guard against OOMKills + min_request = 134217728 # 128 MiB in bytes; hard floor + max_scale_up_percent = 1000 # max % increase per step + max_scale_down_percent = 1 # max % decrease per step + overhead_multiplier = 0.3 # extra headroom over the recommendation + limits_adjustment_enabled = true # adjust limits alongside requests + limit_multiplier = 1 # limits = request × this + min_data_points = 20 # min memory samples before recommendation + adjust_req_even_if_not_set = true # set requests even if workload has none + limits_removal_enabled = false # memory limits removal not supported } - gpu_vertical_scaling = { - enabled = true - min_request = 1000 - max_request = 2000 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = false - } - - gpu_vram_vertical_scaling = { - enabled = true - min_request = 1000 - max_request = 2000 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = false - } - - horizontal_scaling = { - enabled = true - min_replicas = 1 - max_replicas = 2 - overhead_multiplier = 0.05 # 5% - limits_adjustment_enabled = true - } + enable_pmax_protection = true # guard against spike-induced OOMKills + pmax_ratio_threshold = 3 # raise requests when peak is 3× the recommendation } ``` @@ -91,6 +78,7 @@ resource "devzero_workload_policy" "workload_policy" { - `description` (String) Free-form description of the policy to help others understand its intent and scope. - `detection_triggers` (List of String) Detection triggers for when to apply the workload policy. Only one of `pod_creation` or `pod_update` is allowed.The `pod_creation` trigger is used to apply the workload policy when a pod is created.The `pod_update` trigger is used to apply the workload policy when a pod is updated. - `drift_delta_percent` (Number) Percentage drift from baseline that triggers VPA refresh +- `enable_pmax_protection` (Boolean) When true, the recommender raises requests to cover observed peak usage when the peak-to-recommendation ratio exceeds `pmax_ratio_threshold`. Default: false. - `gpu_vertical_scaling` (Attributes) GPU vertical scaling options (see [below for nested schema](#nestedatt--gpu_vertical_scaling)) - `gpu_vram_vertical_scaling` (Attributes) GPU VRAM vertical scaling options (see [below for nested schema](#nestedatt--gpu_vram_vertical_scaling)) - `horizontal_scaling` (Attributes) Horizontal scaling options (see [below for nested schema](#nestedatt--horizontal_scaling)) @@ -101,6 +89,7 @@ resource "devzero_workload_policy" "workload_policy" { - `min_change_percent` (Number) Global minimum change threshold for applying recommendations - `min_data_points` (Number) Global minimum data points required for recommendations - `min_vpa_window_data_points` (Number) Minimum data points in VPA analysis window +- `pmax_ratio_threshold` (Number) Peak-to-recommendation ratio above which pmax protection activates. Example: 3.0 — triggers when peak is 3× the recommendation. Default: 3.0. - `scheduler_plugins` (List of String) Kubernetes scheduler plugins to activate - `stability_cv_max` (Number) Maximum coefficient of variation to consider stable - `startup_period_seconds` (Number) Startup period seconds of the workload policy. The startup period is the period of time to ignore resource usage data after the workload is started. @@ -114,9 +103,11 @@ resource "devzero_workload_policy" "workload_policy" { Optional: +- `adjust_req_even_if_not_set` (Boolean) When true, the recommender will suggest resource requests even if the workload currently has none set. Default: false. - `enabled` (Boolean) Enable or disable vertical scaling for this resource. When disabled, vertical recommendations will not be applied. - `limit_multiplier` (Number) How much higher limits should be vs requests (e.g., 2.0 = 2x the request). - `limits_adjustment_enabled` (Boolean) Allow recommender to adjust container limits as well as requests. When disabled, only requests are modified. +- `limits_removal_enabled` (Boolean) When true, the recommender will remove resource limits from workloads (CPU axis only — memory limits removal is not supported). Default: false. - `max_request` (Number) Upper bound for container resource requests (e.g., CPU millicores or memory bytes) considered by the recommender. - `max_scale_down_percent` (Number) Maximum percent to scale down in one step - `max_scale_up_percent` (Number) Maximum percent to scale up in one step @@ -131,9 +122,11 @@ Optional: Optional: +- `adjust_req_even_if_not_set` (Boolean) When true, the recommender will suggest resource requests even if the workload currently has none set. Default: false. - `enabled` (Boolean) Enable or disable vertical scaling for this resource. When disabled, vertical recommendations will not be applied. - `limit_multiplier` (Number) How much higher limits should be vs requests (e.g., 2.0 = 2x the request). - `limits_adjustment_enabled` (Boolean) Allow recommender to adjust container limits as well as requests. When disabled, only requests are modified. +- `limits_removal_enabled` (Boolean) When true, the recommender will remove resource limits from workloads (CPU axis only — memory limits removal is not supported). Default: false. - `max_request` (Number) Upper bound for container resource requests (e.g., CPU millicores or memory bytes) considered by the recommender. - `max_scale_down_percent` (Number) Maximum percent to scale down in one step - `max_scale_up_percent` (Number) Maximum percent to scale up in one step @@ -148,9 +141,11 @@ Optional: Optional: +- `adjust_req_even_if_not_set` (Boolean) When true, the recommender will suggest resource requests even if the workload currently has none set. Default: false. - `enabled` (Boolean) Enable or disable vertical scaling for this resource. When disabled, vertical recommendations will not be applied. - `limit_multiplier` (Number) How much higher limits should be vs requests (e.g., 2.0 = 2x the request). - `limits_adjustment_enabled` (Boolean) Allow recommender to adjust container limits as well as requests. When disabled, only requests are modified. +- `limits_removal_enabled` (Boolean) When true, the recommender will remove resource limits from workloads (CPU axis only — memory limits removal is not supported). Default: false. - `max_request` (Number) Upper bound for container resource requests (e.g., CPU millicores or memory bytes) considered by the recommender. - `max_scale_down_percent` (Number) Maximum percent to scale down in one step - `max_scale_up_percent` (Number) Maximum percent to scale up in one step @@ -179,9 +174,11 @@ Optional: Optional: +- `adjust_req_even_if_not_set` (Boolean) When true, the recommender will suggest resource requests even if the workload currently has none set. Default: false. - `enabled` (Boolean) Enable or disable vertical scaling for this resource. When disabled, vertical recommendations will not be applied. - `limit_multiplier` (Number) How much higher limits should be vs requests (e.g., 2.0 = 2x the request). - `limits_adjustment_enabled` (Boolean) Allow recommender to adjust container limits as well as requests. When disabled, only requests are modified. +- `limits_removal_enabled` (Boolean) When true, the recommender will remove resource limits from workloads (CPU axis only — memory limits removal is not supported). Default: false. - `max_request` (Number) Upper bound for container resource requests (e.g., CPU millicores or memory bytes) considered by the recommender. - `max_scale_down_percent` (Number) Maximum percent to scale down in one step - `max_scale_up_percent` (Number) Maximum percent to scale up in one step diff --git a/examples/resources/devzero_workload_policy/resource.tf b/examples/resources/devzero_workload_policy/resource.tf index 1c1b1ff..f74b9c5 100644 --- a/examples/resources/devzero_workload_policy/resource.tf +++ b/examples/resources/devzero_workload_policy/resource.tf @@ -9,36 +9,36 @@ resource "devzero_workload_policy" "cost_saving" { name = "cost-saving-policy" description = "Rightsize non-critical workloads" action_triggers = ["on_detection", "on_schedule"] - cron_schedule = "*/15 * * * *" # every 15 min; required when "on_schedule" is set - detection_triggers = ["pod_creation", "pod_update"] # used when "on_detection" is set - loopback_period_seconds = 86400 # 1 day — lookback window for usage data - cooldown_minutes = 300 # 5 hours between successive scale-down actions - min_data_points = 20 # min samples before any recommendation - min_change_percent = 0.2 # apply only if change > 20% + cron_schedule = "*/15 * * * *" # every 15 min; required when "on_schedule" is set + detection_triggers = ["pod_creation", "pod_update"] # used when "on_detection" is set + loopback_period_seconds = 86400 # 1 day — lookback window for usage data + cooldown_minutes = 300 # 5 hours between successive scale-down actions + min_data_points = 20 # min samples before any recommendation + min_change_percent = 0.2 # apply only if change > 20% cpu_vertical_scaling = { enabled = true - target_percentile = 0.75 # P75 of observed usage - min_request = 25 # millicores; hard floor - max_scale_up_percent = 1000 # max % increase per step - max_scale_down_percent = 1 # max % decrease per step - min_data_points = 20 # min CPU samples before recommendation - adjust_req_even_if_not_set = true # set requests even if workload has none - limits_removal_enabled = true # strip CPU limits (cycles compress safely) + target_percentile = 0.75 # P75 of observed usage + min_request = 25 # millicores; hard floor + max_scale_up_percent = 1000 # max % increase per step + max_scale_down_percent = 1 # max % decrease per step + min_data_points = 20 # min CPU samples before recommendation + adjust_req_even_if_not_set = true # set requests even if workload has none + limits_removal_enabled = true # strip CPU limits (cycles compress safely) } memory_vertical_scaling = { enabled = true - target_percentile = 1 # P100 — guard against OOMKills + target_percentile = 1 # P100 — guard against OOMKills min_request = 134217728 # 128 MiB in bytes; hard floor - max_scale_up_percent = 1000 # max % increase per step - max_scale_down_percent = 1 # max % decrease per step - overhead_multiplier = 0.3 # extra headroom over the recommendation - limits_adjustment_enabled = true # adjust limits alongside requests - limit_multiplier = 1 # limits = request × this - min_data_points = 20 # min memory samples before recommendation - adjust_req_even_if_not_set = true # set requests even if workload has none - limits_removal_enabled = false # memory limits removal not supported + max_scale_up_percent = 1000 # max % increase per step + max_scale_down_percent = 1 # max % decrease per step + overhead_multiplier = 0.3 # extra headroom over the recommendation + limits_adjustment_enabled = true # adjust limits alongside requests + limit_multiplier = 1 # limits = request × this + min_data_points = 20 # min memory samples before recommendation + adjust_req_even_if_not_set = true # set requests even if workload has none + limits_removal_enabled = false # memory limits removal not supported } enable_pmax_protection = true # guard against spike-induced OOMKills From 41b7008cb3f75bfdaf3535be7713392cc687fc8f Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 10 Jun 2026 01:13:55 +0530 Subject: [PATCH 4/8] sync examples for node_policy, node_policy_target, and workload_policy_target with Pulumi docs --- docs/resources/node_policy.md | 66 +++++++++++++++++++ docs/resources/node_policy_target.md | 30 ++++----- docs/resources/workload_policy_target.md | 55 ++++++---------- .../resources/devzero_node_policy/resource.tf | 66 +++++++++++++++++++ .../devzero_node_policy_target/resource.tf | 30 ++++----- .../resource.tf | 55 ++++++---------- 6 files changed, 196 insertions(+), 106 deletions(-) diff --git a/docs/resources/node_policy.md b/docs/resources/node_policy.md index b652d57..4aeeabd 100644 --- a/docs/resources/node_policy.md +++ b/docs/resources/node_policy.md @@ -13,6 +13,72 @@ Manages DevZero node policies for Kubernetes cluster node provisioning and optim ## Example Usage ```terraform +# Standard example — values kept in sync with the Pulumi provider +resource "devzero_node_policy" "standard_nodes" { + name = "standard-nodes" + description = "On-demand x86 nodes for general workloads" + weight = 10 + + capacity_types = { + match_expressions = [{ + key = "capacityTypes" + operator = "In" + values = ["on-demand"] + }] + } + + instance_categories = { + match_expressions = [{ + key = "instanceCategories" + operator = "In" + values = ["m", "c"] + }] + } + + instance_sizes = { + match_expressions = [{ + key = "instanceSizes" + operator = "In" + values = ["large", "xlarge", "2xlarge"] + }] + } + + architectures = { + match_expressions = [{ + key = "architectures" + operator = "In" + values = ["amd64"] + }] + } + + operating_systems = { + match_expressions = [{ + key = "operatingSystems" + operator = "In" + values = ["linux"] + }] + } + + disruption = { + consolidation_policy = "WhenEmptyOrUnderutilized" + consolidate_after = "30m" + expire_after = "168h" # 7 days + } + + aws = { + ami_family = "AL2" + role = "KarpenterNodeRole" + + subnet_selector_terms = [ + { tags = { "karpenter.sh/discovery" = "my-cluster" } } + ] + + security_group_selector_terms = [ + { tags = { "karpenter.sh/discovery" = "my-cluster" } } + ] + } +} + # Minimal example - uses sensible defaults resource "devzero_node_policy" "minimal" { name = "minimal-policy" diff --git a/docs/resources/node_policy_target.md b/docs/resources/node_policy_target.md index 29073c3..7cb8769 100644 --- a/docs/resources/node_policy_target.md +++ b/docs/resources/node_policy_target.md @@ -13,21 +13,27 @@ Attaches a node policy to specific clusters. Node policy targets determine which ## Example Usage ```terraform -# Prerequisites - need cluster and node policy resources +# Prerequisites resource "devzero_cluster" "production" { name = "production-cluster" } -resource "devzero_node_policy" "general" { - name = "general-purpose" - node_pool_name = "general-pool" - node_class_name = "general-class" +resource "devzero_node_policy" "standard_nodes" { + name = "standard-nodes" +} + +# Standard example — values kept in sync with the Pulumi provider +resource "devzero_node_policy_target" "cluster_nodes" { + name = "cluster-nodes" + policy_id = devzero_node_policy.standard_nodes.id + cluster_ids = [devzero_cluster.production.id] + enabled = true } # Minimal example - only required attributes resource "devzero_node_policy_target" "minimal" { name = "production-clusters" - policy_id = devzero_node_policy.general.id + policy_id = devzero_node_policy.standard_nodes.id cluster_ids = [devzero_cluster.production.id] # Defaults applied automatically: @@ -35,18 +41,6 @@ resource "devzero_node_policy_target" "minimal" { # - enabled = true } -# Comprehensive example - all attributes -resource "devzero_node_policy_target" "comprehensive" { - name = "production-general-target" - description = "Applies general purpose node policy to production clusters" - policy_id = devzero_node_policy.general.id - enabled = true - cluster_ids = [ - devzero_cluster.production.id, - # Add more cluster IDs as needed - ] -} - # Example with multiple clusters resource "devzero_cluster" "us_east" { name = "production-us-east-1" diff --git a/docs/resources/workload_policy_target.md b/docs/resources/workload_policy_target.md index 667a93c..0c74d2a 100644 --- a/docs/resources/workload_policy_target.md +++ b/docs/resources/workload_policy_target.md @@ -13,49 +13,34 @@ Defines which workloads a policy applies to by selecting namespaces, workloads, ## Example Usage ```terraform -resource "devzero_cluster" "cluster" { - name = "terraform-example" +resource "devzero_cluster" "production" { + name = "production-cluster" } -resource "devzero_workload_policy" "workload_policy" { - name = "terraform-example" +resource "devzero_workload_policy" "cost_saving" { + name = "cost-saving-policy" } -# Only required attributes -resource "devzero_workload_policy_target" "workload_policy_target" { - name = "terraform-example" - policy_id = devzero_workload_policy.workload_policy.id - cluster_ids = [devzero_cluster.cluster.id] +# Minimal — only required attributes +resource "devzero_workload_policy_target" "minimal" { + name = "production-target" + policy_id = devzero_workload_policy.cost_saving.id + cluster_ids = [devzero_cluster.production.id] } -# All attributes -resource "devzero_workload_policy_target" "workload_policy_target" { - name = "terraform-example" - description = "some description" - policy_id = devzero_workload_policy.workload_policy.id - cluster_ids = [devzero_cluster.cluster.id] - priority = 1 +# Full example — values kept in sync with the Pulumi provider +resource "devzero_workload_policy_target" "production" { + name = "production-target" + policy_id = devzero_workload_policy.cost_saving.id + cluster_ids = [devzero_cluster.production.id] + kind_filter = ["Deployment", "StatefulSet"] enabled = true - workload_names = ["workload-1", "workload-2"] # Empty list means all workloads - node_group_names = ["node-group-1", "node-group-2"] # Empty list means all node groups - kind_filter = ["Deployment", "ReplicaSet"] # Empty list means all kinds - - name_pattern = { - pattern = "terraform-example" - flags = "i" - } - - namespace_selector = { - match_labels = { - app = "terraform-example" - } - } - - workload_selector = { - match_labels = { - app = "terraform-example" - } + # Match namespaces by name pattern — useful when namespaces follow a naming + # convention but aren't consistently labeled (e.g. team-*, prod-*). + namespace_pattern = { + pattern = "^prod-" + flags = "i" # case-insensitive } } ``` diff --git a/examples/resources/devzero_node_policy/resource.tf b/examples/resources/devzero_node_policy/resource.tf index 49a7e94..2b1b15f 100644 --- a/examples/resources/devzero_node_policy/resource.tf +++ b/examples/resources/devzero_node_policy/resource.tf @@ -1,3 +1,69 @@ +# Standard example — values kept in sync with the Pulumi provider +resource "devzero_node_policy" "standard_nodes" { + name = "standard-nodes" + description = "On-demand x86 nodes for general workloads" + weight = 10 + + capacity_types = { + match_expressions = [{ + key = "capacityTypes" + operator = "In" + values = ["on-demand"] + }] + } + + instance_categories = { + match_expressions = [{ + key = "instanceCategories" + operator = "In" + values = ["m", "c"] + }] + } + + instance_sizes = { + match_expressions = [{ + key = "instanceSizes" + operator = "In" + values = ["large", "xlarge", "2xlarge"] + }] + } + + architectures = { + match_expressions = [{ + key = "architectures" + operator = "In" + values = ["amd64"] + }] + } + + operating_systems = { + match_expressions = [{ + key = "operatingSystems" + operator = "In" + values = ["linux"] + }] + } + + disruption = { + consolidation_policy = "WhenEmptyOrUnderutilized" + consolidate_after = "30m" + expire_after = "168h" # 7 days + } + + aws = { + ami_family = "AL2" + role = "KarpenterNodeRole" + + subnet_selector_terms = [ + { tags = { "karpenter.sh/discovery" = "my-cluster" } } + ] + + security_group_selector_terms = [ + { tags = { "karpenter.sh/discovery" = "my-cluster" } } + ] + } +} + # Minimal example - uses sensible defaults resource "devzero_node_policy" "minimal" { name = "minimal-policy" diff --git a/examples/resources/devzero_node_policy_target/resource.tf b/examples/resources/devzero_node_policy_target/resource.tf index 32737f3..cafdbe5 100644 --- a/examples/resources/devzero_node_policy_target/resource.tf +++ b/examples/resources/devzero_node_policy_target/resource.tf @@ -1,18 +1,24 @@ -# Prerequisites - need cluster and node policy resources +# Prerequisites resource "devzero_cluster" "production" { name = "production-cluster" } -resource "devzero_node_policy" "general" { - name = "general-purpose" - node_pool_name = "general-pool" - node_class_name = "general-class" +resource "devzero_node_policy" "standard_nodes" { + name = "standard-nodes" +} + +# Standard example — values kept in sync with the Pulumi provider +resource "devzero_node_policy_target" "cluster_nodes" { + name = "cluster-nodes" + policy_id = devzero_node_policy.standard_nodes.id + cluster_ids = [devzero_cluster.production.id] + enabled = true } # Minimal example - only required attributes resource "devzero_node_policy_target" "minimal" { name = "production-clusters" - policy_id = devzero_node_policy.general.id + policy_id = devzero_node_policy.standard_nodes.id cluster_ids = [devzero_cluster.production.id] # Defaults applied automatically: @@ -20,18 +26,6 @@ resource "devzero_node_policy_target" "minimal" { # - enabled = true } -# Comprehensive example - all attributes -resource "devzero_node_policy_target" "comprehensive" { - name = "production-general-target" - description = "Applies general purpose node policy to production clusters" - policy_id = devzero_node_policy.general.id - enabled = true - cluster_ids = [ - devzero_cluster.production.id, - # Add more cluster IDs as needed - ] -} - # Example with multiple clusters resource "devzero_cluster" "us_east" { name = "production-us-east-1" diff --git a/examples/resources/devzero_workload_policy_target/resource.tf b/examples/resources/devzero_workload_policy_target/resource.tf index 43a0fd6..f51020f 100644 --- a/examples/resources/devzero_workload_policy_target/resource.tf +++ b/examples/resources/devzero_workload_policy_target/resource.tf @@ -1,45 +1,30 @@ -resource "devzero_cluster" "cluster" { - name = "terraform-example" +resource "devzero_cluster" "production" { + name = "production-cluster" } -resource "devzero_workload_policy" "workload_policy" { - name = "terraform-example" +resource "devzero_workload_policy" "cost_saving" { + name = "cost-saving-policy" } -# Only required attributes -resource "devzero_workload_policy_target" "workload_policy_target" { - name = "terraform-example" - policy_id = devzero_workload_policy.workload_policy.id - cluster_ids = [devzero_cluster.cluster.id] +# Minimal — only required attributes +resource "devzero_workload_policy_target" "minimal" { + name = "production-target" + policy_id = devzero_workload_policy.cost_saving.id + cluster_ids = [devzero_cluster.production.id] } -# All attributes -resource "devzero_workload_policy_target" "workload_policy_target" { - name = "terraform-example" - description = "some description" - policy_id = devzero_workload_policy.workload_policy.id - cluster_ids = [devzero_cluster.cluster.id] - priority = 1 +# Full example — values kept in sync with the Pulumi provider +resource "devzero_workload_policy_target" "production" { + name = "production-target" + policy_id = devzero_workload_policy.cost_saving.id + cluster_ids = [devzero_cluster.production.id] + kind_filter = ["Deployment", "StatefulSet"] enabled = true - workload_names = ["workload-1", "workload-2"] # Empty list means all workloads - node_group_names = ["node-group-1", "node-group-2"] # Empty list means all node groups - kind_filter = ["Deployment", "ReplicaSet"] # Empty list means all kinds - - name_pattern = { - pattern = "terraform-example" - flags = "i" - } - - namespace_selector = { - match_labels = { - app = "terraform-example" - } - } - - workload_selector = { - match_labels = { - app = "terraform-example" - } + # Match namespaces by name pattern — useful when namespaces follow a naming + # convention but aren't consistently labeled (e.g. team-*, prod-*). + namespace_pattern = { + pattern = "^prod-" + flags = "i" # case-insensitive } } \ No newline at end of file From 3b950bc6f832b83c28f053dbce2f5a6d9d20fc28 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 10 Jun 2026 12:24:05 +0530 Subject: [PATCH 5/8] add namespace pattern and also the fix the values in terraform example --- docs/resources/node_policy.md | 73 +------------------ docs/resources/node_policy_target.md | 23 +++--- docs/resources/workload_policy_target.md | 42 +++++++++-- .../resources/devzero_node_policy/resource.tf | 73 +------------------ .../devzero_node_policy_target/resource.tf | 23 +++--- .../resource.tf | 34 +++++++-- internal/provider/workload_policy_target.go | 14 +++- 7 files changed, 107 insertions(+), 175 deletions(-) diff --git a/docs/resources/node_policy.md b/docs/resources/node_policy.md index 4aeeabd..f060171 100644 --- a/docs/resources/node_policy.md +++ b/docs/resources/node_policy.md @@ -13,72 +13,6 @@ Manages DevZero node policies for Kubernetes cluster node provisioning and optim ## Example Usage ```terraform -# Standard example — values kept in sync with the Pulumi provider -resource "devzero_node_policy" "standard_nodes" { - name = "standard-nodes" - description = "On-demand x86 nodes for general workloads" - weight = 10 - - capacity_types = { - match_expressions = [{ - key = "capacityTypes" - operator = "In" - values = ["on-demand"] - }] - } - - instance_categories = { - match_expressions = [{ - key = "instanceCategories" - operator = "In" - values = ["m", "c"] - }] - } - - instance_sizes = { - match_expressions = [{ - key = "instanceSizes" - operator = "In" - values = ["large", "xlarge", "2xlarge"] - }] - } - - architectures = { - match_expressions = [{ - key = "architectures" - operator = "In" - values = ["amd64"] - }] - } - - operating_systems = { - match_expressions = [{ - key = "operatingSystems" - operator = "In" - values = ["linux"] - }] - } - - disruption = { - consolidation_policy = "WhenEmptyOrUnderutilized" - consolidate_after = "30m" - expire_after = "168h" # 7 days - } - - aws = { - ami_family = "AL2" - role = "KarpenterNodeRole" - - subnet_selector_terms = [ - { tags = { "karpenter.sh/discovery" = "my-cluster" } } - ] - - security_group_selector_terms = [ - { tags = { "karpenter.sh/discovery" = "my-cluster" } } - ] - } -} - # Minimal example - uses sensible defaults resource "devzero_node_policy" "minimal" { name = "minimal-policy" @@ -174,10 +108,9 @@ resource "devzero_node_policy" "aws_comprehensive" { # Disruption policy for cost optimization disruption = { - consolidate_after = "15m" - consolidation_policy = "WhenEmptyOrUnderutilized" - expire_after = "720h" # 30 days - ttl_seconds_after_empty = 300 # 5 minutes + consolidate_after = "15m" + consolidation_policy = "WhenEmptyOrUnderutilized" + expire_after = "720h" # 30 days budgets = [ { diff --git a/docs/resources/node_policy_target.md b/docs/resources/node_policy_target.md index 7cb8769..6e991d6 100644 --- a/docs/resources/node_policy_target.md +++ b/docs/resources/node_policy_target.md @@ -22,14 +22,6 @@ resource "devzero_node_policy" "standard_nodes" { name = "standard-nodes" } -# Standard example — values kept in sync with the Pulumi provider -resource "devzero_node_policy_target" "cluster_nodes" { - name = "cluster-nodes" - policy_id = devzero_node_policy.standard_nodes.id - cluster_ids = [devzero_cluster.production.id] - enabled = true -} - # Minimal example - only required attributes resource "devzero_node_policy_target" "minimal" { name = "production-clusters" @@ -41,6 +33,17 @@ resource "devzero_node_policy_target" "minimal" { # - enabled = true } +# Comprehensive example - all attributes +resource "devzero_node_policy_target" "comprehensive" { + name = "cluster-nodes" + description = "Applies standard node policy to production clusters" + policy_id = devzero_node_policy.standard_nodes.id + enabled = true + cluster_ids = [ + devzero_cluster.production.id, + ] +} + # Example with multiple clusters resource "devzero_cluster" "us_east" { name = "production-us-east-1" @@ -57,7 +60,7 @@ resource "devzero_cluster" "eu_west" { resource "devzero_node_policy_target" "multi_cluster" { name = "all-production-clusters" description = "Apply cost optimization policy to all production clusters" - policy_id = devzero_node_policy.general.id + policy_id = devzero_node_policy.standard_nodes.id enabled = true cluster_ids = [ devzero_cluster.us_east.id, @@ -70,7 +73,7 @@ resource "devzero_node_policy_target" "multi_cluster" { resource "devzero_node_policy_target" "disabled" { name = "staging-clusters" description = "Temporarily disabled while testing new policy" - policy_id = devzero_node_policy.general.id + policy_id = devzero_node_policy.standard_nodes.id enabled = false # Target exists but is not active cluster_ids = [devzero_cluster.production.id] } diff --git a/docs/resources/workload_policy_target.md b/docs/resources/workload_policy_target.md index 0c74d2a..5b34bc8 100644 --- a/docs/resources/workload_policy_target.md +++ b/docs/resources/workload_policy_target.md @@ -28,20 +28,40 @@ resource "devzero_workload_policy_target" "minimal" { cluster_ids = [devzero_cluster.production.id] } -# Full example — values kept in sync with the Pulumi provider -resource "devzero_workload_policy_target" "production" { - name = "production-target" +# All attributes +resource "devzero_workload_policy_target" "full" { + name = "terraform-example" + description = "some description" policy_id = devzero_workload_policy.cost_saving.id cluster_ids = [devzero_cluster.production.id] - kind_filter = ["Deployment", "StatefulSet"] + priority = 1 enabled = true - # Match namespaces by name pattern — useful when namespaces follow a naming - # convention but aren't consistently labeled (e.g. team-*, prod-*). + workload_names = ["workload-1", "workload-2"] # Empty list means all workloads + node_group_names = ["node-group-1", "node-group-2"] # Empty list means all node groups + kind_filter = ["Deployment", "StatefulSet"] # Empty list means all kinds + + name_pattern = { + pattern = "terraform-example" + flags = "i" + } + namespace_pattern = { pattern = "^prod-" flags = "i" # case-insensitive } + + namespace_selector = { + match_labels = { + app = "terraform-example" + } + } + + workload_selector = { + match_labels = { + app = "terraform-example" + } + } } ``` @@ -60,6 +80,7 @@ resource "devzero_workload_policy_target" "production" { - `enabled` (Boolean) Enable or disable this target. When disabled, the associated policy will not apply to the selected workloads. - `kind_filter` (List of String) Restrict matching to specific Kubernetes kinds. Allowed values: `Pod`, `Job`, `Deployment`, `StatefulSet`, `DaemonSet`, `ReplicaSet`, `CronJob`, `ReplicationController`, `Rollout`. - `name_pattern` (Attributes) Regex to match workload names. Useful to target rollouts or name conventions (e.g., `^api-.*`). (see [below for nested schema](#nestedatt--name_pattern)) +- `namespace_pattern` (Attributes) Regex to match namespace names. Useful when namespaces follow a naming convention (e.g., `^prod-`). (see [below for nested schema](#nestedatt--namespace_pattern)) - `namespace_selector` (Attributes) Select namespaces by labels. Uses the same semantics as Kubernetes label selectors. (see [below for nested schema](#nestedatt--namespace_selector)) - `node_group_names` (List of String) Restrict matching to specific node groups by name - `priority` (Number) Evaluation priority among multiple targets. Higher values take precedence when multiple targets overlap. @@ -79,6 +100,15 @@ Optional: - `pattern` (String) Regular expression applied to workload names. Uses RE2 syntax. Example: `^api-(staging|prod)-.*$`. + +### Nested Schema for `namespace_pattern` + +Optional: + +- `flags` (String) Regex flags to modify matching behavior. Supported: `i` (case-insensitive), `m` (multi-line). +- `pattern` (String) Regular expression applied to workload names. Uses RE2 syntax. Example: `^api-(staging|prod)-.*$`. + + ### Nested Schema for `namespace_selector` diff --git a/examples/resources/devzero_node_policy/resource.tf b/examples/resources/devzero_node_policy/resource.tf index 2b1b15f..d2e673b 100644 --- a/examples/resources/devzero_node_policy/resource.tf +++ b/examples/resources/devzero_node_policy/resource.tf @@ -1,69 +1,3 @@ -# Standard example — values kept in sync with the Pulumi provider -resource "devzero_node_policy" "standard_nodes" { - name = "standard-nodes" - description = "On-demand x86 nodes for general workloads" - weight = 10 - - capacity_types = { - match_expressions = [{ - key = "capacityTypes" - operator = "In" - values = ["on-demand"] - }] - } - - instance_categories = { - match_expressions = [{ - key = "instanceCategories" - operator = "In" - values = ["m", "c"] - }] - } - - instance_sizes = { - match_expressions = [{ - key = "instanceSizes" - operator = "In" - values = ["large", "xlarge", "2xlarge"] - }] - } - - architectures = { - match_expressions = [{ - key = "architectures" - operator = "In" - values = ["amd64"] - }] - } - - operating_systems = { - match_expressions = [{ - key = "operatingSystems" - operator = "In" - values = ["linux"] - }] - } - - disruption = { - consolidation_policy = "WhenEmptyOrUnderutilized" - consolidate_after = "30m" - expire_after = "168h" # 7 days - } - - aws = { - ami_family = "AL2" - role = "KarpenterNodeRole" - - subnet_selector_terms = [ - { tags = { "karpenter.sh/discovery" = "my-cluster" } } - ] - - security_group_selector_terms = [ - { tags = { "karpenter.sh/discovery" = "my-cluster" } } - ] - } -} - # Minimal example - uses sensible defaults resource "devzero_node_policy" "minimal" { name = "minimal-policy" @@ -159,10 +93,9 @@ resource "devzero_node_policy" "aws_comprehensive" { # Disruption policy for cost optimization disruption = { - consolidate_after = "15m" - consolidation_policy = "WhenEmptyOrUnderutilized" - expire_after = "720h" # 30 days - ttl_seconds_after_empty = 300 # 5 minutes + consolidate_after = "15m" + consolidation_policy = "WhenEmptyOrUnderutilized" + expire_after = "720h" # 30 days budgets = [ { diff --git a/examples/resources/devzero_node_policy_target/resource.tf b/examples/resources/devzero_node_policy_target/resource.tf index cafdbe5..df09938 100644 --- a/examples/resources/devzero_node_policy_target/resource.tf +++ b/examples/resources/devzero_node_policy_target/resource.tf @@ -7,14 +7,6 @@ resource "devzero_node_policy" "standard_nodes" { name = "standard-nodes" } -# Standard example — values kept in sync with the Pulumi provider -resource "devzero_node_policy_target" "cluster_nodes" { - name = "cluster-nodes" - policy_id = devzero_node_policy.standard_nodes.id - cluster_ids = [devzero_cluster.production.id] - enabled = true -} - # Minimal example - only required attributes resource "devzero_node_policy_target" "minimal" { name = "production-clusters" @@ -26,6 +18,17 @@ resource "devzero_node_policy_target" "minimal" { # - enabled = true } +# Comprehensive example - all attributes +resource "devzero_node_policy_target" "comprehensive" { + name = "cluster-nodes" + description = "Applies standard node policy to production clusters" + policy_id = devzero_node_policy.standard_nodes.id + enabled = true + cluster_ids = [ + devzero_cluster.production.id, + ] +} + # Example with multiple clusters resource "devzero_cluster" "us_east" { name = "production-us-east-1" @@ -42,7 +45,7 @@ resource "devzero_cluster" "eu_west" { resource "devzero_node_policy_target" "multi_cluster" { name = "all-production-clusters" description = "Apply cost optimization policy to all production clusters" - policy_id = devzero_node_policy.general.id + policy_id = devzero_node_policy.standard_nodes.id enabled = true cluster_ids = [ devzero_cluster.us_east.id, @@ -55,7 +58,7 @@ resource "devzero_node_policy_target" "multi_cluster" { resource "devzero_node_policy_target" "disabled" { name = "staging-clusters" description = "Temporarily disabled while testing new policy" - policy_id = devzero_node_policy.general.id + policy_id = devzero_node_policy.standard_nodes.id enabled = false # Target exists but is not active cluster_ids = [devzero_cluster.production.id] } diff --git a/examples/resources/devzero_workload_policy_target/resource.tf b/examples/resources/devzero_workload_policy_target/resource.tf index f51020f..cc09f24 100644 --- a/examples/resources/devzero_workload_policy_target/resource.tf +++ b/examples/resources/devzero_workload_policy_target/resource.tf @@ -13,18 +13,38 @@ resource "devzero_workload_policy_target" "minimal" { cluster_ids = [devzero_cluster.production.id] } -# Full example — values kept in sync with the Pulumi provider -resource "devzero_workload_policy_target" "production" { - name = "production-target" +# All attributes +resource "devzero_workload_policy_target" "full" { + name = "terraform-example" + description = "some description" policy_id = devzero_workload_policy.cost_saving.id cluster_ids = [devzero_cluster.production.id] - kind_filter = ["Deployment", "StatefulSet"] + priority = 1 enabled = true - # Match namespaces by name pattern — useful when namespaces follow a naming - # convention but aren't consistently labeled (e.g. team-*, prod-*). + workload_names = ["workload-1", "workload-2"] # Empty list means all workloads + node_group_names = ["node-group-1", "node-group-2"] # Empty list means all node groups + kind_filter = ["Deployment", "StatefulSet"] # Empty list means all kinds + + name_pattern = { + pattern = "terraform-example" + flags = "i" + } + namespace_pattern = { pattern = "^prod-" flags = "i" # case-insensitive } -} \ No newline at end of file + + namespace_selector = { + match_labels = { + app = "terraform-example" + } + } + + workload_selector = { + match_labels = { + app = "terraform-example" + } + } +} diff --git a/internal/provider/workload_policy_target.go b/internal/provider/workload_policy_target.go index b0a17af..f9fe001 100644 --- a/internal/provider/workload_policy_target.go +++ b/internal/provider/workload_policy_target.go @@ -49,8 +49,9 @@ type WorkloadPolicyTargetResourceModel struct { NamespaceSelector *LabelSelector `tfsdk:"namespace_selector"` WorkloadSelector *LabelSelector `tfsdk:"workload_selector"` KindFilter types.List `tfsdk:"kind_filter"` - NamePattern *RegexPattern `tfsdk:"name_pattern"` - WorkloadNames types.List `tfsdk:"workload_names"` + NamePattern *RegexPattern `tfsdk:"name_pattern"` + NamespacePattern *RegexPattern `tfsdk:"namespace_pattern"` + WorkloadNames types.List `tfsdk:"workload_names"` NodeGroupNames types.List `tfsdk:"node_group_names"` ClusterIds types.List `tfsdk:"cluster_ids"` } @@ -201,6 +202,12 @@ func (r *WorkloadPolicyTargetResource) Schema(ctx context.Context, req resource. Optional: true, Attributes: regexPatternAttributes, }, + "namespace_pattern": schema.SingleNestedAttribute{ + Description: "Regex to match namespace names", + MarkdownDescription: "Regex to match namespace names. Useful when namespaces follow a naming convention (e.g., `^prod-`).", + Optional: true, + Attributes: regexPatternAttributes, + }, "workload_names": schema.ListAttribute{ Description: "Explicit list of workload names to include", MarkdownDescription: "Explicit list of workload names to include", @@ -302,6 +309,7 @@ func (r *WorkloadPolicyTargetResource) Create(ctx context.Context, req resource. WorkloadSelector: workloadSelector, KindFilter: kindFilters, NamePattern: data.NamePattern.toProto(), + NamespacePattern: data.NamespacePattern.toProto(), WorkloadNames: workloadNames, NodeGroupNames: nodeGroupNames, ClusterIds: clusterIds, @@ -416,6 +424,7 @@ func (r *WorkloadPolicyTargetResource) Update(ctx context.Context, req resource. WorkloadSelector: workloadSelector, KindFilter: kindFilters, NamePattern: data.NamePattern.toProto(), + NamespacePattern: data.NamespacePattern.toProto(), WorkloadNames: workloadNames, NodeGroupNames: nodeGroupNames, ClusterIds: clusterIds, @@ -631,6 +640,7 @@ func (m *WorkloadPolicyTargetResourceModel) fromProto(target *apiv1.WorkloadPoli m.WorkloadSelector.fromProto(target.WorkloadSelector) m.KindFilter = types.ListValueMust(types.StringType, fromKindFilter(target.KindFilter)) m.NamePattern.fromProto(target.NamePattern) + m.NamespacePattern.fromProto(target.NamespacePattern) m.WorkloadNames = types.ListValueMust(types.StringType, fromStringList(target.WorkloadNames)) m.NodeGroupNames = types.ListValueMust(types.StringType, fromStringList(target.NodeGroupNames)) m.ClusterIds = types.ListValueMust(types.StringType, fromStringList(target.ClusterIds)) From 2d215430559b506bc1653b82f11e2d26e7533058 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 10 Jun 2026 12:41:22 +0530 Subject: [PATCH 6/8] all fixed --- internal/provider/workload_policy_target.go | 6 +-- .../provider/workload_policy_target_test.go | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/internal/provider/workload_policy_target.go b/internal/provider/workload_policy_target.go index f9fe001..acf6640 100644 --- a/internal/provider/workload_policy_target.go +++ b/internal/provider/workload_policy_target.go @@ -49,9 +49,9 @@ type WorkloadPolicyTargetResourceModel struct { NamespaceSelector *LabelSelector `tfsdk:"namespace_selector"` WorkloadSelector *LabelSelector `tfsdk:"workload_selector"` KindFilter types.List `tfsdk:"kind_filter"` - NamePattern *RegexPattern `tfsdk:"name_pattern"` - NamespacePattern *RegexPattern `tfsdk:"namespace_pattern"` - WorkloadNames types.List `tfsdk:"workload_names"` + NamePattern *RegexPattern `tfsdk:"name_pattern"` + NamespacePattern *RegexPattern `tfsdk:"namespace_pattern"` + WorkloadNames types.List `tfsdk:"workload_names"` NodeGroupNames types.List `tfsdk:"node_group_names"` ClusterIds types.List `tfsdk:"cluster_ids"` } diff --git a/internal/provider/workload_policy_target_test.go b/internal/provider/workload_policy_target_test.go index af5d066..29006b7 100644 --- a/internal/provider/workload_policy_target_test.go +++ b/internal/provider/workload_policy_target_test.go @@ -307,6 +307,51 @@ func TestWorkloadPolicyTargetResourceModel(t *testing.T) { } }) + // Test NamespacePattern toProto + t.Run("NamespacePattern_ToProto", func(t *testing.T) { + pattern := &RegexPattern{ + Pattern: types.StringValue("^prod-"), + Flags: types.StringValue("i"), + } + + proto := pattern.toProto() + if proto == nil { + t.Fatal("Expected non-nil proto") + } + if proto.Pattern != "^prod-" { + t.Errorf("Expected pattern '^prod-', got %s", proto.Pattern) + } + if proto.Flags != "i" { + t.Errorf("Expected flags 'i', got %s", proto.Flags) + } + }) + + // Test NamespacePattern nil toProto + t.Run("NamespacePattern_NilToProto", func(t *testing.T) { + var pattern *RegexPattern + proto := pattern.toProto() + if proto != nil { + t.Errorf("Expected nil proto for nil NamespacePattern, got %v", proto) + } + }) + + // Test NamespacePattern fromProto + t.Run("NamespacePattern_FromProto", func(t *testing.T) { + protoPattern := &apiv1.RegexPattern{ + Pattern: "^prod-", + Flags: "i", + } + + pattern := &RegexPattern{} + pattern.fromProto(protoPattern) + if pattern.Pattern.ValueString() != "^prod-" { + t.Errorf("Expected pattern '^prod-', got %s", pattern.Pattern.ValueString()) + } + if pattern.Flags.ValueString() != "i" { + t.Errorf("Expected flags 'i', got %s", pattern.Flags.ValueString()) + } + }) + // Test LabelSelector with empty collections returns null t.Run("LabelSelector_EmptyCollectionsToNull", func(t *testing.T) { // Create a proto selector with empty match labels and expressions @@ -454,4 +499,8 @@ func validateTargetSchema(t *testing.T, schema schema.Schema) { if _, exists := schema.Attributes["name_pattern"]; !exists { t.Error("name_pattern attribute not found") } + + if _, exists := schema.Attributes["namespace_pattern"]; !exists { + t.Error("namespace_pattern attribute not found") + } } From 27244bd4e141fc92e46d0622c6c94dbcf777abe3 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 10 Jun 2026 15:34:36 +0530 Subject: [PATCH 7/8] adding missing field --- docs/resources/node_policy.md | 23 ++ docs/resources/workload_policy.md | 2 +- docs/resources/workload_rule.md | 18 +- .../devzero_workload_rule/resource.tf | 15 +- internal/provider/node_policy.go | 362 +++++++++++++++++- internal/provider/node_policy_test.go | 219 +++++++++++ internal/provider/workload_policy.go | 9 +- internal/provider/workload_policy_test.go | 46 +++ internal/provider/workload_rule.go | 45 +++ internal/provider/workload_rule_test.go | 65 ++++ 10 files changed, 779 insertions(+), 25 deletions(-) diff --git a/docs/resources/node_policy.md b/docs/resources/node_policy.md index f060171..12f452d 100644 --- a/docs/resources/node_policy.md +++ b/docs/resources/node_policy.md @@ -307,6 +307,7 @@ resource "devzero_node_policy" "azure_example" { - `instance_hypervisors_tip` (String) Tooltip for instance hypervisors - `instance_sizes` (Attributes) Instance sizes selector (e.g., Standard_D4s for Azure, large for AWS) (see [below for nested schema](#nestedatt--instance_sizes)) - `instance_sizes_tip` (String) Tooltip for instance sizes +- `instance_types` (Attributes) Instance types selector — explicit full type names (e.g., m5.xlarge for AWS, Standard_D4s_v2 for Azure) (see [below for nested schema](#nestedatt--instance_types)) - `labels` (Map of String) Map of Kubernetes labels to apply to nodes provisioned with this policy. - `limits` (Attributes) Maximum resource limits for nodes provisioned with this policy. (see [below for nested schema](#nestedatt--limits)) - `limits_tip` (String) Tooltip for limits @@ -625,6 +626,28 @@ Optional: + +### Nested Schema for `instance_types` + +Optional: + +- `match_expressions` (Attributes List) List of label selector requirements (see [below for nested schema](#nestedatt--instance_types--match_expressions)) +- `match_labels` (Map of String) Map of label key-value pairs to match + + +### Nested Schema for `instance_types.match_expressions` + +Required: + +- `key` (String) Label key +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. + +Optional: + +- `values` (List of String) List of values for In/NotIn operators + + + ### Nested Schema for `limits` diff --git a/docs/resources/workload_policy.md b/docs/resources/workload_policy.md index ab7c417..fb8ad0a 100644 --- a/docs/resources/workload_policy.md +++ b/docs/resources/workload_policy.md @@ -76,7 +76,7 @@ resource "devzero_workload_policy" "cost_saving" { - `cron_schedule` (String) Cron expression for scheduled application. Uses standard 5-field cron format in the cluster timezone. - `defragmentation_schedule` (String) Cron expression for background defragmentation that can move workloads to reduce fragmentation. - `description` (String) Free-form description of the policy to help others understand its intent and scope. -- `detection_triggers` (List of String) Detection triggers for when to apply the workload policy. Only one of `pod_creation` or `pod_update` is allowed.The `pod_creation` trigger is used to apply the workload policy when a pod is created.The `pod_update` trigger is used to apply the workload policy when a pod is updated. +- `detection_triggers` (List of String) Detection triggers for when to apply the workload policy. Valid values: `pod_creation`, `pod_update`, `pod_evict`.The `pod_creation` trigger is used to apply the workload policy when a pod is created.The `pod_update` trigger is used to apply the workload policy when a pod is updated.The `pod_evict` trigger is used to apply the workload policy when a pod is evicted. - `drift_delta_percent` (Number) Percentage drift from baseline that triggers VPA refresh - `enable_pmax_protection` (Boolean) When true, the recommender raises requests to cover observed peak usage when the peak-to-recommendation ratio exceeds `pmax_ratio_threshold`. Default: false. - `gpu_vertical_scaling` (Attributes) GPU vertical scaling options (see [below for nested schema](#nestedatt--gpu_vertical_scaling)) diff --git a/docs/resources/workload_rule.md b/docs/resources/workload_rule.md index 3e2160d..368241b 100644 --- a/docs/resources/workload_rule.md +++ b/docs/resources/workload_rule.md @@ -31,7 +31,7 @@ resource "devzero_workload_rule" "manual" { action_triggers = ["on_schedule", "on_detection"] cron_schedule = "0 2 * * *" - detection_triggers = ["pod_creation", "pod_update"] + detection_triggers = ["pod_creation", "pod_update", "pod_evict"] cpu_rule = { enabled = true @@ -56,6 +56,19 @@ resource "devzero_workload_rule" "manual" { max_replicas = 10 target_utilization = 0.70 primary_metric = "cpu" + + # External/Prometheus metric trigger + metrics = [ + { + type = "prometheus" + target_value = "100" + server_address = "http://prometheus.monitoring.svc.cluster.local:9090" + query = "rate(http_requests_total{job=\"my-api\"}[5m])" + metadata = { + "customKey" = "customValue" + } + } + ] } emergency_response = { @@ -324,6 +337,9 @@ Required: Optional: +- `metadata` (Map of String) Free-form key-value metadata for external scalers (e.g. serverAddress, query for Prometheus). +- `query` (String) PromQL query string. Packed into metadata by the service layer. +- `server_address` (String) Prometheus server URL. Packed into metadata by the service layer. - `target_utilization` (String) Target utilization as a decimal string. Example: '0.70' - `target_value` (String) Absolute target value as a string. Example: '50000000' - `weight` (String) Weight for composite formula scaling (0-1 decimal string). Example: '0.5' diff --git a/examples/resources/devzero_workload_rule/resource.tf b/examples/resources/devzero_workload_rule/resource.tf index 43962b3..d5fe1b6 100644 --- a/examples/resources/devzero_workload_rule/resource.tf +++ b/examples/resources/devzero_workload_rule/resource.tf @@ -16,7 +16,7 @@ resource "devzero_workload_rule" "manual" { action_triggers = ["on_schedule", "on_detection"] cron_schedule = "0 2 * * *" - detection_triggers = ["pod_creation", "pod_update"] + detection_triggers = ["pod_creation", "pod_update", "pod_evict"] cpu_rule = { enabled = true @@ -41,6 +41,19 @@ resource "devzero_workload_rule" "manual" { max_replicas = 10 target_utilization = 0.70 primary_metric = "cpu" + + # External/Prometheus metric trigger + metrics = [ + { + type = "prometheus" + target_value = "100" + server_address = "http://prometheus.monitoring.svc.cluster.local:9090" + query = "rate(http_requests_total{job=\"my-api\"}[5m])" + metadata = { + "customKey" = "customValue" + } + } + ] } emergency_response = { diff --git a/internal/provider/node_policy.go b/internal/provider/node_policy.go index 0baac3e..b8b5184 100644 --- a/internal/provider/node_policy.go +++ b/internal/provider/node_policy.go @@ -50,6 +50,7 @@ type NodePolicyResourceModel struct { InstanceHypervisors *LabelSelector `tfsdk:"instance_hypervisors"` InstanceGenerations *LabelSelector `tfsdk:"instance_generations"` InstanceSizes *LabelSelector `tfsdk:"instance_sizes"` + InstanceTypes *LabelSelector `tfsdk:"instance_types"` InstanceCategoriesTip types.String `tfsdk:"instance_categories_tip"` InstanceFamiliesTip types.String `tfsdk:"instance_families_tip"` InstanceCpusTip types.String `tfsdk:"instance_cpus_tip"` @@ -118,31 +119,65 @@ type MetadataOptions struct { HttpTokens types.String `tfsdk:"http_tokens"` } +// KubeletConfiguration defines AWS kubelet configuration overrides. +type KubeletConfiguration struct { + ClusterDns types.List `tfsdk:"cluster_dns"` + MaxPods types.Int32 `tfsdk:"max_pods"` + PodsPerCore types.Int32 `tfsdk:"pods_per_core"` + SystemReserved types.Map `tfsdk:"system_reserved"` + KubeReserved types.Map `tfsdk:"kube_reserved"` + EvictionHard types.Map `tfsdk:"eviction_hard"` + EvictionSoft types.Map `tfsdk:"eviction_soft"` + EvictionSoftGracePeriod types.Map `tfsdk:"eviction_soft_grace_period"` + EvictionMaxPodGracePeriod types.Int32 `tfsdk:"eviction_max_pod_grace_period"` + ImageGcHighThresholdPercent types.Int32 `tfsdk:"image_gc_high_threshold_percent"` + ImageGcLowThresholdPercent types.Int32 `tfsdk:"image_gc_low_threshold_percent"` + CpuCfsQuota types.Bool `tfsdk:"cpu_cfs_quota"` +} + +// AzureKubeletConfiguration defines Azure kubelet configuration overrides. +type AzureKubeletConfiguration struct { + CpuManagerPolicy types.String `tfsdk:"cpu_manager_policy"` + CpuCfsQuota types.Bool `tfsdk:"cpu_cfs_quota"` + CpuCfsQuotaPeriod types.String `tfsdk:"cpu_cfs_quota_period"` + ImageGcHighThresholdPercent types.Int32 `tfsdk:"image_gc_high_threshold_percent"` + ImageGcLowThresholdPercent types.Int32 `tfsdk:"image_gc_low_threshold_percent"` + TopologyManagerPolicy types.String `tfsdk:"topology_manager_policy"` + AllowedUnsafeSysctls types.List `tfsdk:"allowed_unsafe_sysctls"` + ContainerLogMaxSize types.String `tfsdk:"container_log_max_size"` + ContainerLogMaxFiles types.Int32 `tfsdk:"container_log_max_files"` + PodPidsLimit types.Int64 `tfsdk:"pod_pids_limit"` +} + // AWSNodeClass defines AWS-specific node configuration. type AWSNodeClass struct { - SubnetSelectorTerms types.List `tfsdk:"subnet_selector_terms"` - SecurityGroupSelectorTerms types.List `tfsdk:"security_group_selector_terms"` - AmiSelectorTerms types.List `tfsdk:"ami_selector_terms"` - AmiFamily types.String `tfsdk:"ami_family"` - UserData types.String `tfsdk:"user_data"` - Role types.String `tfsdk:"role"` - InstanceProfile types.String `tfsdk:"instance_profile"` - Tags types.Map `tfsdk:"tags"` - BlockDeviceMappings types.List `tfsdk:"block_device_mappings"` - InstanceStorePolicy types.String `tfsdk:"instance_store_policy"` - DetailedMonitoring types.Bool `tfsdk:"detailed_monitoring"` - AssociatePublicIpAddress types.Bool `tfsdk:"associate_public_ip_address"` - MetadataOptions *MetadataOptions `tfsdk:"metadata_options"` + SubnetSelectorTerms types.List `tfsdk:"subnet_selector_terms"` + SecurityGroupSelectorTerms types.List `tfsdk:"security_group_selector_terms"` + CapacityReservationSelectorTerms types.List `tfsdk:"capacity_reservation_selector_terms"` + AmiSelectorTerms types.List `tfsdk:"ami_selector_terms"` + AmiFamily types.String `tfsdk:"ami_family"` + UserData types.String `tfsdk:"user_data"` + Role types.String `tfsdk:"role"` + InstanceProfile types.String `tfsdk:"instance_profile"` + Tags types.Map `tfsdk:"tags"` + Kubelet *KubeletConfiguration `tfsdk:"kubelet"` + BlockDeviceMappings types.List `tfsdk:"block_device_mappings"` + InstanceStorePolicy types.String `tfsdk:"instance_store_policy"` + DetailedMonitoring types.Bool `tfsdk:"detailed_monitoring"` + AssociatePublicIpAddress types.Bool `tfsdk:"associate_public_ip_address"` + MetadataOptions *MetadataOptions `tfsdk:"metadata_options"` + Context types.String `tfsdk:"context"` } // AzureNodeClass defines Azure-specific node configuration. type AzureNodeClass struct { - VnetSubnetId types.String `tfsdk:"vnet_subnet_id"` - OsDiskSizeGb types.Int32 `tfsdk:"os_disk_size_gb"` - ImageFamily types.String `tfsdk:"image_family"` - FipsMode types.String `tfsdk:"fips_mode"` - Tags types.Map `tfsdk:"tags"` - MaxPods types.Int32 `tfsdk:"max_pods"` + VnetSubnetId types.String `tfsdk:"vnet_subnet_id"` + OsDiskSizeGb types.Int32 `tfsdk:"os_disk_size_gb"` + ImageFamily types.String `tfsdk:"image_family"` + FipsMode types.String `tfsdk:"fips_mode"` + Tags types.Map `tfsdk:"tags"` + Kubelet *AzureKubeletConfiguration `tfsdk:"kubelet"` + MaxPods types.Int32 `tfsdk:"max_pods"` } // RawKarpenterSpec defines raw Karpenter YAML specs. @@ -194,6 +229,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ "instance_hypervisors": labelSelectorAttribute("Instance hypervisors selector"), "instance_generations": labelSelectorAttribute("Instance generations selector (e.g., 4 for Azure, 5 for AWS)"), "instance_sizes": labelSelectorAttribute("Instance sizes selector (e.g., Standard_D4s for Azure, large for AWS)"), + "instance_types": labelSelectorAttribute("Instance types selector — explicit full type names (e.g., m5.xlarge for AWS, Standard_D4s_v2 for Azure)"), // Tooltip fields for instance selectors "instance_categories_tip": tooltipAttribute("Tooltip for instance categories"), "instance_families_tip": tooltipAttribute("Tooltip for instance families"), @@ -867,6 +903,14 @@ func (m *NodePolicyResourceModel) toProto(ctx context.Context, diags *diag.Diagn } policy.InstanceSizes = selector } + if m.InstanceTypes != nil { + selector, err := m.InstanceTypes.toProto(ctx) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert instance types: %s", err)) + return nil + } + policy.InstanceTypes = selector + } // Tooltip fields (pointers for optional) if !m.InstanceCategoriesTip.IsNull() { @@ -1058,6 +1102,9 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { if policy.InstanceSizes != nil { m.InstanceSizes = labelSelectorFromProto(policy.InstanceSizes) } + if policy.InstanceTypes != nil { + m.InstanceTypes = labelSelectorFromProto(policy.InstanceTypes) + } // Tooltip fields m.InstanceCategoriesTip = stringPointerValue(policy.InstanceCategoriesTip) @@ -1206,6 +1253,27 @@ func stringPointerValue(val *string) types.String { return types.StringValue(*val) } +func int32PointerValue(val *int32) types.Int32 { + if val == nil { + return types.Int32Null() + } + return types.Int32Value(*val) +} + +func boolPointerValue(val *bool) types.Bool { + if val == nil { + return types.BoolNull() + } + return types.BoolValue(*val) +} + +func stringMapOrNull(m map[string]string) types.Map { + if len(m) == 0 { + return types.MapNull(types.StringType) + } + return types.MapValueMust(types.StringType, fromStringMap(m)) +} + // Helper function to convert a string to types.String, returning null for empty strings. func stringValue(val string) types.String { if val == "" { @@ -1601,6 +1669,119 @@ func (aws *AWSNodeClass) toProto(ctx context.Context, diags *diag.Diagnostics) * spec.MetadataOptions = metadataOpts } + // Capacity reservation selector terms + if !aws.CapacityReservationSelectorTerms.IsNull() && !aws.CapacityReservationSelectorTerms.IsUnknown() { + var terms []*apiv1.CapacityReservationSelectorTerm + for _, elem := range aws.CapacityReservationSelectorTerms.Elements() { + objVal, ok := elem.(types.Object) + if !ok { + continue + } + attrs := objVal.Attributes() + term := &apiv1.CapacityReservationSelectorTerm{} + if id, ok := attrs["id"].(types.String); ok && !id.IsNull() { + term.Id = id.ValueString() + } + if ownerId, ok := attrs["owner_id"].(types.String); ok && !ownerId.IsNull() { + term.OwnerId = ownerId.ValueString() + } + if tags, ok := attrs["tags"].(types.Map); ok && !tags.IsNull() { + tagMap, err := getStringMap(ctx, tags.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert capacity reservation tags: %s", err)) + return nil + } + term.Tags = tagMap + } + terms = append(terms, term) + } + spec.CapacityReservationSelectorTerms = terms + } + + // Kubelet configuration + if aws.Kubelet != nil { + kubelet := &apiv1.KubeletConfiguration{} + if !aws.Kubelet.ClusterDns.IsNull() { + dns, err := getStringList(ctx, aws.Kubelet.ClusterDns.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert cluster_dns: %s", err)) + return nil + } + kubelet.ClusterDns = dns + } + if !aws.Kubelet.MaxPods.IsNull() { + val := aws.Kubelet.MaxPods.ValueInt32() + kubelet.MaxPods = &val + } + if !aws.Kubelet.PodsPerCore.IsNull() { + val := aws.Kubelet.PodsPerCore.ValueInt32() + kubelet.PodsPerCore = &val + } + if !aws.Kubelet.SystemReserved.IsNull() { + m, err := getStringMap(ctx, aws.Kubelet.SystemReserved.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert system_reserved: %s", err)) + return nil + } + kubelet.SystemReserved = m + } + if !aws.Kubelet.KubeReserved.IsNull() { + m, err := getStringMap(ctx, aws.Kubelet.KubeReserved.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert kube_reserved: %s", err)) + return nil + } + kubelet.KubeReserved = m + } + if !aws.Kubelet.EvictionHard.IsNull() { + m, err := getStringMap(ctx, aws.Kubelet.EvictionHard.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert eviction_hard: %s", err)) + return nil + } + kubelet.EvictionHard = m + } + if !aws.Kubelet.EvictionSoft.IsNull() { + m, err := getStringMap(ctx, aws.Kubelet.EvictionSoft.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert eviction_soft: %s", err)) + return nil + } + kubelet.EvictionSoft = m + } + if !aws.Kubelet.EvictionSoftGracePeriod.IsNull() { + m, err := getStringMap(ctx, aws.Kubelet.EvictionSoftGracePeriod.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert eviction_soft_grace_period: %s", err)) + return nil + } + kubelet.EvictionSoftGracePeriod = m + } + if !aws.Kubelet.EvictionMaxPodGracePeriod.IsNull() { + val := aws.Kubelet.EvictionMaxPodGracePeriod.ValueInt32() + kubelet.EvictionMaxPodGracePeriod = &val + } + if !aws.Kubelet.ImageGcHighThresholdPercent.IsNull() { + val := aws.Kubelet.ImageGcHighThresholdPercent.ValueInt32() + kubelet.ImageGcHighThresholdPercent = &val + } + if !aws.Kubelet.ImageGcLowThresholdPercent.IsNull() { + val := aws.Kubelet.ImageGcLowThresholdPercent.ValueInt32() + kubelet.ImageGcLowThresholdPercent = &val + } + if !aws.Kubelet.CpuCfsQuota.IsNull() { + val := aws.Kubelet.CpuCfsQuota.ValueBool() + kubelet.CpuCfsQuota = &val + } + spec.Kubelet = kubelet + } + + // Context + if !aws.Context.IsNull() { + val := aws.Context.ValueString() + spec.Context = &val + } + return spec } @@ -1918,6 +2099,70 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { aws.MetadataOptions = metadataOpts } + // Capacity reservation selector terms + if len(spec.CapacityReservationSelectorTerms) > 0 { + terms := make([]attr.Value, 0, len(spec.CapacityReservationSelectorTerms)) + for _, term := range spec.CapacityReservationSelectorTerms { + termAttrs := map[string]attr.Value{ + "id": stringValue(term.Id), + "owner_id": stringValue(term.OwnerId), + } + if term.Tags != nil { + termAttrs["tags"] = types.MapValueMust(types.StringType, fromStringMap(term.Tags)) + } else { + termAttrs["tags"] = types.MapNull(types.StringType) + } + terms = append(terms, types.ObjectValueMust( + map[string]attr.Type{ + "id": types.StringType, + "owner_id": types.StringType, + "tags": types.MapType{ElemType: types.StringType}, + }, + termAttrs, + )) + } + aws.CapacityReservationSelectorTerms = types.ListValueMust( + types.ObjectType{AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "owner_id": types.StringType, + "tags": types.MapType{ElemType: types.StringType}, + }}, + terms, + ) + } else { + aws.CapacityReservationSelectorTerms = types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{ + "id": types.StringType, + "owner_id": types.StringType, + "tags": types.MapType{ElemType: types.StringType}, + }}) + } + + // Kubelet configuration + if spec.Kubelet != nil { + k := spec.Kubelet + kubelet := &KubeletConfiguration{} + if len(k.ClusterDns) > 0 { + kubelet.ClusterDns = types.ListValueMust(types.StringType, fromStringList(k.ClusterDns)) + } else { + kubelet.ClusterDns = types.ListNull(types.StringType) + } + kubelet.MaxPods = int32PointerValue(k.MaxPods) + kubelet.PodsPerCore = int32PointerValue(k.PodsPerCore) + kubelet.SystemReserved = stringMapOrNull(k.SystemReserved) + kubelet.KubeReserved = stringMapOrNull(k.KubeReserved) + kubelet.EvictionHard = stringMapOrNull(k.EvictionHard) + kubelet.EvictionSoft = stringMapOrNull(k.EvictionSoft) + kubelet.EvictionSoftGracePeriod = stringMapOrNull(k.EvictionSoftGracePeriod) + kubelet.EvictionMaxPodGracePeriod = int32PointerValue(k.EvictionMaxPodGracePeriod) + kubelet.ImageGcHighThresholdPercent = int32PointerValue(k.ImageGcHighThresholdPercent) + kubelet.ImageGcLowThresholdPercent = int32PointerValue(k.ImageGcLowThresholdPercent) + kubelet.CpuCfsQuota = boolPointerValue(k.CpuCfsQuota) + aws.Kubelet = kubelet + } + + // Context + aws.Context = stringPointerValue(spec.Context) + return aws } @@ -1956,6 +2201,56 @@ func (azure *AzureNodeClass) toProto(ctx context.Context, diags *diag.Diagnostic spec.MaxPods = &val } + if azure.Kubelet != nil { + k := &apiv1.AzureKubeletConfiguration{} + if !azure.Kubelet.CpuManagerPolicy.IsNull() { + val := azure.Kubelet.CpuManagerPolicy.ValueString() + k.CpuManagerPolicy = &val + } + if !azure.Kubelet.CpuCfsQuota.IsNull() { + val := azure.Kubelet.CpuCfsQuota.ValueBool() + k.CpuCfsQuota = &val + } + if !azure.Kubelet.CpuCfsQuotaPeriod.IsNull() { + val := azure.Kubelet.CpuCfsQuotaPeriod.ValueString() + k.CpuCfsQuotaPeriod = &val + } + if !azure.Kubelet.ImageGcHighThresholdPercent.IsNull() { + val := azure.Kubelet.ImageGcHighThresholdPercent.ValueInt32() + k.ImageGcHighThresholdPercent = &val + } + if !azure.Kubelet.ImageGcLowThresholdPercent.IsNull() { + val := azure.Kubelet.ImageGcLowThresholdPercent.ValueInt32() + k.ImageGcLowThresholdPercent = &val + } + if !azure.Kubelet.TopologyManagerPolicy.IsNull() { + val := azure.Kubelet.TopologyManagerPolicy.ValueString() + k.TopologyManagerPolicy = &val + } + if !azure.Kubelet.AllowedUnsafeSysctls.IsNull() { + elems := azure.Kubelet.AllowedUnsafeSysctls.Elements() + strs, err := getStringList(ctx, elems) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert allowed_unsafe_sysctls: %s", err)) + return nil + } + k.AllowedUnsafeSysctls = strs + } + if !azure.Kubelet.ContainerLogMaxSize.IsNull() { + val := azure.Kubelet.ContainerLogMaxSize.ValueString() + k.ContainerLogMaxSize = &val + } + if !azure.Kubelet.ContainerLogMaxFiles.IsNull() { + val := azure.Kubelet.ContainerLogMaxFiles.ValueInt32() + k.ContainerLogMaxFiles = &val + } + if !azure.Kubelet.PodPidsLimit.IsNull() { + val := azure.Kubelet.PodPidsLimit.ValueInt64() + k.PodPidsLimit = &val + } + spec.Kubelet = k + } + return spec } @@ -1966,17 +2261,20 @@ func isAWSSpecEmpty(spec *apiv1.AWSNodeClassSpec) bool { } return len(spec.SubnetSelectorTerms) == 0 && len(spec.SecurityGroupSelectorTerms) == 0 && + len(spec.CapacityReservationSelectorTerms) == 0 && len(spec.AmiSelectorTerms) == 0 && spec.AmiFamily == nil && spec.UserData == nil && spec.Role == nil && spec.InstanceProfile == nil && spec.Tags == nil && + spec.Kubelet == nil && len(spec.BlockDeviceMappings) == 0 && spec.InstanceStorePolicy == nil && spec.DetailedMonitoring == nil && spec.AssociatePublicIpAddress == nil && - spec.MetadataOptions == nil + spec.MetadataOptions == nil && + spec.Context == nil } // Helper to check if Azure spec is empty (all fields are nil). @@ -1989,6 +2287,7 @@ func isAzureSpecEmpty(spec *apiv1.AzureNodeClassSpec) bool { spec.ImageFamily == nil && spec.FipsMode == nil && spec.Tags == nil && + spec.Kubelet == nil && spec.MaxPods == nil } @@ -2018,6 +2317,29 @@ func azureNodeClassFromProto(spec *apiv1.AzureNodeClassSpec) *AzureNodeClass { azure.MaxPods = types.Int32Null() } + if spec.Kubelet != nil { + k := &AzureKubeletConfiguration{} + k.CpuManagerPolicy = stringPointerValue(spec.Kubelet.CpuManagerPolicy) + k.CpuCfsQuota = boolPointerValue(spec.Kubelet.CpuCfsQuota) + k.CpuCfsQuotaPeriod = stringPointerValue(spec.Kubelet.CpuCfsQuotaPeriod) + k.ImageGcHighThresholdPercent = int32PointerValue(spec.Kubelet.ImageGcHighThresholdPercent) + k.ImageGcLowThresholdPercent = int32PointerValue(spec.Kubelet.ImageGcLowThresholdPercent) + k.TopologyManagerPolicy = stringPointerValue(spec.Kubelet.TopologyManagerPolicy) + if len(spec.Kubelet.AllowedUnsafeSysctls) > 0 { + k.AllowedUnsafeSysctls = types.ListValueMust(types.StringType, fromStringList(spec.Kubelet.AllowedUnsafeSysctls)) + } else { + k.AllowedUnsafeSysctls = types.ListNull(types.StringType) + } + k.ContainerLogMaxSize = stringPointerValue(spec.Kubelet.ContainerLogMaxSize) + k.ContainerLogMaxFiles = int32PointerValue(spec.Kubelet.ContainerLogMaxFiles) + if spec.Kubelet.PodPidsLimit != nil { + k.PodPidsLimit = types.Int64Value(*spec.Kubelet.PodPidsLimit) + } else { + k.PodPidsLimit = types.Int64Null() + } + azure.Kubelet = k + } + return azure } diff --git a/internal/provider/node_policy_test.go b/internal/provider/node_policy_test.go index 331aa4d..50adefc 100644 --- a/internal/provider/node_policy_test.go +++ b/internal/provider/node_policy_test.go @@ -9,6 +9,8 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" + + apiv1 "github.com/devzero-inc/terraform-provider-devzero/internal/gen/api/v1" ) func TestNodePolicyResourceSchema(t *testing.T) { @@ -794,6 +796,222 @@ func TestNodePolicyResourceModel(t *testing.T) { t.Errorf("Expected snapshot_id 'snap-12345', got %v", bdm.Ebs.SnapshotId) } }) + + // Test AWS kubelet configuration + t.Run("AWSKubelet_ToProto", func(t *testing.T) { + awsConfig := &AWSNodeClass{ + SubnetSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + SecurityGroupSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + CapacityReservationSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + AmiSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + BlockDeviceMappings: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + Kubelet: &KubeletConfiguration{ + MaxPods: types.Int32Value(110), + PodsPerCore: types.Int32Value(10), + CpuCfsQuota: types.BoolValue(true), + ClusterDns: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("10.96.0.10")}), + SystemReserved: types.MapNull(types.StringType), + KubeReserved: types.MapNull(types.StringType), + EvictionHard: types.MapNull(types.StringType), + EvictionSoft: types.MapNull(types.StringType), + EvictionSoftGracePeriod: types.MapNull(types.StringType), + EvictionMaxPodGracePeriod: types.Int32Null(), + ImageGcHighThresholdPercent: types.Int32Value(85), + ImageGcLowThresholdPercent: types.Int32Value(70), + }, + } + ctx := context.Background() + var diags diag.Diagnostics + proto := awsConfig.toProto(ctx, &diags) + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if proto.Kubelet == nil { + t.Fatal("Expected non-nil Kubelet") + } + if proto.Kubelet.MaxPods == nil || *proto.Kubelet.MaxPods != 110 { + t.Errorf("Expected MaxPods=110, got %v", proto.Kubelet.MaxPods) + } + if proto.Kubelet.ImageGcHighThresholdPercent == nil || *proto.Kubelet.ImageGcHighThresholdPercent != 85 { + t.Errorf("Expected ImageGcHighThresholdPercent=85, got %v", proto.Kubelet.ImageGcHighThresholdPercent) + } + if len(proto.Kubelet.ClusterDns) != 1 || proto.Kubelet.ClusterDns[0] != "10.96.0.10" { + t.Errorf("Expected ClusterDns=[10.96.0.10], got %v", proto.Kubelet.ClusterDns) + } + }) + + // Test AWS kubelet from proto + t.Run("AWSKubelet_FromProto", func(t *testing.T) { + maxPods := int32(110) + high := int32(85) + low := int32(70) + proto := &apiv1.AWSNodeClassSpec{ + Kubelet: &apiv1.KubeletConfiguration{ + MaxPods: &maxPods, + ImageGcHighThresholdPercent: &high, + ImageGcLowThresholdPercent: &low, + ClusterDns: []string{"10.96.0.10"}, + }, + } + aws := awsNodeClassFromProto(proto) + if aws.Kubelet == nil { + t.Fatal("Expected non-nil Kubelet") + } + if aws.Kubelet.MaxPods.ValueInt32() != 110 { + t.Errorf("Expected MaxPods=110, got %d", aws.Kubelet.MaxPods.ValueInt32()) + } + if aws.Kubelet.ImageGcHighThresholdPercent.ValueInt32() != 85 { + t.Errorf("Expected ImageGcHighThresholdPercent=85, got %d", aws.Kubelet.ImageGcHighThresholdPercent.ValueInt32()) + } + clusterDnsElems := aws.Kubelet.ClusterDns.Elements() + if len(clusterDnsElems) != 1 { + t.Fatalf("Expected 1 DNS entry, got %d", len(clusterDnsElems)) + } + }) + + // Test AWS context + t.Run("AWSContext_ToProto", func(t *testing.T) { + awsConfig := &AWSNodeClass{ + SubnetSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + SecurityGroupSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + CapacityReservationSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + AmiSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + BlockDeviceMappings: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + Context: types.StringValue("arn:aws:ec2:us-east-1:123456789012:launch-template/lt-1234"), + } + ctx := context.Background() + var diags diag.Diagnostics + proto := awsConfig.toProto(ctx, &diags) + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if proto.Context == nil || *proto.Context != "arn:aws:ec2:us-east-1:123456789012:launch-template/lt-1234" { + t.Errorf("Expected Context ARN, got %v", proto.Context) + } + }) + + // Test Azure kubelet + t.Run("AzureKubelet_ToProto", func(t *testing.T) { + azureConfig := &AzureNodeClass{ + Kubelet: &AzureKubeletConfiguration{ + CpuManagerPolicy: types.StringValue("static"), + CpuCfsQuota: types.BoolValue(true), + CpuCfsQuotaPeriod: types.StringValue("100ms"), + ImageGcHighThresholdPercent: types.Int32Value(85), + ImageGcLowThresholdPercent: types.Int32Value(70), + TopologyManagerPolicy: types.StringValue("restricted"), + AllowedUnsafeSysctls: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("net.ipv4.tcp_syncookies")}), + ContainerLogMaxSize: types.StringValue("50Mi"), + ContainerLogMaxFiles: types.Int32Value(5), + PodPidsLimit: types.Int64Value(4096), + }, + } + ctx := context.Background() + var diags diag.Diagnostics + proto := azureConfig.toProto(ctx, &diags) + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if proto.Kubelet == nil { + t.Fatal("Expected non-nil Kubelet") + } + if proto.Kubelet.CpuManagerPolicy == nil || *proto.Kubelet.CpuManagerPolicy != "static" { + t.Errorf("Expected CpuManagerPolicy=static, got %v", proto.Kubelet.CpuManagerPolicy) + } + if proto.Kubelet.CpuCfsQuota == nil || !*proto.Kubelet.CpuCfsQuota { + t.Error("Expected CpuCfsQuota=true") + } + if proto.Kubelet.ContainerLogMaxSize == nil || *proto.Kubelet.ContainerLogMaxSize != "50Mi" { + t.Errorf("Expected ContainerLogMaxSize=50Mi, got %v", proto.Kubelet.ContainerLogMaxSize) + } + if proto.Kubelet.PodPidsLimit == nil || *proto.Kubelet.PodPidsLimit != 4096 { + t.Errorf("Expected PodPidsLimit=4096, got %v", proto.Kubelet.PodPidsLimit) + } + if len(proto.Kubelet.AllowedUnsafeSysctls) != 1 { + t.Errorf("Expected 1 sysctl, got %d", len(proto.Kubelet.AllowedUnsafeSysctls)) + } + }) + + // Test Azure kubelet from proto + t.Run("AzureKubelet_FromProto", func(t *testing.T) { + policy := "static" + high := int32(85) + low := int32(70) + logSize := "50Mi" + logFiles := int32(5) + podPids := int64(4096) + proto := &apiv1.AzureNodeClassSpec{ + Kubelet: &apiv1.AzureKubeletConfiguration{ + CpuManagerPolicy: &policy, + ImageGcHighThresholdPercent: &high, + ImageGcLowThresholdPercent: &low, + ContainerLogMaxSize: &logSize, + ContainerLogMaxFiles: &logFiles, + PodPidsLimit: &podPids, + AllowedUnsafeSysctls: []string{"net.ipv4.tcp_syncookies"}, + }, + } + azure := azureNodeClassFromProto(proto) + if azure.Kubelet == nil { + t.Fatal("Expected non-nil Kubelet") + } + if azure.Kubelet.CpuManagerPolicy.ValueString() != "static" { + t.Errorf("Expected CpuManagerPolicy=static, got %s", azure.Kubelet.CpuManagerPolicy.ValueString()) + } + if azure.Kubelet.ImageGcHighThresholdPercent.ValueInt32() != 85 { + t.Errorf("Expected ImageGcHighThresholdPercent=85, got %d", azure.Kubelet.ImageGcHighThresholdPercent.ValueInt32()) + } + if azure.Kubelet.PodPidsLimit.ValueInt64() != 4096 { + t.Errorf("Expected PodPidsLimit=4096, got %d", azure.Kubelet.PodPidsLimit.ValueInt64()) + } + sysctls := azure.Kubelet.AllowedUnsafeSysctls.Elements() + if len(sysctls) != 1 { + t.Fatalf("Expected 1 sysctl, got %d", len(sysctls)) + } + }) + + // Test instance_types via LabelSelector conversion + t.Run("InstanceTypes_LabelSelector_ToProto", func(t *testing.T) { + ctx := context.Background() + sel := &LabelSelector{ + MatchLabels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "karpenter.k8s.aws/instance-type": types.StringValue("m5.xlarge"), + }), + MatchExpressions: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{ + "key": types.StringType, + "operator": types.StringType, + "values": types.ListType{ElemType: types.StringType}, + }}), + } + proto, err := sel.toProto(ctx) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if proto == nil { + t.Fatal("Expected non-nil proto") + } + if proto.MatchLabels["karpenter.k8s.aws/instance-type"] != "m5.xlarge" { + t.Errorf("Expected match_labels to contain instance type, got %v", proto.MatchLabels) + } + }) + + // Test instance_types via LabelSelector fromProto + t.Run("InstanceTypes_LabelSelector_FromProto", func(t *testing.T) { + proto := &apiv1.LabelSelector{ + MatchLabels: map[string]string{"karpenter.k8s.aws/instance-type": "m5.xlarge"}, + } + sel := labelSelectorFromProto(proto) + if sel == nil { + t.Fatal("Expected non-nil selector") + } + if sel.MatchLabels.IsNull() { + t.Fatal("Expected non-null MatchLabels") + } + elems := sel.MatchLabels.Elements() + if len(elems) != 1 { + t.Errorf("Expected 1 label, got %d", len(elems)) + } + }) } func validateNodePolicySchema(t *testing.T, schema schema.Schema) { @@ -820,6 +1038,7 @@ func validateNodePolicySchema(t *testing.T, schema schema.Schema) { "description", "weight", "instance_categories", "instance_families", "instance_cpus", "instance_hypervisors", "instance_generations", "instance_sizes", + "instance_types", "zones", "architectures", "capacity_types", "operating_systems", "labels", "taints", "disruption", "limits", "node_pool_name", "node_class_name", diff --git a/internal/provider/workload_policy.go b/internal/provider/workload_policy.go index 8cc5f08..6000f29 100644 --- a/internal/provider/workload_policy.go +++ b/internal/provider/workload_policy.go @@ -232,9 +232,10 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema }, "detection_triggers": schema.ListAttribute{ Description: "Events that trigger application of this policy", - MarkdownDescription: "Detection triggers for when to apply the workload policy. Only one of `pod_creation` or `pod_update` is allowed." + + MarkdownDescription: "Detection triggers for when to apply the workload policy. Valid values: `pod_creation`, `pod_update`, `pod_evict`." + "The `pod_creation` trigger is used to apply the workload policy when a pod is created." + - "The `pod_update` trigger is used to apply the workload policy when a pod is updated.", + "The `pod_update` trigger is used to apply the workload policy when a pod is updated." + + "The `pod_evict` trigger is used to apply the workload policy when a pod is evicted.", Optional: true, Computed: true, ElementType: types.StringType, @@ -585,6 +586,8 @@ func (m *WorkloadPolicyResourceModel) toProto(ctx context.Context, diags *diag.D return apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_CREATION, nil case "pod_update": return apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_UPDATE, nil + case "pod_evict": + return apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_EVICT, nil default: return apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_UNSPECIFIED, fmt.Errorf("invalid detection trigger: %s", value) } @@ -660,6 +663,8 @@ func (m *WorkloadPolicyResourceModel) fromProto(policy *apiv1.WorkloadRecommenda trigger = types.StringValue("pod_creation") case apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_UPDATE: trigger = types.StringValue("pod_update") + case apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_EVICT: + trigger = types.StringValue("pod_evict") } detectionTriggers = append(detectionTriggers, trigger) } diff --git a/internal/provider/workload_policy_test.go b/internal/provider/workload_policy_test.go index bc1246c..5460961 100644 --- a/internal/provider/workload_policy_test.go +++ b/internal/provider/workload_policy_test.go @@ -4,10 +4,13 @@ import ( "context" "testing" + "github.com/hashicorp/terraform-plugin-framework/attr" "github.com/hashicorp/terraform-plugin-framework/diag" "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/types" + + apiv1 "github.com/devzero-inc/terraform-provider-devzero/internal/gen/api/v1" ) func TestWorkloadPolicyResourceSchema(t *testing.T) { @@ -210,6 +213,49 @@ func TestWorkloadPolicyResourceModel(t *testing.T) { t.Errorf("Expected 'network', got %s", result) } }) + + t.Run("DetectionTrigger_PodEvict_ToProto", func(t *testing.T) { + ctx := context.Background() + m := &WorkloadPolicyResourceModel{ + Name: types.StringValue("test"), + Description: types.StringValue(""), + ActionTriggers: types.ListValueMust(types.StringType, nil), + DetectionTriggers: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("pod_evict")}), + SchedulerPlugins: types.ListValueMust(types.StringType, nil), + CronSchedule: types.StringValue("*/15 * * * *"), + DefragmentationSchedule: types.StringValue("*/15 * * * *"), + } + var diags diag.Diagnostics + proto := m.toProto(ctx, &diags, "team-1") + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if len(proto.DetectionTriggers) != 1 { + t.Fatalf("Expected 1 detection trigger, got %d", len(proto.DetectionTriggers)) + } + if proto.DetectionTriggers[0] != apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_EVICT { + t.Errorf("Expected DETECTION_TRIGGER_POD_EVICT, got %v", proto.DetectionTriggers[0]) + } + }) + + t.Run("DetectionTrigger_PodEvict_FromProto", func(t *testing.T) { + policy := &apiv1.WorkloadRecommendationPolicy{ + PolicyId: "p1", + Name: "test", + DetectionTriggers: []apiv1.WorkloadDetectionTrigger{ + apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_EVICT, + }, + } + var m WorkloadPolicyResourceModel + m.fromProto(policy) + elems := m.DetectionTriggers.Elements() + if len(elems) != 1 { + t.Fatalf("Expected 1 detection trigger, got %d", len(elems)) + } + if sv, ok := elems[0].(types.String); !ok || sv.ValueString() != "pod_evict" { + t.Errorf("Expected pod_evict, got %v", elems[0]) + } + }) } func validateSchema(t *testing.T, s schema.Schema) { diff --git a/internal/provider/workload_rule.go b/internal/provider/workload_rule.go index 35d77d6..4819f78 100644 --- a/internal/provider/workload_rule.go +++ b/internal/provider/workload_rule.go @@ -90,6 +90,9 @@ type HPAMetricTriggerModel struct { TargetUtilization types.String `tfsdk:"target_utilization"` TargetValue types.String `tfsdk:"target_value"` Weight types.String `tfsdk:"weight"` + Metadata types.Map `tfsdk:"metadata"` + ServerAddress types.String `tfsdk:"server_address"` + Query types.String `tfsdk:"query"` } type HPAFallbackModel struct { @@ -389,6 +392,19 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Description: "Weight for composite formula scaling (0-1 decimal string). Example: '0.5'", Optional: true, }, + "metadata": schema.MapAttribute{ + Description: "Free-form key-value metadata for external scalers (e.g. serverAddress, query for Prometheus).", + Optional: true, + ElementType: types.StringType, + }, + "server_address": schema.StringAttribute{ + Description: "Prometheus server URL. Packed into metadata by the service layer.", + Optional: true, + }, + "query": schema.StringAttribute{ + Description: "PromQL query string. Packed into metadata by the service layer.", + Optional: true, + }, }, }, }, @@ -1259,6 +1275,23 @@ func hpaMetricTriggersToProto(ms []HPAMetricTriggerModel) []*apiv1.HPAMetricTrig v := m.Weight.ValueString() t.Weight = &v } + if !m.Metadata.IsNull() && !m.Metadata.IsUnknown() { + meta := make(map[string]string) + for k, v := range m.Metadata.Elements() { + if sv, ok := v.(types.String); ok { + meta[k] = sv.ValueString() + } + } + t.Metadata = meta + } + if !m.ServerAddress.IsNull() && !m.ServerAddress.IsUnknown() { + v := m.ServerAddress.ValueString() + t.ServerAddress = &v + } + if !m.Query.IsNull() && !m.Query.IsUnknown() { + v := m.Query.ValueString() + t.Query = &v + } result[i] = t } return result @@ -1275,6 +1308,9 @@ func hpaMetricTriggersFromProto(ps []*apiv1.HPAMetricTrigger) []HPAMetricTrigger TargetUtilization: types.StringNull(), TargetValue: types.StringNull(), Weight: types.StringNull(), + Metadata: types.MapNull(types.StringType), + ServerAddress: types.StringNull(), + Query: types.StringNull(), } if p.TargetUtilization != nil { m.TargetUtilization = types.StringValue(*p.TargetUtilization) @@ -1285,6 +1321,15 @@ func hpaMetricTriggersFromProto(ps []*apiv1.HPAMetricTrigger) []HPAMetricTrigger if p.Weight != nil { m.Weight = types.StringValue(*p.Weight) } + if len(p.Metadata) > 0 { + m.Metadata = types.MapValueMust(types.StringType, fromStringMap(p.Metadata)) + } + if p.ServerAddress != nil { + m.ServerAddress = types.StringValue(*p.ServerAddress) + } + if p.Query != nil { + m.Query = types.StringValue(*p.Query) + } result = append(result, m) } return result diff --git a/internal/provider/workload_rule_test.go b/internal/provider/workload_rule_test.go index 6339cc1..9b4c9ca 100644 --- a/internal/provider/workload_rule_test.go +++ b/internal/provider/workload_rule_test.go @@ -355,6 +355,9 @@ func TestWorkloadRuleResourceModel(t *testing.T) { TargetUtilization: types.StringNull(), TargetValue: types.StringValue("100"), Weight: types.StringValue("0.5"), + Metadata: types.MapNull(types.StringType), + ServerAddress: types.StringNull(), + Query: types.StringNull(), }, }, Fallback: &HPAFallbackModel{ @@ -1599,4 +1602,66 @@ func TestWorkloadRuleResourceModel(t *testing.T) { } } }) + t.Run("HPAMetricTrigger_ServerAddress_Query_Metadata_ToProto", func(t *testing.T) { + addr := "http://prometheus:9090" + query := "rate(http_requests_total[5m])" + ms := []HPAMetricTriggerModel{ + { + Type: types.StringValue("prometheus"), + TargetValue: types.StringValue("100"), + ServerAddress: types.StringValue(addr), + Query: types.StringValue(query), + Metadata: types.MapValueMust(types.StringType, map[string]attr.Value{ + "customKey": types.StringValue("customValue"), + }), + TargetUtilization: types.StringNull(), + Weight: types.StringNull(), + }, + } + protos := hpaMetricTriggersToProto(ms) + if len(protos) != 1 { + t.Fatalf("Expected 1 trigger, got %d", len(protos)) + } + p := protos[0] + if p.ServerAddress == nil || *p.ServerAddress != addr { + t.Errorf("Expected ServerAddress=%q, got %v", addr, p.ServerAddress) + } + if p.Query == nil || *p.Query != query { + t.Errorf("Expected Query=%q, got %v", query, p.Query) + } + if p.Metadata["customKey"] != "customValue" { + t.Errorf("Expected Metadata[customKey]=customValue, got %v", p.Metadata) + } + }) + + t.Run("HPAMetricTrigger_ServerAddress_Query_Metadata_FromProto", func(t *testing.T) { + addr := "http://prometheus:9090" + query := "rate(http_requests_total[5m])" + protos := []*apiv1.HPAMetricTrigger{ + { + Type: "prometheus", + ServerAddress: &addr, + Query: &query, + Metadata: map[string]string{"customKey": "customValue"}, + }, + } + ms := hpaMetricTriggersFromProto(protos) + if len(ms) != 1 { + t.Fatalf("Expected 1 model, got %d", len(ms)) + } + m := ms[0] + if m.ServerAddress.ValueString() != addr { + t.Errorf("Expected ServerAddress=%q, got %s", addr, m.ServerAddress.ValueString()) + } + if m.Query.ValueString() != query { + t.Errorf("Expected Query=%q, got %s", query, m.Query.ValueString()) + } + if m.Metadata.IsNull() { + t.Fatal("Expected non-null Metadata") + } + metaElems := m.Metadata.Elements() + if sv, ok := metaElems["customKey"].(types.String); !ok || sv.ValueString() != "customValue" { + t.Errorf("Expected Metadata[customKey]=customValue, got %v", metaElems) + } + }) } From f721e70a4edf718881fc37e4a5d9d86c8df61291 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 10 Jun 2026 15:48:32 +0530 Subject: [PATCH 8/8] adding missing field --- docs/resources/node_policy.md | 22 +++++++++++----------- internal/provider/node_policy.go | 4 ++-- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/resources/node_policy.md b/docs/resources/node_policy.md index 12f452d..a9fd91f 100644 --- a/docs/resources/node_policy.md +++ b/docs/resources/node_policy.md @@ -341,7 +341,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -462,7 +462,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -508,7 +508,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -530,7 +530,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -552,7 +552,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -574,7 +574,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -596,7 +596,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -618,7 +618,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -640,7 +640,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -671,7 +671,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: @@ -712,7 +712,7 @@ Optional: Required: - `key` (String) Label key -- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`. +- `operator` (String) Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`. Optional: diff --git a/internal/provider/node_policy.go b/internal/provider/node_policy.go index b8b5184..f4428f1 100644 --- a/internal/provider/node_policy.go +++ b/internal/provider/node_policy.go @@ -665,8 +665,8 @@ func labelSelectorAttribute(description string) schema.SingleNestedAttribute { Required: true, }, "operator": schema.StringAttribute{ - Description: "Operator (In, NotIn, Exists, DoesNotExist)", - MarkdownDescription: "Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`.", + Description: "Operator (In, NotIn, Exists, DoesNotExist, Gt, Lt)", + MarkdownDescription: "Operator for matching. Valid values: `In`, `NotIn`, `Exists`, `DoesNotExist`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`.", Required: true, }, "values": schema.ListAttribute{