From f37a3c90163020242a322b517d6e183f662a856a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Erbrech?= Date: Wed, 5 Aug 2026 07:26:19 +1000 Subject: [PATCH 01/23] fix: apply overrides to resources inside envelope objects (#774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: apply overrides to resources inside envelope objects Override rules targeting resources wrapped in ResourceEnvelope or ClusterResourceEnvelope were silently ignored. Two coupled defects caused this: - Override snapshot selection built candidates only from the resource snapshot's selected resources. For an envelope placement that is the wrapper alone, so overrides targeting inner resources never reached the binding. - The work generator applied overrides before envelope detection, so rules were evaluated against the wrapper instead of the resources it carries. Overrides now resolve and apply against the resources inside an envelope. Overrides no longer apply to the envelope wrapper itself, including /data/... A Delete override on an inner resource drops only that manifest. The Work is still created when this empties the envelope. Override failures inside an envelope are now reported on the Overridden condition rather than being misattributed to WorkSynchronized. Collecting override candidates is a best-effort selection concern, so an inner manifest that is invalid or cannot be parsed is skipped rather than failing the caller. Candidate collection runs in the rollout controller, upstream of the work generator; returning an error there would abort the rollout before the work generator could report the failure. The work generator remains the authoritative validator of envelope contents and still surfaces these as sync failures. Fixes #607 Signed-off-by: Stéphane Erbrech --- .../envelopes/override-inside-envelope.yaml | 132 ++++ pkg/controllers/workgenerator/controller.go | 131 ++-- .../controller_integration_test.go | 512 ++++++++++++++ pkg/controllers/workgenerator/envelope.go | 71 +- .../workgenerator/envelope_test.go | 627 +++++++++++++++++- pkg/controllers/workgenerator/suite_test.go | 11 + pkg/utils/overrider/overrider.go | 206 +++++- pkg/utils/overrider/overrider_test.go | 492 +++++++++++++- 8 files changed, 2092 insertions(+), 90 deletions(-) create mode 100644 examples/envelopes/override-inside-envelope.yaml diff --git a/examples/envelopes/override-inside-envelope.yaml b/examples/envelopes/override-inside-envelope.yaml new file mode 100644 index 000000000..779e4e354 --- /dev/null +++ b/examples/envelopes/override-inside-envelope.yaml @@ -0,0 +1,132 @@ +# This example shows overrides targeting resources inside envelope objects. +# Fleet places the unpacked inner manifests on member clusters, not the envelope wrappers. +--- +apiVersion: v1 +kind: Namespace +metadata: + name: app +--- +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ResourceEnvelope +metadata: + name: ingress + namespace: app +data: + "deploy.yaml": + apiVersion: apps/v1 + kind: Deployment + metadata: + name: ingress + namespace: app + spec: + replicas: 1 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: web + image: nginx +--- +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ResourcePlacement +metadata: + name: ingress + namespace: app +spec: + resourceSelectors: + - group: placement.kubernetes-fleet.io + kind: ResourceEnvelope + name: ingress + version: v1beta1 + policy: + placementType: PickAll + strategy: + type: RollingUpdate +--- +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ResourceOverride +metadata: + name: ingress-replicas + namespace: app +spec: + placement: + name: ingress + scope: Namespaced + # This selector matches the inner Deployment in the ResourceEnvelope above. + # It does not target the ResourceEnvelope wrapper or a /data/... path. + resourceSelectors: + - group: apps + kind: Deployment + name: ingress + version: v1 + policy: + overrideRules: + - clusterSelector: + clusterSelectorTerms: + - labelSelector: + matchLabels: + env: canary + jsonPatchOverrides: + - op: replace + path: /spec/replicas + value: 3 +--- +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ClusterResourceEnvelope +metadata: + name: reader-role +data: + "clusterrole.yaml": + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: pod-reader + rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list"] +--- +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ClusterResourcePlacement +metadata: + name: reader-role +spec: + resourceSelectors: + - group: placement.kubernetes-fleet.io + kind: ClusterResourceEnvelope + name: reader-role + version: v1beta1 + policy: + placementType: PickAll + strategy: + type: RollingUpdate +--- +apiVersion: placement.kubernetes-fleet.io/v1beta1 +kind: ClusterResourceOverride +metadata: + name: pod-reader-extra-verb +spec: + placement: + name: reader-role + # This selector matches the inner ClusterRole, not the ClusterResourceEnvelope wrapper. + clusterResourceSelectors: + - group: rbac.authorization.k8s.io + kind: ClusterRole + name: pod-reader + version: v1 + policy: + overrideRules: + - clusterSelector: + clusterSelectorTerms: + - labelSelector: + matchLabels: + env: canary + jsonPatchOverrides: + - op: add + path: /rules/0/verbs/- + value: watch diff --git a/pkg/controllers/workgenerator/controller.go b/pkg/controllers/workgenerator/controller.go index 545f793fa..06b6c4dcc 100644 --- a/pkg/controllers/workgenerator/controller.go +++ b/pkg/controllers/workgenerator/controller.go @@ -150,13 +150,16 @@ func (r *Reconciler) Reconcile(ctx context.Context, req controllerruntime.Reques } } - workUpdated := false - overrideSucceeded := false + // result must be allocated before listing the works so that if listing fails (and syncAllWork + // is never called), the existing status outcome is preserved: the list failure is reported on + // the Overridden condition and no Work is reported as changed. + result := &syncResult{overrideFailed: true} // list all the corresponding works works, syncErr := r.listAllWorksAssociated(ctx, resourceBinding) if syncErr == nil { - // generate and apply the workUpdated works if we have all the works - overrideSucceeded, workUpdated, syncErr = r.syncAllWork(ctx, resourceBinding, works, &cluster) + // generate and apply the works if we have all the works. + // syncAllWork always returns a non-nil result, even on error. + result, syncErr = r.syncAllWork(ctx, resourceBinding, works, &cluster) } // Reset the conditions and failed/drifted/diffed placements. for i := condition.OverriddenCondition; i < condition.TotalCondition; i++ { @@ -165,7 +168,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req controllerruntime.Reques resourceBinding.GetBindingStatus().FailedPlacements = nil resourceBinding.GetBindingStatus().DriftedPlacements = nil resourceBinding.GetBindingStatus().DiffedPlacements = nil - if overrideSucceeded { + if !result.overrideFailed { overrideReason := condition.OverriddenSucceededReason overrideMessage := "Successfully applied the override rules on the resources" if len(resourceBinding.GetBindingSpec().ClusterResourceOverrideSnapshots) == 0 && @@ -191,7 +194,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req controllerruntime.Reques if err := errors.Unwrap(syncErr); err != nil && len(err.Error()) > 2 { errorMessage = errorMessage[len(err.Error())+2:] } - if !overrideSucceeded { + if result.overrideFailed { resourceBinding.SetConditions(metav1.Condition{ Status: metav1.ConditionFalse, Type: string(fleetv1beta1.ResourceBindingOverridden), @@ -217,7 +220,7 @@ func (r *Reconciler) Reconcile(ctx context.Context, req controllerruntime.Reques Message: "All of the works are synchronized to the latest", }) switch { - case !workUpdated: + case !result.workUpdated: // The Work object itself is unchanged; refresh the cluster resource binding status // based on the status information reported on the Work object(s). setBindingStatus(works, resourceBinding) @@ -411,11 +414,20 @@ func (r *Reconciler) listAllWorksAssociated(ctx context.Context, resourceBinding return currentWork, nil } -// syncAllWork generates all the work for the resourceSnapshot and apply them to the corresponding target cluster. -// it returns -// 1: if we apply the overrides successfully -// 2: if we actually made any changes on the hub cluster -func (r *Reconciler) syncAllWork(ctx context.Context, resourceBinding fleetv1beta1.BindingObj, existingWorks map[string]*fleetv1beta1.Work, cluster *clusterv1beta1.MemberCluster) (bool, bool, error) { +// syncResult captures the outcome of a syncAllWork call. +type syncResult struct { + // overrideFailed reports whether a sync error should fail the binding's Overridden condition. + // It is false when syncAllWork succeeds. + overrideFailed bool + // workUpdated reports whether any Work object was actually changed on the hub cluster. + workUpdated bool +} + +// syncAllWork generates all the work for the resourceSnapshot and applies them to the corresponding target cluster. +// +// The returned syncResult is always non-nil, including on error paths, so the caller can safely +// inspect its fields regardless of the returned error. See syncResult for the meaning of each field. +func (r *Reconciler) syncAllWork(ctx context.Context, resourceBinding fleetv1beta1.BindingObj, existingWorks map[string]*fleetv1beta1.Work, cluster *clusterv1beta1.MemberCluster) (*syncResult, error) { updateAny := atomic.NewBool(false) resourceBindingRef := klog.KObj(resourceBinding) @@ -441,17 +453,17 @@ func (r *Reconciler) syncAllWork(ctx context.Context, resourceBinding fleetv1bet }) } if updateErr := errs.Wait(); updateErr != nil { - return false, false, updateErr + return &syncResult{overrideFailed: true}, updateErr } // the hash256 function can handle empty list https://go.dev/play/p/_4HW17fooXM resourceOverrideSnapshotHash, err := resource.HashOf(resourceBinding.GetBindingSpec().ResourceOverrideSnapshots) if err != nil { - return false, false, controller.NewUnexpectedBehaviorError(err) + return &syncResult{overrideFailed: true}, controller.NewUnexpectedBehaviorError(err) } clusterResourceOverrideSnapshotHash, err := resource.HashOf(resourceBinding.GetBindingSpec().ClusterResourceOverrideSnapshots) if err != nil { - return false, false, controller.NewUnexpectedBehaviorError(err) + return &syncResult{overrideFailed: true}, controller.NewUnexpectedBehaviorError(err) } // TODO: check all work synced first before fetching the snapshots after we put ParentResourceOverrideSnapshotHashAnnotation and ParentClusterResourceOverrideSnapshotHashAnnotation in all the work objects @@ -462,22 +474,30 @@ func (r *Reconciler) syncAllWork(ctx context.Context, resourceBinding fleetv1bet // the resourceIndex is deleted but the works might still be up to date with the binding. if areAllWorkSynced(existingWorks, resourceBinding, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash) { klog.V(2).InfoS("All the works are synced with the resourceBinding even if the resource snapshot index is removed", "resourceBinding", resourceBindingRef) - return true, updateAny.Load(), nil + return &syncResult{workUpdated: updateAny.Load()}, nil } - return false, false, controller.NewUserError(err) + return &syncResult{overrideFailed: true}, controller.NewUserError(err) } // TODO(RZ): handle errResourceNotFullyCreated error so we don't need to wait for all the snapshots to be created - return false, false, err + return &syncResult{overrideFailed: true}, err } croMap, err := r.fetchClusterResourceOverrideSnapshots(ctx, resourceBinding) if err != nil { - return false, false, err + return &syncResult{overrideFailed: true}, err } roMap, err := r.fetchResourceOverrideSnapshots(ctx, resourceBinding) if err != nil { - return false, false, err + return &syncResult{overrideFailed: true}, err + } + + // Assemble the override inputs once so the snapshots are fetched a single time per sync + // and threaded down as one value rather than as three separate parameters. + overrideCtx := &overrideContext{ + cluster: cluster, + croMap: croMap, + roMap: roMap, } // issue all the create/update requests for the corresponding works for each snapshot in parallel @@ -489,39 +509,31 @@ func (r *Reconciler) syncAllWork(ctx context.Context, resourceBinding fleetv1bet workNamePrefix, err := getWorkNamePrefixFromSnapshotName(snapshot) if err != nil { klog.ErrorS(err, "Encountered a mal-formatted resource snapshot", "resourceSnapshot", klog.KObj(snapshot)) - return false, false, err + return &syncResult{overrideFailed: true}, err } var simpleManifests []fleetv1beta1.Manifest var newWork []*fleetv1beta1.Work selectedRes := snapshot.GetResourceSnapshotSpec().SelectedResources for j := range selectedRes { selectedResource := selectedRes[j].DeepCopy() - // TODO: apply the override rules on the envelope resources by applying them on the work instead of the selected resource - resourceDeleted, overrideErr := r.applyOverrides(selectedResource, cluster, croMap, roMap) - if overrideErr != nil { - return false, false, overrideErr - } - if resourceDeleted { - klog.V(2).InfoS("The resource is deleted by the override rules", "snapshot", klog.KObj(snapshot), "selectedResource", selectedRes[j]) - continue - } // Process the selected resource. // // Specifically, - // a) if the selected resource is an envelope (configMap-based or envelope-based; the former will soon - // become obsolete), we will create a work object dedicated for the envelope; - // b) otherwise (the selected resource is a regular resource), the resource will be appended to the list of - // simple manifests. + // a) if the selected resource is an envelope CR, we will create a work object dedicated for the + // envelope and apply overrides to its extracted manifests; + // b) otherwise (the selected resource is a regular resource), overrides are applied directly and the + // resource will be appended to the list of simple manifests unless deleted by override. // // Note (chenyu1): this method is added to reduce the cyclomatic complexity of the syncAllWork method. - newWork, simpleManifests, err = r.processOneSelectedResource( - ctx, selectedResource, resourceBinding, snapshot, + var overrideFailed bool + newWork, simpleManifests, overrideFailed, err = r.processOneSelectedResource( + ctx, selectedResource, overrideCtx, resourceBinding, snapshot, workNamePrefix, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash, activeWork, newWork, simpleManifests) if err != nil { klog.ErrorS(err, "Failed to process the selected resource", "snapshot", klog.KObj(snapshot), "selectedResourceIdx", j) - return true, false, err + return &syncResult{overrideFailed: overrideFailed}, err } } if len(simpleManifests) == 0 { @@ -571,31 +583,45 @@ func (r *Reconciler) syncAllWork(ctx context.Context, resourceBinding fleetv1bet // wait for all the create/update/delete requests to finish if updateErr := errs.Wait(); updateErr != nil { - return true, false, updateErr + return &syncResult{}, updateErr } klog.V(2).InfoS("Successfully synced all the work associated with the resourceBinding", "updateAny", updateAny.Load(), "resourceBinding", resourceBindingRef) - return true, updateAny.Load(), nil + return &syncResult{workUpdated: updateAny.Load()}, nil +} + +// overrideContext carries the inputs needed to apply overrides to a resource: the target +// cluster and the override snapshots that apply to the binding, keyed by the resource each +// selector targets. It is assembled once per sync so the snapshots are fetched only once and +// threaded down the call stack as a single value. +type overrideContext struct { + cluster *clusterv1beta1.MemberCluster + croMap map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ClusterResourceOverrideSnapshot + roMap map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot } // processOneSelectedResource processes a single selected resource from the resource snapshot. // // If the selected resource is an envelope (either configMap-based or envelope-based), create a new dedicated // work object for the envelope. Otherwise, append the selected resource to the list of simple manifests. +// +// The returned bool reports an override failure (used to fail the binding's Overridden condition); +// it is only meaningful when the returned error is non-nil and is always false on success. func (r *Reconciler) processOneSelectedResource( ctx context.Context, selectedResource *fleetv1beta1.ResourceContent, + overrideCtx *overrideContext, resourceBinding fleetv1beta1.BindingObj, snapshot fleetv1beta1.ResourceSnapshotObj, workNamePrefix, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash string, activeWork map[string]*fleetv1beta1.Work, newWork []*fleetv1beta1.Work, simpleManifests []fleetv1beta1.Manifest, -) ([]*fleetv1beta1.Work, []fleetv1beta1.Manifest, error) { +) ([]*fleetv1beta1.Work, []fleetv1beta1.Manifest, bool, error) { // Unmarshal the YAML content into an unstructured object. var uResource unstructured.Unstructured if unMarshallErr := uResource.UnmarshalJSON(selectedResource.Raw); unMarshallErr != nil { klog.ErrorS(unMarshallErr, "work has invalid content", "snapshot", klog.KObj(snapshot), "selectedResource", selectedResource.Raw) - return nil, nil, controller.NewUnexpectedBehaviorError(unMarshallErr) + return nil, nil, false, controller.NewUnexpectedBehaviorError(unMarshallErr) } uGVK := uResource.GetObjectKind().GroupVersionKind().GroupKind() @@ -608,15 +634,15 @@ func (r *Reconciler) processOneSelectedResource( "clusterResourceBinding", klog.KObj(resourceBinding), "clusterResourceSnapshot", klog.KObj(snapshot), "selectedResource", klog.KObj(&uResource)) - return nil, nil, controller.NewUnexpectedBehaviorError(err) + return nil, nil, false, controller.NewUnexpectedBehaviorError(err) } - work, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, &clusterResourceEnvelope, workNamePrefix, resourceBinding, snapshot, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash) + work, overrideFailed, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, &clusterResourceEnvelope, workNamePrefix, resourceBinding, snapshot, overrideCtx, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash) if err != nil { klog.ErrorS(err, "Failed to create or get the work object for the ClusterResourceEnvelope", "clusterResourceEnvelope", klog.KObj(&clusterResourceEnvelope), "clusterResourceBinding", klog.KObj(resourceBinding), "clusterResourceSnapshot", klog.KObj(snapshot)) - return nil, nil, err + return nil, nil, overrideFailed, err } activeWork[work.Name] = work newWork = append(newWork, work) @@ -628,25 +654,34 @@ func (r *Reconciler) processOneSelectedResource( "clusterResourceBinding", klog.KObj(resourceBinding), "clusterResourceSnapshot", klog.KObj(snapshot), "selectedResource", klog.KObj(&uResource)) - return nil, nil, controller.NewUnexpectedBehaviorError(err) + return nil, nil, false, controller.NewUnexpectedBehaviorError(err) } - work, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, &resourceEnvelope, workNamePrefix, resourceBinding, snapshot, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash) + work, overrideFailed, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, &resourceEnvelope, workNamePrefix, resourceBinding, snapshot, overrideCtx, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash) if err != nil { klog.ErrorS(err, "Failed to create or get the work object for the ResourceEnvelope", "resourceEnvelope", klog.KObj(&resourceEnvelope), "clusterResourceBinding", klog.KObj(resourceBinding), "clusterResourceSnapshot", klog.KObj(snapshot)) - return nil, nil, err + return nil, nil, overrideFailed, err } activeWork[work.Name] = work newWork = append(newWork, work) default: + resourceDeleted, overrideErr := r.applyOverrides(selectedResource, overrideCtx.cluster, overrideCtx.croMap, overrideCtx.roMap) + if overrideErr != nil { + return nil, nil, true, overrideErr + } + if resourceDeleted { + klog.V(2).InfoS("The resource is deleted by the override rules", "snapshot", klog.KObj(snapshot), "selectedResource", selectedResource) + return newWork, simpleManifests, false, nil + } + // The resource is not an envelope; add it to the list of simple manifests. simpleManifests = append(simpleManifests, fleetv1beta1.Manifest(*selectedResource)) } - return newWork, simpleManifests, nil + return newWork, simpleManifests, false, nil } // syncApplyStrategy syncs the apply strategy specified on a binding object diff --git a/pkg/controllers/workgenerator/controller_integration_test.go b/pkg/controllers/workgenerator/controller_integration_test.go index 19bbafb9f..1bcfd12c1 100644 --- a/pkg/controllers/workgenerator/controller_integration_test.go +++ b/pkg/controllers/workgenerator/controller_integration_test.go @@ -29,10 +29,13 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -42,6 +45,8 @@ import ( "github.com/kubefleet-dev/kubefleet/pkg/controllers/workapplier" "github.com/kubefleet-dev/kubefleet/pkg/utils" "github.com/kubefleet-dev/kubefleet/pkg/utils/condition" + "github.com/kubefleet-dev/kubefleet/pkg/utils/overrider" + testutilsinformer "github.com/kubefleet-dev/kubefleet/test/utils/informer" ) var ( @@ -111,6 +116,8 @@ const ( timeout = time.Second * 20 duration = time.Second * 10 interval = time.Millisecond * 250 + + insideDeploymentName = "inside-deployment" ) var _ = Describe("Test Work Generator Controller for clusterResourcePlacement", func() { @@ -983,6 +990,277 @@ var _ = Describe("Test Work Generator Controller for clusterResourcePlacement", }) }) + Context("Test Bound ClusterResourceBinding with envelope override snapshots", func() { + var objectsToDelete []client.Object + + createObject := func(obj client.Object) { + Expect(k8sClient.Create(ctx, obj)).Should(Succeed()) + objectsToDelete = append(objectsToDelete, obj) + } + + AfterEach(func() { + for i := len(objectsToDelete) - 1; i >= 0; i-- { + Expect(k8sClient.Delete(ctx, objectsToDelete[i])).Should(SatisfyAny(Succeed(), utils.NotFoundMatcher{})) + } + objectsToDelete = nil + }) + + It("Should patch a ResourceEnvelope inner Deployment with a matching ResourceOverride", func() { + envelopeNamespace := "envelope-ns-" + utils.RandStr() + envelopeName := "resource-envelope-" + utils.RandStr() + deploymentName := insideDeploymentName + createObject(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: envelopeNamespace}}) + + masterSnapshot := generateClusterResourceSnapshot(1, 1, 0, [][]byte{ + resourceEnvelopeRaw(envelopeName, envelopeNamespace, map[string][]byte{ + "deployment.json": deploymentManifestRaw(envelopeNamespace, deploymentName, map[string]string{"app": "demo"}), + }), + }) + createObject(masterSnapshot) + createObject(envelopeResourceOverrideSnapshot("ro-"+utils.RandStr(), envelopeNamespace, []placementv1beta1.ResourceSelector{ + { + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: deploymentName, + }, + }, []placementv1beta1.JSONPatchOverride{ + { + Operator: placementv1beta1.JSONPatchOverrideOpAdd, + Path: "/metadata/labels/patched", + Value: apiextensionsv1.JSON{Raw: []byte(`"true"`)}, + }, + })) + croNames, roNames := matchingOverrideSnapshotRefs(masterSnapshot) + spec := placementv1beta1.ResourceBindingSpec{ + State: placementv1beta1.BindingStateBound, + ResourceSnapshotName: masterSnapshot.Name, + TargetCluster: memberClusterName, + ResourceOverrideSnapshots: roNames, + } + createClusterResourceBinding(&binding, spec) + + var workList placementv1beta1.WorkList + fetchEnvelopedWork(&workList, binding, string(placementv1beta1.ResourceEnvelopeType), envelopeName, envelopeNamespace) + got := findManifestObject(workList.Items[0].Spec.Workload.Manifests, "apps", "v1", "Deployment", envelopeNamespace, deploymentName) + want := manifestObject(deploymentManifestRaw(envelopeNamespace, deploymentName, map[string]string{"app": "demo", "patched": "true"})) + diff := cmp.Diff(want.Object, got.Object) + Expect(diff).Should(BeEmpty(), fmt.Sprintf("inner deployment mismatch (-want +got):\n%s", diff)) + Expect(croNames).Should(BeEmpty(), "ResourceOverride test should not select ClusterResourceOverrideSnapshots") + verifyBindingStatusSyncedNotApplied(binding, true, true) + }) + + It("Should patch a ClusterResourceEnvelope inner ClusterRole with a matching ClusterResourceOverride", func() { + envelopeName := "cluster-resource-envelope-" + utils.RandStr() + clusterRoleName := "inside-clusterrole" + masterSnapshot := generateClusterResourceSnapshot(1, 1, 0, [][]byte{ + clusterResourceEnvelopeRaw(envelopeName, map[string][]byte{ + "clusterrole.json": clusterRoleManifestRaw(clusterRoleName, nil), + }), + }) + createObject(masterSnapshot) + createObject(envelopeClusterResourceOverrideSnapshot("cro-"+utils.RandStr(), []placementv1beta1.ResourceSelectorTerm{ + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: clusterRoleName, + }, + }, []placementv1beta1.JSONPatchOverride{ + { + Operator: placementv1beta1.JSONPatchOverrideOpAdd, + Path: "/metadata/labels", + Value: apiextensionsv1.JSON{Raw: []byte(`{"patched":"true"}`)}, + }, + })) + croNames, roNames := matchingOverrideSnapshotRefs(masterSnapshot) + spec := placementv1beta1.ResourceBindingSpec{ + State: placementv1beta1.BindingStateBound, + ResourceSnapshotName: masterSnapshot.Name, + TargetCluster: memberClusterName, + ClusterResourceOverrideSnapshots: croNames, + } + createClusterResourceBinding(&binding, spec) + + var workList placementv1beta1.WorkList + fetchEnvelopedWork(&workList, binding, string(placementv1beta1.ClusterResourceEnvelopeType), envelopeName, "") + got := findManifestObject(workList.Items[0].Spec.Workload.Manifests, "rbac.authorization.k8s.io", "v1", "ClusterRole", "", clusterRoleName) + want := manifestObject(clusterRoleManifestRaw(clusterRoleName, map[string]string{"patched": "true"})) + diff := cmp.Diff(want.Object, got.Object) + Expect(diff).Should(BeEmpty(), fmt.Sprintf("inner clusterRole mismatch (-want +got):\n%s", diff)) + Expect(roNames).Should(BeEmpty(), "ClusterResourceOverride test should not select ResourceOverrideSnapshots") + verifyBindingStatusSyncedNotApplied(binding, true, true) + }) + + It("Should not apply a ResourceOverride targeting the ResourceEnvelope wrapper", func() { + envelopeNamespace := "envelope-ns-" + utils.RandStr() + envelopeName := "resource-envelope-" + utils.RandStr() + deploymentName := insideDeploymentName + createObject(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: envelopeNamespace}}) + + masterSnapshot := generateClusterResourceSnapshot(1, 1, 0, [][]byte{ + resourceEnvelopeRaw(envelopeName, envelopeNamespace, map[string][]byte{ + "deployment.json": deploymentManifestRaw(envelopeNamespace, deploymentName, map[string]string{"app": "demo"}), + }), + }) + createObject(masterSnapshot) + createObject(envelopeResourceOverrideSnapshot("ro-"+utils.RandStr(), envelopeNamespace, []placementv1beta1.ResourceSelector{ + { + Group: placementv1beta1.GroupVersion.Group, + Version: placementv1beta1.GroupVersion.Version, + Kind: string(placementv1beta1.ResourceEnvelopeType), + Name: envelopeName, + }, + }, []placementv1beta1.JSONPatchOverride{ + { + Operator: placementv1beta1.JSONPatchOverrideOpReplace, + Path: "/metadata/name", + Value: apiextensionsv1.JSON{Raw: []byte(`"unexpected-envelope-name"`)}, + }, + })) + croNames, roNames := matchingOverrideSnapshotRefs(masterSnapshot) + spec := placementv1beta1.ResourceBindingSpec{ + State: placementv1beta1.BindingStateBound, + ResourceSnapshotName: masterSnapshot.Name, + TargetCluster: memberClusterName, + ClusterResourceOverrideSnapshots: croNames, + ResourceOverrideSnapshots: roNames, + } + createClusterResourceBinding(&binding, spec) + + var workList placementv1beta1.WorkList + fetchEnvelopedWork(&workList, binding, string(placementv1beta1.ResourceEnvelopeType), envelopeName, envelopeNamespace) + got := findManifestObject(workList.Items[0].Spec.Workload.Manifests, "apps", "v1", "Deployment", envelopeNamespace, deploymentName) + want := manifestObject(deploymentManifestRaw(envelopeNamespace, deploymentName, map[string]string{"app": "demo"})) + diff := cmp.Diff(want.Object, got.Object) + Expect(diff).Should(BeEmpty(), fmt.Sprintf("inner deployment mismatch (-want +got):\n%s", diff)) + Expect(croNames).Should(BeEmpty(), "wrapper-targeting ResourceOverride should not select ClusterResourceOverrideSnapshots") + Expect(roNames).Should(BeEmpty(), "wrapper-targeting ResourceOverride should not be selected") + verifyBindingStatusSyncedNotApplied(binding, false, true) + }) + + It("Should patch only the selected inner resource from a ResourceEnvelope", func() { + envelopeNamespace := "envelope-ns-" + utils.RandStr() + envelopeName := "resource-envelope-" + utils.RandStr() + deploymentName := insideDeploymentName + configMapName := "inside-configmap" + createObject(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: envelopeNamespace}}) + + masterSnapshot := generateClusterResourceSnapshot(1, 1, 0, [][]byte{ + resourceEnvelopeRaw(envelopeName, envelopeNamespace, map[string][]byte{ + "configmap.json": configMapManifestRaw(envelopeNamespace, configMapName), + "deployment.json": deploymentManifestRaw(envelopeNamespace, deploymentName, map[string]string{"app": "demo"}), + }), + }) + createObject(masterSnapshot) + createObject(envelopeResourceOverrideSnapshot("ro-"+utils.RandStr(), envelopeNamespace, []placementv1beta1.ResourceSelector{ + { + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: deploymentName, + }, + }, []placementv1beta1.JSONPatchOverride{ + { + Operator: placementv1beta1.JSONPatchOverrideOpAdd, + Path: "/metadata/labels/patched", + Value: apiextensionsv1.JSON{Raw: []byte(`"true"`)}, + }, + })) + croNames, roNames := matchingOverrideSnapshotRefs(masterSnapshot) + spec := placementv1beta1.ResourceBindingSpec{ + State: placementv1beta1.BindingStateBound, + ResourceSnapshotName: masterSnapshot.Name, + TargetCluster: memberClusterName, + ResourceOverrideSnapshots: roNames, + } + createClusterResourceBinding(&binding, spec) + + var workList placementv1beta1.WorkList + fetchEnvelopedWork(&workList, binding, string(placementv1beta1.ResourceEnvelopeType), envelopeName, envelopeNamespace) + gotDeployment := findManifestObject(workList.Items[0].Spec.Workload.Manifests, "apps", "v1", "Deployment", envelopeNamespace, deploymentName) + wantDeployment := manifestObject(deploymentManifestRaw(envelopeNamespace, deploymentName, map[string]string{"app": "demo", "patched": "true"})) + diff := cmp.Diff(wantDeployment.Object, gotDeployment.Object) + Expect(diff).Should(BeEmpty(), fmt.Sprintf("inner deployment mismatch (-want +got):\n%s", diff)) + gotConfigMap := findManifestObject(workList.Items[0].Spec.Workload.Manifests, "", "v1", "ConfigMap", envelopeNamespace, configMapName) + wantConfigMap := manifestObject(configMapManifestRaw(envelopeNamespace, configMapName)) + diff = cmp.Diff(wantConfigMap.Object, gotConfigMap.Object) + Expect(diff).Should(BeEmpty(), fmt.Sprintf("inner configMap mismatch (-want +got):\n%s", diff)) + Expect(croNames).Should(BeEmpty(), "ResourceOverride test should not select ClusterResourceOverrideSnapshots") + verifyBindingStatusSyncedNotApplied(binding, true, true) + }) + + It("Should report ResourceOverride failures inside a ResourceEnvelope on the Overridden condition", func() { + envelopeNamespace := "envelope-ns-" + utils.RandStr() + envelopeName := "resource-envelope-" + utils.RandStr() + deploymentName := insideDeploymentName + createObject(&corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: envelopeNamespace}}) + + masterSnapshot := generateClusterResourceSnapshot(1, 1, 0, [][]byte{ + resourceEnvelopeRaw(envelopeName, envelopeNamespace, map[string][]byte{ + "deployment.json": deploymentManifestRaw(envelopeNamespace, deploymentName, map[string]string{"app": "demo"}), + }), + }) + createObject(masterSnapshot) + invalidRO := envelopeResourceOverrideSnapshot("ro-"+utils.RandStr(), envelopeNamespace, []placementv1beta1.ResourceSelector{ + { + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: deploymentName, + }, + }, []placementv1beta1.JSONPatchOverride{ + { + Operator: placementv1beta1.JSONPatchOverrideOpAdd, + Path: "/spec/template/spec/containers/0/resources/limits/cpu", + Value: apiextensionsv1.JSON{Raw: []byte(`"100m"`)}, + }, + }) + createObject(invalidRO) + croNames, roNames := matchingOverrideSnapshotRefs(masterSnapshot) + spec := placementv1beta1.ResourceBindingSpec{ + State: placementv1beta1.BindingStateBound, + ResourceSnapshotName: masterSnapshot.Name, + TargetCluster: memberClusterName, + ResourceOverrideSnapshots: roNames, + } + createClusterResourceBinding(&binding, spec) + + Consistently(func() int { + workList := placementv1beta1.WorkList{} + Expect(k8sClient.List(ctx, &workList, client.MatchingLabels{ + placementv1beta1.ParentBindingLabel: binding.Name, + placementv1beta1.PlacementTrackingLabel: testCRPName, + })).Should(Succeed()) + return len(workList.Items) + }, duration, interval).Should(Equal(0), "controller should not create work when an inner envelope override fails") + + Eventually(func() string { + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: binding.Name}, binding)).Should(Succeed()) + wantStatus := placementv1beta1.ResourceBindingStatus{ + Conditions: []metav1.Condition{ + { + Type: string(placementv1beta1.ResourceBindingRolloutStarted), + Status: metav1.ConditionTrue, + Reason: condition.RolloutStartedReason, + ObservedGeneration: binding.GetGeneration(), + }, + { + Type: string(placementv1beta1.ResourceBindingOverridden), + Status: metav1.ConditionFalse, + Reason: condition.OverriddenFailedReason, + ObservedGeneration: binding.GetGeneration(), + }, + }, + } + return cmp.Diff(wantStatus, binding.Status, cmpConditionOption) + }, timeout, interval).Should(BeEmpty(), fmt.Sprintf("binding(%s) mismatch (-want +got)", binding.Name)) + message := binding.GetCondition(string(placementv1beta1.ResourceBindingOverridden)).Message + Expect(message).Should(ContainSubstring("add operation does not apply")) + Expect(croNames).Should(BeEmpty(), "ResourceOverride failure test should not select ClusterResourceOverrideSnapshots") + }) + }) + Context("Test Bound ClusterResourceBinding with multiple resource snapshots", func() { var masterSnapshot, secondSnapshot *placementv1beta1.ClusterResourceSnapshot var binding *placementv1beta1.ClusterResourceBinding @@ -4261,6 +4539,240 @@ func fetchEnvelopedWork(workList *placementv1beta1.WorkList, binding *placementv }, timeout, interval).Should(Succeed(), "Failed to get the expected enveloped work in hub cluster") } +func matchingOverrideSnapshotRefs(masterSnapshot *placementv1beta1.ClusterResourceSnapshot) ([]string, []placementv1beta1.NamespacedName) { + fakeInformer := envelopeOverrideInformerManager() + directClient, err := client.New(cfg, client.Options{Scheme: mgr.GetScheme()}) + Expect(err).Should(Succeed()) + croSnapshots, roSnapshots, err := overrider.FetchAllMatchingOverridesForResourceSnapshot(ctx, directClient, fakeInformer, testCRPName, masterSnapshot) + Expect(err).Should(Succeed()) + + croNames := make([]string, 0, len(croSnapshots)) + for _, snapshot := range croSnapshots { + croNames = append(croNames, snapshot.Name) + } + roNames := make([]placementv1beta1.NamespacedName, 0, len(roSnapshots)) + for _, snapshot := range roSnapshots { + roNames = append(roNames, placementv1beta1.NamespacedName{ + Name: snapshot.Name, + Namespace: snapshot.Namespace, + }) + } + return croNames, roNames +} + +func envelopeOverrideInformerManager() *testutilsinformer.FakeManager { + return &testutilsinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + utils.NamespaceGVK: true, + placementv1beta1.GroupVersion.WithKind(string(placementv1beta1.ClusterResourceEnvelopeType)): true, + { + Group: "admissionregistration.k8s.io", + Version: "v1", + Kind: "ValidatingWebhookConfiguration", + }: true, + { + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinition", + }: true, + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + }: true, + }, + IsClusterScopedResource: true, + } +} + +func envelopeResourceOverrideSnapshot(name, namespace string, selectors []placementv1beta1.ResourceSelector, patches []placementv1beta1.JSONPatchOverride) *placementv1beta1.ResourceOverrideSnapshot { + return &placementv1beta1.ResourceOverrideSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + placementv1beta1.IsLatestSnapshotLabel: "true", + }, + }, + Spec: placementv1beta1.ResourceOverrideSnapshotSpec{ + OverrideSpec: placementv1beta1.ResourceOverrideSpec{ + Placement: &placementv1beta1.PlacementRef{ + Name: testCRPName, + Scope: placementv1beta1.ClusterScoped, + }, + ResourceSelectors: selectors, + Policy: &placementv1beta1.OverridePolicy{ + OverrideRules: []placementv1beta1.OverrideRule{ + { + ClusterSelector: &placementv1beta1.ClusterSelector{ + ClusterSelectorTerms: []placementv1beta1.ClusterSelectorTerm{}, + }, + OverrideType: placementv1beta1.JSONPatchOverrideType, + JSONPatchOverrides: patches, + }, + }, + }, + }, + OverrideHash: []byte(name), + }, + } +} + +func envelopeClusterResourceOverrideSnapshot(name string, selectors []placementv1beta1.ResourceSelectorTerm, patches []placementv1beta1.JSONPatchOverride) *placementv1beta1.ClusterResourceOverrideSnapshot { + return &placementv1beta1.ClusterResourceOverrideSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + placementv1beta1.IsLatestSnapshotLabel: "true", + }, + }, + Spec: placementv1beta1.ClusterResourceOverrideSnapshotSpec{ + OverrideSpec: placementv1beta1.ClusterResourceOverrideSpec{ + Placement: &placementv1beta1.PlacementRef{ + Name: testCRPName, + Scope: placementv1beta1.ClusterScoped, + }, + ClusterResourceSelectors: selectors, + Policy: &placementv1beta1.OverridePolicy{ + OverrideRules: []placementv1beta1.OverrideRule{ + { + ClusterSelector: &placementv1beta1.ClusterSelector{ + ClusterSelectorTerms: []placementv1beta1.ClusterSelectorTerm{}, + }, + OverrideType: placementv1beta1.JSONPatchOverrideType, + JSONPatchOverrides: patches, + }, + }, + }, + }, + OverrideHash: []byte(name), + }, + } +} + +func resourceEnvelopeRaw(name, namespace string, data map[string][]byte) []byte { + rawData := make(map[string]runtime.RawExtension, len(data)) + for key, raw := range data { + rawData[key] = runtime.RawExtension{Raw: raw} + } + return mustMarshalJSON(&placementv1beta1.ResourceEnvelope{ + TypeMeta: metav1.TypeMeta{ + APIVersion: placementv1beta1.GroupVersion.String(), + Kind: string(placementv1beta1.ResourceEnvelopeType), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: rawData, + }) +} + +func clusterResourceEnvelopeRaw(name string, data map[string][]byte) []byte { + rawData := make(map[string]runtime.RawExtension, len(data)) + for key, raw := range data { + rawData[key] = runtime.RawExtension{Raw: raw} + } + return mustMarshalJSON(&placementv1beta1.ClusterResourceEnvelope{ + TypeMeta: metav1.TypeMeta{ + APIVersion: placementv1beta1.GroupVersion.String(), + Kind: string(placementv1beta1.ClusterResourceEnvelopeType), + }, + ObjectMeta: metav1.ObjectMeta{Name: name}, + Data: rawData, + }) +} + +func deploymentManifestRaw(namespace, name string, labels map[string]string) []byte { + return mustMarshalJSON(map[string]interface{}{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]interface{}{ + "name": name, + "namespace": namespace, + "labels": labels, + }, + "spec": map[string]interface{}{ + "replicas": int64(1), + "selector": map[string]interface{}{ + "matchLabels": map[string]string{"app": "demo"}, + }, + "template": map[string]interface{}{ + "metadata": map[string]interface{}{ + "labels": map[string]string{"app": "demo"}, + }, + "spec": map[string]interface{}{ + "containers": []map[string]string{ + { + "name": "nginx", + "image": "nginx:1.14.2", + }, + }, + }, + }, + }, + }) +} + +func configMapManifestRaw(namespace, name string) []byte { + return mustMarshalJSON(map[string]interface{}{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]interface{}{ + "name": name, + "namespace": namespace, + }, + "data": map[string]string{ + "field": "untouched", + }, + }) +} + +func clusterRoleManifestRaw(name string, labels map[string]string) []byte { + metadata := map[string]interface{}{ + "name": name, + } + if labels != nil { + metadata["labels"] = labels + } + return mustMarshalJSON(map[string]interface{}{ + "apiVersion": "rbac.authorization.k8s.io/v1", + "kind": "ClusterRole", + "metadata": metadata, + "rules": []map[string]interface{}{ + { + "apiGroups": []string{""}, + "resources": []string{"pods"}, + "verbs": []string{"get", "list", "watch"}, + }, + }, + }) +} + +func findManifestObject(manifests []placementv1beta1.Manifest, group, version, kind, namespace, name string) unstructured.Unstructured { + for _, manifest := range manifests { + got := manifestObject(manifest.Raw) + gvk := got.GroupVersionKind() + if gvk.Group == group && gvk.Version == version && gvk.Kind == kind && got.GetNamespace() == namespace && got.GetName() == name { + return got + } + } + Fail(fmt.Sprintf("manifest %s/%s, Kind=%s, namespace=%s, name=%s not found", group, version, kind, namespace, name)) + return unstructured.Unstructured{} +} + +func manifestObject(raw []byte) unstructured.Unstructured { + var obj unstructured.Unstructured + Expect(obj.UnmarshalJSON(raw)).Should(Succeed()) + return obj +} + +func mustMarshalJSON(obj interface{}) []byte { + raw, err := json.Marshal(obj) + Expect(err).Should(Succeed()) + return raw +} + func generateClusterResourceBinding(spec placementv1beta1.ResourceBindingSpec) *placementv1beta1.ClusterResourceBinding { return &placementv1beta1.ClusterResourceBinding{ ObjectMeta: metav1.ObjectMeta{ diff --git a/pkg/controllers/workgenerator/envelope.go b/pkg/controllers/workgenerator/envelope.go index 95453274b..99be13c0c 100644 --- a/pkg/controllers/workgenerator/envelope.go +++ b/pkg/controllers/workgenerator/envelope.go @@ -36,21 +36,25 @@ import ( ) // createOrUpdateEnvelopeCRWorkObj creates or updates a work object for a given envelope CR. +// +// The returned bool reports an override failure (used to fail the binding's Overridden condition); +// it is only meaningful when the returned error is non-nil and is always false on success. func (r *Reconciler) createOrUpdateEnvelopeCRWorkObj( ctx context.Context, envelopeReader fleetv1beta1.EnvelopeReader, workNamePrefix string, binding fleetv1beta1.BindingObj, resourceSnapshot fleetv1beta1.ResourceSnapshotObj, + overrideCtx *overrideContext, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash string, -) (*fleetv1beta1.Work, error) { +) (*fleetv1beta1.Work, bool, error) { manifests, err := extractManifestsFromEnvelopeCR(envelopeReader) if err != nil { klog.ErrorS(err, "Failed to extract manifests from the envelope spec", "resourceBinding", klog.KObj(binding), "resourceSnapshot", klog.KObj(resourceSnapshot), "envelope", envelopeReader.GetEnvelopeObjRef()) - return nil, err + return nil, false, err } klog.V(2).InfoS("Successfully extracted wrapped manifests from the envelope", "numOfResources", len(manifests), @@ -58,6 +62,16 @@ func (r *Reconciler) createOrUpdateEnvelopeCRWorkObj( "resourceSnapshot", klog.KObj(resourceSnapshot), "envelope", envelopeReader.GetEnvelopeObjRef()) + var overrideFailed bool + manifests, overrideFailed, err = r.applyOverridesToEnvelopeManifests(manifests, overrideCtx, envelopeReader) + if err != nil { + klog.ErrorS(err, "Failed to apply override rules to the manifests extracted from the envelope", + "resourceBinding", klog.KObj(binding), + "resourceSnapshot", klog.KObj(resourceSnapshot), + "envelope", envelopeReader.GetEnvelopeObjRef()) + return nil, overrideFailed, err + } + // Check to see if a corresponding work object has been created for the envelope. labelMatcher := client.MatchingLabels{ fleetv1beta1.ParentBindingLabel: binding.GetName(), @@ -83,7 +97,7 @@ func (r *Reconciler) createOrUpdateEnvelopeCRWorkObj( "resourceSnapshot", klog.KObj(resourceSnapshot), "envelope", envelopeReader.GetEnvelopeObjRef()) wrappedErr := fmt.Errorf("failed to list work objects when finding the work object for an envelope %v: %w", envelopeReader.GetEnvelopeObjRef(), err) - return nil, controller.NewAPIServerError(true, wrappedErr) + return nil, false, controller.NewAPIServerError(true, wrappedErr) } var work *fleetv1beta1.Work @@ -110,7 +124,7 @@ func (r *Reconciler) createOrUpdateEnvelopeCRWorkObj( r.recorder.Eventf(binding, corev1.EventTypeWarning, "DuplicateEnvelopeWorks", "Multiple Work objects (%v) found for envelope %v in namespace %s; delete all but the oldest to recover", workNames, envelopeReader.GetEnvelopeObjRef(), fmt.Sprintf(utils.NamespaceNameFormat, binding.GetBindingSpec().TargetCluster)) - return nil, controller.NewUnexpectedBehaviorError(wrappedErr) + return nil, false, controller.NewUnexpectedBehaviorError(wrappedErr) case len(workList.Items) == 1: klog.V(2).InfoS("Found existing work object for the envelope; updating it", "work", klog.KObj(&workList.Items[0]), @@ -128,7 +142,54 @@ func (r *Reconciler) createOrUpdateEnvelopeCRWorkObj( work = buildNewWorkForEnvelopeCR(workNamePrefix, binding, resourceSnapshot, envelopeReader, manifests, resourceOverrideSnapshotHash, clusterResourceOverrideSnapshotHash) } - return work, nil + return work, false, nil +} + +// applyOverridesToEnvelopeManifests applies the override rules to each manifest extracted from an +// envelope, dropping any manifest that a Delete override removes, and returns the surviving, +// overridden manifests. +// +// The returned bool reports whether applying the override rules failed, as opposed to a manifest +// parse failure; it is only meaningful when the returned error is non-nil, and lets the caller fail +// the binding's Overridden condition. On success it is always false, so false alone does not mean +// "overrides succeeded". +func (r *Reconciler) applyOverridesToEnvelopeManifests( + manifests []fleetv1beta1.Manifest, + overrideCtx *overrideContext, + envelopeReader fleetv1beta1.EnvelopeReader, +) ([]fleetv1beta1.Manifest, bool, error) { + overriddenManifests := make([]fleetv1beta1.Manifest, 0, len(manifests)) + for i := range manifests { + manifest := manifests[i] + resourceContent := &fleetv1beta1.ResourceContent{ + RawExtension: manifest.RawExtension, + } + if manifest.Raw != nil { + resourceContent.Raw = append([]byte(nil), manifest.Raw...) + } + + var target unstructured.Unstructured + if err := target.UnmarshalJSON(resourceContent.Raw); err != nil { + wrappedErr := fmt.Errorf("failed to parse manifest from envelope %v: %w", envelopeReader.GetEnvelopeObjRef(), err) + return nil, false, controller.NewUnexpectedBehaviorError(wrappedErr) + } + + deleted, err := r.applyOverrides(resourceContent, overrideCtx.cluster, overrideCtx.croMap, overrideCtx.roMap) + if err != nil { + return nil, true, fmt.Errorf("failed to apply overrides to %s from envelope %v: %w", formatOverrideTarget(&target), envelopeReader.GetEnvelopeObjRef(), err) + } + if deleted { + klog.V(2).InfoS("The envelope manifest is deleted by the override rules", + "envelope", envelopeReader.GetEnvelopeObjRef(), + "resource", klog.KObj(&target)) + continue + } + + overriddenManifests = append(overriddenManifests, fleetv1beta1.Manifest{ + RawExtension: resourceContent.RawExtension, + }) + } + return overriddenManifests, false, nil } func extractManifestsFromEnvelopeCR(envelopeReader fleetv1beta1.EnvelopeReader) ([]fleetv1beta1.Manifest, error) { diff --git a/pkg/controllers/workgenerator/envelope_test.go b/pkg/controllers/workgenerator/envelope_test.go index a3283c482..6e51d42ba 100644 --- a/pkg/controllers/workgenerator/envelope_test.go +++ b/pkg/controllers/workgenerator/envelope_test.go @@ -27,12 +27,16 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" corev1 "k8s.io/api/core/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/tools/record" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + clusterv1beta1 "github.com/kubefleet-dev/kubefleet/apis/cluster/v1beta1" fleetv1beta1 "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" "github.com/kubefleet-dev/kubefleet/pkg/utils" "github.com/kubefleet-dev/kubefleet/pkg/utils/controller" @@ -257,6 +261,365 @@ func TestExtractManifestsFromEnvelopeCR(t *testing.T) { } } +func TestApplyOverridesToEnvelopeManifests(t *testing.T) { + cluster := &clusterv1beta1.MemberCluster{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-1"}, + } + fakeInformer := informer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + utils.ConfigMapGVK: true, + utils.DeploymentGVK: true, + }, + IsClusterScopedResource: false, + } + + deploymentRaw := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"web","namespace":"app"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"name":"web","image":"nginx","env":[]}]}}}}` + configMapRaw := `{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"settings","namespace":"app"},"data":{"key":"value"}}` + clusterRoleRaw := `{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"ClusterRole","metadata":{"name":"reader"},"rules":[{"apiGroups":[""],"resources":["pods"],"verbs":["get"]}]}` + otherDeploymentRaw := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"api","namespace":"app"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"api"}},"template":{"metadata":{"labels":{"app":"api"}},"spec":{"containers":[{"name":"api","image":"nginx"}]}}}}` + + tests := []struct { + name string + envelopeReader fleetv1beta1.EnvelopeReader + croMap map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ClusterResourceOverrideSnapshot + roMap map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot + wantByResourceKey map[string]string + wantRawByResourceKey map[string]string + wantErrSubstrings []string + }{ + { + name: "ResourceEnvelope applies ResourceOverride to inner Deployment replicas", + envelopeReader: &fleetv1beta1.ResourceEnvelope{ + ObjectMeta: metav1.ObjectMeta{Name: "app-envelope", Namespace: "app"}, + Data: map[string]runtime.RawExtension{ + "deployment": {Raw: []byte(deploymentRaw)}, + }, + }, + roMap: map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + deploymentResourceIdentifier("app", "web"): {resourceOverrideSnapshot("replicas-ro", "app", placementPatchRule("/spec/replicas", []byte(`5`)))}, + }, + wantByResourceKey: map[string]string{ + "Deployment/app/web": `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"web","namespace":"app"},"spec":{"replicas":5,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"name":"web","image":"nginx","env":[]}]}}}}`, + }, + }, + { + name: "ClusterResourceEnvelope applies ClusterResourceOverride to inner ClusterRole", + envelopeReader: &fleetv1beta1.ClusterResourceEnvelope{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster-envelope"}, + Data: map[string]runtime.RawExtension{ + "clusterrole": {Raw: []byte(clusterRoleRaw)}, + }, + }, + croMap: map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ClusterResourceOverrideSnapshot{ + clusterRoleResourceIdentifier("reader"): {clusterResourceOverrideSnapshot("clusterrole-cro", addPatchRule("/metadata/labels", []byte(`{"patched":"true"}`)))}, + }, + wantByResourceKey: map[string]string{ + "ClusterRole//reader": `{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"ClusterRole","metadata":{"labels":{"patched":"true"},"name":"reader"},"rules":[{"apiGroups":[""],"resources":["pods"],"verbs":["get"]}]}`, + }, + }, + { + name: "override matching only one inner resource leaves siblings byte-identical", + envelopeReader: &fleetv1beta1.ResourceEnvelope{ + ObjectMeta: metav1.ObjectMeta{Name: "mixed-envelope", Namespace: "app"}, + Data: map[string]runtime.RawExtension{ + "configmap": {Raw: []byte(configMapRaw)}, + "deployment": {Raw: []byte(deploymentRaw)}, + }, + }, + roMap: map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + deploymentResourceIdentifier("app", "web"): {resourceOverrideSnapshot("label-ro", "app", addPatchRule("/metadata/labels", []byte(`{"patched":"true"}`)))}, + }, + wantByResourceKey: map[string]string{ + "ConfigMap/app/settings": configMapRaw, + "Deployment/app/web": `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"labels":{"patched":"true"},"name":"web","namespace":"app"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"name":"web","image":"nginx","env":[]}]}}}}`, + }, + wantRawByResourceKey: map[string]string{ + "ConfigMap/app/settings": configMapRaw, + }, + }, + { + name: "Delete override omits only the matching inner manifest", + envelopeReader: &fleetv1beta1.ResourceEnvelope{ + ObjectMeta: metav1.ObjectMeta{Name: "delete-one-envelope", Namespace: "app"}, + Data: map[string]runtime.RawExtension{ + "configmap": {Raw: []byte(configMapRaw)}, + "deployment": {Raw: []byte(deploymentRaw)}, + }, + }, + roMap: map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + deploymentResourceIdentifier("app", "web"): {resourceOverrideSnapshot("delete-ro", "app", deleteOverrideRule())}, + }, + wantByResourceKey: map[string]string{ + "ConfigMap/app/settings": configMapRaw, + }, + }, + { + name: "Delete overrides can produce an empty manifest list", + envelopeReader: &fleetv1beta1.ResourceEnvelope{ + ObjectMeta: metav1.ObjectMeta{Name: "delete-all-envelope", Namespace: "app"}, + Data: map[string]runtime.RawExtension{ + "api": {Raw: []byte(otherDeploymentRaw)}, + "web": {Raw: []byte(deploymentRaw)}, + }, + }, + roMap: map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + deploymentResourceIdentifier("app", "api"): {resourceOverrideSnapshot("delete-api-ro", "app", deleteOverrideRule())}, + deploymentResourceIdentifier("app", "web"): {resourceOverrideSnapshot("delete-web-ro", "app", deleteOverrideRule())}, + }, + wantByResourceKey: map[string]string{}, + }, + { + name: "JSONPatch error identifies inner target and containing envelope", + envelopeReader: &fleetv1beta1.ResourceEnvelope{ + ObjectMeta: metav1.ObjectMeta{Name: "bad-patch-envelope", Namespace: "app"}, + Data: map[string]runtime.RawExtension{ + "deployment": {Raw: []byte(deploymentRaw)}, + }, + }, + roMap: map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + deploymentResourceIdentifier("app", "web"): {resourceOverrideSnapshot("bad-ro", "app", placementPatchRule("/spec/missing/value", []byte(`1`)))}, + }, + wantErrSubstrings: []string{ + `Deployment "web" in namespace "app"`, + "bad-patch-envelope", + `ResourceOverrideSnapshot "bad-ro"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Reconciler{InformerManager: &fakeInformer} + manifests, err := extractManifestsFromEnvelopeCR(tt.envelopeReader) + if err != nil { + t.Fatalf("extractManifestsFromEnvelopeCR() error = %v, want nil", err) + } + + got, overrideFailed, err := r.applyOverridesToEnvelopeManifests(manifests, &overrideContext{cluster: cluster, croMap: tt.croMap, roMap: tt.roMap}, tt.envelopeReader) + if len(tt.wantErrSubstrings) > 0 { + if err == nil { + t.Fatalf("applyOverridesToEnvelopeManifests() error = nil, want non-nil") + } + if !overrideFailed { + t.Errorf("applyOverridesToEnvelopeManifests() overrideFailed = false, want true") + } + for _, want := range tt.wantErrSubstrings { + if !strings.Contains(err.Error(), want) { + t.Errorf("applyOverridesToEnvelopeManifests() error = %q, want to contain %q", err.Error(), want) + } + } + return + } + if err != nil { + t.Fatalf("applyOverridesToEnvelopeManifests() error = %v, want nil", err) + } + if overrideFailed { + t.Errorf("applyOverridesToEnvelopeManifests() overrideFailed = true, want false") + } + + gotByResourceKey := manifestObjectByResourceKey(t, got) + wantByResourceKey := manifestObjectByResourceKeyFromRaw(t, tt.wantByResourceKey) + if diff := cmp.Diff(wantByResourceKey, gotByResourceKey); diff != "" { + t.Errorf("applyOverridesToEnvelopeManifests() mismatch (-want +got):\n%s", diff) + } + gotRawByResourceKey := manifestRawByResourceKey(t, got) + for key, want := range tt.wantRawByResourceKey { + if got := gotRawByResourceKey[key]; got != want { + t.Errorf("applyOverridesToEnvelopeManifests() raw manifest %q = %s, want %s", key, got, want) + } + } + }) + } +} + +func TestCreateOrUpdateEnvelopeCRWorkObj_EmptyManifestListRetained(t *testing.T) { + scheme := serviceScheme(t) + ctx := context.Background() + deploymentRaw := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"web","namespace":"app"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"name":"web","image":"nginx"}]}}}}` + resourceEnvelope := &fleetv1beta1.ResourceEnvelope{ + ObjectMeta: metav1.ObjectMeta{Name: "empty-envelope", Namespace: "app"}, + Data: map[string]runtime.RawExtension{ + "deployment": {Raw: []byte(deploymentRaw)}, + }, + } + resourceSnapshot := &fleetv1beta1.ClusterResourceSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "snapshot", + Labels: map[string]string{ + fleetv1beta1.ResourceIndexLabel: "0", + fleetv1beta1.PlacementTrackingLabel: "crp", + }, + }, + } + resourceBinding := &fleetv1beta1.ClusterResourceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "binding", + Labels: map[string]string{ + fleetv1beta1.PlacementTrackingLabel: "crp", + }, + }, + Spec: fleetv1beta1.ResourceBindingSpec{ + TargetCluster: "cluster-1", + ResourceSnapshotName: "snapshot", + }, + } + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + r := &Reconciler{ + Client: fakeClient, + InformerManager: &informer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{utils.DeploymentGVK: true}, + IsClusterScopedResource: false, + }, + recorder: record.NewFakeRecorder(10), + } + roMap := map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + deploymentResourceIdentifier("app", "web"): {resourceOverrideSnapshot("delete-ro", "app", deleteOverrideRule())}, + } + + got, overrideFailed, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, resourceEnvelope, testWorkNamePrefix, resourceBinding, resourceSnapshot, &overrideContext{cluster: &clusterv1beta1.MemberCluster{ObjectMeta: metav1.ObjectMeta{Name: "cluster-1"}}, roMap: roMap}, "ro-hash", "cro-hash") + if err != nil { + t.Fatalf("createOrUpdateEnvelopeCRWorkObj() error = %v, want nil", err) + } + if overrideFailed { + t.Errorf("createOrUpdateEnvelopeCRWorkObj() overrideFailed = true, want false") + } + if got == nil { + t.Fatalf("createOrUpdateEnvelopeCRWorkObj() = nil, want Work") + } + if len(got.Spec.Workload.Manifests) != 0 { + t.Errorf("createOrUpdateEnvelopeCRWorkObj() manifests len = %d, want 0", len(got.Spec.Workload.Manifests)) + } + if got.Annotations[fleetv1beta1.ParentResourceOverrideSnapshotHashAnnotation] != "ro-hash" { + t.Errorf("resource override hash annotation = %q, want %q", got.Annotations[fleetv1beta1.ParentResourceOverrideSnapshotHashAnnotation], "ro-hash") + } +} + +func manifestObjectByResourceKey(t *testing.T, manifests []fleetv1beta1.Manifest) map[string]map[string]interface{} { + t.Helper() + byKey := make(map[string]map[string]interface{}, len(manifests)) + for i := range manifests { + key, obj := manifestKeyAndObject(t, manifests[i].Raw) + byKey[key] = obj + } + return byKey +} + +func manifestObjectByResourceKeyFromRaw(t *testing.T, manifests map[string]string) map[string]map[string]interface{} { + t.Helper() + byKey := make(map[string]map[string]interface{}, len(manifests)) + for key, raw := range manifests { + gotKey, obj := manifestKeyAndObject(t, []byte(raw)) + if gotKey != key { + t.Fatalf("manifest key from raw = %q, want %q", gotKey, key) + } + byKey[key] = obj + } + return byKey +} + +func manifestRawByResourceKey(t *testing.T, manifests []fleetv1beta1.Manifest) map[string]string { + t.Helper() + byKey := make(map[string]string, len(manifests)) + for i := range manifests { + key, _ := manifestKeyAndObject(t, manifests[i].Raw) + byKey[key] = string(manifests[i].Raw) + } + return byKey +} + +func manifestKeyAndObject(t *testing.T, raw []byte) (string, map[string]interface{}) { + t.Helper() + var u unstructured.Unstructured + if err := u.UnmarshalJSON(raw); err != nil { + t.Fatalf("UnmarshalJSON(%q) error = %v, want nil", string(raw), err) + } + key := fmt.Sprintf("%s/%s/%s", u.GetKind(), u.GetNamespace(), u.GetName()) + return key, u.Object +} + +func deploymentResourceIdentifier(namespace, name string) fleetv1beta1.ResourceIdentifier { + return fleetv1beta1.ResourceIdentifier{ + Group: utils.DeploymentGVK.Group, + Version: utils.DeploymentGVK.Version, + Kind: utils.DeploymentGVK.Kind, + Namespace: namespace, + Name: name, + } +} + +func clusterRoleResourceIdentifier(name string) fleetv1beta1.ResourceIdentifier { + return fleetv1beta1.ResourceIdentifier{ + Group: utils.ClusterRoleGVK.Group, + Version: utils.ClusterRoleGVK.Version, + Kind: utils.ClusterRoleGVK.Kind, + Name: name, + } +} + +func resourceOverrideSnapshot(name, namespace string, rule fleetv1beta1.OverrideRule) *fleetv1beta1.ResourceOverrideSnapshot { + return &fleetv1beta1.ResourceOverrideSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: fleetv1beta1.ResourceOverrideSnapshotSpec{ + OverrideSpec: fleetv1beta1.ResourceOverrideSpec{ + Policy: &fleetv1beta1.OverridePolicy{ + OverrideRules: []fleetv1beta1.OverrideRule{rule}, + }, + }, + }, + } +} + +func clusterResourceOverrideSnapshot(name string, rule fleetv1beta1.OverrideRule) *fleetv1beta1.ClusterResourceOverrideSnapshot { + return &fleetv1beta1.ClusterResourceOverrideSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: fleetv1beta1.ClusterResourceOverrideSnapshotSpec{ + OverrideSpec: fleetv1beta1.ClusterResourceOverrideSpec{ + Policy: &fleetv1beta1.OverridePolicy{ + OverrideRules: []fleetv1beta1.OverrideRule{rule}, + }, + }, + }, + } +} + +func placementPatchRule(path string, value []byte) fleetv1beta1.OverrideRule { + return fleetv1beta1.OverrideRule{ + ClusterSelector: &fleetv1beta1.ClusterSelector{}, + JSONPatchOverrides: []fleetv1beta1.JSONPatchOverride{ + { + Operator: fleetv1beta1.JSONPatchOverrideOpReplace, + Path: path, + Value: apiextensionsv1.JSON{Raw: value}, + }, + }, + } +} + +func addPatchRule(path string, value []byte) fleetv1beta1.OverrideRule { + return fleetv1beta1.OverrideRule{ + ClusterSelector: &fleetv1beta1.ClusterSelector{}, + JSONPatchOverrides: []fleetv1beta1.JSONPatchOverride{ + { + Operator: fleetv1beta1.JSONPatchOverrideOpAdd, + Path: path, + Value: apiextensionsv1.JSON{Raw: value}, + }, + }, + } +} + +func deleteOverrideRule() fleetv1beta1.OverrideRule { + return fleetv1beta1.OverrideRule{ + ClusterSelector: &fleetv1beta1.ClusterSelector{}, + OverrideType: fleetv1beta1.DeleteOverrideType, + } +} + func TestCreateOrUpdateEnvelopeCRWorkObj(t *testing.T) { ignoreWorkMeta := cmpopts.IgnoreFields(metav1.ObjectMeta{}, "Name", "OwnerReferences") scheme := serviceScheme(t) @@ -522,8 +885,8 @@ func TestCreateOrUpdateEnvelopeCRWorkObj(t *testing.T) { } // Call the function under test - got, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, tt.envelopeReader, testWorkNamePrefix, - resourceBinding, resourceSnapshot, tt.resourceOverrideSnapshotHash, tt.clusterResourceOverrideSnapshotHash) + got, overrideFailed, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, tt.envelopeReader, testWorkNamePrefix, + resourceBinding, resourceSnapshot, &overrideContext{cluster: &clusterv1beta1.MemberCluster{}}, tt.resourceOverrideSnapshotHash, tt.clusterResourceOverrideSnapshotHash) if (err != nil) != tt.wantErr { t.Errorf("createOrUpdateEnvelopeCRWorkObj() error = %v, wantErr %v", err, tt.wantErr) @@ -534,6 +897,9 @@ func TestCreateOrUpdateEnvelopeCRWorkObj(t *testing.T) { if diff := cmp.Diff(got, tt.want, ignoreWorkOption, ignoreWorkMeta, ignoreTypeMeta); diff != "" { t.Errorf("createOrUpdateEnvelopeCRWorkObj() mismatch (-got +want):\n%s", diff) } + if overrideFailed { + t.Errorf("createOrUpdateEnvelopeCRWorkObj() overrideFailed = true, want false") + } }) } } @@ -689,9 +1055,10 @@ func TestProcessOneSelectedResource(t *testing.T) { newWork := make([]*fleetv1beta1.Work, 0) simpleManifests := make([]fleetv1beta1.Manifest, 0) - gotNewWork, gotSimpleManifests, err := r.processOneSelectedResource( + gotNewWork, gotSimpleManifests, overrideFailed, err := r.processOneSelectedResource( ctx, tt.selectedResource, + &overrideContext{cluster: &clusterv1beta1.MemberCluster{}}, resourceBinding, snapshot, testWorkNamePrefix, @@ -706,6 +1073,9 @@ func TestProcessOneSelectedResource(t *testing.T) { t.Errorf("processOneSelectedResource() error = %v, wantErr %v", err, tt.wantErr) return } + if overrideFailed { + t.Errorf("processOneSelectedResource() overrideFailed = true, want false") + } if len(gotNewWork) != tt.wantNewWorkLen { t.Errorf("processOneSelectedResource() returned %d new works, want %d", len(gotNewWork), tt.wantNewWorkLen) @@ -723,6 +1093,250 @@ func TestProcessOneSelectedResource(t *testing.T) { } } +func TestProcessOneSelectedResource_OverrideBehavior(t *testing.T) { + scheme := serviceScheme(t) + ctx := context.Background() + resourceBinding := &fleetv1beta1.ClusterResourceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + Labels: map[string]string{ + fleetv1beta1.PlacementTrackingLabel: "test-crp", + }, + }, + Spec: fleetv1beta1.ResourceBindingSpec{ + TargetCluster: "test-cluster", + ResourceSnapshotName: "test-snapshot", + }, + } + snapshot := &fleetv1beta1.ClusterResourceSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-snapshot", + Labels: map[string]string{ + fleetv1beta1.ResourceIndexLabel: "0", + fleetv1beta1.PlacementTrackingLabel: "test-crp", + }, + }, + } + cluster := &clusterv1beta1.MemberCluster{ObjectMeta: metav1.ObjectMeta{Name: "test-cluster"}} + deploymentRaw := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"web","namespace":"app"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"name":"web","image":"nginx","env":[]}]}}}}` + + resourceEnvelope := &fleetv1beta1.ResourceEnvelope{ + TypeMeta: metav1.TypeMeta{ + APIVersion: fleetv1beta1.GroupVersion.String(), + Kind: fleetv1beta1.ResourceEnvelopeKind, + }, + ObjectMeta: metav1.ObjectMeta{Name: "wrapper", Namespace: "app"}, + Data: map[string]runtime.RawExtension{ + "deployment": {Raw: []byte(deploymentRaw)}, + }, + } + resourceEnvelopeContent := createResourceContent(t, resourceEnvelope) + originalEnvelopeRaw := append([]byte(nil), resourceEnvelopeContent.Raw...) + + regularDeployment := &unstructured.Unstructured{} + if err := regularDeployment.UnmarshalJSON([]byte(deploymentRaw)); err != nil { + t.Fatalf("UnmarshalJSON(%q) error = %v, want nil", deploymentRaw, err) + } + regularDeploymentContent := createResourceContent(t, regularDeployment) + + tests := []struct { + name string + selectedResource *fleetv1beta1.ResourceContent + roMap map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot + validate func(t *testing.T, gotNewWork []*fleetv1beta1.Work, gotSimpleManifests []fleetv1beta1.Manifest) + }{ + { + name: "wrapper-targeting override is ignored for ResourceEnvelope", + selectedResource: resourceEnvelopeContent, + roMap: map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + { + Group: fleetv1beta1.GroupVersion.Group, + Version: fleetv1beta1.GroupVersion.Version, + Kind: fleetv1beta1.ResourceEnvelopeKind, + Namespace: "app", + Name: "wrapper", + }: {resourceOverrideSnapshot("wrapper-ro", "app", addPatchRule("/data/injected", []byte(`{"raw":{"apiVersion":"v1","kind":"ConfigMap","metadata":{"name":"bad","namespace":"app"}}}`)))}, + }, + validate: func(t *testing.T, gotNewWork []*fleetv1beta1.Work, gotSimpleManifests []fleetv1beta1.Manifest) { + t.Helper() + if len(gotSimpleManifests) != 0 { + t.Fatalf("processOneSelectedResource() simple manifests len = %d, want 0", len(gotSimpleManifests)) + } + if len(gotNewWork) != 1 { + t.Fatalf("processOneSelectedResource() new works len = %d, want 1", len(gotNewWork)) + } + gotByResourceKey := manifestObjectByResourceKey(t, gotNewWork[0].Spec.Workload.Manifests) + wantByResourceKey := map[string]string{"Deployment/app/web": deploymentRaw} + wantObjectByResourceKey := manifestObjectByResourceKeyFromRaw(t, wantByResourceKey) + if diff := cmp.Diff(wantObjectByResourceKey, gotByResourceKey); diff != "" { + t.Errorf("processOneSelectedResource() envelope manifests mismatch (-want +got):\n%s", diff) + } + if string(resourceEnvelopeContent.Raw) != string(originalEnvelopeRaw) { + t.Errorf("processOneSelectedResource() mutated envelope wrapper raw = %s, want %s", string(resourceEnvelopeContent.Raw), string(originalEnvelopeRaw)) + } + }, + }, + { + name: "non-envelope override is applied exactly once", + selectedResource: regularDeploymentContent, + roMap: map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + deploymentResourceIdentifier("app", "web"): {resourceOverrideSnapshot("env-ro", "app", addPatchRule("/spec/template/spec/containers/0/env/-", []byte(`{"name":"ADDED","value":"true"}`)))}, + }, + validate: func(t *testing.T, gotNewWork []*fleetv1beta1.Work, gotSimpleManifests []fleetv1beta1.Manifest) { + t.Helper() + if len(gotNewWork) != 0 { + t.Fatalf("processOneSelectedResource() new works len = %d, want 0", len(gotNewWork)) + } + if len(gotSimpleManifests) != 1 { + t.Fatalf("processOneSelectedResource() simple manifests len = %d, want 1", len(gotSimpleManifests)) + } + var u unstructured.Unstructured + if err := u.UnmarshalJSON(gotSimpleManifests[0].Raw); err != nil { + t.Fatalf("UnmarshalJSON() error = %v, want nil", err) + } + containers, found, err := unstructured.NestedSlice(u.Object, "spec", "template", "spec", "containers") + if err != nil || !found || len(containers) != 1 { + t.Fatalf("containers lookup error = %v, found = %v, len = %d, want one container", err, found, len(containers)) + } + container, ok := containers[0].(map[string]interface{}) + if !ok { + t.Fatalf("container type = %T, want map[string]interface{}", containers[0]) + } + env, ok := container["env"].([]interface{}) + if !ok { + t.Fatalf("env type = %T, want []interface{}", container["env"]) + } + if len(env) != 1 { + t.Fatalf("env entries len = %d, want 1", len(env)) + } + gotEnv, ok := env[0].(map[string]interface{}) + if !ok { + t.Fatalf("env entry type = %T, want map[string]interface{}", env[0]) + } + wantEnv := map[string]interface{}{"name": "ADDED", "value": "true"} + if diff := cmp.Diff(wantEnv, gotEnv); diff != "" { + t.Errorf("env entry mismatch (-want +got):\n%s", diff) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + r := &Reconciler{ + Client: fakeClient, + InformerManager: &informer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + utils.DeploymentGVK: true, + fleetv1beta1.GroupVersion.WithKind(fleetv1beta1.ResourceEnvelopeKind): true, + }, + IsClusterScopedResource: false, + }, + recorder: record.NewFakeRecorder(10), + } + activeWork := make(map[string]*fleetv1beta1.Work) + gotNewWork, gotSimpleManifests, overrideFailed, err := r.processOneSelectedResource( + ctx, + tt.selectedResource, + &overrideContext{cluster: cluster, roMap: tt.roMap}, + resourceBinding, + snapshot, + testWorkNamePrefix, + "ro-hash", + "cro-hash", + activeWork, + nil, + nil, + ) + if err != nil { + t.Fatalf("processOneSelectedResource() error = %v, want nil", err) + } + if overrideFailed { + t.Errorf("processOneSelectedResource() overrideFailed = true, want false") + } + tt.validate(t, gotNewWork, gotSimpleManifests) + }) + } +} + +func TestProcessOneSelectedResource_EnvelopeInnerOverrideFailureClassifiedAsOverrideFailure(t *testing.T) { + scheme := serviceScheme(t) + ctx := context.Background() + deploymentRaw := `{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"name":"web","namespace":"app"},"spec":{"replicas":1,"selector":{"matchLabels":{"app":"web"}},"template":{"metadata":{"labels":{"app":"web"}},"spec":{"containers":[{"name":"web","image":"nginx"}]}}}}` + resourceEnvelopeContent := createResourceContent(t, &fleetv1beta1.ResourceEnvelope{ + TypeMeta: metav1.TypeMeta{ + APIVersion: fleetv1beta1.GroupVersion.String(), + Kind: fleetv1beta1.ResourceEnvelopeKind, + }, + ObjectMeta: metav1.ObjectMeta{Name: "bad-inner-override", Namespace: "app"}, + Data: map[string]runtime.RawExtension{ + "deployment": {Raw: []byte(deploymentRaw)}, + }, + }) + resourceBinding := &fleetv1beta1.ClusterResourceBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-binding", + Labels: map[string]string{ + fleetv1beta1.PlacementTrackingLabel: "test-crp", + }, + }, + Spec: fleetv1beta1.ResourceBindingSpec{ + TargetCluster: "test-cluster", + ResourceSnapshotName: "test-snapshot", + }, + } + snapshot := &fleetv1beta1.ClusterResourceSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-snapshot", + Labels: map[string]string{ + fleetv1beta1.ResourceIndexLabel: "0", + fleetv1beta1.PlacementTrackingLabel: "test-crp", + }, + }, + } + r := &Reconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + InformerManager: &informer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + utils.DeploymentGVK: true, + fleetv1beta1.GroupVersion.WithKind(fleetv1beta1.ResourceEnvelopeKind): true, + }, + IsClusterScopedResource: false, + }, + recorder: record.NewFakeRecorder(10), + } + roMap := map[fleetv1beta1.ResourceIdentifier][]*fleetv1beta1.ResourceOverrideSnapshot{ + deploymentResourceIdentifier("app", "web"): { + resourceOverrideSnapshot("bad-ro", "app", placementPatchRule("/spec/missing/value", []byte(`1`))), + }, + } + + _, _, overrideFailed, err := r.processOneSelectedResource( + ctx, + resourceEnvelopeContent, + &overrideContext{cluster: &clusterv1beta1.MemberCluster{ObjectMeta: metav1.ObjectMeta{Name: "test-cluster"}}, roMap: roMap}, + resourceBinding, + snapshot, + testWorkNamePrefix, + "ro-hash", + "cro-hash", + make(map[string]*fleetv1beta1.Work), + nil, + nil, + ) + + if err == nil { + t.Fatalf("processOneSelectedResource() error = nil, want non-nil") + } + if !overrideFailed { + t.Errorf("processOneSelectedResource() overrideFailed = false, want true") + } + if !errors.Is(err, controller.ErrUserError) { + t.Errorf("processOneSelectedResource() error = %v, want wrapping controller.ErrUserError", err) + } +} + func createResourceContent(t *testing.T, obj runtime.Object) *fleetv1beta1.ResourceContent { jsonData, err := json.Marshal(obj) if err != nil { @@ -862,8 +1476,8 @@ func TestCreateOrUpdateEnvelopeCRWorkObj_DuplicateWorksSurfaceWithoutMutation(t InformerManager: &informer.FakeManager{}, } - got, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, resourceEnvelope, testWorkNamePrefix, - resourceBinding, resourceSnapshot, "", "") + got, overrideFailed, err := r.createOrUpdateEnvelopeCRWorkObj(ctx, resourceEnvelope, testWorkNamePrefix, + resourceBinding, resourceSnapshot, &overrideContext{cluster: &clusterv1beta1.MemberCluster{}}, "", "") if got != nil { t.Errorf("createOrUpdateEnvelopeCRWorkObj() = %v, want nil on duplicate-detected path", got) @@ -874,6 +1488,9 @@ func TestCreateOrUpdateEnvelopeCRWorkObj_DuplicateWorksSurfaceWithoutMutation(t if !errors.Is(err, controller.ErrUnexpectedBehavior) { t.Errorf("createOrUpdateEnvelopeCRWorkObj() error = %v, want wrapping controller.ErrUnexpectedBehavior", err) } + if overrideFailed { + t.Errorf("createOrUpdateEnvelopeCRWorkObj() overrideFailed = true, want false") + } // Post-condition: all duplicates remain untouched — no auto-deletion. remaining := &fleetv1beta1.WorkList{} diff --git a/pkg/controllers/workgenerator/suite_test.go b/pkg/controllers/workgenerator/suite_test.go index e607846f3..f50131d6e 100644 --- a/pkg/controllers/workgenerator/suite_test.go +++ b/pkg/controllers/workgenerator/suite_test.go @@ -132,6 +132,17 @@ var _ = BeforeSuite(func() { Version: "v1", Kind: "MutatingWebhookConfiguration", }: true, + { + Group: "admissionregistration.k8s.io", + Version: "v1", + Kind: "ValidatingWebhookConfiguration", + }: true, + { + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + }: true, + placementv1beta1.GroupVersion.WithKind(string(placementv1beta1.ClusterResourceEnvelopeType)): true, }, IsClusterScopedResource: true, } diff --git a/pkg/utils/overrider/overrider.go b/pkg/utils/overrider/overrider.go index 97260b0cb..8365c3cc0 100644 --- a/pkg/utils/overrider/overrider.go +++ b/pkg/utils/overrider/overrider.go @@ -19,6 +19,7 @@ package overrider import ( "context" + "encoding/json" "errors" "fmt" "sort" @@ -28,6 +29,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" "k8s.io/klog/v2" "sigs.k8s.io/controller-runtime/pkg/client" @@ -77,37 +79,16 @@ func FetchAllMatchingOverridesForResourceSnapshot( // List all the possible CROs and ROs based on the selected resources. for _, snapshot := range resourceSnapshots { for _, res := range snapshot.GetResourceSnapshotSpec().SelectedResources { - var uResource unstructured.Unstructured - if err := uResource.UnmarshalJSON(res.Raw); err != nil { - klog.ErrorS(err, "Resource has invalid content", "snapshot", klog.KObj(snapshot), "selectedResource", res.Raw) - return nil, nil, controller.NewUnexpectedBehaviorError(err) + croCandidates, roCandidates, err := collectOverrideCandidatesFromSelectedResource(manager, res) + if err != nil { + klog.ErrorS(err, "Failed to collect override candidates from selected resource", "snapshot", klog.KObj(snapshot), "selectedResource", res.Raw) + return nil, nil, err } - // If the resource is namespaced scope resource, the resource could be selected by the namespace or selected - // by the object itself. - if !manager.IsClusterScopedResources(uResource.GroupVersionKind()) { - croKey := placementv1beta1.ResourceIdentifier{ - Group: utils.NamespaceMetaGVK.Group, - Version: utils.NamespaceMetaGVK.Version, - Kind: utils.NamespaceMetaGVK.Kind, - Name: uResource.GetNamespace(), - } - possibleCROs[croKey] = true // selected by the namespace - roKey := placementv1beta1.ResourceIdentifier{ - Group: uResource.GetObjectKind().GroupVersionKind().Group, - Version: uResource.GetObjectKind().GroupVersionKind().Version, - Kind: uResource.GetObjectKind().GroupVersionKind().Kind, - Namespace: uResource.GetNamespace(), - Name: uResource.GetName(), - } - possibleROs[roKey] = true // selected by the object itself - } else { - croKey := placementv1beta1.ResourceIdentifier{ - Group: uResource.GetObjectKind().GroupVersionKind().Group, - Version: uResource.GetObjectKind().GroupVersionKind().Version, - Kind: uResource.GetObjectKind().GroupVersionKind().Kind, - Name: uResource.GetName(), - } - possibleCROs[croKey] = true // selected by the object itself + for cro := range croCandidates { + possibleCROs[cro] = true + } + for ro := range roCandidates { + possibleROs[ro] = true } } } @@ -121,6 +102,7 @@ func FetchAllMatchingOverridesForResourceSnapshot( continue } + matched := false for _, selector := range croList.Items[i].Spec.OverrideSpec.ClusterResourceSelectors { croKey := placementv1beta1.ResourceIdentifier{ Group: selector.Group, @@ -130,9 +112,13 @@ func FetchAllMatchingOverridesForResourceSnapshot( } if possibleCROs[croKey] { filteredCRO = append(filteredCRO, &croList.Items[i]) + matched = true break } } + if !matched { + klog.V(2).InfoS("ClusterResourceOverrideSnapshot has no selector that matches a selected resource or inner envelope resource in this placement", "clusterResourceOverride", klog.KObj(&croList.Items[i]), "placement", placementKey, "selectors", croList.Items[i].Spec.OverrideSpec.ClusterResourceSelectors) + } } for i := range roList.Items { placementInOverride := roList.Items[i].Spec.OverrideSpec.Placement @@ -147,6 +133,7 @@ func FetchAllMatchingOverridesForResourceSnapshot( } } + matched := false for _, selector := range roList.Items[i].Spec.OverrideSpec.ResourceSelectors { roKey := placementv1beta1.ResourceIdentifier{ Group: selector.Group, @@ -157,13 +144,172 @@ func FetchAllMatchingOverridesForResourceSnapshot( } if possibleROs[roKey] { filteredRO = append(filteredRO, &roList.Items[i]) + matched = true break } } + if !matched { + klog.V(2).InfoS("ResourceOverrideSnapshot has no selector that matches a selected resource or inner envelope resource in this placement", "resourceOverride", klog.KObj(&roList.Items[i]), "placement", placementKey, "selectors", roList.Items[i].Spec.OverrideSpec.ResourceSelectors) + } } return filteredCRO, filteredRO, nil } +func collectOverrideCandidatesFromSelectedResource( + manager informer.Manager, + resourceContent placementv1beta1.ResourceContent, +) (map[placementv1beta1.ResourceIdentifier]bool, map[placementv1beta1.ResourceIdentifier]bool, error) { + uResource, err := unmarshalSelectedResource(resourceContent) + if err != nil { + return nil, nil, err + } + + croCandidates := make(map[placementv1beta1.ResourceIdentifier]bool) + roCandidates := make(map[placementv1beta1.ResourceIdentifier]bool) + switch uResource.GroupVersionKind() { + case placementv1beta1.GroupVersion.WithKind(string(placementv1beta1.ClusterResourceEnvelopeType)): + var envelope placementv1beta1.ClusterResourceEnvelope + if err := json.Unmarshal(resourceContent.Raw, &envelope); err != nil { + return nil, nil, controller.NewUnexpectedBehaviorError(err) + } + collectCandidatesFromClusterResourceEnvelope(manager, &envelope, croCandidates) + case placementv1beta1.GroupVersion.WithKind(string(placementv1beta1.ResourceEnvelopeType)): + var envelope placementv1beta1.ResourceEnvelope + if err := json.Unmarshal(resourceContent.Raw, &envelope); err != nil { + return nil, nil, controller.NewUnexpectedBehaviorError(err) + } + croCandidates[namespaceCROCandidate(envelope.GetNamespace())] = true + collectCandidatesFromResourceEnvelope(manager, &envelope, roCandidates) + default: + addCandidatesFromResource(manager, uResource, croCandidates, roCandidates) + } + return croCandidates, roCandidates, nil +} + +func unmarshalSelectedResource(resourceContent placementv1beta1.ResourceContent) (*unstructured.Unstructured, error) { + var uResource unstructured.Unstructured + if err := uResource.UnmarshalJSON(resourceContent.Raw); err != nil { + klog.ErrorS(err, "Resource has invalid content", "selectedResource", resourceContent.Raw) + return nil, controller.NewUnexpectedBehaviorError(err) + } + return &uResource, nil +} + +// collectCandidatesFromClusterResourceEnvelope collects override candidates from the inner manifests of a +// cluster resource envelope. Collecting candidates is a best-effort selection concern and must never block +// the rollout, so an invalid inner manifest or one that cannot be parsed is logged and skipped rather than returning an +// error. The authoritative validation of envelope contents lives in the work generator +// (pkg/controllers/workgenerator/envelope.go), which surfaces the user-facing failure. +func collectCandidatesFromClusterResourceEnvelope( + manager informer.Manager, + envelope *placementv1beta1.ClusterResourceEnvelope, + croCandidates map[placementv1beta1.ResourceIdentifier]bool, +) { + keys := sortedEnvelopeDataKeys(envelope.Data) + for _, key := range keys { + uObj, err := unmarshalEnvelopeData(envelope, key) + if err != nil { + klog.V(2).InfoS("Skipped an inner manifest that could not be parsed while collecting override candidates", "manifestKey", key, "envelope", klog.KObj(envelope), "err", err) + continue + } + if !manager.IsClusterScopedResources(uObj.GroupVersionKind()) { + klog.V(2).InfoS("Skipped an inner manifest while collecting override candidates: a namespaced object has been wrapped in a cluster resource envelope", "manifestKey", key, "wrappedObject", klog.KRef(uObj.GetNamespace(), uObj.GetName()), "envelope", klog.KObj(envelope)) + continue + } + croCandidates[clusterScopedCROCandidate(uObj)] = true + } +} + +// collectCandidatesFromResourceEnvelope collects override candidates from the inner manifests of a resource +// envelope. Collecting candidates is a best-effort selection concern and must never block the rollout, so an +// invalid inner manifest or one that cannot be parsed is logged and skipped rather than returning an error. The +// authoritative validation of envelope contents lives in the work generator +// (pkg/controllers/workgenerator/envelope.go), which surfaces the user-facing failure. +func collectCandidatesFromResourceEnvelope( + manager informer.Manager, + envelope *placementv1beta1.ResourceEnvelope, + roCandidates map[placementv1beta1.ResourceIdentifier]bool, +) { + keys := sortedEnvelopeDataKeys(envelope.Data) + for _, key := range keys { + uObj, err := unmarshalEnvelopeData(envelope, key) + if err != nil { + klog.V(2).InfoS("Skipped an inner manifest that could not be parsed while collecting override candidates", "manifestKey", key, "envelope", klog.KObj(envelope), "err", err) + continue + } + if manager.IsClusterScopedResources(uObj.GroupVersionKind()) { + klog.V(2).InfoS("Skipped an inner manifest while collecting override candidates: a cluster scoped object has been wrapped in a resource envelope", "manifestKey", key, "wrappedObject", klog.KRef(uObj.GetNamespace(), uObj.GetName()), "envelope", klog.KObj(envelope)) + continue + } + if envelope.GetNamespace() != uObj.GetNamespace() { + klog.V(2).InfoS("Skipped an inner manifest while collecting override candidates: a namespaced object has been wrapped in a resource envelope from another namespace", "manifestKey", key, "wrappedObject", klog.KRef(uObj.GetNamespace(), uObj.GetName()), "envelope", klog.KObj(envelope)) + continue + } + roCandidates[namespacedROCandidate(uObj, envelope.GetNamespace())] = true + } +} + +func unmarshalEnvelopeData(envelope placementv1beta1.EnvelopeReader, key string) (*unstructured.Unstructured, error) { + var uObj unstructured.Unstructured + if err := uObj.UnmarshalJSON(envelope.GetData()[key].Raw); err != nil { + return nil, fmt.Errorf("failed to parse the wrapped manifest data to a Kubernetes runtime object (manifestKey=%s,envelopeObjRef=%v): %w", key, envelope.GetEnvelopeObjRef(), err) + } + return &uObj, nil +} + +func sortedEnvelopeDataKeys(data map[string]runtime.RawExtension) []string { + keys := make([]string, 0, len(data)) + for key := range data { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func addCandidatesFromResource( + manager informer.Manager, + uResource *unstructured.Unstructured, + croCandidates map[placementv1beta1.ResourceIdentifier]bool, + roCandidates map[placementv1beta1.ResourceIdentifier]bool, +) { + if !manager.IsClusterScopedResources(uResource.GroupVersionKind()) { + croCandidates[namespaceCROCandidate(uResource.GetNamespace())] = true // selected by the namespace + roCandidates[namespacedROCandidate(uResource, uResource.GetNamespace())] = true // selected by the object itself + return + } + croCandidates[clusterScopedCROCandidate(uResource)] = true // selected by the object itself +} + +func namespaceCROCandidate(namespace string) placementv1beta1.ResourceIdentifier { + return placementv1beta1.ResourceIdentifier{ + Group: utils.NamespaceMetaGVK.Group, + Version: utils.NamespaceMetaGVK.Version, + Kind: utils.NamespaceMetaGVK.Kind, + Name: namespace, + } +} + +func clusterScopedCROCandidate(uObj *unstructured.Unstructured) placementv1beta1.ResourceIdentifier { + gvk := uObj.GroupVersionKind() + return placementv1beta1.ResourceIdentifier{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + Name: uObj.GetName(), + } +} + +func namespacedROCandidate(uObj *unstructured.Unstructured, namespace string) placementv1beta1.ResourceIdentifier { + gvk := uObj.GroupVersionKind() + return placementv1beta1.ResourceIdentifier{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind, + Namespace: namespace, + Name: uObj.GetName(), + } +} + // PickFromResourceMatchedOverridesForTargetCluster filter the overrides that are matched with resources to the target cluster. func PickFromResourceMatchedOverridesForTargetCluster( ctx context.Context, diff --git a/pkg/utils/overrider/overrider_test.go b/pkg/utils/overrider/overrider_test.go index de0c68ffa..e90c6507e 100644 --- a/pkg/utils/overrider/overrider_test.go +++ b/pkg/utils/overrider/overrider_test.go @@ -18,6 +18,7 @@ package overrider import ( "context" + "encoding/json" "errors" "fmt" "testing" @@ -53,6 +54,110 @@ func serviceScheme(t *testing.T) *runtime.Scheme { return scheme } +func clusterResourceSnapshotForTest(resources ...placementv1beta1.ResourceContent) *placementv1beta1.ClusterResourceSnapshot { + return &placementv1beta1.ClusterResourceSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf(placementv1beta1.ResourceSnapshotNameFmt, crpName, 0), + Labels: map[string]string{ + placementv1beta1.ResourceIndexLabel: "0", + placementv1beta1.PlacementTrackingLabel: crpName, + }, + Annotations: map[string]string{ + placementv1beta1.ResourceGroupHashAnnotation: "abc", + placementv1beta1.NumberOfResourceSnapshotsAnnotation: "1", + }, + }, + Spec: placementv1beta1.ResourceSnapshotSpec{ + SelectedResources: resources, + }, + } +} + +func resourceEnvelopeContentForTest(t *testing.T, namespace, name string, data map[string]string) placementv1beta1.ResourceContent { + t.Helper() + return envelopeContentForTest(t, string(placementv1beta1.ResourceEnvelopeType), namespace, name, data) +} + +func clusterResourceEnvelopeContentForTest(t *testing.T, name string, data map[string]string) placementv1beta1.ResourceContent { + t.Helper() + return envelopeContentForTest(t, string(placementv1beta1.ClusterResourceEnvelopeType), "", name, data) +} + +func envelopeContentForTest(t *testing.T, kind, namespace, name string, data map[string]string) placementv1beta1.ResourceContent { + t.Helper() + rawData := make(map[string]json.RawMessage, len(data)) + for key, value := range data { + rawData[key] = json.RawMessage(value) + } + metadata := map[string]string{"name": name} + if namespace != "" { + metadata["namespace"] = namespace + } + raw, err := json.Marshal(map[string]interface{}{ + "apiVersion": placementv1beta1.GroupVersion.String(), + "kind": kind, + "metadata": metadata, + "data": rawData, + }) + if err != nil { + t.Fatalf("json.Marshal(%s) = %v, want no error", kind, err) + } + return placementv1beta1.ResourceContent{RawExtension: runtime.RawExtension{Raw: raw}} +} + +func deploymentRawForTest(namespace, name string) string { + return fmt.Sprintf(`{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"namespace":%q,"name":%q}}`, namespace, name) +} + +func secretRawForTest(namespace, name string) string { + return fmt.Sprintf(`{"apiVersion":"v1","kind":"Secret","metadata":{"namespace":%q,"name":%q}}`, namespace, name) +} + +func clusterRoleRawForTest(name string) string { + return fmt.Sprintf(`{"apiVersion":"rbac.authorization.k8s.io/v1","kind":"ClusterRole","metadata":{"name":%q}}`, name) +} + +func latestCROSnapshotForTest(name string, selectors ...placementv1beta1.ResourceSelectorTerm) placementv1beta1.ClusterResourceOverrideSnapshot { + return placementv1beta1.ClusterResourceOverrideSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + placementv1beta1.IsLatestSnapshotLabel: "true", + }, + }, + Spec: placementv1beta1.ClusterResourceOverrideSnapshotSpec{ + OverrideSpec: placementv1beta1.ClusterResourceOverrideSpec{ + ClusterResourceSelectors: selectors, + }, + }, + } +} + +func latestROSnapshotForTest(namespace, name string, selectors ...placementv1beta1.ResourceSelector) placementv1beta1.ResourceOverrideSnapshot { + return placementv1beta1.ResourceOverrideSnapshot{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: map[string]string{ + placementv1beta1.IsLatestSnapshotLabel: "true", + }, + }, + Spec: placementv1beta1.ResourceOverrideSnapshotSpec{ + OverrideSpec: placementv1beta1.ResourceOverrideSpec{ + ResourceSelectors: selectors, + }, + }, + } +} + +func clusterResourceOverrideSnapshotPtrForTest(snapshot placementv1beta1.ClusterResourceOverrideSnapshot) *placementv1beta1.ClusterResourceOverrideSnapshot { + return &snapshot +} + +func resourceOverrideSnapshotPtrForTest(snapshot placementv1beta1.ResourceOverrideSnapshot) *placementv1beta1.ResourceOverrideSnapshot { + return &snapshot +} + func TestFetchAllMatchingOverridesForResourceSnapshot(t *testing.T) { fakeInformer := informer.FakeManager{ APIResources: map[schema.GroupVersionKind]bool{ @@ -84,6 +189,7 @@ func TestFetchAllMatchingOverridesForResourceSnapshot(t *testing.T) { roList []placementv1beta1.ResourceOverrideSnapshot wantCRO []*placementv1beta1.ClusterResourceOverrideSnapshot wantRO []*placementv1beta1.ResourceOverrideSnapshot + wantErr error }{ { name: "single resource snapshot selecting empty resources", @@ -1482,6 +1588,385 @@ func TestFetchAllMatchingOverridesForResourceSnapshot(t *testing.T) { }, }, }, + { + name: "resource envelope inner deployment matches resource override", + master: clusterResourceSnapshotForTest(resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + })), + roList: []placementv1beta1.ResourceOverrideSnapshot{ + latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{}, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{ + resourceOverrideSnapshotPtrForTest(latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + })), + }, + }, + { + name: "cluster resource envelope inner cluster role matches cluster resource override", + master: clusterResourceSnapshotForTest(clusterResourceEnvelopeContentForTest(t, "env", map[string]string{ + "clusterrole.yaml": clusterRoleRawForTest("foo"), + })), + croList: []placementv1beta1.ClusterResourceOverrideSnapshot{ + latestCROSnapshotForTest("cro-clusterrole", placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "foo", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{ + clusterResourceOverrideSnapshotPtrForTest(latestCROSnapshotForTest("cro-clusterrole", placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "foo", + })), + }, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{}, + }, + { + name: "explicit envelope wrapper selectors are not matched", + master: clusterResourceSnapshotForTest( + clusterResourceEnvelopeContentForTest(t, "env", map[string]string{ + "clusterrole.yaml": clusterRoleRawForTest("foo"), + }), + resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + }), + ), + croList: []placementv1beta1.ClusterResourceOverrideSnapshot{ + latestCROSnapshotForTest("cro-wrapper", placementv1beta1.ResourceSelectorTerm{ + Group: placementv1beta1.GroupVersion.Group, + Version: placementv1beta1.GroupVersion.Version, + Kind: string(placementv1beta1.ClusterResourceEnvelopeType), + Name: "env", + }), + }, + roList: []placementv1beta1.ResourceOverrideSnapshot{ + latestROSnapshotForTest("ns", "ro-wrapper", placementv1beta1.ResourceSelector{ + Group: placementv1beta1.GroupVersion.Group, + Version: placementv1beta1.GroupVersion.Version, + Kind: string(placementv1beta1.ResourceEnvelopeType), + Name: "env", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{}, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{}, + }, + { + name: "resource envelope namespace matches namespace cluster resource override", + master: clusterResourceSnapshotForTest(resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + })), + croList: []placementv1beta1.ClusterResourceOverrideSnapshot{ + latestCROSnapshotForTest("cro-namespace", placementv1beta1.ResourceSelectorTerm{ + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "ns", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{ + clusterResourceOverrideSnapshotPtrForTest(latestCROSnapshotForTest("cro-namespace", placementv1beta1.ResourceSelectorTerm{ + Group: "", + Version: "v1", + Kind: "Namespace", + Name: "ns", + })), + }, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{}, + }, + { + // Collecting override candidates is a best-effort selection concern and must never block the + // rollout. An inner manifest that cannot be parsed is skipped, and candidates from the remaining valid + // manifests in the same envelope are still collected. + name: "resource envelope inner manifest that cannot be parsed is skipped and valid inner manifests still collected", + master: clusterResourceSnapshotForTest(resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "bad.yaml": `"not-an-object"`, + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + })), + roList: []placementv1beta1.ResourceOverrideSnapshot{ + latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{}, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{ + resourceOverrideSnapshotPtrForTest(latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + })), + }, + }, + { + // A cluster-scoped object wrapped in a resource envelope is invalid input, but candidate + // collection must not hard-fail on it: the offending manifest is skipped while the valid inner + // manifest in the same envelope is still collected. The authoritative validation lives in the + // work generator. + name: "cluster scoped object wrapped in resource envelope is skipped and valid inner manifest still collected", + master: clusterResourceSnapshotForTest(resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "clusterrole.yaml": clusterRoleRawForTest("foo"), + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + })), + croList: []placementv1beta1.ClusterResourceOverrideSnapshot{ + latestCROSnapshotForTest("cro-clusterrole", placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "foo", + }), + }, + roList: []placementv1beta1.ResourceOverrideSnapshot{ + latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{}, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{ + resourceOverrideSnapshotPtrForTest(latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + })), + }, + }, + { + // A namespaced object wrapped in a cluster resource envelope is invalid input, but candidate + // collection must not hard-fail on it: the offending manifest is skipped while the valid inner + // manifest in the same envelope is still collected. + name: "namespaced object wrapped in cluster resource envelope is skipped and valid inner manifest still collected", + master: clusterResourceSnapshotForTest(clusterResourceEnvelopeContentForTest(t, "env", map[string]string{ + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + "clusterrole.yaml": clusterRoleRawForTest("foo"), + })), + croList: []placementv1beta1.ClusterResourceOverrideSnapshot{ + latestCROSnapshotForTest("cro-clusterrole", placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "foo", + }), + latestCROSnapshotForTest("cro-deployment", placementv1beta1.ResourceSelectorTerm{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{ + clusterResourceOverrideSnapshotPtrForTest(latestCROSnapshotForTest("cro-clusterrole", placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "foo", + })), + }, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{}, + }, + { + // An inner manifest in a different namespace than the resource envelope is invalid input, but + // candidate collection must not hard-fail on it: the offending manifest is skipped while the + // valid inner manifest in the same envelope is still collected. + name: "inner manifest in a different namespace than the resource envelope is skipped and valid inner manifest still collected", + master: clusterResourceSnapshotForTest(resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "other.yaml": deploymentRawForTest("other-ns", "other-app"), + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + })), + roList: []placementv1beta1.ResourceOverrideSnapshot{ + latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }), + latestROSnapshotForTest("other-ns", "ro-other", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "other-app", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{}, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{ + resourceOverrideSnapshotPtrForTest(latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + })), + }, + }, + { + name: "resource envelope with multiple inner resources only selects targeted override", + master: clusterResourceSnapshotForTest(resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + "secret.yaml": secretRawForTest("ns", "secret-name"), + })), + roList: []placementv1beta1.ResourceOverrideSnapshot{ + latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }), + latestROSnapshotForTest("ns", "ro-service", placementv1beta1.ResourceSelector{ + Group: "", + Version: "v1", + Kind: "Service", + Name: "svc-name", + }), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{}, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{ + resourceOverrideSnapshotPtrForTest(latestROSnapshotForTest("ns", "ro-deployment", placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + })), + }, + }, + { + name: "resource envelope does not duplicate snapshot when multiple selectors match", + master: clusterResourceSnapshotForTest(resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + "secret.yaml": secretRawForTest("ns", "secret-name"), + })), + roList: []placementv1beta1.ResourceOverrideSnapshot{ + latestROSnapshotForTest("ns", "ro-multiple-selectors", + placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }, + placementv1beta1.ResourceSelector{ + Group: "", + Version: "v1", + Kind: "Secret", + Name: "secret-name", + }, + ), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{}, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{ + resourceOverrideSnapshotPtrForTest(latestROSnapshotForTest("ns", "ro-multiple-selectors", + placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }, + placementv1beta1.ResourceSelector{ + Group: "", + Version: "v1", + Kind: "Secret", + Name: "secret-name", + }, + )), + }, + }, + { + // Only a later selector matches; the snapshot is still selected. This pins the partial-match + // behaviour so the no-match warning stays scoped to snapshots where no selector matches at all. + name: "resource override with multiple selectors where only a later selector matches is still selected", + master: clusterResourceSnapshotForTest(resourceEnvelopeContentForTest(t, "ns", "env", map[string]string{ + "deployment.yaml": deploymentRawForTest("ns", "my-app"), + })), + roList: []placementv1beta1.ResourceOverrideSnapshot{ + latestROSnapshotForTest("ns", "ro-partial-match", + placementv1beta1.ResourceSelector{ + Group: "", + Version: "v1", + Kind: "Service", + Name: "does-not-exist", + }, + placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }, + ), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{}, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{ + resourceOverrideSnapshotPtrForTest(latestROSnapshotForTest("ns", "ro-partial-match", + placementv1beta1.ResourceSelector{ + Group: "", + Version: "v1", + Kind: "Service", + Name: "does-not-exist", + }, + placementv1beta1.ResourceSelector{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + Name: "my-app", + }, + )), + }, + }, + { + // Only a later selector matches; the snapshot is still selected. This pins the partial-match + // behaviour so the no-match warning stays scoped to snapshots where no selector matches at all. + name: "cluster resource override with multiple selectors where only a later selector matches is still selected", + master: clusterResourceSnapshotForTest(clusterResourceEnvelopeContentForTest(t, "env", map[string]string{ + "clusterrole.yaml": clusterRoleRawForTest("foo"), + })), + croList: []placementv1beta1.ClusterResourceOverrideSnapshot{ + latestCROSnapshotForTest("cro-partial-match", + placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "does-not-exist", + }, + placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "foo", + }, + ), + }, + wantCRO: []*placementv1beta1.ClusterResourceOverrideSnapshot{ + clusterResourceOverrideSnapshotPtrForTest(latestCROSnapshotForTest("cro-partial-match", + placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "does-not-exist", + }, + placementv1beta1.ResourceSelectorTerm{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + Name: "foo", + }, + )), + }, + wantRO: []*placementv1beta1.ResourceOverrideSnapshot{}, + }, } for _, tc := range tests { @@ -1502,8 +1987,11 @@ func TestFetchAllMatchingOverridesForResourceSnapshot(t *testing.T) { WithObjects(objects...). Build() gotCRO, gotRO, err := FetchAllMatchingOverridesForResourceSnapshot(context.Background(), fakeClient, &fakeInformer, tc.placementKey, tc.master) - if err != nil { - t.Fatalf("fetchAllMatchingOverridesForResourceSnapshot() failed, got err %v, want no err", err) + if gotErr, wantErr := err != nil, tc.wantErr != nil; gotErr != wantErr || (err != nil && !errors.Is(err, tc.wantErr)) { + t.Fatalf("fetchAllMatchingOverridesForResourceSnapshot() got error %v, want error %v", err, tc.wantErr) + } + if tc.wantErr != nil { + return } options := []cmp.Option{ cmpopts.IgnoreFields(metav1.ObjectMeta{}, "ResourceVersion"), From 46321028285ed08309fd6302b3a93c1c39b02bb0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:09:50 +1000 Subject: [PATCH 02/23] chore: bump fkirc/skip-duplicate-actions from 5.3.1 to 5.3.2 (#797) --- .github/workflows/ci.yml | 2 +- .github/workflows/code-lint.yml | 2 +- .github/workflows/upgrade.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 257343bde..f9f679d04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Detect No-op Changes id: noop - uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1 + uses: fkirc/skip-duplicate-actions@b974a9395958c231af965b70070979a577efa578 # v5.3.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} do_not_skip: '["workflow_dispatch", "schedule", "push"]' diff --git a/.github/workflows/code-lint.yml b/.github/workflows/code-lint.yml index 688d1e1a6..f449e083a 100644 --- a/.github/workflows/code-lint.yml +++ b/.github/workflows/code-lint.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Detect No-op Changes id: noop - uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1 + uses: fkirc/skip-duplicate-actions@b974a9395958c231af965b70070979a577efa578 # v5.3.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} do_not_skip: '["workflow_dispatch", "schedule", "push"]' diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index 29e6f5d40..9f4cda09e 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -27,7 +27,7 @@ jobs: steps: - name: Detect No-op Changes id: noop - uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1 + uses: fkirc/skip-duplicate-actions@b974a9395958c231af965b70070979a577efa578 # v5.3.2 with: github_token: ${{ secrets.GITHUB_TOKEN }} do_not_skip: '["workflow_dispatch", "schedule", "push"]' From 8e980c5f3c929db613725fdd1befd9f09895ea24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:10:37 +1000 Subject: [PATCH 03/23] chore: bump actions/setup-go from 6.4.0 to 7.0.0 (#798) --- .github/workflows/ci.yml | 4 ++-- .github/workflows/code-lint.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/trivy.yml | 2 +- .github/workflows/upgrade.yml | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9f679d04..688605d07 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: if: needs.detect-noop.outputs.noop != 'true' steps: - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} @@ -113,7 +113,7 @@ jobs: if: needs.detect-noop.outputs.noop != 'true' steps: - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} diff --git a/.github/workflows/code-lint.yml b/.github/workflows/code-lint.yml index f449e083a..35cac9527 100644 --- a/.github/workflows/code-lint.yml +++ b/.github/workflows/code-lint.yml @@ -37,7 +37,7 @@ jobs: steps: - name: Setup Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} @@ -58,7 +58,7 @@ jobs: steps: - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3b0a0377..11479b110 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 1734fbdc0..dea3e9eae 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -42,7 +42,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Set up Go ${{ env.GO_VERSION }} - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index 9f4cda09e..230bddb3d 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -39,7 +39,7 @@ jobs: if: needs.detect-noop.outputs.noop != 'true' steps: - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} @@ -122,7 +122,7 @@ jobs: if: needs.detect-noop.outputs.noop != 'true' steps: - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} @@ -205,7 +205,7 @@ jobs: if: needs.detect-noop.outputs.noop != 'true' steps: - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: ${{ env.GO_VERSION }} From 019eb7658bfc4a20f1847719139976d0ca74d15c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:27:22 +1000 Subject: [PATCH 04/23] chore: bump actions/github-script from 7 to 9 (#799) --- .github/workflows/squad-heartbeat.yml | 4 ++-- .github/workflows/squad-issue-assign.yml | 4 ++-- .github/workflows/squad-label-enforce.yml | 2 +- .github/workflows/squad-triage.yml | 2 +- .github/workflows/sync-squad-labels.yml | 2 +- .github/workflows/trivy.yml | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml index 2fb36a3bd..6c76c1f9e 100644 --- a/.github/workflows/squad-heartbeat.yml +++ b/.github/workflows/squad-heartbeat.yml @@ -48,7 +48,7 @@ jobs: - name: Ralph — Apply triage decisions if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != '' - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); @@ -100,7 +100,7 @@ jobs: # Copilot auto-assign step (uses PAT if available) - name: Ralph — Assign @copilot issues if: success() - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 1bec8ed25..6705a9bb9 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v7 - name: Identify assigned member and trigger work - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); @@ -112,7 +112,7 @@ jobs: # Separate step: assign @copilot using PAT (required for coding agent) - name: Assign @copilot coding agent if: github.event.label.name == 'squad:copilot' - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }} script: | diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml index 10cce2682..a1dad6d12 100644 --- a/.github/workflows/squad-label-enforce.yml +++ b/.github/workflows/squad-label-enforce.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v7 - name: Enforce mutual exclusivity - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const issue = context.payload.issue; diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index de92a246c..001d1d905 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v7 - name: Triage issue via Lead agent - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml index e6a7f6c63..8415c2ef3 100644 --- a/.github/workflows/sync-squad-labels.yml +++ b/.github/workflows/sync-squad-labels.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v7 - name: Parse roster and sync labels - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const fs = require('fs'); diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index dea3e9eae..1df85de13 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -177,7 +177,7 @@ jobs: - name: Create issue for Copilot if: steps.check-vulns.outputs.has_vulns == 'true' && github.event_name == 'schedule' - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const today = new Date().toISOString().split('T')[0]; From 8485ee5eb8e8b055929a95fd85d2687211f0ef07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 08:27:40 +1000 Subject: [PATCH 05/23] chore: bump oss/go/microsoft/golang from 1.26.5 to 1.26.5-1 in /docker (#800) --- docker/hub-agent.Dockerfile | 2 +- docker/member-agent.Dockerfile | 2 +- docker/refresh-token.Dockerfile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docker/hub-agent.Dockerfile b/docker/hub-agent.Dockerfile index 205960753..7ffe6b92e 100644 --- a/docker/hub-agent.Dockerfile +++ b/docker/hub-agent.Dockerfile @@ -1,5 +1,5 @@ # Build the hubagent binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5-1 AS builder ARG GOOS=linux ARG GOARCH=amd64 diff --git a/docker/member-agent.Dockerfile b/docker/member-agent.Dockerfile index 9270fc6e6..48d3e7fb2 100644 --- a/docker/member-agent.Dockerfile +++ b/docker/member-agent.Dockerfile @@ -1,5 +1,5 @@ # Build the memberagent binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5-1 AS builder ARG GOOS=linux ARG GOARCH=amd64 diff --git a/docker/refresh-token.Dockerfile b/docker/refresh-token.Dockerfile index 4c9dc6e54..0a748499a 100644 --- a/docker/refresh-token.Dockerfile +++ b/docker/refresh-token.Dockerfile @@ -1,5 +1,5 @@ # Build the refreshtoken binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5-1 AS builder ARG GOOS="linux" ARG GOARCH="amd64" From 0f6504c6502e6dba52c0c77f99b62af90e9a240c Mon Sep 17 00:00:00 2001 From: michaelawyu Date: Fri, 7 Aug 2026 06:34:53 +0800 Subject: [PATCH 06/23] interface: [FEP-0001] add API definition for the Placement Policy and Cluster Request API objects (#781) --- Makefile | 4 +- apis/cluster/v1beta1/zz_generated.deepcopy.go | 2 +- .../v1alpha1/clusterrequest_types.go | 107 +++ .../placement/v1alpha1/common.go | 42 ++ apis/kubefleet.dev/placement/v1alpha1/doc.go | 20 + .../placement/v1alpha1/gvk_info.go | 35 + .../v1alpha1/placementpolicy_types.go | 601 +++++++++++++++ .../v1alpha1/zz_generated.deepcopy.go | 556 ++++++++++++++ .../v1alpha1/zz_generated.deepcopy.go | 2 +- .../v1beta1/zz_generated.deepcopy.go | 2 +- ...ubefleet.dev_clusterplacementpolicies.yaml | 683 ++++++++++++++++++ ...acement.kubefleet.dev_clusterrequests.yaml | 306 ++++++++ ...ement.kubefleet.dev_placementpolicies.yaml | 683 ++++++++++++++++++ test/apis/v1alpha1/zz_generated.deepcopy.go | 2 +- 14 files changed, 3039 insertions(+), 6 deletions(-) create mode 100644 apis/kubefleet.dev/placement/v1alpha1/clusterrequest_types.go create mode 100644 apis/kubefleet.dev/placement/v1alpha1/common.go create mode 100644 apis/kubefleet.dev/placement/v1alpha1/doc.go create mode 100644 apis/kubefleet.dev/placement/v1alpha1/gvk_info.go create mode 100644 apis/kubefleet.dev/placement/v1alpha1/placementpolicy_types.go create mode 100644 apis/kubefleet.dev/placement/v1alpha1/zz_generated.deepcopy.go create mode 100644 config/crd/bases/placement.kubefleet.dev_clusterplacementpolicies.yaml create mode 100644 config/crd/bases/placement.kubefleet.dev_clusterrequests.yaml create mode 100644 config/crd/bases/placement.kubefleet.dev_placementpolicies.yaml diff --git a/Makefile b/Makefile index 66f589b70..5f0dccf6a 100644 --- a/Makefile +++ b/Makefile @@ -280,9 +280,9 @@ crd-package: ## Package the raw CRDs into a release tarball with a SHA-256 check @echo "Packaged CRDs into $(CRD_PACKAGE_DIR)/$(CRD_PACKAGE_NAME).tgz" .PHONY: crd-verify -crd-verify: ## Verify the chart CRD directories cover every CRD in config/crd/bases +crd-verify: ## Verify the chart CRD directories cover every CRD in config/crd/bases; note (chenyu1): kubefleet.dev CRDs are ignored for now until the implementation is completed. @bases="$$(mktemp)"; charts="$$(mktemp)"; \ - ls config/crd/bases/ | sort > "$$bases"; \ + ls config/crd/bases/ | grep -v '^placement\.kubefleet\.dev' | sort > "$$bases"; \ { ls charts/hub-agent/templates/crds/; ls charts/member-agent/templates/crds/; } | sort > "$$charts"; \ missing="$$(comm -3 "$$bases" "$$charts")"; \ rm -f "$$bases" "$$charts"; \ diff --git a/apis/cluster/v1beta1/zz_generated.deepcopy.go b/apis/cluster/v1beta1/zz_generated.deepcopy.go index cec52aa39..a51641c3f 100644 --- a/apis/cluster/v1beta1/zz_generated.deepcopy.go +++ b/apis/cluster/v1beta1/zz_generated.deepcopy.go @@ -21,7 +21,7 @@ limitations under the License. package v1beta1 import ( - v1 "k8s.io/api/core/v1" + "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/apis/kubefleet.dev/placement/v1alpha1/clusterrequest_types.go b/apis/kubefleet.dev/placement/v1alpha1/clusterrequest_types.go new file mode 100644 index 000000000..601185319 --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/clusterrequest_types.go @@ -0,0 +1,107 @@ +/* +Copyright 2026 The KubeFleet 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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + ClusterRequestCondTypeCompleted = "Completed" +) + +// ClusterRequest is a KubeFleet API that represents a request for a member cluster to be provisioned. +// It is created by KubeFleet when it fails to find a member cluster that can fulfill some scheduling +// requirements as specified in a PlacementPolicy or ClusterPlacementPolicy object. +// +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster,categories={kubefleet, kubefleet-placement} +// +kubebuilder:storageversion +type ClusterRequest struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The specification of the cluster request. + // +kubebuilder:validation:Required + Spec ClusterRequestSpec `json:"spec,omitempty"` + + // The observed status of the cluster request. + // +kubebuilder:validation:Optional + Status ClusterRequestStatus `json:"status,omitempty"` +} + +type ClusterRequestSpec struct { + // The reference to the placement policy that submits the cluster request. + // + // This field is immutable after creation. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="the placementPolicyRef field is immutable" + PlacementPolicyRef *ObjectReference `json:"placementPolicyRef"` + + // The cluster selector terms that describe the requirements for a new member cluster. + // + // If not specified, any member cluster can satisfy the request. + // + // This field is immutable after creation. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="the clusterSelectorTerms field is immutable" + ClusterSelectorTerms []ClusterLabelAndPropertySelectorTerm `json:"clusterSelectorTerms,omitempty"` +} + +type ClusterRequestStatus struct { + // A list of observed conditions of the cluster request. + // + // +kubebuilder:validation:Optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // The name of the cluster that has been provisioned for this cluster request, if any. + // + // +kubebuilder:validation:Optional + ProvisionedClusterName *string `json:"provisionedClusterName,omitempty"` + + // The last observed most recent creation timestamp across all the member clusters. This field is used + // as an expedient solution to verify if a cluster request is still valid for consideration, i.e., + // if the currently observed most recent cluster creation timestamp is later than this timestamp in the + // status, a new member cluster must have been created after the cluster request was created, + // and thus the cluster request should be considered stale and can be ignored. The placement policy + // that submits the cluster request is responsible for updating this field if the new cluster does not + // meet the need of the associated cluster selector, so that the cluster request can be re-evaluated again; + // it may instead withdraw the cluster request if the new cluster has fulfilled the associated cluster selector. + // + // +kubebuilder:validation:Optional + LastObservedMostRecentClusterCreationTimestamp *metav1.Time `json:"lastObservedMostRecentClusterCreationTimestamp,omitempty"` +} + +// ClusterRequestList contains a list of ClusterRequest. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope="Cluster" +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ClusterRequestList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ClusterRequest `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ClusterRequest{}, &ClusterRequestList{}) +} diff --git a/apis/kubefleet.dev/placement/v1alpha1/common.go b/apis/kubefleet.dev/placement/v1alpha1/common.go new file mode 100644 index 000000000..e1b991dc1 --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/common.go @@ -0,0 +1,42 @@ +/* +Copyright 2026 The KubeFleet 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 + +type ObjectReference struct { + // The namespace of the referenced object. + // + // If the object is cluster-scoped, this field should be left empty. + // + // +kubebuilder:validation:Optional + Namespace string `json:"namespace,omitempty"` + + // The name of the referenced object. + // + // +kubebuilder:validation:Required + Name string `json:"name"` + + // The API group, version, and kind of the referenced object. + + // +kubebuilder:validation:Optional + APIGroup string `json:"apiGroup,omitempty"` + + // +kubebuilder:validation:Required + APIVersion string `json:"apiVersion,omitempty"` + + // +kubebuilder:validation:Required + Kind string `json:"kind,omitempty"` +} diff --git a/apis/kubefleet.dev/placement/v1alpha1/doc.go b/apis/kubefleet.dev/placement/v1alpha1/doc.go new file mode 100644 index 000000000..a2942e09d --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2026 The KubeFleet 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. +*/ + +// +kubebuilder:object:generate=true +// +k8s:deepcopy-gen=package,register +// +groupName=placement.kubefleet.dev +package v1alpha1 diff --git a/apis/kubefleet.dev/placement/v1alpha1/gvk_info.go b/apis/kubefleet.dev/placement/v1alpha1/gvk_info.go new file mode 100644 index 000000000..81efe4327 --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/gvk_info.go @@ -0,0 +1,35 @@ +/* +Copyright 2026 The KubeFleet 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. +*/ + +// +kubebuilder:object:generate=true +// +groupName=placement.kubefleet.dev +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "placement.kubefleet.dev", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/apis/kubefleet.dev/placement/v1alpha1/placementpolicy_types.go b/apis/kubefleet.dev/placement/v1alpha1/placementpolicy_types.go new file mode 100644 index 000000000..5d47fa4af --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/placementpolicy_types.go @@ -0,0 +1,601 @@ +/* +Copyright 2026 The KubeFleet 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" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// The condition types for PlacementPolicy and ClusterPlacementPolicy API objects. +const ( + PlacementPolicyCondTypeResourceCollected = "ResourceCollected" + PlacementPolicyCondTypeScheduled = "Scheduled" + PlacementPolicyCondTypeSynchronized = "Synchronized" + PlacementPolicyCondTypeAvailable = "Available" +) + +// The reasons for each condition type of PlacementPolicy and ClusterPlacementPolicy API objects. +const ( + PlacementPolicyResourceCollectedCondReasonAllResourcesCollected = "AllResourcesCollected" + PlacementPolicyResourceCollectedCondReasonFailedToCollectSomeResources = "FailedToCollectSomeResources" + + PlacementPolicyScheduledCondReasonFoundAllClusters = "FoundAllRequiredClusters" + PlacementPolicyScheduledCondReasonFailedToFindSomeClusters = "FailedToFindSomeRequiredClusters" + + PlacementPolicySynchronizedCondReasonAllClustersSynchronized = "AllClustersSynchronized" + PlacementPolicySynchronizedCondReasonFailedToSynchronizeSomeClusters = "FailedToSynchronizeSomeClusters" + + PlacementPolicyAvailableCondReasonAllClustersAvailable = "ResourcesAvailableOnAllClusters" + PlacementPolicyAvailableCondReasonSomeClustersUnavailable = "ResourcesUnavailableOnSomeClusters" +) + +// PlacementPolicy is the KubeFleet API that enables users to place resources within a namespace across +// member clusters. +// +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,categories={kubefleet, kubefleet-placement} +// +kubebuilder:storageversion +type PlacementPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The specification of the placement policy. + // +kubebuilder:validation:Required + Spec PlacementPolicySpec `json:"spec,omitempty"` + + // The observed status of the placement policy. + // +kubebuilder:validation:Optional + Status PlacementPolicyStatus `json:"status,omitempty"` +} + +// Note (chenyu1): some validations are moved as VAPs, as they involve information that is only available at runtime (e.g., +// the namespace of the current object). + +// ClusterPlacementPolicy is the KubeFleet API that enables users to place namespaced and cluster-scoped resources across +// member clusters. +// +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Cluster,categories={kubefleet, kubefleet-placement} +// +kubebuilder:storageversion +type ClusterPlacementPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The specification of the cluster placement policy. + // +kubebuilder:validation:Required + Spec PlacementPolicySpec `json:"spec,omitempty"` + + // The observed status of the cluster placement policy. + // +kubebuilder:validation:Optional + Status PlacementPolicyStatus `json:"status,omitempty"` +} + +type PlacementPolicySpec struct { + // A list of cluster selectors that specifies the target clusters where KubeFleet should place + // the resources. A cluster selector consists of a list of label and cluster property selectors + // and a count; and for each cluster selector, KubeFleet will pick `count` number of clusters + // that match the given selectors for placing the resources. + // + // If not specified, KubeFleet will place the resources to all available member clusters. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=10 + ClusterSelectors []ClusterSelector `json:"clusterSelectors,omitempty"` + + // A list of resource selectors that specifies the resources that KubeFleet should place across + // the target clusters. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=10 + // +kubebuilder:validation:XValidation:rule="self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector)))",message="name and labelSelector are mutually exclusive in a resource selector" + ResourceSelectors []ResourceSelector `json:"resourceSelectors,omitempty"` + + // The resource revision history limit for this placement policy. + // + // KubeFleet will snapshot the resources selected by a placement policy; when the resources are + // updated and a rollout is triggered on the placement policy, KubeFleet will create a new + // revision of the selected resources (in the form of a resource snapshot), which tracks the state of + // the selected resources at that point of time. These revisions are kept for auditing and + // failure recovery purposes; one can inspect them to see the past state of the selected resources, + // or roll back to a previous revision if the latest revision is not working as expected. + // + // It is also possible to manually request a new resource revision to be created. + // + // The default value is 3. + // + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=20 + // +kubebuilder:validation:Optional + // +kubebuilder:default=3 + ResourceRevisionHistoryLimit *int32 `json:"resourceRevisionHistoryLimit,omitempty"` + + // The strategy that KubeFleet uses to synchronize the selected resources to the target clusters. + // Set the strategy to configure how selected resources are applied to a target cluster, how to handle + // drifts/conflicts when applying the resources, what to do with placed resources when the placement policy + // is deleted, and many more. + // + // +kubebuilder:validation:Optional + SyncStrategy *SyncStrategy `json:"syncStrategy,omitempty"` + + // The tolerations which allows KubeFleet to synchronize selected resources to tainted target + // clusters. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxItems=10 + // +kubebuilder:validation:XValidation:rule="self.all(t, t.key != '' || (t.operator == 'Exists' && t.value == ''))",message="operator must be Exists and value must be empty when key is empty" + // +kubebuilder:validation:XValidation:rule="self.all(t, t.operator != 'Exists' || t.value == '')",message="value must be empty when operator is Exists" + Tolerations []Toleration `json:"tolerations,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="!has(self.minCount) || !has(self.count) || (type(self.count) == string && self.count == 'All') || (type(self.count) == int && self.minCount <= self.count) || (type(self.count) == string && self.count.matches('^[0-9]+$') && self.minCount <= int(self.count))",message="minCount must be less than or equal to count when count is not All" +type ClusterSelector struct { + // A list of terms that form the selector. The terms are ORed, i.e., a cluster would match the selector + // if it matches any of the terms. + // + // If not specified, the selector will match all clusters. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxItems=5 + Terms []ClusterLabelAndPropertySelectorTerm `json:"terms,omitempty"` + + // The desired number of clusters that KubeFleet should select based on the given terms. + // + // The default value is 1. To select all clusters that match the given terms, use the value "All". + // + // +kubebuilder:validation:Optional + // +kubebuilder:default=1 + // +kubebuilder:validation:XIntOrString + // +kubebuilder:validation:Pattern="^([1-9][0-9]{0,2}|All)$" + Count *intstr.IntOrString `json:"count,omitempty"` + + // The minimum number of clusters that KubeFleet should select based on the given terms, when KubeFleet is not able + // to find the desired number of clusters. + // + // The default value is set to the same value of `count`, if `count` is an integer. If `count` is set to "All", the + // default value of `minCount` is 1. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=999 + MinCount *int32 `json:"minCount,omitempty"` + + // The action to take when KubeFleet is not able to find the desired (minimum) number of clusters based on the given terms. + // + // Available options are: + // * RequestCluster: KubeFleet will submit a cluster request to signal that a new cluster is needed to complete the placement. + // It is up to the platform/cloud provider to fulfill the request. + // * KeepSearching: KubeFleet will keep searching for clusters that match the given terms silently; no cluster request will be + // submitted. + // + // This field takes effect only when cluster requests are enabled in KubeFleet. + // + // +kubebuilder:validation:Optional + // +kubebuilder:default=RequestCluster + // +kubebuilder:validation:Enum=RequestCluster;KeepSearching + WhenUnfulfilled WhenUnfulfilledOption `json:"whenUnfulfilled,omitempty"` +} + +type ClusterLabelAndPropertySelectorTerm struct { + // One can mix and match `MatchLabels`, `MatchLabelExpressions`, and `MatchClusterPropertyExpressions` + // in a selector term as needed. The requirements/constraints will be ANDed. + // + // If none of the fields are specified, the selector term will match all clusters. + + // A list of label key-value pairs that a cluster must have to match this selector term. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxProperties=10 + MatchLabels map[string]string `json:"matchLabels,omitempty"` + + // A list of label expressions that a cluster must all satisfy to match this selector term. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxItems=10 + MatchLabelExpressions []LabelClusterPropertyExpression `json:"matchLabelExpressions,omitempty"` + + // A list of cluster property expressions that a cluster must all satisfy to match this selector term. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxItems=10 + MatchClusterPropertyExpressions []LabelClusterPropertyExpression `json:"matchClusterPropertyExpressions,omitempty"` +} + +// +kubebuilder:validation:XValidation:rule="(self.operator == 'In' || self.operator == 'NotIn') ? (has(self.values) && size(self.values) > 0) : true",message="values must be non-empty when operator is In or NotIn" +// +kubebuilder:validation:XValidation:rule="(self.operator == 'Exists' || self.operator == 'DoesNotExist') ? (!has(self.values) || size(self.values) == 0) : true",message="values must be empty when operator is Exists or DoesNotExist" +// +kubebuilder:validation:XValidation:rule="(self.operator == 'Gt' || self.operator == 'Lt' || self.operator == 'Ge' || self.operator == 'Le' || self.operator == 'Eq' || self.operator == 'Ne') ? (has(self.values) && size(self.values) == 1) : true",message="values must contain exactly one element when operator is Gt, Lt, Ge, Le, Eq, or Ne" +type LabelClusterPropertyExpression struct { + // The key of the label or cluster property that selector applies to. + // +kubebuilder:validation:Required + Key string `json:"key"` + + // The operator that specifies the relationship between the current value under the key and the given values. + // + // If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + // cluster property. + // If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + // Applying an unsupported operator to a key will cause an error at the scheduling phase. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=In;NotIn;Exists;DoesNotExist;Gt;Lt;Ge;Le;Eq;Ne + Operator LabelClusterPropertyExpressionOperator `json:"operator"` + + // The values that are used in conjunction with the operator to determine if a selector matches. + // + // If the operator is In or NotIn, the values array must be non-empty. + // If the operator is Exists or DoesNotExist, the values array must be empty. + // If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + // +kubebuilder:validation:Optional + Values []string `json:"values,omitempty"` +} + +type LabelClusterPropertyExpressionOperator string + +const ( + // The operators applicable to labels and string-based cluster properties. + LabelClusterPropertyExpressionOperatorIn LabelClusterPropertyExpressionOperator = "In" + LabelClusterPropertyExpressionOperatorNotIn LabelClusterPropertyExpressionOperator = "NotIn" + LabelClusterPropertyExpressionOperatorExists LabelClusterPropertyExpressionOperator = "Exists" + LabelClusterPropertyExpressionOperatorDoesNotExist LabelClusterPropertyExpressionOperator = "DoesNotExist" + + // The operators applicable to numeric-based cluster properties. + LabelClusterPropertyExpressionOperatorGt LabelClusterPropertyExpressionOperator = "Gt" + LabelClusterPropertyExpressionOperatorLt LabelClusterPropertyExpressionOperator = "Lt" + LabelClusterPropertyExpressionOperatorGe LabelClusterPropertyExpressionOperator = "Ge" + LabelClusterPropertyExpressionOperatorLe LabelClusterPropertyExpressionOperator = "Le" + LabelClusterPropertyExpressionOperatorEq LabelClusterPropertyExpressionOperator = "Eq" + LabelClusterPropertyExpressionOperatorNe LabelClusterPropertyExpressionOperator = "Ne" +) + +type WhenUnfulfilledOption string + +const ( + WhenUnfulfilledOptionRequestCluster WhenUnfulfilledOption = "RequestCluster" + WhenUnfulfilledOptionKeepSearching WhenUnfulfilledOption = "KeepSearching" +) + +type ResourceSelector struct { + // The API group, version, and kind of the resource(s) to select. + // + // For resources in the core API group, set the APIGroup field to an empty string (""), in consistency + // with common Kubernetes practices. + + // +kubebuilder:validation:Optional + APIGroup string `json:"apiGroup,omitempty"` + + // +kubebuilder:validation:Required + APIVersion string `json:"apiVersion,omitempty"` + + // +kubebuilder:validation:Required + Kind string `json:"kind,omitempty"` + + // The name of the resource to select. + // + // Alternatively, one can use the LabelSelector field to select multiple resources by their labels. + // + // This field is mutually exclusive with the LabelSelector field. + // + // +kubebuilder:validation:Optional + Name string `json:"name"` + + // The label selector that selects multiple resources for placement. + // + // Alternatively, one can use the Name field to select a single resource by its name. + // + // This field is mutually exclusive with the Name field. + // + // +kubebuilder:validation:Optional + LabelSelector *metav1.LabelSelector `json:"labelSelector,omitempty"` + + // The namespace of the resource to select. + // + // This field applies only when selecting namespaced resources using the ClusterPlacementPolicy API. + // + // For usage with the PlacementPolicy API, this field must be set empty or use the same value of the PlacementPolicy's + // namespace itself. + // + // +kubebuilder:validation:Optional + Namespace string `json:"namespace,omitempty"` +} + +type SyncStrategy struct { + // The method KubeFleet uses to apply resources to target clusters. + // + // Available options are: + // * ClientSideApply: KubeFleet applies resources to a target cluster using three-way merge patch, similar + // to how the Kubernetes CLI performs a client-side apply. + // * ServerSideApply: KubeFleet applies resources to a target cluster using server-side apply, which allows + // the API server to manage conflicts and merge changes. + // + // The default value is ClientSideApply. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Enum=ClientSideApply;ServerSideApply + // +kubebuilder:default=ClientSideApply + ApplyMethod ApplyMethod `json:"applyMethod,omitempty"` + + // The options for running server-side apply ops. This field takes effect only if the apply method is + // set to ServerSideApply. + // + // +kubebuilder:validation:Optional + ServerSideApplyOptions *ServerSideApplyOptions `json:"serverSideApplyOptions,omitempty"` + + // How to handle resource co-ownership. This is most relevant when KubeFleet must manage resources that + // are already (or expected to be) owned by other non-KubeFleet controllers in target clusters. + // + // Available options are: + // * ShareOwnership: KubeFleet registers itself as a co-owner of the resource. + // * ReportError: KubeFleet reports an error when a resource to be placed is already owned by other controllers. + // + // The default value is ReportError. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Enum=ShareOwnership;ReportError + // +kubebuilder:default=ReportError + WhenOwnedByOthers WhenOwnedByOthersOption `json:"whenOwnedByOthers,omitempty"` + + // The action to take when a resource on the target cluster side has drifted from its desired state as controlled + // by the placement. A drift can occur when a user or a controller on the target cluster makes an inadvertent change + // to a KubeFleet-managed resource. + // + // Available options are: + // * ApplyAnyway: KubeFleet applies the desired state, which might overwrite the drift. + // * ReportError: KubeFleet reports an error and leaves the drift as is. + // + // The default value is ApplyAnyway. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Enum=ApplyAnyway;ReportError + // +kubebuilder:default=ApplyAnyway + WhenDrifted WhenDriftedOption `json:"whenDrifted,omitempty"` + + // The action to take when a resource to be placed already exists on the target cluster side and is not managed + // by KubeFleet. + // + // Available options are: + // * AlwaysTakeOver: KubeFleet takes over the resource by registering itself as an owner of the resource (if + // the resource has no owner or co-ownership is allowed). This enables KubeFleet to adopt the existing resource for + // centralized management. + // * TakeOverIfNoDiff: KubeFleet takes over the resource only if the existing resource reads the same as the desired state + // specified on the hub cluster side. + // * ReportError: KubeFleet reports an error and leaves the existing resource as is. + // + // The default value is ReportError. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Enum=AlwaysTakeOver;TakeOverIfNoDiff;ReportError + // +kubebuilder:default=ReportError + WhenAlreadyExists WhenAlreadyExistsOption `json:"whenAlreadyExists,omitempty"` + + // The action to take on resources managed by a KubeFleet placement when the placement itself is deleted. + // + // Available options are: + // * CleanUpResources: KubeFleet deletes all the resources managed by the placement. + // * OrphanResources: KubeFleet relinquishes ownership of such resources and leaves them as they are on target clusters. + // + // The default value is CleanUpResources. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Enum=CleanUpResources;OrphanResources + // +kubebuilder:default=CleanUpResources + WhenPlacementDeleted WhenPlacementDeletedOption `json:"whenPlacementDeleted,omitempty"` + + // The action to take when a resource to be placed is namespaced but its namespace does not exist on a target cluster. + // + // Available options are: + // * CreateNamespace: KubeFleet creates the namespace on the target cluster. Note that the namespace itself will not be + // managed by KubeFleet, and thus will not be deleted even if the placement itself has been deleted. + // * ReportError: KubeFleet reports an error and does not place the resource to the target cluster. + // + // The default value is CreateNamespace. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Enum=CreateNamespace;ReportError + // +kubebuilder:default=CreateNamespace + WhenNamespaceDoesNotExist WhenNamespaceDoesNotExistOption `json:"whenNamespaceDoesNotExist,omitempty"` + + // How to compare the states between the target cluster side and the hub cluster side, when calculating drifts + // or diffs. + // + // Available options are: + // * PartialComparison: KubeFleet compares only the resource fields that have been explicitly specified on the hub cluster + // side. + // * FullComparison: KubeFleet compares all the fields of a resource, including those that are not specified on + // the hub cluster side. + // + // The default value is PartialComparison. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:Enum=PartialComparison;FullComparison + // +kubebuilder:default=PartialComparison + ComparisonOption ComparisonOption `json:"comparisonOption,omitempty"` +} + +type ApplyMethod string + +const ( + ApplyMethodServerSideApply ApplyMethod = "ServerSideApply" + ApplyMethodClientSideApply ApplyMethod = "ClientSideApply" +) + +type ServerSideApplyOptions struct { + ForceConflicts bool `json:"forceConflicts,omitempty"` +} + +type WhenOwnedByOthersOption string + +const ( + WhenOwnedByOthersOptionShareOwnership WhenOwnedByOthersOption = "ShareOwnership" + WhenOwnedByOthersOptionReportError WhenOwnedByOthersOption = "ReportError" +) + +type WhenDriftedOption string + +const ( + WhenDriftedOptionApplyAnyway WhenDriftedOption = "ApplyAnyway" + WhenDriftedOptionReportError WhenDriftedOption = "ReportError" +) + +type WhenAlreadyExistsOption string + +const ( + WhenAlreadyExistsOptionAlwaysTakeOver WhenAlreadyExistsOption = "AlwaysTakeOver" + WhenAlreadyExistsOptionTakeOverIfNoDiff WhenAlreadyExistsOption = "TakeOverIfNoDiff" + WhenAlreadyExistsOptionReportError WhenAlreadyExistsOption = "ReportError" +) + +type WhenPlacementDeletedOption string + +const ( + WhenPlacementDeletedOptionCleanUpResources WhenPlacementDeletedOption = "CleanUpResources" + WhenPlacementDeletedOptionOrphanResources WhenPlacementDeletedOption = "OrphanResources" +) + +type ComparisonOption string + +const ( + ComparisonOptionPartialComparison ComparisonOption = "PartialComparison" + ComparisonOptionFullComparison ComparisonOption = "FullComparison" +) + +type WhenNamespaceDoesNotExistOption string + +const ( + WhenNamespaceDoesNotExistOptionCreateNamespace WhenNamespaceDoesNotExistOption = "CreateNamespace" + WhenNamespaceDoesNotExistOptionReportError WhenNamespaceDoesNotExistOption = "ReportError" +) + +type Toleration struct { + // The key of the taint that the toleration applies to. + // + // If set to empty, the toleration matches all taint keys; and in this case, the Operator field must be set to Exists. + // This effectively sets the placement to tolerate all taints, regardless of their configuration. + // + // +kubebuilder:validation:Optional + Key string `json:"key,omitempty"` + + // The relationship between the key and value of the taint that the toleration applies to. + // + // Available options are Exists and Equal: + // * Exists: the toleration matches a taint as long as the it has the same key, regardless of its value. + // * Equal: the toleration matches a taint only if it has the same key and value. + // + // If set to Exists, the Value field must be left empty. + // + // Defaults to Equal. + // + // +kubebuilder:default=Equal + // +kubebuilder:validation:Enum=Equal;Exists + // +kubebuilder:validation:Optional + Operator corev1.TolerationOperator `json:"operator,omitempty"` + + // The value of the taint that the toleration applies to. + // + // If the Operator field is set to Exists, this field must be left empty. + // + // +kubebuilder:validation:Optional + Value string `json:"value,omitempty"` + + // The effect of the taint that the toleration applies to. + // + // If set to empty, the toleration matches all taint effects. + // + // Currently the only accepted value is NoSchedule. + // + // +kubebuilder:validation:Enum=NoSchedule + // +kubebuilder:default=NoSchedule + // +kubebuilder:validation:Optional + Effect corev1.TaintEffect `json:"effect,omitempty"` +} + +type PlacementPolicyStatus struct { + // A list of conditions that describe the workload placement. + // +kubebuilder:validation:Optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // The name of the latest revision of the resources selected by this placement policy, in the form of a resource snapshot. + // +kubebuilder:validation:Optional + LatestResourceRevisionName *string `json:"latestResourceRevisionName,omitempty"` + + // The number of clusters that are expected to be selected by this placement. + DesiredClusters *int32 `json:"desiredClusters,omitempty"` + // The number of clusters that have been selected by this placement. + ScheduledClusters *int32 `json:"scheduledClusters,omitempty"` + // The number of clusters that have resources synchronized with their desired state on the hub cluster side. + SynchronizedClusters *int32 `json:"synchronizedClusters,omitempty"` + // The number of clusters that have resources in the available state, as verified by KubeFleet's availability check. + ResourcesAvailableClusters *int32 `json:"resourcesAvailableClusters,omitempty"` + + // The number of ongoing cluster requests that have been submitted by this placement. + OngoingClusterRequests *int32 `json:"ongoingClusterRequests,omitempty"` + + // The binding manager that is currently managing the bindings for this placement. + // +kubebuilder:validation:Optional + BindingManager *BindingManager `json:"bindingManager,omitempty"` +} + +type BindingManager struct { + // A name of the controller that manages the bindings for this placement. + // + // +kubebuilder:validation:Required + ControllerName string `json:"controllerName"` + + // A list of references to the objects that are currently managing the bindings for this placement, + // under the reconciliation of the specified controller. + // + // +kubebuilder:validation:Optional + ObjectRefs []ObjectReference `json:"objectRefs,omitempty"` +} + +// The list objects for the PlacementPolicy and ClusterPlacementPolicy APIs. + +// PlacementPolicyList contains a list of PlacementPolicy. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope="Namespaced" +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type PlacementPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []PlacementPolicy `json:"items"` +} + +// ClusterPlacementPolicyList contains a list of ClusterPlacementPolicy. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope="Cluster" +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ClusterPlacementPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ClusterPlacementPolicy `json:"items"` +} + +// Set up the API types with the scheme builder. +func init() { + SchemeBuilder.Register(&PlacementPolicy{}, &PlacementPolicyList{}) + SchemeBuilder.Register(&ClusterPlacementPolicy{}, &ClusterPlacementPolicyList{}) +} diff --git a/apis/kubefleet.dev/placement/v1alpha1/zz_generated.deepcopy.go b/apis/kubefleet.dev/placement/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 000000000..b5778ee1e --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,556 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2025 The KubeFleet 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. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BindingManager) DeepCopyInto(out *BindingManager) { + *out = *in + if in.ObjectRefs != nil { + in, out := &in.ObjectRefs, &out.ObjectRefs + *out = make([]ObjectReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindingManager. +func (in *BindingManager) DeepCopy() *BindingManager { + if in == nil { + return nil + } + out := new(BindingManager) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterLabelAndPropertySelectorTerm) DeepCopyInto(out *ClusterLabelAndPropertySelectorTerm) { + *out = *in + if in.MatchLabels != nil { + in, out := &in.MatchLabels, &out.MatchLabels + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } + if in.MatchLabelExpressions != nil { + in, out := &in.MatchLabelExpressions, &out.MatchLabelExpressions + *out = make([]LabelClusterPropertyExpression, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.MatchClusterPropertyExpressions != nil { + in, out := &in.MatchClusterPropertyExpressions, &out.MatchClusterPropertyExpressions + *out = make([]LabelClusterPropertyExpression, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterLabelAndPropertySelectorTerm. +func (in *ClusterLabelAndPropertySelectorTerm) DeepCopy() *ClusterLabelAndPropertySelectorTerm { + if in == nil { + return nil + } + out := new(ClusterLabelAndPropertySelectorTerm) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterPlacementPolicy) DeepCopyInto(out *ClusterPlacementPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPlacementPolicy. +func (in *ClusterPlacementPolicy) DeepCopy() *ClusterPlacementPolicy { + if in == nil { + return nil + } + out := new(ClusterPlacementPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterPlacementPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterPlacementPolicyList) DeepCopyInto(out *ClusterPlacementPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterPlacementPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPlacementPolicyList. +func (in *ClusterPlacementPolicyList) DeepCopy() *ClusterPlacementPolicyList { + if in == nil { + return nil + } + out := new(ClusterPlacementPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterPlacementPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterRequest) DeepCopyInto(out *ClusterRequest) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterRequest. +func (in *ClusterRequest) DeepCopy() *ClusterRequest { + if in == nil { + return nil + } + out := new(ClusterRequest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterRequest) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterRequestList) DeepCopyInto(out *ClusterRequestList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterRequest, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterRequestList. +func (in *ClusterRequestList) DeepCopy() *ClusterRequestList { + if in == nil { + return nil + } + out := new(ClusterRequestList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterRequestList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterRequestSpec) DeepCopyInto(out *ClusterRequestSpec) { + *out = *in + if in.PlacementPolicyRef != nil { + in, out := &in.PlacementPolicyRef, &out.PlacementPolicyRef + *out = new(ObjectReference) + **out = **in + } + if in.ClusterSelectorTerms != nil { + in, out := &in.ClusterSelectorTerms, &out.ClusterSelectorTerms + *out = make([]ClusterLabelAndPropertySelectorTerm, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterRequestSpec. +func (in *ClusterRequestSpec) DeepCopy() *ClusterRequestSpec { + if in == nil { + return nil + } + out := new(ClusterRequestSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterRequestStatus) DeepCopyInto(out *ClusterRequestStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ProvisionedClusterName != nil { + in, out := &in.ProvisionedClusterName, &out.ProvisionedClusterName + *out = new(string) + **out = **in + } + if in.LastObservedMostRecentClusterCreationTimestamp != nil { + in, out := &in.LastObservedMostRecentClusterCreationTimestamp, &out.LastObservedMostRecentClusterCreationTimestamp + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterRequestStatus. +func (in *ClusterRequestStatus) DeepCopy() *ClusterRequestStatus { + if in == nil { + return nil + } + out := new(ClusterRequestStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterSelector) DeepCopyInto(out *ClusterSelector) { + *out = *in + if in.Terms != nil { + in, out := &in.Terms, &out.Terms + *out = make([]ClusterLabelAndPropertySelectorTerm, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Count != nil { + in, out := &in.Count, &out.Count + *out = new(intstr.IntOrString) + **out = **in + } + if in.MinCount != nil { + in, out := &in.MinCount, &out.MinCount + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterSelector. +func (in *ClusterSelector) DeepCopy() *ClusterSelector { + if in == nil { + return nil + } + out := new(ClusterSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LabelClusterPropertyExpression) DeepCopyInto(out *LabelClusterPropertyExpression) { + *out = *in + if in.Values != nil { + in, out := &in.Values, &out.Values + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LabelClusterPropertyExpression. +func (in *LabelClusterPropertyExpression) DeepCopy() *LabelClusterPropertyExpression { + if in == nil { + return nil + } + out := new(LabelClusterPropertyExpression) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ObjectReference) DeepCopyInto(out *ObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ObjectReference. +func (in *ObjectReference) DeepCopy() *ObjectReference { + if in == nil { + return nil + } + out := new(ObjectReference) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementPolicy) DeepCopyInto(out *PlacementPolicy) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementPolicy. +func (in *PlacementPolicy) DeepCopy() *PlacementPolicy { + if in == nil { + return nil + } + out := new(PlacementPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PlacementPolicy) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementPolicyList) DeepCopyInto(out *PlacementPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PlacementPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementPolicyList. +func (in *PlacementPolicyList) DeepCopy() *PlacementPolicyList { + if in == nil { + return nil + } + out := new(PlacementPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PlacementPolicyList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementPolicySpec) DeepCopyInto(out *PlacementPolicySpec) { + *out = *in + if in.ClusterSelectors != nil { + in, out := &in.ClusterSelectors, &out.ClusterSelectors + *out = make([]ClusterSelector, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ResourceSelectors != nil { + in, out := &in.ResourceSelectors, &out.ResourceSelectors + *out = make([]ResourceSelector, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.ResourceRevisionHistoryLimit != nil { + in, out := &in.ResourceRevisionHistoryLimit, &out.ResourceRevisionHistoryLimit + *out = new(int32) + **out = **in + } + if in.SyncStrategy != nil { + in, out := &in.SyncStrategy, &out.SyncStrategy + *out = new(SyncStrategy) + (*in).DeepCopyInto(*out) + } + if in.Tolerations != nil { + in, out := &in.Tolerations, &out.Tolerations + *out = make([]Toleration, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementPolicySpec. +func (in *PlacementPolicySpec) DeepCopy() *PlacementPolicySpec { + if in == nil { + return nil + } + out := new(PlacementPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementPolicyStatus) DeepCopyInto(out *PlacementPolicyStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.LatestResourceRevisionName != nil { + in, out := &in.LatestResourceRevisionName, &out.LatestResourceRevisionName + *out = new(string) + **out = **in + } + if in.DesiredClusters != nil { + in, out := &in.DesiredClusters, &out.DesiredClusters + *out = new(int32) + **out = **in + } + if in.ScheduledClusters != nil { + in, out := &in.ScheduledClusters, &out.ScheduledClusters + *out = new(int32) + **out = **in + } + if in.SynchronizedClusters != nil { + in, out := &in.SynchronizedClusters, &out.SynchronizedClusters + *out = new(int32) + **out = **in + } + if in.ResourcesAvailableClusters != nil { + in, out := &in.ResourcesAvailableClusters, &out.ResourcesAvailableClusters + *out = new(int32) + **out = **in + } + if in.OngoingClusterRequests != nil { + in, out := &in.OngoingClusterRequests, &out.OngoingClusterRequests + *out = new(int32) + **out = **in + } + if in.BindingManager != nil { + in, out := &in.BindingManager, &out.BindingManager + *out = new(BindingManager) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementPolicyStatus. +func (in *PlacementPolicyStatus) DeepCopy() *PlacementPolicyStatus { + if in == nil { + return nil + } + out := new(PlacementPolicyStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ResourceSelector) DeepCopyInto(out *ResourceSelector) { + *out = *in + if in.LabelSelector != nil { + in, out := &in.LabelSelector, &out.LabelSelector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ResourceSelector. +func (in *ResourceSelector) DeepCopy() *ResourceSelector { + if in == nil { + return nil + } + out := new(ResourceSelector) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerSideApplyOptions) DeepCopyInto(out *ServerSideApplyOptions) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerSideApplyOptions. +func (in *ServerSideApplyOptions) DeepCopy() *ServerSideApplyOptions { + if in == nil { + return nil + } + out := new(ServerSideApplyOptions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SyncStrategy) DeepCopyInto(out *SyncStrategy) { + *out = *in + if in.ServerSideApplyOptions != nil { + in, out := &in.ServerSideApplyOptions, &out.ServerSideApplyOptions + *out = new(ServerSideApplyOptions) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SyncStrategy. +func (in *SyncStrategy) DeepCopy() *SyncStrategy { + if in == nil { + return nil + } + out := new(SyncStrategy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Toleration) DeepCopyInto(out *Toleration) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Toleration. +func (in *Toleration) DeepCopy() *Toleration { + if in == nil { + return nil + } + out := new(Toleration) + in.DeepCopyInto(out) + return out +} diff --git a/apis/placement/v1alpha1/zz_generated.deepcopy.go b/apis/placement/v1alpha1/zz_generated.deepcopy.go index df9f5e6d7..6d1656d18 100644 --- a/apis/placement/v1alpha1/zz_generated.deepcopy.go +++ b/apis/placement/v1alpha1/zz_generated.deepcopy.go @@ -22,7 +22,7 @@ package v1alpha1 import ( "github.com/kubefleet-dev/kubefleet/apis/placement/v1beta1" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" ) diff --git a/apis/placement/v1beta1/zz_generated.deepcopy.go b/apis/placement/v1beta1/zz_generated.deepcopy.go index b9ff2e710..73d66c8fa 100644 --- a/apis/placement/v1beta1/zz_generated.deepcopy.go +++ b/apis/placement/v1beta1/zz_generated.deepcopy.go @@ -21,7 +21,7 @@ limitations under the License. package v1beta1 import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" ) diff --git a/config/crd/bases/placement.kubefleet.dev_clusterplacementpolicies.yaml b/config/crd/bases/placement.kubefleet.dev_clusterplacementpolicies.yaml new file mode 100644 index 000000000..8b1f91fa3 --- /dev/null +++ b/config/crd/bases/placement.kubefleet.dev_clusterplacementpolicies.yaml @@ -0,0 +1,683 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: clusterplacementpolicies.placement.kubefleet.dev +spec: + group: placement.kubefleet.dev + names: + categories: + - kubefleet + - kubefleet-placement + kind: ClusterPlacementPolicy + listKind: ClusterPlacementPolicyList + plural: clusterplacementpolicies + singular: clusterplacementpolicy + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ClusterPlacementPolicy is the KubeFleet API that enables users to place namespaced and cluster-scoped resources across + member clusters. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The specification of the cluster placement policy. + properties: + clusterSelectors: + description: |- + A list of cluster selectors that specifies the target clusters where KubeFleet should place + the resources. A cluster selector consists of a list of label and cluster property selectors + and a count; and for each cluster selector, KubeFleet will pick `count` number of clusters + that match the given selectors for placing the resources. + + If not specified, KubeFleet will place the resources to all available member clusters. + items: + properties: + count: + anyOf: + - type: integer + - type: string + default: 1 + description: |- + The desired number of clusters that KubeFleet should select based on the given terms. + + The default value is 1. To select all clusters that match the given terms, use the value "All". + pattern: ^([1-9][0-9]{0,2}|All)$ + x-kubernetes-int-or-string: true + minCount: + description: |- + The minimum number of clusters that KubeFleet should select based on the given terms, when KubeFleet is not able + to find the desired number of clusters. + + The default value is set to the same value of `count`, if `count` is an integer. If `count` is set to "All", the + default value of `minCount` is 1. + format: int32 + maximum: 999 + minimum: 0 + type: integer + terms: + description: |- + A list of terms that form the selector. The terms are ORed, i.e., a cluster would match the selector + if it matches any of the terms. + + If not specified, the selector will match all clusters. + items: + properties: + matchClusterPropertyExpressions: + description: A list of cluster property expressions that + a cluster must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is + In or NotIn + rule: '(self.operator == ''In'' || self.operator == + ''NotIn'') ? (has(self.values) && size(self.values) + > 0) : true' + - message: values must be empty when operator is Exists + or DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator + == ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when + operator is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == + ''Lt'' || self.operator == ''Ge'' || self.operator + == ''Le'' || self.operator == ''Eq'' || self.operator + == ''Ne'') ? (has(self.values) && size(self.values) + == 1) : true' + maxItems: 10 + type: array + matchLabelExpressions: + description: A list of label expressions that a cluster + must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is + In or NotIn + rule: '(self.operator == ''In'' || self.operator == + ''NotIn'') ? (has(self.values) && size(self.values) + > 0) : true' + - message: values must be empty when operator is Exists + or DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator + == ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when + operator is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == + ''Lt'' || self.operator == ''Ge'' || self.operator + == ''Le'' || self.operator == ''Eq'' || self.operator + == ''Ne'') ? (has(self.values) && size(self.values) + == 1) : true' + maxItems: 10 + type: array + matchLabels: + additionalProperties: + type: string + description: A list of label key-value pairs that a cluster + must have to match this selector term. + maxProperties: 10 + type: object + type: object + maxItems: 5 + type: array + whenUnfulfilled: + default: RequestCluster + description: |- + The action to take when KubeFleet is not able to find the desired (minimum) number of clusters based on the given terms. + + Available options are: + * RequestCluster: KubeFleet will submit a cluster request to signal that a new cluster is needed to complete the placement. + It is up to the platform/cloud provider to fulfill the request. + * KeepSearching: KubeFleet will keep searching for clusters that match the given terms silently; no cluster request will be + submitted. + + This field takes effect only when cluster requests are enabled in KubeFleet. + enum: + - RequestCluster + - KeepSearching + type: string + type: object + x-kubernetes-validations: + - message: minCount must be less than or equal to count when count + is not All + rule: '!has(self.minCount) || !has(self.count) || (type(self.count) + == string && self.count == ''All'') || (type(self.count) == + int && self.minCount <= self.count) || (type(self.count) == + string && self.count.matches(''^[0-9]+$'') && self.minCount + <= int(self.count))' + maxItems: 10 + minItems: 1 + type: array + resourceRevisionHistoryLimit: + default: 3 + description: |- + The resource revision history limit for this placement policy. + + KubeFleet will snapshot the resources selected by a placement policy; when the resources are + updated and a rollout is triggered on the placement policy, KubeFleet will create a new + revision of the selected resources (in the form of a resource snapshot), which tracks the state of + the selected resources at that point of time. These revisions are kept for auditing and + failure recovery purposes; one can inspect them to see the past state of the selected resources, + or roll back to a previous revision if the latest revision is not working as expected. + + It is also possible to manually request a new resource revision to be created. + + The default value is 3. + format: int32 + maximum: 20 + minimum: 1 + type: integer + resourceSelectors: + description: |- + A list of resource selectors that specifies the resources that KubeFleet should place across + the target clusters. + items: + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + labelSelector: + description: |- + The label selector that selects multiple resources for placement. + + Alternatively, one can use the Name field to select a single resource by its name. + + This field is mutually exclusive with the Name field. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + The name of the resource to select. + + Alternatively, one can use the LabelSelector field to select multiple resources by their labels. + + This field is mutually exclusive with the LabelSelector field. + type: string + namespace: + description: |- + The namespace of the resource to select. + + This field applies only when selecting namespaced resources using the ClusterPlacementPolicy API. + + For usage with the PlacementPolicy API, this field must be set empty or use the same value of the PlacementPolicy's + namespace itself. + type: string + required: + - apiVersion + - kind + type: object + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-validations: + - message: name and labelSelector are mutually exclusive in a resource + selector + rule: self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector))) + syncStrategy: + description: |- + The strategy that KubeFleet uses to synchronize the selected resources to the target clusters. + Set the strategy to configure how selected resources are applied to a target cluster, how to handle + drifts/conflicts when applying the resources, what to do with placed resources when the placement policy + is deleted, and many more. + properties: + applyMethod: + default: ClientSideApply + description: |- + The method KubeFleet uses to apply resources to target clusters. + + Available options are: + * ClientSideApply: KubeFleet applies resources to a target cluster using three-way merge patch, similar + to how the Kubernetes CLI performs a client-side apply. + * ServerSideApply: KubeFleet applies resources to a target cluster using server-side apply, which allows + the API server to manage conflicts and merge changes. + + The default value is ClientSideApply. + enum: + - ClientSideApply + - ServerSideApply + type: string + comparisonOption: + default: PartialComparison + description: |- + How to compare the states between the target cluster side and the hub cluster side, when calculating drifts + or diffs. + + Available options are: + * PartialComparison: KubeFleet compares only the resource fields that have been explicitly specified on the hub cluster + side. + * FullComparison: KubeFleet compares all the fields of a resource, including those that are not specified on + the hub cluster side. + + The default value is PartialComparison. + enum: + - PartialComparison + - FullComparison + type: string + serverSideApplyOptions: + description: |- + The options for running server-side apply ops. This field takes effect only if the apply method is + set to ServerSideApply. + properties: + forceConflicts: + type: boolean + type: object + whenAlreadyExists: + default: ReportError + description: |- + The action to take when a resource to be placed already exists on the target cluster side and is not managed + by KubeFleet. + + Available options are: + * AlwaysTakeOver: KubeFleet takes over the resource by registering itself as an owner of the resource (if + the resource has no owner or co-ownership is allowed). This enables KubeFleet to adopt the existing resource for + centralized management. + * TakeOverIfNoDiff: KubeFleet takes over the resource only if the existing resource reads the same as the desired state + specified on the hub cluster side. + * ReportError: KubeFleet reports an error and leaves the existing resource as is. + + The default value is ReportError. + enum: + - AlwaysTakeOver + - TakeOverIfNoDiff + - ReportError + type: string + whenDrifted: + default: ApplyAnyway + description: |- + The action to take when a resource on the target cluster side has drifted from its desired state as controlled + by the placement. A drift can occur when a user or a controller on the target cluster makes an inadvertent change + to a KubeFleet-managed resource. + + Available options are: + * ApplyAnyway: KubeFleet applies the desired state, which might overwrite the drift. + * ReportError: KubeFleet reports an error and leaves the drift as is. + + The default value is ApplyAnyway. + enum: + - ApplyAnyway + - ReportError + type: string + whenNamespaceDoesNotExist: + default: CreateNamespace + description: |- + The action to take when a resource to be placed is namespaced but its namespace does not exist on a target cluster. + + Available options are: + * CreateNamespace: KubeFleet creates the namespace on the target cluster. Note that the namespace itself will not be + managed by KubeFleet, and thus will not be deleted even if the placement itself has been deleted. + * ReportError: KubeFleet reports an error and does not place the resource to the target cluster. + + The default value is CreateNamespace. + enum: + - CreateNamespace + - ReportError + type: string + whenOwnedByOthers: + default: ReportError + description: |- + How to handle resource co-ownership. This is most relevant when KubeFleet must manage resources that + are already (or expected to be) owned by other non-KubeFleet controllers in target clusters. + + Available options are: + * ShareOwnership: KubeFleet registers itself as a co-owner of the resource. + * ReportError: KubeFleet reports an error when a resource to be placed is already owned by other controllers. + + The default value is ReportError. + enum: + - ShareOwnership + - ReportError + type: string + whenPlacementDeleted: + default: CleanUpResources + description: |- + The action to take on resources managed by a KubeFleet placement when the placement itself is deleted. + + Available options are: + * CleanUpResources: KubeFleet deletes all the resources managed by the placement. + * OrphanResources: KubeFleet relinquishes ownership of such resources and leaves them as they are on target clusters. + + The default value is CleanUpResources. + enum: + - CleanUpResources + - OrphanResources + type: string + type: object + tolerations: + description: |- + The tolerations which allows KubeFleet to synchronize selected resources to tainted target + clusters. + items: + properties: + effect: + default: NoSchedule + description: |- + The effect of the taint that the toleration applies to. + + If set to empty, the toleration matches all taint effects. + + Currently the only accepted value is NoSchedule. + enum: + - NoSchedule + type: string + key: + description: |- + The key of the taint that the toleration applies to. + + If set to empty, the toleration matches all taint keys; and in this case, the Operator field must be set to Exists. + This effectively sets the placement to tolerate all taints, regardless of their configuration. + type: string + operator: + default: Equal + description: |- + The relationship between the key and value of the taint that the toleration applies to. + + Available options are Exists and Equal: + * Exists: the toleration matches a taint as long as the it has the same key, regardless of its value. + * Equal: the toleration matches a taint only if it has the same key and value. + + If set to Exists, the Value field must be left empty. + + Defaults to Equal. + enum: + - Equal + - Exists + type: string + value: + description: |- + The value of the taint that the toleration applies to. + + If the Operator field is set to Exists, this field must be left empty. + type: string + type: object + maxItems: 10 + type: array + x-kubernetes-validations: + - message: operator must be Exists and value must be empty when key + is empty + rule: self.all(t, t.key != '' || (t.operator == 'Exists' && t.value + == '')) + - message: value must be empty when operator is Exists + rule: self.all(t, t.operator != 'Exists' || t.value == '') + required: + - resourceSelectors + type: object + status: + description: The observed status of the cluster placement policy. + properties: + bindingManager: + description: The binding manager that is currently managing the bindings + for this placement. + properties: + controllerName: + description: A name of the controller that manages the bindings + for this placement. + type: string + objectRefs: + description: |- + A list of references to the objects that are currently managing the bindings for this placement, + under the reconciliation of the specified controller. + items: + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + name: + description: The name of the referenced object. + type: string + namespace: + description: |- + The namespace of the referenced object. + + If the object is cluster-scoped, this field should be left empty. + type: string + required: + - apiVersion + - kind + - name + type: object + type: array + required: + - controllerName + type: object + conditions: + description: A list of conditions that describe the workload placement. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + desiredClusters: + description: The number of clusters that are expected to be selected + by this placement. + format: int32 + type: integer + latestResourceRevisionName: + description: The name of the latest revision of the resources selected + by this placement policy, in the form of a resource snapshot. + type: string + ongoingClusterRequests: + description: The number of ongoing cluster requests that have been + submitted by this placement. + format: int32 + type: integer + resourcesAvailableClusters: + description: The number of clusters that have resources in the available + state, as verified by KubeFleet's availability check. + format: int32 + type: integer + scheduledClusters: + description: The number of clusters that have been selected by this + placement. + format: int32 + type: integer + synchronizedClusters: + description: The number of clusters that have resources synchronized + with their desired state on the hub cluster side. + format: int32 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/placement.kubefleet.dev_clusterrequests.yaml b/config/crd/bases/placement.kubefleet.dev_clusterrequests.yaml new file mode 100644 index 000000000..98eca4564 --- /dev/null +++ b/config/crd/bases/placement.kubefleet.dev_clusterrequests.yaml @@ -0,0 +1,306 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: clusterrequests.placement.kubefleet.dev +spec: + group: placement.kubefleet.dev + names: + categories: + - kubefleet + - kubefleet-placement + kind: ClusterRequest + listKind: ClusterRequestList + plural: clusterrequests + singular: clusterrequest + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ClusterRequest is a KubeFleet API that represents a request for a member cluster to be provisioned. + It is created by KubeFleet when it fails to find a member cluster that can fulfill some scheduling + requirements as specified in a PlacementPolicy or ClusterPlacementPolicy object. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The specification of the cluster request. + properties: + clusterSelectorTerms: + description: |- + The cluster selector terms that describe the requirements for a new member cluster. + + If not specified, any member cluster can satisfy the request. + + This field is immutable after creation. + items: + properties: + matchClusterPropertyExpressions: + description: A list of cluster property expressions that a cluster + must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is In or + NotIn + rule: '(self.operator == ''In'' || self.operator == ''NotIn'') + ? (has(self.values) && size(self.values) > 0) : true' + - message: values must be empty when operator is Exists or + DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator == + ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when operator + is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == ''Lt'' + || self.operator == ''Ge'' || self.operator == ''Le'' + || self.operator == ''Eq'' || self.operator == ''Ne'') + ? (has(self.values) && size(self.values) == 1) : true' + maxItems: 10 + type: array + matchLabelExpressions: + description: A list of label expressions that a cluster must + all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is In or + NotIn + rule: '(self.operator == ''In'' || self.operator == ''NotIn'') + ? (has(self.values) && size(self.values) > 0) : true' + - message: values must be empty when operator is Exists or + DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator == + ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when operator + is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == ''Lt'' + || self.operator == ''Ge'' || self.operator == ''Le'' + || self.operator == ''Eq'' || self.operator == ''Ne'') + ? (has(self.values) && size(self.values) == 1) : true' + maxItems: 10 + type: array + matchLabels: + additionalProperties: + type: string + description: A list of label key-value pairs that a cluster + must have to match this selector term. + maxProperties: 10 + type: object + type: object + type: array + x-kubernetes-validations: + - message: the clusterSelectorTerms field is immutable + rule: self == oldSelf + placementPolicyRef: + description: |- + The reference to the placement policy that submits the cluster request. + + This field is immutable after creation. + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + name: + description: The name of the referenced object. + type: string + namespace: + description: |- + The namespace of the referenced object. + + If the object is cluster-scoped, this field should be left empty. + type: string + required: + - apiVersion + - kind + - name + type: object + x-kubernetes-validations: + - message: the placementPolicyRef field is immutable + rule: self == oldSelf + required: + - placementPolicyRef + type: object + status: + description: The observed status of the cluster request. + properties: + conditions: + description: A list of observed conditions of the cluster request. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + lastObservedMostRecentClusterCreationTimestamp: + description: |- + The last observed most recent creation timestamp across all the member clusters. This field is used + as an expedient solution to verify if a cluster request is still valid for consideration, i.e., + if the currently observed most recent cluster creation timestamp is later than this timestamp in the + status, a new member cluster must have been created after the cluster request was created, + and thus the cluster request should be considered stale and can be ignored. The placement policy + that submits the cluster request is responsible for updating this field if the new cluster does not + meet the need of the associated cluster selector, so that the cluster request can be re-evaluated again; + it may instead withdraw the cluster request if the new cluster has fulfilled the associated cluster selector. + format: date-time + type: string + provisionedClusterName: + description: The name of the cluster that has been provisioned for + this cluster request, if any. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/placement.kubefleet.dev_placementpolicies.yaml b/config/crd/bases/placement.kubefleet.dev_placementpolicies.yaml new file mode 100644 index 000000000..972186615 --- /dev/null +++ b/config/crd/bases/placement.kubefleet.dev_placementpolicies.yaml @@ -0,0 +1,683 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: placementpolicies.placement.kubefleet.dev +spec: + group: placement.kubefleet.dev + names: + categories: + - kubefleet + - kubefleet-placement + kind: PlacementPolicy + listKind: PlacementPolicyList + plural: placementpolicies + singular: placementpolicy + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + PlacementPolicy is the KubeFleet API that enables users to place resources within a namespace across + member clusters. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The specification of the placement policy. + properties: + clusterSelectors: + description: |- + A list of cluster selectors that specifies the target clusters where KubeFleet should place + the resources. A cluster selector consists of a list of label and cluster property selectors + and a count; and for each cluster selector, KubeFleet will pick `count` number of clusters + that match the given selectors for placing the resources. + + If not specified, KubeFleet will place the resources to all available member clusters. + items: + properties: + count: + anyOf: + - type: integer + - type: string + default: 1 + description: |- + The desired number of clusters that KubeFleet should select based on the given terms. + + The default value is 1. To select all clusters that match the given terms, use the value "All". + pattern: ^([1-9][0-9]{0,2}|All)$ + x-kubernetes-int-or-string: true + minCount: + description: |- + The minimum number of clusters that KubeFleet should select based on the given terms, when KubeFleet is not able + to find the desired number of clusters. + + The default value is set to the same value of `count`, if `count` is an integer. If `count` is set to "All", the + default value of `minCount` is 1. + format: int32 + maximum: 999 + minimum: 0 + type: integer + terms: + description: |- + A list of terms that form the selector. The terms are ORed, i.e., a cluster would match the selector + if it matches any of the terms. + + If not specified, the selector will match all clusters. + items: + properties: + matchClusterPropertyExpressions: + description: A list of cluster property expressions that + a cluster must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is + In or NotIn + rule: '(self.operator == ''In'' || self.operator == + ''NotIn'') ? (has(self.values) && size(self.values) + > 0) : true' + - message: values must be empty when operator is Exists + or DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator + == ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when + operator is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == + ''Lt'' || self.operator == ''Ge'' || self.operator + == ''Le'' || self.operator == ''Eq'' || self.operator + == ''Ne'') ? (has(self.values) && size(self.values) + == 1) : true' + maxItems: 10 + type: array + matchLabelExpressions: + description: A list of label expressions that a cluster + must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is + In or NotIn + rule: '(self.operator == ''In'' || self.operator == + ''NotIn'') ? (has(self.values) && size(self.values) + > 0) : true' + - message: values must be empty when operator is Exists + or DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator + == ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when + operator is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == + ''Lt'' || self.operator == ''Ge'' || self.operator + == ''Le'' || self.operator == ''Eq'' || self.operator + == ''Ne'') ? (has(self.values) && size(self.values) + == 1) : true' + maxItems: 10 + type: array + matchLabels: + additionalProperties: + type: string + description: A list of label key-value pairs that a cluster + must have to match this selector term. + maxProperties: 10 + type: object + type: object + maxItems: 5 + type: array + whenUnfulfilled: + default: RequestCluster + description: |- + The action to take when KubeFleet is not able to find the desired (minimum) number of clusters based on the given terms. + + Available options are: + * RequestCluster: KubeFleet will submit a cluster request to signal that a new cluster is needed to complete the placement. + It is up to the platform/cloud provider to fulfill the request. + * KeepSearching: KubeFleet will keep searching for clusters that match the given terms silently; no cluster request will be + submitted. + + This field takes effect only when cluster requests are enabled in KubeFleet. + enum: + - RequestCluster + - KeepSearching + type: string + type: object + x-kubernetes-validations: + - message: minCount must be less than or equal to count when count + is not All + rule: '!has(self.minCount) || !has(self.count) || (type(self.count) + == string && self.count == ''All'') || (type(self.count) == + int && self.minCount <= self.count) || (type(self.count) == + string && self.count.matches(''^[0-9]+$'') && self.minCount + <= int(self.count))' + maxItems: 10 + minItems: 1 + type: array + resourceRevisionHistoryLimit: + default: 3 + description: |- + The resource revision history limit for this placement policy. + + KubeFleet will snapshot the resources selected by a placement policy; when the resources are + updated and a rollout is triggered on the placement policy, KubeFleet will create a new + revision of the selected resources (in the form of a resource snapshot), which tracks the state of + the selected resources at that point of time. These revisions are kept for auditing and + failure recovery purposes; one can inspect them to see the past state of the selected resources, + or roll back to a previous revision if the latest revision is not working as expected. + + It is also possible to manually request a new resource revision to be created. + + The default value is 3. + format: int32 + maximum: 20 + minimum: 1 + type: integer + resourceSelectors: + description: |- + A list of resource selectors that specifies the resources that KubeFleet should place across + the target clusters. + items: + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + labelSelector: + description: |- + The label selector that selects multiple resources for placement. + + Alternatively, one can use the Name field to select a single resource by its name. + + This field is mutually exclusive with the Name field. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + name: + description: |- + The name of the resource to select. + + Alternatively, one can use the LabelSelector field to select multiple resources by their labels. + + This field is mutually exclusive with the LabelSelector field. + type: string + namespace: + description: |- + The namespace of the resource to select. + + This field applies only when selecting namespaced resources using the ClusterPlacementPolicy API. + + For usage with the PlacementPolicy API, this field must be set empty or use the same value of the PlacementPolicy's + namespace itself. + type: string + required: + - apiVersion + - kind + type: object + maxItems: 10 + minItems: 1 + type: array + x-kubernetes-validations: + - message: name and labelSelector are mutually exclusive in a resource + selector + rule: self.all(x, !(has(x.name) && size(x.name) > 0 && has(x.labelSelector))) + syncStrategy: + description: |- + The strategy that KubeFleet uses to synchronize the selected resources to the target clusters. + Set the strategy to configure how selected resources are applied to a target cluster, how to handle + drifts/conflicts when applying the resources, what to do with placed resources when the placement policy + is deleted, and many more. + properties: + applyMethod: + default: ClientSideApply + description: |- + The method KubeFleet uses to apply resources to target clusters. + + Available options are: + * ClientSideApply: KubeFleet applies resources to a target cluster using three-way merge patch, similar + to how the Kubernetes CLI performs a client-side apply. + * ServerSideApply: KubeFleet applies resources to a target cluster using server-side apply, which allows + the API server to manage conflicts and merge changes. + + The default value is ClientSideApply. + enum: + - ClientSideApply + - ServerSideApply + type: string + comparisonOption: + default: PartialComparison + description: |- + How to compare the states between the target cluster side and the hub cluster side, when calculating drifts + or diffs. + + Available options are: + * PartialComparison: KubeFleet compares only the resource fields that have been explicitly specified on the hub cluster + side. + * FullComparison: KubeFleet compares all the fields of a resource, including those that are not specified on + the hub cluster side. + + The default value is PartialComparison. + enum: + - PartialComparison + - FullComparison + type: string + serverSideApplyOptions: + description: |- + The options for running server-side apply ops. This field takes effect only if the apply method is + set to ServerSideApply. + properties: + forceConflicts: + type: boolean + type: object + whenAlreadyExists: + default: ReportError + description: |- + The action to take when a resource to be placed already exists on the target cluster side and is not managed + by KubeFleet. + + Available options are: + * AlwaysTakeOver: KubeFleet takes over the resource by registering itself as an owner of the resource (if + the resource has no owner or co-ownership is allowed). This enables KubeFleet to adopt the existing resource for + centralized management. + * TakeOverIfNoDiff: KubeFleet takes over the resource only if the existing resource reads the same as the desired state + specified on the hub cluster side. + * ReportError: KubeFleet reports an error and leaves the existing resource as is. + + The default value is ReportError. + enum: + - AlwaysTakeOver + - TakeOverIfNoDiff + - ReportError + type: string + whenDrifted: + default: ApplyAnyway + description: |- + The action to take when a resource on the target cluster side has drifted from its desired state as controlled + by the placement. A drift can occur when a user or a controller on the target cluster makes an inadvertent change + to a KubeFleet-managed resource. + + Available options are: + * ApplyAnyway: KubeFleet applies the desired state, which might overwrite the drift. + * ReportError: KubeFleet reports an error and leaves the drift as is. + + The default value is ApplyAnyway. + enum: + - ApplyAnyway + - ReportError + type: string + whenNamespaceDoesNotExist: + default: CreateNamespace + description: |- + The action to take when a resource to be placed is namespaced but its namespace does not exist on a target cluster. + + Available options are: + * CreateNamespace: KubeFleet creates the namespace on the target cluster. Note that the namespace itself will not be + managed by KubeFleet, and thus will not be deleted even if the placement itself has been deleted. + * ReportError: KubeFleet reports an error and does not place the resource to the target cluster. + + The default value is CreateNamespace. + enum: + - CreateNamespace + - ReportError + type: string + whenOwnedByOthers: + default: ReportError + description: |- + How to handle resource co-ownership. This is most relevant when KubeFleet must manage resources that + are already (or expected to be) owned by other non-KubeFleet controllers in target clusters. + + Available options are: + * ShareOwnership: KubeFleet registers itself as a co-owner of the resource. + * ReportError: KubeFleet reports an error when a resource to be placed is already owned by other controllers. + + The default value is ReportError. + enum: + - ShareOwnership + - ReportError + type: string + whenPlacementDeleted: + default: CleanUpResources + description: |- + The action to take on resources managed by a KubeFleet placement when the placement itself is deleted. + + Available options are: + * CleanUpResources: KubeFleet deletes all the resources managed by the placement. + * OrphanResources: KubeFleet relinquishes ownership of such resources and leaves them as they are on target clusters. + + The default value is CleanUpResources. + enum: + - CleanUpResources + - OrphanResources + type: string + type: object + tolerations: + description: |- + The tolerations which allows KubeFleet to synchronize selected resources to tainted target + clusters. + items: + properties: + effect: + default: NoSchedule + description: |- + The effect of the taint that the toleration applies to. + + If set to empty, the toleration matches all taint effects. + + Currently the only accepted value is NoSchedule. + enum: + - NoSchedule + type: string + key: + description: |- + The key of the taint that the toleration applies to. + + If set to empty, the toleration matches all taint keys; and in this case, the Operator field must be set to Exists. + This effectively sets the placement to tolerate all taints, regardless of their configuration. + type: string + operator: + default: Equal + description: |- + The relationship between the key and value of the taint that the toleration applies to. + + Available options are Exists and Equal: + * Exists: the toleration matches a taint as long as the it has the same key, regardless of its value. + * Equal: the toleration matches a taint only if it has the same key and value. + + If set to Exists, the Value field must be left empty. + + Defaults to Equal. + enum: + - Equal + - Exists + type: string + value: + description: |- + The value of the taint that the toleration applies to. + + If the Operator field is set to Exists, this field must be left empty. + type: string + type: object + maxItems: 10 + type: array + x-kubernetes-validations: + - message: operator must be Exists and value must be empty when key + is empty + rule: self.all(t, t.key != '' || (t.operator == 'Exists' && t.value + == '')) + - message: value must be empty when operator is Exists + rule: self.all(t, t.operator != 'Exists' || t.value == '') + required: + - resourceSelectors + type: object + status: + description: The observed status of the placement policy. + properties: + bindingManager: + description: The binding manager that is currently managing the bindings + for this placement. + properties: + controllerName: + description: A name of the controller that manages the bindings + for this placement. + type: string + objectRefs: + description: |- + A list of references to the objects that are currently managing the bindings for this placement, + under the reconciliation of the specified controller. + items: + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + name: + description: The name of the referenced object. + type: string + namespace: + description: |- + The namespace of the referenced object. + + If the object is cluster-scoped, this field should be left empty. + type: string + required: + - apiVersion + - kind + - name + type: object + type: array + required: + - controllerName + type: object + conditions: + description: A list of conditions that describe the workload placement. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + desiredClusters: + description: The number of clusters that are expected to be selected + by this placement. + format: int32 + type: integer + latestResourceRevisionName: + description: The name of the latest revision of the resources selected + by this placement policy, in the form of a resource snapshot. + type: string + ongoingClusterRequests: + description: The number of ongoing cluster requests that have been + submitted by this placement. + format: int32 + type: integer + resourcesAvailableClusters: + description: The number of clusters that have resources in the available + state, as verified by KubeFleet's availability check. + format: int32 + type: integer + scheduledClusters: + description: The number of clusters that have been selected by this + placement. + format: int32 + type: integer + synchronizedClusters: + description: The number of clusters that have resources synchronized + with their desired state on the hub cluster side. + format: int32 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/test/apis/v1alpha1/zz_generated.deepcopy.go b/test/apis/v1alpha1/zz_generated.deepcopy.go index 143bdee7b..081bec913 100644 --- a/test/apis/v1alpha1/zz_generated.deepcopy.go +++ b/test/apis/v1alpha1/zz_generated.deepcopy.go @@ -21,7 +21,7 @@ limitations under the License. package v1alpha1 import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) From f20815d7882b0fbd45215bd5b73ceada52cb2636 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:23:29 -0700 Subject: [PATCH 07/23] chore: bump github/codeql-action/analyze from 4.35.4 to 4.37.5 (#801) * chore: bump github/codeql-action/analyze from 4.35.4 to 4.37.5 Bumps [github/codeql-action/analyze](https://github.com/github/codeql-action) from 4.35.4 to 4.37.5. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/68bde559dea0fdcac2102bfdf6230c5f70eb485e...d1ba80a13dd99fba24a470575428917156a28b43) --- updated-dependencies: - dependency-name: github/codeql-action/analyze dependency-version: 4.37.5 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * chore: bump codeql init and autobuild to v4.37.5 pin Co-authored-by: michaelawyu <14261500+michaelawyu@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: michaelawyu <14261500+michaelawyu@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index dc1667315..0b77d67c2 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4 + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4 + uses: github/codeql-action/autobuild@d1ba80a13dd99fba24a470575428917156a28b43 # v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4 + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4 From e52eff2efc6b459fe7770fb8b5cc610307aac4ff Mon Sep 17 00:00:00 2001 From: Yetkin Timocin Date: Wed, 12 Aug 2026 17:35:26 -0700 Subject: [PATCH 08/23] ci: automate cherry-picks to release branches via cherry-pick/0.Y labels (#777) * ci: automate cherry-picks to release branches via cherry-pick/0.Y labels Adds a backport workflow: when a merged PR carries a cherry-pick/0.Y label (added before or after merge), the workflow cherry-picks the squash commit onto release-0.Y and opens a backport PR. Explicit policy, also documented in CONTRIBUTING.md: - squash merges only: merge commits and multi-commit rebases are skipped with a comment asking for a manual backport - conflicts are never pushed: the pick is aborted and manual instructions are commented on the original PR - bot-owned cherry-pick/0.Y/pr-N branches are force-pushed so re-labeling retries idempotently Part of the Phase 1 release-process revamp (#693). Co-Authored-By: Claude Fable 5 Signed-off-by: Yetkin Timocin * ci: resolve github-actions[bot] user ID dynamically for backport commits Resolve the bot user ID from the API instead of hardcoding it, with a numeric-validated fallback to the known ID (41898282): gh api prints the error body to stdout on failure, so a plain || fallback would corrupt the value. Co-Authored-By: Claude Fable 5 Signed-off-by: Yetkin Timocin * ci: soft-fail on missing release branch and fix title-suffix comment Address review feedback: - A missing release-0.Y branch now comments and keeps the run green (matching the documented soft-fail behavior) instead of exiting 1: labels may legitimately be applied before the release branch is cut. - Correct the comment that claimed the target is prefixed to the PR title; it is suffixed because PR-title lint requires the conventional prefix at the start. Co-Authored-By: Claude Fable 5 Signed-off-by: Yetkin Timocin --------- Signed-off-by: Yetkin Timocin Co-authored-by: Claude Fable 5 --- .github/workflows/backport.yml | 189 +++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 29 +++++ 2 files changed, 218 insertions(+) create mode 100644 .github/workflows/backport.yml diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 000000000..3bb6a659e --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,189 @@ +name: Backport + +# Opens a backport pull request against `release-0.Y` when a merged pull +# request carries a `cherry-pick/0.Y` label. The label can be added before or +# after the merge; adding it to an already-merged PR triggers the backport +# immediately. +# +# Policy (see also CONTRIBUTING.md, "Backporting to release branches"): +# +# * Squash merges only. The automation cherry-picks the single squash commit +# recorded as the PR's merge commit. A PR merged with a merge commit, or a +# multi-commit PR merged by rebase, is NOT backported automatically - the +# workflow leaves a comment asking for a manual backport instead. This repo +# squash-merges by convention, so this only matters for exceptions. +# +# * Conflicts are never pushed. If the cherry-pick does not apply cleanly the +# workflow aborts the pick and comments on the original PR with the exact +# commands for a manual backport. It never opens a PR containing conflict +# markers. +# +# * Backport branches are bot-owned and force-pushed. The automation owns +# `cherry-pick/0.Y/pr-` branches and force-pushes them on re-runs so the +# operation is idempotent (re-labeling retries a failed backport). Do not +# push manual work to these branches; use your own branch for manual +# backports. +# +# The cherry-pick keeps the original commit message, including the author's +# Signed-off-by line (DCO), and appends the "(cherry picked from commit ...)" +# trailer via `git cherry-pick -x`. +# +# NOTE: pull_request_target grants a write token, so this workflow must never +# check out or execute code from the PR. It only manipulates git history +# (cherry-pick of an already-merged commit) and calls the GitHub API. All +# PR-controlled strings (title, label names) are passed through environment +# variables, never interpolated into shell text. + +on: + pull_request_target: + types: [closed, labeled] + +permissions: + contents: write + pull-requests: write + +# One backport run per PR at a time: a `closed` event and a late `labeled` +# event for the same PR must not race on the same bot branch. +concurrency: + group: backport-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + backport: + # Run only for merged PRs, and only when the event can introduce a + # cherry-pick label: the merge itself, or a cherry-pick/* label added + # to an already-merged PR. + if: > + github.event.pull_request.merged == true && + (github.event.action == 'closed' || + startsWith(github.event.label.name, 'cherry-pick/')) + runs-on: ubuntu-latest + steps: + - name: Checkout base repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + # Full history: the merge commit and the release branches must both + # be reachable for the cherry-pick. + fetch-depth: 0 + # Deliberately the default ref (base repo main), never the PR head. + persist-credentials: true + + - name: Cherry-pick to release branches + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + EVENT_ACTION: ${{ github.event.action }} + EVENT_LABEL: ${{ github.event.label.name || '' }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + + # Commit as the github-actions[bot] app user so backport commits are + # attributed to the automation (the original author and their DCO + # sign-off are preserved by the cherry-pick). The user ID is resolved + # from the API so the noreply address is provably correct; on API + # failure fall back to the app user's long-stable known ID. Note that + # gh prints the error body to stdout on failure, hence the numeric + # guard rather than a plain `|| echo`. + bot_id="$(gh api 'users/github-actions%5Bbot%5D' --jq .id 2>/dev/null || true)" + case "${bot_id}" in + ''|*[!0-9]*) bot_id=41898282 ;; + esac + git config user.name "github-actions[bot]" + git config user.email "${bot_id}+github-actions[bot]@users.noreply.github.com" + + # Collect the cherry-pick labels to act on: just the added label for + # a `labeled` event, every cherry-pick label on the PR for `closed`. + if [ "${EVENT_ACTION}" = "labeled" ]; then + labels="${EVENT_LABEL}" + else + labels="$(gh api "repos/${REPO}/issues/${PR_NUMBER}/labels" \ + --jq '.[].name | select(startswith("cherry-pick/"))')" + fi + if [ -z "${labels}" ]; then + echo "No cherry-pick/* labels on PR #${PR_NUMBER}; nothing to do." + exit 0 + fi + + comment() { + gh pr comment "${PR_NUMBER}" --repo "${REPO}" --body "$1" + } + + # Backport only squash commits (single parent) whose subject carries + # this PR's number - the shape every squash-merged PR in this repo + # has. This rejects merge commits outright and refuses to guess on + # rebase-merged multi-commit PRs, where picking only the merge SHA + # would silently drop the earlier commits. + parent_count="$(git rev-list --parents -n 1 "${MERGE_SHA}" | wc -w)" + subject="$(git log --format=%s -n 1 "${MERGE_SHA}")" + if [ "${parent_count}" -ne 2 ] || ! grep -q "(#${PR_NUMBER})" <<<"${subject}"; then + comment ":no_entry: Automatic backport skipped: PR #${PR_NUMBER} was not squash-merged (or its merge commit does not reference the PR), so the merge commit cannot be cherry-picked safely. Please backport manually." + exit 0 + fi + + mapfile -t label_list <<<"${labels}" + failed="" + for label in "${label_list[@]}"; do + [ -n "${label}" ] || continue + minor="${label#cherry-pick/}" + target="release-${minor}" + bot_branch="cherry-pick/${minor}/pr-${PR_NUMBER}" + + # Soft-fail (comment, but keep the run green): labels may + # legitimately be applied before the release branch is cut; the + # backport is picked up by re-adding the label once it exists. + if ! git rev-parse --verify --quiet "origin/${target}" >/dev/null; then + comment ":no_entry: Backport to \`${target}\` skipped: the branch does not exist. If the \`${label}\` label is correct, create the release branch first and re-add the label to retry." + continue + fi + + echo "Backporting ${MERGE_SHA} to ${target} (label: ${label})" + git switch --force-create "${bot_branch}" "origin/${target}" + + if ! git cherry-pick -x "${MERGE_SHA}"; then + git cherry-pick --abort || true + comment ":warning: Backport to \`${target}\` failed: the cherry-pick has conflicts. Please backport manually: + + \`\`\` + git fetch origin + git switch -c backport-${PR_NUMBER}-to-${target} origin/${target} + git cherry-pick -x ${MERGE_SHA} + # resolve conflicts, then + git cherry-pick --continue + git push origin backport-${PR_NUMBER}-to-${target} + \`\`\` + + Re-adding the \`${label}\` label retries the automatic backport." + failed="true" + continue + fi + + # Bot-owned branch: force-push so retries are idempotent. + git push --force origin "${bot_branch}" + + # Reuse the open backport PR for this branch if one exists. + existing="$(gh pr list --repo "${REPO}" --head "${bot_branch}" \ + --base "${target}" --state open --json number --jq '.[0].number // empty')" + if [ -n "${existing}" ]; then + echo "Backport PR #${existing} already open for ${bot_branch}; branch updated." + continue + fi + + # Suffix the target rather than prefixing it: PR-title lint runs on + # backport PRs too and requires the conventional prefix (feat:, + # fix:, ...) at the start of the title. + title="$(gh pr view "${PR_NUMBER}" --repo "${REPO}" --json title --jq .title)" + url="$(gh pr create --repo "${REPO}" \ + --base "${target}" --head "${bot_branch}" \ + --title "${title} [backport ${target}]" \ + --body "Automated cherry-pick of #${PR_NUMBER} to \`${target}\`, requested via the \`${label}\` label. + + > [!NOTE] + > Workflows do not run automatically on PRs opened by github-actions; a maintainer may need to close and reopen this PR (or push an empty commit) to trigger CI.")" + comment ":cherries: Backport to \`${target}\` opened: ${url}" + done + + if [ -n "${failed}" ]; then + exit 1 + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e8ba8dd4..3de2f5c1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,3 +63,32 @@ Additive labels (stack on top of the above when applicable): - `ignore-for-release` — hides the PR entirely from auto-generated notes. Default to this for CI-only or internal-cleanup PRs with no user impact. PRs with no `release-note/*` label fall into "Other Changes" in the generated notes. Dependabot PRs are labeled `dependencies` automatically and land under "Maintenance and Dependencies" without a `release-note/*` label. + +## Backporting to release branches + +Fixes that must land in a supported release (see the support window in +[SECURITY.md](SECURITY.md)) are backported by cherry-picking the squash commit +from `main` onto the matching `release-0.Y` branch. Backports are automated by +[`backport.yml`](.github/workflows/backport.yml): + +1. Merge the fix to `main` first. Backports are always cherry-picks of a commit + already on `main`, never direct PRs against a release branch. +2. Add a `cherry-pick/0.Y` label to the PR — before or after the merge, one + label per target minor. On merge (or on labeling an already-merged PR), the + automation opens a backport PR against `release-0.Y`. + +The automation follows three explicit rules: + +- **Squash merges only.** It cherry-picks the PR's single squash commit. PRs + merged any other way (merge commit, multi-commit rebase) are skipped with a + comment and must be backported manually. +- **Conflicts are never pushed.** If the pick does not apply cleanly, it aborts + and comments manual instructions on the original PR; it never opens a PR with + conflict markers. Re-adding the label retries after you resolve the cause. +- **Bot branches are force-pushed.** `cherry-pick/0.Y/pr-` branches belong to + the automation and are overwritten on retries — do manual backports on your + own branch, not on a bot branch. + +Backport PRs keep the original commit's `Signed-off-by` (DCO) and gain a +`(cherry picked from commit ...)` trailer. Merging the backport PR into +`release-0.Y` is still subject to the usual review and CI gates. From 3dfd9dd706aa67505bfae8aa01c37ca8e85822f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:00:50 +1000 Subject: [PATCH 09/23] chore: bump tcort/github-action-markdown-link-check from 1.1.2 to 1.1.3 (#815) --- .github/workflows/markdown-lint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml index 9cfb00bb2..5051e3f67 100644 --- a/.github/workflows/markdown-lint.yml +++ b/.github/workflows/markdown-lint.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: tcort/github-action-markdown-link-check@e7c7a18363c842693fadde5d41a3bd3573a7a225 # v1 + - uses: tcort/github-action-markdown-link-check@e047c5b37f24ab722bbef1a27b6fab7f96bc4068 # v1 with: # this will only show errors in the output use-quiet-mode: 'yes' From 12c3d87dc8e0e2847c4cacba4d7fd703e6678284 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:01:15 +1000 Subject: [PATCH 10/23] chore: bump codecov/codecov-action from 6.0.1 to 7.0.0 (#816) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 688605d07..ce13e0dfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,7 +77,7 @@ jobs: KUBEFLEET_CI_TEST_RUNNER_NAME: 'ginkgo' - name: Upload Codecov report - uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1 + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: ## Repository upload token - get it from codecov.io. Required only for private repositories token: ${{ secrets.CODECOV_TOKEN }} From bcf6eb8a839f94e08fddafa41d67af25474baa7a Mon Sep 17 00:00:00 2001 From: michaelawyu Date: Thu, 13 Aug 2026 21:56:26 +0800 Subject: [PATCH 11/23] interface: [FEP-0001] add API definition for the Placement Resource Snapshot, Placement Binding, and Work API objects (#802) --- .../v1alpha1/placementbinding_types.go | 221 ++++++ .../placementresourcesnapshot_types.go | 118 ++++ .../placement/v1alpha1/work_types.go | 204 ++++++ .../v1alpha1/zz_generated.deepcopy.go | 627 +++++++++++++++++- ...ubefleet.dev_clusterplacementbindings.yaml | 582 ++++++++++++++++ ...dev_clusterplacementresourcesnapshots.yaml | 102 +++ ...ement.kubefleet.dev_placementbindings.yaml | 582 ++++++++++++++++ ...efleet.dev_placementresourcesnapshots.yaml | 102 +++ .../bases/placement.kubefleet.dev_works.yaml | 395 +++++++++++ 9 files changed, 2932 insertions(+), 1 deletion(-) create mode 100644 apis/kubefleet.dev/placement/v1alpha1/placementbinding_types.go create mode 100644 apis/kubefleet.dev/placement/v1alpha1/placementresourcesnapshot_types.go create mode 100644 apis/kubefleet.dev/placement/v1alpha1/work_types.go create mode 100644 config/crd/bases/placement.kubefleet.dev_clusterplacementbindings.yaml create mode 100644 config/crd/bases/placement.kubefleet.dev_clusterplacementresourcesnapshots.yaml create mode 100644 config/crd/bases/placement.kubefleet.dev_placementbindings.yaml create mode 100644 config/crd/bases/placement.kubefleet.dev_placementresourcesnapshots.yaml create mode 100644 config/crd/bases/placement.kubefleet.dev_works.yaml diff --git a/apis/kubefleet.dev/placement/v1alpha1/placementbinding_types.go b/apis/kubefleet.dev/placement/v1alpha1/placementbinding_types.go new file mode 100644 index 000000000..39e2c21b8 --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/placementbinding_types.go @@ -0,0 +1,221 @@ +/* +Copyright 2026 The KubeFleet 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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// The condition types for the PlacementBinding and ClusterPlacementBinding APIs. +const ( + PlacementBindingCondTypeSynchronized = "Synchronized" + PlacementBindingCondTypeAvailable = "Available" +) + +// The reasons for each condition type of the PlacementBinding and ClusterPlacementBinding APIs. +const ( + PlacementBindingSynchronizedCondReasonAllResourcesSynchronized = "AllResourcesSynchronized" + PlacementBindingSynchronizedCondReasonFailedToSynchronizeSomeResources = "FailedToSynchronizeSomeResources" + + PlacementBindingAvailableCondReasonAllResourcesAvailable = "AllResourcesAvailable" + PlacementBindingAvailableCondReasonSomeResourcesUnavailable = "SomeResourcesUnavailable" +) + +// PlacementBinding is the KubeFleet API that binds the resources selected by a placement +// policy to a specific member cluster. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced,categories={kubefleet, kubefleet-placement} +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type PlacementBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The specification of the binding. + // + // +kubebuilder:validation:Required + Spec PlacementBindingSpec `json:"spec"` + + // The observed status of the binding. + // + // +kubebuilder:validation:Optional + Status PlacementBindingStatus `json:"status,omitempty"` +} + +// ClusterPlacementBinding is the KubeFleet API that binds the resources selected by a cluster placement +// policy to a specific member cluster. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster,categories={kubefleet, kubefleet-placement} +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ClusterPlacementBinding struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The specification of the binding. + // + // +kubebuilder:validation:Required + Spec PlacementBindingSpec `json:"spec"` + + // The observed status of the binding. + // + // +kubebuilder:validation:Optional + Status PlacementBindingStatus `json:"status,omitempty"` +} + +type PlacementBindingSpec struct { + // The name of the placement policy that this binding is associated with. + // + // +kubebuilder:validation:Required + PlacementPolicyName string `json:"placementPolicyName"` + + // The cluster selectors associated with (fulfilled by) this binding. + // + // This field is added for informational purposes only. KubeFleet uses hashes of these cluster selectors + // (kept in the annotations) to determine which cluster selectors the binding is associated with. + // + // +kubebuilder:validation:Optional + ClusterSelectors []ClusterSelectorWithTermsOnly `json:"clusterSelectors,omitempty"` + + // The name of the member cluster that this binding is associated with. + // + // +kubebuilder:validation:Required + ClusterName string `json:"clusterName"` + + // The name of the resource snapshot that this binding is associated with. + // + // If the resources being selected cannot fit within a single resource snapshot, this field tracks + // the name of the primary resource snapshot that this binding is associated with. + // + // +kubebuilder:validation:Required + ResourceSnapshotName string `json:"resourceSnapshotName"` + + // The strategy to synchronize the resources to the member cluster. + // + // +kubebuilder:validation:Optional + SyncStrategy *SyncStrategy `json:"syncStrategy,omitempty"` + + // Whether the binding is suspended. If set to true, KubeFleet will remove resources from the associated cluster. + // + // +kubebuilder:validation:Optional + // +kubebuilder:default=false + Suspended bool `json:"suspended,omitempty"` +} + +type ClusterSelectorWithTermsOnly struct { + // The terms that describe the requirements for a target cluster in the cluster selector. + // + // +kubebuilder:validation:Optional + Terms []ClusterLabelAndPropertySelectorTerm `json:"terms,omitempty"` +} + +type PlacementBindingStatus struct { + // A list of observed conditions about the binding. + // + // +kubebuilder:validation:Optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // The number of resources that are included in the currently associated resource snapshot(s). + // + // +kubebuilder:validation:Optional + SelectedResources *int32 `json:"selectedResources,omitempty"` + // The number of selected resources that have been synchronized to the target cluster. + // + // A resource is considered synchronized if it has been successfully created (applied) in the target cluster. + // + // +kubebuilder:validation:Optional + SynchronizedResources *int32 `json:"synchronizedResources,omitempty"` + // The number of selected resources that are available in the target cluster. + // + // A resource is considered available if it has been successfully created (applied) in the target cluster and + // has passed KubeFleet's built-in availability checks (if applicable). + // + // +kubebuilder:validation:Optional + AvailableResources *int32 `json:"availableResources,omitempty"` + + // A list of resources that have failed to be synchronized to the target cluster, or have failed to become + // available in the target cluster. + // + // If there are more than 50 failed resources, only the first 50 will be included in this list. + // + // +kubebuilder:validation:Optional + // +kubebuilder:validation:MaxItems=50 + FailedResources []FailedResource `json:"failedResources,omitempty"` +} + +type FailedResource struct { + // The object reference of the failed resource. + // + // +kubebuilder:validation:Required + ObjectRef ObjectReference `json:"objectRef"` + + // A list of observed conditions about the failed resource. + // + // +kubebuilder:validation:Optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // The details about observed diffs between the resource on the hub cluster and on the member cluster side, if any. + // This field is populated when drift detection is enabled and the resource is of a drifted state, + // or when diff check upon takeovers is enabled and diffs have been detected on the resource to take over. + // + // +kubebuilder:validation:Optional + DiffDetails *DiffDetails `json:"diffDetails,omitempty"` +} + +// The list objects for the PlacementBinding and ClusterPlacementBinding APIs. + +// PlacementBindingList contains a list of PlacementBinding. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type PlacementBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []PlacementBinding `json:"items"` +} + +// ClusterPlacementBindingList contains a list of ClusterPlacementBinding. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ClusterPlacementBindingList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ClusterPlacementBinding `json:"items"` +} + +// Set up the API types with the scheme builder. +func init() { + SchemeBuilder.Register(&PlacementBinding{}, &PlacementBindingList{}) + SchemeBuilder.Register(&ClusterPlacementBinding{}, &ClusterPlacementBindingList{}) +} diff --git a/apis/kubefleet.dev/placement/v1alpha1/placementresourcesnapshot_types.go b/apis/kubefleet.dev/placement/v1alpha1/placementresourcesnapshot_types.go new file mode 100644 index 000000000..0f2d284d7 --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/placementresourcesnapshot_types.go @@ -0,0 +1,118 @@ +/* +Copyright 2026 The KubeFleet 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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// PlacementResourceSnapshot is the KubeFleet API that captures the resources selected by a placement policy +// as seen on the hub cluster at a specific point in time. It is referenced by other KubeFleet APIs +// to enable consistent rollouts of resources across multiple member clusters in the fleet. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced,categories={kubefleet, kubefleet-placement} +// +kubebuilder:storageversion +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type PlacementResourceSnapshot struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The spec of a placement resource snapshot. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="the spec field is immutable" + Spec PlacementResourceSnapshotSpec `json:"spec"` +} + +type PlacementResourceSnapshotSpec struct { + // The manifests of the resources selected by the owner placement policy at the time of snapshot creation. + // + // +kubebuilder:validation:Optional + Resources []SnapshottedResource `json:"resources,omitempty"` +} + +type SnapshottedResource struct { + // The identifier of the resource. + // + // +kubebuilder:validation:Required + Identifier ObjectReference `json:"identifier"` + + // The manifest of the resource. It should be a Kubernetes object in YAML or JSON format. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:EmbeddedResource + // +kubebuilder:pruning:PreserveUnknownFields + Manifest runtime.RawExtension `json:"manifest"` + + // Additional information associated with the resource, if any. + // + // +kubebuilder:validation:Optional + AdditionalInfo map[string][]byte `json:"additionalInfo,omitempty"` +} + +// ClusterPlacementResourceSnapshot is the KubeFleet API that captures the resources selected by +// a cluster placement policy as seen on the hub cluster at a specific point in time. It is referenced +// by other KubeFleet APIs to enable consistent rollouts of resources across multiple member clusters in the fleet. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster,categories={kubefleet, kubefleet-placement} +// +kubebuilder:storageversion +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ClusterPlacementResourceSnapshot struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The spec of a cluster placement resource snapshot. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="the spec field is immutable" + Spec PlacementResourceSnapshotSpec `json:"spec"` +} + +// The list objects for the PlacementResourceSnapshot and ClusterPlacementResourceSnapshot APIs. + +// PlacementResourceSnapshotList contains a list of PlacementResourceSnapshot objects. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type PlacementResourceSnapshotList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []PlacementResourceSnapshot `json:"items"` +} + +// ClusterPlacementResourceSnapshotList contains a list of ClusterPlacementResourceSnapshot objects. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type ClusterPlacementResourceSnapshotList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []ClusterPlacementResourceSnapshot `json:"items"` +} + +// Set up the API types with the scheme builder. +func init() { + SchemeBuilder.Register(&PlacementResourceSnapshot{}, &PlacementResourceSnapshotList{}) + SchemeBuilder.Register(&ClusterPlacementResourceSnapshot{}, &ClusterPlacementResourceSnapshotList{}) +} diff --git a/apis/kubefleet.dev/placement/v1alpha1/work_types.go b/apis/kubefleet.dev/placement/v1alpha1/work_types.go new file mode 100644 index 000000000..f282ab694 --- /dev/null +++ b/apis/kubefleet.dev/placement/v1alpha1/work_types.go @@ -0,0 +1,204 @@ +/* +Copyright 2026 The KubeFleet 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 ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + // The condition types for the Work API. + WorkCondTypeApplied = "Applied" + WorkCondTypeAvailable = "Available" + + // The condition types for each manifest in the Work API. + ManifestCondTypeApplied = "Applied" + ManifestCondTypeAvailable = "Available" +) + +// Work is the KubeFleet API used for synchronizing resources to place between +// the hub cluster and a member cluster in the member cluster reserved namespace. +// +// +genclient +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced,categories={kubefleet, kubefleet-placement} +// +kubebuilder:storageversion +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type Work struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // The specification of the work object. + // + // +kubebuilder:validation:Required + Spec WorkSpec `json:"spec"` + + // The observed status of the work object. + // + // +kubebuilder:validation:Optional + Status WorkStatus `json:"status,omitempty"` +} + +type WorkSpec struct { + // The manifests of the resources to be synchronized to the member cluster. + // + // +kubebuilder:validation:Optional + Manifests []Manifest `json:"manifests,omitempty"` + + // The strategy to synchronize the resources to the member cluster. + // + // +kubebuilder:validation:Optional + SyncStrategy *SyncStrategy `json:"syncStrategy,omitempty"` +} + +type Manifest struct { + // The manifest data. + // + // +kubebuilder:validation:EmbeddedResource + // +kubebuilder:pruning:PreserveUnknownFields + runtime.RawExtension `json:",inline"` +} + +type WorkStatus struct { + // A list of observed conditions of the work object. + // + // +kubebuilder:validation:Optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // The observed status of each manifest in the work object. + // + // +kubebuilder:validation:Optional + Manifests []PerManifestStatus `json:"manifests,omitempty"` +} + +type PerManifestStatus struct { + // The identifier of the resource represented by the manifest. + // + // +kubebuilder:validation:Required + Identifier ManifestIdentifier `json:"identifier,omitempty"` + + // A list of observed conditions of the manifest. + // + // +kubebuilder:validation:Optional + // +patchMergeKey=type + // +patchStrategy=merge + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // The details about observed diffs between the resource on the hub cluster and on the member cluster side, if any. + // This field is populated when drift detection is enabled and the resource is of a drifted state, + // or when diff check upon takeovers is enabled and diffs have been detected on the resource to take over. + // + // +kubebuilder:validation:Optional + DiffDetails *DiffDetails `json:"diffDetails,omitempty"` +} + +type DiffDetails struct { + // The generation of the resource, as seen on the member cluster side. + // + // If set to nil, the resource has not been created yet on the member cluster. + // + // +kubebuilder:validation:Optional + ObservedInMemberClusterGeneration *int64 `json:"observedInMemberClusterGeneration,omitempty"` + + // The timestamp when the diffs are first detected. + // + // +kubebuilder:validation:Required + // +kubebuilder:validation:Type=string + // +kubebuilder:validation:Format=date-time + FirstDiffedObservedTimestamp metav1.Time `json:"firstDiffedObservedTimestamp"` + + // The diffs observed between the state of the resource on the hub cluster and on the member cluster + // side. A diff is reported as a JSON path and the values at the path on both sides. + // + // +kubebuilder:validation:Optional + ObservedDiffs []PatchDetail `json:"observedDiffs,omitempty"` +} + +type PatchDetail struct { + // The JSON path that points to a field that has diffed. + // +kubebuilder:validation:Required + Path string `json:"path"` + + // The value at the JSON path from the member cluster side. + // + // If empty, the JSON path does not exist on the member cluster side. + // + // +kubebuilder:validation:Optional + ValueInMember string `json:"valueInMember,omitempty"` + + // The value at the JSON path from the hub cluster side. + // + // If empty, the JSON path does not exist on the hub cluster side. + // + // +kubebuilder:validation:Optional + ValueInHub string `json:"valueInHub,omitempty"` +} + +type ManifestIdentifier struct { + // The ordinal of the manifest. + // + // +kubebuilder:validation:Required + Ordinal int `json:"ordinal"` + + // The namespace of the manifest. + // + // +kubebuilder:validation:Optional + Namespace string `json:"namespace,omitempty"` + + // The name of the manifest. + // +kubebuilder:validation:Required + Name string `json:"name"` + + // The API group, version, kind, and resource of the manifest. + + // +kubebuilder:validation:Optional + APIGroup string `json:"apiGroup,omitempty"` + + // +kubebuilder:validation:Required + APIVersion string `json:"apiVersion,omitempty"` + + // +kubebuilder:validation:Required + Kind string `json:"kind,omitempty"` + + // +kubebuilder:validation:Required + Resource string `json:"resource,omitempty"` +} + +// WorkList contains a list of Work. +// +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Namespaced +// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object +type WorkList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + + Items []Work `json:"items"` +} + +// Set up the API types with the scheme builder. +func init() { + SchemeBuilder.Register(&Work{}, &WorkList{}) +} diff --git a/apis/kubefleet.dev/placement/v1alpha1/zz_generated.deepcopy.go b/apis/kubefleet.dev/placement/v1alpha1/zz_generated.deepcopy.go index b5778ee1e..429b4e7fa 100644 --- a/apis/kubefleet.dev/placement/v1alpha1/zz_generated.deepcopy.go +++ b/apis/kubefleet.dev/placement/v1alpha1/zz_generated.deepcopy.go @@ -22,7 +22,7 @@ package v1alpha1 import ( "k8s.io/apimachinery/pkg/apis/meta/v1" - runtime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" ) @@ -82,6 +82,65 @@ func (in *ClusterLabelAndPropertySelectorTerm) DeepCopy() *ClusterLabelAndProper return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterPlacementBinding) DeepCopyInto(out *ClusterPlacementBinding) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPlacementBinding. +func (in *ClusterPlacementBinding) DeepCopy() *ClusterPlacementBinding { + if in == nil { + return nil + } + out := new(ClusterPlacementBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterPlacementBinding) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterPlacementBindingList) DeepCopyInto(out *ClusterPlacementBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterPlacementBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPlacementBindingList. +func (in *ClusterPlacementBindingList) DeepCopy() *ClusterPlacementBindingList { + if in == nil { + return nil + } + out := new(ClusterPlacementBindingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterPlacementBindingList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterPlacementPolicy) DeepCopyInto(out *ClusterPlacementPolicy) { *out = *in @@ -141,6 +200,64 @@ func (in *ClusterPlacementPolicyList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterPlacementResourceSnapshot) DeepCopyInto(out *ClusterPlacementResourceSnapshot) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPlacementResourceSnapshot. +func (in *ClusterPlacementResourceSnapshot) DeepCopy() *ClusterPlacementResourceSnapshot { + if in == nil { + return nil + } + out := new(ClusterPlacementResourceSnapshot) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterPlacementResourceSnapshot) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterPlacementResourceSnapshotList) DeepCopyInto(out *ClusterPlacementResourceSnapshotList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ClusterPlacementResourceSnapshot, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterPlacementResourceSnapshotList. +func (in *ClusterPlacementResourceSnapshotList) DeepCopy() *ClusterPlacementResourceSnapshotList { + if in == nil { + return nil + } + out := new(ClusterPlacementResourceSnapshotList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ClusterPlacementResourceSnapshotList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterRequest) DeepCopyInto(out *ClusterRequest) { *out = *in @@ -290,6 +407,82 @@ func (in *ClusterSelector) DeepCopy() *ClusterSelector { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ClusterSelectorWithTermsOnly) DeepCopyInto(out *ClusterSelectorWithTermsOnly) { + *out = *in + if in.Terms != nil { + in, out := &in.Terms, &out.Terms + *out = make([]ClusterLabelAndPropertySelectorTerm, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterSelectorWithTermsOnly. +func (in *ClusterSelectorWithTermsOnly) DeepCopy() *ClusterSelectorWithTermsOnly { + if in == nil { + return nil + } + out := new(ClusterSelectorWithTermsOnly) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DiffDetails) DeepCopyInto(out *DiffDetails) { + *out = *in + if in.ObservedInMemberClusterGeneration != nil { + in, out := &in.ObservedInMemberClusterGeneration, &out.ObservedInMemberClusterGeneration + *out = new(int64) + **out = **in + } + in.FirstDiffedObservedTimestamp.DeepCopyInto(&out.FirstDiffedObservedTimestamp) + if in.ObservedDiffs != nil { + in, out := &in.ObservedDiffs, &out.ObservedDiffs + *out = make([]PatchDetail, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DiffDetails. +func (in *DiffDetails) DeepCopy() *DiffDetails { + if in == nil { + return nil + } + out := new(DiffDetails) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FailedResource) DeepCopyInto(out *FailedResource) { + *out = *in + out.ObjectRef = in.ObjectRef + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.DiffDetails != nil { + in, out := &in.DiffDetails, &out.DiffDetails + *out = new(DiffDetails) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FailedResource. +func (in *FailedResource) DeepCopy() *FailedResource { + if in == nil { + return nil + } + out := new(FailedResource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LabelClusterPropertyExpression) DeepCopyInto(out *LabelClusterPropertyExpression) { *out = *in @@ -310,6 +503,37 @@ func (in *LabelClusterPropertyExpression) DeepCopy() *LabelClusterPropertyExpres return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Manifest) DeepCopyInto(out *Manifest) { + *out = *in + in.RawExtension.DeepCopyInto(&out.RawExtension) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Manifest. +func (in *Manifest) DeepCopy() *Manifest { + if in == nil { + return nil + } + out := new(Manifest) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ManifestIdentifier) DeepCopyInto(out *ManifestIdentifier) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManifestIdentifier. +func (in *ManifestIdentifier) DeepCopy() *ManifestIdentifier { + if in == nil { + return nil + } + out := new(ManifestIdentifier) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ObjectReference) DeepCopyInto(out *ObjectReference) { *out = *in @@ -325,6 +549,179 @@ func (in *ObjectReference) DeepCopy() *ObjectReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PatchDetail) DeepCopyInto(out *PatchDetail) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PatchDetail. +func (in *PatchDetail) DeepCopy() *PatchDetail { + if in == nil { + return nil + } + out := new(PatchDetail) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PerManifestStatus) DeepCopyInto(out *PerManifestStatus) { + *out = *in + out.Identifier = in.Identifier + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.DiffDetails != nil { + in, out := &in.DiffDetails, &out.DiffDetails + *out = new(DiffDetails) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PerManifestStatus. +func (in *PerManifestStatus) DeepCopy() *PerManifestStatus { + if in == nil { + return nil + } + out := new(PerManifestStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementBinding) DeepCopyInto(out *PlacementBinding) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementBinding. +func (in *PlacementBinding) DeepCopy() *PlacementBinding { + if in == nil { + return nil + } + out := new(PlacementBinding) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PlacementBinding) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementBindingList) DeepCopyInto(out *PlacementBindingList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PlacementBinding, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementBindingList. +func (in *PlacementBindingList) DeepCopy() *PlacementBindingList { + if in == nil { + return nil + } + out := new(PlacementBindingList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PlacementBindingList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementBindingSpec) DeepCopyInto(out *PlacementBindingSpec) { + *out = *in + if in.ClusterSelectors != nil { + in, out := &in.ClusterSelectors, &out.ClusterSelectors + *out = make([]ClusterSelectorWithTermsOnly, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SyncStrategy != nil { + in, out := &in.SyncStrategy, &out.SyncStrategy + *out = new(SyncStrategy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementBindingSpec. +func (in *PlacementBindingSpec) DeepCopy() *PlacementBindingSpec { + if in == nil { + return nil + } + out := new(PlacementBindingSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementBindingStatus) DeepCopyInto(out *PlacementBindingStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SelectedResources != nil { + in, out := &in.SelectedResources, &out.SelectedResources + *out = new(int32) + **out = **in + } + if in.SynchronizedResources != nil { + in, out := &in.SynchronizedResources, &out.SynchronizedResources + *out = new(int32) + **out = **in + } + if in.AvailableResources != nil { + in, out := &in.AvailableResources, &out.AvailableResources + *out = new(int32) + **out = **in + } + if in.FailedResources != nil { + in, out := &in.FailedResources, &out.FailedResources + *out = make([]FailedResource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementBindingStatus. +func (in *PlacementBindingStatus) DeepCopy() *PlacementBindingStatus { + if in == nil { + return nil + } + out := new(PlacementBindingStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PlacementPolicy) DeepCopyInto(out *PlacementPolicy) { *out = *in @@ -485,6 +882,86 @@ func (in *PlacementPolicyStatus) DeepCopy() *PlacementPolicyStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementResourceSnapshot) DeepCopyInto(out *PlacementResourceSnapshot) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementResourceSnapshot. +func (in *PlacementResourceSnapshot) DeepCopy() *PlacementResourceSnapshot { + if in == nil { + return nil + } + out := new(PlacementResourceSnapshot) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PlacementResourceSnapshot) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementResourceSnapshotList) DeepCopyInto(out *PlacementResourceSnapshotList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PlacementResourceSnapshot, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementResourceSnapshotList. +func (in *PlacementResourceSnapshotList) DeepCopy() *PlacementResourceSnapshotList { + if in == nil { + return nil + } + out := new(PlacementResourceSnapshotList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PlacementResourceSnapshotList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PlacementResourceSnapshotSpec) DeepCopyInto(out *PlacementResourceSnapshotSpec) { + *out = *in + if in.Resources != nil { + in, out := &in.Resources, &out.Resources + *out = make([]SnapshottedResource, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PlacementResourceSnapshotSpec. +func (in *PlacementResourceSnapshotSpec) DeepCopy() *PlacementResourceSnapshotSpec { + if in == nil { + return nil + } + out := new(PlacementResourceSnapshotSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceSelector) DeepCopyInto(out *ResourceSelector) { *out = *in @@ -520,6 +997,39 @@ func (in *ServerSideApplyOptions) DeepCopy() *ServerSideApplyOptions { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SnapshottedResource) DeepCopyInto(out *SnapshottedResource) { + *out = *in + out.Identifier = in.Identifier + in.Manifest.DeepCopyInto(&out.Manifest) + if in.AdditionalInfo != nil { + in, out := &in.AdditionalInfo, &out.AdditionalInfo + *out = make(map[string][]byte, len(*in)) + for key, val := range *in { + var outVal []byte + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make([]byte, len(*in)) + copy(*out, *in) + } + (*out)[key] = outVal + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SnapshottedResource. +func (in *SnapshottedResource) DeepCopy() *SnapshottedResource { + if in == nil { + return nil + } + out := new(SnapshottedResource) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SyncStrategy) DeepCopyInto(out *SyncStrategy) { *out = *in @@ -554,3 +1064,118 @@ func (in *Toleration) DeepCopy() *Toleration { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Work) DeepCopyInto(out *Work) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Work. +func (in *Work) DeepCopy() *Work { + if in == nil { + return nil + } + out := new(Work) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Work) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkList) DeepCopyInto(out *WorkList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Work, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkList. +func (in *WorkList) DeepCopy() *WorkList { + if in == nil { + return nil + } + out := new(WorkList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *WorkList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkSpec) DeepCopyInto(out *WorkSpec) { + *out = *in + if in.Manifests != nil { + in, out := &in.Manifests, &out.Manifests + *out = make([]Manifest, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.SyncStrategy != nil { + in, out := &in.SyncStrategy, &out.SyncStrategy + *out = new(SyncStrategy) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkSpec. +func (in *WorkSpec) DeepCopy() *WorkSpec { + if in == nil { + return nil + } + out := new(WorkSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkStatus) DeepCopyInto(out *WorkStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Manifests != nil { + in, out := &in.Manifests, &out.Manifests + *out = make([]PerManifestStatus, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkStatus. +func (in *WorkStatus) DeepCopy() *WorkStatus { + if in == nil { + return nil + } + out := new(WorkStatus) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/bases/placement.kubefleet.dev_clusterplacementbindings.yaml b/config/crd/bases/placement.kubefleet.dev_clusterplacementbindings.yaml new file mode 100644 index 000000000..a78e895bc --- /dev/null +++ b/config/crd/bases/placement.kubefleet.dev_clusterplacementbindings.yaml @@ -0,0 +1,582 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: clusterplacementbindings.placement.kubefleet.dev +spec: + group: placement.kubefleet.dev + names: + categories: + - kubefleet + - kubefleet-placement + kind: ClusterPlacementBinding + listKind: ClusterPlacementBindingList + plural: clusterplacementbindings + singular: clusterplacementbinding + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ClusterPlacementBinding is the KubeFleet API that binds the resources selected by a cluster placement + policy to a specific member cluster. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The specification of the binding. + properties: + clusterName: + description: The name of the member cluster that this binding is associated + with. + type: string + clusterSelectors: + description: |- + The cluster selectors associated with (fulfilled by) this binding. + + This field is added for informational purposes only. KubeFleet uses hashes of these cluster selectors + (kept in the annotations) to determine which cluster selectors the binding is associated with. + items: + properties: + terms: + description: The terms that describe the requirements for a + target cluster in the cluster selector. + items: + properties: + matchClusterPropertyExpressions: + description: A list of cluster property expressions that + a cluster must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is + In or NotIn + rule: '(self.operator == ''In'' || self.operator == + ''NotIn'') ? (has(self.values) && size(self.values) + > 0) : true' + - message: values must be empty when operator is Exists + or DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator + == ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when + operator is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == + ''Lt'' || self.operator == ''Ge'' || self.operator + == ''Le'' || self.operator == ''Eq'' || self.operator + == ''Ne'') ? (has(self.values) && size(self.values) + == 1) : true' + maxItems: 10 + type: array + matchLabelExpressions: + description: A list of label expressions that a cluster + must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is + In or NotIn + rule: '(self.operator == ''In'' || self.operator == + ''NotIn'') ? (has(self.values) && size(self.values) + > 0) : true' + - message: values must be empty when operator is Exists + or DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator + == ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when + operator is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == + ''Lt'' || self.operator == ''Ge'' || self.operator + == ''Le'' || self.operator == ''Eq'' || self.operator + == ''Ne'') ? (has(self.values) && size(self.values) + == 1) : true' + maxItems: 10 + type: array + matchLabels: + additionalProperties: + type: string + description: A list of label key-value pairs that a cluster + must have to match this selector term. + maxProperties: 10 + type: object + type: object + type: array + type: object + type: array + placementPolicyName: + description: The name of the placement policy that this binding is + associated with. + type: string + resourceSnapshotName: + description: |- + The name of the resource snapshot that this binding is associated with. + + If the resources being selected cannot fit within a single resource snapshot, this field tracks + the name of the primary resource snapshot that this binding is associated with. + type: string + suspended: + default: false + description: Whether the binding is suspended. If set to true, KubeFleet + will remove resources from the associated cluster. + type: boolean + syncStrategy: + description: The strategy to synchronize the resources to the member + cluster. + properties: + applyMethod: + default: ClientSideApply + description: |- + The method KubeFleet uses to apply resources to target clusters. + + Available options are: + * ClientSideApply: KubeFleet applies resources to a target cluster using three-way merge patch, similar + to how the Kubernetes CLI performs a client-side apply. + * ServerSideApply: KubeFleet applies resources to a target cluster using server-side apply, which allows + the API server to manage conflicts and merge changes. + + The default value is ClientSideApply. + enum: + - ClientSideApply + - ServerSideApply + type: string + comparisonOption: + default: PartialComparison + description: |- + How to compare the states between the target cluster side and the hub cluster side, when calculating drifts + or diffs. + + Available options are: + * PartialComparison: KubeFleet compares only the resource fields that have been explicitly specified on the hub cluster + side. + * FullComparison: KubeFleet compares all the fields of a resource, including those that are not specified on + the hub cluster side. + + The default value is PartialComparison. + enum: + - PartialComparison + - FullComparison + type: string + serverSideApplyOptions: + description: |- + The options for running server-side apply ops. This field takes effect only if the apply method is + set to ServerSideApply. + properties: + forceConflicts: + type: boolean + type: object + whenAlreadyExists: + default: ReportError + description: |- + The action to take when a resource to be placed already exists on the target cluster side and is not managed + by KubeFleet. + + Available options are: + * AlwaysTakeOver: KubeFleet takes over the resource by registering itself as an owner of the resource (if + the resource has no owner or co-ownership is allowed). This enables KubeFleet to adopt the existing resource for + centralized management. + * TakeOverIfNoDiff: KubeFleet takes over the resource only if the existing resource reads the same as the desired state + specified on the hub cluster side. + * ReportError: KubeFleet reports an error and leaves the existing resource as is. + + The default value is ReportError. + enum: + - AlwaysTakeOver + - TakeOverIfNoDiff + - ReportError + type: string + whenDrifted: + default: ApplyAnyway + description: |- + The action to take when a resource on the target cluster side has drifted from its desired state as controlled + by the placement. A drift can occur when a user or a controller on the target cluster makes an inadvertent change + to a KubeFleet-managed resource. + + Available options are: + * ApplyAnyway: KubeFleet applies the desired state, which might overwrite the drift. + * ReportError: KubeFleet reports an error and leaves the drift as is. + + The default value is ApplyAnyway. + enum: + - ApplyAnyway + - ReportError + type: string + whenNamespaceDoesNotExist: + default: CreateNamespace + description: |- + The action to take when a resource to be placed is namespaced but its namespace does not exist on a target cluster. + + Available options are: + * CreateNamespace: KubeFleet creates the namespace on the target cluster. Note that the namespace itself will not be + managed by KubeFleet, and thus will not be deleted even if the placement itself has been deleted. + * ReportError: KubeFleet reports an error and does not place the resource to the target cluster. + + The default value is CreateNamespace. + enum: + - CreateNamespace + - ReportError + type: string + whenOwnedByOthers: + default: ReportError + description: |- + How to handle resource co-ownership. This is most relevant when KubeFleet must manage resources that + are already (or expected to be) owned by other non-KubeFleet controllers in target clusters. + + Available options are: + * ShareOwnership: KubeFleet registers itself as a co-owner of the resource. + * ReportError: KubeFleet reports an error when a resource to be placed is already owned by other controllers. + + The default value is ReportError. + enum: + - ShareOwnership + - ReportError + type: string + whenPlacementDeleted: + default: CleanUpResources + description: |- + The action to take on resources managed by a KubeFleet placement when the placement itself is deleted. + + Available options are: + * CleanUpResources: KubeFleet deletes all the resources managed by the placement. + * OrphanResources: KubeFleet relinquishes ownership of such resources and leaves them as they are on target clusters. + + The default value is CleanUpResources. + enum: + - CleanUpResources + - OrphanResources + type: string + type: object + required: + - clusterName + - placementPolicyName + - resourceSnapshotName + type: object + status: + description: The observed status of the binding. + properties: + availableResources: + description: |- + The number of selected resources that are available in the target cluster. + + A resource is considered available if it has been successfully created (applied) in the target cluster and + has passed KubeFleet's built-in availability checks (if applicable). + format: int32 + type: integer + conditions: + description: A list of observed conditions about the binding. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + failedResources: + description: |- + A list of resources that have failed to be synchronized to the target cluster, or have failed to become + available in the target cluster. + + If there are more than 50 failed resources, only the first 50 will be included in this list. + items: + properties: + conditions: + description: A list of observed conditions about the failed + resource. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + diffDetails: + description: |- + The details about observed diffs between the resource on the hub cluster and on the member cluster side, if any. + This field is populated when drift detection is enabled and the resource is of a drifted state, + or when diff check upon takeovers is enabled and diffs have been detected on the resource to take over. + properties: + firstDiffedObservedTimestamp: + description: The timestamp when the diffs are first detected. + format: date-time + type: string + observedDiffs: + description: |- + The diffs observed between the state of the resource on the hub cluster and on the member cluster + side. A diff is reported as a JSON path and the values at the path on both sides. + items: + properties: + path: + description: The JSON path that points to a field + that has diffed. + type: string + valueInHub: + description: |- + The value at the JSON path from the hub cluster side. + + If empty, the JSON path does not exist on the hub cluster side. + type: string + valueInMember: + description: |- + The value at the JSON path from the member cluster side. + + If empty, the JSON path does not exist on the member cluster side. + type: string + required: + - path + type: object + type: array + observedInMemberClusterGeneration: + description: |- + The generation of the resource, as seen on the member cluster side. + + If set to nil, the resource has not been created yet on the member cluster. + format: int64 + type: integer + required: + - firstDiffedObservedTimestamp + type: object + objectRef: + description: The object reference of the failed resource. + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + name: + description: The name of the referenced object. + type: string + namespace: + description: |- + The namespace of the referenced object. + + If the object is cluster-scoped, this field should be left empty. + type: string + required: + - apiVersion + - kind + - name + type: object + required: + - objectRef + type: object + maxItems: 50 + type: array + selectedResources: + description: The number of resources that are included in the currently + associated resource snapshot(s). + format: int32 + type: integer + synchronizedResources: + description: |- + The number of selected resources that have been synchronized to the target cluster. + + A resource is considered synchronized if it has been successfully created (applied) in the target cluster. + format: int32 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/placement.kubefleet.dev_clusterplacementresourcesnapshots.yaml b/config/crd/bases/placement.kubefleet.dev_clusterplacementresourcesnapshots.yaml new file mode 100644 index 000000000..f0c26eb27 --- /dev/null +++ b/config/crd/bases/placement.kubefleet.dev_clusterplacementresourcesnapshots.yaml @@ -0,0 +1,102 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: clusterplacementresourcesnapshots.placement.kubefleet.dev +spec: + group: placement.kubefleet.dev + names: + categories: + - kubefleet + - kubefleet-placement + kind: ClusterPlacementResourceSnapshot + listKind: ClusterPlacementResourceSnapshotList + plural: clusterplacementresourcesnapshots + singular: clusterplacementresourcesnapshot + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + ClusterPlacementResourceSnapshot is the KubeFleet API that captures the resources selected by + a cluster placement policy as seen on the hub cluster at a specific point in time. It is referenced + by other KubeFleet APIs to enable consistent rollouts of resources across multiple member clusters in the fleet. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The spec of a cluster placement resource snapshot. + properties: + resources: + description: The manifests of the resources selected by the owner + placement policy at the time of snapshot creation. + items: + properties: + additionalInfo: + additionalProperties: + format: byte + type: string + description: Additional information associated with the resource, + if any. + type: object + identifier: + description: The identifier of the resource. + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + name: + description: The name of the referenced object. + type: string + namespace: + description: |- + The namespace of the referenced object. + + If the object is cluster-scoped, this field should be left empty. + type: string + required: + - apiVersion + - kind + - name + type: object + manifest: + description: The manifest of the resource. It should be a Kubernetes + object in YAML or JSON format. + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + required: + - identifier + - manifest + type: object + type: array + type: object + x-kubernetes-validations: + - message: the spec field is immutable + rule: self == oldSelf + required: + - spec + type: object + served: true + storage: true diff --git a/config/crd/bases/placement.kubefleet.dev_placementbindings.yaml b/config/crd/bases/placement.kubefleet.dev_placementbindings.yaml new file mode 100644 index 000000000..b45d48f4e --- /dev/null +++ b/config/crd/bases/placement.kubefleet.dev_placementbindings.yaml @@ -0,0 +1,582 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: placementbindings.placement.kubefleet.dev +spec: + group: placement.kubefleet.dev + names: + categories: + - kubefleet + - kubefleet-placement + kind: PlacementBinding + listKind: PlacementBindingList + plural: placementbindings + singular: placementbinding + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + PlacementBinding is the KubeFleet API that binds the resources selected by a placement + policy to a specific member cluster. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The specification of the binding. + properties: + clusterName: + description: The name of the member cluster that this binding is associated + with. + type: string + clusterSelectors: + description: |- + The cluster selectors associated with (fulfilled by) this binding. + + This field is added for informational purposes only. KubeFleet uses hashes of these cluster selectors + (kept in the annotations) to determine which cluster selectors the binding is associated with. + items: + properties: + terms: + description: The terms that describe the requirements for a + target cluster in the cluster selector. + items: + properties: + matchClusterPropertyExpressions: + description: A list of cluster property expressions that + a cluster must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is + In or NotIn + rule: '(self.operator == ''In'' || self.operator == + ''NotIn'') ? (has(self.values) && size(self.values) + > 0) : true' + - message: values must be empty when operator is Exists + or DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator + == ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when + operator is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == + ''Lt'' || self.operator == ''Ge'' || self.operator + == ''Le'' || self.operator == ''Eq'' || self.operator + == ''Ne'') ? (has(self.values) && size(self.values) + == 1) : true' + maxItems: 10 + type: array + matchLabelExpressions: + description: A list of label expressions that a cluster + must all satisfy to match this selector term. + items: + properties: + key: + description: The key of the label or cluster property + that selector applies to. + type: string + operator: + description: |- + The operator that specifies the relationship between the current value under the key and the given values. + + If the operation is In, NotIn, Exists, or DoesNotExist, the key must be one referring to a label, or to a string-based + cluster property. + If the operation is Gt, Lt, Ge, Le, Eq, or Ne, the key must be one referring to a numeric-based cluster property. + Applying an unsupported operator to a key will cause an error at the scheduling phase. + enum: + - In + - NotIn + - Exists + - DoesNotExist + - Gt + - Lt + - Ge + - Le + - Eq + - Ne + type: string + values: + description: |- + The values that are used in conjunction with the operator to determine if a selector matches. + + If the operator is In or NotIn, the values array must be non-empty. + If the operator is Exists or DoesNotExist, the values array must be empty. + If the operator is Gt, Lt, Ge, Le, Eq, or Ne, the values array must contain exactly one element. + items: + type: string + type: array + required: + - key + - operator + type: object + x-kubernetes-validations: + - message: values must be non-empty when operator is + In or NotIn + rule: '(self.operator == ''In'' || self.operator == + ''NotIn'') ? (has(self.values) && size(self.values) + > 0) : true' + - message: values must be empty when operator is Exists + or DoesNotExist + rule: '(self.operator == ''Exists'' || self.operator + == ''DoesNotExist'') ? (!has(self.values) || size(self.values) + == 0) : true' + - message: values must contain exactly one element when + operator is Gt, Lt, Ge, Le, Eq, or Ne + rule: '(self.operator == ''Gt'' || self.operator == + ''Lt'' || self.operator == ''Ge'' || self.operator + == ''Le'' || self.operator == ''Eq'' || self.operator + == ''Ne'') ? (has(self.values) && size(self.values) + == 1) : true' + maxItems: 10 + type: array + matchLabels: + additionalProperties: + type: string + description: A list of label key-value pairs that a cluster + must have to match this selector term. + maxProperties: 10 + type: object + type: object + type: array + type: object + type: array + placementPolicyName: + description: The name of the placement policy that this binding is + associated with. + type: string + resourceSnapshotName: + description: |- + The name of the resource snapshot that this binding is associated with. + + If the resources being selected cannot fit within a single resource snapshot, this field tracks + the name of the primary resource snapshot that this binding is associated with. + type: string + suspended: + default: false + description: Whether the binding is suspended. If set to true, KubeFleet + will remove resources from the associated cluster. + type: boolean + syncStrategy: + description: The strategy to synchronize the resources to the member + cluster. + properties: + applyMethod: + default: ClientSideApply + description: |- + The method KubeFleet uses to apply resources to target clusters. + + Available options are: + * ClientSideApply: KubeFleet applies resources to a target cluster using three-way merge patch, similar + to how the Kubernetes CLI performs a client-side apply. + * ServerSideApply: KubeFleet applies resources to a target cluster using server-side apply, which allows + the API server to manage conflicts and merge changes. + + The default value is ClientSideApply. + enum: + - ClientSideApply + - ServerSideApply + type: string + comparisonOption: + default: PartialComparison + description: |- + How to compare the states between the target cluster side and the hub cluster side, when calculating drifts + or diffs. + + Available options are: + * PartialComparison: KubeFleet compares only the resource fields that have been explicitly specified on the hub cluster + side. + * FullComparison: KubeFleet compares all the fields of a resource, including those that are not specified on + the hub cluster side. + + The default value is PartialComparison. + enum: + - PartialComparison + - FullComparison + type: string + serverSideApplyOptions: + description: |- + The options for running server-side apply ops. This field takes effect only if the apply method is + set to ServerSideApply. + properties: + forceConflicts: + type: boolean + type: object + whenAlreadyExists: + default: ReportError + description: |- + The action to take when a resource to be placed already exists on the target cluster side and is not managed + by KubeFleet. + + Available options are: + * AlwaysTakeOver: KubeFleet takes over the resource by registering itself as an owner of the resource (if + the resource has no owner or co-ownership is allowed). This enables KubeFleet to adopt the existing resource for + centralized management. + * TakeOverIfNoDiff: KubeFleet takes over the resource only if the existing resource reads the same as the desired state + specified on the hub cluster side. + * ReportError: KubeFleet reports an error and leaves the existing resource as is. + + The default value is ReportError. + enum: + - AlwaysTakeOver + - TakeOverIfNoDiff + - ReportError + type: string + whenDrifted: + default: ApplyAnyway + description: |- + The action to take when a resource on the target cluster side has drifted from its desired state as controlled + by the placement. A drift can occur when a user or a controller on the target cluster makes an inadvertent change + to a KubeFleet-managed resource. + + Available options are: + * ApplyAnyway: KubeFleet applies the desired state, which might overwrite the drift. + * ReportError: KubeFleet reports an error and leaves the drift as is. + + The default value is ApplyAnyway. + enum: + - ApplyAnyway + - ReportError + type: string + whenNamespaceDoesNotExist: + default: CreateNamespace + description: |- + The action to take when a resource to be placed is namespaced but its namespace does not exist on a target cluster. + + Available options are: + * CreateNamespace: KubeFleet creates the namespace on the target cluster. Note that the namespace itself will not be + managed by KubeFleet, and thus will not be deleted even if the placement itself has been deleted. + * ReportError: KubeFleet reports an error and does not place the resource to the target cluster. + + The default value is CreateNamespace. + enum: + - CreateNamespace + - ReportError + type: string + whenOwnedByOthers: + default: ReportError + description: |- + How to handle resource co-ownership. This is most relevant when KubeFleet must manage resources that + are already (or expected to be) owned by other non-KubeFleet controllers in target clusters. + + Available options are: + * ShareOwnership: KubeFleet registers itself as a co-owner of the resource. + * ReportError: KubeFleet reports an error when a resource to be placed is already owned by other controllers. + + The default value is ReportError. + enum: + - ShareOwnership + - ReportError + type: string + whenPlacementDeleted: + default: CleanUpResources + description: |- + The action to take on resources managed by a KubeFleet placement when the placement itself is deleted. + + Available options are: + * CleanUpResources: KubeFleet deletes all the resources managed by the placement. + * OrphanResources: KubeFleet relinquishes ownership of such resources and leaves them as they are on target clusters. + + The default value is CleanUpResources. + enum: + - CleanUpResources + - OrphanResources + type: string + type: object + required: + - clusterName + - placementPolicyName + - resourceSnapshotName + type: object + status: + description: The observed status of the binding. + properties: + availableResources: + description: |- + The number of selected resources that are available in the target cluster. + + A resource is considered available if it has been successfully created (applied) in the target cluster and + has passed KubeFleet's built-in availability checks (if applicable). + format: int32 + type: integer + conditions: + description: A list of observed conditions about the binding. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + failedResources: + description: |- + A list of resources that have failed to be synchronized to the target cluster, or have failed to become + available in the target cluster. + + If there are more than 50 failed resources, only the first 50 will be included in this list. + items: + properties: + conditions: + description: A list of observed conditions about the failed + resource. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + diffDetails: + description: |- + The details about observed diffs between the resource on the hub cluster and on the member cluster side, if any. + This field is populated when drift detection is enabled and the resource is of a drifted state, + or when diff check upon takeovers is enabled and diffs have been detected on the resource to take over. + properties: + firstDiffedObservedTimestamp: + description: The timestamp when the diffs are first detected. + format: date-time + type: string + observedDiffs: + description: |- + The diffs observed between the state of the resource on the hub cluster and on the member cluster + side. A diff is reported as a JSON path and the values at the path on both sides. + items: + properties: + path: + description: The JSON path that points to a field + that has diffed. + type: string + valueInHub: + description: |- + The value at the JSON path from the hub cluster side. + + If empty, the JSON path does not exist on the hub cluster side. + type: string + valueInMember: + description: |- + The value at the JSON path from the member cluster side. + + If empty, the JSON path does not exist on the member cluster side. + type: string + required: + - path + type: object + type: array + observedInMemberClusterGeneration: + description: |- + The generation of the resource, as seen on the member cluster side. + + If set to nil, the resource has not been created yet on the member cluster. + format: int64 + type: integer + required: + - firstDiffedObservedTimestamp + type: object + objectRef: + description: The object reference of the failed resource. + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + name: + description: The name of the referenced object. + type: string + namespace: + description: |- + The namespace of the referenced object. + + If the object is cluster-scoped, this field should be left empty. + type: string + required: + - apiVersion + - kind + - name + type: object + required: + - objectRef + type: object + maxItems: 50 + type: array + selectedResources: + description: The number of resources that are included in the currently + associated resource snapshot(s). + format: int32 + type: integer + synchronizedResources: + description: |- + The number of selected resources that have been synchronized to the target cluster. + + A resource is considered synchronized if it has been successfully created (applied) in the target cluster. + format: int32 + type: integer + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/placement.kubefleet.dev_placementresourcesnapshots.yaml b/config/crd/bases/placement.kubefleet.dev_placementresourcesnapshots.yaml new file mode 100644 index 000000000..ce66aa329 --- /dev/null +++ b/config/crd/bases/placement.kubefleet.dev_placementresourcesnapshots.yaml @@ -0,0 +1,102 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: placementresourcesnapshots.placement.kubefleet.dev +spec: + group: placement.kubefleet.dev + names: + categories: + - kubefleet + - kubefleet-placement + kind: PlacementResourceSnapshot + listKind: PlacementResourceSnapshotList + plural: placementresourcesnapshots + singular: placementresourcesnapshot + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + PlacementResourceSnapshot is the KubeFleet API that captures the resources selected by a placement policy + as seen on the hub cluster at a specific point in time. It is referenced by other KubeFleet APIs + to enable consistent rollouts of resources across multiple member clusters in the fleet. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The spec of a placement resource snapshot. + properties: + resources: + description: The manifests of the resources selected by the owner + placement policy at the time of snapshot creation. + items: + properties: + additionalInfo: + additionalProperties: + format: byte + type: string + description: Additional information associated with the resource, + if any. + type: object + identifier: + description: The identifier of the resource. + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + name: + description: The name of the referenced object. + type: string + namespace: + description: |- + The namespace of the referenced object. + + If the object is cluster-scoped, this field should be left empty. + type: string + required: + - apiVersion + - kind + - name + type: object + manifest: + description: The manifest of the resource. It should be a Kubernetes + object in YAML or JSON format. + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + required: + - identifier + - manifest + type: object + type: array + type: object + x-kubernetes-validations: + - message: the spec field is immutable + rule: self == oldSelf + required: + - spec + type: object + served: true + storage: true diff --git a/config/crd/bases/placement.kubefleet.dev_works.yaml b/config/crd/bases/placement.kubefleet.dev_works.yaml new file mode 100644 index 000000000..08084e435 --- /dev/null +++ b/config/crd/bases/placement.kubefleet.dev_works.yaml @@ -0,0 +1,395 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.0 + name: works.placement.kubefleet.dev +spec: + group: placement.kubefleet.dev + names: + categories: + - kubefleet + - kubefleet-placement + kind: Work + listKind: WorkList + plural: works + singular: work + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + Work is the KubeFleet API used for synchronizing resources to place between + the hub cluster and a member cluster in the member cluster reserved namespace. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: The specification of the work object. + properties: + manifests: + description: The manifests of the resources to be synchronized to + the member cluster. + items: + type: object + x-kubernetes-embedded-resource: true + x-kubernetes-preserve-unknown-fields: true + type: array + syncStrategy: + description: The strategy to synchronize the resources to the member + cluster. + properties: + applyMethod: + default: ClientSideApply + description: |- + The method KubeFleet uses to apply resources to target clusters. + + Available options are: + * ClientSideApply: KubeFleet applies resources to a target cluster using three-way merge patch, similar + to how the Kubernetes CLI performs a client-side apply. + * ServerSideApply: KubeFleet applies resources to a target cluster using server-side apply, which allows + the API server to manage conflicts and merge changes. + + The default value is ClientSideApply. + enum: + - ClientSideApply + - ServerSideApply + type: string + comparisonOption: + default: PartialComparison + description: |- + How to compare the states between the target cluster side and the hub cluster side, when calculating drifts + or diffs. + + Available options are: + * PartialComparison: KubeFleet compares only the resource fields that have been explicitly specified on the hub cluster + side. + * FullComparison: KubeFleet compares all the fields of a resource, including those that are not specified on + the hub cluster side. + + The default value is PartialComparison. + enum: + - PartialComparison + - FullComparison + type: string + serverSideApplyOptions: + description: |- + The options for running server-side apply ops. This field takes effect only if the apply method is + set to ServerSideApply. + properties: + forceConflicts: + type: boolean + type: object + whenAlreadyExists: + default: ReportError + description: |- + The action to take when a resource to be placed already exists on the target cluster side and is not managed + by KubeFleet. + + Available options are: + * AlwaysTakeOver: KubeFleet takes over the resource by registering itself as an owner of the resource (if + the resource has no owner or co-ownership is allowed). This enables KubeFleet to adopt the existing resource for + centralized management. + * TakeOverIfNoDiff: KubeFleet takes over the resource only if the existing resource reads the same as the desired state + specified on the hub cluster side. + * ReportError: KubeFleet reports an error and leaves the existing resource as is. + + The default value is ReportError. + enum: + - AlwaysTakeOver + - TakeOverIfNoDiff + - ReportError + type: string + whenDrifted: + default: ApplyAnyway + description: |- + The action to take when a resource on the target cluster side has drifted from its desired state as controlled + by the placement. A drift can occur when a user or a controller on the target cluster makes an inadvertent change + to a KubeFleet-managed resource. + + Available options are: + * ApplyAnyway: KubeFleet applies the desired state, which might overwrite the drift. + * ReportError: KubeFleet reports an error and leaves the drift as is. + + The default value is ApplyAnyway. + enum: + - ApplyAnyway + - ReportError + type: string + whenNamespaceDoesNotExist: + default: CreateNamespace + description: |- + The action to take when a resource to be placed is namespaced but its namespace does not exist on a target cluster. + + Available options are: + * CreateNamespace: KubeFleet creates the namespace on the target cluster. Note that the namespace itself will not be + managed by KubeFleet, and thus will not be deleted even if the placement itself has been deleted. + * ReportError: KubeFleet reports an error and does not place the resource to the target cluster. + + The default value is CreateNamespace. + enum: + - CreateNamespace + - ReportError + type: string + whenOwnedByOthers: + default: ReportError + description: |- + How to handle resource co-ownership. This is most relevant when KubeFleet must manage resources that + are already (or expected to be) owned by other non-KubeFleet controllers in target clusters. + + Available options are: + * ShareOwnership: KubeFleet registers itself as a co-owner of the resource. + * ReportError: KubeFleet reports an error when a resource to be placed is already owned by other controllers. + + The default value is ReportError. + enum: + - ShareOwnership + - ReportError + type: string + whenPlacementDeleted: + default: CleanUpResources + description: |- + The action to take on resources managed by a KubeFleet placement when the placement itself is deleted. + + Available options are: + * CleanUpResources: KubeFleet deletes all the resources managed by the placement. + * OrphanResources: KubeFleet relinquishes ownership of such resources and leaves them as they are on target clusters. + + The default value is CleanUpResources. + enum: + - CleanUpResources + - OrphanResources + type: string + type: object + type: object + status: + description: The observed status of the work object. + properties: + conditions: + description: A list of observed conditions of the work object. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + manifests: + description: The observed status of each manifest in the work object. + items: + properties: + conditions: + description: A list of observed conditions of the manifest. + items: + description: Condition contains details for one aspect of + the current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + diffDetails: + description: |- + The details about observed diffs between the resource on the hub cluster and on the member cluster side, if any. + This field is populated when drift detection is enabled and the resource is of a drifted state, + or when diff check upon takeovers is enabled and diffs have been detected on the resource to take over. + properties: + firstDiffedObservedTimestamp: + description: The timestamp when the diffs are first detected. + format: date-time + type: string + observedDiffs: + description: |- + The diffs observed between the state of the resource on the hub cluster and on the member cluster + side. A diff is reported as a JSON path and the values at the path on both sides. + items: + properties: + path: + description: The JSON path that points to a field + that has diffed. + type: string + valueInHub: + description: |- + The value at the JSON path from the hub cluster side. + + If empty, the JSON path does not exist on the hub cluster side. + type: string + valueInMember: + description: |- + The value at the JSON path from the member cluster side. + + If empty, the JSON path does not exist on the member cluster side. + type: string + required: + - path + type: object + type: array + observedInMemberClusterGeneration: + description: |- + The generation of the resource, as seen on the member cluster side. + + If set to nil, the resource has not been created yet on the member cluster. + format: int64 + type: integer + required: + - firstDiffedObservedTimestamp + type: object + identifier: + description: The identifier of the resource represented by the + manifest. + properties: + apiGroup: + type: string + apiVersion: + type: string + kind: + type: string + name: + description: The name of the manifest. + type: string + namespace: + description: The namespace of the manifest. + type: string + ordinal: + description: The ordinal of the manifest. + type: integer + resource: + type: string + required: + - apiVersion + - kind + - name + - ordinal + - resource + type: object + required: + - identifier + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} From d4bfa3b7ba237029095c1ea288075e2647238b1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:07:47 -0700 Subject: [PATCH 12/23] chore: bump github/codeql-action/autobuild from 4.37.5 to 4.37.6 (#814) * chore: bump github/codeql-action/autobuild from 4.37.5 to 4.37.6 Bumps [github/codeql-action/autobuild](https://github.com/github/codeql-action) from 4.37.5 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/d1ba80a13dd99fba24a470575428917156a28b43...5595ccaf912efad79be6eef63a5619ff05969be3) --- updated-dependencies: - dependency-name: github/codeql-action/autobuild dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * chore: bump codeql-action init/analyze to 4.37.6 for consistency Co-authored-by: michaelawyu <14261500+michaelawyu@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: michaelawyu <14261500+michaelawyu@users.noreply.github.com> --- .github/workflows/codeql-analysis.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0b77d67c2..759c679d6 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -42,7 +42,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@d1ba80a13dd99fba24a470575428917156a28b43 # v4 + uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,4 +69,4 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4 From fc42415dab29ade1e2994b0f84d20a3916767c39 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:07:01 +1000 Subject: [PATCH 13/23] chore: Fix markdown-link-check failures from dead docs links and mailto validation (#826) --- .github/workflows/markdown.links.config.json | 7 ++++++- .squad/templates/skills/humanizer/SKILL.md | 2 +- SECURITY.md | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/markdown.links.config.json b/.github/workflows/markdown.links.config.json index c4914cd6e..d8007fb0a 100644 --- a/.github/workflows/markdown.links.config.json +++ b/.github/workflows/markdown.links.config.json @@ -7,5 +7,10 @@ "timeout": "5s", "retryOn429": true, "retryCount": 5, - "fallbackRetryDelay": "30s" + "fallbackRetryDelay": "30s", + "ignorePatterns": [ + { + "pattern": "^mailto:" + } + ] } diff --git a/.squad/templates/skills/humanizer/SKILL.md b/.squad/templates/skills/humanizer/SKILL.md index 4dbb854df..be00cc41f 100644 --- a/.squad/templates/skills/humanizer/SKILL.md +++ b/.squad/templates/skills/humanizer/SKILL.md @@ -28,7 +28,7 @@ Use this skill whenever PAO drafts external-facing responses for issues or discu 10. **Baseline comparison** — Responses should align with tone of 5-10 "gold standard" responses (>80% similarity threshold) 11. **Empathetic disagreement** — "We hear you. That's a fair concern." before explaining the reasoning 12. **Information request** — Ask for specific details, not open-ended "can you provide more info?" -13. **No link-dumping** — Don't just paste URLs. Provide context: "Check out the [getting started guide](url) — specifically the section on routing" not just a bare link +13. **No link-dumping** — Don't just paste URLs. Provide context: "Check out the getting started guide — specifically the section on routing" not just a bare link ## Examples diff --git a/SECURITY.md b/SECURITY.md index f00647786..d7efd229b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ being finalized: practice (typically 1–7 days for downstream coordination). - **Vendor advance notification: TBD.** Projects with downstream consumers commonly operate a distributors mailing list for embargo coordination with packagers and downstream forks - (see the [CNCF TAG-Security `SECURITY.md` template](https://github.com/cncf/tag-security/blob/main/project-resources/templates/SECURITY.md) + (see the [CNCF TAG-Security `SECURITY.md` template](https://github.com/cncf/tag-security) for the conventional `cncf--distributors-announce@lists.cncf.io` form). Whether KubeFleet stands one up depends on demonstrated downstream demand. - **GitHub private vulnerability reporting:** to be enabled on this repository as the From d0ea5e54c75ff1a836bd0de0f68806e313dae606 Mon Sep 17 00:00:00 2001 From: Simon Waight Date: Fri, 14 Aug 2026 16:19:58 +1000 Subject: [PATCH 14/23] chore: update maintainer details and contacts (#825) Update maintainer details and contacts Signed-off-by: Simon Waight Co-authored-by: Chen Yu --- CODE_OF_CONDUCT.md | 3 ++- MAINTAINERS.md | 18 +++++++++--------- README.md | 4 ++-- SECURITY.md | 2 +- SUPPORT.md | 9 +++------ 5 files changed, 17 insertions(+), 19 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index b3c1c8c5f..08c85443e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -50,6 +50,7 @@ Examples of unacceptable behavior include but are not limited to: * Other conduct which could reasonably be considered inappropriate in a professional setting The following behaviors are also prohibited: + * Providing knowingly false or misleading information in connection with a Code of Conduct investigation or otherwise intentionally tampering with an investigation. * Retaliating against a person because they reported an incident or provided information about an incident as a witness. @@ -65,7 +66,7 @@ permanently removed from the project team. ## Reporting Report abusive, harassing, or otherwise unacceptable behaviors in the KubeFleet community -to the project team at [kubefleet-maintainers@googlegroups.com](mailto:kubefleet-maintainers@googlegroups.com). +to the [KubeFleet maintainers](mailto:kubefleet@microsoft.com). All reports will be thoroughly reviewed and investigated, and a response will be prepared, as appropriate. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 4b0e8000f..faa0a5f1b 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -1,11 +1,11 @@ # The KubeFleet Maintainers -| Maintainer | Organization | GitHub Username | -|------------------|--------------|----------------------------------------------------| -| Ryan Zhang | Microsoft | [@ryanzhang-oss](https://github.com/ryanzhang-oss) | -| Zhiying Lin | Microsoft | [@zhiying-lin](https://github.com/zhiying-lin) | -| Chen Yu | Microsoft | [@michaelawyu](https://github.com/michaelawyu) | -| Wei Weng | Microsoft | [@weng271190436](https://github.com/weng271190436) | -| Yetkin Timocin | Microsoft | [@ytimocin](https://github.com/ytimocin) | -| Stéphane Erbrech | Microsoft | [@serbrech](https://github.com/serbrech) | -| Simon Waight | Microsoft | [@sjwaight](https://github.com/sjwaight) | +| Maintainer | Organization | GitHub Username | +|--------------------------|--------------|----------------------------------------------------| +| Chen Yu | Microsoft | [@michaelawyu](https://github.com/michaelawyu) | +| Britania Rodriguez Reyes | Microsoft | [britaniar](https://github.com/britaniar) | +| Zhiying Lin | Microsoft | [@zhiying-lin](https://github.com/zhiying-lin) | +| Wei Weng | Microsoft | [@weng271190436](https://github.com/weng271190436) | +| Yetkin Timocin | Microsoft | [@ytimocin](https://github.com/ytimocin) | +| Stéphane Erbrech | Microsoft | [@serbrech](https://github.com/serbrech) | +| Simon Waight | Microsoft | [@sjwaight](https://github.com/sjwaight) | diff --git a/README.md b/README.md index 950f12f24..ef002d747 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,9 @@ You can reach the KubeFleet community and developers via the following channels: ## Community Meetings -Please refer to the [calendar](https://zoom-lfx.platform.linuxfoundation.org/meetings/kubefleet?view=month) for the latest schedule. +We aim to hold one meeting per month. Community meetings for US/EU and APAC/India communities happen in alternate months. -We aim to hold one meeting per month for each of our US/EU and APAC/India communities. +Please refer to the [calendar](https://zoom-lfx.platform.linuxfoundation.org/meetings/kubefleet?view=month) for the latest schedule. For more meeting information please see the [KubeFleet community repository](https://github.com/kubefleet-dev/community#community-meetings). diff --git a/SECURITY.md b/SECURITY.md index d7efd229b..3ee4c6508 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -67,7 +67,7 @@ This section will be updated as each item is decided. ## Reporting Security Issues **Please do not report security vulnerabilities through public GitHub issues.** Instead, -report them to the [KubeFleet maintainers](mailto:kubefleet-maintainers@googlegroups.com). +report them to the [KubeFleet maintainers](mailto:kubefleet@microsoft.com). We prefer all communications to be in English. You should receive a response as soon as possible. If for some reason you do not, please diff --git a/SUPPORT.md b/SUPPORT.md index 97bc6626a..d6c63c85f 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -2,11 +2,8 @@ ## How to file issues and get help -This project uses GitHub Issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new Issue. +This project uses [GitHub Issues](https://github.com/kubefleet-dev/kubefleet/issues/) to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your [bug](https://github.com/kubefleet-dev/kubefleet/issues/new?template=bug_report.md) or [feature request](https://github.com/kubefleet-dev/kubefleet/issues/new?template=feature_request.md) as a new Issue. -For help and questions about using this project, please +For help and questions about using KubeFleet, please use our [GitHub Discussions](https://github.com/kubefleet-dev/kubefleet/discussions/). -* start the conversation in the [GitHub Discussions](https://github.com/kubefleet-dev/kubefleet/discussions/). - -We are actively exploring other means for developers, system admins, and anyone who has an interest -in the multi-cluster domain to engage with us. Please stay tuned. \ No newline at end of file +We are actively exploring other means for developers, system admins, and anyone who has an interest in the multi-cluster domain to engage with us. Please stay tuned. From 361c3efaa4809bbf515039d6fe4650c70703f7dc Mon Sep 17 00:00:00 2001 From: Yetkin Timocin Date: Mon, 17 Aug 2026 15:25:22 -0700 Subject: [PATCH 15/23] fix: harden release tag handling (#821) Three small hardening changes to how a release tag reaches the release workflows. All of these require repository write access to reach, so this is defence in depth rather than a fix for an externally reachable flaw. setup-release.yml interpolated ${{ inputs.tag }} directly into its script body, which expands the value in the shell before the tag regex can reject it. Backticks and $(...) are legal in git ref names and match the v*.*.* push filter, so both trigger paths were affected, not just workflow_dispatch. The tag now arrives as an environment variable, so validation runs before expansion. This makes that regex the single trust boundary for the outputs every downstream job interpolates into its own shell, in release.yml and chart.yml alike. The export job also declared no permissions, so it inherited the caller's - including contents: write and packages: write from chart.yml. It validates an input and writes step outputs, so it needs no token at all. `gh release create` gains --verify-tag. A tag that does not exist already fails earlier, at the checkout in build-and-publish, so this is not the last line of defence; it closes the narrower case where the ref resolves to something that is not the tag, after which gh would create the tag itself at the head of the default branch and the release would point at a different commit than the images. Part of #693. Signed-off-by: Yetkin Timocin --- .github/workflows/release.yml | 8 +++++++- .github/workflows/setup-release.yml | 18 +++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11479b110..66b082abc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -127,7 +127,13 @@ jobs: prerelease="" case "${TAG}" in *-*) prerelease="--prerelease" ;; esac # Create as a draft first so a partially uploaded release is never public. - gh release create "${TAG}" --title "${TAG}" --generate-notes --draft ${prerelease} + # --verify-tag: `gh release create` creates the tag itself when it is + # missing, pointing at the default branch's head. A tag that does not + # exist at all already fails earlier, at build-and-publish's checkout, + # so this guards the narrower case where the ref resolved to something + # that is not the tag - the release would then point at a different + # commit than the images were built from. + gh release create "${TAG}" --title "${TAG}" --generate-notes --draft --verify-tag ${prerelease} created_draft="true" fi gh release upload "${TAG}" \ diff --git a/.github/workflows/setup-release.yml b/.github/workflows/setup-release.yml index ab7aadcaf..65cd8984b 100644 --- a/.github/workflows/setup-release.yml +++ b/.github/workflows/setup-release.yml @@ -24,14 +24,30 @@ env: jobs: export: runs-on: ubuntu-latest + # Validates an input and writes step outputs; it touches no GitHub API, + # so it needs no token. Without this it inherits the caller's + # permissions, and chart.yml calls it with contents/packages write. + permissions: {} outputs: registry: ${{ steps.setup.outputs.registry }} tag: ${{ steps.setup.outputs.tag }} version: ${{ steps.setup.outputs.version }} steps: - id: setup + # The tag arrives as an environment variable rather than being + # interpolated into the script body: `TAG="${{ inputs.tag }}"` + # expands the value in the shell before the validation below can + # reject it. Both callers are affected, not just the + # workflow_dispatch input - backticks and $(...) are legal in git + # ref names and still match the v*.*.* push filter. Reaching + # either needs repository write access, so this is defence in + # depth, but the outputs below are interpolated into shell by + # every downstream job, which makes the regex the trust boundary + # for all of them. + env: + RELEASE_TAG: ${{ inputs.tag }} run: | - TAG="${{ inputs.tag }}" + TAG="${RELEASE_TAG}" if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then echo "Error: Invalid release tag '${TAG}'. Expected format: vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-rc.N" exit 1 From ab78630b514a620b403dfc3d198f472ce9f63c16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:42:13 -0700 Subject: [PATCH 16/23] chore: bump actions/checkout from 6.0.3 to 7.0.1 (#818) Pin every actions/checkout reference to the v7.0.1 commit, and update the Squad workflows that track the floating v7 tag to v7.0.1. Also correct the codespell workflow's version comment, which still read v4.1.7 next to a v7 commit pin. Signed-off-by: Yetkin Timocin Co-authored-by: Yetkin Timocin Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: michaelawyu <14261500+michaelawyu@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .github/workflows/backport.yml | 2 +- .github/workflows/chart.yml | 4 ++-- .github/workflows/ci.yml | 4 ++-- .github/workflows/code-lint.yml | 6 +++--- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/codespell.yml | 2 +- .github/workflows/markdown-lint.yml | 2 +- .github/workflows/release.yml | 4 ++-- .github/workflows/squad-ci.yml | 2 +- .github/workflows/squad-docs.yml | 2 +- .github/workflows/squad-heartbeat.yml | 2 +- .github/workflows/squad-insider-release.yml | 2 +- .github/workflows/squad-issue-assign.yml | 2 +- .github/workflows/squad-label-enforce.yml | 2 +- .github/workflows/squad-preview.yml | 2 +- .github/workflows/squad-promote.yml | 4 ++-- .github/workflows/squad-release.yml | 2 +- .github/workflows/squad-triage.yml | 2 +- .github/workflows/sync-squad-labels.yml | 2 +- .github/workflows/trivy.yml | 2 +- .github/workflows/upgrade.yml | 6 +++--- .github/workflows/workflow-lint.yml | 2 +- 22 files changed, 30 insertions(+), 30 deletions(-) diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 3bb6a659e..dbad00825 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -60,7 +60,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout base repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history: the merge commit and the release branches must both # be reachable for the cherry-pick. diff --git a/.github/workflows/chart.yml b/.github/workflows/chart.yml index 57c7965c5..478dc0328 100644 --- a/.github/workflows/chart.yml +++ b/.github/workflows/chart.yml @@ -39,7 +39,7 @@ jobs: group: helm-chart-publish-gh-pages cancel-in-progress: false steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: true fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Login to GitHub Container Registry uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce13e0dfe..2bf378b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Ginkgo CLI run: | @@ -118,7 +118,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Install Ginkgo CLI run: | diff --git a/.github/workflows/code-lint.yml b/.github/workflows/code-lint.yml index 35cac9527..ec9b9a12e 100644 --- a/.github/workflows/code-lint.yml +++ b/.github/workflows/code-lint.yml @@ -42,7 +42,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: true @@ -63,7 +63,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: golangci-lint run: make lint @@ -76,7 +76,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Helm uses: azure/setup-helm@dda3372f752e03dde6b3237bc9431cdc2f7a02a2 # v5 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 759c679d6..edaa4ff37 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index dfb9a51b0..27b6f5379 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -16,7 +16,7 @@ jobs: with: egress-policy: audit - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4.1.7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # master with: check_filenames: true diff --git a/.github/workflows/markdown-lint.yml b/.github/workflows/markdown-lint.yml index 5051e3f67..060e9ea87 100644 --- a/.github/workflows/markdown-lint.yml +++ b/.github/workflows/markdown-lint.yml @@ -10,7 +10,7 @@ jobs: markdown-link-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: tcort/github-action-markdown-link-check@e047c5b37f24ab722bbef1a27b6fab7f96bc4068 # v1 with: # this will only show errors in the output diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 66b082abc..33553a978 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,7 +49,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.export-registry.outputs.tag }} @@ -109,7 +109,7 @@ jobs: TAG: ${{ needs.export-registry.outputs.tag }} steps: - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.export-registry.outputs.tag }} diff --git a/.github/workflows/squad-ci.yml b/.github/workflows/squad-ci.yml index c5e2a3981..151d194e8 100644 --- a/.github/workflows/squad-ci.yml +++ b/.github/workflows/squad-ci.yml @@ -15,7 +15,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 - name: Build and test run: | diff --git a/.github/workflows/squad-docs.yml b/.github/workflows/squad-docs.yml index 209349bfe..d21d4f9be 100644 --- a/.github/workflows/squad-docs.yml +++ b/.github/workflows/squad-docs.yml @@ -18,7 +18,7 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 - name: Build docs run: | diff --git a/.github/workflows/squad-heartbeat.yml b/.github/workflows/squad-heartbeat.yml index 6c76c1f9e..2a034b684 100644 --- a/.github/workflows/squad-heartbeat.yml +++ b/.github/workflows/squad-heartbeat.yml @@ -25,7 +25,7 @@ jobs: heartbeat: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 - name: Check triage script id: check-script diff --git a/.github/workflows/squad-insider-release.yml b/.github/workflows/squad-insider-release.yml index c46826aa8..65512a7ae 100644 --- a/.github/workflows/squad-insider-release.yml +++ b/.github/workflows/squad-insider-release.yml @@ -12,7 +12,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml index 6705a9bb9..74163707a 100644 --- a/.github/workflows/squad-issue-assign.yml +++ b/.github/workflows/squad-issue-assign.yml @@ -14,7 +14,7 @@ jobs: if: startsWith(github.event.label.name, 'squad:') runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 - name: Identify assigned member and trigger work uses: actions/github-script@v9 diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml index a1dad6d12..bac19d50c 100644 --- a/.github/workflows/squad-label-enforce.yml +++ b/.github/workflows/squad-label-enforce.yml @@ -12,7 +12,7 @@ jobs: enforce: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 - name: Enforce mutual exclusivity uses: actions/github-script@v9 diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml index 3a2887517..6b3c7647e 100644 --- a/.github/workflows/squad-preview.yml +++ b/.github/workflows/squad-preview.yml @@ -12,7 +12,7 @@ jobs: validate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 - name: Build and test run: | diff --git a/.github/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml index daf829671..6be1912c0 100644 --- a/.github/workflows/squad-promote.yml +++ b/.github/workflows/squad-promote.yml @@ -18,7 +18,7 @@ jobs: name: Promote dev → preview runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} @@ -70,7 +70,7 @@ jobs: needs: dev-to-preview runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml index 15e6c0e67..82896f620 100644 --- a/.github/workflows/squad-release.yml +++ b/.github/workflows/squad-release.yml @@ -12,7 +12,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml index 001d1d905..af89291f8 100644 --- a/.github/workflows/squad-triage.yml +++ b/.github/workflows/squad-triage.yml @@ -13,7 +13,7 @@ jobs: if: github.event.label.name == 'squad' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 - name: Triage issue via Lead agent uses: actions/github-script@v9 diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml index 8415c2ef3..1cf5bab02 100644 --- a/.github/workflows/sync-squad-labels.yml +++ b/.github/workflows/sync-squad-labels.yml @@ -14,7 +14,7 @@ jobs: sync-labels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 - name: Parse roster and sync labels uses: actions/github-script@v9 diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 1df85de13..c23fabe6c 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -47,7 +47,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Login to ${{ env.REGISTRY }} uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index 230bddb3d..b1f8fd592 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -44,7 +44,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Fetch the history of all branches and tags. # This is needed for the test suite to switch between releases. @@ -127,7 +127,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Fetch the history of all branches and tags. # This is needed for the test suite to switch between releases. @@ -210,7 +210,7 @@ jobs: go-version: ${{ env.GO_VERSION }} - name: Check out code into the Go module directory - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Fetch the history of all branches and tags. # This is needed for the test suite to switch between releases. diff --git a/.github/workflows/workflow-lint.yml b/.github/workflows/workflow-lint.yml index 793bbcdcd..67febc7a6 100644 --- a/.github/workflows/workflow-lint.yml +++ b/.github/workflows/workflow-lint.yml @@ -27,7 +27,7 @@ jobs: egress-policy: audit - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Verify shellcheck is available run: shellcheck --version From a08f2979606da93baad4afafb56b19e3b9df9585 Mon Sep 17 00:00:00 2001 From: Britania Rodriguez Reyes <145056127+britaniar@users.noreply.github.com> Date: Mon, 17 Aug 2026 16:43:49 -0700 Subject: [PATCH 17/23] fix: update stale Trivy vulnerability database (#828) Use the actively maintained MCR Trivy database source so image scans receive current vulnerability advisories. Signed-off-by: Britania Rodriguez Reyes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/trivy.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index c23fabe6c..1c2294102 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -81,7 +81,7 @@ jobs: env: TRIVY_USERNAME: ${{ github.actor }} TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - TRIVY_DB_REPOSITORY: mcr.microsoft.com/mirror/ghcr/aquasecurity/trivy-db + TRIVY_DB_REPOSITORY: mcr.microsoft.com/oss/v2/aquasecurity/trivy-db - name: Scan ${{ env.REGISTRY }}/${{ env.MEMBER_AGENT_IMAGE_NAME }}:${{ env.IMAGE_VERSION }} uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 @@ -96,7 +96,7 @@ jobs: env: TRIVY_USERNAME: ${{ github.actor }} TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - TRIVY_DB_REPOSITORY: mcr.microsoft.com/mirror/ghcr/aquasecurity/trivy-db + TRIVY_DB_REPOSITORY: mcr.microsoft.com/oss/v2/aquasecurity/trivy-db - name: Scan ${{ env.REGISTRY }}/${{ env.REFRESH_TOKEN_IMAGE_NAME }}:${{ env.IMAGE_VERSION }} uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 @@ -111,7 +111,7 @@ jobs: env: TRIVY_USERNAME: ${{ github.actor }} TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} - TRIVY_DB_REPOSITORY: mcr.microsoft.com/mirror/ghcr/aquasecurity/trivy-db + TRIVY_DB_REPOSITORY: mcr.microsoft.com/oss/v2/aquasecurity/trivy-db - name: Check for vulnerabilities id: check-vulns From 7998d70f1c326f51bf0c0c0f0ba7726aa99c8fd8 Mon Sep 17 00:00:00 2001 From: Britania Rodriguez Reyes <145056127+britaniar@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:14:53 -0700 Subject: [PATCH 18/23] fix: notify security team for scheduled Trivy findings (#830) * fix: notify security team for scheduled Trivy findings Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Britania Rodriguez Reyes * ci: ignore authenticated Slack links Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Britania Rodriguez Reyes * chore: keep Trivy fix scoped Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Britania Rodriguez Reyes --------- Signed-off-by: Britania Rodriguez Reyes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/trivy.yml | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index 1c2294102..e3e355cb0 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -175,13 +175,15 @@ jobs: echo 'EOF' } >> "$GITHUB_OUTPUT" - - name: Create issue for Copilot + - name: Create or update security issue if: steps.check-vulns.outputs.has_vulns == 'true' && github.event_name == 'schedule' uses: actions/github-script@v9 with: script: | const today = new Date().toISOString().split('T')[0]; const title = `fix: address trivy CVEs found on ${today}`; + const securityTeam = '@kubefleet-dev/kubefleet-secops'; + const body = `${process.env.ISSUE_BODY}\n\n### Security owners\n${securityTeam}`; // Check if an open issue already exists for today const existing = await github.rest.issues.listForRepo({ @@ -191,22 +193,23 @@ jobs: labels: 'security,trivy', per_page: 100 }); - const alreadyExists = existing.data.some(i => i.title === title); - if (alreadyExists) { - console.log('Issue already exists for today, skipping.'); - return; + const issue = existing.data.find(i => i.title === title); + if (issue) { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: body + }); + console.log('Updated the existing issue with the current scan and security team mention.'); + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['security', 'trivy'] + }); } - - const body = process.env.ISSUE_BODY; - const issue = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: title, - body: body + '\n\n/cc @kubefleet-dev/kubefleet-secops', - labels: ['security', 'trivy'], - assignees: ['copilot'], - }); - console.log(`Created issue #${issue.data.number}`); env: ISSUE_BODY: ${{ steps.vuln-summary.outputs.body }} - From 9730e84603a7ef7c4406a1f64c4a90f15ce25f53 Mon Sep 17 00:00:00 2001 From: Akshita kumari <110122283+akshita317@users.noreply.github.com> Date: Wed, 19 Aug 2026 06:47:40 +0530 Subject: [PATCH 19/23] test: add coverage for pod and replicaset validating webhooks (#768) * test: add coverage for pod and replicaset validating webhooks The pod and replicaset validating webhooks were the only webhook packages without unit tests. Both deny creation outside the reserved fleet- and kube- namespaces, and both were untested for every branch of Handle. Add table-driven tests covering denial in a non-reserved namespace, admission in each reserved namespace prefix, pass-through of non-CREATE operations, and the decode failure path, following the existing pdb webhook test. Signed-off-by: Akshita <110122283+akshita317@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Akshita <110122283+akshita317@users.noreply.github.com> --------- Signed-off-by: Akshita <110122283+akshita317@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../pod/pod_validating_webhook_test.go | 182 ++++++++++++++++++ .../replicaset_validating_webhook_test.go | 182 ++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 pkg/webhook/pod/pod_validating_webhook_test.go create mode 100644 pkg/webhook/replicaset/replicaset_validating_webhook_test.go diff --git a/pkg/webhook/pod/pod_validating_webhook_test.go b/pkg/webhook/pod/pod_validating_webhook_test.go new file mode 100644 index 000000000..083c99a09 --- /dev/null +++ b/pkg/webhook/pod/pod_validating_webhook_test.go @@ -0,0 +1,182 @@ +/* +Copyright 2026 The KubeFleet 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 pod + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + admissionv1 "k8s.io/api/admission/v1" + authenticationv1 "k8s.io/api/authentication/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestHandle(t *testing.T) { + scheme := runtime.NewScheme() + if err := corev1.AddToScheme(scheme); err != nil { + t.Fatalf("corev1.AddToScheme() = %v, want nil", err) + } + decoder := admission.NewDecoder(scheme) + + podInDefaultNS := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "default", + }, + } + podInFleetNS := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "fleet-system", + }, + } + podInKubeNS := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "kube-system", + }, + } + + podInDefaultNSBytes, err := json.Marshal(podInDefaultNS) + if err != nil { + t.Fatalf("json.Marshal() = %v, want nil", err) + } + podInFleetNSBytes, err := json.Marshal(podInFleetNS) + if err != nil { + t.Fatalf("json.Marshal() = %v, want nil", err) + } + podInKubeNSBytes, err := json.Marshal(podInKubeNS) + if err != nil { + t.Fatalf("json.Marshal() = %v, want nil", err) + } + + userInfo := authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:authenticated"}, + } + + testCases := map[string]struct { + req admission.Request + wantResponse admission.Response + cmpOpts []cmp.Option + }{ + "deny CREATE in non-reserved namespace": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-pod", + Namespace: "default", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: podInDefaultNSBytes, + Object: podInDefaultNS, + }, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Denied(fmt.Sprintf(podDeniedFormat, "default", "test-pod")), + }, + "allow CREATE in fleet- reserved namespace": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-pod", + Namespace: "fleet-system", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: podInFleetNSBytes, + Object: podInFleetNS, + }, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Allowed(""), + }, + "allow CREATE in kube- reserved namespace": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-pod", + Namespace: "kube-system", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: podInKubeNSBytes, + Object: podInKubeNS, + }, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Allowed(""), + }, + "allow UPDATE (non-CREATE operation)": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-pod", + Namespace: "default", + Operation: admissionv1.Update, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Allowed(""), + }, + "allow DELETE (non-CREATE operation)": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-pod", + Namespace: "default", + Operation: admissionv1.Delete, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Allowed(""), + }, + "error on malformed request object": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-pod", + Namespace: "default", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: []byte("not valid json"), + }, + UserInfo: userInfo, + }, + }, + // The exact error message from the decoder is implementation-defined, + // so it is excluded from the comparison; the status code and + // allowed=false still are not. + wantResponse: admission.Errored(http.StatusBadRequest, errors.New("")), + cmpOpts: []cmp.Option{cmpopts.IgnoreFields(metav1.Status{}, "Message")}, + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + v := podValidator{decoder: decoder} + gotResponse := v.Handle(context.Background(), testCase.req) + if diff := cmp.Diff(gotResponse, testCase.wantResponse, testCase.cmpOpts...); diff != "" { + t.Errorf("Handle() mismatch (-got +want):\n%s", diff) + } + }) + } +} diff --git a/pkg/webhook/replicaset/replicaset_validating_webhook_test.go b/pkg/webhook/replicaset/replicaset_validating_webhook_test.go new file mode 100644 index 000000000..ac6becad0 --- /dev/null +++ b/pkg/webhook/replicaset/replicaset_validating_webhook_test.go @@ -0,0 +1,182 @@ +/* +Copyright 2026 The KubeFleet 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 replicaset + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + admissionv1 "k8s.io/api/admission/v1" + appsv1 "k8s.io/api/apps/v1" + authenticationv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func TestHandle(t *testing.T) { + scheme := runtime.NewScheme() + if err := appsv1.AddToScheme(scheme); err != nil { + t.Fatalf("appsv1.AddToScheme() = %v, want nil", err) + } + decoder := admission.NewDecoder(scheme) + + rsInDefaultNS := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "default", + }, + } + rsInFleetNS := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "fleet-system", + }, + } + rsInKubeNS := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-replicaset", + Namespace: "kube-system", + }, + } + + rsInDefaultNSBytes, err := json.Marshal(rsInDefaultNS) + if err != nil { + t.Fatalf("json.Marshal() = %v, want nil", err) + } + rsInFleetNSBytes, err := json.Marshal(rsInFleetNS) + if err != nil { + t.Fatalf("json.Marshal() = %v, want nil", err) + } + rsInKubeNSBytes, err := json.Marshal(rsInKubeNS) + if err != nil { + t.Fatalf("json.Marshal() = %v, want nil", err) + } + + userInfo := authenticationv1.UserInfo{ + Username: "test-user", + Groups: []string{"system:authenticated"}, + } + + testCases := map[string]struct { + req admission.Request + wantResponse admission.Response + cmpOpts []cmp.Option + }{ + "deny CREATE in non-reserved namespace": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-replicaset", + Namespace: "default", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: rsInDefaultNSBytes, + Object: rsInDefaultNS, + }, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Denied(fmt.Sprintf(replicaSetDeniedFormat, "default", "test-replicaset")), + }, + "allow CREATE in fleet- reserved namespace": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-replicaset", + Namespace: "fleet-system", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: rsInFleetNSBytes, + Object: rsInFleetNS, + }, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Allowed(""), + }, + "allow CREATE in kube- reserved namespace": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-replicaset", + Namespace: "kube-system", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: rsInKubeNSBytes, + Object: rsInKubeNS, + }, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Allowed(""), + }, + "allow UPDATE (non-CREATE operation)": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-replicaset", + Namespace: "default", + Operation: admissionv1.Update, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Allowed(""), + }, + "allow DELETE (non-CREATE operation)": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-replicaset", + Namespace: "default", + Operation: admissionv1.Delete, + UserInfo: userInfo, + }, + }, + wantResponse: admission.Allowed(""), + }, + "error on malformed request object": { + req: admission.Request{ + AdmissionRequest: admissionv1.AdmissionRequest{ + Name: "test-replicaset", + Namespace: "default", + Operation: admissionv1.Create, + Object: runtime.RawExtension{ + Raw: []byte("not valid json"), + }, + UserInfo: userInfo, + }, + }, + // The exact error message from the decoder is implementation-defined, + // so it is excluded from the comparison; the status code and + // allowed=false are still compared. + wantResponse: admission.Errored(http.StatusBadRequest, errors.New("")), + cmpOpts: []cmp.Option{cmpopts.IgnoreFields(metav1.Status{}, "Message")}, + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + v := replicaSetValidator{decoder: decoder} + gotResponse := v.Handle(context.Background(), testCase.req) + if diff := cmp.Diff(gotResponse, testCase.wantResponse, testCase.cmpOpts...); diff != "" { + t.Errorf("Handle() mismatch (-got +want):\n%s", diff) + } + }) + } +} From 7a3c60cd371320b8e099108ddca66f3d847f7cec Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:56:41 -0700 Subject: [PATCH 20/23] fix: update Go patch versions for Trivy CVEs (#836) * Initial plan * fix: update Go patch versions for CVE remediation Co-authored-by: britaniar <145056127+britaniar@users.noreply.github.com> * fix: align Go tooling to 1.26.6 Co-authored-by: britaniar <145056127+britaniar@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: britaniar <145056127+britaniar@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- .github/workflows/code-lint.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/trivy.yml | 2 +- .github/workflows/upgrade.yml | 2 +- .golangci.yml | 2 +- docker/hub-agent.Dockerfile | 2 +- docker/member-agent.Dockerfile | 2 +- docker/refresh-token.Dockerfile | 2 +- go.mod | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bf378b0b..24c7b2f78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ on: paths-ignore: [docs/**, "**.md", "**.mdx", "**.png", "**.jpg"] env: - GO_VERSION: '1.25.12' + GO_VERSION: '1.26.6' CERT_MANAGER_VERSION: 'v1.16.2' jobs: diff --git a/.github/workflows/code-lint.yml b/.github/workflows/code-lint.yml index ec9b9a12e..fd0445d21 100644 --- a/.github/workflows/code-lint.yml +++ b/.github/workflows/code-lint.yml @@ -14,7 +14,7 @@ on: env: # Common versions - GO_VERSION: "1.25.12" + GO_VERSION: "1.26.6" jobs: detect-noop: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33553a978..71a4cee5d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,7 @@ env: HUB_AGENT_IMAGE_NAME: hub-agent MEMBER_AGENT_IMAGE_NAME: member-agent REFRESH_TOKEN_IMAGE_NAME: refresh-token - GO_VERSION: "1.25.12" + GO_VERSION: "1.26.6" jobs: export-registry: diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index e3e355cb0..ad233d6cf 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -21,7 +21,7 @@ env: MEMBER_AGENT_IMAGE_NAME: member-agent REFRESH_TOKEN_IMAGE_NAME: refresh-token - GO_VERSION: '1.25.12' + GO_VERSION: '1.26.6' jobs: export-registry: diff --git a/.github/workflows/upgrade.yml b/.github/workflows/upgrade.yml index b1f8fd592..47b151128 100644 --- a/.github/workflows/upgrade.yml +++ b/.github/workflows/upgrade.yml @@ -17,7 +17,7 @@ on: paths-ignore: [docs/**, "**.md", "**.mdx", "**.png", "**.jpg"] env: - GO_VERSION: '1.25.12' + GO_VERSION: '1.26.6' jobs: detect-noop: diff --git a/.golangci.yml b/.golangci.yml index 17ee4aafe..c1e6993c4 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,6 +1,6 @@ run: timeout: 15m - go: '1.25.12' + go: '1.26.6' linters-settings: stylecheck: diff --git a/docker/hub-agent.Dockerfile b/docker/hub-agent.Dockerfile index 7ffe6b92e..62e5ed9c5 100644 --- a/docker/hub-agent.Dockerfile +++ b/docker/hub-agent.Dockerfile @@ -1,5 +1,5 @@ # Build the hubagent binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5-1 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.6-1 AS builder ARG GOOS=linux ARG GOARCH=amd64 diff --git a/docker/member-agent.Dockerfile b/docker/member-agent.Dockerfile index 48d3e7fb2..a2efd1282 100644 --- a/docker/member-agent.Dockerfile +++ b/docker/member-agent.Dockerfile @@ -1,5 +1,5 @@ # Build the memberagent binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5-1 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.6-1 AS builder ARG GOOS=linux ARG GOARCH=amd64 diff --git a/docker/refresh-token.Dockerfile b/docker/refresh-token.Dockerfile index 0a748499a..a42e04473 100644 --- a/docker/refresh-token.Dockerfile +++ b/docker/refresh-token.Dockerfile @@ -1,5 +1,5 @@ # Build the refreshtoken binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.5-1 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.6-1 AS builder ARG GOOS="linux" ARG GOARCH="amd64" diff --git a/go.mod b/go.mod index 53277740a..1c2789c6c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/kubefleet-dev/kubefleet -go 1.25.12 +go 1.26.6 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 From e160c1cdfdd9bf4c0234713d2b7f34e0ec95dab6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:29:19 -0700 Subject: [PATCH 21/23] chore: bump docker/login-action from 4.2.0 to 4.6.0 (#840) Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.6.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...dbcb813823bdd20940b903addbd779551569679f) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/chart.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/trivy.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/chart.yml b/.github/workflows/chart.yml index 478dc0328..600df71d6 100644 --- a/.github/workflows/chart.yml +++ b/.github/workflows/chart.yml @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Login to GitHub Container Registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 71a4cee5d..07762796d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,7 +54,7 @@ jobs: ref: ${{ needs.export-registry.outputs.tag }} - name: Login to ghcr.io - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index ad233d6cf..5c5fe85f2 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -50,7 +50,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Login to ${{ env.REGISTRY }} - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From b64a2c9a02f63ca0fa10665c5ebd5a912d4526d1 Mon Sep 17 00:00:00 2001 From: Britania Rodriguez Reyes Date: Thu, 20 Aug 2026 14:37:04 -0700 Subject: [PATCH 22/23] fix: update CRD installer Go version Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docker/crd-installer.Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/crd-installer.Dockerfile b/docker/crd-installer.Dockerfile index 2b51f648e..65c1706fc 100644 --- a/docker/crd-installer.Dockerfile +++ b/docker/crd-installer.Dockerfile @@ -1,5 +1,5 @@ # Build the crdinstaller binary -FROM mcr.microsoft.com/oss/go/microsoft/golang:1.25.12 AS builder +FROM mcr.microsoft.com/oss/go/microsoft/golang:1.26.6-1 AS builder ARG GOOS=linux ARG GOARCH=amd64 From dbbe3c9ce344a640e3f913b768b07080d8441cf3 Mon Sep 17 00:00:00 2001 From: Britania Rodriguez Reyes Date: Thu, 20 Aug 2026 15:01:27 -0700 Subject: [PATCH 23/23] ci: ignore authenticated Slack links Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/markdown.links.config.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/markdown.links.config.json b/.github/workflows/markdown.links.config.json index d8007fb0a..5f498d811 100644 --- a/.github/workflows/markdown.links.config.json +++ b/.github/workflows/markdown.links.config.json @@ -11,6 +11,9 @@ "ignorePatterns": [ { "pattern": "^mailto:" + }, + { + "pattern": "^https://cloud-native\\.slack\\.com/archives/" } ] }