Skip to content

Commit 698edf2

Browse files
committed
feat: add shard scale-in topology inventory
Read the Cluster, resolved sharding definition, and shard Components through the API reader before classifying a scale-in candidate. Fail closed on identity, ownership, label, deletion, snapshot, or desired-member drift without producing graph or external writes.
1 parent 1a91d26 commit 698edf2

2 files changed

Lines changed: 657 additions & 0 deletions

File tree

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
/*
2+
Copyright (C) 2022-2026 ApeCloud Co., Ltd
3+
4+
This file is part of KubeBlocks project
5+
6+
KubeBlocks is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU Affero General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
KubeBlocks is distributed in the hope that it will be useful,
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU Affero General Public License for more details.
15+
16+
You should have received a copy of the GNU Affero General Public License
17+
along with KubeBlocks. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
package cluster
21+
22+
import (
23+
"context"
24+
"errors"
25+
"fmt"
26+
"reflect"
27+
"slices"
28+
"strings"
29+
30+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
31+
"k8s.io/apimachinery/pkg/types"
32+
"sigs.k8s.io/controller-runtime/pkg/client"
33+
34+
appsv1 "github.com/apecloud/kubeblocks/apis/apps/v1"
35+
"github.com/apecloud/kubeblocks/pkg/constant"
36+
"github.com/apecloud/kubeblocks/pkg/controller/component"
37+
"github.com/apecloud/kubeblocks/pkg/controller/sharding"
38+
)
39+
40+
var errInvalidShardingScaleInTopology = errors.New("invalid sharding scale-in topology")
41+
42+
const shardingScaleInDefaultShardTemplate = "@default"
43+
44+
type shardingScaleInDesiredComponent struct {
45+
ShardTemplateName string
46+
Spec appsv1.ClusterComponentSpec
47+
}
48+
49+
// shardingScaleInTopologyInventory is a source-only candidate snapshot. It
50+
// deliberately contains live objects rather than persisted plan material;
51+
// later builders must close Pod, prerequisite, request, and capability identity
52+
// before any status CAS or external action is allowed.
53+
type shardingScaleInTopologyInventory struct {
54+
Cluster *appsv1.Cluster
55+
Sharding appsv1.ClusterSharding
56+
ShardingDefinition *appsv1.ShardingDefinition
57+
58+
Components []appsv1.Component
59+
DesiredComponents []shardingScaleInDesiredComponent
60+
Leaving []appsv1.Component
61+
Staying []appsv1.Component
62+
}
63+
64+
func loadFreshShardingScaleInTopology(ctx context.Context, apiReader client.Reader,
65+
clusterKey types.NamespacedName, expectedClusterUID types.UID, shardingName string,
66+
) (*shardingScaleInTopologyInventory, error) {
67+
invalid := func(format string, args ...any) error {
68+
return fmt.Errorf("%w: %s", errInvalidShardingScaleInTopology, fmt.Sprintf(format, args...))
69+
}
70+
if apiReader == nil {
71+
return nil, invalid("APIReader must not be nil")
72+
}
73+
if clusterKey.Namespace == "" || clusterKey.Name == "" || expectedClusterUID == "" || shardingName == "" {
74+
return nil, invalid("Cluster key, expected UID, and sharding name must be complete")
75+
}
76+
77+
cluster := &appsv1.Cluster{}
78+
if err := apiReader.Get(ctx, clusterKey, cluster); err != nil {
79+
return nil, err
80+
}
81+
if cluster.Namespace != clusterKey.Namespace || cluster.Name != clusterKey.Name ||
82+
cluster.UID != expectedClusterUID {
83+
return nil, invalid("fresh Cluster UID or name does not match the requested identity")
84+
}
85+
if cluster.Generation <= 0 || cluster.ResourceVersion == "" {
86+
return nil, invalid("fresh Cluster generation and resourceVersion must be non-empty")
87+
}
88+
if !cluster.DeletionTimestamp.IsZero() {
89+
return nil, invalid("fresh Cluster is deleting")
90+
}
91+
92+
shardingSpec, err := exactClusterSharding(cluster, shardingName)
93+
if err != nil {
94+
return nil, err
95+
}
96+
if shardingSpec.Shards <= 0 || shardingSpec.Shards > 32 {
97+
return nil, invalid("desired shard count must be between 1 and 32")
98+
}
99+
if shardingSpec.ShardingDef == "" {
100+
return nil, invalid("sharding definition selector must not be empty")
101+
}
102+
103+
shardingDef, err := resolveShardingDefinition(ctx, apiReader, shardingSpec.ShardingDef)
104+
if err != nil {
105+
return nil, err
106+
}
107+
if shardingDef == nil || shardingDef.Name == "" || shardingDef.UID == "" ||
108+
shardingDef.Generation <= 0 || !shardingDef.DeletionTimestamp.IsZero() {
109+
return nil, invalid("resolved ShardingDefinition identity must be complete and not deleting")
110+
}
111+
if shardingDef.Spec.LifecycleActions == nil ||
112+
shardingDef.Spec.LifecycleActions.ShardRemove == nil ||
113+
shardingDef.Spec.LifecycleActions.ShardRemove.ResultProtocol != appsv1.ShardingScaleInResultProtocolV2 {
114+
return nil, invalid("resolved ShardingDefinition must provide the typed shard-remove action")
115+
}
116+
if err := validateShardingShards(shardingDef, shardingSpec); err != nil {
117+
return nil, err
118+
}
119+
120+
before, err := listFreshShardingScaleInComponents(ctx, apiReader, cluster, shardingName)
121+
if err != nil {
122+
return nil, err
123+
}
124+
if err := validateFreshShardingScaleInComponents(cluster, shardingDef, shardingName, before); err != nil {
125+
return nil, err
126+
}
127+
128+
desiredByTemplate, err := sharding.BuildShardingCompSpecs(
129+
ctx, apiReader, cluster.Namespace, cluster.Name, shardingSpec)
130+
if err != nil {
131+
return nil, err
132+
}
133+
134+
after, err := listFreshShardingScaleInComponents(ctx, apiReader, cluster, shardingName)
135+
if err != nil {
136+
return nil, err
137+
}
138+
if !sameShardingScaleInComponentSnapshot(before, after) {
139+
return nil, invalid("fresh Component snapshot changed while desired members were rebuilt")
140+
}
141+
142+
desiredComponents, err := flattenShardingScaleInDesiredComponents(cluster.Name, desiredByTemplate)
143+
if err != nil {
144+
return nil, err
145+
}
146+
if len(desiredComponents) != int(shardingSpec.Shards) {
147+
return nil, invalid("desired Component count %d does not match desired shards %d",
148+
len(desiredComponents), shardingSpec.Shards)
149+
}
150+
151+
currentByName := make(map[string]*appsv1.Component, len(after))
152+
for i := range after {
153+
currentByName[after[i].Name] = &after[i]
154+
}
155+
staying := make([]appsv1.Component, 0, len(desiredComponents))
156+
for i := range desiredComponents {
157+
name := component.FullName(cluster.Name, desiredComponents[i].Spec.Name)
158+
current, ok := currentByName[name]
159+
if !ok {
160+
return nil, invalid("desired topology would create Component %q", name)
161+
}
162+
staying = append(staying, *current.DeepCopy())
163+
delete(currentByName, name)
164+
}
165+
leaving := make([]appsv1.Component, 0, len(currentByName))
166+
for _, comp := range currentByName {
167+
leaving = append(leaving, *comp.DeepCopy())
168+
}
169+
if len(leaving) == 0 || len(staying) == 0 || len(leaving)+len(staying) != len(after) {
170+
return nil, invalid("scale-in requires non-empty, exhaustive leaving and staying member sets")
171+
}
172+
if len(after) > 32 {
173+
return nil, invalid("current sharding contains more than 32 Components")
174+
}
175+
176+
sortShardingScaleInComponents(after)
177+
sortShardingScaleInComponents(leaving)
178+
sortShardingScaleInComponents(staying)
179+
return &shardingScaleInTopologyInventory{
180+
Cluster: cluster.DeepCopy(),
181+
Sharding: *shardingSpec.DeepCopy(),
182+
ShardingDefinition: shardingDef.DeepCopy(),
183+
Components: deepCopyShardingScaleInComponents(after),
184+
DesiredComponents: deepCopyShardingScaleInDesiredComponents(desiredComponents),
185+
Leaving: leaving,
186+
Staying: staying,
187+
}, nil
188+
}
189+
190+
func exactClusterSharding(cluster *appsv1.Cluster, shardingName string) (*appsv1.ClusterSharding, error) {
191+
var found *appsv1.ClusterSharding
192+
for i := range cluster.Spec.Shardings {
193+
if cluster.Spec.Shardings[i].Name != shardingName {
194+
continue
195+
}
196+
if found != nil {
197+
return nil, fmt.Errorf("%w: Cluster sharding %q is duplicated",
198+
errInvalidShardingScaleInTopology, shardingName)
199+
}
200+
found = &cluster.Spec.Shardings[i]
201+
}
202+
if found == nil {
203+
return nil, fmt.Errorf("%w: Cluster sharding %q was not found",
204+
errInvalidShardingScaleInTopology, shardingName)
205+
}
206+
return found, nil
207+
}
208+
209+
func validateFreshShardingScaleInComponents(cluster *appsv1.Cluster,
210+
shardingDef *appsv1.ShardingDefinition, shardingName string, components []appsv1.Component,
211+
) error {
212+
invalid := func(format string, args ...any) error {
213+
return fmt.Errorf("%w: %s", errInvalidShardingScaleInTopology, fmt.Sprintf(format, args...))
214+
}
215+
if len(components) == 0 || len(components) > 32 {
216+
return invalid("current sharding must contain between 1 and 32 Components")
217+
}
218+
names := map[string]struct{}{}
219+
uids := map[types.UID]struct{}{}
220+
shortNames := map[string]struct{}{}
221+
for i := range components {
222+
comp := &components[i]
223+
if comp.Namespace != cluster.Namespace || comp.Name == "" || comp.UID == "" ||
224+
comp.ResourceVersion == "" || comp.Generation <= 0 {
225+
return invalid("Component identity, generation, and resourceVersion must be complete")
226+
}
227+
if !comp.DeletionTimestamp.IsZero() {
228+
return invalid("Component is deleting: %s/%s", comp.Namespace, comp.Name)
229+
}
230+
if comp.Labels[constant.AppInstanceLabelKey] != cluster.Name ||
231+
comp.Labels[constant.KBAppShardingNameLabelKey] != shardingName ||
232+
comp.Labels[constant.ShardingDefLabelKey] != shardingDef.Name {
233+
return invalid("Component %s/%s does not carry exact Cluster, sharding, and definition labels",
234+
comp.Namespace, comp.Name)
235+
}
236+
owner := metav1.GetControllerOf(comp)
237+
if owner == nil || owner.APIVersion != appsv1.GroupVersion.String() ||
238+
owner.Kind != appsv1.ClusterKind || owner.Name != cluster.Name || owner.UID != cluster.UID {
239+
return invalid("Component %s/%s does not have the exact Cluster controller owner",
240+
comp.Namespace, comp.Name)
241+
}
242+
if comp.Annotations[shardingAddShardKey] != "" {
243+
return invalid("Component %s/%s has a pending shard-add", comp.Namespace, comp.Name)
244+
}
245+
shortName, err := component.ShortName(cluster.Name, comp.Name)
246+
if err != nil || shortName == "" || !strings.HasPrefix(shortName, shardingName+"-") {
247+
return invalid("Component %s/%s has an invalid short name", comp.Namespace, comp.Name)
248+
}
249+
if _, ok := names[comp.Name]; ok {
250+
return invalid("Component name %q is duplicated", comp.Name)
251+
}
252+
if _, ok := uids[comp.UID]; ok {
253+
return invalid("Component UID %q is duplicated", comp.UID)
254+
}
255+
if _, ok := shortNames[shortName]; ok {
256+
return invalid("Component short name %q is duplicated", shortName)
257+
}
258+
names[comp.Name] = struct{}{}
259+
uids[comp.UID] = struct{}{}
260+
shortNames[shortName] = struct{}{}
261+
}
262+
return nil
263+
}
264+
265+
func listFreshShardingScaleInComponents(ctx context.Context, apiReader client.Reader,
266+
cluster *appsv1.Cluster, shardingName string,
267+
) ([]appsv1.Component, error) {
268+
list := &appsv1.ComponentList{}
269+
if err := apiReader.List(ctx, list, client.InNamespace(cluster.Namespace)); err != nil {
270+
return nil, err
271+
}
272+
273+
regularComponentNames := make(map[string]struct{}, len(cluster.Spec.ComponentSpecs))
274+
for i := range cluster.Spec.ComponentSpecs {
275+
if cluster.Spec.ComponentSpecs[i].Name != "" {
276+
regularComponentNames[component.FullName(cluster.Name, cluster.Spec.ComponentSpecs[i].Name)] =
277+
struct{}{}
278+
}
279+
}
280+
281+
components := make([]appsv1.Component, 0)
282+
for i := range list.Items {
283+
comp := &list.Items[i]
284+
labelMatches := comp.Labels[constant.KBAppShardingNameLabelKey] == shardingName
285+
shortName, err := component.ShortName(cluster.Name, comp.Name)
286+
nameMatches := err == nil && strings.HasPrefix(shortName, shardingName+"-")
287+
_, regularComponent := regularComponentNames[comp.Name]
288+
if labelMatches || (nameMatches && !regularComponent) {
289+
components = append(components, *comp.DeepCopy())
290+
}
291+
}
292+
return components, nil
293+
}
294+
295+
func flattenShardingScaleInDesiredComponents(clusterName string,
296+
desiredByTemplate map[string][]*appsv1.ClusterComponentSpec,
297+
) ([]shardingScaleInDesiredComponent, error) {
298+
components := make([]shardingScaleInDesiredComponent, 0)
299+
names := map[string]struct{}{}
300+
for templateName, templateSpecs := range desiredByTemplate {
301+
if templateName == "" {
302+
templateName = shardingScaleInDefaultShardTemplate
303+
}
304+
for i := range templateSpecs {
305+
if templateSpecs[i] == nil || templateSpecs[i].Name == "" {
306+
return nil, fmt.Errorf("%w: desired Component name must not be empty",
307+
errInvalidShardingScaleInTopology)
308+
}
309+
spec := templateSpecs[i].DeepCopy()
310+
fullName := component.FullName(clusterName, spec.Name)
311+
if _, ok := names[fullName]; ok {
312+
return nil, fmt.Errorf("%w: desired Component name %q is duplicated",
313+
errInvalidShardingScaleInTopology, fullName)
314+
}
315+
names[fullName] = struct{}{}
316+
components = append(components, shardingScaleInDesiredComponent{
317+
ShardTemplateName: templateName,
318+
Spec: *spec,
319+
})
320+
}
321+
}
322+
slices.SortFunc(components, func(a, b shardingScaleInDesiredComponent) int {
323+
return strings.Compare(a.Spec.Name, b.Spec.Name)
324+
})
325+
return components, nil
326+
}
327+
328+
func sameShardingScaleInComponentSnapshot(a, b []appsv1.Component) bool {
329+
aCopy := deepCopyShardingScaleInComponents(a)
330+
bCopy := deepCopyShardingScaleInComponents(b)
331+
sortShardingScaleInComponents(aCopy)
332+
sortShardingScaleInComponents(bCopy)
333+
return reflect.DeepEqual(aCopy, bCopy)
334+
}
335+
336+
func deepCopyShardingScaleInComponents(in []appsv1.Component) []appsv1.Component {
337+
out := make([]appsv1.Component, len(in))
338+
for i := range in {
339+
out[i] = *in[i].DeepCopy()
340+
}
341+
return out
342+
}
343+
344+
func deepCopyShardingScaleInDesiredComponents(
345+
in []shardingScaleInDesiredComponent,
346+
) []shardingScaleInDesiredComponent {
347+
out := make([]shardingScaleInDesiredComponent, len(in))
348+
for i := range in {
349+
out[i] = shardingScaleInDesiredComponent{
350+
ShardTemplateName: in[i].ShardTemplateName,
351+
Spec: *in[i].Spec.DeepCopy(),
352+
}
353+
}
354+
return out
355+
}
356+
357+
func sortShardingScaleInComponents(components []appsv1.Component) {
358+
slices.SortFunc(components, func(a, b appsv1.Component) int {
359+
return strings.Compare(a.Name, b.Name)
360+
})
361+
}

0 commit comments

Comments
 (0)