diff --git a/docs/resources/node_policy.md b/docs/resources/node_policy.md
index f060171..a9fd91f 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
@@ -340,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:
@@ -461,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:
@@ -507,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:
@@ -529,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:
@@ -551,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:
@@ -573,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:
@@ -595,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:
@@ -617,7 +618,29 @@ 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:
+
+- `values` (List of String) List of values for In/NotIn operators
+
+
+
+
+### 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`, `Gt`, `Lt`. `Gt`/`Lt` apply to numeric selectors such as `instance_generations` and `instance_cpus`.
Optional:
@@ -648,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:
@@ -689,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/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..f4428f1 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"),
@@ -629,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{
@@ -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)
+ }
+ })
}