From 828ac58aef2a4935f3d33ba4d99e2f4bafb135c6 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Mon, 24 Aug 2026 23:30:55 +0530 Subject: [PATCH 1/3] sync the profiling with the service repo Signed-off-by: Rupam-It --- internal/gen/api/v1/profiling.pb.go | 4 ++-- internal/proto/api/v1/profiling.proto | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/gen/api/v1/profiling.pb.go b/internal/gen/api/v1/profiling.pb.go index 01fcf07..4f3b448 100644 --- a/internal/gen/api/v1/profiling.pb.go +++ b/internal/gen/api/v1/profiling.pb.go @@ -136,8 +136,8 @@ type GetWorkloadProfilesRequest struct { Workloads []*ProfilingWorkloadKey `protobuf:"bytes,3,rep,name=workloads,proto3" json:"workloads,omitempty"` StartTime *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=start_time,json=startTime,proto3,oneof" json:"start_time,omitempty"` EndTime *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=end_time,json=endTime,proto3,oneof" json:"end_time,omitempty"` - // When set to 1D or 7D, serve from the persisted workload_profile_cache - // snapshot for that window instead of computing live over start_time/end_time. + // When set to 1D or 7D, serve the persisted current profile state for that + // window instead of computing live over start_time/end_time. // UNSPECIFIED (default) preserves today's live-compute behavior. Window ProfileWindow `protobuf:"varint,13,opt,name=window,proto3,enum=api.v1.ProfileWindow" json:"window,omitempty"` } diff --git a/internal/proto/api/v1/profiling.proto b/internal/proto/api/v1/profiling.proto index 86c530b..3be3d38 100644 --- a/internal/proto/api/v1/profiling.proto +++ b/internal/proto/api/v1/profiling.proto @@ -35,8 +35,8 @@ message GetWorkloadProfilesRequest { optional google.protobuf.Timestamp start_time = 11; optional google.protobuf.Timestamp end_time = 12; - // When set to 1D or 7D, serve from the persisted workload_profile_cache - // snapshot for that window instead of computing live over start_time/end_time. + // When set to 1D or 7D, serve the persisted current profile state for that + // window instead of computing live over start_time/end_time. // UNSPECIFIED (default) preserves today's live-compute behavior. ProfileWindow window = 13; } From b6492429a75951ae2d75d0924ab883e575868900 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Wed, 26 Aug 2026 00:27:28 +0530 Subject: [PATCH 2/3] Wires up newly-available DevZero API fields across , , and Signed-off-by: Rupam-It --- docs/resources/node_policy.md | 319 ++++- docs/resources/workload_policy.md | 23 + docs/resources/workload_rule.md | 122 +- .../resources/devzero_node_policy/resource.tf | 154 +- .../devzero_workload_policy/resource.tf | 8 + .../devzero_workload_rule/resource.tf | 40 +- internal/provider/node_policy.go | 1273 +++++++++++++++-- internal/provider/node_policy_test.go | 504 ++++++- internal/provider/workload_policy.go | 35 +- internal/provider/workload_policy_test.go | 96 ++ internal/provider/workload_rule.go | 459 +++++- internal/provider/workload_rule_test.go | 305 +++- 12 files changed, 3122 insertions(+), 216 deletions(-) diff --git a/docs/resources/node_policy.md b/docs/resources/node_policy.md index 4bf6433..13a8b02 100644 --- a/docs/resources/node_policy.md +++ b/docs/resources/node_policy.md @@ -136,6 +136,10 @@ resource "devzero_node_policy" "aws_comprehensive" { ami_selector_terms = [ { alias = "al2023@latest" + }, + { + # Resolve the AMI ID from an SSM public parameter instead of an alias + ssm_parameter = "/aws/service/eks/optimized-ami/1.29/amazon-linux-2/recommended/image_id" } ] @@ -168,11 +172,13 @@ resource "devzero_node_policy" "aws_comprehensive" { block_device_mappings = [ { device_name = "/dev/xvda" + root_volume = true ebs = { - volume_size = "100Gi" - volume_type = "gp3" - encrypted = true - delete_on_termination = true + volume_size = "100Gi" + volume_type = "gp3" + encrypted = true + delete_on_termination = true + volume_initialization_rate = 100 # MiB/s to pre-warm the volume from its snapshot } } ] @@ -275,6 +281,146 @@ resource "devzero_node_policy" "azure_example" { } } } + +# GCP example +resource "devzero_node_policy" "gcp_example" { + name = "gcp-production" + description = "Production-ready GCP node policy" + node_pool_name = "production-pool" + node_class_name = "production-class" + weight = 10 + + # GCP-only: select nodes by custom machine shape tokens + instance_shapes = { + match_expressions = [{ + key = "instanceShapes" + operator = "In" + values = ["custom"] + }] + } + + architectures = { + match_expressions = [{ + key = "architectures" + operator = "In" + values = ["amd64"] + }] + } + + capacity_types = { + match_expressions = [{ + key = "capacityTypes" + operator = "In" + values = ["spot", "on-demand"] + }] + } + + labels = { + "dedicated" = "karpenter" + } + + disruption = { + consolidate_after = "5m" + consolidation_policy = "WhenEmptyOrUnderutilized" + expire_after = "168h" # 7 days + } + + # GCP-specific configuration + gcp = { + service_account = "karpenter@my-project.iam.gserviceaccount.com" + + image_selector_terms = [ + { + alias = "ubuntu-2204-lts" + } + ] + image_family = "ubuntu" + + labels = { + "environment" = "production" + } + network_tags = ["allow-ssh", "allow-health-checks"] + + disks = [ + { + size_gib = 100 + category = "pd-ssd" + boot = true + } + ] + + kubelet = { + max_pods = 110 + } + } +} + +# OCI example +resource "devzero_node_policy" "oci_example" { + name = "oci-production" + description = "Production-ready OCI node policy" + node_pool_name = "production-pool" + node_class_name = "production-class" + weight = 10 + + architectures = { + match_expressions = [{ + key = "architectures" + operator = "In" + values = ["amd64"] + }] + } + + disruption = { + consolidate_after = "5m" + consolidation_policy = "WhenEmptyOrUnderutilized" + expire_after = "168h" # 7 days + } + + # OCI-specific configuration + oci = { + vcn_id = "ocid1.vcn.oc1..aaaaaaaaexample" + + image_selector = [ + { + name = "Oracle-Linux-8.9-2024.05.15-0" + } + ] + image_family = "oracle-linux-8" + + subnet_selector = [ + { + name = "production-subnet" + } + ] + security_group_selector = [ + { + name = "production-node-sg" + } + ] + + free_form_tags = { + "Environment" = "production" + } + + boot_config = { + boot_volume_size_in_gbs = 100 + boot_volume_vpus_per_gb = 10 + } + + launch_options = { + boot_volume_type = "PARAVIRTUALIZED" + is_consistent_volume_naming_enabled = true + } + + block_devices = [ + { + size_in_gbs = 50 + vpus_per_gb = 10 + } + ] + } +} ``` @@ -296,6 +442,7 @@ resource "devzero_node_policy" "azure_example" { - `description` (String) Free-form description of the policy to help others understand its intent and scope. - `disruption` (Attributes) Configuration for node disruption policies including consolidation and expiration settings. (see [below for nested schema](#nestedatt--disruption)) - `disruptions_tip` (String) Tooltip for disruptions +- `gcp` (Attributes) GCP-specific configuration for nodes provisioned with this policy. (see [below for nested schema](#nestedatt--gcp)) - `instance_categories` (Attributes) Instance categories selector (e.g., D for Azure, m for AWS) (see [below for nested schema](#nestedatt--instance_categories)) - `instance_categories_tip` (String) Tooltip for instance categories - `instance_cpus` (Attributes) Instance CPU count selector (e.g., 4, 8, 16) (see [below for nested schema](#nestedatt--instance_cpus)) @@ -307,6 +454,9 @@ resource "devzero_node_policy" "azure_example" { - `instance_hypervisors` (Attributes) Instance hypervisors selector (see [below for nested schema](#nestedatt--instance_hypervisors)) - `instance_hypervisors_tip` (String) Tooltip for instance hypervisors - `instance_local_nvme` (Attributes) Ephemeral NVMe storage per node in GiB (AWS only; karpenter.k8s.aws/instance-local-nvme) (see [below for nested schema](#nestedatt--instance_local_nvme)) +- `instance_local_nvme_tip` (String) Tooltip for instance local NVMe +- `instance_shapes` (Attributes) Instance shapes selector (GCP only, e.g., custom shape tokens) (see [below for nested schema](#nestedatt--instance_shapes)) +- `instance_shapes_tip` (String) Tooltip for instance shapes - `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)) @@ -316,10 +466,12 @@ resource "devzero_node_policy" "azure_example" { - `master_override_role_name` (String) Master override role name for Karpenter - `node_class_name` (String) Node class name - `node_pool_name` (String) Node pool name +- `oci` (Attributes) OCI-specific configuration for nodes provisioned with this policy. (see [below for nested schema](#nestedatt--oci)) - `operating_systems` (Attributes) Operating systems selector (e.g., linux, windows) (see [below for nested schema](#nestedatt--operating_systems)) - `operating_systems_tip` (String) Tooltip for operating systems - `raw` (Attributes List) Raw Karpenter NodePool and NodeClass YAML specifications for advanced use cases. (see [below for nested schema](#nestedatt--raw)) - `startup_taints` (Attributes List) List of Kubernetes taints applied to nodes only while they start up (Karpenter `startupTaints`). Removed once the node is ready. (see [below for nested schema](#nestedatt--startup_taints)) +- `startup_taints_tip` (String) Tooltip for startup taints - `taints` (Attributes List) List of Kubernetes taints to apply to nodes provisioned with this policy. (see [below for nested schema](#nestedatt--taints)) - `taints_tip` (String) Tooltip for taints - `weight` (Number) Priority weight for this node policy. Higher weights are preferred when multiple policies match. Default: 10 (medium priority). @@ -384,6 +536,7 @@ Optional: - `id` (String) AMI ID - `name` (String) AMI name - `owner` (String) AMI owner +- `ssm_parameter` (String) SSM parameter path used to resolve the AMI ID (e.g., `/aws/service/eks/optimized-ami/...`). - `tags` (Map of String) AMI tags selector @@ -394,6 +547,7 @@ Optional: - `device_name` (String) Device name (e.g., /dev/xvda) - `ebs` (Attributes) EBS volume configuration (see [below for nested schema](#nestedatt--aws--block_device_mappings--ebs)) +- `root_volume` (Boolean) Whether this mapping targets the root volume ### Nested Schema for `aws.block_device_mappings.ebs` @@ -406,6 +560,7 @@ Optional: - `kms_key_id` (String) KMS key ID for encryption - `snapshot_id` (String) Snapshot ID to create volume from - `throughput` (Number) Throughput in MiB/s for gp3 volumes +- `volume_initialization_rate` (Number) Initialization rate for the EBS volume in MiB/s, used when restoring from a snapshot. - `volume_size` (String) Volume size (e.g., '100Gi') - `volume_type` (String) Volume type (gp2, gp3, io1, io2, sc1, st1) @@ -549,6 +704,61 @@ Optional: + +### Nested Schema for `gcp` + +Optional: + +- `disks` (Attributes List) Disks to attach to nodes (see [below for nested schema](#nestedatt--gcp--disks)) +- `image_family` (String) Image family +- `image_selector_terms` (Attributes List) Image selector terms (see [below for nested schema](#nestedatt--gcp--image_selector_terms)) +- `kubelet` (Attributes) Kubelet configuration overrides applied to nodes launched by this policy. (see [below for nested schema](#nestedatt--gcp--kubelet)) +- `labels` (Map of String) GCP instance labels +- `metadata` (Map of String) GCP instance metadata +- `network_tags` (List of String) GCP network tags +- `service_account` (String) GCP service account email to attach to nodes + + +### Nested Schema for `gcp.disks` + +Optional: + +- `boot` (Boolean) Whether this is the boot disk +- `category` (String) Disk category (e.g. pd-standard, pd-ssd, pd-balanced) +- `secondary_boot_image` (String) Secondary boot image reference +- `secondary_boot_mode` (String) Secondary boot mode +- `size_gib` (Number) Disk size in GiB + + + +### Nested Schema for `gcp.image_selector_terms` + +Optional: + +- `alias` (String) Image alias +- `id` (String) Image ID + + + +### Nested Schema for `gcp.kubelet` + +Optional: + +- `cluster_dns` (List of String) Cluster DNS server IPs +- `cpu_cfs_quota` (Boolean) Enable CPU CFS quota enforcement for containers that specify CPU limits +- `eviction_hard` (Map of String) Hard eviction thresholds (e.g. memory.available = 100Mi) +- `eviction_max_pod_grace_period` (Number) Maximum pod termination grace period (seconds) used on soft eviction +- `eviction_soft` (Map of String) Soft eviction thresholds +- `eviction_soft_grace_period` (Map of String) Grace periods for soft eviction thresholds +- `image_gc_high_threshold_percent` (Number) Disk usage percentage above which image garbage collection runs +- `image_gc_low_threshold_percent` (Number) Disk usage percentage below which image garbage collection stops +- `kube_reserved` (Map of String) Resources reserved for Kubernetes system daemons +- `max_pods` (Number) Maximum number of pods per node +- `pods_per_core` (Number) Maximum pods per CPU core +- `system_reserved` (Map of String) Resources reserved for system daemons (e.g. cpu, memory, ephemeral-storage) + + + ### Nested Schema for `instance_categories` @@ -681,6 +891,28 @@ Optional: + +### Nested Schema for `instance_shapes` + +Optional: + +- `match_expressions` (Attributes List) List of label selector requirements (see [below for nested schema](#nestedatt--instance_shapes--match_expressions)) +- `match_labels` (Map of String) Map of label key-value pairs to match + + +### Nested Schema for `instance_shapes.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: + +- `values` (List of String) List of values for In/NotIn operators + + + ### Nested Schema for `instance_sizes` @@ -734,6 +966,85 @@ Optional: - `memory` (String) Maximum memory limit for nodes (e.g., '512Gi', '1Ti'). + +### Nested Schema for `oci` + +Optional: + +- `agent_list` (List of String) Oracle Cloud Agent plugins to enable +- `block_devices` (Attributes List) Additional block volumes to attach (see [below for nested schema](#nestedatt--oci--block_devices)) +- `boot_config` (Attributes) Boot volume configuration (see [below for nested schema](#nestedatt--oci--boot_config)) +- `free_form_tags` (Map of String) OCI free-form tags to apply to instances +- `image_family` (String) Image family +- `image_selector` (Attributes List) Image selector terms (see [below for nested schema](#nestedatt--oci--image_selector)) +- `launch_options` (Attributes) Instance launch options (see [below for nested schema](#nestedatt--oci--launch_options)) +- `meta_data` (Map of String) OCI instance metadata +- `pre_install_script` (String) Script to run before installation +- `security_group_selector` (Attributes List) Security group (NSG) selector terms (see [below for nested schema](#nestedatt--oci--security_group_selector)) +- `subnet_selector` (Attributes List) Subnet selector terms (see [below for nested schema](#nestedatt--oci--subnet_selector)) +- `tags` (Map of String) OCI defined tags to apply to instances +- `user_data` (String) User data script for instance initialization +- `vcn_id` (String) OCI VCN ID + + +### Nested Schema for `oci.block_devices` + +Optional: + +- `size_in_gbs` (Number) Volume size in GB +- `vpus_per_gb` (Number) Volume performance units per GB + + + +### Nested Schema for `oci.boot_config` + +Optional: + +- `boot_volume_size_in_gbs` (Number) Boot volume size in GB +- `boot_volume_vpus_per_gb` (Number) Boot volume performance units per GB + + + +### Nested Schema for `oci.image_selector` + +Optional: + +- `compartment_id` (String) Compartment ID the image belongs to +- `id` (String) Image ID +- `name` (String) Image name + + + +### Nested Schema for `oci.launch_options` + +Optional: + +- `boot_volume_type` (String) Boot volume attachment type +- `firmware` (String) Firmware type +- `is_consistent_volume_naming_enabled` (Boolean) Enable consistent volume naming +- `network_type` (String) Network attachment type +- `remote_data_volume_type` (String) Remote data volume attachment type + + + +### Nested Schema for `oci.security_group_selector` + +Optional: + +- `id` (String) Security group ID +- `name` (String) Security group name + + + +### Nested Schema for `oci.subnet_selector` + +Optional: + +- `id` (String) Subnet ID +- `name` (String) Subnet name + + + ### Nested Schema for `operating_systems` diff --git a/docs/resources/workload_policy.md b/docs/resources/workload_policy.md index adf6b70..c6c447d 100644 --- a/docs/resources/workload_policy.md +++ b/docs/resources/workload_policy.md @@ -58,6 +58,14 @@ resource "devzero_workload_policy" "cost_saving" { enable_pmax_protection = true # guard against spike-induced OOMKills pmax_ratio_threshold = 3 # raise requests when peak is 3× the recommendation + + emergency_response = { + oom_enabled = true + oom_memory_multiplier = 1.5 + cpu_throttling_enabled = true + cpu_throttling_threshold = 0.20 + cpu_throttling_multiplier = 1.25 + } } ``` @@ -83,6 +91,7 @@ resource "devzero_workload_policy" "cost_saving" { - `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. 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 +- `emergency_response` (Attributes) Emergency response configuration for OOM and CPU throttle events (see [below for nested schema](#nestedatt--emergency_response)) - `enable_in_place_vertical_scaling` (Boolean) When true, vertical recommendations are applied in place (without recreating pods) where the cluster supports it. Default: false. - `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)) @@ -139,6 +148,20 @@ Optional: - `target_percentile` (Number) Target percentile for resource sizing (e.g., 0.75 = P75). + +### Nested Schema for `emergency_response` + +Optional: + +- `cpu_throttling_enabled` (Boolean) React to CPU throttling by increasing CPU request +- `cpu_throttling_multiplier` (Number) Multiplier applied to CPU request on throttle reaction +- `cpu_throttling_threshold` (Number) Throttle ratio threshold that triggers a reaction (0-1) +- `oom_cooldown_seconds` (Number) Seconds to wait between OOM reactions +- `oom_enabled` (Boolean) React to OOM kills by increasing memory +- `oom_max_reactions` (Number) Maximum number of OOM reactions before giving up +- `oom_memory_multiplier` (Number) Multiplier applied to memory on OOM + + ### Nested Schema for `gpu_vertical_scaling` diff --git a/docs/resources/workload_rule.md b/docs/resources/workload_rule.md index 02cf2ad..ccd30d8 100644 --- a/docs/resources/workload_rule.md +++ b/docs/resources/workload_rule.md @@ -81,8 +81,44 @@ resource "devzero_workload_rule" "manual" { cpu_throttling_multiplier = 1.25 } - live_migration_enabled = false - use_in_place_vertical_scaling = false + # JVM heap sizing (only applies when the workload is detected as running a JVM) + jvm_heap_rule = { + enabled = true + target_percentile = 0.95 + headroom_multiplier = 1.2 + non_heap_overhead_percent = 0.15 + min_heap_bytes = 268435456 # 256Mi + max_heap_bytes = 4294967296 # 4Gi + prefer_container_support = false + } + jvm_cpu_startup_floor_millicores = 250 # override the 75m default while the JVM warms up + + # Hand the ScaledObject lifecycle to KEDA instead of generating an HPA + keda_scaled_object = { + min_replica_count = 1 + max_replica_count = 20 + cooldown_period = 300 + + triggers = [ + { + type = "prometheus" + metadata = { + serverAddress = "http://prometheus.monitoring.svc.cluster.local:9090" + query = "rate(http_requests_total{job=\"my-api\"}[5m])" + threshold = "100" + } + } + ] + + fallback = { + failure_threshold = 3 + replicas = 2 + } + } + + live_migration_enabled = false + use_in_place_vertical_scaling = false + allow_in_place_memory_limit_decrease = false } # Per-container rules @@ -133,6 +169,7 @@ resource "devzero_workload_rule" "per_container" { ### Optional - `action_triggers` (List of String) When to apply recommendations. Valid values: 'on_detection', 'on_schedule' +- `allow_in_place_memory_limit_decrease` (Boolean) Opt-in: allow an in-place resize to lower a container's memory limit. Only consulted when `use_in_place_vertical_scaling` is true; a decrease still additionally requires a cluster new enough to accept one. Default false because shrinking a live container's memory limit can OOM-kill it. - `auto_generate` (Boolean) When true the engine generates all rule fields automatically; manual field overrides are ignored - `containers` (Attributes List) Per-container resource rule configurations. When empty, workload-level rules apply to all containers. (see [below for nested schema](#nestedatt--containers)) - `cpu_rule` (Attributes) CPU vertical scaling rule configuration (see [below for nested schema](#nestedatt--cpu_rule)) @@ -143,6 +180,9 @@ resource "devzero_workload_rule" "per_container" { - `emergency_response` (Attributes) Emergency response configuration for OOM and CPU throttle events (see [below for nested schema](#nestedatt--emergency_response)) - `gpu_rule` (Attributes) GPU vertical scaling rule configuration (see [below for nested schema](#nestedatt--gpu_rule)) - `hpa_rule` (Attributes) Horizontal (replica) scaling rule configuration (see [below for nested schema](#nestedatt--hpa_rule)) +- `jvm_cpu_startup_floor_millicores` (Number) Per-rule override of the JVM CPU startup floor in millicores. Unset inherits the policy/system default (75m); explicit `0` disables the floor for this rule. Always-on for detected JVMs, independent of `jvm_heap_rule.enabled`. +- `jvm_heap_rule` (Attributes) JVM heap optimization overrides for this rule (see [below for nested schema](#nestedatt--jvm_heap_rule)) +- `keda_scaled_object` (Attributes) KEDA ScaledObject template authored by the user. When set, the in-cluster operator owns the ScaledObject lifecycle (create/update/delete) instead of generating its own HPA. (see [below for nested schema](#nestedatt--keda_scaled_object)) - `live_migration_enabled` (Boolean) Allow live pod migration when applying recommendations - `lookback_period_seconds` (Number) Per-rule override of the metrics lookback window in seconds. Unset inherits the team default (7 days). Minimum 3600 (1h), maximum 2592000 (30d); higher tiers may be capped server-side. - `memory_rule` (Attributes) Memory vertical scaling rule configuration (see [below for nested schema](#nestedatt--memory_rule)) @@ -370,6 +410,84 @@ Optional: + +### Nested Schema for `jvm_heap_rule` + +Optional: + +- `enabled` (Boolean) Enable JVM heap optimization +- `headroom_multiplier` (Number) Multiplier applied to the target heap usage to derive the recommended max heap +- `max_heap_bytes` (Number) Maximum recommended max heap size in bytes +- `min_heap_bytes` (Number) Minimum recommended max heap size in bytes +- `non_heap_overhead_bytes` (Number) Non-heap memory overhead in bytes, added on top of non_heap_overhead_percent +- `non_heap_overhead_percent` (Number) Non-heap memory overhead as a percentage of heap size +- `prefer_container_support` (Boolean) Prefer the JVM's own container-aware ergonomics (-XX:+UseContainerSupport) over an explicit -Xmx +- `target_percentile` (Number) Percentile of heap usage data used as the recommendation target (0-1) + + + +### Nested Schema for `keda_scaled_object` + +Optional: + +- `advanced` (Attributes) Advanced KEDA ScaledObject settings (see [below for nested schema](#nestedatt--keda_scaled_object--advanced)) +- `cooldown_period` (Number) Seconds to wait after the last trigger reported active before scaling down to idle/min replicas +- `fallback` (Attributes) Replica fallback configuration when the scaler's metrics are unavailable (see [below for nested schema](#nestedatt--keda_scaled_object--fallback)) +- `idle_replica_count` (Number) Number of replicas to scale down to when idle +- `initial_cooldown_period` (Number) Cooldown period applied only on initial ScaledObject creation +- `max_replica_count` (Number) Maximum number of replicas +- `min_replica_count` (Number) Minimum number of replicas +- `polling_interval` (Number) Seconds between checks of the trigger sources +- `triggers` (Attributes List) KEDA scale triggers (see [below for nested schema](#nestedatt--keda_scaled_object--triggers)) + + +### Nested Schema for `keda_scaled_object.advanced` + +Optional: + +- `advanced_behavior_json` (String) Opaque JSON-encoded Kubernetes `HorizontalPodAutoscalerBehavior`, carried through verbatim so this provider never has to re-model Kubernetes autoscaling types. +- `restore_to_original_replica_count` (Boolean) Restore the original replica count when the ScaledObject is deleted + + + +### Nested Schema for `keda_scaled_object.fallback` + +Optional: + +- `behavior` (String) Fallback strategy +- `failure_threshold` (Number) Number of consecutive metric failures before activating fallback +- `replicas` (Number) Number of replicas to fall back to when metrics are unavailable + + + +### Nested Schema for `keda_scaled_object.triggers` + +Required: + +- `type` (String) KEDA scaler type. Example: 'prometheus', 'cpu', 'kafka' + +Optional: + +- `authentication_ref` (Attributes) Reference to a KEDA TriggerAuthentication/ClusterTriggerAuthentication (see [below for nested schema](#nestedatt--keda_scaled_object--triggers--authentication_ref)) +- `metadata` (Map of String) Scaler-specific metadata, as required by the chosen KEDA scaler type +- `metric_type` (String) Metric target type. One of: 'Value', 'AverageValue', 'Utilization' +- `name` (String) Trigger name +- `use_cached_metrics` (Boolean) Use KEDA's cached metrics for this trigger + + +### Nested Schema for `keda_scaled_object.triggers.authentication_ref` + +Required: + +- `name` (String) Name of the referenced authentication resource + +Optional: + +- `kind` (String) Kind of the referenced authentication resource. One of: 'TriggerAuthentication', 'ClusterTriggerAuthentication' + + + + ### Nested Schema for `memory_rule` diff --git a/examples/resources/devzero_node_policy/resource.tf b/examples/resources/devzero_node_policy/resource.tf index d2e673b..a0c5af9 100644 --- a/examples/resources/devzero_node_policy/resource.tf +++ b/examples/resources/devzero_node_policy/resource.tf @@ -121,6 +121,10 @@ resource "devzero_node_policy" "aws_comprehensive" { ami_selector_terms = [ { alias = "al2023@latest" + }, + { + # Resolve the AMI ID from an SSM public parameter instead of an alias + ssm_parameter = "/aws/service/eks/optimized-ami/1.29/amazon-linux-2/recommended/image_id" } ] @@ -153,11 +157,13 @@ resource "devzero_node_policy" "aws_comprehensive" { block_device_mappings = [ { device_name = "/dev/xvda" + root_volume = true ebs = { - volume_size = "100Gi" - volume_type = "gp3" - encrypted = true - delete_on_termination = true + volume_size = "100Gi" + volume_type = "gp3" + encrypted = true + delete_on_termination = true + volume_initialization_rate = 100 # MiB/s to pre-warm the volume from its snapshot } } ] @@ -260,3 +266,143 @@ resource "devzero_node_policy" "azure_example" { } } } + +# GCP example +resource "devzero_node_policy" "gcp_example" { + name = "gcp-production" + description = "Production-ready GCP node policy" + node_pool_name = "production-pool" + node_class_name = "production-class" + weight = 10 + + # GCP-only: select nodes by custom machine shape tokens + instance_shapes = { + match_expressions = [{ + key = "instanceShapes" + operator = "In" + values = ["custom"] + }] + } + + architectures = { + match_expressions = [{ + key = "architectures" + operator = "In" + values = ["amd64"] + }] + } + + capacity_types = { + match_expressions = [{ + key = "capacityTypes" + operator = "In" + values = ["spot", "on-demand"] + }] + } + + labels = { + "dedicated" = "karpenter" + } + + disruption = { + consolidate_after = "5m" + consolidation_policy = "WhenEmptyOrUnderutilized" + expire_after = "168h" # 7 days + } + + # GCP-specific configuration + gcp = { + service_account = "karpenter@my-project.iam.gserviceaccount.com" + + image_selector_terms = [ + { + alias = "ubuntu-2204-lts" + } + ] + image_family = "ubuntu" + + labels = { + "environment" = "production" + } + network_tags = ["allow-ssh", "allow-health-checks"] + + disks = [ + { + size_gib = 100 + category = "pd-ssd" + boot = true + } + ] + + kubelet = { + max_pods = 110 + } + } +} + +# OCI example +resource "devzero_node_policy" "oci_example" { + name = "oci-production" + description = "Production-ready OCI node policy" + node_pool_name = "production-pool" + node_class_name = "production-class" + weight = 10 + + architectures = { + match_expressions = [{ + key = "architectures" + operator = "In" + values = ["amd64"] + }] + } + + disruption = { + consolidate_after = "5m" + consolidation_policy = "WhenEmptyOrUnderutilized" + expire_after = "168h" # 7 days + } + + # OCI-specific configuration + oci = { + vcn_id = "ocid1.vcn.oc1..aaaaaaaaexample" + + image_selector = [ + { + name = "Oracle-Linux-8.9-2024.05.15-0" + } + ] + image_family = "oracle-linux-8" + + subnet_selector = [ + { + name = "production-subnet" + } + ] + security_group_selector = [ + { + name = "production-node-sg" + } + ] + + free_form_tags = { + "Environment" = "production" + } + + boot_config = { + boot_volume_size_in_gbs = 100 + boot_volume_vpus_per_gb = 10 + } + + launch_options = { + boot_volume_type = "PARAVIRTUALIZED" + is_consistent_volume_naming_enabled = true + } + + block_devices = [ + { + size_in_gbs = 50 + vpus_per_gb = 10 + } + ] + } +} diff --git a/examples/resources/devzero_workload_policy/resource.tf b/examples/resources/devzero_workload_policy/resource.tf index f74b9c5..4123fff 100644 --- a/examples/resources/devzero_workload_policy/resource.tf +++ b/examples/resources/devzero_workload_policy/resource.tf @@ -43,4 +43,12 @@ resource "devzero_workload_policy" "cost_saving" { enable_pmax_protection = true # guard against spike-induced OOMKills pmax_ratio_threshold = 3 # raise requests when peak is 3× the recommendation + + emergency_response = { + oom_enabled = true + oom_memory_multiplier = 1.5 + cpu_throttling_enabled = true + cpu_throttling_threshold = 0.20 + cpu_throttling_multiplier = 1.25 + } } \ No newline at end of file diff --git a/examples/resources/devzero_workload_rule/resource.tf b/examples/resources/devzero_workload_rule/resource.tf index 62041de..f66d971 100644 --- a/examples/resources/devzero_workload_rule/resource.tf +++ b/examples/resources/devzero_workload_rule/resource.tf @@ -66,8 +66,44 @@ resource "devzero_workload_rule" "manual" { cpu_throttling_multiplier = 1.25 } - live_migration_enabled = false - use_in_place_vertical_scaling = false + # JVM heap sizing (only applies when the workload is detected as running a JVM) + jvm_heap_rule = { + enabled = true + target_percentile = 0.95 + headroom_multiplier = 1.2 + non_heap_overhead_percent = 0.15 + min_heap_bytes = 268435456 # 256Mi + max_heap_bytes = 4294967296 # 4Gi + prefer_container_support = false + } + jvm_cpu_startup_floor_millicores = 250 # override the 75m default while the JVM warms up + + # Hand the ScaledObject lifecycle to KEDA instead of generating an HPA + keda_scaled_object = { + min_replica_count = 1 + max_replica_count = 20 + cooldown_period = 300 + + triggers = [ + { + type = "prometheus" + metadata = { + serverAddress = "http://prometheus.monitoring.svc.cluster.local:9090" + query = "rate(http_requests_total{job=\"my-api\"}[5m])" + threshold = "100" + } + } + ] + + fallback = { + failure_threshold = 3 + replicas = 2 + } + } + + live_migration_enabled = false + use_in_place_vertical_scaling = false + allow_in_place_memory_limit_decrease = false } # Per-container rules diff --git a/internal/provider/node_policy.go b/internal/provider/node_policy.go index 49508ab..7dcb451 100644 --- a/internal/provider/node_policy.go +++ b/internal/provider/node_policy.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/int64default" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" @@ -51,12 +52,14 @@ type NodePolicyResourceModel struct { InstanceGenerations *LabelSelector `tfsdk:"instance_generations"` InstanceSizes *LabelSelector `tfsdk:"instance_sizes"` InstanceTypes *LabelSelector `tfsdk:"instance_types"` + InstanceShapes *LabelSelector `tfsdk:"instance_shapes"` InstanceCategoriesTip types.String `tfsdk:"instance_categories_tip"` InstanceFamiliesTip types.String `tfsdk:"instance_families_tip"` InstanceCpusTip types.String `tfsdk:"instance_cpus_tip"` InstanceHypervisorsTip types.String `tfsdk:"instance_hypervisors_tip"` InstanceGenerationsTip types.String `tfsdk:"instance_generations_tip"` InstanceSizesTip types.String `tfsdk:"instance_sizes_tip"` + InstanceShapesTip types.String `tfsdk:"instance_shapes_tip"` Zones *LabelSelector `tfsdk:"zones"` Architectures *LabelSelector `tfsdk:"architectures"` CapacityTypes *LabelSelector `tfsdk:"capacity_types"` @@ -68,6 +71,7 @@ type NodePolicyResourceModel struct { Labels types.Map `tfsdk:"labels"` Taints types.List `tfsdk:"taints"` // List of Taint objects StartupTaints types.List `tfsdk:"startup_taints"` // List of Taint objects + StartupTaintsTip types.String `tfsdk:"startup_taints_tip"` Disruption *DisruptionPolicy `tfsdk:"disruption"` Limits *ResourceLimits `tfsdk:"limits"` TaintsTip types.String `tfsdk:"taints_tip"` @@ -78,8 +82,11 @@ type NodePolicyResourceModel struct { NodeClassName types.String `tfsdk:"node_class_name"` Aws *AWSNodeClass `tfsdk:"aws"` Azure *AzureNodeClass `tfsdk:"azure"` + Gcp *GCPNodeClass `tfsdk:"gcp"` + Oci *OCINodeClass `tfsdk:"oci"` ZonalShift *ZonalShiftConfig `tfsdk:"zonal_shift"` InstanceLocalNvme *LabelSelector `tfsdk:"instance_local_nvme"` + InstanceLocalNvmeTip types.String `tfsdk:"instance_local_nvme_tip"` CloudProviderId types.Int64 `tfsdk:"cloud_provider_id"` Raw types.List `tfsdk:"raw"` // List of RawKarpenterSpec objects } @@ -192,6 +199,51 @@ type AzureNodeClass struct { ImageVersion types.String `tfsdk:"image_version"` } +// GCPNodeClass defines GCP-specific node configuration. +type GCPNodeClass struct { + ServiceAccount types.String `tfsdk:"service_account"` + ImageSelectorTerms types.List `tfsdk:"image_selector_terms"` // List of {alias, id} + ImageFamily types.String `tfsdk:"image_family"` + Kubelet *KubeletConfiguration `tfsdk:"kubelet"` + Labels types.Map `tfsdk:"labels"` + Metadata types.Map `tfsdk:"metadata"` + NetworkTags types.List `tfsdk:"network_tags"` // List of strings + Disks types.List `tfsdk:"disks"` // List of {size_gib, category, boot, secondary_boot_image, secondary_boot_mode} +} + +// OCINodeClass defines OCI-specific node configuration. +type OCINodeClass struct { + VcnId types.String `tfsdk:"vcn_id"` + ImageSelector types.List `tfsdk:"image_selector"` // List of {id, name, compartment_id} + SubnetSelector types.List `tfsdk:"subnet_selector"` // List of {id, name} + SecurityGroupSelector types.List `tfsdk:"security_group_selector"` // List of {id, name} + UserData types.String `tfsdk:"user_data"` + PreInstallScript types.String `tfsdk:"pre_install_script"` + MetaData types.Map `tfsdk:"meta_data"` + ImageFamily types.String `tfsdk:"image_family"` + Tags types.Map `tfsdk:"tags"` + FreeFormTags types.Map `tfsdk:"free_form_tags"` + BootConfig *OCIBootConfig `tfsdk:"boot_config"` + LaunchOptions *OCILaunchOptions `tfsdk:"launch_options"` + BlockDevices types.List `tfsdk:"block_devices"` // List of {size_in_gbs, vpus_per_gb} + AgentList types.List `tfsdk:"agent_list"` // List of strings +} + +// OCIBootConfig defines OCI boot volume configuration. +type OCIBootConfig struct { + BootVolumeSizeInGbs types.Int64 `tfsdk:"boot_volume_size_in_gbs"` + BootVolumeVpusPerGb types.Int64 `tfsdk:"boot_volume_vpus_per_gb"` +} + +// OCILaunchOptions defines OCI instance launch options. +type OCILaunchOptions struct { + BootVolumeType types.String `tfsdk:"boot_volume_type"` + Firmware types.String `tfsdk:"firmware"` + NetworkType types.String `tfsdk:"network_type"` + RemoteDataVolumeType types.String `tfsdk:"remote_data_volume_type"` + IsConsistentVolumeNamingEnabled types.Bool `tfsdk:"is_consistent_volume_naming_enabled"` +} + // RawKarpenterSpec defines raw Karpenter YAML specs. type RawKarpenterSpec struct { NodepoolYaml types.String `tfsdk:"nodepool_yaml"` @@ -242,6 +294,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ "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)"), + "instance_shapes": labelSelectorAttribute("Instance shapes selector (GCP only, e.g., custom shape tokens)"), // Tooltip fields for instance selectors "instance_categories_tip": tooltipAttribute("Tooltip for instance categories"), "instance_families_tip": tooltipAttribute("Tooltip for instance families"), @@ -249,6 +302,7 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ "instance_hypervisors_tip": tooltipAttribute("Tooltip for instance hypervisors"), "instance_generations_tip": tooltipAttribute("Tooltip for instance generations"), "instance_sizes_tip": tooltipAttribute("Tooltip for instance sizes"), + "instance_shapes_tip": tooltipAttribute("Tooltip for instance shapes"), // Additional selectors "zones": labelSelectorAttribute("Availability zones selector"), "architectures": labelSelectorAttribute("CPU architectures selector (e.g., amd64, arm64)"), @@ -335,7 +389,8 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ }, }, }, - "instance_local_nvme": labelSelectorAttribute("Ephemeral NVMe storage per node in GiB (AWS only; karpenter.k8s.aws/instance-local-nvme)"), + "instance_local_nvme": labelSelectorAttribute("Ephemeral NVMe storage per node in GiB (AWS only; karpenter.k8s.aws/instance-local-nvme)"), + "instance_local_nvme_tip": tooltipAttribute("Tooltip for instance local NVMe"), "cloud_provider_id": schema.Int64Attribute{ Description: "Cloud provider ID this policy is intended for (informational)", MarkdownDescription: "Cloud provider ID this policy is intended for: `1` = AWS, `2` = Azure, `3` = GCP, `4` = OCI. Informational/UI filter — compilation always uses the target cluster's provider.", @@ -427,9 +482,10 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ }, }, // Tooltips for node configuration - "taints_tip": tooltipAttribute("Tooltip for taints"), - "disruptions_tip": tooltipAttribute("Tooltip for disruptions"), - "limits_tip": tooltipAttribute("Tooltip for limits"), + "taints_tip": tooltipAttribute("Tooltip for taints"), + "startup_taints_tip": tooltipAttribute("Tooltip for startup taints"), + "disruptions_tip": tooltipAttribute("Tooltip for disruptions"), + "limits_tip": tooltipAttribute("Tooltip for limits"), // Karpenter naming "master_override_role_name": schema.StringAttribute{ Description: "Master override role name for Karpenter", @@ -514,6 +570,11 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Description: "AMI alias", Optional: true, }, + "ssm_parameter": schema.StringAttribute{ + Description: "SSM parameter path to resolve the AMI ID from", + MarkdownDescription: "SSM parameter path used to resolve the AMI ID (e.g., `/aws/service/eks/optimized-ami/...`).", + Optional: true, + }, "tags": schema.MapAttribute{ Description: "AMI tags selector", Optional: true, @@ -552,6 +613,10 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Description: "Device name (e.g., /dev/xvda)", Optional: true, }, + "root_volume": schema.BoolAttribute{ + Description: "Whether this mapping targets the root volume", + Optional: true, + }, "ebs": schema.SingleNestedAttribute{ Description: "EBS volume configuration", Optional: true, @@ -588,6 +653,11 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ Description: "Encrypt the volume", Optional: true, }, + "volume_initialization_rate": schema.Int32Attribute{ + Description: "EBS volume initialization (fast snapshot restore) rate in MiB/s", + MarkdownDescription: "Initialization rate for the EBS volume in MiB/s, used when restoring from a snapshot.", + Optional: true, + }, }, }, }, @@ -615,6 +685,20 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ MarkdownDescription: "Configuration for EC2 instance metadata service. Defaults provide secure IMDS v2 configuration.", Optional: true, Computed: true, + Default: objectdefault.StaticValue(types.ObjectValueMust( + map[string]attr.Type{ + "http_endpoint": types.StringType, + "http_protocol_ipv6": types.StringType, + "http_put_response_hop_limit": types.Int64Type, + "http_tokens": types.StringType, + }, + map[string]attr.Value{ + "http_endpoint": types.StringValue("enabled"), + "http_protocol_ipv6": types.StringValue("disabled"), + "http_put_response_hop_limit": types.Int64Value(2), + "http_tokens": types.StringValue("required"), + }, + )), Attributes: map[string]schema.Attribute{ "http_endpoint": schema.StringAttribute{ Description: "Enable or disable the HTTP metadata endpoint", @@ -826,6 +910,294 @@ func (r *NodePolicyResource) Schema(ctx context.Context, req resource.SchemaRequ }, }, }, + // GCP provider configuration + "gcp": schema.SingleNestedAttribute{ + Description: "GCP-specific node configuration", + MarkdownDescription: "GCP-specific configuration for nodes provisioned with this policy.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "service_account": schema.StringAttribute{ + Description: "GCP service account email to attach to nodes", + Optional: true, + }, + "image_selector_terms": schema.ListNestedAttribute{ + Description: "Image selector terms", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "alias": schema.StringAttribute{ + Description: "Image alias", + Optional: true, + }, + "id": schema.StringAttribute{ + Description: "Image ID", + Optional: true, + }, + }, + }, + }, + "image_family": schema.StringAttribute{ + Description: "Image family", + Optional: true, + }, + "kubelet": schema.SingleNestedAttribute{ + Description: "Kubelet configuration overrides", + MarkdownDescription: "Kubelet configuration overrides applied to nodes launched by this policy.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "cluster_dns": schema.ListAttribute{ + Description: "Cluster DNS server IPs", + Optional: true, + ElementType: types.StringType, + }, + "max_pods": schema.Int32Attribute{ + Description: "Maximum number of pods per node", + Optional: true, + }, + "pods_per_core": schema.Int32Attribute{ + Description: "Maximum pods per CPU core", + Optional: true, + }, + "system_reserved": schema.MapAttribute{ + Description: "Resources reserved for system daemons (e.g. cpu, memory, ephemeral-storage)", + Optional: true, + ElementType: types.StringType, + }, + "kube_reserved": schema.MapAttribute{ + Description: "Resources reserved for Kubernetes system daemons", + Optional: true, + ElementType: types.StringType, + }, + "eviction_hard": schema.MapAttribute{ + Description: "Hard eviction thresholds (e.g. memory.available = 100Mi)", + Optional: true, + ElementType: types.StringType, + }, + "eviction_soft": schema.MapAttribute{ + Description: "Soft eviction thresholds", + Optional: true, + ElementType: types.StringType, + }, + "eviction_soft_grace_period": schema.MapAttribute{ + Description: "Grace periods for soft eviction thresholds", + Optional: true, + ElementType: types.StringType, + }, + "eviction_max_pod_grace_period": schema.Int32Attribute{ + Description: "Maximum pod termination grace period (seconds) used on soft eviction", + Optional: true, + }, + "image_gc_high_threshold_percent": schema.Int32Attribute{ + Description: "Disk usage percentage above which image garbage collection runs", + Optional: true, + }, + "image_gc_low_threshold_percent": schema.Int32Attribute{ + Description: "Disk usage percentage below which image garbage collection stops", + Optional: true, + }, + "cpu_cfs_quota": schema.BoolAttribute{ + Description: "Enable CPU CFS quota enforcement for containers that specify CPU limits", + Optional: true, + }, + }, + }, + "labels": schema.MapAttribute{ + Description: "GCP instance labels", + Optional: true, + ElementType: types.StringType, + }, + "metadata": schema.MapAttribute{ + Description: "GCP instance metadata", + Optional: true, + ElementType: types.StringType, + }, + "network_tags": schema.ListAttribute{ + Description: "GCP network tags", + Optional: true, + ElementType: types.StringType, + }, + "disks": schema.ListNestedAttribute{ + Description: "Disks to attach to nodes", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "size_gib": schema.Int32Attribute{ + Description: "Disk size in GiB", + Optional: true, + }, + "category": schema.StringAttribute{ + Description: "Disk category (e.g. pd-standard, pd-ssd, pd-balanced)", + Optional: true, + }, + "boot": schema.BoolAttribute{ + Description: "Whether this is the boot disk", + Optional: true, + }, + "secondary_boot_image": schema.StringAttribute{ + Description: "Secondary boot image reference", + Optional: true, + }, + "secondary_boot_mode": schema.StringAttribute{ + Description: "Secondary boot mode", + Optional: true, + }, + }, + }, + }, + }, + }, + // OCI provider configuration + "oci": schema.SingleNestedAttribute{ + Description: "OCI-specific node configuration", + MarkdownDescription: "OCI-specific configuration for nodes provisioned with this policy.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "vcn_id": schema.StringAttribute{ + Description: "OCI VCN ID", + Optional: true, + }, + "image_selector": schema.ListNestedAttribute{ + Description: "Image selector terms", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Image ID", + Optional: true, + }, + "name": schema.StringAttribute{ + Description: "Image name", + Optional: true, + }, + "compartment_id": schema.StringAttribute{ + Description: "Compartment ID the image belongs to", + Optional: true, + }, + }, + }, + }, + "subnet_selector": schema.ListNestedAttribute{ + Description: "Subnet selector terms", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Subnet ID", + Optional: true, + }, + "name": schema.StringAttribute{ + Description: "Subnet name", + Optional: true, + }, + }, + }, + }, + "security_group_selector": schema.ListNestedAttribute{ + Description: "Security group (NSG) selector terms", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "Security group ID", + Optional: true, + }, + "name": schema.StringAttribute{ + Description: "Security group name", + Optional: true, + }, + }, + }, + }, + "user_data": schema.StringAttribute{ + Description: "User data script for instance initialization", + Optional: true, + }, + "pre_install_script": schema.StringAttribute{ + Description: "Script to run before installation", + Optional: true, + }, + "meta_data": schema.MapAttribute{ + Description: "OCI instance metadata", + Optional: true, + ElementType: types.StringType, + }, + "image_family": schema.StringAttribute{ + Description: "Image family", + Optional: true, + }, + "tags": schema.MapAttribute{ + Description: "OCI defined tags to apply to instances", + Optional: true, + ElementType: types.StringType, + }, + "free_form_tags": schema.MapAttribute{ + Description: "OCI free-form tags to apply to instances", + Optional: true, + ElementType: types.StringType, + }, + "boot_config": schema.SingleNestedAttribute{ + Description: "Boot volume configuration", + Optional: true, + Attributes: map[string]schema.Attribute{ + "boot_volume_size_in_gbs": schema.Int64Attribute{ + Description: "Boot volume size in GB", + Optional: true, + }, + "boot_volume_vpus_per_gb": schema.Int64Attribute{ + Description: "Boot volume performance units per GB", + Optional: true, + }, + }, + }, + "launch_options": schema.SingleNestedAttribute{ + Description: "Instance launch options", + Optional: true, + Attributes: map[string]schema.Attribute{ + "boot_volume_type": schema.StringAttribute{ + Description: "Boot volume attachment type", + Optional: true, + }, + "firmware": schema.StringAttribute{ + Description: "Firmware type", + Optional: true, + }, + "network_type": schema.StringAttribute{ + Description: "Network attachment type", + Optional: true, + }, + "remote_data_volume_type": schema.StringAttribute{ + Description: "Remote data volume attachment type", + Optional: true, + }, + "is_consistent_volume_naming_enabled": schema.BoolAttribute{ + Description: "Enable consistent volume naming", + Optional: true, + }, + }, + }, + "block_devices": schema.ListNestedAttribute{ + Description: "Additional block volumes to attach", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "size_in_gbs": schema.Int64Attribute{ + Description: "Volume size in GB", + Optional: true, + }, + "vpus_per_gb": schema.Int64Attribute{ + Description: "Volume performance units per GB", + Optional: true, + }, + }, + }, + }, + "agent_list": schema.ListAttribute{ + Description: "Oracle Cloud Agent plugins to enable", + Optional: true, + ElementType: types.StringType, + }, + }, + }, // Raw Karpenter specs "raw": schema.ListNestedAttribute{ Description: "Raw Karpenter YAML specifications", @@ -1135,6 +1507,14 @@ func (m *NodePolicyResourceModel) toProto(ctx context.Context, diags *diag.Diagn } policy.InstanceTypes = selector } + if m.InstanceShapes != nil { + selector, err := m.InstanceShapes.toProto(ctx) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert instance shapes: %s", err)) + return nil + } + policy.InstanceShapes = selector + } // Tooltip fields (pointers for optional) if !m.InstanceCategoriesTip.IsNull() { @@ -1161,6 +1541,10 @@ func (m *NodePolicyResourceModel) toProto(ctx context.Context, diags *diag.Diagn val := m.InstanceSizesTip.ValueString() policy.InstanceSizesTip = &val } + if !m.InstanceShapesTip.IsNull() { + val := m.InstanceShapesTip.ValueString() + policy.InstanceShapesTip = &val + } // Additional selectors if m.Zones != nil { @@ -1256,6 +1640,11 @@ func (m *NodePolicyResourceModel) toProto(ctx context.Context, diags *diag.Diagn policy.StartupTaints = startupTaints } + if !m.StartupTaintsTip.IsNull() { + val := m.StartupTaintsTip.ValueString() + policy.StartupTaintsTip = &val + } + // Zonal shift (AWS only) if m.ZonalShift != nil { policy.ZonalShift = &apiv1.ZonalShiftConfig{ @@ -1274,6 +1663,10 @@ func (m *NodePolicyResourceModel) toProto(ctx context.Context, diags *diag.Diagn } policy.InstanceLocalNvme = selector } + if !m.InstanceLocalNvmeTip.IsNull() { + val := m.InstanceLocalNvmeTip.ValueString() + policy.InstanceLocalNvmeTip = &val + } // Cloud provider id (informational) policy.CloudProviderId = m.CloudProviderId.ValueInt64Pointer() @@ -1320,6 +1713,16 @@ func (m *NodePolicyResourceModel) toProto(ctx context.Context, diags *diag.Diagn policy.Azure = m.Azure.toProto(ctx, diags) } + // GCP configuration + if m.Gcp != nil { + policy.Gcp = m.Gcp.toProto(ctx, diags) + } + + // OCI configuration + if m.Oci != nil { + policy.Oci = m.Oci.toProto(ctx, diags) + } + // Raw Karpenter specs if !m.Raw.IsNull() && !m.Raw.IsUnknown() { rawSpecs, err := getElementList(ctx, m.Raw.Elements(), func(ctx context.Context, value RawKarpenterSpec) (*apiv1.RawKarpenterSpec, error) { @@ -1391,6 +1794,9 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { if policy.InstanceTypes != nil { m.InstanceTypes = labelSelectorFromProto(policy.InstanceTypes) } + if policy.InstanceShapes != nil { + m.InstanceShapes = labelSelectorFromProto(policy.InstanceShapes) + } // Tooltip fields m.InstanceCategoriesTip = stringPointerValue(policy.InstanceCategoriesTip) @@ -1399,6 +1805,7 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { m.InstanceHypervisorsTip = stringPointerValue(policy.InstanceHypervisorsTip) m.InstanceGenerationsTip = stringPointerValue(policy.InstanceGenerationsTip) m.InstanceSizesTip = stringPointerValue(policy.InstanceSizesTip) + m.InstanceShapesTip = stringPointerValue(policy.InstanceShapesTip) // Additional selectors if policy.Zones != nil { @@ -1431,12 +1838,12 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { m.Taints = taintListFromProto(policy.Taints) // Disruption policy - if policy.Disruption != nil { + if policy.Disruption != nil && !isDisruptionEmpty(policy.Disruption) { m.Disruption = disruptionPolicyFromProto(policy.Disruption) } // Limits - if policy.Limits != nil { + if policy.Limits != nil && !isResourceLimitsEmpty(policy.Limits) { m.Limits = &ResourceLimits{ Cpu: types.StringValue(policy.Limits.Cpu), Memory: types.StringValue(policy.Limits.Memory), @@ -1446,9 +1853,10 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { // Tooltip fields for node config // Startup taints m.StartupTaints = taintListFromProto(policy.StartupTaints) + m.StartupTaintsTip = stringPointerValue(policy.StartupTaintsTip) // Zonal shift - if policy.ZonalShift != nil { + if policy.ZonalShift != nil && !isZonalShiftEmpty(policy.ZonalShift) { m.ZonalShift = &ZonalShiftConfig{ RespectZonalShift: types.BoolValue(policy.ZonalShift.RespectZonalShift), EvictImpactedNodes: types.BoolValue(policy.ZonalShift.EvictImpactedNodes), @@ -1464,6 +1872,7 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { } else { m.InstanceLocalNvme = nil } + m.InstanceLocalNvmeTip = stringPointerValue(policy.InstanceLocalNvmeTip) m.CloudProviderId = types.Int64PointerValue(policy.CloudProviderId) @@ -1486,6 +1895,16 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { m.Azure = azureNodeClassFromProto(policy.Azure) } + // GCP configuration + if policy.Gcp != nil && !isGCPSpecEmpty(policy.Gcp) { + m.Gcp = gcpNodeClassFromProto(policy.Gcp) + } + + // OCI configuration + if policy.Oci != nil && !isOCISpecEmpty(policy.Oci) { + m.Oci = ociNodeClassFromProto(policy.Oci) + } + // Raw specs if len(policy.Raw) > 0 { rawSpecs := make([]attr.Value, 0, len(policy.Raw)) @@ -1801,6 +2220,9 @@ func (aws *AWSNodeClass) toProto(ctx context.Context, diags *diag.Diagnostics) * if alias, ok := attrs["alias"].(types.String); ok && !alias.IsNull() { term.Alias = alias.ValueString() } + if ssmParameter, ok := attrs["ssm_parameter"].(types.String); ok && !ssmParameter.IsNull() { + term.SsmParameter = ssmParameter.ValueString() + } if tags, ok := attrs["tags"].(types.Map); ok && !tags.IsNull() { tagMap, err := getStringMap(ctx, tags.Elements()) if err != nil { @@ -1860,6 +2282,12 @@ func (aws *AWSNodeClass) toProto(ctx context.Context, diags *diag.Diagnostics) * mapping.DeviceName = &val } + // Root volume (pointer) + if rootVolume, ok := attrs["root_volume"].(types.Bool); ok && !rootVolume.IsNull() { + val := rootVolume.ValueBool() + mapping.RootVolume = &val + } + // EBS configuration (nested) if ebsObj, ok := attrs["ebs"].(types.Object); ok && !ebsObj.IsNull() { ebsAttrs := ebsObj.Attributes() @@ -1897,6 +2325,10 @@ func (aws *AWSNodeClass) toProto(ctx context.Context, diags *diag.Diagnostics) * val := encrypted.ValueBool() ebs.Encrypted = &val } + if volInitRate, ok := ebsAttrs["volume_initialization_rate"].(types.Int32); ok && !volInitRate.IsNull() { + val := volInitRate.ValueInt32() + ebs.VolumeInitializationRate = &val + } mapping.Ebs = ebs } @@ -1975,78 +2407,10 @@ func (aws *AWSNodeClass) toProto(ctx context.Context, diags *diag.Diagnostics) * // 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 + kubelet, err := kubeletConfigurationToProto(ctx, aws.Kubelet) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert kubelet configuration: %s", err)) + return nil } spec.Kubelet = kubelet } @@ -2148,10 +2512,11 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { terms := make([]attr.Value, 0, len(spec.AmiSelectorTerms)) for _, term := range spec.AmiSelectorTerms { termAttrs := map[string]attr.Value{ - "id": stringValue(term.Id), - "name": stringValue(term.Name), - "owner": stringValue(term.Owner), - "alias": stringValue(term.Alias), + "id": stringValue(term.Id), + "name": stringValue(term.Name), + "owner": stringValue(term.Owner), + "alias": stringValue(term.Alias), + "ssm_parameter": stringValue(term.SsmParameter), } if term.Tags != nil { termAttrs["tags"] = types.MapValueMust(types.StringType, fromStringMap(term.Tags)) @@ -2160,11 +2525,12 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { } terms = append(terms, types.ObjectValueMust( map[string]attr.Type{ - "id": types.StringType, - "name": types.StringType, - "owner": types.StringType, - "alias": types.StringType, - "tags": types.MapType{ElemType: types.StringType}, + "id": types.StringType, + "name": types.StringType, + "owner": types.StringType, + "alias": types.StringType, + "ssm_parameter": types.StringType, + "tags": types.MapType{ElemType: types.StringType}, }, termAttrs, )) @@ -2172,11 +2538,12 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { aws.AmiSelectorTerms = types.ListValueMust( types.ObjectType{ AttrTypes: map[string]attr.Type{ - "id": types.StringType, - "name": types.StringType, - "owner": types.StringType, - "alias": types.StringType, - "tags": types.MapType{ElemType: types.StringType}, + "id": types.StringType, + "name": types.StringType, + "owner": types.StringType, + "alias": types.StringType, + "ssm_parameter": types.StringType, + "tags": types.MapType{ElemType: types.StringType}, }, }, terms, @@ -2184,11 +2551,12 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { } else { aws.AmiSelectorTerms = types.ListNull(types.ObjectType{ AttrTypes: map[string]attr.Type{ - "id": types.StringType, - "name": types.StringType, - "owner": types.StringType, - "alias": types.StringType, - "tags": types.MapType{ElemType: types.StringType}, + "id": types.StringType, + "name": types.StringType, + "owner": types.StringType, + "alias": types.StringType, + "ssm_parameter": types.StringType, + "tags": types.MapType{ElemType: types.StringType}, }, }) } @@ -2212,6 +2580,7 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { for _, mapping := range spec.BlockDeviceMappings { mappingAttrs := map[string]attr.Value{ "device_name": stringPointerValue(mapping.DeviceName), + "root_volume": boolPointerValue(mapping.RootVolume), } // EBS configuration (nested) @@ -2243,46 +2612,51 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { } else { ebsAttrs["encrypted"] = types.BoolNull() } + ebsAttrs["volume_initialization_rate"] = int32PointerValue(mapping.Ebs.VolumeInitializationRate) mappingAttrs["ebs"] = types.ObjectValueMust( map[string]attr.Type{ - "volume_size": types.StringType, - "volume_type": types.StringType, - "iops": types.Int64Type, - "throughput": types.Int64Type, - "kms_key_id": types.StringType, - "snapshot_id": types.StringType, - "delete_on_termination": types.BoolType, - "encrypted": types.BoolType, + "volume_size": types.StringType, + "volume_type": types.StringType, + "iops": types.Int64Type, + "throughput": types.Int64Type, + "kms_key_id": types.StringType, + "snapshot_id": types.StringType, + "delete_on_termination": types.BoolType, + "encrypted": types.BoolType, + "volume_initialization_rate": types.Int32Type, }, ebsAttrs, ) } else { mappingAttrs["ebs"] = types.ObjectNull(map[string]attr.Type{ - "volume_size": types.StringType, - "volume_type": types.StringType, - "iops": types.Int64Type, - "throughput": types.Int64Type, - "kms_key_id": types.StringType, - "snapshot_id": types.StringType, - "delete_on_termination": types.BoolType, - "encrypted": types.BoolType, + "volume_size": types.StringType, + "volume_type": types.StringType, + "iops": types.Int64Type, + "throughput": types.Int64Type, + "kms_key_id": types.StringType, + "snapshot_id": types.StringType, + "delete_on_termination": types.BoolType, + "encrypted": types.BoolType, + "volume_initialization_rate": types.Int32Type, }) } mappings = append(mappings, types.ObjectValueMust( map[string]attr.Type{ "device_name": types.StringType, + "root_volume": types.BoolType, "ebs": types.ObjectType{ AttrTypes: map[string]attr.Type{ - "volume_size": types.StringType, - "volume_type": types.StringType, - "iops": types.Int64Type, - "throughput": types.Int64Type, - "kms_key_id": types.StringType, - "snapshot_id": types.StringType, - "delete_on_termination": types.BoolType, - "encrypted": types.BoolType, + "volume_size": types.StringType, + "volume_type": types.StringType, + "iops": types.Int64Type, + "throughput": types.Int64Type, + "kms_key_id": types.StringType, + "snapshot_id": types.StringType, + "delete_on_termination": types.BoolType, + "encrypted": types.BoolType, + "volume_initialization_rate": types.Int32Type, }, }, }, @@ -2293,16 +2667,18 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { types.ObjectType{ AttrTypes: map[string]attr.Type{ "device_name": types.StringType, + "root_volume": types.BoolType, "ebs": types.ObjectType{ AttrTypes: map[string]attr.Type{ - "volume_size": types.StringType, - "volume_type": types.StringType, - "iops": types.Int64Type, - "throughput": types.Int64Type, - "kms_key_id": types.StringType, - "snapshot_id": types.StringType, - "delete_on_termination": types.BoolType, - "encrypted": types.BoolType, + "volume_size": types.StringType, + "volume_type": types.StringType, + "iops": types.Int64Type, + "throughput": types.Int64Type, + "kms_key_id": types.StringType, + "snapshot_id": types.StringType, + "delete_on_termination": types.BoolType, + "encrypted": types.BoolType, + "volume_initialization_rate": types.Int32Type, }, }, }, @@ -2313,16 +2689,18 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { aws.BlockDeviceMappings = types.ListNull(types.ObjectType{ AttrTypes: map[string]attr.Type{ "device_name": types.StringType, + "root_volume": types.BoolType, "ebs": types.ObjectType{ AttrTypes: map[string]attr.Type{ - "volume_size": types.StringType, - "volume_type": types.StringType, - "iops": types.Int64Type, - "throughput": types.Int64Type, - "kms_key_id": types.StringType, - "snapshot_id": types.StringType, - "delete_on_termination": types.BoolType, - "encrypted": types.BoolType, + "volume_size": types.StringType, + "volume_type": types.StringType, + "iops": types.Int64Type, + "throughput": types.Int64Type, + "kms_key_id": types.StringType, + "snapshot_id": types.StringType, + "delete_on_termination": types.BoolType, + "encrypted": types.BoolType, + "volume_initialization_rate": types.Int32Type, }, }, }, @@ -2414,25 +2792,7 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { // 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 + aws.Kubelet = kubeletConfigurationFromProto(spec.Kubelet) } // Context @@ -2441,6 +2801,101 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { return aws } +// kubeletConfigurationToProto converts the shared kubelet configuration model to protobuf. +// Used by both AWS and GCP node classes, which reuse the same KubeletConfiguration message. +func kubeletConfigurationToProto(ctx context.Context, k *KubeletConfiguration) (*apiv1.KubeletConfiguration, error) { + kubelet := &apiv1.KubeletConfiguration{} + if !k.ClusterDns.IsNull() { + dns, err := getStringList(ctx, k.ClusterDns.Elements()) + if err != nil { + return nil, fmt.Errorf("cluster_dns: %w", err) + } + kubelet.ClusterDns = dns + } + if !k.MaxPods.IsNull() { + val := k.MaxPods.ValueInt32() + kubelet.MaxPods = &val + } + if !k.PodsPerCore.IsNull() { + val := k.PodsPerCore.ValueInt32() + kubelet.PodsPerCore = &val + } + if !k.SystemReserved.IsNull() { + m, err := getStringMap(ctx, k.SystemReserved.Elements()) + if err != nil { + return nil, fmt.Errorf("system_reserved: %w", err) + } + kubelet.SystemReserved = m + } + if !k.KubeReserved.IsNull() { + m, err := getStringMap(ctx, k.KubeReserved.Elements()) + if err != nil { + return nil, fmt.Errorf("kube_reserved: %w", err) + } + kubelet.KubeReserved = m + } + if !k.EvictionHard.IsNull() { + m, err := getStringMap(ctx, k.EvictionHard.Elements()) + if err != nil { + return nil, fmt.Errorf("eviction_hard: %w", err) + } + kubelet.EvictionHard = m + } + if !k.EvictionSoft.IsNull() { + m, err := getStringMap(ctx, k.EvictionSoft.Elements()) + if err != nil { + return nil, fmt.Errorf("eviction_soft: %w", err) + } + kubelet.EvictionSoft = m + } + if !k.EvictionSoftGracePeriod.IsNull() { + m, err := getStringMap(ctx, k.EvictionSoftGracePeriod.Elements()) + if err != nil { + return nil, fmt.Errorf("eviction_soft_grace_period: %w", err) + } + kubelet.EvictionSoftGracePeriod = m + } + if !k.EvictionMaxPodGracePeriod.IsNull() { + val := k.EvictionMaxPodGracePeriod.ValueInt32() + kubelet.EvictionMaxPodGracePeriod = &val + } + if !k.ImageGcHighThresholdPercent.IsNull() { + val := k.ImageGcHighThresholdPercent.ValueInt32() + kubelet.ImageGcHighThresholdPercent = &val + } + if !k.ImageGcLowThresholdPercent.IsNull() { + val := k.ImageGcLowThresholdPercent.ValueInt32() + kubelet.ImageGcLowThresholdPercent = &val + } + if !k.CpuCfsQuota.IsNull() { + val := k.CpuCfsQuota.ValueBool() + kubelet.CpuCfsQuota = &val + } + return kubelet, nil +} + +// kubeletConfigurationFromProto converts the shared kubelet configuration message to the Terraform model. +func kubeletConfigurationFromProto(k *apiv1.KubeletConfiguration) *KubeletConfiguration { + 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) + return kubelet +} + // Azure Node Class conversion functions. func (azure *AzureNodeClass) toProto(ctx context.Context, diags *diag.Diagnostics) *apiv1.AzureNodeClassSpec { spec := &apiv1.AzureNodeClassSpec{} @@ -2557,6 +3012,36 @@ func isAWSSpecEmpty(spec *apiv1.AWSNodeClassSpec) bool { spec.Context == nil } +// isDisruptionEmpty reports whether the API returned an unset disruption policy +// (the backend echoes back a non-nil, all-zero-value message rather than nil). +func isDisruptionEmpty(d *apiv1.DisruptionPolicy) bool { + if d == nil { + return true + } + return d.ConsolidateAfter == "" && + d.ConsolidationPolicy == "" && + d.ExpireAfter == "" && + d.TtlSecondsAfterEmpty == 0 && + d.TerminationGracePeriodSeconds == 0 && + len(d.Budgets) == 0 +} + +// isResourceLimitsEmpty reports whether the API returned an unset resource limits block. +func isResourceLimitsEmpty(l *apiv1.ResourceLimits) bool { + if l == nil { + return true + } + return l.Cpu == "" && l.Memory == "" +} + +// isZonalShiftEmpty reports whether the API returned an unset zonal shift config. +func isZonalShiftEmpty(z *apiv1.ZonalShiftConfig) bool { + if z == nil { + return true + } + return !z.RespectZonalShift && !z.EvictImpactedNodes && !z.AllowZoneFallback +} + // Helper to check if Azure spec is empty (all fields are nil). func isAzureSpecEmpty(spec *apiv1.AzureNodeClassSpec) bool { if spec == nil { @@ -2625,6 +3110,480 @@ func azureNodeClassFromProto(spec *apiv1.AzureNodeClassSpec) *AzureNodeClass { return azure } +// GCP Node Class conversion functions. +func (gcp *GCPNodeClass) toProto(ctx context.Context, diags *diag.Diagnostics) *apiv1.GCPNodeClassSpec { + spec := &apiv1.GCPNodeClassSpec{ + ServiceAccount: gcp.ServiceAccount.ValueString(), + } + + // Image selector terms + if !gcp.ImageSelectorTerms.IsNull() && !gcp.ImageSelectorTerms.IsUnknown() { + var terms []*apiv1.GCPImageSelectorTerm + for _, elem := range gcp.ImageSelectorTerms.Elements() { + objVal, ok := elem.(types.Object) + if !ok { + continue + } + attrs := objVal.Attributes() + term := &apiv1.GCPImageSelectorTerm{} + if alias, ok := attrs["alias"].(types.String); ok && !alias.IsNull() { + term.Alias = alias.ValueString() + } + if id, ok := attrs["id"].(types.String); ok && !id.IsNull() { + term.Id = id.ValueString() + } + terms = append(terms, term) + } + spec.ImageSelectorTerms = terms + } + + if !gcp.ImageFamily.IsNull() { + val := gcp.ImageFamily.ValueString() + spec.ImageFamily = &val + } + + if gcp.Kubelet != nil { + kubelet, err := kubeletConfigurationToProto(ctx, gcp.Kubelet) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert GCP kubelet configuration: %s", err)) + return nil + } + spec.KubeletConfiguration = kubelet + } + + if !gcp.Labels.IsNull() { + labels, err := getStringMap(ctx, gcp.Labels.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert GCP labels: %s", err)) + return nil + } + spec.Labels = labels + } + + if !gcp.Metadata.IsNull() { + metadata, err := getStringMap(ctx, gcp.Metadata.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert GCP metadata: %s", err)) + return nil + } + spec.Metadata = metadata + } + + if !gcp.NetworkTags.IsNull() { + tags, err := getStringList(ctx, gcp.NetworkTags.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert GCP network tags: %s", err)) + return nil + } + spec.NetworkTags = tags + } + + // Disks + if !gcp.Disks.IsNull() && !gcp.Disks.IsUnknown() { + var disks []*apiv1.GCPDisk + for _, elem := range gcp.Disks.Elements() { + objVal, ok := elem.(types.Object) + if !ok { + continue + } + attrs := objVal.Attributes() + disk := &apiv1.GCPDisk{} + if sizeGib, ok := attrs["size_gib"].(types.Int32); ok && !sizeGib.IsNull() { + disk.SizeGib = sizeGib.ValueInt32() + } + if category, ok := attrs["category"].(types.String); ok && !category.IsNull() { + disk.Category = category.ValueString() + } + if boot, ok := attrs["boot"].(types.Bool); ok && !boot.IsNull() { + disk.Boot = boot.ValueBool() + } + if secondaryBootImage, ok := attrs["secondary_boot_image"].(types.String); ok && !secondaryBootImage.IsNull() { + disk.SecondaryBootImage = secondaryBootImage.ValueString() + } + if secondaryBootMode, ok := attrs["secondary_boot_mode"].(types.String); ok && !secondaryBootMode.IsNull() { + disk.SecondaryBootMode = secondaryBootMode.ValueString() + } + disks = append(disks, disk) + } + spec.Disks = disks + } + + return spec +} + +// Helper to check if GCP spec is empty (all fields are nil/empty). +func isGCPSpecEmpty(spec *apiv1.GCPNodeClassSpec) bool { + if spec == nil { + return true + } + return spec.ServiceAccount == "" && + len(spec.ImageSelectorTerms) == 0 && + spec.ImageFamily == nil && + spec.KubeletConfiguration == nil && + len(spec.Labels) == 0 && + len(spec.Metadata) == 0 && + len(spec.NetworkTags) == 0 && + len(spec.Disks) == 0 +} + +func gcpNodeClassFromProto(spec *apiv1.GCPNodeClassSpec) *GCPNodeClass { + gcp := &GCPNodeClass{ + ServiceAccount: stringValue(spec.ServiceAccount), + } + + imageSelectorTermAttrTypes := map[string]attr.Type{ + "alias": types.StringType, + "id": types.StringType, + } + if len(spec.ImageSelectorTerms) > 0 { + terms := make([]attr.Value, 0, len(spec.ImageSelectorTerms)) + for _, term := range spec.ImageSelectorTerms { + terms = append(terms, types.ObjectValueMust(imageSelectorTermAttrTypes, map[string]attr.Value{ + "alias": stringValue(term.Alias), + "id": stringValue(term.Id), + })) + } + gcp.ImageSelectorTerms = types.ListValueMust(types.ObjectType{AttrTypes: imageSelectorTermAttrTypes}, terms) + } else { + gcp.ImageSelectorTerms = types.ListNull(types.ObjectType{AttrTypes: imageSelectorTermAttrTypes}) + } + + gcp.ImageFamily = stringPointerValue(spec.ImageFamily) + + if spec.KubeletConfiguration != nil { + gcp.Kubelet = kubeletConfigurationFromProto(spec.KubeletConfiguration) + } + + gcp.Labels = stringMapOrNull(spec.Labels) + gcp.Metadata = stringMapOrNull(spec.Metadata) + + if len(spec.NetworkTags) > 0 { + gcp.NetworkTags = types.ListValueMust(types.StringType, fromStringList(spec.NetworkTags)) + } else { + gcp.NetworkTags = types.ListNull(types.StringType) + } + + diskAttrTypes := map[string]attr.Type{ + "size_gib": types.Int32Type, + "category": types.StringType, + "boot": types.BoolType, + "secondary_boot_image": types.StringType, + "secondary_boot_mode": types.StringType, + } + if len(spec.Disks) > 0 { + disks := make([]attr.Value, 0, len(spec.Disks)) + for _, disk := range spec.Disks { + disks = append(disks, types.ObjectValueMust(diskAttrTypes, map[string]attr.Value{ + "size_gib": types.Int32Value(disk.SizeGib), + "category": stringValue(disk.Category), + "boot": types.BoolValue(disk.Boot), + "secondary_boot_image": stringValue(disk.SecondaryBootImage), + "secondary_boot_mode": stringValue(disk.SecondaryBootMode), + })) + } + gcp.Disks = types.ListValueMust(types.ObjectType{AttrTypes: diskAttrTypes}, disks) + } else { + gcp.Disks = types.ListNull(types.ObjectType{AttrTypes: diskAttrTypes}) + } + + return gcp +} + +// OCI Node Class conversion functions. +func (oci *OCINodeClass) toProto(ctx context.Context, diags *diag.Diagnostics) *apiv1.OCINodeClassSpec { + spec := &apiv1.OCINodeClassSpec{ + VcnId: oci.VcnId.ValueString(), + ImageFamily: oci.ImageFamily.ValueString(), + } + + if !oci.ImageSelector.IsNull() && !oci.ImageSelector.IsUnknown() { + var terms []*apiv1.OCIImageSelectorTerm + for _, elem := range oci.ImageSelector.Elements() { + objVal, ok := elem.(types.Object) + if !ok { + continue + } + attrs := objVal.Attributes() + term := &apiv1.OCIImageSelectorTerm{} + if id, ok := attrs["id"].(types.String); ok && !id.IsNull() { + term.Id = id.ValueString() + } + if name, ok := attrs["name"].(types.String); ok && !name.IsNull() { + term.Name = name.ValueString() + } + if compartmentId, ok := attrs["compartment_id"].(types.String); ok && !compartmentId.IsNull() { + term.CompartmentId = compartmentId.ValueString() + } + terms = append(terms, term) + } + spec.ImageSelector = terms + } + + if !oci.SubnetSelector.IsNull() && !oci.SubnetSelector.IsUnknown() { + var terms []*apiv1.OCISubnetSelectorTerm + for _, elem := range oci.SubnetSelector.Elements() { + objVal, ok := elem.(types.Object) + if !ok { + continue + } + attrs := objVal.Attributes() + term := &apiv1.OCISubnetSelectorTerm{} + if id, ok := attrs["id"].(types.String); ok && !id.IsNull() { + term.Id = id.ValueString() + } + if name, ok := attrs["name"].(types.String); ok && !name.IsNull() { + term.Name = name.ValueString() + } + terms = append(terms, term) + } + spec.SubnetSelector = terms + } + + if !oci.SecurityGroupSelector.IsNull() && !oci.SecurityGroupSelector.IsUnknown() { + var terms []*apiv1.OCISecurityGroupSelectorTerm + for _, elem := range oci.SecurityGroupSelector.Elements() { + objVal, ok := elem.(types.Object) + if !ok { + continue + } + attrs := objVal.Attributes() + term := &apiv1.OCISecurityGroupSelectorTerm{} + if id, ok := attrs["id"].(types.String); ok && !id.IsNull() { + term.Id = id.ValueString() + } + if name, ok := attrs["name"].(types.String); ok && !name.IsNull() { + term.Name = name.ValueString() + } + terms = append(terms, term) + } + spec.SecurityGroupSelector = terms + } + + if !oci.UserData.IsNull() { + val := oci.UserData.ValueString() + spec.UserData = &val + } + if !oci.PreInstallScript.IsNull() { + val := oci.PreInstallScript.ValueString() + spec.PreInstallScript = &val + } + + if !oci.MetaData.IsNull() { + metaData, err := getStringMap(ctx, oci.MetaData.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert OCI meta_data: %s", err)) + return nil + } + spec.MetaData = metaData + } + + if !oci.Tags.IsNull() { + tags, err := getStringMap(ctx, oci.Tags.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert OCI tags: %s", err)) + return nil + } + spec.Tags = tags + } + + if !oci.FreeFormTags.IsNull() { + freeFormTags, err := getStringMap(ctx, oci.FreeFormTags.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert OCI free_form_tags: %s", err)) + return nil + } + spec.FreeFormTags = freeFormTags + } + + if oci.BootConfig != nil { + spec.BootConfig = &apiv1.OCIBootConfig{ + BootVolumeSizeInGbs: oci.BootConfig.BootVolumeSizeInGbs.ValueInt64(), + BootVolumeVpusPerGb: oci.BootConfig.BootVolumeVpusPerGb.ValueInt64(), + } + } + + if oci.LaunchOptions != nil { + launchOptions := &apiv1.OCILaunchOptions{} + if !oci.LaunchOptions.BootVolumeType.IsNull() { + val := oci.LaunchOptions.BootVolumeType.ValueString() + launchOptions.BootVolumeType = &val + } + if !oci.LaunchOptions.Firmware.IsNull() { + val := oci.LaunchOptions.Firmware.ValueString() + launchOptions.Firmware = &val + } + if !oci.LaunchOptions.NetworkType.IsNull() { + val := oci.LaunchOptions.NetworkType.ValueString() + launchOptions.NetworkType = &val + } + if !oci.LaunchOptions.RemoteDataVolumeType.IsNull() { + val := oci.LaunchOptions.RemoteDataVolumeType.ValueString() + launchOptions.RemoteDataVolumeType = &val + } + if !oci.LaunchOptions.IsConsistentVolumeNamingEnabled.IsNull() { + val := oci.LaunchOptions.IsConsistentVolumeNamingEnabled.ValueBool() + launchOptions.IsConsistentVolumeNamingEnabled = &val + } + spec.LaunchOptions = launchOptions + } + + if !oci.BlockDevices.IsNull() && !oci.BlockDevices.IsUnknown() { + var devices []*apiv1.OCIVolumeAttributes + for _, elem := range oci.BlockDevices.Elements() { + objVal, ok := elem.(types.Object) + if !ok { + continue + } + attrs := objVal.Attributes() + device := &apiv1.OCIVolumeAttributes{} + if sizeInGbs, ok := attrs["size_in_gbs"].(types.Int64); ok && !sizeInGbs.IsNull() { + device.SizeInGbs = sizeInGbs.ValueInt64() + } + if vpusPerGb, ok := attrs["vpus_per_gb"].(types.Int64); ok && !vpusPerGb.IsNull() { + device.VpusPerGb = vpusPerGb.ValueInt64() + } + devices = append(devices, device) + } + spec.BlockDevices = devices + } + + if !oci.AgentList.IsNull() { + agents, err := getStringList(ctx, oci.AgentList.Elements()) + if err != nil { + diags.AddError("Conversion Error", fmt.Sprintf("Unable to convert OCI agent_list: %s", err)) + return nil + } + spec.AgentList = agents + } + + return spec +} + +// Helper to check if OCI spec is empty (all fields are nil/empty). +func isOCISpecEmpty(spec *apiv1.OCINodeClassSpec) bool { + if spec == nil { + return true + } + return spec.VcnId == "" && + len(spec.ImageSelector) == 0 && + len(spec.SubnetSelector) == 0 && + len(spec.SecurityGroupSelector) == 0 && + spec.UserData == nil && + spec.PreInstallScript == nil && + len(spec.MetaData) == 0 && + spec.ImageFamily == "" && + len(spec.Tags) == 0 && + len(spec.FreeFormTags) == 0 && + spec.BootConfig == nil && + spec.LaunchOptions == nil && + len(spec.BlockDevices) == 0 && + len(spec.AgentList) == 0 +} + +func ociNodeClassFromProto(spec *apiv1.OCINodeClassSpec) *OCINodeClass { + oci := &OCINodeClass{ + VcnId: stringValue(spec.VcnId), + ImageFamily: stringValue(spec.ImageFamily), + } + + imageSelectorAttrTypes := map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + "compartment_id": types.StringType, + } + if len(spec.ImageSelector) > 0 { + terms := make([]attr.Value, 0, len(spec.ImageSelector)) + for _, term := range spec.ImageSelector { + terms = append(terms, types.ObjectValueMust(imageSelectorAttrTypes, map[string]attr.Value{ + "id": stringValue(term.Id), + "name": stringValue(term.Name), + "compartment_id": stringValue(term.CompartmentId), + })) + } + oci.ImageSelector = types.ListValueMust(types.ObjectType{AttrTypes: imageSelectorAttrTypes}, terms) + } else { + oci.ImageSelector = types.ListNull(types.ObjectType{AttrTypes: imageSelectorAttrTypes}) + } + + idNameAttrTypes := map[string]attr.Type{ + "id": types.StringType, + "name": types.StringType, + } + if len(spec.SubnetSelector) > 0 { + terms := make([]attr.Value, 0, len(spec.SubnetSelector)) + for _, term := range spec.SubnetSelector { + terms = append(terms, types.ObjectValueMust(idNameAttrTypes, map[string]attr.Value{ + "id": stringValue(term.Id), + "name": stringValue(term.Name), + })) + } + oci.SubnetSelector = types.ListValueMust(types.ObjectType{AttrTypes: idNameAttrTypes}, terms) + } else { + oci.SubnetSelector = types.ListNull(types.ObjectType{AttrTypes: idNameAttrTypes}) + } + + if len(spec.SecurityGroupSelector) > 0 { + terms := make([]attr.Value, 0, len(spec.SecurityGroupSelector)) + for _, term := range spec.SecurityGroupSelector { + terms = append(terms, types.ObjectValueMust(idNameAttrTypes, map[string]attr.Value{ + "id": stringValue(term.Id), + "name": stringValue(term.Name), + })) + } + oci.SecurityGroupSelector = types.ListValueMust(types.ObjectType{AttrTypes: idNameAttrTypes}, terms) + } else { + oci.SecurityGroupSelector = types.ListNull(types.ObjectType{AttrTypes: idNameAttrTypes}) + } + + oci.UserData = stringPointerValue(spec.UserData) + oci.PreInstallScript = stringPointerValue(spec.PreInstallScript) + oci.MetaData = stringMapOrNull(spec.MetaData) + oci.Tags = stringMapOrNull(spec.Tags) + oci.FreeFormTags = stringMapOrNull(spec.FreeFormTags) + + if spec.BootConfig != nil { + oci.BootConfig = &OCIBootConfig{ + BootVolumeSizeInGbs: types.Int64Value(spec.BootConfig.BootVolumeSizeInGbs), + BootVolumeVpusPerGb: types.Int64Value(spec.BootConfig.BootVolumeVpusPerGb), + } + } + + if spec.LaunchOptions != nil { + oci.LaunchOptions = &OCILaunchOptions{ + BootVolumeType: stringPointerValue(spec.LaunchOptions.BootVolumeType), + Firmware: stringPointerValue(spec.LaunchOptions.Firmware), + NetworkType: stringPointerValue(spec.LaunchOptions.NetworkType), + RemoteDataVolumeType: stringPointerValue(spec.LaunchOptions.RemoteDataVolumeType), + IsConsistentVolumeNamingEnabled: boolPointerValue(spec.LaunchOptions.IsConsistentVolumeNamingEnabled), + } + } + + blockDeviceAttrTypes := map[string]attr.Type{ + "size_in_gbs": types.Int64Type, + "vpus_per_gb": types.Int64Type, + } + if len(spec.BlockDevices) > 0 { + devices := make([]attr.Value, 0, len(spec.BlockDevices)) + for _, device := range spec.BlockDevices { + devices = append(devices, types.ObjectValueMust(blockDeviceAttrTypes, map[string]attr.Value{ + "size_in_gbs": types.Int64Value(device.SizeInGbs), + "vpus_per_gb": types.Int64Value(device.VpusPerGb), + })) + } + oci.BlockDevices = types.ListValueMust(types.ObjectType{AttrTypes: blockDeviceAttrTypes}, devices) + } else { + oci.BlockDevices = types.ListNull(types.ObjectType{AttrTypes: blockDeviceAttrTypes}) + } + + if len(spec.AgentList) > 0 { + oci.AgentList = types.ListValueMust(types.StringType, fromStringList(spec.AgentList)) + } else { + oci.AgentList = types.ListNull(types.StringType) + } + + return oci +} + // Helper functions for enum conversions. // //nolint:unparam // Only RAID0 is currently supported, but function provides extensibility diff --git a/internal/provider/node_policy_test.go b/internal/provider/node_policy_test.go index 50adefc..a8e0bb5 100644 --- a/internal/provider/node_policy_test.go +++ b/internal/provider/node_policy_test.go @@ -1012,6 +1012,497 @@ func TestNodePolicyResourceModel(t *testing.T) { t.Errorf("Expected 1 label, got %d", len(elems)) } }) + + // Test AmiSelectorTerms ssm_parameter field + t.Run("AmiSelectorTerms_SsmParameter_ToProto", func(t *testing.T) { + attrTypes := map[string]attr.Type{ + "tags": types.MapType{ElemType: types.StringType}, + "id": types.StringType, + "name": types.StringType, + "owner": types.StringType, + "alias": types.StringType, + "ssm_parameter": types.StringType, + } + awsConfig := &AWSNodeClass{ + AmiSelectorTerms: types.ListValueMust( + types.ObjectType{AttrTypes: attrTypes}, + []attr.Value{ + types.ObjectValueMust(attrTypes, map[string]attr.Value{ + "tags": types.MapNull(types.StringType), + "id": types.StringNull(), + "name": types.StringNull(), + "owner": types.StringNull(), + "alias": types.StringNull(), + "ssm_parameter": types.StringValue("/aws/service/eks/optimized-ami/1.29/amazon-linux-2/recommended/image_id"), + }), + }, + ), + SubnetSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + SecurityGroupSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + BlockDeviceMappings: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + } + ctx := context.Background() + var diags diag.Diagnostics + proto := awsConfig.toProto(ctx, &diags) + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if len(proto.AmiSelectorTerms) != 1 { + t.Fatalf("Expected 1 AMI selector term, got %d", len(proto.AmiSelectorTerms)) + } + if proto.AmiSelectorTerms[0].SsmParameter != "/aws/service/eks/optimized-ami/1.29/amazon-linux-2/recommended/image_id" { + t.Errorf("Expected ssm_parameter to be set, got %q", proto.AmiSelectorTerms[0].SsmParameter) + } + }) + + t.Run("AmiSelectorTerms_SsmParameter_FromProto", func(t *testing.T) { + proto := &apiv1.AWSNodeClassSpec{ + AmiSelectorTerms: []*apiv1.AMISelectorTerm{ + {SsmParameter: "/aws/service/eks/optimized-ami/1.29/amazon-linux-2/recommended/image_id"}, + }, + } + aws := awsNodeClassFromProto(proto) + elems := aws.AmiSelectorTerms.Elements() + if len(elems) != 1 { + t.Fatalf("Expected 1 AMI selector term, got %d", len(elems)) + } + obj, ok := elems[0].(types.Object) + if !ok { + t.Fatal("Expected object element") + } + ssmParam, ok := obj.Attributes()["ssm_parameter"].(types.String) + if !ok || ssmParam.ValueString() != "/aws/service/eks/optimized-ami/1.29/amazon-linux-2/recommended/image_id" { + t.Errorf("Expected ssm_parameter to round-trip, got %v", obj.Attributes()["ssm_parameter"]) + } + }) + + // Test BlockDeviceMappings root_volume and Ebs volume_initialization_rate + t.Run("BlockDeviceMappings_RootVolumeAndInitRate_ToProto", func(t *testing.T) { + ebsAttrTypes := map[string]attr.Type{ + "volume_size": types.StringType, + "volume_type": types.StringType, + "iops": types.Int64Type, + "throughput": types.Int64Type, + "kms_key_id": types.StringType, + "delete_on_termination": types.BoolType, + "encrypted": types.BoolType, + "snapshot_id": types.StringType, + "volume_initialization_rate": types.Int32Type, + } + mappingAttrTypes := map[string]attr.Type{ + "device_name": types.StringType, + "root_volume": types.BoolType, + "ebs": types.ObjectType{AttrTypes: ebsAttrTypes}, + } + awsConfig := &AWSNodeClass{ + BlockDeviceMappings: types.ListValueMust( + types.ObjectType{AttrTypes: mappingAttrTypes}, + []attr.Value{ + types.ObjectValueMust(mappingAttrTypes, map[string]attr.Value{ + "device_name": types.StringValue("/dev/xvda"), + "root_volume": types.BoolValue(true), + "ebs": types.ObjectValueMust(ebsAttrTypes, map[string]attr.Value{ + "volume_size": types.StringValue("100Gi"), + "volume_type": types.StringValue("gp3"), + "iops": types.Int64Null(), + "throughput": types.Int64Null(), + "kms_key_id": types.StringNull(), + "delete_on_termination": types.BoolNull(), + "encrypted": types.BoolNull(), + "snapshot_id": types.StringNull(), + "volume_initialization_rate": types.Int32Value(50), + }), + }), + }, + ), + AmiFamily: types.StringValue("AL2"), + SubnetSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + SecurityGroupSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + AmiSelectorTerms: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{}}), + } + ctx := context.Background() + var diags diag.Diagnostics + proto := awsConfig.toProto(ctx, &diags) + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if len(proto.BlockDeviceMappings) != 1 { + t.Fatalf("Expected 1 block device mapping, got %d", len(proto.BlockDeviceMappings)) + } + bdm := proto.BlockDeviceMappings[0] + if bdm.RootVolume == nil || !*bdm.RootVolume { + t.Error("Expected root_volume to be true") + } + if bdm.Ebs == nil || bdm.Ebs.VolumeInitializationRate == nil || *bdm.Ebs.VolumeInitializationRate != 50 { + t.Errorf("Expected volume_initialization_rate=50, got %v", bdm.Ebs.VolumeInitializationRate) + } + }) + + t.Run("BlockDeviceMappings_RootVolumeAndInitRate_FromProto", func(t *testing.T) { + rootVolume := true + initRate := int32(50) + proto := &apiv1.AWSNodeClassSpec{ + BlockDeviceMappings: []*apiv1.BlockDeviceMapping{ + { + RootVolume: &rootVolume, + Ebs: &apiv1.BlockDevice{ + VolumeInitializationRate: &initRate, + }, + }, + }, + } + aws := awsNodeClassFromProto(proto) + elems := aws.BlockDeviceMappings.Elements() + if len(elems) != 1 { + t.Fatalf("Expected 1 block device mapping, got %d", len(elems)) + } + obj, ok := elems[0].(types.Object) + if !ok { + t.Fatal("Expected object element") + } + rootVol, ok := obj.Attributes()["root_volume"].(types.Bool) + if !ok || !rootVol.ValueBool() { + t.Errorf("Expected root_volume=true, got %v", obj.Attributes()["root_volume"]) + } + ebsObj, ok := obj.Attributes()["ebs"].(types.Object) + if !ok { + t.Fatal("Expected ebs object") + } + rate, ok := ebsObj.Attributes()["volume_initialization_rate"].(types.Int32) + if !ok || rate.ValueInt32() != 50 { + t.Errorf("Expected volume_initialization_rate=50, got %v", ebsObj.Attributes()["volume_initialization_rate"]) + } + }) + + // Test top-level InstanceShapes, InstanceShapesTip, InstanceLocalNvmeTip, StartupTaintsTip + t.Run("NodePolicy_NewTopLevelFields_ToProto", func(t *testing.T) { + model := &NodePolicyResourceModel{ + Name: types.StringValue("test-policy"), + InstanceShapes: &LabelSelector{ + MatchLabels: types.MapValueMust(types.StringType, map[string]attr.Value{ + "karpenter.k8s.aws/instance-shape": types.StringValue("standard"), + }), + MatchExpressions: types.ListNull(types.ObjectType{AttrTypes: map[string]attr.Type{ + "key": types.StringType, "operator": types.StringType, "values": types.ListType{ElemType: types.StringType}, + }}), + }, + InstanceShapesTip: types.StringValue("Select instance shapes"), + InstanceLocalNvmeTip: types.StringValue("Local NVMe tip"), + StartupTaintsTip: types.StringValue("Startup taints tip"), + } + ctx := context.Background() + var diags diag.Diagnostics + proto := model.toProto(ctx, &diags, "test-team-id") + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if proto.InstanceShapes == nil { + t.Fatal("Expected non-nil InstanceShapes") + } + if proto.InstanceShapes.MatchLabels["karpenter.k8s.aws/instance-shape"] != "standard" { + t.Errorf("Expected instance shape label, got %v", proto.InstanceShapes.MatchLabels) + } + if proto.InstanceShapesTip == nil || *proto.InstanceShapesTip != "Select instance shapes" { + t.Errorf("Expected InstanceShapesTip, got %v", proto.InstanceShapesTip) + } + if proto.InstanceLocalNvmeTip == nil || *proto.InstanceLocalNvmeTip != "Local NVMe tip" { + t.Errorf("Expected InstanceLocalNvmeTip, got %v", proto.InstanceLocalNvmeTip) + } + if proto.StartupTaintsTip == nil || *proto.StartupTaintsTip != "Startup taints tip" { + t.Errorf("Expected StartupTaintsTip, got %v", proto.StartupTaintsTip) + } + }) + + // Test GCP node class conversion + t.Run("GCPNodeClass_ToProto", func(t *testing.T) { + imageSelectorAttrTypes := map[string]attr.Type{"alias": types.StringType, "id": types.StringType} + diskAttrTypes := map[string]attr.Type{ + "size_gib": types.Int32Type, + "category": types.StringType, + "boot": types.BoolType, + "secondary_boot_image": types.StringType, + "secondary_boot_mode": types.StringType, + } + gcp := &GCPNodeClass{ + ServiceAccount: types.StringValue("my-service-account@project.iam.gserviceaccount.com"), + ImageSelectorTerms: types.ListValueMust( + types.ObjectType{AttrTypes: imageSelectorAttrTypes}, + []attr.Value{ + types.ObjectValueMust(imageSelectorAttrTypes, map[string]attr.Value{ + "alias": types.StringValue("ubuntu"), + "id": types.StringNull(), + }), + }, + ), + ImageFamily: types.StringValue("ubuntu-2204-lts"), + Kubelet: &KubeletConfiguration{ + MaxPods: types.Int32Value(110), + PodsPerCore: types.Int32Null(), + CpuCfsQuota: types.BoolNull(), + ClusterDns: types.ListNull(types.StringType), + 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.Int32Null(), + ImageGcLowThresholdPercent: types.Int32Null(), + }, + Labels: types.MapValueMust(types.StringType, map[string]attr.Value{"env": types.StringValue("prod")}), + Metadata: types.MapNull(types.StringType), + NetworkTags: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("allow-ssh")}), + Disks: types.ListValueMust( + types.ObjectType{AttrTypes: diskAttrTypes}, + []attr.Value{ + types.ObjectValueMust(diskAttrTypes, map[string]attr.Value{ + "size_gib": types.Int32Value(100), + "category": types.StringValue("pd-ssd"), + "boot": types.BoolValue(true), + "secondary_boot_image": types.StringValue(""), + "secondary_boot_mode": types.StringValue(""), + }), + }, + ), + } + ctx := context.Background() + var diags diag.Diagnostics + proto := gcp.toProto(ctx, &diags) + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if proto.ServiceAccount != "my-service-account@project.iam.gserviceaccount.com" { + t.Errorf("Expected ServiceAccount to match, got %s", proto.ServiceAccount) + } + if len(proto.ImageSelectorTerms) != 1 || proto.ImageSelectorTerms[0].Alias != "ubuntu" { + t.Errorf("Expected 1 image selector term with alias=ubuntu, got %v", proto.ImageSelectorTerms) + } + if proto.ImageFamily == nil || *proto.ImageFamily != "ubuntu-2204-lts" { + t.Errorf("Expected ImageFamily=ubuntu-2204-lts, got %v", proto.ImageFamily) + } + if proto.KubeletConfiguration == nil || proto.KubeletConfiguration.MaxPods == nil || *proto.KubeletConfiguration.MaxPods != 110 { + t.Errorf("Expected KubeletConfiguration.MaxPods=110, got %v", proto.KubeletConfiguration) + } + if proto.Labels["env"] != "prod" { + t.Errorf("Expected Labels[env]=prod, got %v", proto.Labels) + } + if len(proto.NetworkTags) != 1 || proto.NetworkTags[0] != "allow-ssh" { + t.Errorf("Expected NetworkTags=[allow-ssh], got %v", proto.NetworkTags) + } + if len(proto.Disks) != 1 || proto.Disks[0].SizeGib != 100 || proto.Disks[0].Category != "pd-ssd" || !proto.Disks[0].Boot { + t.Errorf("Expected 1 disk with size_gib=100 category=pd-ssd boot=true, got %v", proto.Disks) + } + }) + + t.Run("GCPNodeClass_FromProto", func(t *testing.T) { + imageFamily := "ubuntu-2204-lts" + maxPods := int32(110) + proto := &apiv1.GCPNodeClassSpec{ + ServiceAccount: "my-service-account@project.iam.gserviceaccount.com", + ImageSelectorTerms: []*apiv1.GCPImageSelectorTerm{ + {Alias: "ubuntu"}, + }, + ImageFamily: &imageFamily, + KubeletConfiguration: &apiv1.KubeletConfiguration{MaxPods: &maxPods}, + Labels: map[string]string{"env": "prod"}, + NetworkTags: []string{"allow-ssh"}, + Disks: []*apiv1.GCPDisk{ + {SizeGib: 100, Category: "pd-ssd", Boot: true}, + }, + } + gcp := gcpNodeClassFromProto(proto) + if gcp.ServiceAccount.ValueString() != "my-service-account@project.iam.gserviceaccount.com" { + t.Errorf("Expected ServiceAccount to round-trip, got %s", gcp.ServiceAccount.ValueString()) + } + terms := gcp.ImageSelectorTerms.Elements() + if len(terms) != 1 { + t.Fatalf("Expected 1 image selector term, got %d", len(terms)) + } + if gcp.ImageFamily.ValueString() != "ubuntu-2204-lts" { + t.Errorf("Expected ImageFamily to round-trip, got %s", gcp.ImageFamily.ValueString()) + } + if gcp.Kubelet == nil || gcp.Kubelet.MaxPods.ValueInt32() != 110 { + t.Errorf("Expected Kubelet.MaxPods=110, got %v", gcp.Kubelet) + } + if gcp.Labels.IsNull() || len(gcp.Labels.Elements()) != 1 { + t.Errorf("Expected 1 label, got %v", gcp.Labels) + } + disks := gcp.Disks.Elements() + if len(disks) != 1 { + t.Fatalf("Expected 1 disk, got %d", len(disks)) + } + }) + + t.Run("GCPSpecEmpty", func(t *testing.T) { + if !isGCPSpecEmpty(nil) { + t.Error("Expected nil spec to be empty") + } + if !isGCPSpecEmpty(&apiv1.GCPNodeClassSpec{}) { + t.Error("Expected zero-value spec to be empty") + } + if isGCPSpecEmpty(&apiv1.GCPNodeClassSpec{ServiceAccount: "sa@project.iam.gserviceaccount.com"}) { + t.Error("Expected spec with ServiceAccount to be non-empty") + } + }) + + // Test OCI node class conversion + t.Run("OCINodeClass_ToProto", func(t *testing.T) { + imageSelectorAttrTypes := map[string]attr.Type{"id": types.StringType, "name": types.StringType, "compartment_id": types.StringType} + idNameAttrTypes := map[string]attr.Type{"id": types.StringType, "name": types.StringType} + blockDeviceAttrTypes := map[string]attr.Type{"size_in_gbs": types.Int64Type, "vpus_per_gb": types.Int64Type} + oci := &OCINodeClass{ + VcnId: types.StringValue("ocid1.vcn.oc1..aaaa"), + ImageSelector: types.ListValueMust( + types.ObjectType{AttrTypes: imageSelectorAttrTypes}, + []attr.Value{ + types.ObjectValueMust(imageSelectorAttrTypes, map[string]attr.Value{ + "id": types.StringValue("ocid1.image.oc1..bbbb"), + "name": types.StringNull(), + "compartment_id": types.StringNull(), + }), + }, + ), + SubnetSelector: types.ListValueMust( + types.ObjectType{AttrTypes: idNameAttrTypes}, + []attr.Value{ + types.ObjectValueMust(idNameAttrTypes, map[string]attr.Value{ + "id": types.StringValue("ocid1.subnet.oc1..cccc"), "name": types.StringNull(), + }), + }, + ), + SecurityGroupSelector: types.ListNull(types.ObjectType{AttrTypes: idNameAttrTypes}), + UserData: types.StringValue("#!/bin/bash\necho hi"), + PreInstallScript: types.StringNull(), + MetaData: types.MapNull(types.StringType), + ImageFamily: types.StringValue("oracle-linux-8"), + Tags: types.MapNull(types.StringType), + FreeFormTags: types.MapValueMust(types.StringType, map[string]attr.Value{"env": types.StringValue("prod")}), + BootConfig: &OCIBootConfig{ + BootVolumeSizeInGbs: types.Int64Value(100), + BootVolumeVpusPerGb: types.Int64Value(10), + }, + LaunchOptions: &OCILaunchOptions{ + BootVolumeType: types.StringValue("PARAVIRTUALIZED"), + Firmware: types.StringNull(), + NetworkType: types.StringNull(), + RemoteDataVolumeType: types.StringNull(), + IsConsistentVolumeNamingEnabled: types.BoolValue(true), + }, + BlockDevices: types.ListValueMust( + types.ObjectType{AttrTypes: blockDeviceAttrTypes}, + []attr.Value{ + types.ObjectValueMust(blockDeviceAttrTypes, map[string]attr.Value{ + "size_in_gbs": types.Int64Value(50), + "vpus_per_gb": types.Int64Value(10), + }), + }, + ), + AgentList: types.ListValueMust(types.StringType, []attr.Value{types.StringValue("bastion")}), + } + ctx := context.Background() + var diags diag.Diagnostics + proto := oci.toProto(ctx, &diags) + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if proto.VcnId != "ocid1.vcn.oc1..aaaa" { + t.Errorf("Expected VcnId to match, got %s", proto.VcnId) + } + if len(proto.ImageSelector) != 1 || proto.ImageSelector[0].Id != "ocid1.image.oc1..bbbb" { + t.Errorf("Expected 1 image selector with id, got %v", proto.ImageSelector) + } + if len(proto.SubnetSelector) != 1 || proto.SubnetSelector[0].Id != "ocid1.subnet.oc1..cccc" { + t.Errorf("Expected 1 subnet selector with id, got %v", proto.SubnetSelector) + } + if proto.UserData == nil || *proto.UserData != "#!/bin/bash\necho hi" { + t.Errorf("Expected UserData to match, got %v", proto.UserData) + } + if proto.ImageFamily != "oracle-linux-8" { + t.Errorf("Expected ImageFamily to match, got %s", proto.ImageFamily) + } + if proto.FreeFormTags["env"] != "prod" { + t.Errorf("Expected FreeFormTags[env]=prod, got %v", proto.FreeFormTags) + } + if proto.BootConfig == nil || proto.BootConfig.BootVolumeSizeInGbs != 100 || proto.BootConfig.BootVolumeVpusPerGb != 10 { + t.Errorf("Expected BootConfig with size=100 vpus=10, got %v", proto.BootConfig) + } + if proto.LaunchOptions == nil || proto.LaunchOptions.BootVolumeType == nil || *proto.LaunchOptions.BootVolumeType != "PARAVIRTUALIZED" { + t.Errorf("Expected LaunchOptions.BootVolumeType=PARAVIRTUALIZED, got %v", proto.LaunchOptions) + } + if proto.LaunchOptions.IsConsistentVolumeNamingEnabled == nil || !*proto.LaunchOptions.IsConsistentVolumeNamingEnabled { + t.Error("Expected IsConsistentVolumeNamingEnabled=true") + } + if len(proto.BlockDevices) != 1 || proto.BlockDevices[0].SizeInGbs != 50 || proto.BlockDevices[0].VpusPerGb != 10 { + t.Errorf("Expected 1 block device with size=50 vpus=10, got %v", proto.BlockDevices) + } + if len(proto.AgentList) != 1 || proto.AgentList[0] != "bastion" { + t.Errorf("Expected AgentList=[bastion], got %v", proto.AgentList) + } + }) + + t.Run("OCINodeClass_FromProto", func(t *testing.T) { + userData := "#!/bin/bash\necho hi" + bootVolType := "PARAVIRTUALIZED" + consistentNaming := true + proto := &apiv1.OCINodeClassSpec{ + VcnId: "ocid1.vcn.oc1..aaaa", + ImageSelector: []*apiv1.OCIImageSelectorTerm{ + {Id: "ocid1.image.oc1..bbbb"}, + }, + UserData: &userData, + ImageFamily: "oracle-linux-8", + FreeFormTags: map[string]string{"env": "prod"}, + BootConfig: &apiv1.OCIBootConfig{ + BootVolumeSizeInGbs: 100, + BootVolumeVpusPerGb: 10, + }, + LaunchOptions: &apiv1.OCILaunchOptions{ + BootVolumeType: &bootVolType, + IsConsistentVolumeNamingEnabled: &consistentNaming, + }, + BlockDevices: []*apiv1.OCIVolumeAttributes{ + {SizeInGbs: 50, VpusPerGb: 10}, + }, + AgentList: []string{"bastion"}, + } + oci := ociNodeClassFromProto(proto) + if oci.VcnId.ValueString() != "ocid1.vcn.oc1..aaaa" { + t.Errorf("Expected VcnId to round-trip, got %s", oci.VcnId.ValueString()) + } + if len(oci.ImageSelector.Elements()) != 1 { + t.Errorf("Expected 1 image selector, got %d", len(oci.ImageSelector.Elements())) + } + if oci.UserData.ValueString() != userData { + t.Errorf("Expected UserData to round-trip, got %s", oci.UserData.ValueString()) + } + if oci.BootConfig == nil || oci.BootConfig.BootVolumeSizeInGbs.ValueInt64() != 100 { + t.Errorf("Expected BootConfig to round-trip, got %v", oci.BootConfig) + } + if oci.LaunchOptions == nil || oci.LaunchOptions.BootVolumeType.ValueString() != "PARAVIRTUALIZED" { + t.Errorf("Expected LaunchOptions to round-trip, got %v", oci.LaunchOptions) + } + if !oci.LaunchOptions.IsConsistentVolumeNamingEnabled.ValueBool() { + t.Error("Expected IsConsistentVolumeNamingEnabled=true") + } + if len(oci.BlockDevices.Elements()) != 1 { + t.Errorf("Expected 1 block device, got %d", len(oci.BlockDevices.Elements())) + } + if len(oci.AgentList.Elements()) != 1 { + t.Errorf("Expected 1 agent, got %d", len(oci.AgentList.Elements())) + } + }) + + t.Run("OCISpecEmpty", func(t *testing.T) { + if !isOCISpecEmpty(nil) { + t.Error("Expected nil spec to be empty") + } + if !isOCISpecEmpty(&apiv1.OCINodeClassSpec{}) { + t.Error("Expected zero-value spec to be empty") + } + if isOCISpecEmpty(&apiv1.OCINodeClassSpec{VcnId: "ocid1.vcn.oc1..aaaa"}) { + t.Error("Expected spec with VcnId to be non-empty") + } + }) } func validateNodePolicySchema(t *testing.T, schema schema.Schema) { @@ -1038,11 +1529,11 @@ func validateNodePolicySchema(t *testing.T, schema schema.Schema) { "description", "weight", "instance_categories", "instance_families", "instance_cpus", "instance_hypervisors", "instance_generations", "instance_sizes", - "instance_types", + "instance_types", "instance_shapes", "zones", "architectures", "capacity_types", "operating_systems", "labels", "taints", "disruption", "limits", "node_pool_name", "node_class_name", - "aws", "azure", "raw", + "aws", "azure", "gcp", "oci", "raw", } for _, attr := range optionalAttrs { if _, exists := schema.Attributes[attr]; !exists { @@ -1053,6 +1544,7 @@ func validateNodePolicySchema(t *testing.T, schema schema.Schema) { // Validate tooltip fields exist tooltipAttrs := []string{ "instance_categories_tip", "instance_families_tip", "instance_cpus_tip", + "instance_shapes_tip", "instance_local_nvme_tip", "startup_taints_tip", "zones_tip", "architectures_tip", "capacity_type_tip", "operating_systems_tip", "taints_tip", "disruptions_tip", "limits_tip", } @@ -1070,4 +1562,12 @@ func validateNodePolicySchema(t *testing.T, schema schema.Schema) { if _, exists := schema.Attributes["azure"]; !exists { t.Error("Azure configuration not found in schema") } + + if _, exists := schema.Attributes["gcp"]; !exists { + t.Error("GCP configuration not found in schema") + } + + if _, exists := schema.Attributes["oci"]; !exists { + t.Error("OCI configuration not found in schema") + } } diff --git a/internal/provider/workload_policy.go b/internal/provider/workload_policy.go index 7299b84..c2c092c 100644 --- a/internal/provider/workload_policy.go +++ b/internal/provider/workload_policy.go @@ -69,9 +69,10 @@ type WorkloadPolicyResourceModel struct { EnablePmaxProtection types.Bool `tfsdk:"enable_pmax_protection"` PmaxRatioThreshold types.Float32 `tfsdk:"pmax_ratio_threshold"` - EnableInPlaceVerticalScaling types.Bool `tfsdk:"enable_in_place_vertical_scaling"` - AllowInPlaceMemoryLimitDecrease types.Bool `tfsdk:"allow_in_place_memory_limit_decrease"` - PdbEnabled types.Bool `tfsdk:"pdb_enabled"` + EnableInPlaceVerticalScaling types.Bool `tfsdk:"enable_in_place_vertical_scaling"` + AllowInPlaceMemoryLimitDecrease types.Bool `tfsdk:"allow_in_place_memory_limit_decrease"` + PdbEnabled types.Bool `tfsdk:"pdb_enabled"` + EmergencyResponse *EmergencyResponseModel `tfsdk:"emergency_response"` CpuFloorPercent types.Int64 `tfsdk:"cpu_floor_percent"` CpuCeilingPercent types.Int64 `tfsdk:"cpu_ceiling_percent"` @@ -495,6 +496,11 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Computed: true, Default: booldefault.StaticBool(false), }, + "emergency_response": schema.SingleNestedAttribute{ + Description: "Emergency response configuration for OOM and CPU throttle events", + Optional: true, + Attributes: emergencyResponseAttributes(), + }, "cpu_floor_percent": schema.Int64Attribute{ Description: "Floor for CPU requests as a percent of the initial request (1-100)", Optional: true, @@ -816,6 +822,7 @@ func (m *WorkloadPolicyResourceModel) toProto(ctx context.Context, diags *diag.D EnableInPlaceVerticalScaling: m.EnableInPlaceVerticalScaling.ValueBool(), AllowInPlaceMemoryLimitDecrease: m.AllowInPlaceMemoryLimitDecrease.ValueBool(), PdbEnabled: m.PdbEnabled.ValueBool(), + EmergencyResponse: m.EmergencyResponse.toProto(), CpuFloorPercent: m.CpuFloorPercent.ValueInt64Pointer(), CpuCeilingPercent: m.CpuCeilingPercent.ValueInt64Pointer(), @@ -928,6 +935,7 @@ func (m *WorkloadPolicyResourceModel) fromProto(policy *apiv1.WorkloadRecommenda m.EnableInPlaceVerticalScaling = types.BoolValue(policy.EnableInPlaceVerticalScaling) m.AllowInPlaceMemoryLimitDecrease = types.BoolValue(policy.AllowInPlaceMemoryLimitDecrease) m.PdbEnabled = types.BoolValue(policy.PdbEnabled) + m.EmergencyResponse = emergencyResponseFromProto(policy.EmergencyResponse) m.CpuFloorPercent = types.Int64PointerValue(policy.CpuFloorPercent) m.CpuCeilingPercent = types.Int64PointerValue(policy.CpuCeilingPercent) @@ -971,8 +979,18 @@ func (o *VerticalScalingOptions) toProto() *apiv1.VerticalScalingOptimizationTar } } +// isVerticalScalingEmpty reports whether the API returned an unset scaling +// block. The backend echoes back a non-nil message with baseline defaults +// (e.g. min_data_points=15) on every axis regardless of whether it was +// configured, so field-by-field zero checks are unreliable — Enabled is the +// only field the backend faithfully reports as unset for an axis the user +// never configured. +func isVerticalScalingEmpty(target *apiv1.VerticalScalingOptimizationTarget) bool { + return target == nil || !target.Enabled +} + func verticalScalingOptionsFromProto(target *apiv1.VerticalScalingOptimizationTarget) *VerticalScalingOptions { - if target == nil { + if isVerticalScalingEmpty(target) { return nil } o := &VerticalScalingOptions{} @@ -1032,8 +1050,15 @@ func (o *HorizontalScalingOptions) toProto() *apiv1.HorizontalScalingOptimizatio } } +// isHorizontalScalingEmpty reports whether the API returned an unset +// horizontal scaling block (see isVerticalScalingEmpty for why this check +// is necessary). +func isHorizontalScalingEmpty(target *apiv1.HorizontalScalingOptimizationTarget) bool { + return target == nil || !target.Enabled +} + func horizontalScalingOptionsFromProto(target *apiv1.HorizontalScalingOptimizationTarget) *HorizontalScalingOptions { - if target == nil { + if isHorizontalScalingEmpty(target) { return nil } o := &HorizontalScalingOptions{} diff --git a/internal/provider/workload_policy_test.go b/internal/provider/workload_policy_test.go index 5460961..5f95262 100644 --- a/internal/provider/workload_policy_test.go +++ b/internal/provider/workload_policy_test.go @@ -256,6 +256,102 @@ func TestWorkloadPolicyResourceModel(t *testing.T) { t.Errorf("Expected pod_evict, got %v", elems[0]) } }) + + t.Run("EmergencyResponse_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, nil), + SchedulerPlugins: types.ListValueMust(types.StringType, nil), + CronSchedule: types.StringValue("*/15 * * * *"), + DefragmentationSchedule: types.StringValue("*/15 * * * *"), + EmergencyResponse: &EmergencyResponseModel{ + OomEnabled: types.BoolValue(true), + OomMemoryMultiplier: types.Float32Value(2.0), + OomMaxReactions: types.Int32Value(3), + OomCooldownSeconds: types.Int32Value(60), + CpuThrottlingEnabled: types.BoolValue(true), + CpuThrottlingThreshold: types.Float32Value(0.8), + CpuThrottlingMultiplier: types.Float32Value(1.5), + }, + } + var diags diag.Diagnostics + proto := m.toProto(ctx, &diags, "team-1") + if diags.HasError() { + t.Fatalf("Expected no error, got %v", diags) + } + if proto.EmergencyResponse == nil { + t.Fatal("Expected non-nil EmergencyResponse") + } + if !proto.EmergencyResponse.OomEnabled { + t.Error("Expected OomEnabled=true") + } + if proto.EmergencyResponse.OomMemoryMultiplier != 2.0 { + t.Errorf("Expected OomMemoryMultiplier=2.0, got %f", proto.EmergencyResponse.OomMemoryMultiplier) + } + }) + + t.Run("EmergencyResponse_ToProto_NilWhenUnset", 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, nil), + 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 proto.EmergencyResponse != nil { + t.Error("Expected nil EmergencyResponse when unset") + } + }) + + t.Run("EmergencyResponse_FromProto", func(t *testing.T) { + policy := &apiv1.WorkloadRecommendationPolicy{ + PolicyId: "p1", + Name: "test", + EmergencyResponse: &apiv1.EmergencyResponseConfig{ + OomEnabled: true, + OomMemoryMultiplier: 2.0, + OomMaxReactions: 3, + OomCooldownSeconds: 60, + CpuThrottlingEnabled: true, + CpuThrottlingThreshold: 0.8, + CpuThrottlingMultiplier: 1.5, + }, + } + var m WorkloadPolicyResourceModel + m.fromProto(policy) + if m.EmergencyResponse == nil { + t.Fatal("Expected non-nil EmergencyResponse") + } + if !m.EmergencyResponse.OomEnabled.ValueBool() { + t.Error("Expected OomEnabled=true") + } + if m.EmergencyResponse.OomMemoryMultiplier.ValueFloat32() != 2.0 { + t.Errorf("Expected OomMemoryMultiplier=2.0, got %f", m.EmergencyResponse.OomMemoryMultiplier.ValueFloat32()) + } + }) + + t.Run("EmergencyResponse_FromProto_NilWhenUnset", func(t *testing.T) { + policy := &apiv1.WorkloadRecommendationPolicy{ + PolicyId: "p1", + Name: "test", + } + var m WorkloadPolicyResourceModel + m.fromProto(policy) + if m.EmergencyResponse != nil { + t.Error("Expected nil EmergencyResponse when unset") + } + }) } func validateSchema(t *testing.T, s schema.Schema) { diff --git a/internal/provider/workload_rule.go b/internal/provider/workload_rule.go index f51282b..203b47a 100644 --- a/internal/provider/workload_rule.go +++ b/internal/provider/workload_rule.go @@ -60,6 +60,11 @@ type WorkloadRuleResourceModel struct { Containers []ContainerRuleModel `tfsdk:"containers"` Disabled types.Bool `tfsdk:"disabled"` LookbackPeriodSeconds types.Int32 `tfsdk:"lookback_period_seconds"` + + AllowInPlaceMemoryLimitDecrease types.Bool `tfsdk:"allow_in_place_memory_limit_decrease"` + JvmHeapRule *JVMHeapRuleModel `tfsdk:"jvm_heap_rule"` + JvmCpuStartupFloorMillicores types.Int64 `tfsdk:"jvm_cpu_startup_floor_millicores"` + KedaScaledObject *KEDAScaledObjectModel `tfsdk:"keda_scaled_object"` } type ResourceRuleConfigModel struct { @@ -138,6 +143,54 @@ type EmergencyResponseModel struct { CpuThrottlingMultiplier types.Float32 `tfsdk:"cpu_throttling_multiplier"` } +type JVMHeapRuleModel struct { + Enabled types.Bool `tfsdk:"enabled"` + TargetPercentile types.Float32 `tfsdk:"target_percentile"` + HeadroomMultiplier types.Float32 `tfsdk:"headroom_multiplier"` + NonHeapOverheadPercent types.Float32 `tfsdk:"non_heap_overhead_percent"` + NonHeapOverheadBytes types.Int64 `tfsdk:"non_heap_overhead_bytes"` + MinHeapBytes types.Int64 `tfsdk:"min_heap_bytes"` + MaxHeapBytes types.Int64 `tfsdk:"max_heap_bytes"` + PreferContainerSupport types.Bool `tfsdk:"prefer_container_support"` +} + +type KEDAScaledObjectModel struct { + Triggers []KEDATriggerModel `tfsdk:"triggers"` + MinReplicaCount types.Int32 `tfsdk:"min_replica_count"` + MaxReplicaCount types.Int32 `tfsdk:"max_replica_count"` + IdleReplicaCount types.Int32 `tfsdk:"idle_replica_count"` + PollingInterval types.Int32 `tfsdk:"polling_interval"` + CooldownPeriod types.Int32 `tfsdk:"cooldown_period"` + InitialCooldownPeriod types.Int32 `tfsdk:"initial_cooldown_period"` + Fallback *KEDAFallbackModel `tfsdk:"fallback"` + Advanced *KEDAAdvancedModel `tfsdk:"advanced"` +} + +type KEDATriggerModel struct { + Type types.String `tfsdk:"type"` + Name types.String `tfsdk:"name"` + Metadata types.Map `tfsdk:"metadata"` + MetricType types.String `tfsdk:"metric_type"` + AuthenticationRef *KEDAAuthenticationRefModel `tfsdk:"authentication_ref"` + UseCachedMetrics types.Bool `tfsdk:"use_cached_metrics"` +} + +type KEDAAuthenticationRefModel struct { + Name types.String `tfsdk:"name"` + Kind types.String `tfsdk:"kind"` +} + +type KEDAFallbackModel struct { + FailureThreshold types.Int32 `tfsdk:"failure_threshold"` + Replicas types.Int32 `tfsdk:"replicas"` + Behavior types.String `tfsdk:"behavior"` +} + +type KEDAAdvancedModel struct { + RestoreToOriginalReplicaCount types.Bool `tfsdk:"restore_to_original_replica_count"` + AdvancedBehaviorJson types.String `tfsdk:"advanced_behavior_json"` +} + type ContainerRuleModel struct { ContainerName types.String `tfsdk:"container_name"` CpuRule *ContainerResourceConfigModel `tfsdk:"cpu_rule"` @@ -157,6 +210,45 @@ type ContainerResourceConfigModel struct { LimitUseRss types.Bool `tfsdk:"limit_use_rss"` } +func emergencyResponseAttributes() map[string]schema.Attribute { + return map[string]schema.Attribute{ + "oom_enabled": schema.BoolAttribute{ + Description: "React to OOM kills by increasing memory", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "oom_memory_multiplier": schema.Float32Attribute{ + Description: "Multiplier applied to memory on OOM", + Optional: true, + }, + "oom_max_reactions": schema.Int32Attribute{ + Description: "Maximum number of OOM reactions before giving up", + Optional: true, + Computed: true, + }, + "oom_cooldown_seconds": schema.Int32Attribute{ + Description: "Seconds to wait between OOM reactions", + Optional: true, + Computed: true, + }, + "cpu_throttling_enabled": schema.BoolAttribute{ + Description: "React to CPU throttling by increasing CPU request", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "cpu_throttling_threshold": schema.Float32Attribute{ + Description: "Throttle ratio threshold that triggers a reaction (0-1)", + Optional: true, + }, + "cpu_throttling_multiplier": schema.Float32Attribute{ + Description: "Multiplier applied to CPU request on throttle reaction", + Optional: true, + }, + } +} + func hpaScalingRulesAttributes() map[string]schema.Attribute { return map[string]schema.Attribute{ "stabilization_window_seconds": schema.Int32Attribute{ @@ -276,10 +368,14 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe "request_use_rss": schema.BoolAttribute{ Description: "Memory only: size the request from RSS instead of working set", Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), }, "limit_use_rss": schema.BoolAttribute{ Description: "Memory only: derive the limit from an RSS-based recommendation", Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), }, } } @@ -323,10 +419,14 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe "request_use_rss": schema.BoolAttribute{ Description: "Memory only: size the request from RSS instead of working set", Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), }, "limit_use_rss": schema.BoolAttribute{ Description: "Memory only: derive the limit from an RSS-based recommendation", Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), }, } } @@ -492,40 +592,163 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe "emergency_response": schema.SingleNestedAttribute{ Description: "Emergency response configuration for OOM and CPU throttle events", Optional: true, + Attributes: emergencyResponseAttributes(), + }, + "jvm_heap_rule": schema.SingleNestedAttribute{ + Description: "JVM heap optimization overrides for this rule", + Optional: true, Attributes: map[string]schema.Attribute{ - "oom_enabled": schema.BoolAttribute{ - Description: "React to OOM kills by increasing memory", + "enabled": schema.BoolAttribute{ + Description: "Enable JVM heap optimization", Optional: true, Computed: true, Default: booldefault.StaticBool(false), }, - "oom_memory_multiplier": schema.Float32Attribute{ - Description: "Multiplier applied to memory on OOM", + "target_percentile": schema.Float32Attribute{ + Description: "Percentile of heap usage data used as the recommendation target (0-1)", Optional: true, }, - "oom_max_reactions": schema.Int32Attribute{ - Description: "Maximum number of OOM reactions before giving up", + "headroom_multiplier": schema.Float32Attribute{ + Description: "Multiplier applied to the target heap usage to derive the recommended max heap", Optional: true, - Computed: true, }, - "oom_cooldown_seconds": schema.Int32Attribute{ - Description: "Seconds to wait between OOM reactions", + "non_heap_overhead_percent": schema.Float32Attribute{ + Description: "Non-heap memory overhead as a percentage of heap size", + Optional: true, + }, + "non_heap_overhead_bytes": schema.Int64Attribute{ + Description: "Non-heap memory overhead in bytes, added on top of non_heap_overhead_percent", + Optional: true, + }, + "min_heap_bytes": schema.Int64Attribute{ + Description: "Minimum recommended max heap size in bytes", + Optional: true, + }, + "max_heap_bytes": schema.Int64Attribute{ + Description: "Maximum recommended max heap size in bytes", Optional: true, - Computed: true, }, - "cpu_throttling_enabled": schema.BoolAttribute{ - Description: "React to CPU throttling by increasing CPU request", + "prefer_container_support": schema.BoolAttribute{ + Description: "Prefer the JVM's own container-aware ergonomics (-XX:+UseContainerSupport) over an explicit -Xmx", Optional: true, Computed: true, Default: booldefault.StaticBool(false), }, - "cpu_throttling_threshold": schema.Float32Attribute{ - Description: "Throttle ratio threshold that triggers a reaction (0-1)", + }, + }, + "jvm_cpu_startup_floor_millicores": schema.Int64Attribute{ + Description: "Per-rule override of the JVM CPU startup floor in millicores", + MarkdownDescription: "Per-rule override of the JVM CPU startup floor in millicores. Unset inherits the policy/system default (75m); explicit `0` disables the floor for this rule. Always-on for detected JVMs, independent of `jvm_heap_rule.enabled`.", + Optional: true, + }, + "keda_scaled_object": schema.SingleNestedAttribute{ + Description: "KEDA ScaledObject template", + MarkdownDescription: "KEDA ScaledObject template authored by the user. When set, the in-cluster operator owns the ScaledObject lifecycle (create/update/delete) instead of generating its own HPA.", + Optional: true, + Attributes: map[string]schema.Attribute{ + "triggers": schema.ListNestedAttribute{ + Description: "KEDA scale triggers", + Optional: true, + NestedObject: schema.NestedAttributeObject{ + Attributes: map[string]schema.Attribute{ + "type": schema.StringAttribute{ + Description: "KEDA scaler type. Example: 'prometheus', 'cpu', 'kafka'", + Required: true, + }, + "name": schema.StringAttribute{ + Description: "Trigger name", + Optional: true, + }, + "metadata": schema.MapAttribute{ + Description: "Scaler-specific metadata, as required by the chosen KEDA scaler type", + Optional: true, + ElementType: types.StringType, + }, + "metric_type": schema.StringAttribute{ + Description: "Metric target type. One of: 'Value', 'AverageValue', 'Utilization'", + Optional: true, + }, + "authentication_ref": schema.SingleNestedAttribute{ + Description: "Reference to a KEDA TriggerAuthentication/ClusterTriggerAuthentication", + Optional: true, + Attributes: map[string]schema.Attribute{ + "name": schema.StringAttribute{ + Description: "Name of the referenced authentication resource", + Required: true, + }, + "kind": schema.StringAttribute{ + Description: "Kind of the referenced authentication resource. One of: 'TriggerAuthentication', 'ClusterTriggerAuthentication'", + Optional: true, + }, + }, + }, + "use_cached_metrics": schema.BoolAttribute{ + Description: "Use KEDA's cached metrics for this trigger", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + }, + }, + }, + "min_replica_count": schema.Int32Attribute{ + Description: "Minimum number of replicas", + Optional: true, + }, + "max_replica_count": schema.Int32Attribute{ + Description: "Maximum number of replicas", + Optional: true, + }, + "idle_replica_count": schema.Int32Attribute{ + Description: "Number of replicas to scale down to when idle", + Optional: true, + }, + "polling_interval": schema.Int32Attribute{ + Description: "Seconds between checks of the trigger sources", + Optional: true, + }, + "cooldown_period": schema.Int32Attribute{ + Description: "Seconds to wait after the last trigger reported active before scaling down to idle/min replicas", + Optional: true, + }, + "initial_cooldown_period": schema.Int32Attribute{ + Description: "Cooldown period applied only on initial ScaledObject creation", Optional: true, }, - "cpu_throttling_multiplier": schema.Float32Attribute{ - Description: "Multiplier applied to CPU request on throttle reaction", + "fallback": schema.SingleNestedAttribute{ + Description: "Replica fallback configuration when the scaler's metrics are unavailable", Optional: true, + Attributes: map[string]schema.Attribute{ + "failure_threshold": schema.Int32Attribute{ + Description: "Number of consecutive metric failures before activating fallback", + Optional: true, + }, + "replicas": schema.Int32Attribute{ + Description: "Number of replicas to fall back to when metrics are unavailable", + Optional: true, + }, + "behavior": schema.StringAttribute{ + Description: "Fallback strategy", + Optional: true, + }, + }, + }, + "advanced": schema.SingleNestedAttribute{ + Description: "Advanced KEDA ScaledObject settings", + Optional: true, + Attributes: map[string]schema.Attribute{ + "restore_to_original_replica_count": schema.BoolAttribute{ + Description: "Restore the original replica count when the ScaledObject is deleted", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, + "advanced_behavior_json": schema.StringAttribute{ + Description: "Opaque JSON-encoded Kubernetes HorizontalPodAutoscalerBehavior", + MarkdownDescription: "Opaque JSON-encoded Kubernetes `HorizontalPodAutoscalerBehavior`, carried through verbatim so this provider never has to re-model Kubernetes autoscaling types.", + Optional: true, + }, + }, }, }, }, @@ -586,6 +809,13 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe Computed: true, Default: booldefault.StaticBool(false), }, + "allow_in_place_memory_limit_decrease": schema.BoolAttribute{ + Description: "Allow an in-place resize to lower a container's memory limit", + MarkdownDescription: "Opt-in: allow an in-place resize to lower a container's memory limit. Only consulted when `use_in_place_vertical_scaling` is true; a decrease still additionally requires a cluster new enough to accept one. Default false because shrinking a live container's memory limit can OOM-kill it.", + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + }, "disabled": schema.BoolAttribute{ Description: "Create the rule in a disabled state", MarkdownDescription: "Whether the rule is disabled. A disabled rule exists but is not evaluated. Changing this after creation uses the ToggleWorkloadRuleDisabled API (the upsert API rejects `disabled` on update).", @@ -851,17 +1081,21 @@ func (m *WorkloadRuleResourceModel) toProto(ctx context.Context, diags *diag.Dia } fields := &apiv1.ManualRuleFields{ - CpuRule: m.CpuRule.toProto(), - MemoryRule: m.MemoryRule.toProto(), - GpuRule: m.GpuRule.toProto(), - HpaRule: m.HpaRule.toProto(), - EmergencyResponse: m.EmergencyResponse.toProto(), - ActionTriggers: actionTriggers, - DetectionTriggers: detectionTriggers, - SchedulerPlugins: schedulerPlugins, - LiveMigrationEnabled: m.LiveMigrationEnabled.ValueBool(), - UseInPlaceVerticalScaling: m.UseInPlaceVerticalScaling.ValueBool(), - Containers: containerRuleModelsToProto(m.Containers), + CpuRule: m.CpuRule.toProto(), + MemoryRule: m.MemoryRule.toProto(), + GpuRule: m.GpuRule.toProto(), + HpaRule: m.HpaRule.toProto(), + EmergencyResponse: m.EmergencyResponse.toProto(), + ActionTriggers: actionTriggers, + DetectionTriggers: detectionTriggers, + SchedulerPlugins: schedulerPlugins, + LiveMigrationEnabled: m.LiveMigrationEnabled.ValueBool(), + UseInPlaceVerticalScaling: m.UseInPlaceVerticalScaling.ValueBool(), + AllowInPlaceMemoryLimitDecrease: m.AllowInPlaceMemoryLimitDecrease.ValueBool(), + Containers: containerRuleModelsToProto(m.Containers), + JvmHeapRule: m.JvmHeapRule.toProto(), + JvmCpuStartupFloorMillicores: m.JvmCpuStartupFloorMillicores.ValueInt64Pointer(), + KedaScaledObject: m.KedaScaledObject.toProto(), } if !m.StartupPeriodSeconds.IsNull() && !m.StartupPeriodSeconds.IsUnknown() { @@ -909,6 +1143,15 @@ func (m *WorkloadRuleResourceModel) preserveNullsFrom(plan *WorkloadRuleResource if plan.EmergencyResponse == nil { m.EmergencyResponse = nil } + if plan.JvmHeapRule == nil { + m.JvmHeapRule = nil + } + if plan.KedaScaledObject == nil { + m.KedaScaledObject = nil + } + if plan.JvmCpuStartupFloorMillicores.IsNull() { + m.JvmCpuStartupFloorMillicores = types.Int64Null() + } if plan.ActionTriggers.IsNull() { m.ActionTriggers = types.ListNull(types.StringType) } @@ -957,6 +1200,10 @@ func (m *WorkloadRuleResourceModel) fromProto(r *apiv1.WorkloadRule) { m.GpuRule = resourceRuleConfigFromProto(r.GpuRule) m.HpaRule = hpaRuleConfigFromProto(r.HpaRule) m.EmergencyResponse = emergencyResponseFromProto(r.EmergencyResponse) + m.AllowInPlaceMemoryLimitDecrease = types.BoolValue(r.AllowInPlaceMemoryLimitDecrease) + m.JvmHeapRule = jvmHeapRuleFromProto(r.JvmHeapRule) + m.JvmCpuStartupFloorMillicores = types.Int64PointerValue(r.JvmCpuStartupFloorMillicores) + m.KedaScaledObject = kedaScaledObjectFromProto(r.KedaScaledObject) actionTriggers := make([]attr.Value, 0) for _, at := range r.ActionTriggers { @@ -1083,8 +1330,8 @@ func resourceRuleConfigFromProto(p *apiv1.ResourceRuleConfig) *ResourceRuleConfi InitialLimit: types.Int64PointerValue(p.InitialLimit), LimitFloorPercent: types.Int64PointerValue(p.LimitFloorPercent), LimitCeilingPercent: types.Int64PointerValue(p.LimitCeilingPercent), - RequestUseRss: types.BoolPointerValue(p.RequestUseRss), - LimitUseRss: types.BoolPointerValue(p.LimitUseRss), + RequestUseRss: types.BoolValue(p.RequestUseRss != nil && *p.RequestUseRss), + LimitUseRss: types.BoolValue(p.LimitUseRss != nil && *p.LimitUseRss), } if p.MinRequest != nil { m.MinRequest = types.Int64Value(*p.MinRequest) @@ -1230,6 +1477,156 @@ func emergencyResponseFromProto(p *apiv1.EmergencyResponseConfig) *EmergencyResp } } +// ---------- JVMHeapRule ---------- + +func (m *JVMHeapRuleModel) toProto() *apiv1.JVMHeapRuleConfig { + if m == nil { + return nil + } + return &apiv1.JVMHeapRuleConfig{ + Enabled: m.Enabled.ValueBool(), + TargetPercentile: m.TargetPercentile.ValueFloat32Pointer(), + HeadroomMultiplier: m.HeadroomMultiplier.ValueFloat32Pointer(), + NonHeapOverheadPercent: m.NonHeapOverheadPercent.ValueFloat32Pointer(), + NonHeapOverheadBytes: m.NonHeapOverheadBytes.ValueInt64Pointer(), + MinHeapBytes: m.MinHeapBytes.ValueInt64Pointer(), + MaxHeapBytes: m.MaxHeapBytes.ValueInt64Pointer(), + PreferContainerSupport: m.PreferContainerSupport.ValueBool(), + } +} + +func jvmHeapRuleFromProto(p *apiv1.JVMHeapRuleConfig) *JVMHeapRuleModel { + if p == nil { + return nil + } + return &JVMHeapRuleModel{ + Enabled: types.BoolValue(p.Enabled), + TargetPercentile: types.Float32PointerValue(p.TargetPercentile), + HeadroomMultiplier: types.Float32PointerValue(p.HeadroomMultiplier), + NonHeapOverheadPercent: types.Float32PointerValue(p.NonHeapOverheadPercent), + NonHeapOverheadBytes: types.Int64PointerValue(p.NonHeapOverheadBytes), + MinHeapBytes: types.Int64PointerValue(p.MinHeapBytes), + MaxHeapBytes: types.Int64PointerValue(p.MaxHeapBytes), + PreferContainerSupport: types.BoolValue(p.PreferContainerSupport), + } +} + +// ---------- KEDAScaledObject ---------- + +func (m *KEDAScaledObjectModel) toProto() *apiv1.KEDAScaledObjectTemplate { + if m == nil { + return nil + } + p := &apiv1.KEDAScaledObjectTemplate{ + Triggers: kedaTriggersToProto(m.Triggers), + MinReplicaCount: m.MinReplicaCount.ValueInt32Pointer(), + MaxReplicaCount: m.MaxReplicaCount.ValueInt32Pointer(), + IdleReplicaCount: m.IdleReplicaCount.ValueInt32Pointer(), + PollingInterval: m.PollingInterval.ValueInt32Pointer(), + CooldownPeriod: m.CooldownPeriod.ValueInt32Pointer(), + InitialCooldownPeriod: m.InitialCooldownPeriod.ValueInt32Pointer(), + } + if m.Fallback != nil { + p.Fallback = &apiv1.KEDAFallback{ + FailureThreshold: m.Fallback.FailureThreshold.ValueInt32(), + Replicas: m.Fallback.Replicas.ValueInt32(), + Behavior: m.Fallback.Behavior.ValueString(), + } + } + if m.Advanced != nil { + p.Advanced = &apiv1.KEDAAdvanced{ + RestoreToOriginalReplicaCount: m.Advanced.RestoreToOriginalReplicaCount.ValueBool(), + AdvancedBehaviorJson: m.Advanced.AdvancedBehaviorJson.ValueString(), + } + } + return p +} + +func kedaScaledObjectFromProto(p *apiv1.KEDAScaledObjectTemplate) *KEDAScaledObjectModel { + if p == nil { + return nil + } + m := &KEDAScaledObjectModel{ + Triggers: kedaTriggersFromProto(p.Triggers), + MinReplicaCount: types.Int32PointerValue(p.MinReplicaCount), + MaxReplicaCount: types.Int32PointerValue(p.MaxReplicaCount), + IdleReplicaCount: types.Int32PointerValue(p.IdleReplicaCount), + PollingInterval: types.Int32PointerValue(p.PollingInterval), + CooldownPeriod: types.Int32PointerValue(p.CooldownPeriod), + InitialCooldownPeriod: types.Int32PointerValue(p.InitialCooldownPeriod), + } + if p.Fallback != nil { + m.Fallback = &KEDAFallbackModel{ + FailureThreshold: int32OrNull(p.Fallback.FailureThreshold), + Replicas: int32OrNull(p.Fallback.Replicas), + Behavior: stringValue(p.Fallback.Behavior), + } + } + if p.Advanced != nil { + m.Advanced = &KEDAAdvancedModel{ + RestoreToOriginalReplicaCount: types.BoolValue(p.Advanced.RestoreToOriginalReplicaCount), + AdvancedBehaviorJson: stringValue(p.Advanced.AdvancedBehaviorJson), + } + } + return m +} + +func kedaTriggersToProto(ts []KEDATriggerModel) []*apiv1.KEDATrigger { + if len(ts) == 0 { + return nil + } + result := make([]*apiv1.KEDATrigger, len(ts)) + for i, t := range ts { + kt := &apiv1.KEDATrigger{ + Type: t.Type.ValueString(), + Name: t.Name.ValueString(), + MetricType: t.MetricType.ValueString(), + UseCachedMetrics: t.UseCachedMetrics.ValueBool(), + } + if !t.Metadata.IsNull() && !t.Metadata.IsUnknown() { + meta := make(map[string]string, len(t.Metadata.Elements())) + for k, v := range t.Metadata.Elements() { + if sv, ok := v.(types.String); ok { + meta[k] = sv.ValueString() + } + } + kt.Metadata = meta + } + if t.AuthenticationRef != nil { + kt.AuthenticationRef = &apiv1.KEDAAuthenticationRef{ + Name: t.AuthenticationRef.Name.ValueString(), + Kind: t.AuthenticationRef.Kind.ValueString(), + } + } + result[i] = kt + } + return result +} + +func kedaTriggersFromProto(ps []*apiv1.KEDATrigger) []KEDATriggerModel { + result := make([]KEDATriggerModel, 0, len(ps)) + for _, p := range ps { + if p == nil { + continue + } + m := KEDATriggerModel{ + Type: types.StringValue(p.Type), + Name: stringValue(p.Name), + Metadata: stringMapOrNull(p.Metadata), + MetricType: stringValue(p.MetricType), + UseCachedMetrics: types.BoolValue(p.UseCachedMetrics), + } + if p.AuthenticationRef != nil { + m.AuthenticationRef = &KEDAAuthenticationRefModel{ + Name: types.StringValue(p.AuthenticationRef.Name), + Kind: stringValue(p.AuthenticationRef.Kind), + } + } + result = append(result, m) + } + return result +} + // ---------- Containers ---------- func containerRuleModelsToProto(cs []ContainerRuleModel) []*apiv1.ContainerResourceRuleConfig { @@ -1306,8 +1703,8 @@ func containerResourceConfigFromProto(p *apiv1.ContainerResourceConfig) *Contain MaxRequest: types.Int64Null(), LimitMultiplier: types.Float32Null(), TargetPercentile: types.Float32Null(), - RequestUseRss: types.BoolPointerValue(p.RequestUseRss), - LimitUseRss: types.BoolPointerValue(p.LimitUseRss), + RequestUseRss: types.BoolValue(p.RequestUseRss != nil && *p.RequestUseRss), + LimitUseRss: types.BoolValue(p.LimitUseRss != nil && *p.LimitUseRss), } if p.MinRequest != nil { m.MinRequest = types.Int64Value(*p.MinRequest) diff --git a/internal/provider/workload_rule_test.go b/internal/provider/workload_rule_test.go index 80254cc..4166fcb 100644 --- a/internal/provider/workload_rule_test.go +++ b/internal/provider/workload_rule_test.go @@ -641,6 +641,269 @@ func TestWorkloadRuleResourceModel(t *testing.T) { } }) + // ---------- JVMHeapRuleModel ---------- + + t.Run("JVMHeapRuleModel_ToProto", func(t *testing.T) { + m := &JVMHeapRuleModel{ + Enabled: types.BoolValue(true), + TargetPercentile: types.Float32Value(0.75), + HeadroomMultiplier: types.Float32Value(1.2), + NonHeapOverheadPercent: types.Float32Value(0.1), + NonHeapOverheadBytes: types.Int64Value(1024), + MinHeapBytes: types.Int64Value(2048), + MaxHeapBytes: types.Int64Value(4096), + PreferContainerSupport: types.BoolValue(true), + } + + p := m.toProto() + if p == nil { + t.Fatal("Expected non-nil proto") + } + if !p.Enabled { + t.Error("Expected Enabled=true") + } + if p.TargetPercentile == nil || *p.TargetPercentile != 0.75 { + t.Errorf("Expected TargetPercentile=0.75, got %v", p.TargetPercentile) + } + if p.HeadroomMultiplier == nil || *p.HeadroomMultiplier != 1.2 { + t.Errorf("Expected HeadroomMultiplier=1.2, got %v", p.HeadroomMultiplier) + } + if p.NonHeapOverheadPercent == nil || *p.NonHeapOverheadPercent != 0.1 { + t.Errorf("Expected NonHeapOverheadPercent=0.1, got %v", p.NonHeapOverheadPercent) + } + if p.NonHeapOverheadBytes == nil || *p.NonHeapOverheadBytes != 1024 { + t.Errorf("Expected NonHeapOverheadBytes=1024, got %v", p.NonHeapOverheadBytes) + } + if p.MinHeapBytes == nil || *p.MinHeapBytes != 2048 { + t.Errorf("Expected MinHeapBytes=2048, got %v", p.MinHeapBytes) + } + if p.MaxHeapBytes == nil || *p.MaxHeapBytes != 4096 { + t.Errorf("Expected MaxHeapBytes=4096, got %v", p.MaxHeapBytes) + } + if !p.PreferContainerSupport { + t.Error("Expected PreferContainerSupport=true") + } + }) + + t.Run("JVMHeapRuleModel_ToProto_NilWhenNil", func(t *testing.T) { + var m *JVMHeapRuleModel + if m.toProto() != nil { + t.Error("Expected nil proto from nil model") + } + }) + + t.Run("JVMHeapRuleFromProto", func(t *testing.T) { + targetPercentile := float32(0.75) + headroomMultiplier := float32(1.2) + nonHeapOverheadPercent := float32(0.1) + nonHeapOverheadBytes := int64(1024) + minHeapBytes := int64(2048) + maxHeapBytes := int64(4096) + + p := &apiv1.JVMHeapRuleConfig{ + Enabled: true, + TargetPercentile: &targetPercentile, + HeadroomMultiplier: &headroomMultiplier, + NonHeapOverheadPercent: &nonHeapOverheadPercent, + NonHeapOverheadBytes: &nonHeapOverheadBytes, + MinHeapBytes: &minHeapBytes, + MaxHeapBytes: &maxHeapBytes, + PreferContainerSupport: true, + } + + m := jvmHeapRuleFromProto(p) + if m == nil { + t.Fatal("Expected non-nil model") + } + if !m.Enabled.ValueBool() { + t.Error("Expected Enabled=true") + } + if m.TargetPercentile.ValueFloat32() != 0.75 { + t.Errorf("Expected TargetPercentile=0.75, got %f", m.TargetPercentile.ValueFloat32()) + } + if m.HeadroomMultiplier.ValueFloat32() != 1.2 { + t.Errorf("Expected HeadroomMultiplier=1.2, got %f", m.HeadroomMultiplier.ValueFloat32()) + } + if m.NonHeapOverheadBytes.ValueInt64() != 1024 { + t.Errorf("Expected NonHeapOverheadBytes=1024, got %d", m.NonHeapOverheadBytes.ValueInt64()) + } + if m.MinHeapBytes.ValueInt64() != 2048 { + t.Errorf("Expected MinHeapBytes=2048, got %d", m.MinHeapBytes.ValueInt64()) + } + if m.MaxHeapBytes.ValueInt64() != 4096 { + t.Errorf("Expected MaxHeapBytes=4096, got %d", m.MaxHeapBytes.ValueInt64()) + } + if !m.PreferContainerSupport.ValueBool() { + t.Error("Expected PreferContainerSupport=true") + } + }) + + t.Run("JVMHeapRuleFromProto_Nil", func(t *testing.T) { + if jvmHeapRuleFromProto(nil) != nil { + t.Error("Expected nil model from nil proto") + } + }) + + // ---------- KEDAScaledObjectModel ---------- + + t.Run("KEDAScaledObjectModel_ToProto", func(t *testing.T) { + metadata, diags := types.MapValue(types.StringType, map[string]attr.Value{ + "query": types.StringValue("up"), + }) + if diags.HasError() { + t.Fatalf("Failed to build metadata map: %v", diags) + } + + m := &KEDAScaledObjectModel{ + Triggers: []KEDATriggerModel{ + { + Type: types.StringValue("prometheus"), + Name: types.StringValue("trigger-1"), + Metadata: metadata, + MetricType: types.StringValue("Value"), + AuthenticationRef: &KEDAAuthenticationRefModel{ + Name: types.StringValue("auth-secret"), + Kind: types.StringValue("TriggerAuthentication"), + }, + UseCachedMetrics: types.BoolValue(true), + }, + }, + MinReplicaCount: types.Int32Value(1), + MaxReplicaCount: types.Int32Value(10), + IdleReplicaCount: types.Int32Value(0), + PollingInterval: types.Int32Value(30), + CooldownPeriod: types.Int32Value(300), + InitialCooldownPeriod: types.Int32Value(60), + Fallback: &KEDAFallbackModel{ + FailureThreshold: types.Int32Value(3), + Replicas: types.Int32Value(2), + Behavior: types.StringValue("static"), + }, + Advanced: &KEDAAdvancedModel{ + RestoreToOriginalReplicaCount: types.BoolValue(true), + AdvancedBehaviorJson: types.StringValue(`{"foo":"bar"}`), + }, + } + + p := m.toProto() + if p == nil { + t.Fatal("Expected non-nil proto") + } + if len(p.Triggers) != 1 { + t.Fatalf("Expected 1 trigger, got %d", len(p.Triggers)) + } + trig := p.Triggers[0] + if trig.Type != "prometheus" { + t.Errorf("Expected Type=prometheus, got %s", trig.Type) + } + if trig.Name != "trigger-1" { + t.Errorf("Expected Name=trigger-1, got %s", trig.Name) + } + if trig.Metadata["query"] != "up" { + t.Errorf("Expected Metadata[query]=up, got %v", trig.Metadata) + } + if trig.AuthenticationRef == nil || trig.AuthenticationRef.Name != "auth-secret" { + t.Errorf("Expected AuthenticationRef.Name=auth-secret, got %v", trig.AuthenticationRef) + } + if !trig.UseCachedMetrics { + t.Error("Expected UseCachedMetrics=true") + } + if p.MinReplicaCount == nil || *p.MinReplicaCount != 1 { + t.Errorf("Expected MinReplicaCount=1, got %v", p.MinReplicaCount) + } + if p.MaxReplicaCount == nil || *p.MaxReplicaCount != 10 { + t.Errorf("Expected MaxReplicaCount=10, got %v", p.MaxReplicaCount) + } + if p.Fallback == nil || p.Fallback.FailureThreshold != 3 || p.Fallback.Replicas != 2 || p.Fallback.Behavior != "static" { + t.Errorf("Unexpected Fallback: %+v", p.Fallback) + } + if p.Advanced == nil || !p.Advanced.RestoreToOriginalReplicaCount || p.Advanced.AdvancedBehaviorJson != `{"foo":"bar"}` { + t.Errorf("Unexpected Advanced: %+v", p.Advanced) + } + }) + + t.Run("KEDAScaledObjectModel_ToProto_NilWhenNil", func(t *testing.T) { + var m *KEDAScaledObjectModel + if m.toProto() != nil { + t.Error("Expected nil proto from nil model") + } + }) + + t.Run("KEDAScaledObjectFromProto", func(t *testing.T) { + minReplicaCount := int32(1) + maxReplicaCount := int32(10) + + p := &apiv1.KEDAScaledObjectTemplate{ + Triggers: []*apiv1.KEDATrigger{ + { + Type: "prometheus", + Name: "trigger-1", + Metadata: map[string]string{"query": "up"}, + MetricType: "Value", + AuthenticationRef: &apiv1.KEDAAuthenticationRef{ + Name: "auth-secret", + Kind: "TriggerAuthentication", + }, + UseCachedMetrics: true, + }, + }, + MinReplicaCount: &minReplicaCount, + MaxReplicaCount: &maxReplicaCount, + Fallback: &apiv1.KEDAFallback{ + FailureThreshold: 3, + Replicas: 2, + Behavior: "static", + }, + Advanced: &apiv1.KEDAAdvanced{ + RestoreToOriginalReplicaCount: true, + AdvancedBehaviorJson: `{"foo":"bar"}`, + }, + } + + m := kedaScaledObjectFromProto(p) + if m == nil { + t.Fatal("Expected non-nil model") + } + if len(m.Triggers) != 1 { + t.Fatalf("Expected 1 trigger, got %d", len(m.Triggers)) + } + trig := m.Triggers[0] + if trig.Type.ValueString() != "prometheus" { + t.Errorf("Expected Type=prometheus, got %s", trig.Type.ValueString()) + } + if trig.AuthenticationRef == nil || trig.AuthenticationRef.Name.ValueString() != "auth-secret" { + t.Errorf("Expected AuthenticationRef.Name=auth-secret, got %v", trig.AuthenticationRef) + } + if m.MinReplicaCount.ValueInt32() != 1 { + t.Errorf("Expected MinReplicaCount=1, got %d", m.MinReplicaCount.ValueInt32()) + } + if m.Fallback == nil || m.Fallback.FailureThreshold.ValueInt32() != 3 { + t.Errorf("Unexpected Fallback: %+v", m.Fallback) + } + if m.Advanced == nil || !m.Advanced.RestoreToOriginalReplicaCount.ValueBool() { + t.Errorf("Unexpected Advanced: %+v", m.Advanced) + } + }) + + t.Run("KEDAScaledObjectFromProto_Nil", func(t *testing.T) { + if kedaScaledObjectFromProto(nil) != nil { + t.Error("Expected nil model from nil proto") + } + }) + + t.Run("KedaTriggersToProto_EmptyWhenEmpty", func(t *testing.T) { + if kedaTriggersToProto(nil) != nil { + t.Error("Expected nil slice from nil/empty input") + } + }) + + t.Run("KedaTriggersFromProto_EmptyWhenEmpty", func(t *testing.T) { + result := kedaTriggersFromProto(nil) + if len(result) != 0 { + t.Errorf("Expected empty slice, got %v", result) + } + }) + // ---------- ContainerResourceConfigModel ---------- t.Run("ContainerResourceConfigModel_ToProto", func(t *testing.T) { @@ -928,11 +1191,13 @@ func TestWorkloadRuleResourceModel(t *testing.T) { DetectionTriggers: types.ListValueMust(types.StringType, []attr.Value{ types.StringValue("pod_creation"), }), - SchedulerPlugins: types.ListValueMust(types.StringType, []attr.Value{}), - DefragmentationSchedule: types.StringNull(), - LiveMigrationEnabled: types.BoolValue(false), - UseInPlaceVerticalScaling: types.BoolValue(true), - Containers: nil, + SchedulerPlugins: types.ListValueMust(types.StringType, []attr.Value{}), + DefragmentationSchedule: types.StringNull(), + LiveMigrationEnabled: types.BoolValue(false), + UseInPlaceVerticalScaling: types.BoolValue(true), + AllowInPlaceMemoryLimitDecrease: types.BoolValue(true), + JvmCpuStartupFloorMillicores: types.Int64Value(500), + Containers: nil, } req := m.toProto(ctx, &diags, "team-456", true) @@ -972,6 +1237,18 @@ func TestWorkloadRuleResourceModel(t *testing.T) { if req.Fields.DefragmentationSchedule != nil { t.Errorf("Expected nil DefragmentationSchedule, got %v", req.Fields.DefragmentationSchedule) } + if !req.Fields.AllowInPlaceMemoryLimitDecrease { + t.Error("Expected AllowInPlaceMemoryLimitDecrease=true") + } + if req.Fields.JvmCpuStartupFloorMillicores == nil || *req.Fields.JvmCpuStartupFloorMillicores != 500 { + t.Errorf("Expected JvmCpuStartupFloorMillicores=500, got %v", req.Fields.JvmCpuStartupFloorMillicores) + } + if req.Fields.JvmHeapRule != nil { + t.Error("Expected nil JvmHeapRule when not set") + } + if req.Fields.KedaScaledObject != nil { + t.Error("Expected nil KedaScaledObject when not set") + } }) // ---------- WorkloadRuleResourceModel.fromProto ---------- @@ -999,10 +1276,11 @@ func TestWorkloadRuleResourceModel(t *testing.T) { apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_CREATION, apiv1.WorkloadDetectionTrigger_DETECTION_TRIGGER_POD_UPDATE, }, - SchedulerPlugins: []string{"binpacking"}, - DefragmentationSchedule: &defragSchedule, - LiveMigrationEnabled: true, - UseInPlaceVerticalScaling: false, + SchedulerPlugins: []string{"binpacking"}, + DefragmentationSchedule: &defragSchedule, + LiveMigrationEnabled: true, + UseInPlaceVerticalScaling: false, + AllowInPlaceMemoryLimitDecrease: true, } var m WorkloadRuleResourceModel @@ -1035,6 +1313,15 @@ func TestWorkloadRuleResourceModel(t *testing.T) { if m.UseInPlaceVerticalScaling.ValueBool() { t.Error("Expected UseInPlaceVerticalScaling=false") } + if !m.AllowInPlaceMemoryLimitDecrease.ValueBool() { + t.Error("Expected AllowInPlaceMemoryLimitDecrease=true") + } + if m.JvmHeapRule != nil { + t.Error("Expected nil JvmHeapRule when not set") + } + if m.KedaScaledObject != nil { + t.Error("Expected nil KedaScaledObject when not set") + } // Verify action triggers if m.ActionTriggers.IsNull() || len(m.ActionTriggers.Elements()) != 1 { From 5b37e85e857225587d17e0cd74cee8086d8cb0b0 Mon Sep 17 00:00:00 2001 From: Rupam-It Date: Thu, 27 Aug 2026 00:31:22 +0530 Subject: [PATCH 3/3] fixed Signed-off-by: Rupam-It --- internal/provider/node_policy.go | 34 +++++++++++++------ internal/provider/workload_policy.go | 50 ++++++++++++++++++++++++++-- internal/provider/workload_rule.go | 19 +++++++++-- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/internal/provider/node_policy.go b/internal/provider/node_policy.go index 7dcb451..31cc104 100644 --- a/internal/provider/node_policy.go +++ b/internal/provider/node_policy.go @@ -1840,6 +1840,8 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { // Disruption policy if policy.Disruption != nil && !isDisruptionEmpty(policy.Disruption) { m.Disruption = disruptionPolicyFromProto(policy.Disruption) + } else { + m.Disruption = nil } // Limits @@ -1848,6 +1850,8 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { Cpu: types.StringValue(policy.Limits.Cpu), Memory: types.StringValue(policy.Limits.Memory), } + } else { + m.Limits = nil } // Tooltip fields for node config @@ -1888,21 +1892,29 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) { // AWS configuration if policy.Aws != nil && !isAWSSpecEmpty(policy.Aws) { m.Aws = awsNodeClassFromProto(policy.Aws) + } else { + m.Aws = nil } // Azure configuration if policy.Azure != nil && !isAzureSpecEmpty(policy.Azure) { m.Azure = azureNodeClassFromProto(policy.Azure) + } else { + m.Azure = nil } // GCP configuration if policy.Gcp != nil && !isGCPSpecEmpty(policy.Gcp) { m.Gcp = gcpNodeClassFromProto(policy.Gcp) + } else { + m.Gcp = nil } // OCI configuration if policy.Oci != nil && !isOCISpecEmpty(policy.Oci) { m.Oci = ociNodeClassFromProto(policy.Oci) + } else { + m.Oci = nil } // Raw specs @@ -2726,31 +2738,31 @@ func awsNodeClassFromProto(spec *apiv1.AWSNodeClassSpec) *AWSNodeClass { aws.AssociatePublicIpAddress = types.BoolNull() } - // Metadata options + // Metadata options. This attribute is Computed with a static object + // Default, so it must never resolve to null — fall back to the schema's + // default values (matching the Default in the schema) at every level + // when the backend omits the block or an individual field. + metadataOpts := &MetadataOptions{ + HttpEndpoint: types.StringValue("enabled"), + HttpProtocolIpv6: types.StringValue("disabled"), + HttpPutResponseHopLimit: types.Int64Value(2), + HttpTokens: types.StringValue("required"), + } if spec.MetadataOptions != nil { - metadataOpts := &MetadataOptions{} if spec.MetadataOptions.HttpEndpoint != nil { metadataOpts.HttpEndpoint = types.StringValue(*spec.MetadataOptions.HttpEndpoint) - } else { - metadataOpts.HttpEndpoint = types.StringNull() } if spec.MetadataOptions.HttpProtocolIpv6 != nil { metadataOpts.HttpProtocolIpv6 = types.StringValue(*spec.MetadataOptions.HttpProtocolIpv6) - } else { - metadataOpts.HttpProtocolIpv6 = types.StringNull() } if spec.MetadataOptions.HttpPutResponseHopLimit != nil { metadataOpts.HttpPutResponseHopLimit = types.Int64Value(*spec.MetadataOptions.HttpPutResponseHopLimit) - } else { - metadataOpts.HttpPutResponseHopLimit = types.Int64Null() } if spec.MetadataOptions.HttpTokens != nil { metadataOpts.HttpTokens = types.StringValue(*spec.MetadataOptions.HttpTokens) - } else { - metadataOpts.HttpTokens = types.StringNull() } - aws.MetadataOptions = metadataOpts } + aws.MetadataOptions = metadataOpts // Capacity reservation selector terms if len(spec.CapacityReservationSelectorTerms) > 0 { diff --git a/internal/provider/workload_policy.go b/internal/provider/workload_policy.go index c2c092c..419533c 100644 --- a/internal/provider/workload_policy.go +++ b/internal/provider/workload_policy.go @@ -210,11 +210,15 @@ func (r *WorkloadPolicyResource) Schema(ctx context.Context, req resource.Schema Description: "Memory only: size the request from RSS instead of working set", MarkdownDescription: "Memory only: when true, size the memory request recommendation from RSS (resident set size) instead of the default working set. Ignored for CPU/GPU.", Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), }, "limit_use_rss": schema.BoolAttribute{ Description: "Memory only: derive the limit from an RSS-based recommendation", MarkdownDescription: "Memory only: when true, the limit is derived from an RSS-based recommendation instead of the working-set one (the limit multiplier still applies). Ignored for CPU/GPU.", Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), }, } } @@ -636,7 +640,9 @@ func (r *WorkloadPolicyResource) Create(ctx context.Context, req resource.Create return } + plan := data data.fromProto(createWorkloadPolicyResp.Msg.Policy) + data.preserveNullsFrom(&plan) // Write logs using the tflog package tflog.Trace(ctx, "created a resource") @@ -675,7 +681,14 @@ func (r *WorkloadPolicyResource) Read(ctx context.Context, req resource.ReadRequ return } + prior := data data.fromProto(getWorkloadPolicyResp.Msg.Policy) + if !prior.Name.IsNull() { + // prior.Name is only null right after import (ImportStatePassthroughID + // only sets id), where there is no real prior config to preserve nulls + // from and fromProto's result should be trusted as-is. + data.preserveNullsFrom(&prior) + } // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) @@ -712,7 +725,9 @@ func (r *WorkloadPolicyResource) Update(ctx context.Context, req resource.Update return } + plan := data data.fromProto(updateWorkloadPolicyResp.Msg.Policy) + data.preserveNullsFrom(&plan) // Save updated data into Terraform state resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) @@ -845,6 +860,37 @@ func (m *WorkloadPolicyResourceModel) toProto(ctx context.Context, diags *diag.D } } +// preserveNullsFrom nils out fields that were null in plan (i.e. never +// configured by the user), undoing any non-null value fromProto assigned to +// them. This is necessary because the backend echoes back non-nil messages +// with baseline defaults (e.g. min_data_points=15, enabled=false) for axes +// the user never configured, and isVerticalScalingEmpty/isHorizontalScalingEmpty +// key emptiness off Enabled alone — which means a deliberately-configured +// block with enabled=false (or an axis whose enabled defaults to false, like +// gpu_vertical_scaling and horizontal_scaling) would otherwise be collapsed +// to null even though it's present in the plan. See preserveNullsFrom on +// WorkloadRuleResourceModel for the same pattern. +func (m *WorkloadPolicyResourceModel) preserveNullsFrom(plan *WorkloadPolicyResourceModel) { + if plan.CPUVerticalScaling == nil { + m.CPUVerticalScaling = nil + } + if plan.MemoryVerticalScaling == nil { + m.MemoryVerticalScaling = nil + } + if plan.GPUVerticalScaling == nil { + m.GPUVerticalScaling = nil + } + if plan.GPUVRAMVerticalScaling == nil { + m.GPUVRAMVerticalScaling = nil + } + if plan.HorizontalScaling == nil { + m.HorizontalScaling = nil + } + if plan.EmergencyResponse == nil { + m.EmergencyResponse = nil + } +} + func (m *WorkloadPolicyResourceModel) fromProto(policy *apiv1.WorkloadRecommendationPolicy) { m.Id = types.StringValue(policy.PolicyId) m.Name = types.StringValue(policy.Name) @@ -1025,8 +1071,8 @@ func verticalScalingOptionsFromProto(target *apiv1.VerticalScalingOptimizationTa } o.AdjustReqEvenIfNotSet = types.BoolValue(target.AdjustReqEvenIfNotSet) o.LimitsRemovalEnabled = types.BoolValue(target.LimitsRemovalEnabled) - o.RequestUseRss = types.BoolPointerValue(target.RequestUseRss) - o.LimitUseRss = types.BoolPointerValue(target.LimitUseRss) + o.RequestUseRss = types.BoolValue(target.RequestUseRss != nil && *target.RequestUseRss) + o.LimitUseRss = types.BoolValue(target.LimitUseRss != nil && *target.LimitUseRss) return o } diff --git a/internal/provider/workload_rule.go b/internal/provider/workload_rule.go index 203b47a..a540cbb 100644 --- a/internal/provider/workload_rule.go +++ b/internal/provider/workload_rule.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32default" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/schema/validator" @@ -722,10 +723,14 @@ func (r *WorkloadRuleResource) Schema(ctx context.Context, req resource.SchemaRe "failure_threshold": schema.Int32Attribute{ Description: "Number of consecutive metric failures before activating fallback", Optional: true, + Computed: true, + Default: int32default.StaticInt32(0), }, "replicas": schema.Int32Attribute{ Description: "Number of replicas to fall back to when metrics are unavailable", Optional: true, + Computed: true, + Default: int32default.StaticInt32(0), }, "behavior": schema.StringAttribute{ Description: "Fallback strategy", @@ -936,7 +941,12 @@ func (r *WorkloadRuleResource) Read(ctx context.Context, req resource.ReadReques prior := data data.fromProto(getRuleResp.Msg.Rule) - data.preserveNullsFrom(&prior) + if !prior.Name.IsNull() { + // prior.Name is only null right after import (ImportStatePassthroughID + // only sets id), where there is no real prior config to preserve nulls + // from and fromProto's result should be trusted as-is. + data.preserveNullsFrom(&prior) + } resp.Diagnostics.Append(resp.State.Set(ctx, &data)...) } @@ -1557,8 +1567,8 @@ func kedaScaledObjectFromProto(p *apiv1.KEDAScaledObjectTemplate) *KEDAScaledObj } if p.Fallback != nil { m.Fallback = &KEDAFallbackModel{ - FailureThreshold: int32OrNull(p.Fallback.FailureThreshold), - Replicas: int32OrNull(p.Fallback.Replicas), + FailureThreshold: types.Int32Value(p.Fallback.FailureThreshold), + Replicas: types.Int32Value(p.Fallback.Replicas), Behavior: stringValue(p.Fallback.Behavior), } } @@ -1604,6 +1614,9 @@ func kedaTriggersToProto(ts []KEDATriggerModel) []*apiv1.KEDATrigger { } func kedaTriggersFromProto(ps []*apiv1.KEDATrigger) []KEDATriggerModel { + if len(ps) == 0 { + return nil + } result := make([]KEDATriggerModel, 0, len(ps)) for _, p := range ps { if p == nil {