Skip to content

Commit 716e07d

Browse files
feat(scheduling): phase 1 - swift-nic capacity scheduling, pending reservations, provision shard pinning
Builds on phase 0 (ready/not-ready mirroring). Adds capacity-aware placement of HostedControlPlanes onto management clusters based on SWIFT-NIC capacity, plus provision-shard pinning at Cluster Service creation. Per eligible management cluster, available swift NICs are computed as: ScaleCeiling.Capacity[swift-nic] - ObservedResources.Usage[swift-nic] - (non-empty NotReadyResourceIDs)*3 - (non-nil PendingAssignedClusters)*3 and an HCP fits when available >= 3 (swiftNICsPerHCP; a conservative flat per-HCP cost that never overbooks). Nil/empty list entries do not correspond to a real HCP and never reserve capacity. API: - coreapi: add ServiceProviderCluster.Spec.ManagementClusterResourceID (scheduler intent; drift from the observed Status placement is logged for investigation but NOT auto-corrected), regenerate deepcopy. - fleetapi: add ManagementClusterScheduling.Status.PendingAssignedClusters (transient reservations), regenerate deepcopy. Controllers: - placement: new PlacementController (cluster-keyed, single worker). Backfills Spec from an already-observed Status placement during rollout; otherwise runs a pure, capacity-aware selection (fit filter + lowest-available bin-pack tie-break), reserves capacity in the chosen MC's PendingAssignedClusters, then records Spec. Transient write/conflict failures return an error so the workqueue retries with backoff. - placement: new PendingCleanupController (MC-keyed periodic sweep) removes stale pending reservations. Effective placement favors the SPC's observed Status over Spec; an entry is kept when it points here or is still resolving (nil) and removed when it points elsewhere or the SPC is gone. SPCs are read via the shared lister/cache; transient failures return an error for workqueue backoff. - placement: ManagementClusterPlacementSync reconciles Status from Cluster Service only while the observed placement is still unknown; once Status is set it skips the CS lookup. Spec/Status drift is logged, never auto-corrected. - fleet CapacityReportingController: in the same read-modify-write, drops pending reservations that are now observed (Ready or NotReady). - creation: ClusterPendingClusterServiceIDAssign gates on Spec.ManagementClusterResourceID != nil; ClusterClusterServiceCreate pins the provision shard via ClusterBuilder.ProvisionShardID resolved from the placed management cluster. - wire PlacementController and PendingCleanupController into backend (single worker each; FleetDBClient + listers). Tests: pure selection combos + lowest-available tie-break; capacity formula edges incl. nil/empty entries; capacity-report pending cleanup; pending-cleanup stale-entry matrix (Status-favored); rollout backfill; placement-sync skip-when-Status-set and log-only drift; gated needsWork; provision shard pinning. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent fb8beff commit 716e07d

20 files changed

Lines changed: 1850 additions & 188 deletions

backend/pkg/app/backend.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,9 @@ func (b *Backend) runBackendControllersUnderLeaderElection(ctx context.Context,
462462
_, serviceProviderClusterLister := backendInformers.ServiceProviderClusters()
463463
_, serviceProviderNodePoolLister := backendInformers.ServiceProviderNodePools()
464464

465+
// Aggregate placement-state gauge recomputed periodically from the SPC cache.
466+
placementMetricsController := metrics.NewPlacementMetricsController(b.options.MetricsRegisterer, serviceProviderClusterLister)
467+
465468
subscriptionNonClusterDataDumpController := datadump.NewSubscriptionNonClusterDataDumpController(b.options.ResourcesDBClient, backendInformers)
466469
clusterRecursiveDataDumpController := datadump.NewClusterRecursiveDataDumpController(b.options.ResourcesDBClient, b.options.KubeApplierDBClients, managementClusterLister, activeOperationLister, backendInformers, unionKubeApplierInformers)
467470
csStateDumpController := datadump.NewCSStateDumpController(b.options.ResourcesDBClient, activeOperationLister, backendInformers, unionKubeApplierInformers, b.options.ClustersServiceClient)
@@ -881,6 +884,18 @@ func (b *Backend) runBackendControllersUnderLeaderElection(ctx context.Context,
881884
backendInformers,
882885
unionKubeApplierInformers,
883886
)
887+
placementController := clusterplacement.NewPlacementController(
888+
b.options.ResourcesDBClient,
889+
b.options.FleetDBClient,
890+
managementClusterLister,
891+
backendInformers,
892+
unionKubeApplierInformers,
893+
)
894+
pendingCleanupController := clusterplacement.NewPendingCleanupController(
895+
b.options.FleetDBClient,
896+
serviceProviderClusterLister,
897+
fleetInformers,
898+
)
884899

885900
nodePoolClusterServiceCreateController := nodepoolcreation.NewNodePoolClusterServiceCreateController(
886901
b.options.ResourcesDBClient,
@@ -952,6 +967,7 @@ func (b *Backend) runBackendControllersUnderLeaderElection(ctx context.Context,
952967
clusterClusterServiceCreateController := clustercreation.NewClusterClusterServiceCreateController(
953968
b.options.ResourcesDBClient,
954969
b.options.ClustersServiceClient,
970+
managementClusterLister,
955971
backendInformers,
956972
)
957973

@@ -1126,7 +1142,10 @@ func (b *Backend) runBackendControllersUnderLeaderElection(ctx context.Context,
11261142
go nodePoolMetricsController.Run(ctx, 1)
11271143
go externalAuthMetricsController.Run(ctx, 1)
11281144
go clusterInfoMetricsController.Run(ctx, 1)
1145+
go placementMetricsController.Run(ctx, 1)
11291146
go placementSyncController.Run(ctx, 20)
1147+
go placementController.Run(ctx, 1) // single worker: capacity selection reads/reserves across MCs and must not race itself
1148+
go pendingCleanupController.Run(ctx, 1) // single worker: sweeps pending reservations per management cluster
11301149
go cosmosMigrationController.Run(ctx, 5)
11311150
go virtualMachineResourceSKUsCachedReaderController.Run(ctx, 20)
11321151
go backupScheduleController.Run(ctx, 20)

backend/pkg/controllers/cluster/creation/cluster_cluster_service_create_controller.go

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,31 +29,35 @@ import (
2929
"github.com/Azure/ARO-HCP/internal/database/cosmosstorage/cosmosstorageutils"
3030
"github.com/Azure/ARO-HCP/internal/database/informers/coreinformers"
3131
"github.com/Azure/ARO-HCP/internal/database/listers/corelisters"
32+
"github.com/Azure/ARO-HCP/internal/database/listers/fleetlisters"
3233
"github.com/Azure/ARO-HCP/internal/ocm"
3334
"github.com/Azure/ARO-HCP/internal/utils"
3435
)
3536

3637
type clusterClusterServiceCreateSyncer struct {
37-
resourcesDBClient corecosmosstorage.ResourcesDBClient
38-
clusterLister corelisters.ClusterLister
39-
subscriptionLister corelisters.SubscriptionLister
40-
clustersServiceClient ocm.ClusterServiceClientSpec
38+
resourcesDBClient corecosmosstorage.ResourcesDBClient
39+
clusterLister corelisters.ClusterLister
40+
subscriptionLister corelisters.SubscriptionLister
41+
managementClusterLister fleetlisters.ManagementClusterLister
42+
clustersServiceClient ocm.ClusterServiceClientSpec
4143
}
4244

4345
var _ controllerutils.ClusterSyncer = (*clusterClusterServiceCreateSyncer)(nil)
4446

4547
func NewClusterClusterServiceCreateController(
4648
resourcesDBClient corecosmosstorage.ResourcesDBClient,
4749
clustersServiceClient ocm.ClusterServiceClientSpec,
50+
managementClusterLister fleetlisters.ManagementClusterLister,
4851
backendInformers coreinformers.BackendInformers,
4952
) controllerutils.Controller {
5053
_, clusterLister := backendInformers.Clusters()
5154
_, subscriptionLister := backendInformers.Subscriptions()
5255
syncer := &clusterClusterServiceCreateSyncer{
53-
resourcesDBClient: resourcesDBClient,
54-
clusterLister: clusterLister,
55-
subscriptionLister: subscriptionLister,
56-
clustersServiceClient: clustersServiceClient,
56+
resourcesDBClient: resourcesDBClient,
57+
clusterLister: clusterLister,
58+
subscriptionLister: subscriptionLister,
59+
managementClusterLister: managementClusterLister,
60+
clustersServiceClient: clustersServiceClient,
5761
}
5862

5963
return controllerutils.NewClusterWatchingController(
@@ -235,10 +239,18 @@ func (c *clusterClusterServiceCreateSyncer) csClustersMatchingClusterByAzureInfo
235239
func (c *clusterClusterServiceCreateSyncer) createClusterServiceCluster(ctx context.Context, cluster *coreapi.HCPOpenShiftCluster, serviceProviderCluster *coreapi.ServiceProviderCluster, tenantID string) (*arohcpv1alpha1.Cluster, error) {
236240
logger := utils.LoggerFromContext(ctx)
237241

242+
provisionShardID, err := c.provisionShardID(ctx, serviceProviderCluster)
243+
if err != nil {
244+
return nil, utils.TrackError(err)
245+
}
246+
238247
csClusterBuilder, err := ocm.BuildCSCluster(cluster.ID, tenantID, cluster, nil, nil, serviceProviderCluster)
239248
if err != nil {
240249
return nil, utils.TrackError(fmt.Errorf("failed to build CS cluster: %w", err))
241250
}
251+
// Pin the CS provision shard for the scheduler-selected management cluster via
252+
// the SDK builder method.
253+
csClusterBuilder.ProvisionShardID(provisionShardID)
242254
clusterServiceUID := cluster.ServiceProviderProperties.PendingClusterServiceID.ClusterID()
243255

244256
logger.Info("Creating cluster in Cluster Service", "version", serviceProviderCluster.Spec.ControlPlaneVersion.DesiredVersion.String())
@@ -253,3 +265,36 @@ func (c *clusterClusterServiceCreateSyncer) createClusterServiceCluster(ctx cont
253265

254266
return result, nil
255267
}
268+
269+
// provisionShardID resolves the Cluster Service provision shard ID for the
270+
// management cluster the scheduler pinned on
271+
// ServiceProviderCluster.Spec.ManagementClusterResourceID. The caller sets it on
272+
// the CS cluster via ClusterBuilder.ProvisionShardID so the new CS cluster is
273+
// created on the correct provision shard.
274+
func (c *clusterClusterServiceCreateSyncer) provisionShardID(ctx context.Context, serviceProviderCluster *coreapi.ServiceProviderCluster) (string, error) {
275+
managementClusterResourceID := serviceProviderCluster.Spec.ManagementClusterResourceID
276+
if managementClusterResourceID == nil {
277+
return "", fmt.Errorf("ServiceProviderCluster has no Spec.ManagementClusterResourceID; placement is not resolved")
278+
}
279+
// A management cluster is a singleton within a stamp, so its resource ID is
280+
// .../stamps/<stampIdentifier>/managementClusters/default and the lister is
281+
// keyed by the stamp identifier (the parent segment's name).
282+
if managementClusterResourceID.Parent == nil {
283+
return "", fmt.Errorf("management cluster resource ID %q has no parent stamp", managementClusterResourceID.String())
284+
}
285+
stampIdentifier := managementClusterResourceID.Parent.Name
286+
287+
managementCluster, err := c.managementClusterLister.Get(ctx, stampIdentifier)
288+
if cosmosstorageutils.IsNotFoundError(err) {
289+
return "", fmt.Errorf("management cluster %q not found", managementClusterResourceID.String())
290+
}
291+
if err != nil {
292+
return "", utils.TrackError(fmt.Errorf("failed to get management cluster %q: %w", managementClusterResourceID.String(), err))
293+
}
294+
295+
if managementCluster.Status.ClusterServiceProvisionShardID == nil {
296+
return "", fmt.Errorf("management cluster %q has no ClusterServiceProvisionShardID", managementClusterResourceID.String())
297+
}
298+
299+
return managementCluster.Status.ClusterServiceProvisionShardID.ID(), nil
300+
}

backend/pkg/controllers/cluster/creation/cluster_cluster_service_create_controller_test.go

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ import (
3434

3535
"github.com/Azure/ARO-HCP/backend/pkg/utils/controllerutils"
3636
"github.com/Azure/ARO-HCP/internal/api/coreapi"
37+
"github.com/Azure/ARO-HCP/internal/api/fleetapi"
3738
"github.com/Azure/ARO-HCP/internal/api/metadataapi"
3839
"github.com/Azure/ARO-HCP/internal/apitesting/coreapitesting"
3940
"github.com/Azure/ARO-HCP/internal/database/cosmosstoragetesting/corecosmosstoragetesting"
4041
"github.com/Azure/ARO-HCP/internal/database/listertesting/corelistertesting"
42+
"github.com/Azure/ARO-HCP/internal/database/listertesting/fleetlistertesting"
4143
"github.com/Azure/ARO-HCP/internal/ocm"
4244
"github.com/Azure/ARO-HCP/internal/utils"
4345
)
@@ -52,8 +54,30 @@ const (
5254
testClusterUID = "00000000-0000-0000-0000-000000000000"
5355
// testManagedResourceGroup must match what coreapitesting.MinimumValidClusterTestCase() sets.
5456
testManagedResourceGroup = "testManagedResourceGroup"
57+
// testStampIdentifier / testProvisionShardID drive the management-cluster
58+
// placement + provision-shard-pinning fixtures.
59+
testStampIdentifier = "1"
60+
testProvisionShardID = "shard-abc123"
5561
)
5662

63+
// testManagementClusterResourceID returns the resource ID of the placed management cluster.
64+
func testManagementClusterResourceID() *azcorearm.ResourceID {
65+
return metadataapi.Must(fleetapi.ToManagementClusterResourceID(testStampIdentifier))
66+
}
67+
68+
// newTestManagementCluster returns a management cluster carrying the CS provision
69+
// shard used by the provision-shard-pinning tests.
70+
func newTestManagementCluster() *fleetapi.ManagementCluster {
71+
resourceID := testManagementClusterResourceID()
72+
return &fleetapi.ManagementCluster{
73+
CosmosMetadata: coreapi.CosmosMetadata{ResourceID: resourceID, PartitionKey: testStampIdentifier},
74+
ResourceID: resourceID,
75+
Status: fleetapi.ManagementClusterStatus{
76+
ClusterServiceProvisionShardID: ptr.To(metadataapi.Must(metadataapi.NewInternalID("/api/aro_hcp/v1alpha1/provision_shards/" + testProvisionShardID))),
77+
},
78+
}
79+
}
80+
5781
// testClusterResourceID builds the ARM resource ID for the test cluster.
5882
func testClusterResourceID() *azcorearm.ResourceID {
5983
return metadataapi.Must(azcorearm.ParseResourceID(
@@ -128,6 +152,7 @@ func TestClusterClusterServiceCreate_SyncOnce(t *testing.T) {
128152
listCluster *coreapi.HCPOpenShiftCluster // cluster seeded into the lister (nil = not found)
129153
dbCluster *coreapi.HCPOpenShiftCluster // cluster stored in the DB
130154
existingServiceProviderCluster *coreapi.ServiceProviderCluster // nil = not pre-seeded; controller get-or-creates
155+
managementClusters []*fleetapi.ManagementCluster // seeded into the fleet lister
131156
setupMockCS func(ctrl *gomock.Controller) ocm.ClusterServiceClientSpec
132157
expectError bool
133158
verifyDB func(t *testing.T, ctx context.Context, db *corecosmosstoragetesting.MockResourcesDBClient)
@@ -142,7 +167,9 @@ func TestClusterClusterServiceCreate_SyncOnce(t *testing.T) {
142167
}),
143168
existingServiceProviderCluster: newTestSPC(func(spc *coreapi.ServiceProviderCluster) {
144169
spc.Spec.ControlPlaneVersion.DesiredVersion = desiredVersion
170+
spc.Spec.ManagementClusterResourceID = testManagementClusterResourceID()
145171
}),
172+
managementClusters: []*fleetapi.ManagementCluster{newTestManagementCluster()},
146173
setupMockCS: func(ctrl *gomock.Controller) ocm.ClusterServiceClientSpec {
147174
mockCS := ocm.NewMockClusterServiceClientSpec(ctrl)
148175
mockCS.EXPECT().
@@ -154,6 +181,7 @@ func TestClusterClusterServiceCreate_SyncOnce(t *testing.T) {
154181
built, buildErr := builder.Build()
155182
require.NoError(t, buildErr)
156183
assert.Equal(t, pendingClusterServiceID.ID(), built.ID(), "PostCluster should use the final segment of PendingClusterServiceID")
184+
assert.Equal(t, testProvisionShardID, built.ProvisionShardID(), "PostCluster should pin the provision shard from the placed management cluster")
157185
csCluster, err := arohcpv1alpha1.NewCluster().
158186
ID(pendingClusterServiceID.ID()).
159187
HREF(testClusterServiceIDStr).
@@ -232,7 +260,9 @@ func TestClusterClusterServiceCreate_SyncOnce(t *testing.T) {
232260
}),
233261
existingServiceProviderCluster: newTestSPC(func(spc *coreapi.ServiceProviderCluster) {
234262
spc.Spec.ControlPlaneVersion.DesiredVersion = desiredVersion
263+
spc.Spec.ManagementClusterResourceID = testManagementClusterResourceID()
235264
}),
265+
managementClusters: []*fleetapi.ManagementCluster{newTestManagementCluster()},
236266
setupMockCS: func(ctrl *gomock.Controller) ocm.ClusterServiceClientSpec {
237267
mockCS := ocm.NewMockClusterServiceClientSpec(ctrl)
238268
// Build the CS cluster with Azure fields matching the test cluster so it
@@ -284,10 +314,11 @@ func TestClusterClusterServiceCreate_SyncOnce(t *testing.T) {
284314
listerClusters = []*coreapi.HCPOpenShiftCluster{tt.listCluster}
285315
}
286316
syncer := &clusterClusterServiceCreateSyncer{
287-
resourcesDBClient: mockDB,
288-
clusterLister: &corelistertesting.SliceClusterLister{Clusters: listerClusters},
289-
subscriptionLister: &corelistertesting.SliceSubscriptionLister{Subscriptions: []*coreapi.Subscription{subscription}},
290-
clustersServiceClient: mockCS,
317+
resourcesDBClient: mockDB,
318+
clusterLister: &corelistertesting.SliceClusterLister{Clusters: listerClusters},
319+
subscriptionLister: &corelistertesting.SliceSubscriptionLister{Subscriptions: []*coreapi.Subscription{subscription}},
320+
managementClusterLister: &fleetlistertesting.SliceManagementClusterLister{ManagementClusters: tt.managementClusters},
321+
clustersServiceClient: mockCS,
291322
}
292323

293324
key := controllerutils.HCPClusterKey{

backend/pkg/controllers/cluster/creation/cluster_pending_cluster_service_id_assign_controller.go

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ import (
3131
)
3232

3333
type clusterPendingClusterServiceIDAssignSyncer struct {
34-
clusterLister corelisters.ClusterLister
35-
resourcesDBClient corecosmosstorage.ResourcesDBClient
34+
clusterLister corelisters.ClusterLister
35+
serviceProviderClusterLister corelisters.ServiceProviderClusterLister
36+
resourcesDBClient corecosmosstorage.ResourcesDBClient
3637
}
3738

3839
var _ controllerutils.ClusterSyncer = (*clusterPendingClusterServiceIDAssignSyncer)(nil)
@@ -41,9 +42,11 @@ const ClusterPendingClusterServiceIDAssignControllerName = "ClusterPendingCluste
4142

4243
func NewClusterPendingClusterServiceIDAssignController(resourcesDBClient corecosmosstorage.ResourcesDBClient, backendInformers coreinformers.BackendInformers) controllerutils.Controller {
4344
_, clusterLister := backendInformers.Clusters()
45+
_, serviceProviderClusterLister := backendInformers.ServiceProviderClusters()
4446
syncer := &clusterPendingClusterServiceIDAssignSyncer{
45-
clusterLister: clusterLister,
46-
resourcesDBClient: resourcesDBClient,
47+
clusterLister: clusterLister,
48+
serviceProviderClusterLister: serviceProviderClusterLister,
49+
resourcesDBClient: resourcesDBClient,
4750
}
4851

4952
return controllerutils.NewClusterWatchingController(
@@ -56,11 +59,19 @@ func NewClusterPendingClusterServiceIDAssignController(resourcesDBClient corecos
5659
)
5760
}
5861

59-
func (c *clusterPendingClusterServiceIDAssignSyncer) needsWork(cluster *coreapi.HCPOpenShiftCluster) bool {
62+
// needsWork reports whether a PendingClusterServiceID should be assigned. In
63+
// addition to the cluster not yet having a (pending or resolved) Cluster Service
64+
// ID and not being deleted, placement must already be resolved: the
65+
// ServiceProviderCluster must have Spec.ManagementClusterResourceID set by the
66+
// PlacementController. This gates Cluster Service creation on a management
67+
// cluster having been chosen first.
68+
func (c *clusterPendingClusterServiceIDAssignSyncer) needsWork(cluster *coreapi.HCPOpenShiftCluster, serviceProviderCluster *coreapi.ServiceProviderCluster) bool {
6069
return cluster.ServiceProviderProperties.DeletionTimestamp == nil &&
6170
cluster.ServiceProviderProperties.PendingClusterServiceID == nil &&
6271
(cluster.ServiceProviderProperties.ClusterServiceID == nil ||
63-
len(cluster.ServiceProviderProperties.ClusterServiceID.String()) == 0)
72+
len(cluster.ServiceProviderProperties.ClusterServiceID.String()) == 0) &&
73+
serviceProviderCluster != nil &&
74+
serviceProviderCluster.Spec.ManagementClusterResourceID != nil
6475
}
6576

6677
func (c *clusterPendingClusterServiceIDAssignSyncer) SyncOnce(ctx context.Context, key controllerutils.HCPClusterKey) error {
@@ -74,7 +85,16 @@ func (c *clusterPendingClusterServiceIDAssignSyncer) SyncOnce(ctx context.Contex
7485
return utils.TrackError(err)
7586
}
7687

77-
if !c.needsWork(cluster) {
88+
serviceProviderCluster, err := c.serviceProviderClusterLister.Get(ctx, key.SubscriptionID, key.ResourceGroupName, key.HCPClusterName)
89+
if cosmosstorageutils.IsNotFoundError(err) {
90+
// Placement has not produced a ServiceProviderCluster yet; wait.
91+
return nil
92+
}
93+
if err != nil {
94+
return utils.TrackError(err)
95+
}
96+
97+
if !c.needsWork(cluster, serviceProviderCluster) {
7898
return nil
7999
}
80100

0 commit comments

Comments
 (0)