Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions api/v1alpha/instance_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,13 @@ type InstanceStatus struct {
//
// +kubebuilder:validation:Optional
Controller *InstanceControllerStatus `json:"controller,omitempty"`

// Suspended, when true, indicates that the instance's process should be stopped
// without releasing its placement, disk attachments, or quota allocation.
// The provider controller stops the running container/VM.
//
// +kubebuilder:validation:Optional
Suspended bool `json:"suspended,omitempty"`
}

type InstanceControllerStatus struct {
Expand Down Expand Up @@ -587,6 +594,15 @@ const (
// InstanceAvailableReasonAvailable indicates that the instance is available
InstanceAvailableReasonAvailable = "Available"

// InstanceReadyReasonSuspended indicates the instance is intentionally
// stopped due to project suspension. Its placement, disk, and quota
// allocation are retained; the process will restart from disk on reinstatement.
InstanceReadyReasonSuspended = "Suspended"

// InstanceAvailableReasonSuspended indicates the instance is suspended
// and is not currently serving traffic.
InstanceAvailableReasonSuspended = "Suspended"

// InstanceProgrammedReasonPendingProgramming indicates that the instance has not been programmed
InstanceProgrammedReasonPendingProgramming = "PendingProgramming"

Expand Down
6 changes: 6 additions & 0 deletions api/v1alpha/workloaddeployment_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ type WorkloadDeploymentStatus struct {
//
// +kubebuilder:validation:Optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`

// Suspended, when true, requests that all instances managed by this deployment
// be stopped without releasing their placement, disk attachments, or quota allocation.
//
// +kubebuilder:validation:Optional
Suspended bool `json:"suspended,omitempty"`
}

const (
Expand Down
6 changes: 6 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,12 @@ func initializeClusterDiscovery(
Teardowns: []consumerprovider.Teardown{
controller.NewComputeTeardown(quotaClientManager, federationClient, scheme),
},
Suspends: []consumerprovider.Suspend{
controller.NewComputeSuspend(providerMgr.GetEventRecorder("compute-suspension")),
},
Resumes: []consumerprovider.Resume{
controller.NewComputeResume(providerMgr.GetEventRecorder("compute-suspension")),
},
})
if err != nil {
return nil, nil, "", nil, fmt.Errorf("unable to create consumer provider: %w", err)
Expand Down
6 changes: 6 additions & 0 deletions config/base/crd/bases/compute.datumapis.com_instances.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,12 @@ spec:
type: object
type: object
type: array
suspended:
description: |-
Suspended, when true, indicates that the instance's process should be stopped
without releasing its placement, disk attachments, or quota allocation.
The provider controller stops the running container/VM.
type: boolean
type: object
type: object
served: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1173,6 +1173,11 @@ spec:
description: The number of instances created
format: int32
type: integer
suspended:
description: |-
Suspended, when true, requests that all instances managed by this deployment
be stopped without releasing their placement, disk attachments, or quota allocation.
type: boolean
updatedReplicas:
description: |-
The number of instances updated to the latest template revision, i.e.
Expand Down
9 changes: 0 additions & 9 deletions config/components/controller_rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,3 @@ rules:
- get
- list
- watch
- apiGroups:
- services.miloapis.com
resources:
- serviceconsumers
- services
verbs:
- get
- list
- watch
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359
go.miloapis.com/milo v0.30.3
go.miloapis.com/milo v0.32.0
golang.org/x/crypto v0.53.0
golang.org/x/sync v0.21.0
google.golang.org/protobuf v1.36.11
Expand Down Expand Up @@ -68,7 +68,7 @@ require (
github.com/spf13/pflag v1.0.9 // indirect
github.com/stoewer/go-strcase v1.3.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.miloapis.com/service-catalog v0.3.2-0.20260716164540-4e9a0970caaa
go.miloapis.com/service-catalog v0.4.0
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
Expand Down
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,10 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359 h1:P3dePA6cCXKimZzE6d7Xxpj2rz54BxOHI8K8ic7VQ+c=
go.datum.net/network-services-operator v0.21.10-0.20260528021428-b0f2347f5359/go.mod h1:Nr0PsCodkTW31vWVxR9dhAP9w0y+WHUYeyrcRnchcIE=
go.miloapis.com/milo v0.30.3 h1:gnk3Vsn6ahgfO9zIXlZksqJOqOydbJuXpSqvdFBw7Lo=
go.miloapis.com/milo v0.30.3/go.mod h1:GKK3afjCwshfZfvhjNe1wp/H45z4m7x5oG/8xbSgU1M=
go.miloapis.com/service-catalog v0.3.2-0.20260716164540-4e9a0970caaa h1:Sn+/EvOO3GW9LReL7ksV0Qw/OWAEAxXIQyekcK/B8Yw=
go.miloapis.com/service-catalog v0.3.2-0.20260716164540-4e9a0970caaa/go.mod h1:Kkg6Tfpbc3wQaZizfd8sp3ap3uv9WYXEoZW/R4RBy3M=
go.miloapis.com/milo v0.32.0 h1:TkNIQu/37d+SEquLJ5+GmdisSl+K2RT7eEC4idg6RIs=
go.miloapis.com/milo v0.32.0/go.mod h1:GKK3afjCwshfZfvhjNe1wp/H45z4m7x5oG/8xbSgU1M=
go.miloapis.com/service-catalog v0.4.0 h1:LvO1WCHMCoFokpS5igWMP8kyqly9gUFQmQj5IGhwuKs=
go.miloapis.com/service-catalog v0.4.0/go.mod h1:1OfIYkdWH0lpbUH1d0Dc4A1yCtyNpcaKh50H6faG45A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
Expand Down
45 changes: 45 additions & 0 deletions internal/controller/instance_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,14 @@ func (r *InstanceReconciler) Reconcile(ctx context.Context, req mcreconcile.Requ
return ctrl.Result{}, r.reconcileDeletion(ctx, cl.GetClient(), req.ClusterName, &instance)
}

// Honor project suspension: stop all normal provisioning and mark the
// instance unavailable. Placement, quota claim, and disk attachments are
// intentionally left intact so the instance can restart from disk when
// Status.Suspended is cleared on reinstatement.
if instance.Status.Suspended {
return ctrl.Result{}, r.reconcileSuspendedState(ctx, cl.GetClient(), &instance)
}

if !controllerutil.ContainsFinalizer(&instance, instanceQuotaFinalizer) {
controllerutil.AddFinalizer(&instance, instanceQuotaFinalizer)
if err := cl.GetClient().Update(ctx, &instance); err != nil {
Expand Down Expand Up @@ -947,6 +955,43 @@ func (r *InstanceReconciler) emitEvent(obj *computev1alpha.Instance, eventType,
r.recorder.Eventf(obj, nil, eventType, reason, action, "%s", message)
}

// reconcileSuspendedState is called when instance.Status.Suspended is true. It
// sets Ready=False/Suspended and Available=False/Suspended without touching the
// instance's placement, quota claim, or disk attachments. Scheduling gates are
// left in place; quota is not re-evaluated. The only work done here is a status
// update (if anything changed) and a write-back to the upstream hub so the
// management plane reflects the suspended state.
func (r *InstanceReconciler) reconcileSuspendedState(
ctx context.Context,
cl client.Client,
instance *computev1alpha.Instance,
) error {
changed := apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
Type: computev1alpha.InstanceReady,
Status: metav1.ConditionFalse,
Reason: computev1alpha.InstanceReadyReasonSuspended,
Message: "Instance is suspended due to project suspension.",
ObservedGeneration: instance.Generation,
})
changed = apimeta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
Type: computev1alpha.InstanceAvailable,
Status: metav1.ConditionFalse,
Reason: computev1alpha.InstanceAvailableReasonSuspended,
Message: "Instance is suspended and not serving traffic.",
ObservedGeneration: instance.Generation,
}) || changed

if changed {
if err := cl.Status().Update(ctx, instance); err != nil {
return fmt.Errorf("updating instance status for suspension: %w", err)
}
}

// Write suspended status back to the federation hub so the management
// plane aggregates the correct per-instance state.
return r.writeBackToUpstream(ctx, instance)
}

// reconcileDeletion handles quota-claim cleanup when an Instance is being
// deleted. It removes the quota finalizer once the ResourceClaim is gone.
func (r *InstanceReconciler) reconcileDeletion(ctx context.Context, cl client.Client, clusterName multicluster.ClusterName, instance *computev1alpha.Instance) error {
Expand Down
154 changes: 154 additions & 0 deletions internal/controller/suspend_hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// SPDX-License-Identifier: AGPL-3.0-only

package controller

import (
"context"
"fmt"

corev1 "k8s.io/api/core/v1"
"k8s.io/client-go/tools/events"
"sigs.k8s.io/controller-runtime/pkg/client"

computev1alpha "go.datum.net/compute/api/v1alpha"
)

const (
// eventReasonProjectPaused is emitted on each Instance after it is
// successfully suspended due to a project suspension signal.
eventReasonProjectPaused = "ProjectPaused"

// eventReasonProjectResumed is emitted on each Instance after it is
// successfully reinstated following a project reinstatement signal.
eventReasonProjectResumed = "ProjectResumed"

// eventReasonPauseFailed is emitted on an Instance when the controller
// fails to suspend or resume it. The error is still returned so the
// provider's backoff retry fires; no teardown or deletion is attempted.
eventReasonPauseFailed = "PauseFailed"

eventActionSuspending = "Suspending"
eventActionResuming = "Resuming"
)

// ComputeSuspend implements consumer.Suspend. It sets spec.suspended=true on
// every Instance owned by the suspended consumer project, scoped by the
// services.miloapis.com/service-name label. It is idempotent: instances that
// are already suspended are skipped.
//
// On success it emits a ProjectPaused event on each affected Instance.
// On any patch failure it emits a PauseFailed Warning event on that Instance
// and returns the error immediately so the service-catalog provider retries
// with backoff. No teardown or deletion is ever attempted.
type ComputeSuspend struct {
recorder events.EventRecorder
}

// NewComputeSuspend creates a ComputeSuspend. recorder is used to emit
// ProjectPaused / PauseFailed events on affected Instance objects.
func NewComputeSuspend(recorder events.EventRecorder) *ComputeSuspend {
return &ComputeSuspend{recorder: recorder}
}

// SuspendConsumer implements consumer.Suspend.
func (cs *ComputeSuspend) SuspendConsumer(
ctx context.Context,
consumerProject string,
consumerClient client.Client,
serviceNames []string,
) error {
for _, svcName := range serviceNames {
// Suspend all WorkloadDeployments
var wds computev1alpha.WorkloadDeploymentList
if err := consumerClient.List(ctx, &wds,
client.MatchingLabels{labelServiceName: svcName},
); err != nil {
return fmt.Errorf("listing workloaddeployments for service %q: %w", svcName, err)
}
for i := range wds.Items {
wd := &wds.Items[i]
if wd.Status.Suspended {
continue // already suspended — idempotent
}
base := wd.DeepCopy()
wd.Status.Suspended = true
if err := consumerClient.Status().Patch(ctx, wd, client.MergeFrom(base)); err != nil {
if cs.recorder != nil {
cs.recorder.Eventf(wd, nil, corev1.EventTypeWarning,
eventReasonPauseFailed, eventActionSuspending,
"Failed to suspend workloaddeployment %s/%s for project %s: %v",
wd.Namespace, wd.Name, consumerProject, err)
}
return fmt.Errorf("patching workloaddeployment %s/%s status to suspended: %w",
wd.Namespace, wd.Name, err)
}
if cs.recorder != nil {
cs.recorder.Eventf(wd, nil, corev1.EventTypeNormal,
eventReasonProjectPaused, eventActionSuspending,
"WorkloadDeployment suspended due to project suspension of %s", consumerProject)
}
}
}
return nil
}

// ComputeResume implements consumer.Resume. It clears spec.suspended on every
// Instance owned by the reinstated consumer project, scoped by the
// services.miloapis.com/service-name label. It is idempotent: instances that
// are already active are skipped.
//
// On success it emits a ProjectResumed event on each affected Instance.
// On any patch failure it emits a PauseFailed Warning event on that Instance
// and returns the error immediately so the service-catalog provider retries
// with backoff. No teardown or deletion is ever attempted.
type ComputeResume struct {
recorder events.EventRecorder
}

// NewComputeResume creates a ComputeResume. recorder is used to emit
// ProjectResumed / PauseFailed events on affected Instance objects.
func NewComputeResume(recorder events.EventRecorder) *ComputeResume {
return &ComputeResume{recorder: recorder}
}

// ResumeConsumer implements consumer.Resume.
func (cr *ComputeResume) ResumeConsumer(
ctx context.Context,
consumerProject string,
consumerClient client.Client,
serviceNames []string,
) error {
for _, svcName := range serviceNames {
// Resume all WorkloadDeployments
var wds computev1alpha.WorkloadDeploymentList
if err := consumerClient.List(ctx, &wds,
client.MatchingLabels{labelServiceName: svcName},
); err != nil {
return fmt.Errorf("listing workloaddeployments for service %q: %w", svcName, err)
}
for i := range wds.Items {
wd := &wds.Items[i]
if !wd.Status.Suspended {
continue // already active — idempotent
}
base := wd.DeepCopy()
wd.Status.Suspended = false
if err := consumerClient.Status().Patch(ctx, wd, client.MergeFrom(base)); err != nil {
if cr.recorder != nil {
cr.recorder.Eventf(wd, nil, corev1.EventTypeWarning,
eventReasonPauseFailed, eventActionResuming,
"Failed to resume workloaddeployment %s/%s for project %s: %v",
wd.Namespace, wd.Name, consumerProject, err)
}
return fmt.Errorf("patching workloaddeployment %s/%s status to resumed: %w",
wd.Namespace, wd.Name, err)
}
if cr.recorder != nil {
cr.recorder.Eventf(wd, nil, corev1.EventTypeNormal,
eventReasonProjectResumed, eventActionResuming,
"WorkloadDeployment resumed after project reinstatement of %s", consumerProject)
}
}
}
return nil
}
Loading
Loading