Repository: rancher/fleet
Pull request #1627 (2023-07-03 15:56:57)

Add NamespaceLabels and NamespaceAnnotations
This adds the ability to append labels or annotations to the namespace created by fleet as part of the helm install process.

`NamespaceLabels` and `NamespaceAnnotations` are pointer to maps. Nothing will be done if they are nil, which means they are not provided by the user.

Helm creates the namespace with one label whose key is `name`. This is the only label we preserve, other labels or annotations that are not provided in `NamespaceLabels` or `NamespaceAnnotations` will be removed.

refers to https://github.com/rancher/fleet/issues/1553


Patch:
diff --git a/charts/fleet-crd/templates/crds.yaml b/charts/fleet-crd/templates/crds.yaml
index a9c6e9c6..c2fdbe74 100644
--- a/charts/fleet-crd/templates/crds.yaml
+++ b/charts/fleet-crd/templates/crds.yaml
@@ -216,6 +216,18 @@ spec:
               namespace:
                 nullable: true
                 type: string
+              namespaceAnnotations:
+                additionalProperties:
+                  nullable: true
+                  type: string
+                nullable: true
+                type: object
+              namespaceLabels:
+                additionalProperties:
+                  nullable: true
+                  type: string
+                nullable: true
+                type: object
               paused:
                 type: boolean
               resources:
@@ -617,6 +629,18 @@ spec:
                     namespace:
                       nullable: true
                       type: string
+                    namespaceAnnotations:
+                      additionalProperties:
+                        nullable: true
+                        type: string
+                      nullable: true
+                      type: object
+                    namespaceLabels:
+                      additionalProperties:
+                        nullable: true
+                        type: string
+                      nullable: true
+                      type: object
                     serviceAccount:
                       nullable: true
                       type: string
@@ -1161,6 +1185,18 @@ spec:
                   namespace:
                     nullable: true
                     type: string
+                  namespaceAnnotations:
+                    additionalProperties:
+                      nullable: true
+                      type: string
+                    nullable: true
+                    type: object
+                  namespaceLabels:
+                    additionalProperties:
+                      nullable: true
+                      type: string
+                    nullable: true
+                    type: object
                   serviceAccount:
                     nullable: true
                     type: string
@@ -1335,6 +1371,18 @@ spec:
                   namespace:
                     nullable: true
                     type: string
+                  namespaceAnnotations:
+                    additionalProperties:
+                      nullable: true
+                      type: string
+                    nullable: true
+                    type: object
+                  namespaceLabels:
+                    additionalProperties:
+                      nullable: true
+                      type: string
+                    nullable: true
+                    type: object
                   serviceAccount:
                     nullable: true
                     type: string
diff --git a/internal/cmd/agent/controllers/bundledeployment/controller.go b/internal/cmd/agent/controllers/bundledeployment/controller.go
index 46ef3963..a4967fe1 100644
--- a/internal/cmd/agent/controllers/bundledeployment/controller.go
+++ b/internal/cmd/agent/controllers/bundledeployment/controller.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"reflect"
 	"regexp"
 	"strings"
 	"sync"
@@ -22,13 +23,18 @@ import (
 	"github.com/rancher/wrangler/pkg/condition"
 	"github.com/rancher/wrangler/pkg/merr"
 
+	corev1 "k8s.io/api/core/v1"
 	"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/util/wait"
 	"k8s.io/client-go/dynamic"
 )
 
+var nsResource = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
+
 type handler struct {
 	cleanupOnce sync.Once
 
@@ -130,11 +136,113 @@ func (h *handler) DeployBundle(bd *fleet.BundleDeployment, status fleet.BundleDe
 	status.Release = release
 	status.AppliedDeploymentID = bd.Spec.DeploymentID
 
+	if err := h.setNamespaceLabelsAndAnnotations(bd, release); err != nil {
+		return fleet.BundleDeploymentStatus{}, err
+	}
+
 	// Setting the error to nil clears any existing error
 	condition.Cond(fleet.BundleDeploymentConditionInstalled).SetError(&status, "", nil)
 	return status, nil
 }
 
+// setNamespaceLabelsAndAnnotations updates the namespace for the release, applying all labels and annotations to that namespace as configured in the bundle spec.
+func (h *handler) setNamespaceLabelsAndAnnotations(bd *fleet.BundleDeployment, releaseID string) error {
+	if bd.Spec.Options.NamespaceLabels == nil && bd.Spec.Options.NamespaceAnnotations == nil {
+		return nil
+	}
+
+	ns, err := h.fetchNamespace(releaseID)
+	if err != nil {
+		return err
+	}
+
+	if reflect.DeepEqual(bd.Spec.Options.NamespaceLabels, ns.Labels) && reflect.DeepEqual(bd.Spec.Options.NamespaceAnnotations, ns.Annotations) {
+		return nil
+	}
+
+	if bd.Spec.Options.NamespaceLabels != nil {
+		addLabelsFromOptions(ns.Labels, *bd.Spec.Options.NamespaceLabels)
+	}
+	if bd.Spec.Options.NamespaceAnnotations != nil {
+		if ns.Annotations == nil {
+			ns.Annotations = map[string]string{}
+		}
+		addAnnotationsFromOptions(ns.Annotations, *bd.Spec.Options.NamespaceAnnotations)
+	}
+	err = h.updateNamespace(ns)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// updateNamespace updates a namespace resource in the cluster.
+func (h *handler) updateNamespace(ns *corev1.Namespace) error {
+	u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(ns)
+	if err != nil {
+		return err
+	}
+	_, err = h.dynamic.Resource(nsResource).Update(h.ctx, &unstructured.Unstructured{Object: u}, metav1.UpdateOptions{})
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// fetchNamespace gets the namespace matching the release ID. Returns an error if none is found.
+func (h *handler) fetchNamespace(releaseID string) (*corev1.Namespace, error) {
+	// releaseID is composed of release.Namespace/release.Name/release.Version
+	namespace := strings.Split(releaseID, "/")[0]
+	list, err := h.dynamic.Resource(nsResource).List(h.ctx, metav1.ListOptions{
+		LabelSelector: "name=" + namespace,
+	})
+	if err != nil {
+		return nil, err
+	}
+	if len(list.Items) == 0 {
+		return nil, fmt.Errorf("namespace %s not found", namespace)
+	}
+	var ns corev1.Namespace
+	err = runtime.DefaultUnstructuredConverter.
+		FromUnstructured(list.Items[0].Object, &ns)
+	if err != nil {
+		return nil, err
+	}
+
+	return &ns, nil
+}
+
+// addLabelsFromOptions updates nsLabels so that it only contains all labels specified in optLabels, plus the `name` labels added by Helm when creating the namespace.
+func addLabelsFromOptions(nsLabels map[string]string, optLabels map[string]string) {
+	for k, v := range optLabels {
+		nsLabels[k] = v
+	}
+
+	// Delete labels not defined in the options.
+	// Keep the name label as it is added by helm when creating the namespace.
+	for k := range nsLabels {
+		if _, ok := optLabels[k]; k != "name" && !ok {
+			delete(nsLabels, k)
+		}
+	}
+}
+
+// addAnnotationsFromOptions updates nsAnnotations so that it only contains all annotations specified in optAnnotations.
+func addAnnotationsFromOptions(nsAnnotations map[string]string, optAnnotations map[string]string) {
+	for k, v := range optAnnotations {
+		nsAnnotations[k] = v
+	}
+
+	// Delete Annotations not defined in the options.
+	for k := range nsAnnotations {
+		if _, ok := optAnnotations[k]; !ok {
+			delete(nsAnnotations, k)
+		}
+	}
+}
+
 // deployErrToStatus converts an error into a status update
 func deployErrToStatus(err error, status fleet.BundleDeploymentStatus) (bool, fleet.BundleDeploymentStatus) {
 	if err == nil {
diff --git a/internal/cmd/controller/mocks/dynamic_mock.go b/internal/cmd/controller/mocks/dynamic_mock.go
new file mode 100644
index 00000000..2134cfc6
--- /dev/null
+++ b/internal/cmd/controller/mocks/dynamic_mock.go
@@ -0,0 +1,431 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: github.com/rancher/client-go@v0.24.0-fleet1/dynamic/interface.go
+
+// Package mock_dynamic is a generated GoMock package.
+package mocks
+
+import (
+	context "context"
+	reflect "reflect"
+
+	gomock "github.com/golang/mock/gomock"
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	schema "k8s.io/apimachinery/pkg/runtime/schema"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	dynamic "k8s.io/client-go/dynamic"
+)
+
+// MockInterface is a mock of Interface interface.
+type MockInterface struct {
+	ctrl     *gomock.Controller
+	recorder *MockInterfaceMockRecorder
+}
+
+// MockInterfaceMockRecorder is the mock recorder for MockInterface.
+type MockInterfaceMockRecorder struct {
+	mock *MockInterface
+}
+
+// NewMockInterface creates a new mock instance.
+func NewMockInterface(ctrl *gomock.Controller) *MockInterface {
+	mock := &MockInterface{ctrl: ctrl}
+	mock.recorder = &MockInterfaceMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder {
+	return m.recorder
+}
+
+// Resource mocks base method.
+func (m *MockInterface) Resource(resource schema.GroupVersionResource) dynamic.NamespaceableResourceInterface {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Resource", resource)
+	ret0, _ := ret[0].(dynamic.NamespaceableResourceInterface)
+	return ret0
+}
+
+// Resource indicates an expected call of Resource.
+func (mr *MockInterfaceMockRecorder) Resource(resource interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resource", reflect.TypeOf((*MockInterface)(nil).Resource), resource)
+}
+
+// MockResourceInterface is a mock of ResourceInterface interface.
+type MockResourceInterface struct {
+	ctrl     *gomock.Controller
+	recorder *MockResourceInterfaceMockRecorder
+}
+
+// MockResourceInterfaceMockRecorder is the mock recorder for MockResourceInterface.
+type MockResourceInterfaceMockRecorder struct {
+	mock *MockResourceInterface
+}
+
+// NewMockResourceInterface creates a new mock instance.
+func NewMockResourceInterface(ctrl *gomock.Controller) *MockResourceInterface {
+	mock := &MockResourceInterface{ctrl: ctrl}
+	mock.recorder = &MockResourceInterfaceMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockResourceInterface) EXPECT() *MockResourceInterfaceMockRecorder {
+	return m.recorder
+}
+
+// Create mocks base method.
+func (m *MockResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options v1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, obj, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Create", varargs...)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Create indicates an expected call of Create.
+func (mr *MockResourceInterfaceMockRecorder) Create(ctx, obj, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, obj, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockResourceInterface)(nil).Create), varargs...)
+}
+
+// Delete mocks base method.
+func (m *MockResourceInterface) Delete(ctx context.Context, name string, options v1.DeleteOptions, subresources ...string) error {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, name, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Delete", varargs...)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Delete indicates an expected call of Delete.
+func (mr *MockResourceInterfaceMockRecorder) Delete(ctx, name, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, name, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockResourceInterface)(nil).Delete), varargs...)
+}
+
+// DeleteCollection mocks base method.
+func (m *MockResourceInterface) DeleteCollection(ctx context.Context, options v1.DeleteOptions, listOptions v1.ListOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeleteCollection", ctx, options, listOptions)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeleteCollection indicates an expected call of DeleteCollection.
+func (mr *MockResourceInterfaceMockRecorder) DeleteCollection(ctx, options, listOptions interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCollection", reflect.TypeOf((*MockResourceInterface)(nil).DeleteCollection), ctx, options, listOptions)
+}
+
+// Get mocks base method.
+func (m *MockResourceInterface) Get(ctx context.Context, name string, options v1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, name, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Get", varargs...)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockResourceInterfaceMockRecorder) Get(ctx, name, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, name, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockResourceInterface)(nil).Get), varargs...)
+}
+
+// List mocks base method.
+func (m *MockResourceInterface) List(ctx context.Context, opts v1.ListOptions) (*unstructured.UnstructuredList, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "List", ctx, opts)
+	ret0, _ := ret[0].(*unstructured.UnstructuredList)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// List indicates an expected call of List.
+func (mr *MockResourceInterfaceMockRecorder) List(ctx, opts interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockResourceInterface)(nil).List), ctx, opts)
+}
+
+// Patch mocks base method.
+func (m *MockResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options v1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, name, pt, data, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Patch", varargs...)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Patch indicates an expected call of Patch.
+func (mr *MockResourceInterfaceMockRecorder) Patch(ctx, name, pt, data, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, name, pt, data, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockResourceInterface)(nil).Patch), varargs...)
+}
+
+// Update mocks base method.
+func (m *MockResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options v1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, obj, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Update", varargs...)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Update indicates an expected call of Update.
+func (mr *MockResourceInterfaceMockRecorder) Update(ctx, obj, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, obj, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockResourceInterface)(nil).Update), varargs...)
+}
+
+// UpdateStatus mocks base method.
+func (m *MockResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options v1.UpdateOptions) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdateStatus", ctx, obj, options)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// UpdateStatus indicates an expected call of UpdateStatus.
+func (mr *MockResourceInterfaceMockRecorder) UpdateStatus(ctx, obj, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockResourceInterface)(nil).UpdateStatus), ctx, obj, options)
+}
+
+// Watch mocks base method.
+func (m *MockResourceInterface) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Watch", ctx, opts)
+	ret0, _ := ret[0].(watch.Interface)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Watch indicates an expected call of Watch.
+func (mr *MockResourceInterfaceMockRecorder) Watch(ctx, opts interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Watch", reflect.TypeOf((*MockResourceInterface)(nil).Watch), ctx, opts)
+}
+
+// MockNamespaceableResourceInterface is a mock of NamespaceableResourceInterface interface.
+type MockNamespaceableResourceInterface struct {
+	ctrl     *gomock.Controller
+	recorder *MockNamespaceableResourceInterfaceMockRecorder
+}
+
+// MockNamespaceableResourceInterfaceMockRecorder is the mock recorder for MockNamespaceableResourceInterface.
+type MockNamespaceableResourceInterfaceMockRecorder struct {
+	mock *MockNamespaceableResourceInterface
+}
+
+// NewMockNamespaceableResourceInterface creates a new mock instance.
+func NewMockNamespaceableResourceInterface(ctrl *gomock.Controller) *MockNamespaceableResourceInterface {
+	mock := &MockNamespaceableResourceInterface{ctrl: ctrl}
+	mock.recorder = &MockNamespaceableResourceInterfaceMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockNamespaceableResourceInterface) EXPECT() *MockNamespaceableResourceInterfaceMockRecorder {
+	return m.recorder
+}
+
+// Create mocks base method.
+func (m *MockNamespaceableResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options v1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, obj, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Create", varargs...)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Create indicates an expected call of Create.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) Create(ctx, obj, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, obj, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).Create), varargs...)
+}
+
+// Delete mocks base method.
+func (m *MockNamespaceableResourceInterface) Delete(ctx context.Context, name string, options v1.DeleteOptions, subresources ...string) error {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, name, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Delete", varargs...)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Delete indicates an expected call of Delete.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) Delete(ctx, name, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, name, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).Delete), varargs...)
+}
+
+// DeleteCollection mocks base method.
+func (m *MockNamespaceableResourceInterface) DeleteCollection(ctx context.Context, options v1.DeleteOptions, listOptions v1.ListOptions) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "DeleteCollection", ctx, options, listOptions)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// DeleteCollection indicates an expected call of DeleteCollection.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) DeleteCollection(ctx, options, listOptions interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCollection", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).DeleteCollection), ctx, options, listOptions)
+}
+
+// Get mocks base method.
+func (m *MockNamespaceableResourceInterface) Get(ctx context.Context, name string, options v1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, name, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Get", varargs...)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) Get(ctx, name, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, name, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).Get), varargs...)
+}
+
+// List mocks base method.
+func (m *MockNamespaceableResourceInterface) List(ctx context.Context, opts v1.ListOptions) (*unstructured.UnstructuredList, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "List", ctx, opts)
+	ret0, _ := ret[0].(*unstructured.UnstructuredList)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// List indicates an expected call of List.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) List(ctx, opts interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).List), ctx, opts)
+}
+
+// Namespace mocks base method.
+func (m *MockNamespaceableResourceInterface) Namespace(arg0 string) dynamic.ResourceInterface {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Namespace", arg0)
+	ret0, _ := ret[0].(dynamic.ResourceInterface)
+	return ret0
+}
+
+// Namespace indicates an expected call of Namespace.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) Namespace(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Namespace", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).Namespace), arg0)
+}
+
+// Patch mocks base method.
+func (m *MockNamespaceableResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options v1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, name, pt, data, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Patch", varargs...)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Patch indicates an expected call of Patch.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) Patch(ctx, name, pt, data, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, name, pt, data, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Patch", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).Patch), varargs...)
+}
+
+// Update mocks base method.
+func (m *MockNamespaceableResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options v1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	varargs := []interface{}{ctx, obj, options}
+	for _, a := range subresources {
+		varargs = append(varargs, a)
+	}
+	ret := m.ctrl.Call(m, "Update", varargs...)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Update indicates an expected call of Update.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) Update(ctx, obj, options interface{}, subresources ...interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	varargs := append([]interface{}{ctx, obj, options}, subresources...)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).Update), varargs...)
+}
+
+// UpdateStatus mocks base method.
+func (m *MockNamespaceableResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options v1.UpdateOptions) (*unstructured.Unstructured, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdateStatus", ctx, obj, options)
+	ret0, _ := ret[0].(*unstructured.Unstructured)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// UpdateStatus indicates an expected call of UpdateStatus.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) UpdateStatus(ctx, obj, options interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateStatus", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).UpdateStatus), ctx, obj, options)
+}
+
+// Watch mocks base method.
+func (m *MockNamespaceableResourceInterface) Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Watch", ctx, opts)
+	ret0, _ := ret[0].(watch.Interface)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Watch indicates an expected call of Watch.
+func (mr *MockNamespaceableResourceInterfaceMockRecorder) Watch(ctx, opts interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Watch", reflect.TypeOf((*MockNamespaceableResourceInterface)(nil).Watch), ctx, opts)
+}
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/bundle.go b/pkg/apis/fleet.cattle.io/v1alpha1/bundle.go
index 3ea2355a..90c8be3b 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/bundle.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/bundle.go
@@ -244,6 +244,12 @@ type BundleDeploymentOptions struct {
 
 	// CorrectDrift specifies how drift correction should work.
 	CorrectDrift CorrectDrift `json:"correctDrift,omitempty"`
+
+	// NamespaceLabels are labels that will be appended to the namespace created by Fleet.
+	NamespaceLabels *map[string]string `json:"namespaceLabels,omitempty"`
+
+	// NamespaceAnnotations are annotations that will be appended to the namespace created by Fleet.
+	NamespaceAnnotations *map[string]string `json:"namespaceAnnotations,omitempty"`
 }
 
 type DiffOptions struct {
diff --git a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated_deepcopy.go b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated_deepcopy.go
index a08e2755..feaae867 100644
--- a/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated_deepcopy.go
+++ b/pkg/apis/fleet.cattle.io/v1alpha1/zz_generated_deepcopy.go
@@ -202,6 +202,28 @@ func (in *BundleDeploymentOptions) DeepCopyInto(out *BundleDeploymentOptions) {
 	}
 	in.IgnoreOptions.DeepCopyInto(&out.IgnoreOptions)
 	out.CorrectDrift = in.CorrectDrift
+	if in.NamespaceLabels != nil {
+		in, out := &in.NamespaceLabels, &out.NamespaceLabels
+		*out = new(map[string]string)
+		if **in != nil {
+			in, out := *in, *out
+			*out = make(map[string]string, len(*in))
+			for key, val := range *in {
+				(*out)[key] = val
+			}
+		}
+	}
+	if in.NamespaceAnnotations != nil {
+		in, out := &in.NamespaceAnnotations, &out.NamespaceAnnotations
+		*out = new(map[string]string)
+		if **in != nil {
+			in, out := *in, *out
+			*out = make(map[string]string, len(*in))
+			for key, val := range *in {
+				(*out)[key] = val
+			}
+		}
+	}
 	return
 }
 


Test patch:
diff --git a/internal/cmd/agent/controllers/bundledeployment/controller_test.go b/internal/cmd/agent/controllers/bundledeployment/controller_test.go
new file mode 100644
index 00000000..26ffd2fd
--- /dev/null
+++ b/internal/cmd/agent/controllers/bundledeployment/controller_test.go
@@ -0,0 +1,147 @@
+package bundledeployment
+
+import (
+	"errors"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/rancher/fleet/internal/cmd/controller/mocks"
+	fleet "github.com/rancher/fleet/pkg/apis/fleet.cattle.io/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+func TestSetNamespaceLabelsAndAnnotations(t *testing.T) {
+	tests := map[string]struct {
+		bd         *fleet.BundleDeployment
+		ns         corev1.Namespace
+		release    string
+		expectedNs corev1.Namespace
+	}{
+		"NamespaceLabels and NamespaceAnnotations are appended": {
+			bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+				Options: fleet.BundleDeploymentOptions{
+					NamespaceLabels:      &map[string]string{"optLabel1": "optValue1", "optLabel2": "optValue2"},
+					NamespaceAnnotations: &map[string]string{"optAnn1": "optValue1"},
+				},
+			}},
+			ns: corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{"name": "test"},
+				},
+			},
+			release: "namespace/foo/bar",
+			expectedNs: corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels:      map[string]string{"name": "test", "optLabel1": "optValue1", "optLabel2": "optValue2"},
+					Annotations: map[string]string{"optAnn1": "optValue1"},
+				},
+			},
+		},
+
+		"NamespaceLabels and NamespaceAnnotations removes entries that are not in the options, except the name label": {
+			bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+				Options: fleet.BundleDeploymentOptions{
+					NamespaceLabels:      &map[string]string{"optLabel": "optValue"},
+					NamespaceAnnotations: &map[string]string{},
+				},
+			}},
+			ns: corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels:      map[string]string{"nsLabel": "nsValue", "name": "test"},
+					Annotations: map[string]string{"nsAnn": "nsValue"},
+				},
+			},
+			release: "namespace/foo/bar",
+			expectedNs: corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels:      map[string]string{"optLabel": "optValue", "name": "test"},
+					Annotations: map[string]string{},
+				},
+			},
+		},
+
+		"NamespaceLabels and NamespaceAnnotations updates existing values": {
+			bd: &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+				Options: fleet.BundleDeploymentOptions{
+					NamespaceLabels:      &map[string]string{"bdLabel": "labelUpdated"},
+					NamespaceAnnotations: &map[string]string{"bdAnn": "annUpdated"},
+				},
+			}},
+			ns: corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels:      map[string]string{"bdLabel": "nsValue"},
+					Annotations: map[string]string{"bdAnn": "nsValue"},
+				},
+			},
+			release: "namespace/foo/bar",
+			expectedNs: corev1.Namespace{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels:      map[string]string{"bdLabel": "labelUpdated"},
+					Annotations: map[string]string{"bdAnn": "annUpdated"},
+				},
+			},
+		},
+	}
+
+	for name, test := range tests {
+		t.Run(name, func(t *testing.T) {
+			ctrl := gomock.NewController(t)
+			defer ctrl.Finish()
+
+			mockDynamic := mocks.NewMockInterface(ctrl)
+			mockNamespaceableResourceInterface := mocks.NewMockNamespaceableResourceInterface(ctrl)
+			u, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&test.ns)
+			// Resource will be called twice, one time for UPDATE and another time for LIST
+			mockDynamic.EXPECT().Resource(gomock.Any()).Return(mockNamespaceableResourceInterface).Times(2)
+			mockNamespaceableResourceInterface.EXPECT().List(gomock.Any(), metav1.ListOptions{
+				LabelSelector: "name=namespace",
+			}).Return(&unstructured.UnstructuredList{
+				Items: []unstructured.Unstructured{{Object: u}},
+			}, nil).Times(1)
+			uns, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&test.expectedNs)
+			mockNamespaceableResourceInterface.EXPECT().Update(gomock.Any(), &unstructured.Unstructured{Object: uns}, gomock.Any()).Times(1)
+
+			h := handler{
+				dynamic: mockDynamic,
+			}
+			err := h.setNamespaceLabelsAndAnnotations(test.bd, test.release)
+
+			if err != nil {
+				t.Errorf("expected nil error: got %v", err)
+			}
+		})
+	}
+}
+
+func TestSetNamespaceLabelsAndAnnotationsError(t *testing.T) {
+	bd := &fleet.BundleDeployment{Spec: fleet.BundleDeploymentSpec{
+		Options: fleet.BundleDeploymentOptions{
+			NamespaceLabels:      &map[string]string{"optLabel1": "optValue1", "optLabel2": "optValue2"},
+			NamespaceAnnotations: &map[string]string{"optAnn1": "optValue1"},
+		},
+	}}
+	release := "test/foo/bar"
+	expectedErr := errors.New("namespace test not found")
+
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	mockDynamic := mocks.NewMockInterface(ctrl)
+	mockNamespaceableResourceInterface := mocks.NewMockNamespaceableResourceInterface(ctrl)
+	mockDynamic.EXPECT().Resource(gomock.Any()).Return(mockNamespaceableResourceInterface).Times(1)
+	mockNamespaceableResourceInterface.EXPECT().List(gomock.Any(), metav1.ListOptions{
+		LabelSelector: "name=test",
+	}).Return(&unstructured.UnstructuredList{
+		Items: []unstructured.Unstructured{},
+	}, nil).Times(1)
+	h := handler{
+		dynamic: mockDynamic,
+	}
+	err := h.setNamespaceLabelsAndAnnotations(bd, release)
+
+	if err.Error() != expectedErr.Error() {
+		t.Errorf("expected error %v: got %v", expectedErr, err)
+	}
+}