From e81131ca571643df75e194d0ce623173dfa65cda Mon Sep 17 00:00:00 2001
From: Alex Savanovich <40720931+savme@users.noreply.github.com>
Date: Wed, 29 Jul 2026 20:14:54 +0300
Subject: [PATCH 1/4] feat: add /scale subresource to WorkloadDeployment
---
api/v1alpha/workloaddeployment_types.go | 12 +
api/v1alpha/zz_generated.deepcopy.go | 5 +
...ute.datumapis.com_workloaddeployments.yaml | 14 +
config/components/controller_rbac/role.yaml | 9 +
docs/api/instances.md | 375 +++++++++++++++-
docs/api/workloaddeployments.md | 398 ++++++++++++++++-
docs/api/workloads.md | 415 +++++++++++++++++-
.../instancecontrol/instancecontrol.go | 1 +
.../stateful/stateful_control.go | 5 +-
.../stateful/stateful_control_test.go | 49 ++-
internal/controller/teardown.go | 3 +
internal/controller/workload_controller.go | 1 +
.../controller/workload_controller_test.go | 48 ++
.../workloaddeployment_controller.go | 32 +-
.../workloaddeployment_controller_test.go | 110 ++++-
.../workloaddeployment_location_test.go | 1 +
16 files changed, 1431 insertions(+), 47 deletions(-)
diff --git a/api/v1alpha/workloaddeployment_types.go b/api/v1alpha/workloaddeployment_types.go
index c1ec37f0..0c6c6380 100644
--- a/api/v1alpha/workloaddeployment_types.go
+++ b/api/v1alpha/workloaddeployment_types.go
@@ -33,6 +33,12 @@ type WorkloadDeploymentSpec struct {
//
// +kubebuilder:validation:Required
ScaleSettings HorizontalScaleSettings `json:"scaleSettings"`
+
+ // Replicas is the current desired replica target for this deployment. When
+ // unset, the deployment reconciles to scaleSettings.minReplicas.
+ //
+ // +kubebuilder:validation:Optional
+ Replicas *int32 `json:"replicas,omitempty"`
}
// WorkloadDeploymentStatus defines the observed state of WorkloadDeployment
@@ -65,6 +71,11 @@ type WorkloadDeploymentStatus struct {
// The number of instances which are ready.
ReadyReplicas int32 `json:"readyReplicas"`
+ // Selector is the label selector that identifies Pods backing this deployment.
+ //
+ // +kubebuilder:validation:Optional
+ Selector string `json:"selector,omitempty"`
+
// The most recent generation observed by the deployment controller. When
// this matches metadata.generation, the controller has reconciled the
// latest spec (e.g. a restart request).
@@ -90,6 +101,7 @@ const (
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
+// +kubebuilder:subresource:scale:specpath=.spec.replicas,statuspath=.status.replicas,selectorpath=.status.selector
// +kubebuilder:metadata:annotations="discovery.miloapis.com/parent-contexts=Project"
// WorkloadDeployment is the Schema for the workloaddeployments API
diff --git a/api/v1alpha/zz_generated.deepcopy.go b/api/v1alpha/zz_generated.deepcopy.go
index ca5b2830..65d1e1e9 100644
--- a/api/v1alpha/zz_generated.deepcopy.go
+++ b/api/v1alpha/zz_generated.deepcopy.go
@@ -984,6 +984,11 @@ func (in *WorkloadDeploymentSpec) DeepCopyInto(out *WorkloadDeploymentSpec) {
out.WorkloadRef = in.WorkloadRef
in.Template.DeepCopyInto(&out.Template)
in.ScaleSettings.DeepCopyInto(&out.ScaleSettings)
+ if in.Replicas != nil {
+ in, out := &in.Replicas, &out.Replicas
+ *out = new(int32)
+ **out = **in
+ }
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkloadDeploymentSpec.
diff --git a/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml b/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml
index f7f9fd22..75b77feb 100644
--- a/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml
+++ b/config/base/crd/bases/compute.datumapis.com_workloaddeployments.yaml
@@ -78,6 +78,12 @@ spec:
placementName:
description: The placement in the workload which is driving a deployment
type: string
+ replicas:
+ description: |-
+ Replicas is the current desired replica target for this deployment. When
+ unset, the deployment reconciles to scaleSettings.minReplicas.
+ format: int32
+ type: integer
scaleSettings:
description: Scale settings such as minimum and maximum replica counts.
properties:
@@ -1173,6 +1179,10 @@ spec:
description: The number of instances created
format: int32
type: integer
+ selector:
+ description: Selector is the label selector that identifies Pods backing
+ this deployment.
+ type: string
suspended:
description: |-
Suspended, when true, requests that all instances managed by this deployment
@@ -1197,4 +1207,8 @@ spec:
served: true
storage: true
subresources:
+ scale:
+ labelSelectorPath: .status.selector
+ specReplicasPath: .spec.replicas
+ statusReplicasPath: .status.replicas
status: {}
diff --git a/config/components/controller_rbac/role.yaml b/config/components/controller_rbac/role.yaml
index d3f92436..e0c6beb5 100644
--- a/config/components/controller_rbac/role.yaml
+++ b/config/components/controller_rbac/role.yaml
@@ -96,3 +96,12 @@ rules:
- get
- list
- watch
+- apiGroups:
+ - services.miloapis.com
+ resources:
+ - serviceconsumers
+ - services
+ verbs:
+ - get
+ - list
+ - watch
diff --git a/docs/api/instances.md b/docs/api/instances.md
index 580ab30c..b603ad99 100644
--- a/docs/api/instances.md
+++ b/docs/api/instances.md
@@ -53,14 +53,16 @@ Instance is the Schema for the instances API
spec |
object |
- InstanceSpec defines the desired state of Instance
+ Spec defines the desired state of an Instance.
|
false |
| status |
object |
- InstanceStatus defines the observed state of Instance
+ Status defines the current state of an Instance.
+
+ Default: map[conditions:[map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for controller reason:Pending status:Unknown type:Programmed] map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for controller reason:Pending status:Unknown type:Available] map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for controller reason:Pending status:Unknown type:Ready] map[lastTransitionTime:1970-01-01T00:00:00Z message:Waiting for quota evaluation reason:PendingEvaluation status:Unknown type:QuotaGranted]]]
|
false |
@@ -72,7 +74,7 @@ Instance is the Schema for the instances API
-InstanceSpec defines the desired state of Instance
+Spec defines the desired state of an Instance.
@@ -97,11 +99,26 @@ InstanceSpec defines the desired state of Instance
The runtime type of the instance, such as a container sandbox or a VM.
true |
+
+ | controller |
+ object |
+
+ Controller contains settings driven by the controller managing the instance.
+ |
+ false |
+
+ | location |
+ object |
+
+ The location which the instance has been scheduled to
+ |
+ false |
| volumes |
[]object |
-
+ Volumes that must be available to attach to an instance's containers or
+Virtual Machine.
|
false |
@@ -536,6 +553,30 @@ used by the instance.
The name of the container.
true |
+
+ | args |
+ []string |
+
+ Arguments to the entrypoint, overriding the image's CMD. Combined with
+Command: when Command is also set the resulting invocation is
+append(Command, Args...). When only Args is set it overrides CMD while
+preserving the image's ENTRYPOINT.
+
+If neither Command nor Args is set, the image's own ENTRYPOINT and CMD
+are used unchanged.
+ |
+ false |
+
+ | command |
+ []string |
+
+ Entrypoint array to run in the container image, overriding the image's
+ENTRYPOINT. Each element is a separate token, not a shell command — to run a
+shell command use: ["sh", "-c", "my command"].
+
+If not provided, the container image's own ENTRYPOINT is used.
+ |
+ false |
| env |
[]object |
@@ -545,6 +586,18 @@ used by the instance.
so replicate the structure here too.
false |
+
+ | envFrom |
+ []object |
+
+ List of sources to populate environment variables in the container.
+The keys defined within a source must be a C_IDENTIFIER. All invalid
+keys will be reported as an event when the container is starting. When a
+key exists in multiple sources, the value associated with the last source
+will take precedence. Values defined by an Env with a duplicate key will
+take precedence.
+ |
+ false |
| ports |
[]object |
@@ -590,7 +643,8 @@ EnvVar represents an environment variable present in a Container.
name |
string |
- Name of the environment variable. Must be a C_IDENTIFIER.
+ Name of the environment variable.
+May consist of any printable ASCII characters except '='.
|
true |
@@ -650,6 +704,14 @@ Source for the environment variable's value. Cannot be used if value is not empt
spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs.
false |
+
+ | fileKeyRef |
+ object |
+
+ FileKeyRef selects a key of the env file.
+Requires the EnvFiles feature gate to be enabled.
+ |
+ false |
| resourceFieldRef |
object |
@@ -751,6 +813,66 @@ spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podI
+### Instance.spec.runtime.sandbox.containers[index].env[index].valueFrom.fileKeyRef
+[↩ Parent](#instancespecruntimesandboxcontainersindexenvindexvaluefrom)
+
+
+
+FileKeyRef selects a key of the env file.
+Requires the EnvFiles feature gate to be enabled.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | key |
+ string |
+
+ The key within the env file. An invalid key will prevent the pod from starting.
+The keys defined within a source may consist of any printable ASCII characters except '='.
+During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters.
+ |
+ true |
+
+ | path |
+ string |
+
+ The path within the volume from which to select the file.
+Must be relative and may not contain the '..' path or start with '..'.
+ |
+ true |
+
+ | volumeName |
+ string |
+
+ The name of the volume mount containing the env file.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the file or its key must be defined. If the file or key
+does not exist, then the env var is not published.
+If optional is set to true and the specified key does not exist,
+the environment variable will not be set in the Pod's containers.
+
+If optional is set to false and the specified key does not exist,
+an error will be returned during Pod creation.
+
+ Default: false
+ |
+ false |
+
+
+
+
### Instance.spec.runtime.sandbox.containers[index].env[index].valueFrom.resourceFieldRef
[↩ Parent](#instancespecruntimesandboxcontainersindexenvindexvaluefrom)
@@ -840,6 +962,117 @@ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/nam
+### Instance.spec.runtime.sandbox.containers[index].envFrom[index]
+[↩ Parent](#instancespecruntimesandboxcontainersindex)
+
+
+
+EnvFromSource represents a source for a set of ConfigMaps or Secrets to be
+used as environment variables in a container.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | configMapRef |
+ object |
+
+ The ConfigMap to select from.
+ |
+ false |
+
+ | prefix |
+ string |
+
+ An optional identifier to prepend to each key in the referenced
+ConfigMap or Secret. Must be a valid C_IDENTIFIER.
+ |
+ false |
+
+ | secretRef |
+ object |
+
+ The Secret to select from.
+ |
+ false |
+
+
+
+
+### Instance.spec.runtime.sandbox.containers[index].envFrom[index].configMapRef
+[↩ Parent](#instancespecruntimesandboxcontainersindexenvfromindex)
+
+
+
+The ConfigMap to select from.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of the ConfigMap in the same namespace as the Workload.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the ConfigMap must be defined.
+ |
+ false |
+
+
+
+
+### Instance.spec.runtime.sandbox.containers[index].envFrom[index].secretRef
+[↩ Parent](#instancespecruntimesandboxcontainersindexenvfromindex)
+
+
+
+The Secret to select from.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of the Secret in the same namespace as the Workload.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the Secret must be defined.
+ |
+ false |
+
+
+
+
### Instance.spec.runtime.sandbox.containers[index].ports[index]
[↩ Parent](#instancespecruntimesandboxcontainersindex)
@@ -1108,6 +1341,102 @@ If not specified, this field defaults to TCP.
+### Instance.spec.controller
+[↩ Parent](#instancespec)
+
+
+
+Controller contains settings driven by the controller managing the instance.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | templateHash |
+ string |
+
+ TemplateHash is the hash of the instance template applied for this instance.
+ |
+ true |
+
+ | schedulingGates |
+ []object |
+
+ SchedulingGates is a list of gates that must be satisfied before the
+instance can be scheduled.
+ |
+ false |
+
+
+
+
+### Instance.spec.controller.schedulingGates[index]
+[↩ Parent](#instancespeccontroller)
+
+
+
+
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ The name of the gate.
+ |
+ true |
+
+
+
+
+### Instance.spec.location
+[↩ Parent](#instancespec)
+
+
+
+The location which the instance has been scheduled to
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of a datum location
+ |
+ true |
+
+ | namespace |
+ string |
+
+ Namespace for the datum location
+ |
+ true |
+
+
+
+
### Instance.spec.volumes[index]
[↩ Parent](#instancespec)
@@ -1690,7 +2019,7 @@ mode, like fsGroup, and the result can be other mode bits set.
-InstanceStatus defines the observed state of Instance
+Status defines the current state of an Instance.
@@ -1709,6 +2038,13 @@ InstanceStatus defines the observed state of Instance
Known condition types are: "Available", "Progressing"
false |
+
+ | controller |
+ object |
+
+ Controller contains status information about the controller managing the instance.
+ |
+ false |
| networkInterfaces |
[]object |
@@ -1797,6 +2133,33 @@ with respect to the current state of the instance.
+### Instance.status.controller
+[↩ Parent](#instancestatus)
+
+
+
+Controller contains status information about the controller managing the instance.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | observedTemplateHash |
+ string |
+
+ ObservedTemplateHash is the hash of the instance template applied for this instance.
+ |
+ true |
+
+
+
+
### Instance.status.networkInterfaces[index]
[↩ Parent](#instancestatus)
diff --git a/docs/api/workloaddeployments.md b/docs/api/workloaddeployments.md
index 63b905d1..e3e5b079 100644
--- a/docs/api/workloaddeployments.md
+++ b/docs/api/workloaddeployments.md
@@ -118,6 +118,16 @@ WorkloadDeploymentSpec defines the desired state of WorkloadDeployment
The workload that a deployment belongs to
true |
+
+ | replicas |
+ integer |
+
+ Replicas is the current desired replica target for this deployment. When
+unset, the deployment reconciles to scaleSettings.minReplicas.
+
+ Format: int32
+ |
+ false |
@@ -139,6 +149,16 @@ Scale settings such as minimum and maximum replica counts.
+ | instanceManagementPolicy |
+ string |
+
+ Controls how instances are managed during scale up and down, as well as
+during maintenance events.
+
+ Default: OrderedReady
+ |
+ true |
+
| minReplicas |
integer |
@@ -338,11 +358,26 @@ Describes the desired configuration of an instance
The runtime type of the instance, such as a container sandbox or a VM.
|
true |
+
+ | controller |
+ object |
+
+ Controller contains settings driven by the controller managing the instance.
+ |
+ false |
+
+ | location |
+ object |
+
+ The location which the instance has been scheduled to
+ |
+ false |
| volumes |
[]object |
-
+ Volumes that must be available to attach to an instance's containers or
+Virtual Machine.
|
false |
@@ -777,6 +812,30 @@ used by the instance.
The name of the container.
true |
+
+ | args |
+ []string |
+
+ Arguments to the entrypoint, overriding the image's CMD. Combined with
+Command: when Command is also set the resulting invocation is
+append(Command, Args...). When only Args is set it overrides CMD while
+preserving the image's ENTRYPOINT.
+
+If neither Command nor Args is set, the image's own ENTRYPOINT and CMD
+are used unchanged.
+ |
+ false |
+
+ | command |
+ []string |
+
+ Entrypoint array to run in the container image, overriding the image's
+ENTRYPOINT. Each element is a separate token, not a shell command — to run a
+shell command use: ["sh", "-c", "my command"].
+
+If not provided, the container image's own ENTRYPOINT is used.
+ |
+ false |
| env |
[]object |
@@ -786,6 +845,18 @@ used by the instance.
so replicate the structure here too.
false |
+
+ | envFrom |
+ []object |
+
+ List of sources to populate environment variables in the container.
+The keys defined within a source must be a C_IDENTIFIER. All invalid
+keys will be reported as an event when the container is starting. When a
+key exists in multiple sources, the value associated with the last source
+will take precedence. Values defined by an Env with a duplicate key will
+take precedence.
+ |
+ false |
| ports |
[]object |
@@ -831,7 +902,8 @@ EnvVar represents an environment variable present in a Container.
name |
string |
- Name of the environment variable. Must be a C_IDENTIFIER.
+ Name of the environment variable.
+May consist of any printable ASCII characters except '='.
|
true |
@@ -891,6 +963,14 @@ Source for the environment variable's value. Cannot be used if value is not empt
spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs.
false |
+
+ | fileKeyRef |
+ object |
+
+ FileKeyRef selects a key of the env file.
+Requires the EnvFiles feature gate to be enabled.
+ |
+ false |
| resourceFieldRef |
object |
@@ -992,6 +1072,66 @@ spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podI
+### WorkloadDeployment.spec.template.spec.runtime.sandbox.containers[index].env[index].valueFrom.fileKeyRef
+[↩ Parent](#workloaddeploymentspectemplatespecruntimesandboxcontainersindexenvindexvaluefrom)
+
+
+
+FileKeyRef selects a key of the env file.
+Requires the EnvFiles feature gate to be enabled.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | key |
+ string |
+
+ The key within the env file. An invalid key will prevent the pod from starting.
+The keys defined within a source may consist of any printable ASCII characters except '='.
+During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters.
+ |
+ true |
+
+ | path |
+ string |
+
+ The path within the volume from which to select the file.
+Must be relative and may not contain the '..' path or start with '..'.
+ |
+ true |
+
+ | volumeName |
+ string |
+
+ The name of the volume mount containing the env file.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the file or its key must be defined. If the file or key
+does not exist, then the env var is not published.
+If optional is set to true and the specified key does not exist,
+the environment variable will not be set in the Pod's containers.
+
+If optional is set to false and the specified key does not exist,
+an error will be returned during Pod creation.
+
+ Default: false
+ |
+ false |
+
+
+
+
### WorkloadDeployment.spec.template.spec.runtime.sandbox.containers[index].env[index].valueFrom.resourceFieldRef
[↩ Parent](#workloaddeploymentspectemplatespecruntimesandboxcontainersindexenvindexvaluefrom)
@@ -1081,6 +1221,117 @@ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/nam
+### WorkloadDeployment.spec.template.spec.runtime.sandbox.containers[index].envFrom[index]
+[↩ Parent](#workloaddeploymentspectemplatespecruntimesandboxcontainersindex)
+
+
+
+EnvFromSource represents a source for a set of ConfigMaps or Secrets to be
+used as environment variables in a container.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | configMapRef |
+ object |
+
+ The ConfigMap to select from.
+ |
+ false |
+
+ | prefix |
+ string |
+
+ An optional identifier to prepend to each key in the referenced
+ConfigMap or Secret. Must be a valid C_IDENTIFIER.
+ |
+ false |
+
+ | secretRef |
+ object |
+
+ The Secret to select from.
+ |
+ false |
+
+
+
+
+### WorkloadDeployment.spec.template.spec.runtime.sandbox.containers[index].envFrom[index].configMapRef
+[↩ Parent](#workloaddeploymentspectemplatespecruntimesandboxcontainersindexenvfromindex)
+
+
+
+The ConfigMap to select from.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of the ConfigMap in the same namespace as the Workload.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the ConfigMap must be defined.
+ |
+ false |
+
+
+
+
+### WorkloadDeployment.spec.template.spec.runtime.sandbox.containers[index].envFrom[index].secretRef
+[↩ Parent](#workloaddeploymentspectemplatespecruntimesandboxcontainersindexenvfromindex)
+
+
+
+The Secret to select from.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of the Secret in the same namespace as the Workload.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the Secret must be defined.
+ |
+ false |
+
+
+
+
### WorkloadDeployment.spec.template.spec.runtime.sandbox.containers[index].ports[index]
[↩ Parent](#workloaddeploymentspectemplatespecruntimesandboxcontainersindex)
@@ -1349,6 +1600,102 @@ If not specified, this field defaults to TCP.
+### WorkloadDeployment.spec.template.spec.controller
+[↩ Parent](#workloaddeploymentspectemplatespec)
+
+
+
+Controller contains settings driven by the controller managing the instance.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | templateHash |
+ string |
+
+ TemplateHash is the hash of the instance template applied for this instance.
+ |
+ true |
+
+ | schedulingGates |
+ []object |
+
+ SchedulingGates is a list of gates that must be satisfied before the
+instance can be scheduled.
+ |
+ false |
+
+
+
+
+### WorkloadDeployment.spec.template.spec.controller.schedulingGates[index]
+[↩ Parent](#workloaddeploymentspectemplatespeccontroller)
+
+
+
+
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ The name of the gate.
+ |
+ true |
+
+
+
+
+### WorkloadDeployment.spec.template.spec.location
+[↩ Parent](#workloaddeploymentspectemplatespec)
+
+
+
+The location which the instance has been scheduled to
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of a datum location
+ |
+ true |
+
+ | namespace |
+ string |
+
+ Namespace for the datum location
+ |
+ true |
+
+
+
+
### WorkloadDeployment.spec.template.spec.volumes[index]
[↩ Parent](#workloaddeploymentspectemplatespec)
@@ -2035,8 +2382,8 @@ WorkloadDeploymentStatus defines the observed state of WorkloadDeployment
currentReplicas |
integer |
- The number of instances created by a deployment and have the latest
-deployment generation settings applied.
+ The number of instances which have the latest workload settings applied
+and are programmed (a subset of UpdatedReplicas that are ready to serve).
Format: int32
|
@@ -2045,7 +2392,16 @@ deployment generation settings applied.
desiredReplicas |
integer |
- The desired number of instances to be managed by a deployment.
+ The desired number of instances
+
+ Format: int32
+ |
+ true |
+
+ | readyReplicas |
+ integer |
+
+ The number of instances which are ready.
Format: int32
|
@@ -2054,7 +2410,19 @@ deployment generation settings applied.
replicas |
integer |
- The number of instances created by a deployment
+ The number of instances created
+
+ Format: int32
+ |
+ true |
+
+ | updatedReplicas |
+ integer |
+
+ The number of instances updated to the latest template revision, i.e.
+whose observed template hash matches the desired template, regardless of
+readiness. Lags Replicas during a rolling update or restart, then catches
+back up — making an in-progress roll observable.
Format: int32
|
@@ -2074,6 +2442,24 @@ Known condition types are: "Available", "Progressing"
The location which the deployment has been scheduled to
false |
+
+ | observedGeneration |
+ integer |
+
+ The most recent generation observed by the deployment controller. When
+this matches metadata.generation, the controller has reconciled the
+latest spec (e.g. a restart request).
+
+ Format: int64
+ |
+ false |
+
+ | selector |
+ string |
+
+ Selector is the label selector that identifies Pods backing this deployment.
+ |
+ false |
diff --git a/docs/api/workloads.md b/docs/api/workloads.md
index c7742714..df10dcc0 100644
--- a/docs/api/workloads.md
+++ b/docs/api/workloads.md
@@ -160,6 +160,16 @@ Scale settings such as minimum and maximum replica counts.
+ | instanceManagementPolicy |
+ string |
+
+ Controls how instances are managed during scale up and down, as well as
+during maintenance events.
+
+ Default: OrderedReady
+ |
+ true |
+
| minReplicas |
integer |
@@ -359,11 +369,26 @@ Describes the desired configuration of an instance
The runtime type of the instance, such as a container sandbox or a VM.
|
true |
+
+ | controller |
+ object |
+
+ Controller contains settings driven by the controller managing the instance.
+ |
+ false |
+
+ | location |
+ object |
+
+ The location which the instance has been scheduled to
+ |
+ false |
| volumes |
[]object |
-
+ Volumes that must be available to attach to an instance's containers or
+Virtual Machine.
|
false |
@@ -798,6 +823,30 @@ used by the instance.
The name of the container.
true |
+
+ | args |
+ []string |
+
+ Arguments to the entrypoint, overriding the image's CMD. Combined with
+Command: when Command is also set the resulting invocation is
+append(Command, Args...). When only Args is set it overrides CMD while
+preserving the image's ENTRYPOINT.
+
+If neither Command nor Args is set, the image's own ENTRYPOINT and CMD
+are used unchanged.
+ |
+ false |
+
+ | command |
+ []string |
+
+ Entrypoint array to run in the container image, overriding the image's
+ENTRYPOINT. Each element is a separate token, not a shell command — to run a
+shell command use: ["sh", "-c", "my command"].
+
+If not provided, the container image's own ENTRYPOINT is used.
+ |
+ false |
| env |
[]object |
@@ -807,6 +856,18 @@ used by the instance.
so replicate the structure here too.
false |
+
+ | envFrom |
+ []object |
+
+ List of sources to populate environment variables in the container.
+The keys defined within a source must be a C_IDENTIFIER. All invalid
+keys will be reported as an event when the container is starting. When a
+key exists in multiple sources, the value associated with the last source
+will take precedence. Values defined by an Env with a duplicate key will
+take precedence.
+ |
+ false |
| ports |
[]object |
@@ -852,7 +913,8 @@ EnvVar represents an environment variable present in a Container.
name |
string |
- Name of the environment variable. Must be a C_IDENTIFIER.
+ Name of the environment variable.
+May consist of any printable ASCII characters except '='.
|
true |
@@ -912,6 +974,14 @@ Source for the environment variable's value. Cannot be used if value is not empt
spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs.
false |
+
+ | fileKeyRef |
+ object |
+
+ FileKeyRef selects a key of the env file.
+Requires the EnvFiles feature gate to be enabled.
+ |
+ false |
| resourceFieldRef |
object |
@@ -1013,6 +1083,66 @@ spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podI
+### Workload.spec.template.spec.runtime.sandbox.containers[index].env[index].valueFrom.fileKeyRef
+[↩ Parent](#workloadspectemplatespecruntimesandboxcontainersindexenvindexvaluefrom)
+
+
+
+FileKeyRef selects a key of the env file.
+Requires the EnvFiles feature gate to be enabled.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | key |
+ string |
+
+ The key within the env file. An invalid key will prevent the pod from starting.
+The keys defined within a source may consist of any printable ASCII characters except '='.
+During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters.
+ |
+ true |
+
+ | path |
+ string |
+
+ The path within the volume from which to select the file.
+Must be relative and may not contain the '..' path or start with '..'.
+ |
+ true |
+
+ | volumeName |
+ string |
+
+ The name of the volume mount containing the env file.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the file or its key must be defined. If the file or key
+does not exist, then the env var is not published.
+If optional is set to true and the specified key does not exist,
+the environment variable will not be set in the Pod's containers.
+
+If optional is set to false and the specified key does not exist,
+an error will be returned during Pod creation.
+
+ Default: false
+ |
+ false |
+
+
+
+
### Workload.spec.template.spec.runtime.sandbox.containers[index].env[index].valueFrom.resourceFieldRef
[↩ Parent](#workloadspectemplatespecruntimesandboxcontainersindexenvindexvaluefrom)
@@ -1102,6 +1232,117 @@ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/nam
+### Workload.spec.template.spec.runtime.sandbox.containers[index].envFrom[index]
+[↩ Parent](#workloadspectemplatespecruntimesandboxcontainersindex)
+
+
+
+EnvFromSource represents a source for a set of ConfigMaps or Secrets to be
+used as environment variables in a container.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | configMapRef |
+ object |
+
+ The ConfigMap to select from.
+ |
+ false |
+
+ | prefix |
+ string |
+
+ An optional identifier to prepend to each key in the referenced
+ConfigMap or Secret. Must be a valid C_IDENTIFIER.
+ |
+ false |
+
+ | secretRef |
+ object |
+
+ The Secret to select from.
+ |
+ false |
+
+
+
+
+### Workload.spec.template.spec.runtime.sandbox.containers[index].envFrom[index].configMapRef
+[↩ Parent](#workloadspectemplatespecruntimesandboxcontainersindexenvfromindex)
+
+
+
+The ConfigMap to select from.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of the ConfigMap in the same namespace as the Workload.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the ConfigMap must be defined.
+ |
+ false |
+
+
+
+
+### Workload.spec.template.spec.runtime.sandbox.containers[index].envFrom[index].secretRef
+[↩ Parent](#workloadspectemplatespecruntimesandboxcontainersindexenvfromindex)
+
+
+
+The Secret to select from.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of the Secret in the same namespace as the Workload.
+ |
+ true |
+
+ | optional |
+ boolean |
+
+ Specify whether the Secret must be defined.
+ |
+ false |
+
+
+
+
### Workload.spec.template.spec.runtime.sandbox.containers[index].ports[index]
[↩ Parent](#workloadspectemplatespecruntimesandboxcontainersindex)
@@ -1370,6 +1611,102 @@ If not specified, this field defaults to TCP.
+### Workload.spec.template.spec.controller
+[↩ Parent](#workloadspectemplatespec)
+
+
+
+Controller contains settings driven by the controller managing the instance.
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | templateHash |
+ string |
+
+ TemplateHash is the hash of the instance template applied for this instance.
+ |
+ true |
+
+ | schedulingGates |
+ []object |
+
+ SchedulingGates is a list of gates that must be satisfied before the
+instance can be scheduled.
+ |
+ false |
+
+
+
+
+### Workload.spec.template.spec.controller.schedulingGates[index]
+[↩ Parent](#workloadspectemplatespeccontroller)
+
+
+
+
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ The name of the gate.
+ |
+ true |
+
+
+
+
+### Workload.spec.template.spec.location
+[↩ Parent](#workloadspectemplatespec)
+
+
+
+The location which the instance has been scheduled to
+
+
+
+
+ | Name |
+ Type |
+ Description |
+ Required |
+
+
+
+ | name |
+ string |
+
+ Name of a datum location
+ |
+ true |
+
+ | namespace |
+ string |
+
+ Namespace for the datum location
+ |
+ true |
+
+
+
+
### Workload.spec.template.spec.volumes[index]
[↩ Parent](#workloadspectemplatespec)
@@ -2022,8 +2359,17 @@ WorkloadStatus defines the observed state of Workload
currentReplicas |
integer |
- The number of instances created by a placement and have the latest
-workload generation settings applied.
+ The number of instances which have the latest workload settings applied
+and are programmed (a subset of UpdatedReplicas that are ready to serve).
+
+ Format: int32
+ |
+ true |
+
+ | deployments |
+ integer |
+
+ The number of deployments that currently exist
Format: int32
|
@@ -2032,7 +2378,16 @@ workload generation settings applied.
desiredReplicas |
integer |
- The desired number of instances to be managed by a placement.
+ The desired number of instances
+
+ Format: int32
+ |
+ true |
+
+ | readyReplicas |
+ integer |
+
+ The number of instances which are ready.
Format: int32
|
@@ -2041,7 +2396,19 @@ workload generation settings applied.
replicas |
integer |
- The number of instances created by a placement
+ The number of instances that currently exist
+
+ Format: int32
+ |
+ true |
+
+ | updatedReplicas |
+ integer |
+
+ The number of instances updated to the latest template revision (their
+observed template hash matches the desired template), regardless of
+readiness. Lags Replicas during a rolling update or restart, then catches
+back up — making an in-progress roll observable.
Format: int32
|
@@ -2061,6 +2428,15 @@ Known condition types are: "Available", "Progressing"
The status of the workload gateway if configured.
false |
+
+ | observedGeneration |
+ integer |
+
+ The most recent generation observed by the workload controller.
+
+ Format: int64
+ |
+ false |
| placements |
[]object |
@@ -2536,8 +2912,8 @@ RouteGroupKind indicates the group and kind of a Route resource.
currentReplicas |
integer |
- The number of instances created by a placement and have the latest
-workload generation settings applied.
+ The number of instances which have the latest workload settings applied
+and are programmed (a subset of UpdatedReplicas that are ready to serve).
Format: int32
|
@@ -2546,7 +2922,7 @@ workload generation settings applied.
desiredReplicas |
integer |
- The desired number of instances to be managed by a placement.
+ The desired number of instances
Format: int32
|
@@ -2558,11 +2934,30 @@ workload generation settings applied.
The name of the placement
true |
+
+ | readyReplicas |
+ integer |
+
+ The number of instances which are ready.
+
+ Format: int32
+ |
+ true |
| replicas |
integer |
- The number of instances created by a placement
+ The number of instances that currently exist
+
+ Format: int32
+ |
+ true |
+
+ | updatedReplicas |
+ integer |
+
+ The number of instances updated to the latest template revision, regardless
+of readiness. Lags Replicas during a rolling update or restart.
Format: int32
|
diff --git a/internal/controller/instancecontrol/instancecontrol.go b/internal/controller/instancecontrol/instancecontrol.go
index d2c83692..56ed2ebb 100644
--- a/internal/controller/instancecontrol/instancecontrol.go
+++ b/internal/controller/instancecontrol/instancecontrol.go
@@ -19,6 +19,7 @@ type Strategy interface {
ctx context.Context,
scheme *runtime.Scheme,
deployment *v1alpha.WorkloadDeployment,
+ desiredReplicas int32,
currentInstances []v1alpha.Instance,
) ([]Action, error)
}
diff --git a/internal/controller/instancecontrol/stateful/stateful_control.go b/internal/controller/instancecontrol/stateful/stateful_control.go
index ee64cb60..c9be87d3 100644
--- a/internal/controller/instancecontrol/stateful/stateful_control.go
+++ b/internal/controller/instancecontrol/stateful/stateful_control.go
@@ -57,6 +57,7 @@ func (c *statefulControl) GetActions(
ctx context.Context,
scheme *runtime.Scheme,
deployment *v1alpha.WorkloadDeployment,
+ desiredReplicas int32,
currentInstances []v1alpha.Instance,
) ([]instancecontrol.Action, error) {
instanceTemplateHash := instancecontrol.ComputeHash(deployment.Spec.Template)
@@ -75,7 +76,7 @@ func (c *statefulControl) GetActions(
// Instances that are desired to exist. We do not currently support the
// concept of a partition, so will fill the entire slice.
- desiredInstances := make([]*v1alpha.Instance, deployment.Spec.ScaleSettings.MinReplicas)
+ desiredInstances := make([]*v1alpha.Instance, desiredReplicas)
for _, instance := range currentInstances {
instanceIndex := getInstanceOrdinal(instance.Name)
@@ -88,7 +89,7 @@ func (c *statefulControl) GetActions(
// It's possible that the incoming currentInstances will have gaps in
// instances, so fill them in.
- for i := range deployment.Spec.ScaleSettings.MinReplicas {
+ for i := range desiredReplicas {
if desiredInstances[i] == nil {
desiredInstances[i] = &v1alpha.Instance{
ObjectMeta: metav1.ObjectMeta{
diff --git a/internal/controller/instancecontrol/stateful/stateful_control_test.go b/internal/controller/instancecontrol/stateful/stateful_control_test.go
index 63300be2..dc0557f2 100644
--- a/internal/controller/instancecontrol/stateful/stateful_control_test.go
+++ b/internal/controller/instancecontrol/stateful/stateful_control_test.go
@@ -36,7 +36,7 @@ func TestFreshDeployment(t *testing.T) {
// No instances
var currentInstances []v1alpha.Instance
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 2)
@@ -50,6 +50,21 @@ func TestFreshDeployment(t *testing.T) {
assert.True(t, actions[1].IsSkipped())
}
+func TestFreshDeployment_UsesDesiredReplicasInput(t *testing.T) {
+ ctx := context.Background()
+ control := NewWithOptions(Options{})
+
+ deployment := getWorkloadDeployment("test-fresh-deploy", 1)
+
+ actions, err := control.GetActions(ctx, scheme, deployment, 3, nil)
+
+ assert.NoError(t, err)
+ assert.Len(t, actions, 3)
+ assert.Equal(t, "test-fresh-deploy-0", actions[0].Object.GetName())
+ assert.Equal(t, "test-fresh-deploy-1", actions[1].Object.GetName())
+ assert.Equal(t, "test-fresh-deploy-2", actions[2].Object.GetName())
+}
+
// TestFreshDeployment_InstanceHasOwnerReference verifies that Instances produced
// by GetActions carry a controller owner reference to the WorkloadDeployment.
// The simplified WD finalizer relies on Kubernetes GC to cascade Instance
@@ -59,7 +74,7 @@ func TestFreshDeployment_InstanceHasOwnerReference(t *testing.T) {
control := NewWithOptions(Options{})
deployment := getWorkloadDeployment("test-wd", 1)
- actions, err := control.GetActions(ctx, scheme, deployment, nil)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, nil)
require.NoError(t, err)
require.Len(t, actions, 1)
@@ -88,7 +103,7 @@ func TestUpdateWithAllReadyInstances(t *testing.T) {
deployment.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image = "test-image-update"
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 2)
@@ -118,7 +133,7 @@ func TestScaleUpWithNotReadyInstance(t *testing.T) {
})
currentInstances = append(currentInstances, *notReadyInstance)
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 2)
@@ -145,7 +160,7 @@ func TestScaleUpWithDeletingReadyInstance(t *testing.T) {
deletingInstance.DeletionTimestamp = ptr.To(metav1.Now())
currentInstances = append(currentInstances, *deletingInstance)
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 2)
@@ -169,7 +184,7 @@ func TestScaleDownWithAllReadyInstances(t *testing.T) {
currentInstances = append(currentInstances, *getInstanceForDeployment(deployment, 0))
currentInstances = append(currentInstances, *getInstanceForDeployment(deployment, 1))
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 1)
@@ -189,7 +204,7 @@ func TestNetworkingEnabledAddsNetworkGate(t *testing.T) {
deployment := getWorkloadDeployment("test-deploy-net-on", 1)
var currentInstances []v1alpha.Instance
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 1)
@@ -220,7 +235,7 @@ func TestNetworkingDisabledOmitsNetworkGate(t *testing.T) {
deployment := getWorkloadDeployment("test-deploy-net-off", 1)
var currentInstances []v1alpha.Instance
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 1)
@@ -252,7 +267,7 @@ func TestInstanceLabels_FourNewLabelsStamped(t *testing.T) {
deployment := getWorkloadDeployment("test-labels-deploy", 1)
var currentInstances []v1alpha.Instance
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 1)
@@ -289,7 +304,7 @@ func TestInstanceLabels_RefreshedOnRecreate(t *testing.T) {
deployment.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image = "updated-image"
// First reconcile: the drifted instance is deleted (recreate), not updated.
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 1)
assert.Equal(t, instancecontrol.ActionTypeDelete, actions[0].ActionType())
@@ -297,7 +312,7 @@ func TestInstanceLabels_RefreshedOnRecreate(t *testing.T) {
// Next reconcile, after the old instance has been fully deleted and is gone:
// the empty slot is refilled by the create path, which stamps the labels.
- actions, err = control.GetActions(ctx, scheme, deployment, nil)
+ actions, err = control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, nil)
assert.NoError(t, err)
assert.Len(t, actions, 1)
assert.Equal(t, instancecontrol.ActionTypeCreate, actions[0].ActionType())
@@ -328,7 +343,7 @@ func TestInstanceLocation_SetWhenDeploymentStatusLocationPresent(t *testing.T) {
}
var currentInstances []v1alpha.Instance
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
assert.Len(t, actions, 1)
@@ -353,7 +368,7 @@ func TestInstanceLocation_NilWhenDeploymentStatusLocationAbsent(t *testing.T) {
// deployment.Status.Location is intentionally not set (nil)
var currentInstances []v1alpha.Instance
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err, "instance creation must succeed even when Status.Location is nil")
assert.Len(t, actions, 1, "exactly one create action must be produced")
@@ -393,7 +408,7 @@ func TestLabelBackfill_NotReadyMatchingHash(t *testing.T) {
// Instance 1: needs to be created (nil in desiredInstances), so we only provide instance0.
currentInstances := []v1alpha.Instance{*instance0}
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
@@ -465,7 +480,7 @@ func TestLabelBackfill_Idempotent(t *testing.T) {
}
currentInstances := []v1alpha.Instance{*instance}
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
@@ -490,7 +505,7 @@ func TestLabelBackfill_ReadyInstanceCorrected(t *testing.T) {
delete(instance.Labels, v1alpha.CityCodeLabel)
currentInstances := []v1alpha.Instance{*instance}
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
@@ -553,7 +568,7 @@ func TestLabelBackfill_DoesNotAffectRollingUpdate(t *testing.T) {
deployment.Spec.Template.Spec.Runtime.Sandbox.Containers[0].Image = "rolling-update-image"
currentInstances := []v1alpha.Instance{*instance0, *instance1}
- actions, err := control.GetActions(ctx, scheme, deployment, currentInstances)
+ actions, err := control.GetActions(ctx, scheme, deployment, deployment.Spec.ScaleSettings.MinReplicas, currentInstances)
assert.NoError(t, err)
diff --git a/internal/controller/teardown.go b/internal/controller/teardown.go
index 44df2cdd..9a287ee7 100644
--- a/internal/controller/teardown.go
+++ b/internal/controller/teardown.go
@@ -51,6 +51,9 @@ func NewComputeTeardown(
}
}
+// +kubebuilder:rbac:groups=services.miloapis.com,resources=serviceconsumers,verbs=get;list;watch
+// +kubebuilder:rbac:groups=services.miloapis.com,resources=services,verbs=get;list;watch
+
// TeardownConsumer implements consumer.Teardown.
func (ct *ComputeTeardown) TeardownConsumer(
ctx context.Context,
diff --git a/internal/controller/workload_controller.go b/internal/controller/workload_controller.go
index 861ed736..f97eecb2 100644
--- a/internal/controller/workload_controller.go
+++ b/internal/controller/workload_controller.go
@@ -490,6 +490,7 @@ func (r *WorkloadReconciler) getDeploymentsForWorkload(
CityCode: cityCode,
Template: workload.Spec.Template,
ScaleSettings: placement.ScaleSettings,
+ Replicas: new(placement.ScaleSettings.MinReplicas),
},
})
}
diff --git a/internal/controller/workload_controller_test.go b/internal/controller/workload_controller_test.go
index e8c01ae8..d8dbef80 100644
--- a/internal/controller/workload_controller_test.go
+++ b/internal/controller/workload_controller_test.go
@@ -8,12 +8,15 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ corev1 "k8s.io/api/core/v1"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
computev1alpha "go.datum.net/compute/api/v1alpha"
+ networkingv1alpha "go.datum.net/network-services-operator/api/v1alpha"
)
// makeWorkload builds a Workload with the given generation for use in
@@ -79,6 +82,51 @@ func runReconcileWorkloadStatus(t *testing.T, workload *computev1alpha.Workload,
return cond
}
+func TestGetDeploymentsForWorkload_InitializesReplicas(t *testing.T) {
+ t.Parallel()
+
+ workload := &computev1alpha.Workload{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-workload",
+ Namespace: testDefaultNamespace,
+ UID: types.UID("workload-uid"),
+ },
+ Spec: computev1alpha.WorkloadSpec{
+ Placements: []computev1alpha.WorkloadPlacement{
+ {
+ Name: testDefaultPlacement,
+ CityCodes: []string{"DFW"},
+ ScaleSettings: computev1alpha.HorizontalScaleSettings{
+ MinReplicas: 2,
+ },
+ },
+ },
+ },
+ }
+ location := &networkingv1alpha.LocationBinding{
+ ObjectMeta: metav1.ObjectMeta{Name: "dfw"},
+ Spec: networkingv1alpha.LocationBindingSpec{
+ LocationRef: corev1.LocalObjectReference{Name: "dfw"},
+ Topology: map[string]string{"topology.datum.net/city-code": "DFW"},
+ },
+ }
+
+ s := newNetworkingScheme()
+ cl := fake.NewClientBuilder().
+ WithScheme(s).
+ WithObjects(location).
+ WithIndex(&computev1alpha.WorkloadDeployment{}, deploymentWorkloadUIDIndex, deploymentWorkloadUIDIndexFunc).
+ Build()
+ r := &WorkloadReconciler{}
+
+ desired, orphaned, err := r.getDeploymentsForWorkload(context.Background(), cl, workload)
+ require.NoError(t, err)
+ require.Empty(t, orphaned)
+ require.Len(t, desired, 1)
+ require.NotNil(t, desired[0].Spec.Replicas)
+ assert.Equal(t, int32(2), *desired[0].Spec.Replicas)
+}
+
// TestReconcileWorkloadStatus_AllDeploymentsSameReason verifies that when all
// deployments share the same blocking reason, that reason is propagated to the
// Workload Available condition with ObservedGeneration set correctly.
diff --git a/internal/controller/workloaddeployment_controller.go b/internal/controller/workloaddeployment_controller.go
index 059ce361..25155a99 100644
--- a/internal/controller/workloaddeployment_controller.go
+++ b/internal/controller/workloaddeployment_controller.go
@@ -11,6 +11,7 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
apimeta "k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -60,6 +61,21 @@ type WorkloadDeploymentReconciler struct {
enableReferencedDataGate bool
}
+func effectiveDesiredReplicas(deployment *computev1alpha.WorkloadDeployment) int32 {
+ if !deployment.DeletionTimestamp.IsZero() {
+ return 0
+ }
+ if deployment.Spec.Replicas != nil {
+ return *deployment.Spec.Replicas
+ }
+ return deployment.Spec.ScaleSettings.MinReplicas
+}
+
+func workloadDeploymentPodSelector(deployment *computev1alpha.WorkloadDeployment) string {
+ set := labels.Set{computev1alpha.WorkloadDeploymentUIDLabel: string(deployment.GetUID())}
+ return set.AsSelector().String()
+}
+
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloaddeployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloaddeployments/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloaddeployments/finalizers,verbs=update
@@ -104,6 +120,14 @@ func (r *WorkloadDeploymentReconciler) Reconcile(ctx context.Context, req mcreco
if !deployment.DeletionTimestamp.IsZero() {
return ctrl.Result{}, nil
}
+ if deployment.Spec.Replicas == nil {
+ base := deployment.DeepCopy()
+ deployment.Spec.Replicas = new(deployment.Spec.ScaleSettings.MinReplicas)
+ if err := cl.GetClient().Patch(ctx, &deployment, client.MergeFrom(base)); err != nil {
+ return ctrl.Result{}, fmt.Errorf("failed initializing deployment replicas: %w", err)
+ }
+ return ctrl.Result{Requeue: true}, nil
+ }
logger.Info("reconciling deployment")
defer logger.Info("reconcile complete")
@@ -116,6 +140,7 @@ func (r *WorkloadDeploymentReconciler) Reconcile(ctx context.Context, req mcreco
listOpts := client.MatchingLabels{
computev1alpha.WorkloadDeploymentUIDLabel: string(deployment.GetUID()),
}
+ desiredReplicas := effectiveDesiredReplicas(&deployment)
var instances computev1alpha.InstanceList
if err := cl.GetClient().List(ctx, &instances, listOpts); err != nil {
@@ -127,7 +152,7 @@ func (r *WorkloadDeploymentReconciler) Reconcile(ctx context.Context, req mcreco
EnableReferencedDataGate: r.enableReferencedDataGate,
})
- actions, err := instanceControl.GetActions(ctx, cl.GetScheme(), &deployment, instances.Items)
+ actions, err := instanceControl.GetActions(ctx, cl.GetScheme(), &deployment, desiredReplicas, instances.Items)
if err != nil {
return ctrl.Result{}, fmt.Errorf("failed getting instance control actions: %w", err)
}
@@ -177,10 +202,6 @@ func (r *WorkloadDeploymentReconciler) Reconcile(ctx context.Context, req mcreco
// their gates removed.
replicas := len(instances.Items)
- desiredReplicas := deployment.Spec.ScaleSettings.MinReplicas
- if dt := deployment.DeletionTimestamp; !dt.IsZero() {
- desiredReplicas = 0
- }
currentReplicas, updatedReplicas, readyReplicas, quotaBlockedReplicas, referencedDataBlockedReplicas, err := r.reconcileInstanceGates(ctx, cl.GetClient(), &deployment, instances.Items, networkReady)
if err != nil {
@@ -192,6 +213,7 @@ func (r *WorkloadDeploymentReconciler) Reconcile(ctx context.Context, req mcreco
deployment.Status.UpdatedReplicas = int32(updatedReplicas)
deployment.Status.DesiredReplicas = desiredReplicas
deployment.Status.ReadyReplicas = int32(readyReplicas)
+ deployment.Status.Selector = workloadDeploymentPodSelector(&deployment)
deployment.Status.ObservedGeneration = deployment.Generation
switch {
diff --git a/internal/controller/workloaddeployment_controller_test.go b/internal/controller/workloaddeployment_controller_test.go
index 45dfbea6..fb877c05 100644
--- a/internal/controller/workloaddeployment_controller_test.go
+++ b/internal/controller/workloaddeployment_controller_test.go
@@ -55,6 +55,7 @@ func wdControllerTestDeployment(minReplicas int32) *computev1alpha.WorkloadDeplo
CityCode: wdControllerTestCityCode,
PlacementName: testDefaultPlacement,
WorkloadRef: computev1alpha.WorkloadReference{Name: wdControllerTestWorkload},
+ Replicas: new(minReplicas),
ScaleSettings: computev1alpha.HorizontalScaleSettings{
MinReplicas: minReplicas,
// Always present in production: the API server defaults the policy
@@ -374,6 +375,62 @@ func TestReconcileInstanceGates_ClearsNetworkSchedulingGate(t *testing.T) {
})
}
+func TestEffectiveDesiredReplicas(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ mutate func(*computev1alpha.WorkloadDeployment)
+ wantCount int32
+ }{
+ {
+ name: "falls back to minReplicas when unset",
+ mutate: func(deployment *computev1alpha.WorkloadDeployment) { deployment.Spec.Replicas = nil },
+ wantCount: 2,
+ },
+ {
+ name: "uses spec replicas when set",
+ mutate: func(deployment *computev1alpha.WorkloadDeployment) {
+ deployment.Spec.Replicas = new(int32(5))
+ },
+ wantCount: 5,
+ },
+ {
+ name: "deletion scales to zero",
+ mutate: func(deployment *computev1alpha.WorkloadDeployment) {
+ deployment.Spec.Replicas = new(int32(5))
+ now := metav1.Now()
+ deployment.DeletionTimestamp = &now
+ },
+ wantCount: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ deployment := wdControllerTestDeployment(2)
+ if tt.mutate != nil {
+ tt.mutate(deployment)
+ }
+
+ assert.Equal(t, tt.wantCount, effectiveDesiredReplicas(deployment))
+ })
+ }
+}
+
+func TestWorkloadDeploymentPodSelector(t *testing.T) {
+ t.Parallel()
+
+ deployment := wdControllerTestDeployment(1)
+
+ assert.Equal(t,
+ "compute.datumapis.com/workload-deployment-uid=wd-uid-test",
+ workloadDeploymentPodSelector(deployment),
+ )
+}
+
// newTestWDReconciler builds a WorkloadDeploymentReconciler wired to a fake
// project cluster with the controller finalizer pre-registered, mirroring
// SetupWithManager. Networking is disabled so Reconcile treats the network as
@@ -421,7 +478,7 @@ func TestWorkloadDeploymentReconcile_FinalizerAddRequeues(t *testing.T) {
// Second reconcile (post-requeue) proceeds past the finalizer branch and
// publishes status: ObservedGeneration tracks the deployment generation and
- // DesiredReplicas reflects scale settings.
+ // DesiredReplicas reflects the effective desired count.
result, err = r.Reconcile(context.Background(), req)
require.NoError(t, err)
assert.Equal(t, ctrl.Result{}, result)
@@ -429,10 +486,61 @@ func TestWorkloadDeploymentReconcile_FinalizerAddRequeues(t *testing.T) {
require.NoError(t, cl.Get(context.Background(), req.NamespacedName, &updated))
assert.Equal(t, updated.Generation, updated.Status.ObservedGeneration)
assert.Equal(t, int32(1), updated.Status.DesiredReplicas)
+ assert.Equal(t, workloadDeploymentPodSelector(&updated), updated.Status.Selector)
assert.True(t, apimeta.IsStatusConditionTrue(updated.Status.Conditions, computev1alpha.WorkloadDeploymentReplicasReady),
"no instances are quota-blocked, so ReplicasReady must be true")
}
+func TestWorkloadDeploymentReconcile_UsesSpecReplicas(t *testing.T) {
+ t.Parallel()
+
+ deployment := wdControllerTestDeployment(1)
+ deployment.Spec.Replicas = new(int32(3))
+ cl := newProjectFakeClient(deployment)
+ r := newTestWDReconciler(cl)
+ req := mcreconcile.Request{
+ ClusterName: testCluster,
+ Request: ctrl.Request{
+ NamespacedName: types.NamespacedName{Name: wdControllerTestName, Namespace: wdControllerTestNS},
+ },
+ }
+
+ _, err := r.Reconcile(context.Background(), req)
+ require.NoError(t, err)
+ _, err = r.Reconcile(context.Background(), req)
+ require.NoError(t, err)
+
+ var updated computev1alpha.WorkloadDeployment
+ require.NoError(t, cl.Get(context.Background(), req.NamespacedName, &updated))
+ assert.Equal(t, int32(3), updated.Status.DesiredReplicas)
+ assert.Equal(t, workloadDeploymentPodSelector(&updated), updated.Status.Selector)
+}
+
+func TestWorkloadDeploymentReconcile_InitializesReplicas(t *testing.T) {
+ t.Parallel()
+
+ deployment := wdControllerTestDeployment(2)
+ deployment.Spec.Replicas = nil
+ deployment.Finalizers = []string{workloadControllerFinalizer}
+ cl := newProjectFakeClient(deployment)
+ r := newTestWDReconciler(cl)
+ req := mcreconcile.Request{
+ ClusterName: testCluster,
+ Request: ctrl.Request{
+ NamespacedName: types.NamespacedName{Name: wdControllerTestName, Namespace: wdControllerTestNS},
+ },
+ }
+
+ result, err := r.Reconcile(context.Background(), req)
+ require.NoError(t, err)
+ assert.Equal(t, ctrl.Result{Requeue: true}, result)
+
+ var updated computev1alpha.WorkloadDeployment
+ require.NoError(t, cl.Get(context.Background(), req.NamespacedName, &updated))
+ require.NotNil(t, updated.Spec.Replicas)
+ assert.Equal(t, int32(2), *updated.Spec.Replicas)
+}
+
// ─── wdRefDataCondChanged tests ───────────────────────────────────────────────
// TestWdRefDataCondChanged_BothNil verifies that two nil conditions are treated
diff --git a/internal/controller/workloaddeployment_location_test.go b/internal/controller/workloaddeployment_location_test.go
index dd41381a..d8b6aa8e 100644
--- a/internal/controller/workloaddeployment_location_test.go
+++ b/internal/controller/workloaddeployment_location_test.go
@@ -174,6 +174,7 @@ func TestWorkloadDeploymentReconcile_NoMatchingLocation_SetsCondition(t *testing
Spec: computev1alpha.WorkloadDeploymentSpec{
CityCode: locTestCityCode,
WorkloadRef: computev1alpha.WorkloadReference{Name: "location-test-workload"},
+ Replicas: new(int32(1)),
ScaleSettings: computev1alpha.HorizontalScaleSettings{
MinReplicas: 1,
// Production deployments always carry the kubebuilder-defaulted
From 50b063946ed8fb361c68b4b8e270811436849221 Mon Sep 17 00:00:00 2001
From: Alex Savanovich <40720931+savme@users.noreply.github.com>
Date: Mon, 3 Aug 2026 15:47:45 +0200
Subject: [PATCH 2/4] feat: add HPA controller for workload deployments
---
cmd/main.go | 33 +-
config/components/controller_rbac/role.yaml | 12 +
internal/controller/teardown.go | 12 +-
internal/controller/testing_helpers_test.go | 2 +
.../workloaddeployment_hpa_controller.go | 206 ++++++++++++
.../workloaddeployment_hpa_controller_test.go | 298 ++++++++++++++++++
6 files changed, 556 insertions(+), 7 deletions(-)
create mode 100644 internal/controller/workloaddeployment_hpa_controller.go
create mode 100644 internal/controller/workloaddeployment_hpa_controller_test.go
diff --git a/cmd/main.go b/cmd/main.go
index 24c8bb74..ab19da30 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -16,6 +16,7 @@ import (
// to ensure that exec-entrypoint and run can make use of them.
"github.com/KimMachineGun/automemlimit/memlimit"
"golang.org/x/sync/errgroup"
+ autoscalingv2 "k8s.io/api/autoscaling/v2"
_ "k8s.io/client-go/plugin/pkg/client/auth"
apimeta "k8s.io/apimachinery/pkg/api/meta"
@@ -31,6 +32,7 @@ import (
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
"sigs.k8s.io/controller-runtime/pkg/cluster"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
@@ -97,6 +99,19 @@ func init() {
// +kubebuilder:scaffold:scheme
}
+func managedResourceGVKs(s *runtime.Scheme, objs ...client.Object) ([]schema.GroupVersionKind, error) {
+ gvks := make([]schema.GroupVersionKind, 0, len(objs))
+ for _, obj := range objs {
+ gvk, err := apiutil.GVKForObject(obj, s)
+ if err != nil {
+ return nil, err
+ }
+ gvks = append(gvks, gvk)
+ }
+
+ return gvks, nil
+}
+
//nolint:gocyclo // main wires all controller paths; complexity is inherent to startup sequencing
func main() {
@@ -361,6 +376,11 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", "WorkloadDeployment")
os.Exit(1)
}
+
+ if err = (&controller.WorkloadDeploymentHPAReconciler{}).SetupWithManager(mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "WorkloadDeploymentHPA")
+ os.Exit(1)
+ }
}
if enableCellControllers {
@@ -560,6 +580,15 @@ func initializeClusterDiscovery(
return nil, nil, "", nil, fmt.Errorf("unable to create root client for service-catalog: %w", err)
}
+ managedResources, err := managedResourceGVKs(
+ scheme,
+ &computev1alpha.Instance{},
+ &autoscalingv2.HorizontalPodAutoscaler{},
+ )
+ if err != nil {
+ return nil, nil, "", nil, fmt.Errorf("unable to resolve managed resource GVKs: %w", err)
+ }
+
provider, err = consumerprovider.New(providerMgr, consumerprovider.Options{
RootClient: rootClient,
Scheme: scheme,
@@ -570,9 +599,7 @@ func initializeClusterDiscovery(
o.Cache.DefaultTransform = cache.TransformStripManagedFields()
},
},
- ManagedResources: []schema.GroupVersionKind{
- computev1alpha.GroupVersion.WithKind("Instance"),
- },
+ ManagedResources: managedResources,
Teardowns: []consumerprovider.Teardown{
controller.NewComputeTeardown(quotaClientManager, federationClient, scheme),
},
diff --git a/config/components/controller_rbac/role.yaml b/config/components/controller_rbac/role.yaml
index e0c6beb5..b945fe9b 100644
--- a/config/components/controller_rbac/role.yaml
+++ b/config/components/controller_rbac/role.yaml
@@ -24,6 +24,18 @@ rules:
verbs:
- get
- list
+- apiGroups:
+ - autoscaling
+ resources:
+ - horizontalpodautoscalers
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
- apiGroups:
- compute.datumapis.com
resources:
diff --git a/internal/controller/teardown.go b/internal/controller/teardown.go
index 9a287ee7..18625d1d 100644
--- a/internal/controller/teardown.go
+++ b/internal/controller/teardown.go
@@ -18,10 +18,14 @@ import (
quotametrics "go.datum.net/compute/internal/quota"
)
-// labelServiceName is the label key the consumer provider uses to scope
-// deactivation cleanup. Every Instance the compute operator creates in a
-// consumer project carries this label so disengage can target them by service.
-const labelServiceName = "services.miloapis.com/service-name"
+// labelServiceName and labelServiceValue are the label key and value the
+// consumer provider uses to scope deactivation cleanup. Every resource the
+// compute operator creates in a consumer project carries this label so disengage
+// can target them by service.
+const (
+ labelServiceName = "services.miloapis.com/service-name"
+ labelServiceValue = "compute.datumapis.com"
+)
// ComputeTeardown implements consumer.Teardown. It is invoked after the
// consumer provider has cancelled the per-cluster context and marked labeled
diff --git a/internal/controller/testing_helpers_test.go b/internal/controller/testing_helpers_test.go
index 17b24f47..52b3ca36 100644
--- a/internal/controller/testing_helpers_test.go
+++ b/internal/controller/testing_helpers_test.go
@@ -7,6 +7,7 @@ import (
"fmt"
"sync"
+ autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/events"
@@ -26,6 +27,7 @@ import (
// cluster (corev1 + compute).
func newProjectScheme() *runtime.Scheme {
s := runtime.NewScheme()
+ _ = autoscalingv2.AddToScheme(s)
_ = corev1.AddToScheme(s)
_ = computev1alpha.AddToScheme(s)
return s
diff --git a/internal/controller/workloaddeployment_hpa_controller.go b/internal/controller/workloaddeployment_hpa_controller.go
new file mode 100644
index 00000000..6ba5e6cf
--- /dev/null
+++ b/internal/controller/workloaddeployment_hpa_controller.go
@@ -0,0 +1,206 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package controller
+
+import (
+ "context"
+ "fmt"
+
+ autoscalingv2 "k8s.io/api/autoscaling/v2"
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ mcbuilder "sigs.k8s.io/multicluster-runtime/pkg/builder"
+ mccontext "sigs.k8s.io/multicluster-runtime/pkg/context"
+ mcmanager "sigs.k8s.io/multicluster-runtime/pkg/manager"
+ mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
+
+ computev1alpha "go.datum.net/compute/api/v1alpha"
+)
+
+// WorkloadDeploymentHPAReconciler manages cell-local HorizontalPodAutoscalers
+// for WorkloadDeployments that opt into load-driven autoscaling.
+type WorkloadDeploymentHPAReconciler struct {
+ mgr mcmanager.Manager
+}
+
+// +kubebuilder:rbac:groups=compute.datumapis.com,resources=workloaddeployments,verbs=get;list;watch
+// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete
+
+func (r *WorkloadDeploymentHPAReconciler) Reconcile(ctx context.Context, req mcreconcile.Request) (ctrl.Result, error) {
+ logger := log.FromContext(ctx)
+
+ cl, err := r.mgr.GetCluster(ctx, req.ClusterName)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ ctx = mccontext.WithCluster(ctx, req.ClusterName)
+
+ var deployment computev1alpha.WorkloadDeployment
+ if err := cl.GetClient().Get(ctx, req.NamespacedName, &deployment); err != nil {
+ return ctrl.Result{}, client.IgnoreNotFound(err)
+ }
+
+ if !deployment.DeletionTimestamp.IsZero() {
+ return ctrl.Result{}, nil
+ }
+
+ if !workloadDeploymentAutoscalingEnabled(&deployment) {
+ return ctrl.Result{}, deleteWorkloadDeploymentHPA(ctx, cl.GetClient(), &deployment)
+ }
+
+ logger.Info("reconciling deployment HPA")
+ defer logger.Info("deployment HPA reconcile complete")
+
+ metrics, err := workloadDeploymentHPAMetrics(&deployment)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ hpa := autoscalingv2.HorizontalPodAutoscaler{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: deployment.Name,
+ Namespace: deployment.Namespace,
+ },
+ }
+
+ _, err = controllerutil.CreateOrPatch(ctx, cl.GetClient(), &hpa, func() error {
+ if hpa.UID != "" && !metav1.IsControlledBy(&hpa, &deployment) {
+ return fmt.Errorf("HPA %s/%s already exists and is not controlled by WorkloadDeployment %s/%s",
+ hpa.Namespace, hpa.Name, deployment.Namespace, deployment.Name)
+ }
+
+ if err := controllerutil.SetControllerReference(&deployment, &hpa, cl.GetScheme()); err != nil {
+ return err
+ }
+
+ hpa.Labels = workloadDeploymentHPALabels(&deployment)
+
+ hpa.Spec = autoscalingv2.HorizontalPodAutoscalerSpec{
+ ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{
+ APIVersion: computev1alpha.GroupVersion.String(),
+ Kind: "WorkloadDeployment",
+ Name: deployment.Name,
+ },
+ MinReplicas: new(deployment.Spec.ScaleSettings.MinReplicas),
+ MaxReplicas: *deployment.Spec.ScaleSettings.MaxReplicas,
+ Metrics: metrics,
+ }
+
+ return nil
+ })
+ if err != nil {
+ return ctrl.Result{}, fmt.Errorf("failed reconciling deployment HPA: %w", err)
+ }
+
+ return ctrl.Result{}, nil
+}
+
+func workloadDeploymentAutoscalingEnabled(deployment *computev1alpha.WorkloadDeployment) bool {
+ return deployment.Spec.ScaleSettings.MaxReplicas != nil && len(deployment.Spec.ScaleSettings.Metrics) > 0
+}
+
+func deleteWorkloadDeploymentHPA(ctx context.Context, c client.Client, deployment *computev1alpha.WorkloadDeployment) error {
+ var hpa autoscalingv2.HorizontalPodAutoscaler
+ if err := c.Get(ctx, client.ObjectKey{Namespace: deployment.Namespace, Name: deployment.Name}, &hpa); err != nil {
+ if apierrors.IsNotFound(err) {
+ return nil
+ }
+ return fmt.Errorf("failed fetching deployment HPA: %w", err)
+ }
+ if !metav1.IsControlledBy(&hpa, deployment) {
+ return nil
+ }
+
+ if err := c.Delete(ctx, &hpa); client.IgnoreNotFound(err) != nil {
+ return fmt.Errorf("failed deleting deployment HPA: %w", err)
+ }
+
+ return nil
+}
+
+func workloadDeploymentHPALabels(deployment *computev1alpha.WorkloadDeployment) map[string]string {
+ return map[string]string{
+ labelServiceName: labelServiceValue,
+ computev1alpha.WorkloadDeploymentUIDLabel: string(deployment.UID),
+ computev1alpha.WorkloadDeploymentNameLabel: deployment.Name,
+ computev1alpha.WorkloadNameLabel: deployment.Spec.WorkloadRef.Name,
+ computev1alpha.PlacementNameLabel: deployment.Spec.PlacementName,
+ computev1alpha.CityCodeLabel: deployment.Spec.CityCode,
+ }
+}
+
+func workloadDeploymentHPAMetrics(deployment *computev1alpha.WorkloadDeployment) ([]autoscalingv2.MetricSpec, error) {
+ metrics := make([]autoscalingv2.MetricSpec, 0, len(deployment.Spec.ScaleSettings.Metrics))
+ for i, metric := range deployment.Spec.ScaleSettings.Metrics {
+ if metric.Resource == nil {
+ return nil, fmt.Errorf("metric %d has no resource source", i)
+ }
+
+ if metric.Resource.Name != corev1.ResourceCPU && metric.Resource.Name != corev1.ResourceMemory {
+ return nil, fmt.Errorf("metric %d uses unsupported resource %q", i, metric.Resource.Name)
+ }
+
+ target, err := workloadDeploymentHPAMetricTarget(metric.Resource.Target)
+ if err != nil {
+ return nil, fmt.Errorf("metric %d has invalid target: %w", i, err)
+ }
+
+ metrics = append(metrics, autoscalingv2.MetricSpec{
+ Type: autoscalingv2.ResourceMetricSourceType,
+ Resource: &autoscalingv2.ResourceMetricSource{
+ Name: metric.Resource.Name,
+ Target: target,
+ },
+ })
+ }
+
+ return metrics, nil
+}
+
+func workloadDeploymentHPAMetricTarget(target computev1alpha.MetricTarget) (autoscalingv2.MetricTarget, error) {
+ setTargets := 0
+ if target.Value != nil {
+ setTargets++
+ }
+ if target.AverageValue != nil {
+ setTargets++
+ }
+ if target.AverageUtilization != nil {
+ setTargets++
+ }
+ if setTargets != 1 {
+ return autoscalingv2.MetricTarget{}, fmt.Errorf("exactly one target value must be set")
+ }
+
+ if target.Value != nil {
+ value := target.Value.DeepCopy()
+ return autoscalingv2.MetricTarget{Type: autoscalingv2.ValueMetricType, Value: &value}, nil
+ }
+
+ if target.AverageValue != nil {
+ averageValue := target.AverageValue.DeepCopy()
+ return autoscalingv2.MetricTarget{Type: autoscalingv2.AverageValueMetricType, AverageValue: &averageValue}, nil
+ }
+
+ return autoscalingv2.MetricTarget{
+ Type: autoscalingv2.UtilizationMetricType,
+ AverageUtilization: new(*target.AverageUtilization),
+ }, nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *WorkloadDeploymentHPAReconciler) SetupWithManager(mgr mcmanager.Manager) error {
+ r.mgr = mgr
+
+ return mcbuilder.ControllerManagedBy(mgr).
+ For(&computev1alpha.WorkloadDeployment{}, mcbuilder.WithEngageWithLocalCluster(false)).
+ Owns(&autoscalingv2.HorizontalPodAutoscaler{}, mcbuilder.WithPredicates(predicate.GenerationChangedPredicate{})).
+ Complete(r)
+}
diff --git a/internal/controller/workloaddeployment_hpa_controller_test.go b/internal/controller/workloaddeployment_hpa_controller_test.go
new file mode 100644
index 00000000..df6cca4e
--- /dev/null
+++ b/internal/controller/workloaddeployment_hpa_controller_test.go
@@ -0,0 +1,298 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+
+package controller
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ autoscalingv2 "k8s.io/api/autoscaling/v2"
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/resource"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+ "sigs.k8s.io/multicluster-runtime/pkg/multicluster"
+ mcreconcile "sigs.k8s.io/multicluster-runtime/pkg/reconcile"
+
+ computev1alpha "go.datum.net/compute/api/v1alpha"
+)
+
+const (
+ hpaTestCluster = "test-cluster"
+ hpaTestUID = "existing-hpa"
+)
+
+func hpaTestDeployment() *computev1alpha.WorkloadDeployment {
+ averageUtilization := int32(75)
+ maxReplicas := int32(10)
+ return &computev1alpha.WorkloadDeployment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: wdControllerTestName,
+ Namespace: wdControllerTestNS,
+ UID: wdControllerTestUID,
+ },
+ Spec: computev1alpha.WorkloadDeploymentSpec{
+ CityCode: wdControllerTestCityCode,
+ PlacementName: testDefaultPlacement,
+ WorkloadRef: computev1alpha.WorkloadReference{Name: wdControllerTestWorkload},
+ ScaleSettings: computev1alpha.HorizontalScaleSettings{
+ MinReplicas: 2,
+ MaxReplicas: new(maxReplicas),
+ Metrics: []computev1alpha.MetricSpec{
+ {
+ Resource: &computev1alpha.ResourceMetricSource{
+ Name: corev1.ResourceCPU,
+ Target: computev1alpha.MetricTarget{
+ AverageUtilization: new(averageUtilization),
+ },
+ },
+ },
+ },
+ InstanceManagementPolicy: computev1alpha.OrderedReadyInstanceManagementPolicyType,
+ },
+ },
+ }
+}
+
+func newHPAReconciler(cl client.Client) *WorkloadDeploymentHPAReconciler {
+ return &WorkloadDeploymentHPAReconciler{
+ mgr: newFakeMCManager(hpaTestCluster, newFakeCluster(cl)),
+ }
+}
+
+func reconcileHPA(t *testing.T, r *WorkloadDeploymentHPAReconciler, deployment *computev1alpha.WorkloadDeployment) {
+ t.Helper()
+ _, err := r.Reconcile(context.Background(), mcreconcile.Request{
+ ClusterName: multicluster.ClusterName(hpaTestCluster),
+ Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: deployment.Namespace, Name: deployment.Name}},
+ })
+ require.NoError(t, err)
+}
+
+func TestWorkloadDeploymentHPAReconciler_CreatesHPA(t *testing.T) {
+ t.Parallel()
+
+ deployment := hpaTestDeployment()
+ cl := newProjectFakeClient(deployment)
+ reconcileHPA(t, newHPAReconciler(cl), deployment)
+
+ var hpa autoscalingv2.HorizontalPodAutoscaler
+ require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa))
+
+ assert.Equal(t, computev1alpha.GroupVersion.String(), hpa.Spec.ScaleTargetRef.APIVersion)
+ assert.Equal(t, "WorkloadDeployment", hpa.Spec.ScaleTargetRef.Kind)
+ assert.Equal(t, deployment.Name, hpa.Spec.ScaleTargetRef.Name)
+ require.NotNil(t, hpa.Spec.MinReplicas)
+ assert.Equal(t, int32(2), *hpa.Spec.MinReplicas)
+ assert.Equal(t, int32(10), hpa.Spec.MaxReplicas)
+
+ require.Len(t, hpa.Spec.Metrics, 1)
+ assert.Equal(t, autoscalingv2.ResourceMetricSourceType, hpa.Spec.Metrics[0].Type)
+ require.NotNil(t, hpa.Spec.Metrics[0].Resource)
+ assert.Equal(t, corev1.ResourceCPU, hpa.Spec.Metrics[0].Resource.Name)
+ assert.Equal(t, autoscalingv2.UtilizationMetricType, hpa.Spec.Metrics[0].Resource.Target.Type)
+ require.NotNil(t, hpa.Spec.Metrics[0].Resource.Target.AverageUtilization)
+ assert.Equal(t, int32(75), *hpa.Spec.Metrics[0].Resource.Target.AverageUtilization)
+
+ assert.Equal(t, map[string]string{
+ labelServiceName: labelServiceValue,
+ computev1alpha.WorkloadDeploymentUIDLabel: string(deployment.UID),
+ computev1alpha.WorkloadDeploymentNameLabel: deployment.Name,
+ computev1alpha.WorkloadNameLabel: deployment.Spec.WorkloadRef.Name,
+ computev1alpha.PlacementNameLabel: deployment.Spec.PlacementName,
+ computev1alpha.CityCodeLabel: deployment.Spec.CityCode,
+ }, hpa.Labels)
+ require.Len(t, hpa.OwnerReferences, 1)
+ assert.Equal(t, deployment.Name, hpa.OwnerReferences[0].Name)
+ assert.True(t, *hpa.OwnerReferences[0].Controller)
+}
+
+func TestWorkloadDeploymentHPAReconciler_UpdatesHPA(t *testing.T) {
+ t.Parallel()
+
+ deployment := hpaTestDeployment()
+ existing := &autoscalingv2.HorizontalPodAutoscaler{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: deployment.Name,
+ Namespace: deployment.Namespace,
+ UID: hpaTestUID,
+ OwnerReferences: []metav1.OwnerReference{
+ *metav1.NewControllerRef(deployment, computev1alpha.GroupVersion.WithKind("WorkloadDeployment")),
+ },
+ },
+ Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
+ ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{APIVersion: "apps/v1", Kind: "Deployment", Name: "old"},
+ MinReplicas: new(int32(1)),
+ MaxReplicas: 1,
+ },
+ }
+ cl := newProjectFakeClient(deployment, existing)
+ reconcileHPA(t, newHPAReconciler(cl), deployment)
+
+ var hpa autoscalingv2.HorizontalPodAutoscaler
+ require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa))
+ assert.Equal(t, computev1alpha.GroupVersion.String(), hpa.Spec.ScaleTargetRef.APIVersion)
+ assert.Equal(t, "WorkloadDeployment", hpa.Spec.ScaleTargetRef.Kind)
+ assert.Equal(t, int32(10), hpa.Spec.MaxReplicas)
+}
+
+func TestWorkloadDeploymentHPAReconciler_DoesNotAdoptUnownedHPA(t *testing.T) {
+ t.Parallel()
+
+ deployment := hpaTestDeployment()
+ existing := &autoscalingv2.HorizontalPodAutoscaler{
+ ObjectMeta: metav1.ObjectMeta{Name: deployment.Name, Namespace: deployment.Namespace, UID: hpaTestUID},
+ }
+ cl := newProjectFakeClient(deployment, existing)
+ r := newHPAReconciler(cl)
+
+ _, err := r.Reconcile(context.Background(), mcreconcile.Request{
+ ClusterName: multicluster.ClusterName(hpaTestCluster),
+ Request: reconcile.Request{NamespacedName: types.NamespacedName{Namespace: deployment.Namespace, Name: deployment.Name}},
+ })
+ require.Error(t, err)
+
+ var hpa autoscalingv2.HorizontalPodAutoscaler
+ require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa))
+ assert.Empty(t, hpa.OwnerReferences)
+}
+
+func TestWorkloadDeploymentHPAReconciler_DeletesHPAWhenAutoscalingDisabled(t *testing.T) {
+ t.Parallel()
+
+ deployment := hpaTestDeployment()
+ deployment.Spec.ScaleSettings.MaxReplicas = nil
+ existing := &autoscalingv2.HorizontalPodAutoscaler{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: deployment.Name,
+ Namespace: deployment.Namespace,
+ UID: hpaTestUID,
+ OwnerReferences: []metav1.OwnerReference{
+ *metav1.NewControllerRef(deployment, computev1alpha.GroupVersion.WithKind("WorkloadDeployment")),
+ },
+ },
+ }
+ cl := newProjectFakeClient(deployment, existing)
+ reconcileHPA(t, newHPAReconciler(cl), deployment)
+
+ var hpa autoscalingv2.HorizontalPodAutoscaler
+ err := cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa)
+ assert.True(t, apierrors.IsNotFound(err))
+}
+
+func TestWorkloadDeploymentHPAReconciler_DoesNotDeleteUnownedHPA(t *testing.T) {
+ t.Parallel()
+
+ deployment := hpaTestDeployment()
+ deployment.Spec.ScaleSettings.MaxReplicas = nil
+ existing := &autoscalingv2.HorizontalPodAutoscaler{
+ ObjectMeta: metav1.ObjectMeta{Name: deployment.Name, Namespace: deployment.Namespace, UID: hpaTestUID},
+ }
+ cl := newProjectFakeClient(deployment, existing)
+ reconcileHPA(t, newHPAReconciler(cl), deployment)
+
+ var hpa autoscalingv2.HorizontalPodAutoscaler
+ require.NoError(t, cl.Get(context.Background(), client.ObjectKeyFromObject(deployment), &hpa))
+ assert.Empty(t, hpa.OwnerReferences)
+}
+
+func TestWorkloadDeploymentHPAMetrics(t *testing.T) {
+ t.Parallel()
+
+ value := resource.MustParse("100m")
+ averageValue := resource.MustParse("256Mi")
+ averageUtilization := int32(80)
+
+ tests := []struct {
+ name string
+ metrics []computev1alpha.MetricSpec
+ wantType autoscalingv2.MetricTargetType
+ wantErr bool
+ wantMetric corev1.ResourceName
+ }{
+ {
+ name: "value",
+ metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{
+ Name: corev1.ResourceCPU,
+ Target: computev1alpha.MetricTarget{Value: &value},
+ }}},
+ wantType: autoscalingv2.ValueMetricType,
+ wantMetric: corev1.ResourceCPU,
+ },
+ {
+ name: "average value",
+ metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{
+ Name: corev1.ResourceMemory,
+ Target: computev1alpha.MetricTarget{AverageValue: &averageValue},
+ }}},
+ wantType: autoscalingv2.AverageValueMetricType,
+ wantMetric: corev1.ResourceMemory,
+ },
+ {
+ name: "average utilization",
+ metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{
+ Name: corev1.ResourceCPU,
+ Target: computev1alpha.MetricTarget{AverageUtilization: &averageUtilization},
+ }}},
+ wantType: autoscalingv2.UtilizationMetricType,
+ wantMetric: corev1.ResourceCPU,
+ },
+ {
+ name: "missing resource",
+ metrics: []computev1alpha.MetricSpec{{}},
+ wantErr: true,
+ },
+ {
+ name: "unsupported resource",
+ metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{
+ Name: corev1.ResourceEphemeralStorage,
+ Target: computev1alpha.MetricTarget{AverageUtilization: &averageUtilization},
+ }}},
+ wantErr: true,
+ },
+ {
+ name: "multiple targets",
+ metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{
+ Name: corev1.ResourceCPU,
+ Target: computev1alpha.MetricTarget{
+ Value: &value,
+ AverageUtilization: &averageUtilization,
+ },
+ }}},
+ wantErr: true,
+ },
+ {
+ name: "missing target",
+ metrics: []computev1alpha.MetricSpec{{Resource: &computev1alpha.ResourceMetricSource{
+ Name: corev1.ResourceCPU,
+ }}},
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ deployment := hpaTestDeployment()
+ deployment.Spec.ScaleSettings.Metrics = tt.metrics
+
+ metrics, err := workloadDeploymentHPAMetrics(deployment)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+ require.Len(t, metrics, 1)
+ require.NotNil(t, metrics[0].Resource)
+ assert.Equal(t, tt.wantMetric, metrics[0].Resource.Name)
+ assert.Equal(t, tt.wantType, metrics[0].Resource.Target.Type)
+ })
+ }
+}
From 77e6dcab229e4d03c212980b6adb900ae59e30bd Mon Sep 17 00:00:00 2001
From: Alex Savanovich <40720931+savme@users.noreply.github.com>
Date: Tue, 4 Aug 2026 14:10:20 +0200
Subject: [PATCH 3/4] fix: name WorkloadDeployment HPA controller
---
internal/controller/workloaddeployment_hpa_controller.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/internal/controller/workloaddeployment_hpa_controller.go b/internal/controller/workloaddeployment_hpa_controller.go
index 6ba5e6cf..b1cd0aff 100644
--- a/internal/controller/workloaddeployment_hpa_controller.go
+++ b/internal/controller/workloaddeployment_hpa_controller.go
@@ -200,6 +200,7 @@ func (r *WorkloadDeploymentHPAReconciler) SetupWithManager(mgr mcmanager.Manager
r.mgr = mgr
return mcbuilder.ControllerManagedBy(mgr).
+ Named("workload-deployment-hpa").
For(&computev1alpha.WorkloadDeployment{}, mcbuilder.WithEngageWithLocalCluster(false)).
Owns(&autoscalingv2.HorizontalPodAutoscaler{}, mcbuilder.WithPredicates(predicate.GenerationChangedPredicate{})).
Complete(r)
From fe2906629e835cedc9e5f2301fd9b046e1dbff7c Mon Sep 17 00:00:00 2001
From: Alex Savanovich <40720931+savme@users.noreply.github.com>
Date: Tue, 4 Aug 2026 14:56:36 +0200
Subject: [PATCH 4/4] test: add WorkloadDeployment HPA lifecycle chainsaw tests
---
.github/workflows/test-e2e.yml | 13 +-
.gitignore | 2 +
Makefile | 22 +--
Taskfile.yaml | 132 ++++++++++++++++++
test/e2e/.chainsaw.yaml | 14 ++
test/e2e/e2e_test.go | 6 -
.../hpa-lifecycle/chainsaw-test.yaml | 21 +++
.../workloaddeployment/hpa-lifecycle/hpa.yaml | 25 ++++
.../hpa-lifecycle/workloaddeployment.yaml | 32 +++++
.../hpa-stray/chainsaw-test.yaml | 24 ++++
.../hpa-stray/stray-hpa.yaml | 21 +++
.../hpa-stray/workloaddeployment.yaml | 32 +++++
.../hpa-update/chainsaw-test.yaml | 20 +++
.../hpa-update/hpa-initial.yaml | 15 ++
.../hpa-update/hpa-updated.yaml | 15 ++
.../hpa-update/workloaddeployment.yaml | 32 +++++
16 files changed, 399 insertions(+), 27 deletions(-)
create mode 100644 Taskfile.yaml
create mode 100644 test/e2e/.chainsaw.yaml
delete mode 100644 test/e2e/e2e_test.go
create mode 100644 test/e2e/workloaddeployment/hpa-lifecycle/chainsaw-test.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-lifecycle/hpa.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-lifecycle/workloaddeployment.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-stray/chainsaw-test.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-stray/stray-hpa.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-stray/workloaddeployment.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-update/chainsaw-test.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-update/hpa-initial.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-update/hpa-updated.yaml
create mode 100644 test/e2e/workloaddeployment/hpa-update/workloaddeployment.yaml
diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml
index b2eda8c3..837e97fc 100644
--- a/.github/workflows/test-e2e.yml
+++ b/.github/workflows/test-e2e.yml
@@ -1,5 +1,8 @@
name: E2E Tests
+env:
+ TASK_X_REMOTE_TASKFILES: 1
+
on:
push:
pull_request:
@@ -17,6 +20,9 @@ jobs:
with:
go-version-file: go.mod
+ - name: Setup Task
+ uses: arduino/setup-task@v2
+
- name: Install the latest version of kind
run: |
curl -Lo ./kind https://kind.sigs.k8s.io/dl/latest/kind-linux-amd64
@@ -26,10 +32,5 @@ jobs:
- name: Verify kind installation
run: kind version
- - name: Create kind cluster
- run: kind create cluster
-
- name: Running Test e2e
- run: |
- go mod tidy
- make test-e2e
+ run: make test-e2e
diff --git a/.gitignore b/.gitignore
index d5cc564d..3092d0a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,5 @@ bin/
# Local e2e environment artefacts (Kind kubeconfigs, etc.)
tmp/
+.test-infra/
+.task/
diff --git a/Makefile b/Makefile
index 3d6a3e2e..6a5a68b5 100644
--- a/Makefile
+++ b/Makefile
@@ -64,22 +64,14 @@ vet: ## Run go vet against code.
test: manifests generate fmt vet envtest ## Run tests.
KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e) -coverprofile cover.out
-# TODO(user): To use a different vendor for e2e tests, modify the setup under 'tests/e2e'.
-# The default setup assumes Kind is pre-installed and builds/loads the Manager Docker image locally.
-# Prometheus and CertManager are installed by default; skip with:
-# - PROMETHEUS_INSTALL_SKIP=true
-# - CERT_MANAGER_INSTALL_SKIP=true
.PHONY: test-e2e
-test-e2e: manifests generate fmt vet ## Run the e2e tests. Expected an isolated environment using Kind.
- # @command -v kind >/dev/null 2>&1 || { \
- # echo "Kind is not installed. Please install Kind manually."; \
- # exit 1; \
- # }
- # @kind get clusters | grep -q 'kind' || { \
- # echo "No Kind cluster is running. Please start a Kind cluster before running the e2e tests."; \
- # exit 1; \
- # }
- # go test ./test/e2e/ -v -ginkgo.v
+test-e2e: manifests generate fmt vet ## Run Chainsaw e2e tests in an isolated test-infra Kind cluster.
+ @set -e; \
+ export TASK_X_REMOTE_TASKFILES=1; \
+ cleanup() { task e2e:down || true; }; \
+ trap cleanup EXIT; \
+ task e2e:up; \
+ task e2e
.PHONY: lint
lint: golangci-lint ## Run golangci-lint linter
diff --git a/Taskfile.yaml b/Taskfile.yaml
new file mode 100644
index 00000000..b53f9004
--- /dev/null
+++ b/Taskfile.yaml
@@ -0,0 +1,132 @@
+version: '3'
+
+dotenv: ['.env']
+
+includes:
+ # Remote Taskfile from datum-cloud/test-infra. Requires
+ # TASK_X_REMOTE_TASKFILES=1. See: https://taskfile.dev/experiments/remote-taskfiles
+ test-infra:
+ taskfile: https://raw.githubusercontent.com/datum-cloud/test-infra/{{.TEST_INFRA_REPO_REF}}/Taskfile.yml
+ checksum: 1d057f69ef11b9fb7a94bbe7723f50f9508098cb2481f375f38ca5d55533d93e
+ vars:
+ CLUSTER_NAME: '{{.E2E_CLUSTER}}'
+ REPO_REF: '{{.TEST_INFRA_REPO_REF}}'
+ WAIT_TIMEOUT: '{{.WAIT_TIMEOUT}}'
+
+vars:
+ WAIT_TIMEOUT: '300s'
+ TOOL_DIR: '{{.USER_WORKING_DIR}}/bin'
+ IMG_NAME: 'compute'
+ IMG_TAG: 'e2e'
+ IMG: '{{.IMG_NAME}}:{{.IMG_TAG}}'
+ E2E_CLUSTER: 'test-infra'
+ E2E_KUBECONFIG: '.test-infra/kubeconfig'
+ TEST_INFRA_REPO_REF: 'v0.7.1'
+ CHAINSAW_VERSION: 'v0.2.15'
+
+tasks:
+ default:
+ cmds:
+ - task --list
+ silent: true
+
+ dev:build:
+ desc: Build the compute manager container image
+ silent: true
+ cmds:
+ - docker build -t {{.IMG}} .
+
+ e2e:up:
+ desc: Bring up a test-infra Kind cluster with the compute cell controller
+ silent: true
+ env:
+ KUBECONFIG: '{{.E2E_KUBECONFIG}}'
+ cmds:
+ - task: test-infra:cluster-up
+ vars:
+ CLUSTER_NAME: '{{.E2E_CLUSTER}}'
+ - task: dev:build
+ - task: test-infra:kind-load-image
+ vars:
+ CLUSTER_NAME: '{{.E2E_CLUSTER}}'
+ IMAGES: '{{.IMG}}'
+ - task test-infra:kubectl -- apply -k config/base/crd
+ - task test-infra:kubectl -- create namespace compute-system --dry-run=client -o yaml | task test-infra:kubectl -- apply -f -
+ - task test-infra:kubectl -- apply -k config/overlays/cell
+ - |
+ task test-infra:kubectl -- -n compute-system apply -f - <<'EOF'
+ apiVersion: v1
+ kind: ConfigMap
+ metadata:
+ name: compute-config
+ data:
+ config.yaml: |
+ apiVersion: apiserver.config.datumapis.com/v1alpha1
+ kind: WorkloadOperator
+ metricsServer:
+ bindAddress: "0"
+ discovery: {}
+ EOF
+ - task test-infra:kubectl -- -n compute-system set image deployment/compute-manager manager={{.IMG}}
+ - task test-infra:kubectl -- -n compute-system patch deployment compute-manager --type=json -p='[{"op":"replace","path":"/spec/template/spec/containers/0/imagePullPolicy","value":"IfNotPresent"}]'
+ - task test-infra:kubectl -- -n compute-system rollout status deployment/compute-manager --timeout=180s
+
+ e2e:
+ desc: Run Chainsaw e2e tests against the isolated Kind cluster
+ silent: true
+ deps:
+ - e2e:preflight
+ - install:chainsaw
+ env:
+ KUBECONFIG: '{{.E2E_KUBECONFIG}}'
+ cmds:
+ - '{{.TOOL_DIR}}/chainsaw test test/e2e --config test/e2e/.chainsaw.yaml'
+
+ install:chainsaw:
+ desc: Install Chainsaw locally
+ silent: true
+ cmds:
+ - |
+ mkdir -p {{.TOOL_DIR}}
+ if [ ! -f "{{.TOOL_DIR}}/chainsaw-{{.CHAINSAW_VERSION}}" ]; then
+ GOBIN={{.TOOL_DIR}} go install github.com/kyverno/chainsaw@{{.CHAINSAW_VERSION}}
+ mv {{.TOOL_DIR}}/chainsaw {{.TOOL_DIR}}/chainsaw-{{.CHAINSAW_VERSION}}
+ fi
+ ln -sf {{.TOOL_DIR}}/chainsaw-{{.CHAINSAW_VERSION}} {{.TOOL_DIR}}/chainsaw
+ status:
+ - test -f {{.TOOL_DIR}}/chainsaw-{{.CHAINSAW_VERSION}}
+
+ e2e:preflight:
+ internal: true
+ silent: true
+ env:
+ KUBECONFIG: '{{.E2E_KUBECONFIG}}'
+ cmds:
+ - |
+ if [ ! -f "$KUBECONFIG" ]; then
+ echo "missing e2e kubeconfig at $KUBECONFIG"
+ echo "run: TASK_X_REMOTE_TASKFILES=1 task e2e:up"
+ exit 1
+ fi
+ - |
+ context="$(kubectl config current-context 2>/dev/null || true)"
+ if [ "$context" != "kind-{{.E2E_CLUSTER}}" ]; then
+ echo "e2e kubeconfig points at $context, expected kind-{{.E2E_CLUSTER}}"
+ echo "run: TASK_X_REMOTE_TASKFILES=1 task e2e:up"
+ exit 1
+ fi
+ - |
+ if ! kubectl get crd workloaddeployments.compute.datumapis.com >/dev/null 2>&1; then
+ echo "Compute CRDs are not installed in the e2e cluster"
+ echo "run: TASK_X_REMOTE_TASKFILES=1 task e2e:up"
+ exit 1
+ fi
+ - kubectl -n compute-system rollout status deployment/compute-manager --timeout=30s
+
+ e2e:down:
+ desc: Tear down the e2e test-infra Kind cluster
+ silent: true
+ cmds:
+ - task: test-infra:cluster-down
+ vars:
+ CLUSTER_NAME: '{{.E2E_CLUSTER}}'
diff --git a/test/e2e/.chainsaw.yaml b/test/e2e/.chainsaw.yaml
new file mode 100644
index 00000000..bd19c7a6
--- /dev/null
+++ b/test/e2e/.chainsaw.yaml
@@ -0,0 +1,14 @@
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Configuration
+metadata:
+ name: compute-e2e
+spec:
+ timeouts:
+ apply: 60s
+ assert: 120s
+ cleanup: 120s
+ delete: 60s
+ error: 60s
+ exec: 60s
+ parallel: 1
+ skipDelete: false
diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go
deleted file mode 100644
index 86de3c02..00000000
--- a/test/e2e/e2e_test.go
+++ /dev/null
@@ -1,6 +0,0 @@
-package e2e
-
-// This file exists due to the inability to bypass e2e generation when using
-// kubebuilder to generate resources.
-//
-// See: https://github.com/kubernetes-sigs/kubebuilder/issues/4509
diff --git a/test/e2e/workloaddeployment/hpa-lifecycle/chainsaw-test.yaml b/test/e2e/workloaddeployment/hpa-lifecycle/chainsaw-test.yaml
new file mode 100644
index 00000000..b247c4ee
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-lifecycle/chainsaw-test.yaml
@@ -0,0 +1,21 @@
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: workloaddeployment-hpa-lifecycle
+spec:
+ steps:
+ - name: create autoscaled WorkloadDeployment
+ try:
+ - apply:
+ file: workloaddeployment.yaml
+ - assert:
+ file: hpa.yaml
+ - name: disable autoscaling
+ try:
+ - script:
+ content: |
+ kubectl -n "$NAMESPACE" patch workloaddeployment.compute.datumapis.com chainsaw-hpa --type=json \
+ -p='[{"op":"remove","path":"/spec/scaleSettings/maxReplicas"},{"op":"remove","path":"/spec/scaleSettings/metrics"}]'
+ - script:
+ content: |
+ kubectl -n "$NAMESPACE" wait --for=delete horizontalpodautoscaler.autoscaling/chainsaw-hpa --timeout=60s
diff --git a/test/e2e/workloaddeployment/hpa-lifecycle/hpa.yaml b/test/e2e/workloaddeployment/hpa-lifecycle/hpa.yaml
new file mode 100644
index 00000000..91abd3eb
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-lifecycle/hpa.yaml
@@ -0,0 +1,25 @@
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: chainsaw-hpa
+ namespace: ($namespace)
+ labels:
+ services.miloapis.com/service-name: compute.datumapis.com
+ compute.datumapis.com/workload-deployment-name: chainsaw-hpa
+ compute.datumapis.com/workload-name: chainsaw-workload
+ compute.datumapis.com/placement-name: chainsaw-placement
+ compute.datumapis.com/city-code: DFW
+spec:
+ scaleTargetRef:
+ apiVersion: compute.datumapis.com/v1alpha
+ kind: WorkloadDeployment
+ name: chainsaw-hpa
+ minReplicas: 2
+ maxReplicas: 10
+ metrics:
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 75
diff --git a/test/e2e/workloaddeployment/hpa-lifecycle/workloaddeployment.yaml b/test/e2e/workloaddeployment/hpa-lifecycle/workloaddeployment.yaml
new file mode 100644
index 00000000..3813b88d
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-lifecycle/workloaddeployment.yaml
@@ -0,0 +1,32 @@
+apiVersion: compute.datumapis.com/v1alpha
+kind: WorkloadDeployment
+metadata:
+ name: chainsaw-hpa
+ namespace: ($namespace)
+spec:
+ workloadRef:
+ name: chainsaw-workload
+ uid: chainsaw-workload-uid
+ placementName: chainsaw-placement
+ cityCode: DFW
+ template:
+ spec:
+ runtime:
+ resources:
+ instanceType: datumcloud/d1-standard-2
+ sandbox:
+ containers:
+ - name: app
+ image: registry.k8s.io/pause:3.10
+ networkInterfaces:
+ - network:
+ name: default
+ scaleSettings:
+ minReplicas: 2
+ maxReplicas: 10
+ metrics:
+ - resource:
+ name: cpu
+ target:
+ averageUtilization: 75
+ instanceManagementPolicy: OrderedReady
diff --git a/test/e2e/workloaddeployment/hpa-stray/chainsaw-test.yaml b/test/e2e/workloaddeployment/hpa-stray/chainsaw-test.yaml
new file mode 100644
index 00000000..d9fd6f6d
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-stray/chainsaw-test.yaml
@@ -0,0 +1,24 @@
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: workloaddeployment-hpa-stray
+spec:
+ steps:
+ - name: create stray HPA
+ try:
+ - apply:
+ file: stray-hpa.yaml
+ - name: create matching autoscaled WorkloadDeployment
+ try:
+ - apply:
+ file: workloaddeployment.yaml
+ - assert:
+ file: stray-hpa.yaml
+ - name: disable autoscaling
+ try:
+ - script:
+ content: |
+ kubectl -n "$NAMESPACE" patch workloaddeployment.compute.datumapis.com chainsaw-stray --type=json \
+ -p='[{"op":"remove","path":"/spec/scaleSettings/maxReplicas"},{"op":"remove","path":"/spec/scaleSettings/metrics"}]'
+ - assert:
+ file: stray-hpa.yaml
diff --git a/test/e2e/workloaddeployment/hpa-stray/stray-hpa.yaml b/test/e2e/workloaddeployment/hpa-stray/stray-hpa.yaml
new file mode 100644
index 00000000..ecb69f49
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-stray/stray-hpa.yaml
@@ -0,0 +1,21 @@
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: chainsaw-stray
+ namespace: ($namespace)
+ labels:
+ e2e.compute.datumapis.com/stray: "true"
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: not-owned-by-compute
+ minReplicas: 1
+ maxReplicas: 4
+ metrics:
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 50
diff --git a/test/e2e/workloaddeployment/hpa-stray/workloaddeployment.yaml b/test/e2e/workloaddeployment/hpa-stray/workloaddeployment.yaml
new file mode 100644
index 00000000..3a855fa1
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-stray/workloaddeployment.yaml
@@ -0,0 +1,32 @@
+apiVersion: compute.datumapis.com/v1alpha
+kind: WorkloadDeployment
+metadata:
+ name: chainsaw-stray
+ namespace: ($namespace)
+spec:
+ workloadRef:
+ name: chainsaw-workload
+ uid: chainsaw-workload-uid
+ placementName: chainsaw-placement
+ cityCode: DFW
+ template:
+ spec:
+ runtime:
+ resources:
+ instanceType: datumcloud/d1-standard-2
+ sandbox:
+ containers:
+ - name: app
+ image: registry.k8s.io/pause:3.10
+ networkInterfaces:
+ - network:
+ name: default
+ scaleSettings:
+ minReplicas: 2
+ maxReplicas: 10
+ metrics:
+ - resource:
+ name: cpu
+ target:
+ averageUtilization: 75
+ instanceManagementPolicy: OrderedReady
diff --git a/test/e2e/workloaddeployment/hpa-update/chainsaw-test.yaml b/test/e2e/workloaddeployment/hpa-update/chainsaw-test.yaml
new file mode 100644
index 00000000..9f73939c
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-update/chainsaw-test.yaml
@@ -0,0 +1,20 @@
+apiVersion: chainsaw.kyverno.io/v1alpha1
+kind: Test
+metadata:
+ name: workloaddeployment-hpa-update
+spec:
+ steps:
+ - name: create autoscaled WorkloadDeployment
+ try:
+ - apply:
+ file: workloaddeployment.yaml
+ - assert:
+ file: hpa-initial.yaml
+ - name: update autoscaling settings
+ try:
+ - script:
+ content: |
+ kubectl -n "$NAMESPACE" patch workloaddeployment.compute.datumapis.com chainsaw-hpa-update --type=merge \
+ -p='{"spec":{"scaleSettings":{"minReplicas":3,"maxReplicas":12,"metrics":[{"resource":{"name":"memory","target":{"averageValue":"512Mi"}}}]}}}'
+ - assert:
+ file: hpa-updated.yaml
diff --git a/test/e2e/workloaddeployment/hpa-update/hpa-initial.yaml b/test/e2e/workloaddeployment/hpa-update/hpa-initial.yaml
new file mode 100644
index 00000000..8c07db42
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-update/hpa-initial.yaml
@@ -0,0 +1,15 @@
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: chainsaw-hpa-update
+ namespace: ($namespace)
+spec:
+ minReplicas: 2
+ maxReplicas: 10
+ metrics:
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 75
diff --git a/test/e2e/workloaddeployment/hpa-update/hpa-updated.yaml b/test/e2e/workloaddeployment/hpa-update/hpa-updated.yaml
new file mode 100644
index 00000000..592f246f
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-update/hpa-updated.yaml
@@ -0,0 +1,15 @@
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: chainsaw-hpa-update
+ namespace: ($namespace)
+spec:
+ minReplicas: 3
+ maxReplicas: 12
+ metrics:
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: AverageValue
+ averageValue: 512Mi
diff --git a/test/e2e/workloaddeployment/hpa-update/workloaddeployment.yaml b/test/e2e/workloaddeployment/hpa-update/workloaddeployment.yaml
new file mode 100644
index 00000000..eea78f22
--- /dev/null
+++ b/test/e2e/workloaddeployment/hpa-update/workloaddeployment.yaml
@@ -0,0 +1,32 @@
+apiVersion: compute.datumapis.com/v1alpha
+kind: WorkloadDeployment
+metadata:
+ name: chainsaw-hpa-update
+ namespace: ($namespace)
+spec:
+ workloadRef:
+ name: chainsaw-workload
+ uid: chainsaw-workload-uid
+ placementName: chainsaw-placement
+ cityCode: DFW
+ template:
+ spec:
+ runtime:
+ resources:
+ instanceType: datumcloud/d1-standard-2
+ sandbox:
+ containers:
+ - name: app
+ image: registry.k8s.io/pause:3.10
+ networkInterfaces:
+ - network:
+ name: default
+ scaleSettings:
+ minReplicas: 2
+ maxReplicas: 10
+ metrics:
+ - resource:
+ name: cpu
+ target:
+ averageUtilization: 75
+ instanceManagementPolicy: OrderedReady