From 86350eabcc2b384745ae80a4c65c3732b00e8c38 Mon Sep 17 00:00:00 2001 From: jzywieck Date: Wed, 8 Apr 2026 14:56:16 +0200 Subject: [PATCH 1/7] add app crd Signed-off-by: jzywieck --- PROJECT | 9 + api/apps/v1alpha1/app_types.go | 131 +++++++ api/apps/v1alpha1/zz_generated.deepcopy.go | 150 +++++++ cmd/main.go | 7 + config/crd/bases/apps.splunk.com_apps.yaml | 370 ++++++++++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/apps_app_admin_role.yaml | 27 ++ config/rbac/apps_app_editor_role.yaml | 33 ++ config/rbac/apps_app_viewer_role.yaml | 29 ++ config/rbac/kustomization.yaml | 3 + config/rbac/role.yaml | 3 + config/samples/apps_v1alpha1_app.yaml | 29 ++ config/samples/kustomization.yaml | 1 + internal/controller/apps/app_controller.go | 63 +++ .../controller/apps/app_controller_test.go | 84 ++++ 15 files changed, 940 insertions(+) create mode 100644 api/apps/v1alpha1/app_types.go create mode 100644 config/crd/bases/apps.splunk.com_apps.yaml create mode 100644 config/rbac/apps_app_admin_role.yaml create mode 100644 config/rbac/apps_app_editor_role.yaml create mode 100644 config/rbac/apps_app_viewer_role.yaml create mode 100644 config/samples/apps_v1alpha1_app.yaml create mode 100644 internal/controller/apps/app_controller.go create mode 100644 internal/controller/apps/app_controller_test.go diff --git a/PROJECT b/PROJECT index 44fae8561..3397ad96b 100644 --- a/PROJECT +++ b/PROJECT @@ -150,4 +150,13 @@ resources: kind: AppSource path: github.com/splunk/splunk-operator/api/apps/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: splunk.com + group: apps + kind: App + path: github.com/splunk/splunk-operator/api/apps/v1alpha1 + version: v1alpha1 version: "3" diff --git a/api/apps/v1alpha1/app_types.go b/api/apps/v1alpha1/app_types.go new file mode 100644 index 000000000..5fce12882 --- /dev/null +++ b/api/apps/v1alpha1/app_types.go @@ -0,0 +1,131 @@ +// Copyright (c) 2018-2026 Splunk Inc. All rights reserved. + +// 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 ( + // AppPausedAnnotation is the annotation that pauses the reconciliation (triggers + // an immediate requeue) + AppPausedAnnotation = "app.enterprise.splunk.com/paused" +) + +// AppTargetRef defines the target environment the app should bind to. +type AppTargetRef struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Kind string `json:"kind"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Name string `json:"name"` +} + +// AppPackageSpec defines the package location within the source. +type AppPackageSpec struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Path string `json:"path"` +} + +// AppSpec defines the desired state of App. +type AppSpec struct { + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + AppID string `json:"appID"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Version string `json:"version"` + + // +kubebuilder:validation:Required + TargetRef AppTargetRef `json:"targetRef"` + + // +kubebuilder:validation:Required + SourceRef AppSource `json:"sourceRef"` + + // +kubebuilder:validation:Required + Package AppPackageSpec `json:"package"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + Scope string `json:"scope"` +} + +// AppStatus defines the observed state of App. +type AppStatus struct { + // Phase of the app resource + Phase string `json:"phase,omitempty"` + + // Auxillary message describing CR status + Message string `json:"message,omitempty"` + + // InstalledVersion is the app version installed on target + InstalledVersion string `json:"installedVersion,omitempty"` + + // Artifact tracks the resolved app package details + Artifact *AppArtifactStatus `json:"artifact,omitempty"` + + // ObservedGeneration tracks the latest reconciled generation + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Conditions represent the latest available observations of the app + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// AppArtifactStatus defines resolved app artifact details. +type AppArtifactStatus struct { + // ResolvedPath is the resolved artifact path within the source + ResolvedPath string `json:"resolvedPath,omitempty"` + + // Etag is the artifact hash or ETag used for change detection + Etag string `json:"etag,omitempty"` + + // LastFetchedTime is the last time the artifact was fetched + LastFetchedTime metav1.Time `json:"lastFetchedTime,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// App is the Schema for the apps API. +// +k8s:openapi-gen=true +// +kubebuilder:resource:path=apps,scope=Namespaced +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase",description="Status of app" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp",description="Age of app resource" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.message",description="Auxillary message describing CR status" +// +kubebuilder:storageversion +type App struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + Spec AppSpec `json:"spec"` + Status AppStatus `json:"status,omitempty,omitzero"` +} + +// +kubebuilder:object:root=true + +// AppList contains a list of App. +type AppList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []App `json:"items"` +} + +func init() { + SchemeBuilder.Register(&App{}, &AppList{}) +} diff --git a/api/apps/v1alpha1/zz_generated.deepcopy.go b/api/apps/v1alpha1/zz_generated.deepcopy.go index f91ba2348..872dd43be 100644 --- a/api/apps/v1alpha1/zz_generated.deepcopy.go +++ b/api/apps/v1alpha1/zz_generated.deepcopy.go @@ -25,6 +25,96 @@ import ( runtime "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *App) DeepCopyInto(out *App) { + *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 App. +func (in *App) DeepCopy() *App { + if in == nil { + return nil + } + out := new(App) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *App) 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 *AppArtifactStatus) DeepCopyInto(out *AppArtifactStatus) { + *out = *in + in.LastFetchedTime.DeepCopyInto(&out.LastFetchedTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppArtifactStatus. +func (in *AppArtifactStatus) DeepCopy() *AppArtifactStatus { + if in == nil { + return nil + } + out := new(AppArtifactStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppList) DeepCopyInto(out *AppList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]App, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppList. +func (in *AppList) DeepCopy() *AppList { + if in == nil { + return nil + } + out := new(AppList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *AppList) 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 *AppPackageSpec) DeepCopyInto(out *AppPackageSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppPackageSpec. +func (in *AppPackageSpec) DeepCopy() *AppPackageSpec { + if in == nil { + return nil + } + out := new(AppPackageSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AppSource) DeepCopyInto(out *AppSource) { *out = *in @@ -186,3 +276,63 @@ func (in *AppSourceStatus) DeepCopy() *AppSourceStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppSpec) DeepCopyInto(out *AppSpec) { + *out = *in + out.TargetRef = in.TargetRef + in.SourceRef.DeepCopyInto(&out.SourceRef) + out.Package = in.Package +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppSpec. +func (in *AppSpec) DeepCopy() *AppSpec { + if in == nil { + return nil + } + out := new(AppSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppStatus) DeepCopyInto(out *AppStatus) { + *out = *in + if in.Artifact != nil { + in, out := &in.Artifact, &out.Artifact + *out = new(AppArtifactStatus) + (*in).DeepCopyInto(*out) + } + 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]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppStatus. +func (in *AppStatus) DeepCopy() *AppStatus { + if in == nil { + return nil + } + out := new(AppStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppTargetRef) DeepCopyInto(out *AppTargetRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppTargetRef. +func (in *AppTargetRef) DeepCopy() *AppTargetRef { + if in == nil { + return nil + } + out := new(AppTargetRef) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index 9e61b7276..468037185 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -326,6 +326,13 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "AppSource") os.Exit(1) } + if err := (&appscontroller.AppReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "App") + os.Exit(1) + } //+kubebuilder:scaffold:builder // Register certificate watchers with the manager diff --git a/config/crd/bases/apps.splunk.com_apps.yaml b/config/crd/bases/apps.splunk.com_apps.yaml new file mode 100644 index 000000000..cabc1f5d6 --- /dev/null +++ b/config/crd/bases/apps.splunk.com_apps.yaml @@ -0,0 +1,370 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: apps.apps.splunk.com +spec: + group: apps.splunk.com + names: + kind: App + listKind: AppList + plural: apps + singular: app + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Status of app + jsonPath: .status.phase + name: Phase + type: string + - description: Age of app resource + jsonPath: .metadata.creationTimestamp + name: Age + type: date + - description: Auxillary message describing CR status + jsonPath: .status.message + name: Message + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: App is the Schema for the apps API. + 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: AppSpec defines the desired state of App. + properties: + appID: + minLength: 1 + type: string + package: + description: AppPackageSpec defines the package location within the + source. + properties: + path: + minLength: 1 + type: string + required: + - path + type: object + scope: + minLength: 1 + type: string + sourceRef: + description: AppSource is the Schema for the appsources API. + 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: AppSourceSpec defines the desired state of AppSource. + properties: + auth: + description: Authentication configuration + properties: + secretRef: + description: |- + LocalObjectReference contains enough information to let you locate the + referenced object inside the same namespace. + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + required: + - secretRef + type: object + git: + description: Git specific configuration + properties: + ref: + default: main + type: string + repo: + type: string + required: + - repo + type: object + pollIntervalSeconds: + default: 60 + description: PollIntervalSeconds is the interval in seconds + to poll remote repository + format: int32 + type: integer + s3: + description: S3 specific configuration + properties: + basePath: + type: string + bucket: + type: string + endpoint: + type: string + region: + type: string + required: + - endpoint + type: object + type: + description: |- + Type of the App Source + Valid values are "git", "s3", "gcp", "azure" + enum: + - git + - s3 + - gcp + - azure + type: string + required: + - auth + - type + type: object + x-kubernetes-validations: + - message: s3 configuration is required when type is s3 + rule: self.type != 's3' || has(self.s3) + - message: git configuration is required when type is git + rule: self.type != 'git' || has(self.git) + - message: exactly one of s3 or git must be specified + rule: '[has(self.s3), has(self.git)].filter(x, x == true).size() + == 1' + status: + description: AppSourceStatus defines the observed state of AppSource. + properties: + condition: + description: Conditions represent the current state of the + AppSource + 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 + lastPolledTime: + description: LastPolledTime represents the last time the AppSource + was polled + format: date-time + type: string + observedGeneration: + description: |- + ObservedGeneration represents the most recent generation observed for this AppSource + This will be used to determine if the AppSource needs to be reconciled + format: int64 + type: integer + type: object + type: object + targetRef: + description: AppTargetRef defines the target environment the app should + bind to. + properties: + kind: + minLength: 1 + type: string + name: + minLength: 1 + type: string + required: + - kind + - name + type: object + version: + minLength: 1 + type: string + required: + - appID + - package + - scope + - sourceRef + - targetRef + - version + type: object + status: + description: AppStatus defines the observed state of App. + properties: + artifact: + description: Artifact tracks the resolved app package details + properties: + etag: + description: Etag is the artifact hash or ETag used for change + detection + type: string + lastFetchedTime: + description: LastFetchedTime is the last time the artifact was + fetched + format: date-time + type: string + resolvedPath: + description: ResolvedPath is the resolved artifact path within + the source + type: string + type: object + conditions: + description: Conditions represent the latest available observations + of the app + 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 + installedVersion: + description: InstalledVersion is the app version installed on target + type: string + message: + description: Auxillary message describing CR status + type: string + observedGeneration: + description: ObservedGeneration tracks the latest reconciled generation + format: int64 + type: integer + phase: + description: Phase of the app resource + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 79c5bfb92..b6882604b 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -14,6 +14,7 @@ resources: - bases/enterprise.splunk.com_queues.yaml - bases/enterprise.splunk.com_objectstorages.yaml - bases/apps.splunk.com_appsources.yaml +- bases/apps.splunk.com_apps.yaml #+kubebuilder:scaffold:crdkustomizeresource diff --git a/config/rbac/apps_app_admin_role.yaml b/config/rbac/apps_app_admin_role.yaml new file mode 100644 index 000000000..ffa48fda5 --- /dev/null +++ b/config/rbac/apps_app_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project splunk-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over apps.splunk.com. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: apps-app-admin-role +rules: +- apiGroups: + - apps.splunk.com + resources: + - apps + verbs: + - '*' +- apiGroups: + - apps.splunk.com + resources: + - apps/status + verbs: + - get diff --git a/config/rbac/apps_app_editor_role.yaml b/config/rbac/apps_app_editor_role.yaml new file mode 100644 index 000000000..3f8a938d7 --- /dev/null +++ b/config/rbac/apps_app_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project splunk-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the apps.splunk.com. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: apps-app-editor-role +rules: +- apiGroups: + - apps.splunk.com + resources: + - apps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apps.splunk.com + resources: + - apps/status + verbs: + - get diff --git a/config/rbac/apps_app_viewer_role.yaml b/config/rbac/apps_app_viewer_role.yaml new file mode 100644 index 000000000..a3162d17e --- /dev/null +++ b/config/rbac/apps_app_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project splunk-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to apps.splunk.com resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: apps-app-viewer-role +rules: +- apiGroups: + - apps.splunk.com + resources: + - apps + verbs: + - get + - list + - watch +- apiGroups: + - apps.splunk.com + resources: + - apps/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 7a749f7e7..4ce205d4c 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -21,6 +21,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the splunk-operator itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- apps_app_admin_role.yaml +- apps_app_editor_role.yaml +- apps_app_viewer_role.yaml - apps_appsource_admin_role.yaml - apps_appsource_editor_role.yaml - apps_appsource_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 2e6aa754e..b70d057c0 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -47,6 +47,7 @@ rules: - apiGroups: - apps.splunk.com resources: + - apps - appsources verbs: - create @@ -59,12 +60,14 @@ rules: - apiGroups: - apps.splunk.com resources: + - apps/finalizers - appsources/finalizers verbs: - update - apiGroups: - apps.splunk.com resources: + - apps/status - appsources/status verbs: - get diff --git a/config/samples/apps_v1alpha1_app.yaml b/config/samples/apps_v1alpha1_app.yaml new file mode 100644 index 000000000..3147f41b7 --- /dev/null +++ b/config/samples/apps_v1alpha1_app.yaml @@ -0,0 +1,29 @@ +apiVersion: apps.splunk.com/v1alpha1 +kind: App +metadata: + labels: + app.kubernetes.io/name: splunk-operator + app.kubernetes.io/managed-by: kustomize + name: app-sample +spec: + appID: sample-app + version: "1.0.0" + targetRef: + kind: Standalone + name: standalone-sample + sourceRef: + kind: AppSource + apiVersion: apps.splunk.com/v1alpha1 + spec: + type: s3 + s3: + endpoint: "https://s3.amazonaws.com" + bucket: "my-bucket" + basePath: "my-path" + auth: + secretRef: + name: "my-secret" + polling: 60 + package: + path: apps/sample-app.tgz + scope: local diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index e55533bed..5caa932f4 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -17,4 +17,5 @@ resources: - enterprise_v4_queue.yaml - enterprise_v4_objectstorage.yaml - apps_v1alpha1_appsource.yaml +- apps_v1alpha1_app.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/internal/controller/apps/app_controller.go b/internal/controller/apps/app_controller.go new file mode 100644 index 000000000..e4c54e566 --- /dev/null +++ b/internal/controller/apps/app_controller.go @@ -0,0 +1,63 @@ +/* +Copyright 2021. + +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 apps + +import ( + "context" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + appsv1alpha1 "github.com/splunk/splunk-operator/api/apps/v1alpha1" +) + +// AppReconciler reconciles a App object +type AppReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=apps.splunk.com,resources=apps,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=apps.splunk.com,resources=apps/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=apps.splunk.com,resources=apps/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// TODO(user): Modify the Reconcile function to compare the state specified by +// the App object against the actual cluster state, and then +// perform operations to make the cluster state reflect the state specified by +// the user. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.21.0/pkg/reconcile +func (r *AppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + _ = logf.FromContext(ctx) + + // TODO(user): your logic here + + return ctrl.Result{}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *AppReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&appsv1alpha1.App{}). + Named("apps-app"). + Complete(r) +} diff --git a/internal/controller/apps/app_controller_test.go b/internal/controller/apps/app_controller_test.go new file mode 100644 index 000000000..e2f448500 --- /dev/null +++ b/internal/controller/apps/app_controller_test.go @@ -0,0 +1,84 @@ +/* +Copyright 2021. + +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 apps + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + appsv1alpha1 "github.com/splunk/splunk-operator/api/apps/v1alpha1" +) + +var _ = Describe("App Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + app := &appsv1alpha1.App{} + + BeforeEach(func() { + By("creating the custom resource for the Kind App") + err := k8sClient.Get(ctx, typeNamespacedName, app) + if err != nil && errors.IsNotFound(err) { + resource := &appsv1alpha1.App{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + // TODO(user): Specify other spec details if needed. + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &appsv1alpha1.App{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance App") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &AppReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) From 99d10ebe56978ca6ff62d6aeade9a7463a1f4135 Mon Sep 17 00:00:00 2001 From: jzywieck Date: Fri, 10 Apr 2026 15:36:44 +0200 Subject: [PATCH 2/7] fix: Change AppPausedAnnotation to apps endpoint Signed-off-by: jzywieck --- api/apps/v1alpha1/app_types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/apps/v1alpha1/app_types.go b/api/apps/v1alpha1/app_types.go index 5fce12882..deb2c9505 100644 --- a/api/apps/v1alpha1/app_types.go +++ b/api/apps/v1alpha1/app_types.go @@ -21,7 +21,7 @@ import ( const ( // AppPausedAnnotation is the annotation that pauses the reconciliation (triggers // an immediate requeue) - AppPausedAnnotation = "app.enterprise.splunk.com/paused" + AppPausedAnnotation = "apps.splunk.com/paused" ) // AppTargetRef defines the target environment the app should bind to. From f5518fe75797f15c5cdb260665065ec1fec69b43 Mon Sep 17 00:00:00 2001 From: jzywieck Date: Fri, 10 Apr 2026 17:17:33 +0200 Subject: [PATCH 3/7] fix: align the App CRD with ERD Signed-off-by: jzywieck --- api/apps/v1alpha1/app_types.go | 8 +- api/apps/v1alpha1/zz_generated.deepcopy.go | 19 ++- config/crd/bases/apps.splunk.com_apps.yaml | 176 +-------------------- config/samples/apps_v1alpha1_app.yaml | 13 +- 4 files changed, 29 insertions(+), 187 deletions(-) diff --git a/api/apps/v1alpha1/app_types.go b/api/apps/v1alpha1/app_types.go index deb2c9505..606557b65 100644 --- a/api/apps/v1alpha1/app_types.go +++ b/api/apps/v1alpha1/app_types.go @@ -42,6 +42,12 @@ type AppPackageSpec struct { Path string `json:"path"` } +// AppSourceSpec defines the app source details. +type AppSourceRef struct { + // +kubebuilder:validation:Required + Name string `json:"name"` +} + // AppSpec defines the desired state of App. type AppSpec struct { // +kubebuilder:validation:Required @@ -56,7 +62,7 @@ type AppSpec struct { TargetRef AppTargetRef `json:"targetRef"` // +kubebuilder:validation:Required - SourceRef AppSource `json:"sourceRef"` + SourceRef AppSourceRef `json:"sourceRef"` // +kubebuilder:validation:Required Package AppPackageSpec `json:"package"` diff --git a/api/apps/v1alpha1/zz_generated.deepcopy.go b/api/apps/v1alpha1/zz_generated.deepcopy.go index 872dd43be..8d23256d1 100644 --- a/api/apps/v1alpha1/zz_generated.deepcopy.go +++ b/api/apps/v1alpha1/zz_generated.deepcopy.go @@ -30,7 +30,7 @@ func (in *App) DeepCopyInto(out *App) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - in.Spec.DeepCopyInto(&out.Spec) + out.Spec = in.Spec in.Status.DeepCopyInto(&out.Status) } @@ -205,6 +205,21 @@ func (in *AppSourceList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AppSourceRef) DeepCopyInto(out *AppSourceRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AppSourceRef. +func (in *AppSourceRef) DeepCopy() *AppSourceRef { + if in == nil { + return nil + } + out := new(AppSourceRef) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AppSourceS3Spec) DeepCopyInto(out *AppSourceS3Spec) { *out = *in @@ -281,7 +296,7 @@ func (in *AppSourceStatus) DeepCopy() *AppSourceStatus { func (in *AppSpec) DeepCopyInto(out *AppSpec) { *out = *in out.TargetRef = in.TargetRef - in.SourceRef.DeepCopyInto(&out.SourceRef) + out.SourceRef = in.SourceRef out.Package = in.Package } diff --git a/config/crd/bases/apps.splunk.com_apps.yaml b/config/crd/bases/apps.splunk.com_apps.yaml index cabc1f5d6..f5359f04d 100644 --- a/config/crd/bases/apps.splunk.com_apps.yaml +++ b/config/crd/bases/apps.splunk.com_apps.yaml @@ -69,180 +69,12 @@ spec: minLength: 1 type: string sourceRef: - description: AppSource is the Schema for the appsources API. + description: AppSourceSpec defines the app source details. 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 + name: type: string - metadata: - type: object - spec: - description: AppSourceSpec defines the desired state of AppSource. - properties: - auth: - description: Authentication configuration - properties: - secretRef: - description: |- - LocalObjectReference contains enough information to let you locate the - referenced object inside the same namespace. - properties: - name: - default: "" - description: |- - Name of the referent. - This field is effectively required, but due to backwards compatibility is - allowed to be empty. Instances of this type with an empty value here are - almost certainly wrong. - More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names - type: string - type: object - x-kubernetes-map-type: atomic - required: - - secretRef - type: object - git: - description: Git specific configuration - properties: - ref: - default: main - type: string - repo: - type: string - required: - - repo - type: object - pollIntervalSeconds: - default: 60 - description: PollIntervalSeconds is the interval in seconds - to poll remote repository - format: int32 - type: integer - s3: - description: S3 specific configuration - properties: - basePath: - type: string - bucket: - type: string - endpoint: - type: string - region: - type: string - required: - - endpoint - type: object - type: - description: |- - Type of the App Source - Valid values are "git", "s3", "gcp", "azure" - enum: - - git - - s3 - - gcp - - azure - type: string - required: - - auth - - type - type: object - x-kubernetes-validations: - - message: s3 configuration is required when type is s3 - rule: self.type != 's3' || has(self.s3) - - message: git configuration is required when type is git - rule: self.type != 'git' || has(self.git) - - message: exactly one of s3 or git must be specified - rule: '[has(self.s3), has(self.git)].filter(x, x == true).size() - == 1' - status: - description: AppSourceStatus defines the observed state of AppSource. - properties: - condition: - description: Conditions represent the current state of the - AppSource - 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 - lastPolledTime: - description: LastPolledTime represents the last time the AppSource - was polled - format: date-time - type: string - observedGeneration: - description: |- - ObservedGeneration represents the most recent generation observed for this AppSource - This will be used to determine if the AppSource needs to be reconciled - format: int64 - type: integer - type: object + required: + - name type: object targetRef: description: AppTargetRef defines the target environment the app should diff --git a/config/samples/apps_v1alpha1_app.yaml b/config/samples/apps_v1alpha1_app.yaml index 3147f41b7..8d4db3abb 100644 --- a/config/samples/apps_v1alpha1_app.yaml +++ b/config/samples/apps_v1alpha1_app.yaml @@ -12,18 +12,7 @@ spec: kind: Standalone name: standalone-sample sourceRef: - kind: AppSource - apiVersion: apps.splunk.com/v1alpha1 - spec: - type: s3 - s3: - endpoint: "https://s3.amazonaws.com" - bucket: "my-bucket" - basePath: "my-path" - auth: - secretRef: - name: "my-secret" - polling: 60 + name: appsource-sample-s3 package: path: apps/sample-app.tgz scope: local From b80ace9af2d72b817f873d94f5bf4ba0823f5773 Mon Sep 17 00:00:00 2001 From: jzywieck Date: Fri, 10 Apr 2026 17:19:20 +0200 Subject: [PATCH 4/7] feat: add field validation to appSourceRef Signed-off-by: jzywieck --- api/apps/v1alpha1/app_types.go | 1 + config/crd/bases/apps.splunk.com_apps.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/api/apps/v1alpha1/app_types.go b/api/apps/v1alpha1/app_types.go index 606557b65..4211ca144 100644 --- a/api/apps/v1alpha1/app_types.go +++ b/api/apps/v1alpha1/app_types.go @@ -45,6 +45,7 @@ type AppPackageSpec struct { // AppSourceSpec defines the app source details. type AppSourceRef struct { // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 Name string `json:"name"` } diff --git a/config/crd/bases/apps.splunk.com_apps.yaml b/config/crd/bases/apps.splunk.com_apps.yaml index f5359f04d..b09cc1c90 100644 --- a/config/crd/bases/apps.splunk.com_apps.yaml +++ b/config/crd/bases/apps.splunk.com_apps.yaml @@ -72,6 +72,7 @@ spec: description: AppSourceSpec defines the app source details. properties: name: + minLength: 1 type: string required: - name From f6b189dbc093b3e39530a457cef0f313d93840fc Mon Sep 17 00:00:00 2001 From: jzywieck Date: Fri, 10 Apr 2026 15:27:25 +0200 Subject: [PATCH 5/7] feat: add app validation Signed-off-by: jzywieck --- cmd/main.go | 2 +- config/crd/bases/apps.splunk.com_apps.yaml | 9 + config/webhook/manifests.yaml | 9 + .../enterprise/validation/app_validation.go | 190 +++++++++++++++ .../validation/app_validation_test.go | 223 ++++++++++++++++++ pkg/splunk/enterprise/validation/registry.go | 177 +++++++------- pkg/splunk/enterprise/validation/validate.go | 2 + 7 files changed, 528 insertions(+), 84 deletions(-) create mode 100644 pkg/splunk/enterprise/validation/app_validation.go create mode 100644 pkg/splunk/enterprise/validation/app_validation_test.go diff --git a/cmd/main.go b/cmd/main.go index 468037185..fef385798 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -303,7 +303,7 @@ func main() { webhookServer := validation.NewWebhookServer(validation.WebhookServerOptions{ Port: 9443, CertDir: "/tmp/k8s-webhook-server/serving-certs", - Validators: validation.DefaultValidators, + Validators: validation.DefaultValidators(mgr.GetClient()), ReadTimeout: readTimeout, WriteTimeout: writeTimeout, }) diff --git a/config/crd/bases/apps.splunk.com_apps.yaml b/config/crd/bases/apps.splunk.com_apps.yaml index b09cc1c90..2df9d0624 100644 --- a/config/crd/bases/apps.splunk.com_apps.yaml +++ b/config/crd/bases/apps.splunk.com_apps.yaml @@ -102,6 +102,15 @@ spec: - targetRef - version type: object + x-kubernetes-validations: + - message: appID is immutable once created + rule: self.appID == oldSelf.appID + - message: targetRef is immutable once created + rule: self.targetRef == oldSelf.targetRef + - message: sourceRef is immutable once created + rule: self.sourceRef == oldSelf.sourceRef + - message: scope is immutable once created + rule: self.scope == oldSelf.scope status: description: AppStatus defines the observed state of App. properties: diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index f534bd66b..e6d02eaa5 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -28,4 +28,13 @@ webhooks: - clustermanagers - licensemanagers - monitoringconsoles + - apiGroups: + - apps.splunk.com + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - apps sideEffects: None diff --git a/pkg/splunk/enterprise/validation/app_validation.go b/pkg/splunk/enterprise/validation/app_validation.go new file mode 100644 index 000000000..63f029192 --- /dev/null +++ b/pkg/splunk/enterprise/validation/app_validation.go @@ -0,0 +1,190 @@ +/* +Copyright (c) 2018-2026 Splunk Inc. All rights reserved. + +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 validation + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "sigs.k8s.io/controller-runtime/pkg/client" + + appsv1alpha1 "github.com/splunk/splunk-operator/api/apps/v1alpha1" +) + +// AppValidator validates App resources at admission time. +// This implements the Validator interface and can be used +// with the generic validation registry to route App validation +// requests to this struct. +type AppValidator struct { + k8sClient client.Client +} + +// NewAppValidator creates an App validator backed by a Kubernetes client. +func NewAppValidator(k8sClient client.Client) Validator { + return &AppValidator{k8sClient: k8sClient} +} + +// ValidateCreate validates an App on CREATE. +func (v *AppValidator) ValidateCreate(obj runtime.Object) field.ErrorList { + app, ok := obj.(*appsv1alpha1.App) + if !ok { + return field.ErrorList{field.InternalError(nil, + &TypeAssertionError{Expected: &appsv1alpha1.App{}, Actual: obj})} + } + + return ValidateAppCreate(v.k8sClient, app) +} + +// ValidateUpdate validates an App on UPDATE. +func (v *AppValidator) ValidateUpdate(obj, oldObj runtime.Object) field.ErrorList { + app, ok := obj.(*appsv1alpha1.App) + if !ok { + return field.ErrorList{field.InternalError(nil, + &TypeAssertionError{Expected: &appsv1alpha1.App{}, Actual: obj})} + } + + oldApp, ok := oldObj.(*appsv1alpha1.App) + if !ok { + return field.ErrorList{field.InternalError(nil, + &TypeAssertionError{Expected: &appsv1alpha1.App{}, Actual: oldObj})} + } + + return ValidateAppUpdate(v.k8sClient, app, oldApp) +} + +// GetGroupKind returns the GroupKind for App. +func (v *AppValidator) GetGroupKind(obj runtime.Object) schema.GroupKind { + return schema.GroupKind{Group: appsv1alpha1.GroupVersion.Group, Kind: "App"} +} + +// GetName returns the App name. +func (v *AppValidator) GetName(obj runtime.Object) string { + app, ok := obj.(*appsv1alpha1.App) + if !ok { + return "" + } + + return app.GetName() +} + +// GetWarningsOnCreate returns warnings for App CREATE. +func (v *AppValidator) GetWarningsOnCreate(obj runtime.Object) []string { + app, ok := obj.(*appsv1alpha1.App) + if !ok { + return nil + } + + return GetAppWarningsOnCreate(app) +} + +// GetWarningsOnUpdate returns warnings for App UPDATE. +func (v *AppValidator) GetWarningsOnUpdate(obj, oldObj runtime.Object) []string { + app, ok := obj.(*appsv1alpha1.App) + if !ok { + return nil + } + + oldApp, ok := oldObj.(*appsv1alpha1.App) + if !ok { + return nil + } + + return GetAppWarningsOnUpdate(app, oldApp) +} + +// ValidateAppCreate validates an App on CREATE. +func ValidateAppCreate(k8sClient client.Client, obj *appsv1alpha1.App) field.ErrorList { + return validateApp(context.Background(), k8sClient, obj) +} + +// ValidateAppUpdate validates an App on UPDATE. +func ValidateAppUpdate(k8sClient client.Client, obj, _ *appsv1alpha1.App) field.ErrorList { + return validateApp(context.Background(), k8sClient, obj) +} + +// GetAppWarningsOnCreate returns warnings for App CREATE. +func GetAppWarningsOnCreate(obj *appsv1alpha1.App) []string { + return nil +} + +// GetAppWarningsOnUpdate returns warnings for App UPDATE. +func GetAppWarningsOnUpdate(obj, oldObj *appsv1alpha1.App) []string { + return nil +} + +func validateApp(ctx context.Context, k8sClient client.Client, app *appsv1alpha1.App) field.ErrorList { + if k8sClient == nil { + return field.ErrorList{field.InternalError(field.NewPath("spec"), fmt.Errorf("kubernetes client is required for App validation"))} + } + + var allErrs field.ErrorList + allErrs = append(allErrs, validateAppSourceRef(ctx, k8sClient, app)...) + allErrs = append(allErrs, validateAppUniqueness(ctx, k8sClient, app)...) + + return allErrs +} + +func validateAppSourceRef(ctx context.Context, k8sClient client.Client, app *appsv1alpha1.App) field.ErrorList { + var allErrs field.ErrorList + + sourceRefPath := field.NewPath("spec").Child("sourceRef").Child("metadata").Child("name") + key := client.ObjectKey{Name: app.Spec.SourceRef.Name, Namespace: app.Namespace} + + var source appsv1alpha1.AppSource + if err := k8sClient.Get(ctx, key, &source); err != nil { + if apierrors.IsNotFound(err) { + allErrs = append(allErrs, field.NotFound(sourceRefPath, app.Spec.SourceRef.Name)) + return allErrs + } + + allErrs = append(allErrs, field.InternalError(sourceRefPath, fmt.Errorf("failed to validate AppSource reference: %w", err))) + } + + return allErrs +} + +func validateAppUniqueness(ctx context.Context, k8sClient client.Client, app *appsv1alpha1.App) field.ErrorList { + var allErrs field.ErrorList + + var appList appsv1alpha1.AppList + if err := k8sClient.List(ctx, &appList, client.InNamespace(app.Namespace)); err != nil { + return field.ErrorList{field.InternalError(field.NewPath("spec"), fmt.Errorf("failed to validate App uniqueness: %w", err))} + } + + for i := range appList.Items { + other := &appList.Items[i] + if other.Name == app.Name { + continue + } + + if other.Spec.AppID == app.Spec.AppID && + other.Spec.Scope == app.Spec.Scope && + other.Spec.TargetRef == app.Spec.TargetRef { + allErrs = append(allErrs, field.Invalid( + field.NewPath("spec"), + fmt.Sprintf("%s/%s:%s:%s/%s", app.Namespace, app.Name, app.Spec.AppID, app.Spec.TargetRef.Kind, app.Spec.TargetRef.Name), + fmt.Sprintf("another App %q already exists in namespace %q with the same targetRef, appID, and scope", other.Name, app.Namespace))) + break + } + } + + return allErrs +} diff --git a/pkg/splunk/enterprise/validation/app_validation_test.go b/pkg/splunk/enterprise/validation/app_validation_test.go new file mode 100644 index 000000000..b9020b813 --- /dev/null +++ b/pkg/splunk/enterprise/validation/app_validation_test.go @@ -0,0 +1,223 @@ +/* +Copyright (c) 2018-2026 Splunk Inc. All rights reserved. + +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 validation + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + admissionv1 "k8s.io/api/admission/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + appsv1alpha1 "github.com/splunk/splunk-operator/api/apps/v1alpha1" +) + +func TestValidateAppCreate(t *testing.T) { + tests := []struct { + name string + app *appsv1alpha1.App + objects []client.Object + wantErrCount int + wantErrFields []string + wantMessage string + }{ + { + name: "valid app", + app: newValidApp("app-one"), + objects: []client.Object{ + newAppSource("source-one"), + }, + wantErrCount: 0, + }, + { + name: "missing referenced AppSource", + app: newValidApp("app-one"), + wantErrCount: 1, + wantErrFields: []string{ + "spec.sourceRef.metadata.name", + }, + wantMessage: "Not found", + }, + { + name: "duplicate target appID scope tuple", + app: newValidApp("app-two"), + objects: []client.Object{ + newAppSource("source-one"), + newValidApp("app-existing"), + }, + wantErrCount: 1, + wantErrFields: []string{ + "spec", + }, + wantMessage: "same targetRef, appID, and scope", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateAppCreate(newValidationClient(t, tt.objects...), tt.app) + require.Len(t, errs, tt.wantErrCount) + + for i, wantField := range tt.wantErrFields { + assert.Equal(t, wantField, errs[i].Field) + } + + if tt.wantMessage != "" && len(errs) > 0 { + assert.Contains(t, errs[0].Error(), tt.wantMessage) + } + }) + } +} + +func TestValidateAppUpdate(t *testing.T) { + tests := []struct { + name string + app *appsv1alpha1.App + oldApp *appsv1alpha1.App + objects []client.Object + wantErrCount int + wantErrFields []string + }{ + { + name: "webhook allows mutable field changes", + app: func() *appsv1alpha1.App { + app := newValidApp("app-one") + app.Spec.Version = "2.0.0" + app.Spec.Package.Path = "apps/sample-app-v2.tgz" + return app + }(), + oldApp: func() *appsv1alpha1.App { + app := newValidApp("app-one") + app.Spec.Version = "1.0.0" + app.Spec.Package.Path = "apps/sample-app-v1.tgz" + return app + }(), + objects: []client.Object{ + newAppSource("source-one"), + }, + wantErrCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errs := ValidateAppUpdate(newValidationClient(t, tt.objects...), tt.app, tt.oldApp) + require.Len(t, errs, tt.wantErrCount) + + for i, wantField := range tt.wantErrFields { + assert.Equal(t, wantField, errs[i].Field) + } + }) + } +} + +func TestValidateAdmissionReviewForApp(t *testing.T) { + app := newValidApp("app-one") + validators := map[schema.GroupVersionResource]Validator{ + AppGVR: NewAppValidator(newValidationClient(t, newAppSource("source-one"))), + } + + raw, err := json.Marshal(app) + require.NoError(t, err) + + warnings, err := Validate(&admissionv1.AdmissionReview{ + Request: &admissionv1.AdmissionRequest{ + UID: "app-test-uid", + Operation: admissionv1.Create, + Resource: metav1.GroupVersionResource{ + Group: appsv1alpha1.GroupVersion.Group, + Version: appsv1alpha1.GroupVersion.Version, + Resource: "apps", + }, + Object: runtime.RawExtension{Raw: raw}, + }, + }, validators) + + assert.NoError(t, err) + assert.Empty(t, warnings) +} + +func newValidationClient(t *testing.T, objects ...client.Object) client.Client { + t.Helper() + + scheme := runtime.NewScheme() + require.NoError(t, appsv1alpha1.AddToScheme(scheme)) + + return fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() +} + +func newValidApp(name string) *appsv1alpha1.App { + return &appsv1alpha1.App{ + TypeMeta: metav1.TypeMeta{ + APIVersion: appsv1alpha1.GroupVersion.String(), + Kind: "App", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: appsv1alpha1.AppSpec{ + AppID: "sample-app", + Version: "1.0.0", + TargetRef: appsv1alpha1.AppTargetRef{ + Kind: "Standalone", + Name: "standalone-sample", + }, + SourceRef: appsv1alpha1.AppSource{ + TypeMeta: metav1.TypeMeta{ + APIVersion: appsv1alpha1.GroupVersion.String(), + Kind: "AppSource", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "source-one", + }, + }, + Package: appsv1alpha1.AppPackageSpec{ + Path: "apps/sample-app.tgz", + }, + Scope: "local", + }, + } +} + +func newAppSource(name string) *appsv1alpha1.AppSource { + return &appsv1alpha1.AppSource{ + TypeMeta: metav1.TypeMeta{ + APIVersion: appsv1alpha1.GroupVersion.String(), + Kind: "AppSource", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: appsv1alpha1.AppSourceSpec{ + Type: "s3", + S3: &appsv1alpha1.AppSourceS3Spec{ + Endpoint: "https://s3.amazonaws.com", + }, + }, + } +} diff --git a/pkg/splunk/enterprise/validation/registry.go b/pkg/splunk/enterprise/validation/registry.go index 695183a8f..c4cfee5db 100644 --- a/pkg/splunk/enterprise/validation/registry.go +++ b/pkg/splunk/enterprise/validation/registry.go @@ -18,6 +18,7 @@ package validation import ( "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" enterpriseApi "github.com/splunk/splunk-operator/api/enterprise/v4" ) @@ -71,97 +72,107 @@ var ( Version: "v4", Resource: "monitoringconsoles", } + + AppGVR = schema.GroupVersionResource{ + Group: "apps.splunk.com", + Version: "v1alpha1", + Resource: "apps", + } ) -// DefaultValidators is the registry of validators for all Splunk Enterprise CRDs -var DefaultValidators = map[schema.GroupVersionResource]Validator{ - StandaloneGVR: &GenericValidator[*enterpriseApi.Standalone]{ - ValidateCreateFunc: ValidateStandaloneCreate, - ValidateUpdateFunc: ValidateStandaloneUpdate, - WarningsOnCreateFunc: GetStandaloneWarningsOnCreate, - WarningsOnUpdateFunc: GetStandaloneWarningsOnUpdate, - GroupKind: schema.GroupKind{ - Group: "enterprise.splunk.com", - Kind: "Standalone", +// DefaultValidators returns the validator registry used by the admission webhook. +func DefaultValidators(k8sClient client.Client) map[schema.GroupVersionResource]Validator { + return map[schema.GroupVersionResource]Validator{ + StandaloneGVR: &GenericValidator[*enterpriseApi.Standalone]{ + ValidateCreateFunc: ValidateStandaloneCreate, + ValidateUpdateFunc: ValidateStandaloneUpdate, + WarningsOnCreateFunc: GetStandaloneWarningsOnCreate, + WarningsOnUpdateFunc: GetStandaloneWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "Standalone", + }, }, - }, - - IndexerClusterGVR: &GenericValidator[*enterpriseApi.IndexerCluster]{ - ValidateCreateFunc: ValidateIndexerClusterCreate, - ValidateUpdateFunc: ValidateIndexerClusterUpdate, - WarningsOnCreateFunc: GetIndexerClusterWarningsOnCreate, - WarningsOnUpdateFunc: GetIndexerClusterWarningsOnUpdate, - GroupKind: schema.GroupKind{ - Group: "enterprise.splunk.com", - Kind: "IndexerCluster", + + IndexerClusterGVR: &GenericValidator[*enterpriseApi.IndexerCluster]{ + ValidateCreateFunc: ValidateIndexerClusterCreate, + ValidateUpdateFunc: ValidateIndexerClusterUpdate, + WarningsOnCreateFunc: GetIndexerClusterWarningsOnCreate, + WarningsOnUpdateFunc: GetIndexerClusterWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "IndexerCluster", + }, }, - }, - - SearchHeadClusterGVR: &GenericValidator[*enterpriseApi.SearchHeadCluster]{ - ValidateCreateFunc: ValidateSearchHeadClusterCreate, - ValidateUpdateFunc: ValidateSearchHeadClusterUpdate, - WarningsOnCreateFunc: GetSearchHeadClusterWarningsOnCreate, - WarningsOnUpdateFunc: GetSearchHeadClusterWarningsOnUpdate, - GroupKind: schema.GroupKind{ - Group: "enterprise.splunk.com", - Kind: "SearchHeadCluster", + + SearchHeadClusterGVR: &GenericValidator[*enterpriseApi.SearchHeadCluster]{ + ValidateCreateFunc: ValidateSearchHeadClusterCreate, + ValidateUpdateFunc: ValidateSearchHeadClusterUpdate, + WarningsOnCreateFunc: GetSearchHeadClusterWarningsOnCreate, + WarningsOnUpdateFunc: GetSearchHeadClusterWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "SearchHeadCluster", + }, }, - }, - - ClusterManagerGVR: &GenericValidator[*enterpriseApi.ClusterManager]{ - ValidateCreateFunc: ValidateClusterManagerCreate, - ValidateUpdateFunc: ValidateClusterManagerUpdate, - WarningsOnCreateFunc: GetClusterManagerWarningsOnCreate, - WarningsOnUpdateFunc: GetClusterManagerWarningsOnUpdate, - GroupKind: schema.GroupKind{ - Group: "enterprise.splunk.com", - Kind: "ClusterManager", + + ClusterManagerGVR: &GenericValidator[*enterpriseApi.ClusterManager]{ + ValidateCreateFunc: ValidateClusterManagerCreate, + ValidateUpdateFunc: ValidateClusterManagerUpdate, + WarningsOnCreateFunc: GetClusterManagerWarningsOnCreate, + WarningsOnUpdateFunc: GetClusterManagerWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "ClusterManager", + }, }, - }, - - // ClusterMaster is an alias for ClusterManager (deprecated) - ClusterMasterGVR: &GenericValidator[*enterpriseApi.ClusterManager]{ - ValidateCreateFunc: ValidateClusterManagerCreate, - ValidateUpdateFunc: ValidateClusterManagerUpdate, - WarningsOnCreateFunc: GetClusterManagerWarningsOnCreate, - WarningsOnUpdateFunc: GetClusterManagerWarningsOnUpdate, - GroupKind: schema.GroupKind{ - Group: "enterprise.splunk.com", - Kind: "ClusterManager", + + // ClusterMaster is an alias for ClusterManager (deprecated) + ClusterMasterGVR: &GenericValidator[*enterpriseApi.ClusterManager]{ + ValidateCreateFunc: ValidateClusterManagerCreate, + ValidateUpdateFunc: ValidateClusterManagerUpdate, + WarningsOnCreateFunc: GetClusterManagerWarningsOnCreate, + WarningsOnUpdateFunc: GetClusterManagerWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "ClusterManager", + }, }, - }, - - LicenseManagerGVR: &GenericValidator[*enterpriseApi.LicenseManager]{ - ValidateCreateFunc: ValidateLicenseManagerCreate, - ValidateUpdateFunc: ValidateLicenseManagerUpdate, - WarningsOnCreateFunc: GetLicenseManagerWarningsOnCreate, - WarningsOnUpdateFunc: GetLicenseManagerWarningsOnUpdate, - GroupKind: schema.GroupKind{ - Group: "enterprise.splunk.com", - Kind: "LicenseManager", + + LicenseManagerGVR: &GenericValidator[*enterpriseApi.LicenseManager]{ + ValidateCreateFunc: ValidateLicenseManagerCreate, + ValidateUpdateFunc: ValidateLicenseManagerUpdate, + WarningsOnCreateFunc: GetLicenseManagerWarningsOnCreate, + WarningsOnUpdateFunc: GetLicenseManagerWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "LicenseManager", + }, }, - }, - - // LicenseMaster is an alias for LicenseManager (deprecated) - LicenseMasterGVR: &GenericValidator[*enterpriseApi.LicenseManager]{ - ValidateCreateFunc: ValidateLicenseManagerCreate, - ValidateUpdateFunc: ValidateLicenseManagerUpdate, - WarningsOnCreateFunc: GetLicenseManagerWarningsOnCreate, - WarningsOnUpdateFunc: GetLicenseManagerWarningsOnUpdate, - GroupKind: schema.GroupKind{ - Group: "enterprise.splunk.com", - Kind: "LicenseManager", + + // LicenseMaster is an alias for LicenseManager (deprecated) + LicenseMasterGVR: &GenericValidator[*enterpriseApi.LicenseManager]{ + ValidateCreateFunc: ValidateLicenseManagerCreate, + ValidateUpdateFunc: ValidateLicenseManagerUpdate, + WarningsOnCreateFunc: GetLicenseManagerWarningsOnCreate, + WarningsOnUpdateFunc: GetLicenseManagerWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "LicenseManager", + }, }, - }, - - MonitoringConsoleGVR: &GenericValidator[*enterpriseApi.MonitoringConsole]{ - ValidateCreateFunc: ValidateMonitoringConsoleCreate, - ValidateUpdateFunc: ValidateMonitoringConsoleUpdate, - WarningsOnCreateFunc: GetMonitoringConsoleWarningsOnCreate, - WarningsOnUpdateFunc: GetMonitoringConsoleWarningsOnUpdate, - GroupKind: schema.GroupKind{ - Group: "enterprise.splunk.com", - Kind: "MonitoringConsole", + + MonitoringConsoleGVR: &GenericValidator[*enterpriseApi.MonitoringConsole]{ + ValidateCreateFunc: ValidateMonitoringConsoleCreate, + ValidateUpdateFunc: ValidateMonitoringConsoleUpdate, + WarningsOnCreateFunc: GetMonitoringConsoleWarningsOnCreate, + WarningsOnUpdateFunc: GetMonitoringConsoleWarningsOnUpdate, + GroupKind: schema.GroupKind{ + Group: "enterprise.splunk.com", + Kind: "MonitoringConsole", + }, }, - }, + + AppGVR: NewAppValidator(k8sClient), + } } diff --git a/pkg/splunk/enterprise/validation/validate.go b/pkg/splunk/enterprise/validation/validate.go index bed7aa6cc..6df1104a0 100644 --- a/pkg/splunk/enterprise/validation/validate.go +++ b/pkg/splunk/enterprise/validation/validate.go @@ -26,6 +26,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/serializer" "k8s.io/apimachinery/pkg/util/validation/field" + appsv1alpha1 "github.com/splunk/splunk-operator/api/apps/v1alpha1" enterpriseApi "github.com/splunk/splunk-operator/api/enterprise/v4" ) @@ -35,6 +36,7 @@ var ( ) func init() { + _ = appsv1alpha1.AddToScheme(scheme) _ = enterpriseApi.AddToScheme(scheme) codecs = serializer.NewCodecFactory(scheme) } From 148efd437e157fe2b7acc6d7a24aaeaae9fd0754 Mon Sep 17 00:00:00 2001 From: jzywieck Date: Fri, 10 Apr 2026 17:26:57 +0200 Subject: [PATCH 6/7] adjust to the new approach Signed-off-by: jzywieck --- pkg/splunk/enterprise/validation/app_validation.go | 2 +- .../enterprise/validation/app_validation_test.go | 10 ++-------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/pkg/splunk/enterprise/validation/app_validation.go b/pkg/splunk/enterprise/validation/app_validation.go index 63f029192..a2e48e66a 100644 --- a/pkg/splunk/enterprise/validation/app_validation.go +++ b/pkg/splunk/enterprise/validation/app_validation.go @@ -145,7 +145,7 @@ func validateApp(ctx context.Context, k8sClient client.Client, app *appsv1alpha1 func validateAppSourceRef(ctx context.Context, k8sClient client.Client, app *appsv1alpha1.App) field.ErrorList { var allErrs field.ErrorList - sourceRefPath := field.NewPath("spec").Child("sourceRef").Child("metadata").Child("name") + sourceRefPath := field.NewPath("spec").Child("sourceRef").Child("name") key := client.ObjectKey{Name: app.Spec.SourceRef.Name, Namespace: app.Namespace} var source appsv1alpha1.AppSource diff --git a/pkg/splunk/enterprise/validation/app_validation_test.go b/pkg/splunk/enterprise/validation/app_validation_test.go index b9020b813..97901f88d 100644 --- a/pkg/splunk/enterprise/validation/app_validation_test.go +++ b/pkg/splunk/enterprise/validation/app_validation_test.go @@ -186,14 +186,8 @@ func newValidApp(name string) *appsv1alpha1.App { Kind: "Standalone", Name: "standalone-sample", }, - SourceRef: appsv1alpha1.AppSource{ - TypeMeta: metav1.TypeMeta{ - APIVersion: appsv1alpha1.GroupVersion.String(), - Kind: "AppSource", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "source-one", - }, + SourceRef: appsv1alpha1.AppSourceRef{ + Name: "source-one", }, Package: appsv1alpha1.AppPackageSpec{ Path: "apps/sample-app.tgz", From 3fddba02159e976d0fa756e225e3ef790cd6686e Mon Sep 17 00:00:00 2001 From: jzywieck Date: Wed, 15 Apr 2026 15:13:11 +0200 Subject: [PATCH 7/7] CEL rules adjustment Signed-off-by: jzywieck --- api/apps/v1alpha1/app_types.go | 4 ++++ config/crd/bases/apps.splunk.com_apps.yaml | 21 +++++++++++-------- .../validation/app_validation_test.go | 2 +- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/api/apps/v1alpha1/app_types.go b/api/apps/v1alpha1/app_types.go index 4211ca144..c1b49f021 100644 --- a/api/apps/v1alpha1/app_types.go +++ b/api/apps/v1alpha1/app_types.go @@ -53,6 +53,7 @@ type AppSourceRef struct { type AppSpec struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.appID is immutable" AppID string `json:"appID"` // +kubebuilder:validation:Required @@ -60,9 +61,11 @@ type AppSpec struct { Version string `json:"version"` // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.targetRef is immutable" TargetRef AppTargetRef `json:"targetRef"` // +kubebuilder:validation:Required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.sourceRef is immutable" SourceRef AppSourceRef `json:"sourceRef"` // +kubebuilder:validation:Required @@ -70,6 +73,7 @@ type AppSpec struct { // +kubebuilder:validation:Required // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.scope is immutable" Scope string `json:"scope"` } diff --git a/config/crd/bases/apps.splunk.com_apps.yaml b/config/crd/bases/apps.splunk.com_apps.yaml index 2df9d0624..b3f871665 100644 --- a/config/crd/bases/apps.splunk.com_apps.yaml +++ b/config/crd/bases/apps.splunk.com_apps.yaml @@ -55,6 +55,9 @@ spec: appID: minLength: 1 type: string + x-kubernetes-validations: + - message: spec.appID is immutable + rule: self == oldSelf package: description: AppPackageSpec defines the package location within the source. @@ -68,6 +71,9 @@ spec: scope: minLength: 1 type: string + x-kubernetes-validations: + - message: spec.scope is immutable + rule: self == oldSelf sourceRef: description: AppSourceSpec defines the app source details. properties: @@ -77,6 +83,9 @@ spec: required: - name type: object + x-kubernetes-validations: + - message: spec.sourceRef is immutable + rule: self == oldSelf targetRef: description: AppTargetRef defines the target environment the app should bind to. @@ -91,6 +100,9 @@ spec: - kind - name type: object + x-kubernetes-validations: + - message: spec.targetRef is immutable + rule: self == oldSelf version: minLength: 1 type: string @@ -102,15 +114,6 @@ spec: - targetRef - version type: object - x-kubernetes-validations: - - message: appID is immutable once created - rule: self.appID == oldSelf.appID - - message: targetRef is immutable once created - rule: self.targetRef == oldSelf.targetRef - - message: sourceRef is immutable once created - rule: self.sourceRef == oldSelf.sourceRef - - message: scope is immutable once created - rule: self.scope == oldSelf.scope status: description: AppStatus defines the observed state of App. properties: diff --git a/pkg/splunk/enterprise/validation/app_validation_test.go b/pkg/splunk/enterprise/validation/app_validation_test.go index 97901f88d..18d25c721 100644 --- a/pkg/splunk/enterprise/validation/app_validation_test.go +++ b/pkg/splunk/enterprise/validation/app_validation_test.go @@ -54,7 +54,7 @@ func TestValidateAppCreate(t *testing.T) { app: newValidApp("app-one"), wantErrCount: 1, wantErrFields: []string{ - "spec.sourceRef.metadata.name", + "spec.sourceRef.name", }, wantMessage: "Not found", },