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
61 changes: 61 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: CI

on:
push:
pull_request:

permissions:
contents: read

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false

- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: v1.64.5
args: --timeout 5m0s

verify:
name: Verify
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false

- name: Verify
run: hack/verify-all.sh -v

test:
name: Unit Test
runs-on: ubuntu-latest
needs: verify
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false

- name: Run tests
run: make test
2 changes: 1 addition & 1 deletion .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ linters:
enable:
- dupl
- errcheck
- exportloopref
- copyloopvar
- goconst
- gocyclo
- gofmt
Expand Down
13 changes: 9 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ help: ## Display this help.

.PHONY: manifests
manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
$(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases
$(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./apis/..." paths="./pkg/..." paths="./cmd/..." output:crd:artifacts:config=config/crd/bases

.PHONY: generate
generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations.
$(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..."
$(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./apis/..." paths="./pkg/..." paths="./cmd/..."
hack/update-codegen.sh

.PHONY: fmt
Expand Down Expand Up @@ -164,7 +164,7 @@ GOLANGCI_LINT = $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION)
KUSTOMIZE_VERSION ?= v5.3.0
CONTROLLER_TOOLS_VERSION ?= v0.17.3
ENVTEST_VERSION ?= release-0.17
GOLANGCI_LINT_VERSION ?= v1.54.2
GOLANGCI_LINT_VERSION ?= v1.64.5
KB_TOOLS_ARCHIVE_NAME :=kubebuilder-tools-$(ENVTEST_K8S_VERSION)-$(GOHOSTOS)-$(GOHOSTARCH).tar.gz
KB_TOOLS_ARCHIVE_PATH := /tmp/$(KB_TOOLS_ARCHIVE_NAME)
export KUBEBUILDER_ASSETS ?=/tmp/kubebuilder/bin
Expand All @@ -187,7 +187,12 @@ $(ENVTEST): $(LOCALBIN)
.PHONY: golangci-lint
golangci-lint: $(GOLANGCI_LINT) ## Download golangci-lint locally if necessary.
$(GOLANGCI_LINT): $(LOCALBIN)
$(call go-install-tool,$(GOLANGCI_LINT),github.com/golangci/golangci-lint/cmd/golangci-lint,${GOLANGCI_LINT_VERSION})
@[ -f $(GOLANGCI_LINT) ] || { \
set -e; \
echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)" ;\
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(LOCALBIN) $(GOLANGCI_LINT_VERSION) ;\
mv $(LOCALBIN)/golangci-lint $(GOLANGCI_LINT) ;\
}

# download the kubebuilder-tools to get kube-apiserver binaries from it
.PHONY: ensure-kubebuilder-tools
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ and this repository serves as the foundation for developing a standardized,
robust framework for multi-cluster management in a cloud-native environment.

The Cluster Inventory API aims to provide a consistent and automated approach for applications,
frameworks, and toolsets to discover and interact with multiple Kubernetes clusters.
frameworks, and toolsets to discover, interact with, and make placement decisions across multiple Kubernetes clusters.
The concept of Cluster Inventory is akin to service discovery in a microservices architecture.
It allows multi-cluster applications to dynamically discover available clusters and respond to various cluster lifecycle events.
Such events include auto-scaling, upgrades, failures, and connectivity issues.
Expand Down Expand Up @@ -35,6 +35,26 @@ can integrate without needing to navigate the specific implementation details of
- **Vendor Neutrality**: The API provides a vendor-neutral integration point,
allowing operations tools and external consumers to define and manage clusters across different cloud environments uniformly.

## PlacementDecision API

The [PlacementDecision API](https://github.com/kubernetes/enhancements/blob/master/keps/sig-multicluster/5313-placement-decision/README.md)
is a vendor-neutral API that standardizes the output of multicluster placement calculations.
A `PlacementDecision` object is data only: a namespaced list of chosen clusters whose referenced names
must map one-to-one to `ClusterProfile`s. Any scheduler can emit the object and any consumer can watch it.

### Motivation and Goals

Today every multicluster scheduler publishes its own API to convey where a workload should run,
forcing downstream tools such as GitOps engines, workload orchestrators, progressive rollout controllers,
or AI/ML pipelines to understand a scheduler-specific API.

The `PlacementDecision API` solves the integration explosion problem:

- **Reduces integration burden**: Consumers write ONE integration that works with ANY scheduler implementing this API.
- **Enables scheduler portability**: Switch schedulers without rewriting consumer code.
- **Enables consumer portability**: New consumers work with all schedulers by implementing one standard API.
- **Simplifies RBAC**: One resource schema to secure instead of different permissions for each scheduler's API.

## Community, discussion, contribution, and support

Learn how to engage with the Kubernetes community on the [community page](http://kubernetes.io/community/).
Expand Down
37 changes: 23 additions & 14 deletions apis/v1alpha1/groupversion_info.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
Copyright 2024 The Kubernetes Authors.
Copyright 2025 The Kubernetes Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand All @@ -20,8 +20,6 @@ limitations under the License.
package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/scheme"
)
Expand All @@ -31,9 +29,16 @@ const (
Group = "multicluster.x-k8s.io"
// Version is the API version.
Version = "v1alpha1"
// Kind is the resource kind.

// ClusterProfile resource constants.
// Kind is the resource kind for ClusterProfile.
Kind = "ClusterProfile"
resource = "clusterprofiles"

// PlacementDecision resource constants.
// PlacementDecisionKind is the resource kind for PlacementDecision.
PlacementDecisionKind = "PlacementDecision"
placementDecisionResource = "placementdecisions"
)

var (
Expand Down Expand Up @@ -61,6 +66,20 @@ var (
Resource: resource,
}

// PlacementDecisionSchemeGroupVersionKind is the group, version and kind for the PlacementDecision CR.
PlacementDecisionSchemeGroupVersionKind = schema.GroupVersionKind{
Group: Group,
Version: Version,
Kind: PlacementDecisionKind,
}

// PlacementDecisionSchemeGroupVersionResource is the group, version and resource for the PlacementDecision CR.
PlacementDecisionSchemeGroupVersionResource = schema.GroupVersionResource{
Group: Group,
Version: Version,
Resource: placementDecisionResource,
}

// AddToScheme adds the types in this group-version to the given scheme.
AddToScheme = SchemeBuilder.AddToScheme
)
Expand All @@ -70,13 +89,3 @@ var (
func Resource(resource string) schema.GroupResource {
return schema.GroupResource{Group: GroupVersion.Group, Resource: resource}
}

// Adds the list of known types to api.Scheme.
func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(GroupVersion,
&ClusterProfile{},
&ClusterProfileList{},
)
metav1.AddToGroupVersion(scheme, GroupVersion)
return nil
}
96 changes: 96 additions & 0 deletions apis/v1alpha1/placementdecision_types.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
Copyright 2025 The Kubernetes 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 v1alpha1

import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

// Label keys used for PlacementDecision correlation and discovery.
const (
// DecisionKeyLabel links all slices to the same decision when a decision spans multiple slices.
// When multiple slices exist for one logical decision, the producer MUST set the same
// decision-key on all slices.
DecisionKeyLabel = "multicluster.x-k8s.io/decision-key"

// DecisionIndexLabel indicates the index position of this slice when order matters.
// If a scheduler needs to preserve the order of selected clusters and the result spans
// multiple slices, it should label each PlacementDecision with this label where the
// value starts at 0 and increments by 1.
DecisionIndexLabel = "multicluster.x-k8s.io/decision-index"

// PlacementKeyLabel links a decision to an originating workload when applicable.
// Producers may set this label on PlacementDecision slices when the decision is workload scoped.
// Decisions not tied to a workload need not set this label.
PlacementKeyLabel = "multicluster.x-k8s.io/placement-key"
)

// ClusterDecision references a target ClusterProfile to apply workloads to.
type ClusterDecision struct {
// ClusterProfileRef is a reference to the target ClusterProfile.
// The reference must point to a valid ClusterProfile in the fleet.
// +required
ClusterProfileRef corev1.ObjectReference `json:"clusterProfileRef"`
Copy link
Contributor

@qiujian16 qiujian16 Dec 12, 2025

Choose a reason for hiding this comment

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

nit, defining reference in this file rather than using objectRef would bring better doc on API


// Reason is an optional explanation of why this cluster was chosen.
// This can be useful for debugging and auditing placement decisions.
// +optional
Reason string `json:"reason,omitempty"`
}

//+genclient
//+kubebuilder:object:root=true
//+kubebuilder:resource:scope=Namespaced

// PlacementDecision publishes the set of clusters chosen by a scheduler at a point in time.
// It is a data-only resource that acts as the interface between schedulers and consumers.
// Schedulers write decisions using this format; consumers read from it.
//
// Following the EndpointSlice convention, a single scheduling decision can fan out to N
// PlacementDecision slices, each limited to 100 clusters. To correlate slices, producers
// MUST set the same multicluster.x-k8s.io/decision-key label on all slices when more than
// one slice exists.
type PlacementDecision struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`

// Decisions is the list of clusters chosen for this placement decision.
// Up to 100 ClusterDecisions per object (slice) to stay well below the etcd limit.
// +kubebuilder:validation:MinItems=0
// +kubebuilder:validation:MaxItems=100
// +required
Decisions []ClusterDecision `json:"decisions"`

// SchedulerName is the name of the scheduler that created this decision.
// This is optional and can be used for debugging and auditing purposes.
// +optional
SchedulerName string `json:"schedulerName,omitempty"`
}

//+kubebuilder:object:root=true

// PlacementDecisionList contains a list of PlacementDecision.
type PlacementDecisionList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []PlacementDecision `json:"items"`
}

func init() {
SchemeBuilder.Register(&PlacementDecision{}, &PlacementDecisionList{})
}
Loading