diff --git a/api/v1alpha1/impvm_types.go b/api/v1alpha1/impvm_types.go index d3d01c6..d06fb8a 100644 --- a/api/v1alpha1/impvm_types.go +++ b/api/v1alpha1/impvm_types.go @@ -130,10 +130,16 @@ type ImpVMSpec struct { // DesiredState is the requested run state. When set to "Suspended" the agent // snapshots the VM to node-local storage and frees its memory; setting it back - // to "Running" resumes it from that snapshot on the same node. + // to "Running" resumes it from that snapshot on the same node. "ScaleToZero" + // makes suspension automatic: idle → suspend, first packet → resume. // +optional // +kubebuilder:default=Running DesiredState VMDesiredState `json:"desiredState,omitempty"` + + // IdleTimeout is how long a ScaleToZero VM must see no traffic before the agent + // auto-suspends it. Ignored unless desiredState is "ScaleToZero". Defaults to 5m. + // +optional + IdleTimeout *metav1.Duration `json:"idleTimeout,omitempty"` } // UserDataSource references a ConfigMap containing cloud-init user-data. @@ -187,6 +193,11 @@ type ImpVMStatus struct { // +optional SuspendedAt *metav1.Time `json:"suspendedAt,omitempty"` + // LastActivityTime is the most recent time the agent observed traffic on the + // VM's interface. Used by ScaleToZero idle detection; observability only. + // +optional + LastActivityTime *metav1.Time `json:"lastActivityTime,omitempty"` + // RestartCount is the cumulative number of times this VM has been restarted. // +optional RestartCount int32 `json:"restartCount,omitempty"` diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index e6c955f..8b61c77 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -106,7 +106,7 @@ const ( // VMDesiredState is the operator/user-requested run state for an ImpVM. // The agent drives the observed Phase toward this target. -// +kubebuilder:validation:Enum=Running;Suspended +// +kubebuilder:validation:Enum=Running;Suspended;ScaleToZero type VMDesiredState string const ( @@ -114,6 +114,11 @@ const ( VMDesiredStateRunning VMDesiredState = "Running" // VMDesiredStateSuspended requests the VM be snapshotted and its memory freed. VMDesiredStateSuspended VMDesiredState = "Suspended" + // VMDesiredStateScaleToZero is a mode, not a fixed target: the agent suspends + // the VM when it has been idle (no traffic) for spec.idleTimeout and resumes it + // automatically on the first inbound packet. status.Phase cycles Running↔Suspended + // underneath while desiredState stays ScaleToZero. + VMDesiredStateScaleToZero VMDesiredState = "ScaleToZero" ) // Arch is the CPU architecture for a VM class. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4fde85b..2196356 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1063,6 +1063,11 @@ func (in *ImpVMSpec) DeepCopyInto(out *ImpVMSpec) { *out = new(v1.Duration) **out = **in } + if in.IdleTimeout != nil { + in, out := &in.IdleTimeout, &out.IdleTimeout + *out = new(v1.Duration) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImpVMSpec. @@ -1098,6 +1103,10 @@ func (in *ImpVMStatus) DeepCopyInto(out *ImpVMStatus) { in, out := &in.SuspendedAt, &out.SuspendedAt *out = (*in).DeepCopy() } + if in.LastActivityTime != nil { + in, out := &in.LastActivityTime, &out.LastActivityTime + *out = (*in).DeepCopy() + } if in.NextRetryAfter != nil { in, out := &in.NextRetryAfter, &out.NextRetryAfter *out = (*in).DeepCopy() diff --git a/charts/imp-crds/templates/impvms.yaml b/charts/imp-crds/templates/impvms.yaml index 731663d..1790982 100644 --- a/charts/imp-crds/templates/impvms.yaml +++ b/charts/imp-crds/templates/impvms.yaml @@ -75,6 +75,18 @@ spec: required: - name type: object + desiredState: + default: Running + description: |- + DesiredState is the requested run state. When set to "Suspended" the agent + snapshots the VM to node-local storage and frees its memory; setting it back + to "Running" resumes it from that snapshot on the same node. "ScaleToZero" + makes suspension automatic: idle → suspend, first packet → resume. + enum: + - Running + - Suspended + - ScaleToZero + type: string env: description: Env sets environment variables inside the VM via the guest agent. @@ -280,6 +292,14 @@ spec: x-kubernetes-map-type: atomic type: object type: array + expireAfter: + description: |- + ExpireAfter is the maximum wall-clock runtime from first Running transition. + 0 or unset disables automatic expiration. Minimum enabled value is 60s. + type: string + x-kubernetes-validations: + - message: expireAfter must be 0 (disabled) or at least 60s + rule: duration(self) == duration('0s') || duration(self) >= duration('60s') guestAgent: description: GuestAgent controls guest agent injection. Overrides defaults when set. @@ -289,6 +309,11 @@ spec: to true when omitted. type: boolean type: object + idleTimeout: + description: |- + IdleTimeout is how long a ScaleToZero VM must see no traffic before the agent + auto-suspends it. Ignored unless desiredState is "ScaleToZero". Defaults to 5m. + type: string image: description: |- Image is the OCI image used as the VM rootfs. CMD/ENTRYPOINT from the @@ -494,6 +519,13 @@ spec: type: integer type: object type: object + rescheduleOnNodeLoss: + description: |- + RescheduleOnNodeLoss opts this VM into automatic rescheduling when its + assigned node becomes unhealthy. Only takes effect when spec.lifecycle is + "persistent". The VM is NOT rescheduled if any PersistentVolumeClaim is + owned by it (PVCs cannot follow the VM to a new node). + type: boolean restartPolicy: description: |- RestartPolicy overrides restart policy for this specific VM. @@ -706,9 +738,21 @@ spec: (informational). format: date-time type: string + expiresAt: + description: |- + ExpiresAt is the computed expiration timestamp (RunningAt + Spec.ExpireAfter). + Empty when expiration is disabled or VM has not reached Running yet. + format: date-time + type: string ip: description: IP is the IP address assigned to the VM by the ImpNetwork. type: string + lastActivityTime: + description: |- + LastActivityTime is the most recent time the agent observed traffic on the + VM's interface. Used by ScaleToZero idle detection; observability only. + format: date-time + type: string nextRetryAfter: description: NextRetryAfter is the earliest time the controller will attempt the next restart. @@ -728,6 +772,9 @@ spec: - Succeeded - Failed - RetryExhausted + - Suspending + - Suspended + - Resuming type: string restartCount: description: RestartCount is the cumulative number of times this VM @@ -754,6 +801,17 @@ spec: Used to detect and time out stuck start attempts. format: date-time type: string + suspendSnapshotPath: + description: |- + SuspendSnapshotPath is the node-local directory holding the VM's suspend + snapshot (vm.state + vm.mem). Set when the VM is Suspended; the resume path + restores from here. Empty when the VM is not suspended. + type: string + suspendedAt: + description: SuspendedAt is the time the VM last transitioned to phase + Suspended. + format: date-time + type: string type: object type: object served: true diff --git a/config/crd/bases/imp.dev_impvms.yaml b/config/crd/bases/imp.dev_impvms.yaml index 92870fb..9968e68 100644 --- a/config/crd/bases/imp.dev_impvms.yaml +++ b/config/crd/bases/imp.dev_impvms.yaml @@ -79,10 +79,12 @@ spec: description: |- DesiredState is the requested run state. When set to "Suspended" the agent snapshots the VM to node-local storage and frees its memory; setting it back - to "Running" resumes it from that snapshot on the same node. + to "Running" resumes it from that snapshot on the same node. "ScaleToZero" + makes suspension automatic: idle → suspend, first packet → resume. enum: - Running - Suspended + - ScaleToZero type: string env: description: Env sets environment variables inside the VM via the @@ -306,6 +308,11 @@ spec: to true when omitted. type: boolean type: object + idleTimeout: + description: |- + IdleTimeout is how long a ScaleToZero VM must see no traffic before the agent + auto-suspends it. Ignored unless desiredState is "ScaleToZero". Defaults to 5m. + type: string image: description: |- Image is the OCI image used as the VM rootfs. CMD/ENTRYPOINT from the @@ -739,6 +746,12 @@ spec: ip: description: IP is the IP address assigned to the VM by the ImpNetwork. type: string + lastActivityTime: + description: |- + LastActivityTime is the most recent time the agent observed traffic on the + VM's interface. Used by ScaleToZero idle detection; observability only. + format: date-time + type: string nextRetryAfter: description: NextRetryAfter is the earliest time the controller will attempt the next restart. diff --git a/internal/webhook/v1alpha1/impvm_webhook.go b/internal/webhook/v1alpha1/impvm_webhook.go index 36a94a7..8428195 100644 --- a/internal/webhook/v1alpha1/impvm_webhook.go +++ b/internal/webhook/v1alpha1/impvm_webhook.go @@ -173,5 +173,15 @@ func validateImpVM(vm *impdevv1alpha1.ImpVM) field.ErrorList { } } + // idleTimeout gates ScaleToZero auto-suspend; too small a value thrashes + // against the resume latency, so floor it at 10s. + if vm.Spec.IdleTimeout != nil && vm.Spec.IdleTimeout.Duration < 10*time.Second { + errs = append(errs, field.Invalid( + field.NewPath("spec", "idleTimeout"), + vm.Spec.IdleTimeout.Duration.String(), + "idleTimeout must be at least 10s", + )) + } + return errs } diff --git a/internal/webhook/v1alpha1/impvm_webhook_test.go b/internal/webhook/v1alpha1/impvm_webhook_test.go index 8f7228c..ddaa64a 100644 --- a/internal/webhook/v1alpha1/impvm_webhook_test.go +++ b/internal/webhook/v1alpha1/impvm_webhook_test.go @@ -199,6 +199,29 @@ func TestImpVMWebhook_ValidateCreate_Valid_ClassRef(t *testing.T) { } } +func TestImpVMWebhook_ValidateCreate_IdleTimeoutTooSmall(t *testing.T) { + wh := &ImpVMWebhook{} + vm := newVM("", "my-class", "my-image") + vm.Spec.IdleTimeout = &metav1.Duration{Duration: 5 * time.Second} + + _, err := wh.ValidateCreate(context.Background(), vm) + if err == nil { + t.Fatal("expected error for idleTimeout below 10s, got nil") + } +} + +func TestImpVMWebhook_ValidateCreate_IdleTimeoutValid(t *testing.T) { + wh := &ImpVMWebhook{} + vm := newVM("", "my-class", "my-image") + vm.Spec.DesiredState = impdevv1alpha1.VMDesiredStateScaleToZero + vm.Spec.IdleTimeout = &metav1.Duration{Duration: 2 * time.Minute} + + _, err := wh.ValidateCreate(context.Background(), vm) + if err != nil { + t.Errorf("expected no error for valid ScaleToZero+idleTimeout, got: %v", err) + } +} + func TestImpVMWebhook_ValidateCreate_Valid_TemplateRef(t *testing.T) { wh := &ImpVMWebhook{} vm := newVM("my-template", "", "") // templateRef only, no image required