Skip to content
Draft
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
3 changes: 2 additions & 1 deletion docs/api-reference/operator-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,7 +491,8 @@ _Appears in:_
| `availableReplicas` _integer_ | AvailableReplicas is the number of PodCliqueSet replicas that are available.<br />A PodCliqueSet replica is considered available when all standalone PodCliques within that replica<br />have MinAvailableBreached condition = False AND all PodCliqueScalingGroups (PCSG) within that replica<br />have MinAvailableBreached condition = False. | 0 | |
| `hpaPodSelector` _string_ | Selector is the label selector that determines which pods are part of the PodGang.<br />PodGang is a unit of scale and this selector is used by HPA to scale the PodGang based on metrics captured for<br />the pods that match this selector. | | |
| `podGangStatuses` _[PodGangStatus](#podgangstatus) array_ | PodGangStatuses captures the status for all the PodGang's that are part of the PodCliqueSet. | | |
| `currentGenerationHash` _string_ | CurrentGenerationHash is a hash value generated out of a collection of fields in a PodCliqueSet.<br />Since only a subset of fields is taken into account when generating the hash, not every change in the PodCliqueSetSpec will<br />be accounted for when generating this hash value. A field in PodCliqueSetSpec is included if a change to it triggers<br />a rolling recreate of PodCliques and/or PodCliqueScalingGroups.<br />Only if this value is not nil and the newly computed hash value is different from the persisted CurrentGenerationHash value<br />then an update needs to be triggered. | | |
| `currentGenerationHash` _string_ | CurrentGenerationHash is the opaque identity selected for the current rollout-relevant revision. | | |
| `currentRevision` _string_ | CurrentRevision names the ControllerRevision containing the selected rollout revision. | | |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any kind of cleanup for old revisions or are they all kept for the whole PCS lifetime?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

| `updateProgress` _[PodCliqueSetUpdateProgress](#podcliquesetupdateprogress)_ | UpdateProgress represents the progress of an update. | | |


Expand Down
20 changes: 20 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,26 @@ Follow the instructions in the [quickstart guide](quickstart.md) to deploy a Pod

## Upgrade Notes

### Rollout revision adoption

Before upgrading to a release that stores PodCliqueSet revisions in
`apps/v1` `ControllerRevision` objects:

1. Apply the release CRDs before starting the new operator. The
`PodCliqueSet` CRD must preserve `status.currentRevision`.
2. Allow every existing PodCliqueSet update to finish. A legacy object is
adopted only when `status.observedGeneration` equals `metadata.generation`
and no automatic update is active.
3. Start the new operator. It records the already-observed desired workload as
a baseline ControllerRevision while preserving the existing rollout hashes;
it does not update or replace workload children during adoption.

The next rollout-relevant spec change creates a new ControllerRevision and
uses the normal update strategy. An active or otherwise unobserved legacy
revision is left unchanged and reports a reconciliation error until the
migration precondition is resolved. Downgrading afterward to an operator that
does not understand ControllerRevision-backed selection is unsupported.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Preventing the option to rollback is a big deal IMHO. Can you think of ways to support it, even if it comes with some price, including even pods being restarted? At least so we can discuss the tradeoffs. Being able to rollback is important, especially given the complexity of changes like this one and other major ones that are in the pipeline.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me take a look. It should work theoretically as long as the actual template hash doesn't change, but I didn't test it.


### ClusterTopology renamed to ClusterTopologyBinding

Grove does not provide automatic migration for existing `ClusterTopology`
Expand Down
13 changes: 6 additions & 7 deletions operator/api/core/v1alpha1/crds/grove.io_podcliquesets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11062,13 +11062,12 @@ spec:
type: object
type: array
currentGenerationHash:
description: |-
CurrentGenerationHash is a hash value generated out of a collection of fields in a PodCliqueSet.
Since only a subset of fields is taken into account when generating the hash, not every change in the PodCliqueSetSpec will
be accounted for when generating this hash value. A field in PodCliqueSetSpec is included if a change to it triggers
a rolling recreate of PodCliques and/or PodCliqueScalingGroups.
Only if this value is not nil and the newly computed hash value is different from the persisted CurrentGenerationHash value
then an update needs to be triggered.
description: CurrentGenerationHash is the opaque identity selected
for the current rollout-relevant revision.
type: string
currentRevision:
description: CurrentRevision names the ControllerRevision containing
the selected rollout revision.
type: string
hpaPodSelector:
description: |-
Expand Down
9 changes: 3 additions & 6 deletions operator/api/core/v1alpha1/podcliqueset.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,10 @@ type PodCliqueSetStatus struct {
Selector *string `json:"hpaPodSelector,omitempty"`
// PodGangStatuses captures the status for all the PodGang's that are part of the PodCliqueSet.
PodGangStatutes []PodGangStatus `json:"podGangStatuses,omitempty"`
// CurrentGenerationHash is a hash value generated out of a collection of fields in a PodCliqueSet.
// Since only a subset of fields is taken into account when generating the hash, not every change in the PodCliqueSetSpec will
// be accounted for when generating this hash value. A field in PodCliqueSetSpec is included if a change to it triggers
// a rolling recreate of PodCliques and/or PodCliqueScalingGroups.
// Only if this value is not nil and the newly computed hash value is different from the persisted CurrentGenerationHash value
// then an update needs to be triggered.
// CurrentGenerationHash is the opaque identity selected for the current rollout-relevant revision.
CurrentGenerationHash *string `json:"currentGenerationHash,omitempty"`
// CurrentRevision names the ControllerRevision containing the selected rollout revision.
CurrentRevision *string `json:"currentRevision,omitempty"`
// UpdateProgress represents the progress of an update.
UpdateProgress *PodCliqueSetUpdateProgress `json:"updateProgress,omitempty"`
}
Expand Down
5 changes: 5 additions & 0 deletions operator/api/core/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions operator/charts/templates/clusterrole.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ metadata:
labels:
{{- include "operator.clusterrole.labels" . | nindent 4 }}
rules:
- apiGroups:
- apps
resources:
- controllerrevisions
verbs:
- create
- delete
- get
- list
- watch
- apiGroups:
- scheduler.grove.io
resources:
Expand Down
3 changes: 2 additions & 1 deletion operator/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ require (
github.com/lib/pq v1.10.9 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
Expand Down Expand Up @@ -136,6 +136,7 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.42.0 // indirect
Expand Down
10 changes: 4 additions & 6 deletions operator/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
Expand Down Expand Up @@ -376,8 +375,8 @@ golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
Expand All @@ -395,7 +394,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,16 @@ package utils

import (
"context"
"fmt"
"slices"
"time"

apicommon "github.com/ai-dynamo/grove/operator/api/common"
"github.com/ai-dynamo/grove/operator/api/common/constants"
grovecorev1alpha1 "github.com/ai-dynamo/grove/operator/api/core/v1alpha1"
commonrevision "github.com/ai-dynamo/grove/operator/internal/controller/common/revision"
"github.com/ai-dynamo/grove/operator/internal/utils"
k8sutils "github.com/ai-dynamo/grove/operator/internal/utils/kubernetes"

"github.com/samber/lo"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand Down Expand Up @@ -198,19 +196,6 @@ func groupPCLQsByLabel(pclqs []grovecorev1alpha1.PodClique, labelKey string) map
return grouped
}

// ComputePCLQPodTemplateHash computes the pod template hash for the PCLQ pod spec.
func ComputePCLQPodTemplateHash(pclqTemplateSpec *grovecorev1alpha1.PodCliqueTemplateSpec, priorityClassName string) string {
podTemplateSpec := corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: pclqTemplateSpec.Labels,
Annotations: pclqTemplateSpec.Annotations,
},
Spec: pclqTemplateSpec.Spec.PodSpec,
}
podTemplateSpec.Spec.PriorityClassName = priorityClassName
return k8sutils.ComputeHash(&podTemplateSpec)
}

// IsPCLQAutoUpdateInProgress checks if PodClique is under an auto-orchestrated update.
func IsPCLQAutoUpdateInProgress(pclq *grovecorev1alpha1.PodClique) bool {
return pclq.Status.UpdateProgress != nil && pclq.Status.UpdateProgress.UpdateEndedAt == nil
Expand All @@ -223,17 +208,13 @@ func IsLastPCLQUpdateCompleted(pclq *grovecorev1alpha1.PodClique) bool {
return pclq.Status.UpdateProgress != nil && pclq.Status.UpdateProgress.UpdateEndedAt != nil
}

// GetExpectedPCLQPodTemplateHash finds the matching PodCliqueTemplateSpec from the PodCliqueSet and computes the pod template hash for the PCLQ pod spec.
func GetExpectedPCLQPodTemplateHash(pcs *grovecorev1alpha1.PodCliqueSet, pclqObjectMeta metav1.ObjectMeta) (string, error) {
// GetExpectedPCLQPodTemplateHash returns the selected pod template identity for a PodClique.
func GetExpectedPCLQPodTemplateHash(revision *commonrevision.SelectedRevision, pclqObjectMeta metav1.ObjectMeta) (string, error) {
cliqueName, err := utils.GetPodCliqueNameFromPodCliqueFQN(pclqObjectMeta)
if err != nil {
return "", err
}
matchingPCLQTemplateSpec := FindPodCliqueTemplateSpecByName(pcs, cliqueName)
if matchingPCLQTemplateSpec == nil {
return "", fmt.Errorf("pod clique template not found for cliqueName: %s", cliqueName)
}
return ComputePCLQPodTemplateHash(matchingPCLQTemplateSpec, pcs.Spec.Template.PriorityClassName), nil
return revision.CliqueHash(cliqueName)
}

// FindPodCliqueTemplateSpecByName retrieves the PodCliqueTemplateSpec from the PodCliqueSet by its name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (

apicommon "github.com/ai-dynamo/grove/operator/api/common"
grovecorev1alpha1 "github.com/ai-dynamo/grove/operator/api/core/v1alpha1"
commonrevision "github.com/ai-dynamo/grove/operator/internal/controller/common/revision"

"github.com/samber/lo"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand Down Expand Up @@ -122,8 +123,8 @@ func GetPCSGsByPCSReplicaIndex(ctx context.Context, cl client.Client, pcsObjKey
return pcsgsByPCSReplicaIndex, nil
}

// GetPCLQTemplateHashes generates the Pod template hash for all PCLQs in a PCSG. Returns a map of [PCLQ Name : PodTemplateHas]
func GetPCLQTemplateHashes(pcs *grovecorev1alpha1.PodCliqueSet, pcsg *grovecorev1alpha1.PodCliqueScalingGroup) map[string]string {
// GetPCLQTemplateHashes returns the selected Pod template identities for all PCLQs in a PCSG.
func GetPCLQTemplateHashes(revision *commonrevision.SelectedRevision, pcs *grovecorev1alpha1.PodCliqueSet, pcsg *grovecorev1alpha1.PodCliqueScalingGroup) map[string]string {
pclqTemplateSpecs := make([]*grovecorev1alpha1.PodCliqueTemplateSpec, 0, len(pcsg.Spec.CliqueNames))
for _, cliqueName := range pcsg.Spec.CliqueNames {
pclqTemplateSpec := FindPodCliqueTemplateSpecByName(pcs, cliqueName)
Expand All @@ -136,7 +137,11 @@ func GetPCLQTemplateHashes(pcs *grovecorev1alpha1.PodCliqueSet, pcsg *grovecorev
for pcsgReplicaIndex := range int(pcsg.Spec.Replicas) {
for _, pclqTemplateSpec := range pclqTemplateSpecs {
pclqFQN := apicommon.GeneratePodCliqueName(apicommon.ResourceNameReplica{Name: pcsg.Name, Replica: pcsgReplicaIndex}, pclqTemplateSpec.Name)
cliqueTemplateSpecHashes[pclqFQN] = ComputePCLQPodTemplateHash(pclqTemplateSpec, pcs.Spec.Template.PriorityClassName)
hash, err := revision.CliqueHash(pclqTemplateSpec.Name)
if err != nil {
continue
}
cliqueTemplateSpecHashes[pclqFQN] = hash
}
}
return cliqueTemplateSpecHashes
Expand All @@ -145,9 +150,9 @@ func GetPCLQTemplateHashes(pcs *grovecorev1alpha1.PodCliqueSet, pcsg *grovecorev
// GetPCLQsInPCSGPendingUpdate collects the PodClique FQNs that are pending updates.
// It identifies PCLQ pending update by comparing the current PodTemplateHash label on an existing PCLQ with that of
// a computed PodTemplateHash from the latest PodCliqueSet resource.
func GetPCLQsInPCSGPendingUpdate(pcs *grovecorev1alpha1.PodCliqueSet, pcsg *grovecorev1alpha1.PodCliqueScalingGroup, existingPCLQs []grovecorev1alpha1.PodClique) []string {
func GetPCLQsInPCSGPendingUpdate(revision *commonrevision.SelectedRevision, pcs *grovecorev1alpha1.PodCliqueSet, pcsg *grovecorev1alpha1.PodCliqueScalingGroup, existingPCLQs []grovecorev1alpha1.PodClique) []string {
pclqFQNsPendingUpdate := make([]string, 0, len(existingPCLQs))
expectedPCLQPodTemplateHashes := GetPCLQTemplateHashes(pcs, pcsg)
expectedPCLQPodTemplateHashes := GetPCLQTemplateHashes(revision, pcs, pcsg)
for _, existingPCLQ := range existingPCLQs {
existingPodTemplateHash := existingPCLQ.Labels[apicommon.LabelPodTemplateHash]
expectedPodTemplateHash := expectedPCLQPodTemplateHashes[existingPCLQ.Name]
Expand Down
51 changes: 51 additions & 0 deletions operator/internal/controller/common/component/utils/revision.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// /*
// Copyright 2026 The Grove Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// */

package utils

import (
"context"
"fmt"

grovecorev1alpha1 "github.com/ai-dynamo/grove/operator/api/core/v1alpha1"
commonrevision "github.com/ai-dynamo/grove/operator/internal/controller/common/revision"

appsv1 "k8s.io/api/apps/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
)

// GetSelectedPodCliqueSetRevision loads and validates the revision selected by the PodCliqueSet.
func GetSelectedPodCliqueSetRevision(ctx context.Context, cl client.Client, pcs *grovecorev1alpha1.PodCliqueSet) (*commonrevision.SelectedRevision, error) {
if pcs.Status.CurrentRevision == nil || *pcs.Status.CurrentRevision == "" {
return nil, fmt.Errorf("PodCliqueSet %v has no selected ControllerRevision", client.ObjectKeyFromObject(pcs))
}
controllerRevision := &appsv1.ControllerRevision{}
if err := cl.Get(ctx, client.ObjectKey{Namespace: pcs.Namespace, Name: *pcs.Status.CurrentRevision}, controllerRevision); err != nil {
return nil, err
}
if !metav1.IsControlledBy(controllerRevision, pcs) {
return nil, fmt.Errorf("ControllerRevision %v is not controlled by PodCliqueSet %v", client.ObjectKeyFromObject(controllerRevision), client.ObjectKeyFromObject(pcs))
}
selectedRevision, err := commonrevision.DecodeSelectedRevision(controllerRevision.Data.Raw)
if err != nil {
return nil, fmt.Errorf("ControllerRevision %v has invalid revision data: %w", client.ObjectKeyFromObject(controllerRevision), err)
}
if pcs.Status.CurrentGenerationHash == nil || *pcs.Status.CurrentGenerationHash != selectedRevision.GenerationHash() {
return nil, fmt.Errorf("PodCliqueSet %v generation identity does not match ControllerRevision %v", client.ObjectKeyFromObject(pcs), client.ObjectKeyFromObject(controllerRevision))
}
return selectedRevision, nil
}
Loading
Loading