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) + } + }) }