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/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;
}
diff --git a/internal/provider/node_policy.go b/internal/provider/node_policy.go
index 49508ab..31cc104 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,24 +1838,29 @@ 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)
+ } else {
+ m.Disruption = nil
}
// 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),
}
+ } else {
+ m.Limits = nil
}
// 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 +1876,7 @@ func (m *NodePolicyResourceModel) fromProto(policy *apiv1.NodePolicy) {
} else {
m.InstanceLocalNvme = nil
}
+ m.InstanceLocalNvmeTip = stringPointerValue(policy.InstanceLocalNvmeTip)
m.CloudProviderId = types.Int64PointerValue(policy.CloudProviderId)
@@ -1479,11 +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
@@ -1801,6 +2232,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 +2294,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 +2337,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 +2419,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 +2524,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 +2537,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 +2550,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 +2563,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 +2592,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 +2624,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 +2679,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 +2701,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,
},
},
},
@@ -2348,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 {
@@ -2414,25 +2804,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 +2813,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 +3024,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 +3122,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..419533c 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"`
@@ -209,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),
},
}
}
@@ -495,6 +500,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,
@@ -630,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")
@@ -669,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)...)
@@ -706,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)...)
@@ -816,6 +837,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(),
@@ -838,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)
@@ -928,6 +981,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 +1025,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{}
@@ -1007,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
}
@@ -1032,8 +1096,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..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"
@@ -60,6 +61,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 +144,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 +211,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 +369,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 +420,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 +593,167 @@ 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,
- Computed: true,
},
- "cpu_throttling_enabled": schema.BoolAttribute{
- Description: "React to CPU throttling by increasing CPU request",
+ "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,
+ },
+ "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,
+ },
+ "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,
+ 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",
+ Optional: true,
+ },
+ },
},
- "cpu_throttling_multiplier": schema.Float32Attribute{
- Description: "Multiplier applied to CPU request on throttle reaction",
+ "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 +814,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).",
@@ -706,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)...)
}
@@ -851,17 +1091,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 +1153,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 +1210,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 +1340,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 +1487,159 @@ 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: types.Int32Value(p.Fallback.FailureThreshold),
+ Replicas: types.Int32Value(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 {
+ if len(ps) == 0 {
+ return nil
+ }
+ 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 +1716,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 {