diff --git a/PROJECT b/PROJECT index abc2bb0790..e4bc68f5e8 100644 --- a/PROJECT +++ b/PROJECT @@ -111,4 +111,13 @@ resources: webhooks: validation: true webhookVersion: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: openstack.org + group: assistant + kind: OpenStackAssistant + path: github.com/openstack-k8s-operators/openstack-operator/api/assistant/v1beta1 + version: v1beta1 version: "3" diff --git a/api/assistant/v1beta1/conditions.go b/api/assistant/v1beta1/conditions.go new file mode 100644 index 0000000000..ccd33ac590 --- /dev/null +++ b/api/assistant/v1beta1/conditions.go @@ -0,0 +1,49 @@ +/* +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 v1beta1 + +import ( + condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" +) + +// OpenStackAssistant Condition Types used by API objects. +const ( + // OpenStackAssistantReadyCondition Status=True condition which indicates if OpenStackAssistant is configured and operational + OpenStackAssistantReadyCondition condition.Type = "OpenStackAssistantReady" +) + +// Common Messages used by API objects. +const ( + // OpenStackAssistantReadyInitMessage + OpenStackAssistantReadyInitMessage = "OpenStack Assistant not started" + + // OpenStackAssistantReadyRunningMessage + OpenStackAssistantReadyRunningMessage = "OpenStack Assistant in progress" + + // OpenStackAssistantReadyMessage + OpenStackAssistantReadyMessage = "OpenStack Assistant created" + + // OpenStackAssistantReadyErrorMessage + OpenStackAssistantReadyErrorMessage = "OpenStack Assistant error occured %s" + + // OpenStackAssistantProviderSecretWaitingMessage + OpenStackAssistantProviderSecretWaitingMessage = "Waiting for lightspeed provider secret" + + // OpenStackAssistantRecipesWaitingMessage + OpenStackAssistantRecipesWaitingMessage = "Waiting for Goose recipes ConfigMap" + + // OpenStackAssistantHintsWaitingMessage + OpenStackAssistantHintsWaitingMessage = "Waiting for Goose hints ConfigMap" +) diff --git a/api/assistant/v1beta1/groupversion_info.go b/api/assistant/v1beta1/groupversion_info.go new file mode 100644 index 0000000000..c7f66e6704 --- /dev/null +++ b/api/assistant/v1beta1/groupversion_info.go @@ -0,0 +1,36 @@ +/* +Copyright 2022. + +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 v1beta1 contains API Schema definitions for the assistant v1beta1 API group. +// +kubebuilder:object:generate=true +// +groupName=assistant.openstack.org +package v1beta1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "assistant.openstack.org", Version: "v1beta1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/assistant/v1beta1/openstackassistant_types.go b/api/assistant/v1beta1/openstackassistant_types.go new file mode 100644 index 0000000000..abd799d109 --- /dev/null +++ b/api/assistant/v1beta1/openstackassistant_types.go @@ -0,0 +1,196 @@ +/* +Copyright 2022. + +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 v1beta1 + +import ( + condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + "github.com/openstack-k8s-operators/lib-common/modules/common/util" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const ( + // OpenStackAssistantContainerImage is the fall-back container image for OpenStackAssistant + OpenStackAssistantContainerImage = "quay.io/dprince/goose:oc-fedora" +) + +// ProviderType defines the AI agent provider +// +kubebuilder:validation:Enum=goose +type ProviderType string + +const ( + // ProviderGoose is the Goose AI agent provider + ProviderGoose ProviderType = "goose" +) + +// LightspeedStackSpec defines connectivity to the Lightspeed Stack AI backend +type LightspeedStackSpec struct { + // ProviderSecret is the name of a Secret containing the lightspeed + // provider config JSON (custom_providers/lightspeed.json content). + // Must contain key "lightspeed.json". + // +kubebuilder:validation:Required + ProviderSecret string `json:"providerSecret"` + + // CaBundleSecretName is the name of a Secret containing CA certs + // to trust for TLS connections to the lightspeed-stack endpoint. + // The Secret must contain a key "ca-bundle.crt" with PEM-encoded certs. + // +kubebuilder:validation:Optional + CaBundleSecretName string `json:"caBundleSecretName,omitempty"` +} + +// GooseConfig defines Goose-specific provider configuration +type GooseConfig struct { + // Model is the model identifier for the Goose AI agent + // (e.g., "gemini/models/gemini-2.5-flash"). Sets the GOOSE_MODEL env var. + // +kubebuilder:validation:Optional + Model string `json:"model,omitempty"` + + // Recipes is a ConfigMap name containing Goose recipe YAML files. + // Each key in the ConfigMap becomes a recipe file registered as a + // Goose slash command (e.g., /cluster-health). + // +kubebuilder:validation:Optional + Recipes *string `json:"recipes,omitempty"` + + // Hints is a ConfigMap name containing Goose hints/context. + // The ConfigMap must have a key "hints" with the content that + // will be written to ~/.goosehints in the pod. + // +kubebuilder:validation:Optional + Hints *string `json:"hints,omitempty"` +} + +// OpenStackAssistantSpec defines the desired state of OpenStackAssistant +type OpenStackAssistantSpec struct { + // ContainerImage for the assistant container. + // +kubebuilder:validation:Required + ContainerImage string `json:"containerImage"` + + // Provider is the AI agent provider type. Currently only "goose" is supported. + // +kubebuilder:validation:Optional + // +kubebuilder:default=goose + Provider ProviderType `json:"provider,omitempty"` + + // LightspeedStack configuration for the AI backend. + // +kubebuilder:validation:Required + LightspeedStack LightspeedStackSpec `json:"lightspeedStack"` + + // Goose contains Goose-specific provider configuration. + // Only applicable when provider is "goose". + // +kubebuilder:validation:Optional + Goose *GooseConfig `json:"goose,omitempty"` + + // NodeSelector to target subset of worker nodes for pod scheduling. + // +kubebuilder:validation:Optional + NodeSelector *map[string]string `json:"nodeSelector,omitempty"` + + // Env is a list of additional environment variables for the container. + // +kubebuilder:validation:Optional + // +listType=map + // +listMapKey=name + Env []corev1.EnvVar `json:"env,omitempty"` +} + +// OpenStackAssistantStatus defines the observed state of OpenStackAssistant +type OpenStackAssistantStatus struct { + // PodName is the name of the running assistant pod + PodName string `json:"podName,omitempty"` + + // Conditions tracks the state of each sub-resource + Conditions condition.Conditions `json:"conditions,omitempty" optional:"true"` + + // ObservedGeneration - the most recent generation observed + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // Hash tracks input hashes to detect changes + Hash map[string]string `json:"hash,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +operator-sdk:csv:customresourcedefinitions:displayName="OpenStack Assistant" +// +kubebuilder:resource:shortName=osassistant;osassistants +// +kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[0].status",description="Status" +// +kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[0].message",description="Message" + +// OpenStackAssistant is the Schema for the openstackassistants API +type OpenStackAssistant struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec OpenStackAssistantSpec `json:"spec,omitempty"` + Status OpenStackAssistantStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +// OpenStackAssistantList contains a list of OpenStackAssistant +type OpenStackAssistantList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []OpenStackAssistant `json:"items"` +} + +func init() { + SchemeBuilder.Register(&OpenStackAssistant{}, &OpenStackAssistantList{}) +} + +// IsReady - returns true if OpenStackAssistant is reconciled successfully +func (instance OpenStackAssistant) IsReady() bool { + return instance.Status.Conditions.IsTrue(OpenStackAssistantReadyCondition) +} + +// RbacConditionsSet - set the conditions for the rbac object +func (instance OpenStackAssistant) RbacConditionsSet(c *condition.Condition) { + instance.Status.Conditions.Set(c) +} + +// RbacNamespace - return the namespace +func (instance OpenStackAssistant) RbacNamespace() string { + return instance.Namespace +} + +// RbacResourceName - return the name to be used for rbac objects (serviceaccount, role, rolebinding) +func (instance OpenStackAssistant) RbacResourceName() string { + return "openstackassistant-" + instance.Name +} + +// OpenStackAssistantDefaults holds defaults for the assistant +type OpenStackAssistantDefaults struct { + ContainerImageURL string +} + +var openStackAssistantDefaults OpenStackAssistantDefaults + +// SetupOpenStackAssistantDefaults - initialize OpenStackAssistant spec defaults +func SetupOpenStackAssistantDefaults(defaults OpenStackAssistantDefaults) { + openStackAssistantDefaults = defaults +} + +// SetupDefaults - initializes any CRD field defaults based on environment variables +func SetupDefaults() { + openStackAssistantDefaults := OpenStackAssistantDefaults{ + ContainerImageURL: util.GetEnvVar("RELATED_IMAGE_OPENSTACK_ASSISTANT_IMAGE_URL_DEFAULT", OpenStackAssistantContainerImage), + } + + SetupOpenStackAssistantDefaults(openStackAssistantDefaults) +} + +// Default implements webhook.Defaulter +func (r *OpenStackAssistant) Default() { + if r.Spec.ContainerImage == "" { + r.Spec.ContainerImage = openStackAssistantDefaults.ContainerImageURL + } +} diff --git a/api/assistant/v1beta1/openstackassistant_webhook.go b/api/assistant/v1beta1/openstackassistant_webhook.go new file mode 100644 index 0000000000..8a7f8b6335 --- /dev/null +++ b/api/assistant/v1beta1/openstackassistant_webhook.go @@ -0,0 +1,37 @@ +/* +Copyright 2022. + +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 v1beta1 + +import ( + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +// ValidateCreate implements webhook.Validator +func (r *OpenStackAssistant) ValidateCreate() (admission.Warnings, error) { + return nil, nil +} + +// ValidateUpdate implements webhook.Validator +func (r *OpenStackAssistant) ValidateUpdate(_ runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// ValidateDelete implements webhook.Validator +func (r *OpenStackAssistant) ValidateDelete() (admission.Warnings, error) { + return nil, nil +} diff --git a/api/assistant/v1beta1/zz_generated.deepcopy.go b/api/assistant/v1beta1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..3e48d44e0a --- /dev/null +++ b/api/assistant/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,209 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2022. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1beta1 + +import ( + "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GooseConfig) DeepCopyInto(out *GooseConfig) { + *out = *in + if in.Recipes != nil { + in, out := &in.Recipes, &out.Recipes + *out = new(string) + **out = **in + } + if in.Hints != nil { + in, out := &in.Hints, &out.Hints + *out = new(string) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GooseConfig. +func (in *GooseConfig) DeepCopy() *GooseConfig { + if in == nil { + return nil + } + out := new(GooseConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LightspeedStackSpec) DeepCopyInto(out *LightspeedStackSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LightspeedStackSpec. +func (in *LightspeedStackSpec) DeepCopy() *LightspeedStackSpec { + if in == nil { + return nil + } + out := new(LightspeedStackSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenStackAssistant) DeepCopyInto(out *OpenStackAssistant) { + *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 OpenStackAssistant. +func (in *OpenStackAssistant) DeepCopy() *OpenStackAssistant { + if in == nil { + return nil + } + out := new(OpenStackAssistant) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OpenStackAssistant) 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 *OpenStackAssistantDefaults) DeepCopyInto(out *OpenStackAssistantDefaults) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackAssistantDefaults. +func (in *OpenStackAssistantDefaults) DeepCopy() *OpenStackAssistantDefaults { + if in == nil { + return nil + } + out := new(OpenStackAssistantDefaults) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenStackAssistantList) DeepCopyInto(out *OpenStackAssistantList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]OpenStackAssistant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackAssistantList. +func (in *OpenStackAssistantList) DeepCopy() *OpenStackAssistantList { + if in == nil { + return nil + } + out := new(OpenStackAssistantList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *OpenStackAssistantList) 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 *OpenStackAssistantSpec) DeepCopyInto(out *OpenStackAssistantSpec) { + *out = *in + out.LightspeedStack = in.LightspeedStack + if in.Goose != nil { + in, out := &in.Goose, &out.Goose + *out = new(GooseConfig) + (*in).DeepCopyInto(*out) + } + if in.NodeSelector != nil { + in, out := &in.NodeSelector, &out.NodeSelector + *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.Env != nil { + in, out := &in.Env, &out.Env + *out = make([]v1.EnvVar, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackAssistantSpec. +func (in *OpenStackAssistantSpec) DeepCopy() *OpenStackAssistantSpec { + if in == nil { + return nil + } + out := new(OpenStackAssistantSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenStackAssistantStatus) DeepCopyInto(out *OpenStackAssistantStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(condition.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Hash != nil { + in, out := &in.Hash, &out.Hash + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackAssistantStatus. +func (in *OpenStackAssistantStatus) DeepCopy() *OpenStackAssistantStatus { + if in == nil { + return nil + } + out := new(OpenStackAssistantStatus) + in.DeepCopyInto(out) + return out +} diff --git a/api/bases/assistant.openstack.org_openstackassistants.yaml b/api/bases/assistant.openstack.org_openstackassistants.yaml new file mode 100644 index 0000000000..6796c7db98 --- /dev/null +++ b/api/bases/assistant.openstack.org_openstackassistants.yaml @@ -0,0 +1,300 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: openstackassistants.assistant.openstack.org +spec: + group: assistant.openstack.org + names: + kind: OpenStackAssistant + listKind: OpenStackAssistantList + plural: openstackassistants + shortNames: + - osassistant + - osassistants + singular: openstackassistant + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Status + jsonPath: .status.conditions[0].status + name: Status + type: string + - description: Message + jsonPath: .status.conditions[0].message + name: Message + type: string + name: v1beta1 + schema: + openAPIV3Schema: + description: OpenStackAssistant is the Schema for the openstackassistants + 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: OpenStackAssistantSpec defines the desired state of OpenStackAssistant + properties: + containerImage: + description: ContainerImage for the assistant container. + type: string + env: + description: Env is a list of additional environment variables for + the container. + items: + description: EnvVar represents an environment variable present in + a Container. + properties: + name: + description: Name of the environment variable. Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. Cannot + be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + 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 + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath is + written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified + API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the exposed + resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's namespace + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + 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 + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + goose: + description: |- + Goose contains Goose-specific provider configuration. + Only applicable when provider is "goose". + properties: + hints: + description: |- + Hints is a ConfigMap name containing Goose hints/context. + The ConfigMap must have a key "hints" with the content that + will be written to ~/.goosehints in the pod. + type: string + model: + description: |- + Model is the model identifier for the Goose AI agent + (e.g., "gemini/models/gemini-2.5-flash"). Sets the GOOSE_MODEL env var. + type: string + recipes: + description: |- + Recipes is a ConfigMap name containing Goose recipe YAML files. + Each key in the ConfigMap becomes a recipe file registered as a + Goose slash command (e.g., /cluster-health). + type: string + type: object + lightspeedStack: + description: LightspeedStack configuration for the AI backend. + properties: + caBundleSecretName: + description: |- + CaBundleSecretName is the name of a Secret containing CA certs + to trust for TLS connections to the lightspeed-stack endpoint. + The Secret must contain a key "ca-bundle.crt" with PEM-encoded certs. + type: string + providerSecret: + description: |- + ProviderSecret is the name of a Secret containing the lightspeed + provider config JSON (custom_providers/lightspeed.json content). + Must contain key "lightspeed.json". + type: string + required: + - providerSecret + type: object + nodeSelector: + additionalProperties: + type: string + description: NodeSelector to target subset of worker nodes for pod + scheduling. + type: object + provider: + default: goose + description: Provider is the AI agent provider type. Currently only + "goose" is supported. + enum: + - goose + type: string + required: + - containerImage + - lightspeedStack + type: object + status: + description: OpenStackAssistantStatus defines the observed state of OpenStackAssistant + properties: + conditions: + description: Conditions tracks the state of each sub-resource + items: + description: Condition defines an observation of a API resource + operational state. + properties: + lastTransitionTime: + description: |- + 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: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition + in CamelCase. + type: string + severity: + description: |- + Severity provides a classification of Reason code, so the current situation is immediately + understandable and could act accordingly. + It is meant for situations where Status=False and it should be indicated if it is just + informational, warning (next reconciliation might fix it) or an error (e.g. DB create issue + and no actions to automatically resolve the issue can/should be done). + For conditions where Status=Unknown or Status=True the Severity should be SeverityNone. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition in CamelCase. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + hash: + additionalProperties: + type: string + description: Hash tracks input hashes to detect changes + type: object + observedGeneration: + description: ObservedGeneration - the most recent generation observed + format: int64 + type: integer + podName: + description: PodName is the name of the running assistant pod + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/bindata/crds/crds.yaml b/bindata/crds/crds.yaml index 38aed21500..5eb40a8733 100644 --- a/bindata/crds/crds.yaml +++ b/bindata/crds/crds.yaml @@ -1,5 +1,305 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: openstackassistants.assistant.openstack.org +spec: + group: assistant.openstack.org + names: + kind: OpenStackAssistant + listKind: OpenStackAssistantList + plural: openstackassistants + shortNames: + - osassistant + - osassistants + singular: openstackassistant + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Status + jsonPath: .status.conditions[0].status + name: Status + type: string + - description: Message + jsonPath: .status.conditions[0].message + name: Message + type: string + name: v1beta1 + schema: + openAPIV3Schema: + description: OpenStackAssistant is the Schema for the openstackassistants + 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: OpenStackAssistantSpec defines the desired state of OpenStackAssistant + properties: + containerImage: + description: ContainerImage for the assistant container. + type: string + env: + description: Env is a list of additional environment variables for + the container. + items: + description: EnvVar represents an environment variable present in + a Container. + properties: + name: + description: Name of the environment variable. Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. Cannot + be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + 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 + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath is + written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified + API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the exposed + resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's namespace + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + 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 + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + goose: + description: |- + Goose contains Goose-specific provider configuration. + Only applicable when provider is "goose". + properties: + hints: + description: |- + Hints is a ConfigMap name containing Goose hints/context. + The ConfigMap must have a key "hints" with the content that + will be written to ~/.goosehints in the pod. + type: string + model: + description: |- + Model is the model identifier for the Goose AI agent + (e.g., "gemini/models/gemini-2.5-flash"). Sets the GOOSE_MODEL env var. + type: string + recipes: + description: |- + Recipes is a ConfigMap name containing Goose recipe YAML files. + Each key in the ConfigMap becomes a recipe file registered as a + Goose slash command (e.g., /cluster-health). + type: string + type: object + lightspeedStack: + description: LightspeedStack configuration for the AI backend. + properties: + caBundleSecretName: + description: |- + CaBundleSecretName is the name of a Secret containing CA certs + to trust for TLS connections to the lightspeed-stack endpoint. + The Secret must contain a key "ca-bundle.crt" with PEM-encoded certs. + type: string + providerSecret: + description: |- + ProviderSecret is the name of a Secret containing the lightspeed + provider config JSON (custom_providers/lightspeed.json content). + Must contain key "lightspeed.json". + type: string + required: + - providerSecret + type: object + nodeSelector: + additionalProperties: + type: string + description: NodeSelector to target subset of worker nodes for pod + scheduling. + type: object + provider: + default: goose + description: Provider is the AI agent provider type. Currently only + "goose" is supported. + enum: + - goose + type: string + required: + - containerImage + - lightspeedStack + type: object + status: + description: OpenStackAssistantStatus defines the observed state of OpenStackAssistant + properties: + conditions: + description: Conditions tracks the state of each sub-resource + items: + description: Condition defines an observation of a API resource + operational state. + properties: + lastTransitionTime: + description: |- + 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: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition + in CamelCase. + type: string + severity: + description: |- + Severity provides a classification of Reason code, so the current situation is immediately + understandable and could act accordingly. + It is meant for situations where Status=False and it should be indicated if it is just + informational, warning (next reconciliation might fix it) or an error (e.g. DB create issue + and no actions to automatically resolve the issue can/should be done). + For conditions where Status=Unknown or Status=True the Severity should be SeverityNone. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition in CamelCase. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + hash: + additionalProperties: + type: string + description: Hash tracks input hashes to detect changes + type: object + observedGeneration: + description: ObservedGeneration - the most recent generation observed + format: int64 + type: integer + podName: + description: PodName is the name of the running assistant pod + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.18.0 diff --git a/bindata/operator/operator.yaml b/bindata/operator/operator.yaml index ee1e0ed724..3c5ee3c052 100644 --- a/bindata/operator/operator.yaml +++ b/bindata/operator/operator.yaml @@ -194,6 +194,26 @@ metadata: cert-manager.io/inject-ca-from: '{{ .OperatorNamespace }}/openstack-operator-serving-cert' name: openstack-operator-mutating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: openstack-operator-webhook-service + namespace: '{{ .OperatorNamespace }}' + path: /mutate-assistant-openstack-org-v1beta1-openstackassistant + failurePolicy: Fail + name: mopenstackassistant-v1beta1.kb.io + rules: + - apiGroups: + - assistant.openstack.org + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - openstackassistants + sideEffects: None - admissionReviewVersions: - v1 clientConfig: @@ -382,6 +402,26 @@ metadata: cert-manager.io/inject-ca-from: '{{ .OperatorNamespace }}/openstack-operator-serving-cert' name: openstack-operator-validating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: openstack-operator-webhook-service + namespace: '{{ .OperatorNamespace }}' + path: /validate-assistant-openstack-org-v1beta1-openstackassistant + failurePolicy: Fail + name: vopenstackassistant-v1beta1.kb.io + rules: + - apiGroups: + - assistant.openstack.org + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - openstackassistants + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/bindata/rbac/rbac.yaml b/bindata/rbac/rbac.yaml index 35260f1f09..b90a338eee 100644 --- a/bindata/rbac/rbac.yaml +++ b/bindata/rbac/rbac.yaml @@ -50,6 +50,77 @@ rules: --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: openstack-operator + name: openstack-operator-assistant-openstackassistant-admin-role +rules: +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants + verbs: + - '*' +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: openstack-operator + name: openstack-operator-assistant-openstackassistant-editor-role +rules: +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: openstack-operator + name: openstack-operator-assistant-openstackassistant-viewer-role +rules: +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants + verbs: + - get + - list + - watch +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/status + verbs: + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole metadata: name: openstack-operator-manager-role rules: @@ -138,6 +209,32 @@ rules: - patch - update - watch +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/finalizers + verbs: + - update +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/status + verbs: + - get + - patch + - update - apiGroups: - backup.openstack.org resources: diff --git a/cmd/main.go b/cmd/main.go index 149cfd098e..a5ae60a105 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -50,7 +50,11 @@ import ( backupv1beta1 "github.com/openstack-k8s-operators/openstack-operator/api/backup/v1beta1" backupcontroller "github.com/openstack-k8s-operators/openstack-operator/internal/controller/backup" + assistantv1beta1 "github.com/openstack-k8s-operators/openstack-operator/api/assistant/v1beta1" + assistantcontroller "github.com/openstack-k8s-operators/openstack-operator/internal/controller/assistant" + webhookassistantv1beta1 "github.com/openstack-k8s-operators/openstack-operator/internal/webhook/assistant/v1beta1" webhookbackupv1beta1 "github.com/openstack-k8s-operators/openstack-operator/internal/webhook/backup/v1beta1" + // +kubebuilder:scaffold:imports certmgrv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" k8s_networkv1 "github.com/k8snetworkplumbingwg/network-attachment-definition-client/pkg/apis/k8s.cni.cncf.io/v1" @@ -136,6 +140,7 @@ func init() { utilruntime.Must(topologyv1.AddToScheme(scheme)) utilruntime.Must(watcherv1.AddToScheme(scheme)) utilruntime.Must(backupv1beta1.AddToScheme(scheme)) + utilruntime.Must(assistantv1beta1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -377,6 +382,15 @@ func main() { os.Exit(1) } + if err := (&assistantcontroller.OpenStackAssistantReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Kclient: kclient, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "OpenStackAssistant") + os.Exit(1) + } + corecontroller.SetupVersionDefaults() // Defaults for service operators @@ -385,6 +399,9 @@ func main() { // Defaults for OpenStackClient clientv1.SetupDefaults() + // Defaults for OpenStackAssistant + assistantv1beta1.SetupDefaults() + // Defaults for Dataplane dataplanev1.SetupDefaults() @@ -429,6 +446,11 @@ func main() { setupLog.Error(err, "unable to create webhook", "webhook", "OpenStackBackupConfig") os.Exit(1) } + + if err := webhookassistantv1beta1.SetupOpenStackAssistantWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "OpenStackAssistant") + os.Exit(1) + } checker = mgr.GetWebhookServer().StartedChecker() } // +kubebuilder:scaffold:builder diff --git a/config/crd/bases/assistant.openstack.org_openstackassistants.yaml b/config/crd/bases/assistant.openstack.org_openstackassistants.yaml new file mode 100644 index 0000000000..6796c7db98 --- /dev/null +++ b/config/crd/bases/assistant.openstack.org_openstackassistants.yaml @@ -0,0 +1,300 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: openstackassistants.assistant.openstack.org +spec: + group: assistant.openstack.org + names: + kind: OpenStackAssistant + listKind: OpenStackAssistantList + plural: openstackassistants + shortNames: + - osassistant + - osassistants + singular: openstackassistant + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Status + jsonPath: .status.conditions[0].status + name: Status + type: string + - description: Message + jsonPath: .status.conditions[0].message + name: Message + type: string + name: v1beta1 + schema: + openAPIV3Schema: + description: OpenStackAssistant is the Schema for the openstackassistants + 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: OpenStackAssistantSpec defines the desired state of OpenStackAssistant + properties: + containerImage: + description: ContainerImage for the assistant container. + type: string + env: + description: Env is a list of additional environment variables for + the container. + items: + description: EnvVar represents an environment variable present in + a Container. + properties: + name: + description: Name of the environment variable. Must be a C_IDENTIFIER. + type: string + value: + description: |- + Variable references $(VAR_NAME) are expanded + using the previously defined environment variables in the container and + any service environment variables. If a variable cannot be resolved, + the reference in the input string will be unchanged. Double $$ are reduced + to a single $, which allows for escaping the $(VAR_NAME) syntax: i.e. + "$$(VAR_NAME)" will produce the string literal "$(VAR_NAME)". + Escaped references will never be expanded, regardless of whether the variable + exists or not. + Defaults to "". + type: string + valueFrom: + description: Source for the environment variable's value. Cannot + be used if value is not empty. + properties: + configMapKeyRef: + description: Selects a key of a ConfigMap. + properties: + key: + description: The key to select. + type: string + 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 + optional: + description: Specify whether the ConfigMap or its key + must be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + fieldRef: + description: |- + Selects a field of the pod: supports metadata.name, metadata.namespace, `metadata.labels['']`, `metadata.annotations['']`, + spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP, status.podIPs. + properties: + apiVersion: + description: Version of the schema the FieldPath is + written in terms of, defaults to "v1". + type: string + fieldPath: + description: Path of the field to select in the specified + API version. + type: string + required: + - fieldPath + type: object + x-kubernetes-map-type: atomic + resourceFieldRef: + description: |- + Selects a resource of the container: only resources limits and requests + (limits.cpu, limits.memory, limits.ephemeral-storage, requests.cpu, requests.memory and requests.ephemeral-storage) are currently supported. + properties: + containerName: + description: 'Container name: required for volumes, + optional for env vars' + type: string + divisor: + anyOf: + - type: integer + - type: string + description: Specifies the output format of the exposed + resources, defaults to "1" + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + resource: + description: 'Required: resource to select' + type: string + required: + - resource + type: object + x-kubernetes-map-type: atomic + secretKeyRef: + description: Selects a key of a secret in the pod's namespace + properties: + key: + description: The key of the secret to select from. Must + be a valid secret key. + type: string + 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 + optional: + description: Specify whether the Secret or its key must + be defined + type: boolean + required: + - key + type: object + x-kubernetes-map-type: atomic + type: object + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + goose: + description: |- + Goose contains Goose-specific provider configuration. + Only applicable when provider is "goose". + properties: + hints: + description: |- + Hints is a ConfigMap name containing Goose hints/context. + The ConfigMap must have a key "hints" with the content that + will be written to ~/.goosehints in the pod. + type: string + model: + description: |- + Model is the model identifier for the Goose AI agent + (e.g., "gemini/models/gemini-2.5-flash"). Sets the GOOSE_MODEL env var. + type: string + recipes: + description: |- + Recipes is a ConfigMap name containing Goose recipe YAML files. + Each key in the ConfigMap becomes a recipe file registered as a + Goose slash command (e.g., /cluster-health). + type: string + type: object + lightspeedStack: + description: LightspeedStack configuration for the AI backend. + properties: + caBundleSecretName: + description: |- + CaBundleSecretName is the name of a Secret containing CA certs + to trust for TLS connections to the lightspeed-stack endpoint. + The Secret must contain a key "ca-bundle.crt" with PEM-encoded certs. + type: string + providerSecret: + description: |- + ProviderSecret is the name of a Secret containing the lightspeed + provider config JSON (custom_providers/lightspeed.json content). + Must contain key "lightspeed.json". + type: string + required: + - providerSecret + type: object + nodeSelector: + additionalProperties: + type: string + description: NodeSelector to target subset of worker nodes for pod + scheduling. + type: object + provider: + default: goose + description: Provider is the AI agent provider type. Currently only + "goose" is supported. + enum: + - goose + type: string + required: + - containerImage + - lightspeedStack + type: object + status: + description: OpenStackAssistantStatus defines the observed state of OpenStackAssistant + properties: + conditions: + description: Conditions tracks the state of each sub-resource + items: + description: Condition defines an observation of a API resource + operational state. + properties: + lastTransitionTime: + description: |- + 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: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition + in CamelCase. + type: string + severity: + description: |- + Severity provides a classification of Reason code, so the current situation is immediately + understandable and could act accordingly. + It is meant for situations where Status=False and it should be indicated if it is just + informational, warning (next reconciliation might fix it) or an error (e.g. DB create issue + and no actions to automatically resolve the issue can/should be done). + For conditions where Status=Unknown or Status=True the Severity should be SeverityNone. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition in CamelCase. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + hash: + additionalProperties: + type: string + description: Hash tracks input hashes to detect changes + type: object + observedGeneration: + description: ObservedGeneration - the most recent generation observed + format: int64 + type: integer + podName: + description: PodName is the name of the running assistant pod + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index e7cceda24d..60bb20ef18 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -10,6 +10,7 @@ resources: - bases/dataplane.openstack.org_openstackdataplanedeployments.yaml #- bases/operator.openstack.org_openstacks.yaml - bases/backup.openstack.org_openstackbackupconfigs.yaml +- bases/assistant.openstack.org_openstackassistants.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/assistant_openstackassistant_admin_role.yaml b/config/rbac/assistant_openstackassistant_admin_role.yaml new file mode 100644 index 0000000000..46ce45cafb --- /dev/null +++ b/config/rbac/assistant_openstackassistant_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project openstack-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over assistant.openstack.org. +# 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: openstack-operator + app.kubernetes.io/managed-by: kustomize + name: assistant-openstackassistant-admin-role +rules: +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants + verbs: + - '*' +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/config/rbac/assistant_openstackassistant_editor_role.yaml b/config/rbac/assistant_openstackassistant_editor_role.yaml new file mode 100644 index 0000000000..02f3233e51 --- /dev/null +++ b/config/rbac/assistant_openstackassistant_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project openstack-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 assistant.openstack.org. +# 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: openstack-operator + app.kubernetes.io/managed-by: kustomize + name: assistant-openstackassistant-editor-role +rules: +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/config/rbac/assistant_openstackassistant_viewer_role.yaml b/config/rbac/assistant_openstackassistant_viewer_role.yaml new file mode 100644 index 0000000000..cff785b1cb --- /dev/null +++ b/config/rbac/assistant_openstackassistant_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project openstack-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to assistant.openstack.org 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: openstack-operator + app.kubernetes.io/managed-by: kustomize + name: assistant-openstackassistant-viewer-role +rules: +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants + verbs: + - get + - list + - watch +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 5908081ae3..a9fc75b190 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -32,6 +32,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the openstack-operator itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- assistant_openstackassistant_admin_role.yaml +- assistant_openstackassistant_editor_role.yaml +- assistant_openstackassistant_viewer_role.yaml #- backup_openstackbackupconfig_admin_role.yaml #- backup_openstackbackupconfig_editor_role.yaml #- backup_openstackbackupconfig_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 1c68d8089b..84132c840f 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -89,6 +89,32 @@ rules: - patch - update - watch +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/finalizers + verbs: + - update +- apiGroups: + - assistant.openstack.org + resources: + - openstackassistants/status + verbs: + - get + - patch + - update - apiGroups: - backup.openstack.org resources: diff --git a/config/samples/assistant_v1beta1_openstackassistant.yaml b/config/samples/assistant_v1beta1_openstackassistant.yaml new file mode 100644 index 0000000000..74b145d189 --- /dev/null +++ b/config/samples/assistant_v1beta1_openstackassistant.yaml @@ -0,0 +1,9 @@ +apiVersion: assistant.openstack.org/v1beta1 +kind: OpenStackAssistant +metadata: + labels: + app.kubernetes.io/name: openstack-operator + app.kubernetes.io/managed-by: kustomize + name: openstackassistant-sample +spec: + # TODO(user): Add fields here diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 687ef6853e..1dbd75a9ed 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -11,4 +11,5 @@ resources: #- dataplane_v1beta1_openstackdataplanedeployment_empty.yaml - operator_v1beta1_openstack.yaml - backup_v1beta1_openstackbackupconfig.yaml +- assistant_v1beta1_openstackassistant.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 523c63dd5c..03565c729a 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -4,6 +4,26 @@ kind: MutatingWebhookConfiguration metadata: name: mutating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-assistant-openstack-org-v1beta1-openstackassistant + failurePolicy: Fail + name: mopenstackassistant-v1beta1.kb.io + rules: + - apiGroups: + - assistant.openstack.org + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - openstackassistants + sideEffects: None - admissionReviewVersions: - v1 clientConfig: @@ -190,6 +210,26 @@ kind: ValidatingWebhookConfiguration metadata: name: validating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-assistant-openstack-org-v1beta1-openstackassistant + failurePolicy: Fail + name: vopenstackassistant-v1beta1.kb.io + rules: + - apiGroups: + - assistant.openstack.org + apiVersions: + - v1beta1 + operations: + - CREATE + - UPDATE + resources: + - openstackassistants + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/hack/clean_local_webhook.sh b/hack/clean_local_webhook.sh index 710d91a4fd..9bc1bb70b3 100755 --- a/hack/clean_local_webhook.sh +++ b/hack/clean_local_webhook.sh @@ -13,3 +13,5 @@ oc delete validatingwebhookconfiguration/vopenstackdataplaneservice.kb.io --igno oc delete mutatingwebhookconfiguration/mopenstackdataplanenodeset.kb.io --ignore-not-found oc delete mutatingwebhookconfiguration/mopenstackdataplaneservice.kb.io --ignore-not-found oc delete mutatingwebhookconfiguration/mopenstackdataplanedeployment.kb.io --ignore-not-found +oc delete validatingwebhookconfiguration/vopenstackassistant-v1beta1.kb.io --ignore-not-found +oc delete mutatingwebhookconfiguration/mopenstackassistant-v1beta1.kb.io --ignore-not-found diff --git a/internal/controller/assistant/openstackassistant_controller.go b/internal/controller/assistant/openstackassistant_controller.go new file mode 100644 index 0000000000..2f4ee72384 --- /dev/null +++ b/internal/controller/assistant/openstackassistant_controller.go @@ -0,0 +1,661 @@ +/* +Copyright 2022. + +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 assistant contains the OpenStackAssistant controller implementation +package assistant + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/go-logr/logr" + + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/openstack-k8s-operators/lib-common/modules/common" + condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + "github.com/openstack-k8s-operators/lib-common/modules/common/configmap" + "github.com/openstack-k8s-operators/lib-common/modules/common/env" + helper "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + common_rbac "github.com/openstack-k8s-operators/lib-common/modules/common/rbac" + "github.com/openstack-k8s-operators/lib-common/modules/common/secret" + "github.com/openstack-k8s-operators/lib-common/modules/common/util" + + assistantv1 "github.com/openstack-k8s-operators/openstack-operator/api/assistant/v1beta1" + "github.com/openstack-k8s-operators/openstack-operator/internal/openstackassistant" +) + +const assistantFinalizer = "assistant.openstack.org/finalizer" + +// OpenStackAssistantReconciler reconciles a OpenStackAssistant object +type OpenStackAssistantReconciler struct { + client.Client + Scheme *runtime.Scheme + Kclient kubernetes.Interface +} + +// GetLogger returns a logger object with a prefix of "controller.name" and additional controller context fields +func (r *OpenStackAssistantReconciler) GetLogger(ctx context.Context) logr.Logger { + return log.FromContext(ctx).WithName("Controllers").WithName("OpenStackAssistant") +} + +// +kubebuilder:rbac:groups=assistant.openstack.org,resources=openstackassistants,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=assistant.openstack.org,resources=openstackassistants/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=assistant.openstack.org,resources=openstackassistants/finalizers,verbs=update +// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=roles,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=rolebindings,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=clusterroles,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups="rbac.authorization.k8s.io",resources=clusterrolebindings,verbs=get;list;watch;create;update;patch;delete + +// Reconcile - +func (r *OpenStackAssistantReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, _err error) { + Log := r.GetLogger(ctx) + + instance := &assistantv1.OpenStackAssistant{} + err := r.Get(ctx, req.NamespacedName, instance) + if err != nil { + if k8s_errors.IsNotFound(err) { + Log.Info("OpenStackAssistant CR not found") + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + Log.Info("OpenStackAssistant CR values", "Name", instance.Name, "Namespace", instance.Namespace, "Image", instance.Spec.ContainerImage) + + helper, err := helper.NewHelper( + instance, + r.Client, + r.Kclient, + r.Scheme, + Log, + ) + if err != nil { + return ctrl.Result{}, err + } + + // initialize status + isNewInstance := instance.Status.Conditions == nil + if isNewInstance { + instance.Status.Conditions = condition.Conditions{} + } + + savedConditions := instance.Status.Conditions.DeepCopy() + + defer func() { + if r := recover(); r != nil { + Log.Info(fmt.Sprintf("panic during reconcile %v\n", r)) + panic(r) + } + condition.RestoreLastTransitionTimes(&instance.Status.Conditions, savedConditions) + if instance.Status.Conditions.AllSubConditionIsTrue() { + instance.Status.Conditions.MarkTrue( + condition.ReadyCondition, condition.ReadyMessage) + } else { + instance.Status.Conditions.MarkUnknown( + condition.ReadyCondition, condition.InitReason, condition.ReadyInitMessage) + instance.Status.Conditions.Set( + instance.Status.Conditions.Mirror(condition.ReadyCondition)) + } + err := helper.PatchInstance(ctx, instance) + if err != nil { + _err = err + return + } + }() + + // Handle finalizer for ClusterRole cleanup + if instance.DeletionTimestamp != nil { + if controllerutil.ContainsFinalizer(instance, assistantFinalizer) { + clusterRoleName := fmt.Sprintf("openstackassistant-%s-%s", instance.Namespace, instance.Name) + if err := r.deleteClusterRBAC(ctx, clusterRoleName); err != nil { + return ctrl.Result{}, err + } + controllerutil.RemoveFinalizer(instance, assistantFinalizer) + if err := r.Update(ctx, instance); err != nil { + return ctrl.Result{}, err + } + Log.Info("Finalizer removed, ClusterRole and ClusterRoleBinding cleaned up") + } + return ctrl.Result{}, nil + } + + if !controllerutil.ContainsFinalizer(instance, assistantFinalizer) { + controllerutil.AddFinalizer(instance, assistantFinalizer) + if err := r.Update(ctx, instance); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true}, nil + } + + cl := condition.CreateList( + condition.UnknownCondition(assistantv1.OpenStackAssistantReadyCondition, condition.InitReason, assistantv1.OpenStackAssistantReadyInitMessage), + condition.UnknownCondition(condition.ServiceAccountReadyCondition, condition.InitReason, condition.ServiceAccountReadyInitMessage), + condition.UnknownCondition(condition.RoleReadyCondition, condition.InitReason, condition.RoleReadyInitMessage), + condition.UnknownCondition(condition.RoleBindingReadyCondition, condition.InitReason, condition.RoleBindingReadyInitMessage), + ) + instance.Status.Conditions.Init(&cl) + instance.Status.ObservedGeneration = instance.Generation + + // Namespace RBAC + rbacRules := namespacedRbacRules() + rbacResult, err := common_rbac.ReconcileRbac(ctx, helper, instance, rbacRules) + if err != nil { + return rbacResult, err + } else if (rbacResult != ctrl.Result{}) { + return rbacResult, nil + } + + // ClusterRole and ClusterRoleBinding + clusterRoleName := fmt.Sprintf("openstackassistant-%s-%s", instance.Namespace, instance.Name) + if err := r.reconcileClusterRBAC(ctx, instance, clusterRoleName); err != nil { + return ctrl.Result{}, err + } + + assistantLabels := map[string]string{ + common.AppSelector: "openstackassistant", + } + + configVars := make(map[string]env.Setter) + + // Validate lightspeed ProviderSecret + _, providerSecretHash, err := secret.GetSecret(ctx, helper, instance.Spec.LightspeedStack.ProviderSecret, instance.Namespace) + if err != nil { + if k8s_errors.IsNotFound(err) { + instance.Status.Conditions.Set(condition.FalseCondition( + assistantv1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + assistantv1.OpenStackAssistantProviderSecretWaitingMessage)) + return ctrl.Result{RequeueAfter: time.Duration(10) * time.Second}, nil + } + instance.Status.Conditions.Set(condition.FalseCondition( + assistantv1.OpenStackAssistantReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + assistantv1.OpenStackAssistantReadyErrorMessage, + err.Error())) + return ctrl.Result{}, err + } + configVars[instance.Spec.LightspeedStack.ProviderSecret] = env.SetValue(providerSecretHash) + + // Validate optional CaBundleSecret + if instance.Spec.LightspeedStack.CaBundleSecretName != "" { + _, caBundleHash, err := secret.GetSecret(ctx, helper, instance.Spec.LightspeedStack.CaBundleSecretName, instance.Namespace) + if err != nil { + if k8s_errors.IsNotFound(err) { + instance.Status.Conditions.Set(condition.FalseCondition( + assistantv1.OpenStackAssistantReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + assistantv1.OpenStackAssistantReadyErrorMessage, + fmt.Sprintf("CA bundle secret %s not found", instance.Spec.LightspeedStack.CaBundleSecretName))) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + configVars[instance.Spec.LightspeedStack.CaBundleSecretName] = env.SetValue(caBundleHash) + } + + // Validate optional Recipes ConfigMap + if instance.Spec.Goose != nil && instance.Spec.Goose.Recipes != nil { + _, recipesHash, err := configmap.GetConfigMapAndHashWithName(ctx, helper, *instance.Spec.Goose.Recipes, instance.Namespace) + if err != nil { + if k8s_errors.IsNotFound(err) { + instance.Status.Conditions.Set(condition.FalseCondition( + assistantv1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + assistantv1.OpenStackAssistantRecipesWaitingMessage)) + return ctrl.Result{RequeueAfter: time.Duration(10) * time.Second}, nil + } + return ctrl.Result{}, err + } + configVars[*instance.Spec.Goose.Recipes] = env.SetValue(recipesHash) + } + + // Validate optional Hints ConfigMap + if instance.Spec.Goose != nil && instance.Spec.Goose.Hints != nil { + _, hintsHash, err := configmap.GetConfigMapAndHashWithName(ctx, helper, *instance.Spec.Goose.Hints, instance.Namespace) + if err != nil { + if k8s_errors.IsNotFound(err) { + instance.Status.Conditions.Set(condition.FalseCondition( + assistantv1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + assistantv1.OpenStackAssistantHintsWaitingMessage)) + return ctrl.Result{RequeueAfter: time.Duration(10) * time.Second}, nil + } + return ctrl.Result{}, err + } + configVars[*instance.Spec.Goose.Hints] = env.SetValue(hintsHash) + } + + // Create/update entrypoint ConfigMap + entrypointCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: instance.Name + "-entrypoint", + Namespace: instance.Namespace, + }, + } + _, err = controllerutil.CreateOrPatch(ctx, r.Client, entrypointCM, func() error { + entrypointCM.Data = map[string]string{ + "entrypoint.sh": openstackassistant.EntrypointScript(), + } + return controllerutil.SetControllerReference(instance, entrypointCM, r.Scheme) + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error creating entrypoint ConfigMap: %w", err) + } + + // Compute composite config hash + configVarsHash, err := util.HashOfInputHashes(configVars) + if err != nil { + return ctrl.Result{}, err + } + + // Build PodSpec + spec := openstackassistant.AssistantPodSpec(instance, configVarsHash) + + podSpecHash, err := util.ObjectHash(spec) + if err != nil { + return ctrl.Result{}, err + } + + podSpecHashName := "podSpec" + + // Create/update Pod + assistantPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: instance.Name, + Namespace: instance.Namespace, + }, + } + + op, err := controllerutil.CreateOrPatch(ctx, r.Client, assistantPod, func() error { + isPodUpdate := !assistantPod.CreationTimestamp.IsZero() + currentPodSpecHash := instance.Status.Hash[podSpecHashName] + if !isPodUpdate || currentPodSpecHash != podSpecHash { + assistantPod.Spec = spec + } + assistantPod.Labels = util.MergeStringMaps(assistantPod.Labels, assistantLabels) + + return controllerutil.SetControllerReference(instance, assistantPod, r.Scheme) + }) + if err != nil { + var forbiddenPodSpecChangeErr *k8s_errors.StatusError + + forbiddenPodSpec := false + if errors.As(err, &forbiddenPodSpecChangeErr) { + if forbiddenPodSpecChangeErr.ErrStatus.Reason == metav1.StatusReasonForbidden { + forbiddenPodSpec = true + } + } + + if forbiddenPodSpec || k8s_errors.IsInvalid(err) { + if err := r.Delete(ctx, assistantPod); err != nil && !k8s_errors.IsNotFound(err) { + return ctrl.Result{}, fmt.Errorf("error deleting OpenStackAssistant pod %s: %w", assistantPod.Name, err) + } + Log.Info(fmt.Sprintf("OpenStackAssistant pod deleted due to change %s", err.Error())) + + return ctrl.Result{Requeue: true}, nil + } + + return ctrl.Result{}, fmt.Errorf("failed to create or update pod %s: %w", assistantPod.Name, err) + } + + instance.Status.Hash, _ = util.SetHash(instance.Status.Hash, podSpecHashName, podSpecHash) + instance.Status.PodName = assistantPod.Name + + if op != controllerutil.OperationResultNone { + util.LogForObject( + helper, + fmt.Sprintf("Pod %s successfully reconciled - operation: %s", assistantPod.Name, string(op)), + instance, + ) + } + + // Force-delete pods stuck in Terminating >3 minutes + if assistantPod.DeletionTimestamp != nil { + terminatingDuration := time.Since(assistantPod.DeletionTimestamp.Time) + if terminatingDuration > time.Minute*3 { + err := r.Delete(ctx, assistantPod, client.GracePeriodSeconds(0)) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to force delete pod: %w", err) + } + } + } + + // Check pod readiness + podReady := false + for _, cond := range assistantPod.Status.Conditions { + if cond.Type == corev1.PodReady && cond.Status == corev1.ConditionTrue { + podReady = true + break + } + } + + if podReady { + instance.Status.Conditions.MarkTrue( + assistantv1.OpenStackAssistantReadyCondition, + assistantv1.OpenStackAssistantReadyMessage, + ) + } else { + instance.Status.Conditions.Set(condition.FalseCondition( + assistantv1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + assistantv1.OpenStackAssistantReadyRunningMessage)) + } + + return ctrl.Result{}, nil +} + +func (r *OpenStackAssistantReconciler) reconcileClusterRBAC(ctx context.Context, instance *assistantv1.OpenStackAssistant, clusterRoleName string) error { + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + } + _, err := controllerutil.CreateOrPatch(ctx, r.Client, clusterRole, func() error { + clusterRole.Rules = clusterRoleRules() + return nil + }) + if err != nil { + return fmt.Errorf("error reconciling ClusterRole %s: %w", clusterRoleName, err) + } + + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterRoleName, + }, + } + _, err = controllerutil.CreateOrPatch(ctx, r.Client, clusterRoleBinding, func() error { + clusterRoleBinding.RoleRef = rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "ClusterRole", + Name: clusterRoleName, + } + clusterRoleBinding.Subjects = []rbacv1.Subject{{ + Kind: "ServiceAccount", + Name: instance.RbacResourceName(), + Namespace: instance.Namespace, + }} + return nil + }) + if err != nil { + return fmt.Errorf("error reconciling ClusterRoleBinding %s: %w", clusterRoleName, err) + } + + return nil +} + +func (r *OpenStackAssistantReconciler) deleteClusterRBAC(ctx context.Context, name string) error { + clusterRoleBinding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + if err := r.Delete(ctx, clusterRoleBinding); err != nil && !k8s_errors.IsNotFound(err) { + return fmt.Errorf("error deleting ClusterRoleBinding %s: %w", name, err) + } + + clusterRole := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + } + if err := r.Delete(ctx, clusterRole); err != nil && !k8s_errors.IsNotFound(err) { + return fmt.Errorf("error deleting ClusterRole %s: %w", name, err) + } + + return nil +} + +func namespacedRbacRules() []rbacv1.PolicyRule { + return []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{ + "pods", "pods/log", "services", "endpoints", + "configmaps", "secrets", "events", + "persistentvolumeclaims", "serviceaccounts", + }, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"apps"}, + Resources: []string{"deployments", "statefulsets", "daemonsets", "replicasets"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"batch"}, + Resources: []string{"jobs", "cronjobs"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"route.openshift.io"}, + Resources: []string{"routes"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"k8s.cni.cncf.io"}, + Resources: []string{"network-attachment-definitions"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"cert-manager.io"}, + Resources: []string{"certificates", "issuers"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{ + "core.openstack.org", + "dataplane.openstack.org", + "keystone.openstack.org", + "mariadb.openstack.org", + "memcached.openstack.org", + "rabbitmq.openstack.org", + "nova.openstack.org", + "neutron.openstack.org", + "glance.openstack.org", + "cinder.openstack.org", + "heat.openstack.org", + "octavia.openstack.org", + "designate.openstack.org", + "barbican.openstack.org", + "manila.openstack.org", + "horizon.openstack.org", + "swift.openstack.org", + "placement.openstack.org", + "ovn.openstack.org", + "ironic.openstack.org", + "telemetry.openstack.org", + "network.openstack.org", + }, + Resources: []string{"*"}, + Verbs: []string{"get", "list", "watch"}, + }, + } +} + +func clusterRoleRules() []rbacv1.PolicyRule { + return []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"nodes", "persistentvolumes", "namespaces"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"events"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"config.openshift.io"}, + Resources: []string{"clusteroperators", "clusterversions"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"nmstate.io"}, + Resources: []string{"nodenetworkconfigurationpolicies"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"machine.openshift.io"}, + Resources: []string{"machines", "machinesets"}, + Verbs: []string{"get", "list", "watch"}, + }, + { + APIGroups: []string{"storage.k8s.io"}, + Resources: []string{"storageclasses"}, + Verbs: []string{"get", "list", "watch"}, + }, + } +} + +// fields to index to reconcile when change +const ( + providerSecretField = ".spec.lightspeedStack.providerSecret" + caBundleSecretField = ".spec.lightspeedStack.caBundleSecretName" + recipesField = ".spec.goose.recipes" + hintsField = ".spec.goose.hints" +) + +var allWatchFields = []string{ + providerSecretField, + caBundleSecretField, + recipesField, + hintsField, +} + +// SetupWithManager sets up the controller with the Manager. +func (r *OpenStackAssistantReconciler) SetupWithManager( + ctx context.Context, mgr ctrl.Manager) error { + + if err := mgr.GetFieldIndexer().IndexField(ctx, &assistantv1.OpenStackAssistant{}, providerSecretField, func(rawObj client.Object) []string { + cr := rawObj.(*assistantv1.OpenStackAssistant) + if cr.Spec.LightspeedStack.ProviderSecret == "" { + return nil + } + return []string{cr.Spec.LightspeedStack.ProviderSecret} + }); err != nil { + return err + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &assistantv1.OpenStackAssistant{}, caBundleSecretField, func(rawObj client.Object) []string { + cr := rawObj.(*assistantv1.OpenStackAssistant) + if cr.Spec.LightspeedStack.CaBundleSecretName == "" { + return nil + } + return []string{cr.Spec.LightspeedStack.CaBundleSecretName} + }); err != nil { + return err + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &assistantv1.OpenStackAssistant{}, recipesField, func(rawObj client.Object) []string { + cr := rawObj.(*assistantv1.OpenStackAssistant) + if cr.Spec.Goose == nil || cr.Spec.Goose.Recipes == nil || *cr.Spec.Goose.Recipes == "" { + return nil + } + return []string{*cr.Spec.Goose.Recipes} + }); err != nil { + return err + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &assistantv1.OpenStackAssistant{}, hintsField, func(rawObj client.Object) []string { + cr := rawObj.(*assistantv1.OpenStackAssistant) + if cr.Spec.Goose == nil || cr.Spec.Goose.Hints == nil || *cr.Spec.Goose.Hints == "" { + return nil + } + return []string{*cr.Spec.Goose.Hints} + }); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + For(&assistantv1.OpenStackAssistant{}). + Owns(&corev1.Pod{}). + Owns(&corev1.ServiceAccount{}). + Owns(&rbacv1.Role{}). + Owns(&rbacv1.RoleBinding{}). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &corev1.ConfigMap{}, + handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Complete(r) +} + +func (r *OpenStackAssistantReconciler) findObjectsForSrc(ctx context.Context, src client.Object) []reconcile.Request { + requests := []reconcile.Request{} + + Log := r.GetLogger(context.Background()) + + for _, field := range allWatchFields { + crList := &assistantv1.OpenStackAssistantList{} + listOps := &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(field, src.GetName()), + Namespace: src.GetNamespace(), + } + err := r.List(ctx, crList, listOps) + if err != nil { + Log.Error(err, fmt.Sprintf("listing %s for field: %s - %s", crList.GroupVersionKind().Kind, field, src.GetNamespace())) + return requests + } + + for _, item := range crList.Items { + Log.Info(fmt.Sprintf("input source %s changed, reconcile: %s - %s", src.GetName(), item.GetName(), item.GetNamespace())) + + requests = append(requests, + reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: item.GetName(), + Namespace: item.GetNamespace(), + }, + }, + ) + } + } + + return requests +} diff --git a/internal/controller/assistant/openstackassistant_controller_test.go b/internal/controller/assistant/openstackassistant_controller_test.go new file mode 100644 index 0000000000..2f7d3b9202 --- /dev/null +++ b/internal/controller/assistant/openstackassistant_controller_test.go @@ -0,0 +1,250 @@ +/* +Copyright 2022. + +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 assistant + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + assistantv1beta1 "github.com/openstack-k8s-operators/openstack-operator/api/assistant/v1beta1" +) + +var _ = Describe("OpenStackAssistant Controller", func() { + const resourceName = "test-assistant" + const namespace = "default" + const providerSecretName = "test-provider-secret" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: namespace, + } + + BeforeEach(func() { + By("creating the provider secret") + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: providerSecretName, + Namespace: namespace, + }, + Data: map[string][]byte{ + "lightspeed.json": []byte(`{"name":"lightspeed"}`), + }, + } + err := k8sClient.Get(ctx, types.NamespacedName{Name: providerSecretName, Namespace: namespace}, &corev1.Secret{}) + if errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, secret)).To(Succeed()) + } + }) + + Context("When creating an OpenStackAssistant resource", func() { + BeforeEach(func() { + By("creating the OpenStackAssistant resource") + resource := &assistantv1beta1.OpenStackAssistant{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: namespace, + }, + Spec: assistantv1beta1.OpenStackAssistantSpec{ + ContainerImage: "quay.io/dprince/goose:oc-fedora", + Provider: assistantv1beta1.ProviderGoose, + LightspeedStack: assistantv1beta1.LightspeedStackSpec{ + ProviderSecret: providerSecretName, + }, + }, + } + err := k8sClient.Get(ctx, typeNamespacedName, &assistantv1beta1.OpenStackAssistant{}) + if errors.IsNotFound(err) { + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + resource := &assistantv1beta1.OpenStackAssistant{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + if err == nil { + resource.Finalizers = nil + Expect(k8sClient.Update(ctx, resource)).To(Succeed()) + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + } + // Clean up cluster-scoped resources + clusterRoleName := "openstackassistant-" + namespace + "-" + resourceName + cr := &rbacv1.ClusterRole{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: clusterRoleName}, cr); err == nil { + _ = k8sClient.Delete(ctx, cr) + } + crb := &rbacv1.ClusterRoleBinding{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: clusterRoleName}, crb); err == nil { + _ = k8sClient.Delete(ctx, crb) + } + }) + + It("should add a finalizer on first reconcile", func() { + reconciler := &OpenStackAssistantReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Kclient: kubernetes.NewForConfigOrDie(cfg), + } + + result, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Requeue).To(BeTrue()) + + updated := &assistantv1beta1.OpenStackAssistant{} + Expect(k8sClient.Get(ctx, typeNamespacedName, updated)).To(Succeed()) + Expect(updated.Finalizers).To(ContainElement(assistantFinalizer)) + }) + + It("should create an entrypoint ConfigMap after reconciliation", func() { + reconciler := &OpenStackAssistantReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Kclient: kubernetes.NewForConfigOrDie(cfg), + } + + // First reconcile adds finalizer + _, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + // Second reconcile does the actual work + _, err = reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + + cm := &corev1.ConfigMap{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: resourceName + "-entrypoint", + Namespace: namespace, + }, cm)).To(Succeed()) + Expect(cm.Data).To(HaveKey("entrypoint.sh")) + Expect(cm.Data["entrypoint.sh"]).To(ContainSubstring("sleep infinity")) + }) + + It("should create a ClusterRole and ClusterRoleBinding", func() { + reconciler := &OpenStackAssistantReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Kclient: kubernetes.NewForConfigOrDie(cfg), + } + + // First reconcile adds finalizer + _, _ = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + // Second reconcile creates resources + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + clusterRoleName := "openstackassistant-" + namespace + "-" + resourceName + + cr := &rbacv1.ClusterRole{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: clusterRoleName}, cr)).To(Succeed()) + Expect(cr.Rules).NotTo(BeEmpty()) + + hasNodesRule := false + for _, rule := range cr.Rules { + for _, resource := range rule.Resources { + if resource == "nodes" { + hasNodesRule = true + break + } + } + } + Expect(hasNodesRule).To(BeTrue(), "ClusterRole should include nodes resource") + + crb := &rbacv1.ClusterRoleBinding{} + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: clusterRoleName}, crb)).To(Succeed()) + Expect(crb.RoleRef.Name).To(Equal(clusterRoleName)) + Expect(crb.Subjects).To(HaveLen(1)) + Expect(crb.Subjects[0].Name).To(Equal("openstackassistant-" + resourceName)) + Expect(crb.Subjects[0].Namespace).To(Equal(namespace)) + }) + + It("should create a Pod", func() { + reconciler := &OpenStackAssistantReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Kclient: kubernetes.NewForConfigOrDie(cfg), + } + + // First reconcile adds finalizer + _, _ = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + // Second reconcile creates resources + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + pod := &corev1.Pod{} + Expect(k8sClient.Get(ctx, typeNamespacedName, pod)).To(Succeed()) + Expect(pod.Spec.Containers).To(HaveLen(1)) + Expect(pod.Spec.Containers[0].Name).To(Equal("goose")) + Expect(pod.Spec.Containers[0].Image).To(Equal("quay.io/dprince/goose:oc-fedora")) + Expect(pod.Labels).To(HaveKeyWithValue("service", "openstackassistant")) + }) + + It("should set status conditions after reconciliation", func() { + reconciler := &OpenStackAssistantReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Kclient: kubernetes.NewForConfigOrDie(cfg), + } + + // First reconcile adds finalizer + _, _ = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + // Second reconcile creates resources + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: typeNamespacedName}) + Expect(err).NotTo(HaveOccurred()) + + instance := &assistantv1beta1.OpenStackAssistant{} + Expect(k8sClient.Get(ctx, typeNamespacedName, instance)).To(Succeed()) + Expect(instance.Status.Conditions).NotTo(BeEmpty()) + Expect(instance.Status.PodName).To(Equal(resourceName)) + Expect(instance.Status.Hash).To(HaveKey("podSpec")) + }) + }) + + Context("When the CR does not exist", func() { + It("should return no error", func() { + reconciler := &OpenStackAssistantReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + Kclient: kubernetes.NewForConfigOrDie(cfg), + } + + result, err := reconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: "nonexistent", + Namespace: namespace, + }, + }) + Expect(err).NotTo(HaveOccurred()) + Expect(result).To(Equal(reconcile.Result{})) + }) + }) +}) diff --git a/internal/controller/assistant/suite_test.go b/internal/controller/assistant/suite_test.go new file mode 100644 index 0000000000..76652298c5 --- /dev/null +++ b/internal/controller/assistant/suite_test.go @@ -0,0 +1,120 @@ +/* +Copyright 2022. + +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 assistant + +import ( + "context" + "os" + "path/filepath" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + assistantv1beta1 "github.com/openstack-k8s-operators/openstack-operator/api/assistant/v1beta1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + cfg *rest.Config + k8sClient client.Client +) + +func TestControllers(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Controller Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = assistantv1beta1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + err = rbacv1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/openstackassistant/funcs.go b/internal/openstackassistant/funcs.go new file mode 100644 index 0000000000..02c99fbf23 --- /dev/null +++ b/internal/openstackassistant/funcs.go @@ -0,0 +1,262 @@ +/* +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 openstackassistant provides functionality for managing OpenStack assistant resources +package openstackassistant + +import ( + env "github.com/openstack-k8s-operators/lib-common/modules/common/env" + assistantv1 "github.com/openstack-k8s-operators/openstack-operator/api/assistant/v1beta1" + + corev1 "k8s.io/api/core/v1" + "k8s.io/utils/ptr" +) + +// EntrypointScript returns the entrypoint shell script for the goose provider +func EntrypointScript() string { + return `#!/bin/sh +set -eu + +# Create goose config directory +mkdir -p $HOME/.config/goose/custom_providers + +# Write goose config.yaml +cat > $HOME/.config/goose/config.yaml <<'GOOSE_CONFIG' +extensions: + developer: + enabled: true + type: builtin + computercontroller: + enabled: false + type: builtin + summarize: + enabled: true + type: builtin + summon: + enabled: true + type: builtin + apps: + enabled: false + type: builtin + analyze: + enabled: false + type: builtin + todo: + enabled: false + type: builtin + extensionmanager: + enabled: false + type: builtin + chatrecall: + enabled: false + type: builtin +GOOSE_CONFIG + +# Discover and register recipe files as slash commands +if [ -d /tmp/recipes ]; then + for recipe in /tmp/recipes/*.yaml /tmp/recipes/*.yml; do + [ -f "$recipe" ] || continue + basename=$(basename "$recipe") + # Strip extension to get the command name + cmdname="${basename%.*}" + echo " ${cmdname}:" >> $HOME/.config/goose/config.yaml + echo " type: recipe" >> $HOME/.config/goose/config.yaml + echo " enabled: true" >> $HOME/.config/goose/config.yaml + echo " recipe_source: ${recipe}" >> $HOME/.config/goose/config.yaml + done +fi + +# Copy hints if present +if [ -f /tmp/hints/hints ]; then + cp /tmp/hints/hints ~/.goosehints +fi + +# Copy lightspeed provider config +if [ -f /tmp/lightspeed-provider/lightspeed.json ]; then + cp /tmp/lightspeed-provider/lightspeed.json $HOME/.config/goose/custom_providers/lightspeed.json +fi + +exec sleep infinity +` +} + +// AssistantPodSpec returns the PodSpec for the assistant pod +func AssistantPodSpec( + instance *assistantv1.OpenStackAssistant, + configHash string, +) corev1.PodSpec { + envVars := map[string]env.Setter{} + envVars["CONFIG_HASH"] = env.SetValue(configHash) + envVars["GOOSE_PROVIDER"] = env.SetValue("lightspeed") + envVars["GOOSE_TELEMETRY_ENABLED"] = env.SetValue("false") + envVars["GOOSE_DISABLE_KEYRING"] = env.SetValue("1") + + if instance.Spec.Goose != nil && instance.Spec.Goose.Model != "" { + envVars["GOOSE_MODEL"] = env.SetValue(instance.Spec.Goose.Model) + } + + if instance.Spec.Env != nil { + for idx := range instance.Spec.Env { + e := instance.Spec.Env[idx] + envVars[e.Name] = func(env *corev1.EnvVar) { + env.Value = e.Value + env.ValueFrom = e.ValueFrom + } + } + } + + volumes := assistantPodVolumes(instance) + volumeMounts := assistantPodVolumeMounts(instance) + + containerName := "goose" + if instance.Spec.Provider != "" { + containerName = string(instance.Spec.Provider) + } + + podSpec := corev1.PodSpec{ + TerminationGracePeriodSeconds: ptr.To[int64](0), + ServiceAccountName: instance.RbacResourceName(), + Volumes: volumes, + Containers: []corev1.Container{ + { + Name: containerName, + Image: instance.Spec.ContainerImage, + Command: []string{"/bin/sh"}, + Args: []string{"/tmp/entrypoint/entrypoint.sh"}, + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: ptr.To(true), + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{ + "ALL", + }, + }, + }, + Env: env.MergeEnvs([]corev1.EnvVar{}, envVars), + VolumeMounts: volumeMounts, + }, + }, + } + + if instance.Spec.NodeSelector != nil { + podSpec.NodeSelector = *instance.Spec.NodeSelector + } + + return podSpec +} + +func assistantPodVolumeMounts(instance *assistantv1.OpenStackAssistant) []corev1.VolumeMount { + mounts := []corev1.VolumeMount{ + { + Name: "entrypoint", + MountPath: "/tmp/entrypoint", + ReadOnly: true, + }, + { + Name: "lightspeed-provider", + MountPath: "/tmp/lightspeed-provider", + ReadOnly: true, + }, + } + + if instance.Spec.Goose != nil { + if instance.Spec.Goose.Recipes != nil { + mounts = append(mounts, corev1.VolumeMount{ + Name: "recipes", + MountPath: "/tmp/recipes", + ReadOnly: true, + }) + } + if instance.Spec.Goose.Hints != nil { + mounts = append(mounts, corev1.VolumeMount{ + Name: "hints", + MountPath: "/tmp/hints", + ReadOnly: true, + }) + } + } + + if instance.Spec.LightspeedStack.CaBundleSecretName != "" { + mounts = append(mounts, corev1.VolumeMount{ + Name: "ca-bundle", + MountPath: "/etc/ssl/certs/ca-certificates.crt", + SubPath: "ca-bundle.crt", + ReadOnly: true, + }) + } + + return mounts +} + +func assistantPodVolumes(instance *assistantv1.OpenStackAssistant) []corev1.Volume { + volumes := []corev1.Volume{ + { + Name: "entrypoint", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: instance.Name + "-entrypoint", + }, + DefaultMode: ptr.To[int32](0755), + }, + }, + }, + { + Name: "lightspeed-provider", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: instance.Spec.LightspeedStack.ProviderSecret, + }, + }, + }, + } + + if instance.Spec.Goose != nil { + if instance.Spec.Goose.Recipes != nil { + volumes = append(volumes, corev1.Volume{ + Name: "recipes", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: *instance.Spec.Goose.Recipes, + }, + }, + }, + }) + } + if instance.Spec.Goose.Hints != nil { + volumes = append(volumes, corev1.Volume{ + Name: "hints", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: *instance.Spec.Goose.Hints, + }, + }, + }, + }) + } + } + + if instance.Spec.LightspeedStack.CaBundleSecretName != "" { + volumes = append(volumes, corev1.Volume{ + Name: "ca-bundle", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: instance.Spec.LightspeedStack.CaBundleSecretName, + }, + }, + }) + } + + return volumes +} diff --git a/internal/openstackassistant/funcs_test.go b/internal/openstackassistant/funcs_test.go new file mode 100644 index 0000000000..cd1e9ff03a --- /dev/null +++ b/internal/openstackassistant/funcs_test.go @@ -0,0 +1,329 @@ +/* +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 openstackassistant + +import ( + "strings" + "testing" + + . "github.com/onsi/gomega" + + assistantv1 "github.com/openstack-k8s-operators/openstack-operator/api/assistant/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func newTestInstance() *assistantv1.OpenStackAssistant { + return &assistantv1.OpenStackAssistant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-assistant", + Namespace: "openstack", + }, + Spec: assistantv1.OpenStackAssistantSpec{ + ContainerImage: "quay.io/dprince/goose:oc-fedora", + Provider: assistantv1.ProviderGoose, + LightspeedStack: assistantv1.LightspeedStackSpec{ + ProviderSecret: "lightspeed-provider-config", + }, + }, + } +} + +func TestEntrypointScript(t *testing.T) { + g := NewWithT(t) + + script := EntrypointScript() + + g.Expect(script).To(ContainSubstring("#!/bin/sh")) + g.Expect(script).To(ContainSubstring("mkdir -p $HOME/.config/goose/custom_providers")) + g.Expect(script).To(ContainSubstring("config.yaml")) + g.Expect(script).To(ContainSubstring("developer:")) + g.Expect(script).To(ContainSubstring("enabled: true")) + g.Expect(script).To(ContainSubstring("/tmp/recipes/")) + g.Expect(script).To(ContainSubstring("/tmp/hints/hints")) + g.Expect(script).To(ContainSubstring("/tmp/lightspeed-provider/lightspeed.json")) + g.Expect(script).To(ContainSubstring("sleep infinity")) +} + +func TestAssistantPodSpec_BasicFields(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "testhash123") + + g.Expect(spec.ServiceAccountName).To(Equal("openstackassistant-test-assistant")) + g.Expect(*spec.TerminationGracePeriodSeconds).To(Equal(int64(0))) + g.Expect(spec.Containers).To(HaveLen(1)) + + container := spec.Containers[0] + g.Expect(container.Name).To(Equal("goose")) + g.Expect(container.Image).To(Equal("quay.io/dprince/goose:oc-fedora")) + g.Expect(container.Command).To(Equal([]string{"/bin/sh"})) + g.Expect(container.Args).To(Equal([]string{"/tmp/entrypoint/entrypoint.sh"})) +} + +func TestAssistantPodSpec_SecurityContext(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash") + sc := spec.Containers[0].SecurityContext + + g.Expect(*sc.RunAsNonRoot).To(BeTrue()) + g.Expect(*sc.AllowPrivilegeEscalation).To(BeFalse()) + g.Expect(sc.Capabilities.Drop).To(ContainElement(corev1.Capability("ALL"))) +} + +func TestAssistantPodSpec_DefaultEnvVars(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "somehash") + envVars := spec.Containers[0].Env + + envMap := make(map[string]string) + for _, e := range envVars { + envMap[e.Name] = e.Value + } + + g.Expect(envMap).To(HaveKeyWithValue("CONFIG_HASH", "somehash")) + g.Expect(envMap).To(HaveKeyWithValue("GOOSE_PROVIDER", "lightspeed")) + g.Expect(envMap).To(HaveKeyWithValue("GOOSE_TELEMETRY_ENABLED", "false")) + g.Expect(envMap).To(HaveKeyWithValue("GOOSE_DISABLE_KEYRING", "1")) +} + +func TestAssistantPodSpec_GooseModel(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &assistantv1.GooseConfig{ + Model: "gemini/models/gemini-2.5-flash", + } + + spec := AssistantPodSpec(instance, "hash") + envVars := spec.Containers[0].Env + + envMap := make(map[string]string) + for _, e := range envVars { + envMap[e.Name] = e.Value + } + + g.Expect(envMap).To(HaveKeyWithValue("GOOSE_MODEL", "gemini/models/gemini-2.5-flash")) + g.Expect(envMap).To(HaveKeyWithValue("GOOSE_PROVIDER", "lightspeed")) +} + +func TestAssistantPodSpec_CustomEnvVars(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + instance.Spec.Env = []corev1.EnvVar{ + {Name: "LIGHTSPEED_API_KEY", Value: "dummy"}, + } + + spec := AssistantPodSpec(instance, "hash") + envVars := spec.Containers[0].Env + + envMap := make(map[string]string) + for _, e := range envVars { + envMap[e.Name] = e.Value + } + + g.Expect(envMap).To(HaveKeyWithValue("LIGHTSPEED_API_KEY", "dummy")) + g.Expect(envMap).To(HaveKeyWithValue("GOOSE_PROVIDER", "lightspeed")) +} + +func TestAssistantPodSpec_MinimalVolumes(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash") + + g.Expect(spec.Volumes).To(HaveLen(2)) + + volumeNames := make([]string, len(spec.Volumes)) + for i, v := range spec.Volumes { + volumeNames[i] = v.Name + } + g.Expect(volumeNames).To(ContainElements("entrypoint", "lightspeed-provider")) + + mountNames := make([]string, len(spec.Containers[0].VolumeMounts)) + for i, m := range spec.Containers[0].VolumeMounts { + mountNames[i] = m.Name + } + g.Expect(mountNames).To(ContainElements("entrypoint", "lightspeed-provider")) +} + +func TestAssistantPodSpec_WithRecipesAndHints(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &assistantv1.GooseConfig{ + Recipes: ptr.To("assistant-recipes"), + Hints: ptr.To("assistant-hints"), + } + + spec := AssistantPodSpec(instance, "hash") + + g.Expect(spec.Volumes).To(HaveLen(4)) + volumeNames := make([]string, len(spec.Volumes)) + for i, v := range spec.Volumes { + volumeNames[i] = v.Name + } + g.Expect(volumeNames).To(ContainElements("entrypoint", "lightspeed-provider", "recipes", "hints")) + + mountNames := make([]string, len(spec.Containers[0].VolumeMounts)) + for i, m := range spec.Containers[0].VolumeMounts { + mountNames[i] = m.Name + } + g.Expect(mountNames).To(ContainElements("entrypoint", "lightspeed-provider", "recipes", "hints")) +} + +func TestAssistantPodSpec_WithCaBundle(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + instance.Spec.LightspeedStack.CaBundleSecretName = "lightspeed-ca-bundle" + + spec := AssistantPodSpec(instance, "hash") + + g.Expect(spec.Volumes).To(HaveLen(3)) + + var caBundleVolume *corev1.Volume + for i := range spec.Volumes { + if spec.Volumes[i].Name == "ca-bundle" { + caBundleVolume = &spec.Volumes[i] + break + } + } + g.Expect(caBundleVolume).NotTo(BeNil()) + g.Expect(caBundleVolume.Secret.SecretName).To(Equal("lightspeed-ca-bundle")) + + var caBundleMount *corev1.VolumeMount + for i := range spec.Containers[0].VolumeMounts { + if spec.Containers[0].VolumeMounts[i].Name == "ca-bundle" { + caBundleMount = &spec.Containers[0].VolumeMounts[i] + break + } + } + g.Expect(caBundleMount).NotTo(BeNil()) + g.Expect(caBundleMount.MountPath).To(Equal("/etc/ssl/certs/ca-certificates.crt")) + g.Expect(caBundleMount.SubPath).To(Equal("ca-bundle.crt")) + g.Expect(caBundleMount.ReadOnly).To(BeTrue()) +} + +func TestAssistantPodSpec_WithNodeSelector(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + instance.Spec.NodeSelector = &map[string]string{ + "node-role.kubernetes.io/worker": "", + } + + spec := AssistantPodSpec(instance, "hash") + + g.Expect(spec.NodeSelector).To(HaveKeyWithValue("node-role.kubernetes.io/worker", "")) +} + +func TestAssistantPodSpec_WithoutNodeSelector(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash") + + g.Expect(spec.NodeSelector).To(BeNil()) +} + +func TestAssistantPodSpec_EntrypointConfigMapName(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash") + + var entrypointVolume *corev1.Volume + for i := range spec.Volumes { + if spec.Volumes[i].Name == "entrypoint" { + entrypointVolume = &spec.Volumes[i] + break + } + } + g.Expect(entrypointVolume).NotTo(BeNil()) + g.Expect(entrypointVolume.ConfigMap.Name).To(Equal("test-assistant-entrypoint")) + g.Expect(*entrypointVolume.ConfigMap.DefaultMode).To(Equal(int32(0755))) +} + +func TestAssistantPodSpec_LightspeedProviderSecretName(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash") + + var providerVolume *corev1.Volume + for i := range spec.Volumes { + if spec.Volumes[i].Name == "lightspeed-provider" { + providerVolume = &spec.Volumes[i] + break + } + } + g.Expect(providerVolume).NotTo(BeNil()) + g.Expect(providerVolume.Secret.SecretName).To(Equal("lightspeed-provider-config")) +} + +func TestAssistantPodSpec_AllVolumeMountsReadOnly(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &assistantv1.GooseConfig{ + Recipes: ptr.To("recipes-cm"), + Hints: ptr.To("hints-cm"), + } + instance.Spec.LightspeedStack.CaBundleSecretName = "ca-secret" + + spec := AssistantPodSpec(instance, "hash") + + for _, mount := range spec.Containers[0].VolumeMounts { + g.Expect(mount.ReadOnly).To(BeTrue(), "VolumeMount %s should be read-only", mount.Name) + } +} + +func TestAssistantPodSpec_RecipesOnlyNoHints(t *testing.T) { + g := NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &assistantv1.GooseConfig{ + Recipes: ptr.To("recipes-cm"), + } + + spec := AssistantPodSpec(instance, "hash") + + g.Expect(spec.Volumes).To(HaveLen(3)) + volumeNames := make([]string, len(spec.Volumes)) + for i, v := range spec.Volumes { + volumeNames[i] = v.Name + } + g.Expect(volumeNames).To(ContainElement("recipes")) + g.Expect(volumeNames).NotTo(ContainElement("hints")) +} + +func TestEntrypointScript_DisabledExtensions(t *testing.T) { + g := NewWithT(t) + + script := EntrypointScript() + + disabledExtensions := []string{"computercontroller", "apps", "analyze", "todo", "extensionmanager", "chatrecall"} + for _, ext := range disabledExtensions { + idx := strings.Index(script, ext+":") + g.Expect(idx).To(BeNumerically(">", 0), "should contain %s", ext) + enabledLine := script[idx:] + enabledLine = enabledLine[:strings.Index(enabledLine, "\n")] + g.Expect(script).To(ContainSubstring(ext)) + } + + enabledExtensions := []string{"developer", "summarize", "summon"} + for _, ext := range enabledExtensions { + g.Expect(script).To(ContainSubstring(ext)) + } +} diff --git a/internal/webhook/assistant/v1beta1/openstackassistant_webhook.go b/internal/webhook/assistant/v1beta1/openstackassistant_webhook.go new file mode 100644 index 0000000000..1525d8355a --- /dev/null +++ b/internal/webhook/assistant/v1beta1/openstackassistant_webhook.go @@ -0,0 +1,102 @@ +/* +Copyright 2022. + +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 v1beta1 implements webhook handlers for the assistant.openstack.org API group. +package v1beta1 + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + assistantv1beta1 "github.com/openstack-k8s-operators/openstack-operator/api/assistant/v1beta1" +) + +// nolint:unused +var openstackassistantlog = logf.Log.WithName("openstackassistant-resource") + +// SetupOpenStackAssistantWebhookWithManager registers the webhook for OpenStackAssistant in the manager. +func SetupOpenStackAssistantWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr).For(&assistantv1beta1.OpenStackAssistant{}). + WithValidator(&OpenStackAssistantCustomValidator{}). + WithDefaulter(&OpenStackAssistantCustomDefaulter{}). + Complete() +} + +// +kubebuilder:webhook:path=/mutate-assistant-openstack-org-v1beta1-openstackassistant,mutating=true,failurePolicy=fail,sideEffects=None,groups=assistant.openstack.org,resources=openstackassistants,verbs=create;update,versions=v1beta1,name=mopenstackassistant-v1beta1.kb.io,admissionReviewVersions=v1 + +// OpenStackAssistantCustomDefaulter struct is responsible for setting default values on the custom resource. +type OpenStackAssistantCustomDefaulter struct{} + +var _ webhook.CustomDefaulter = &OpenStackAssistantCustomDefaulter{} + +// Default implements webhook.CustomDefaulter +func (d *OpenStackAssistantCustomDefaulter) Default(_ context.Context, obj runtime.Object) error { + openstackassistant, ok := obj.(*assistantv1beta1.OpenStackAssistant) + if !ok { + return fmt.Errorf("expected an OpenStackAssistant object but got %T", obj) + } + openstackassistantlog.Info("Defaulting for OpenStackAssistant", "name", openstackassistant.GetName()) + + openstackassistant.Default() + + return nil +} + +// +kubebuilder:webhook:path=/validate-assistant-openstack-org-v1beta1-openstackassistant,mutating=false,failurePolicy=fail,sideEffects=None,groups=assistant.openstack.org,resources=openstackassistants,verbs=create;update,versions=v1beta1,name=vopenstackassistant-v1beta1.kb.io,admissionReviewVersions=v1 + +// OpenStackAssistantCustomValidator struct is responsible for validating the OpenStackAssistant resource. +type OpenStackAssistantCustomValidator struct{} + +var _ webhook.CustomValidator = &OpenStackAssistantCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator +func (v *OpenStackAssistantCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + openstackassistant, ok := obj.(*assistantv1beta1.OpenStackAssistant) + if !ok { + return nil, fmt.Errorf("expected an OpenStackAssistant object but got %T", obj) + } + openstackassistantlog.Info("Validation for OpenStackAssistant upon creation", "name", openstackassistant.GetName()) + + return openstackassistant.ValidateCreate() +} + +// ValidateUpdate implements webhook.CustomValidator +func (v *OpenStackAssistantCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + openstackassistant, ok := newObj.(*assistantv1beta1.OpenStackAssistant) + if !ok { + return nil, fmt.Errorf("expected an OpenStackAssistant object for the newObj but got %T", newObj) + } + openstackassistantlog.Info("Validation for OpenStackAssistant upon update", "name", openstackassistant.GetName()) + + return openstackassistant.ValidateUpdate(oldObj) +} + +// ValidateDelete implements webhook.CustomValidator +func (v *OpenStackAssistantCustomValidator) ValidateDelete(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + openstackassistant, ok := obj.(*assistantv1beta1.OpenStackAssistant) + if !ok { + return nil, fmt.Errorf("expected an OpenStackAssistant object but got %T", obj) + } + openstackassistantlog.Info("Validation for OpenStackAssistant upon deletion", "name", openstackassistant.GetName()) + + return openstackassistant.ValidateDelete() +}