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..6aa4285d37 --- /dev/null +++ b/api/assistant/v1beta1/openstackassistant_types.go @@ -0,0 +1,212 @@ +/* +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"` +} + +// MCPServerRef references an MCP server endpoint to configure as a Goose extension +type MCPServerRef struct { + // Name is the extension name in Goose config + // +kubebuilder:validation:Required + Name string `json:"name"` + + // URL is the MCP server's Streamable HTTP endpoint + // (e.g. http://openstackclient-mcp.openstack.svc:8080/openstack/) + // +kubebuilder:validation:Required + URL string `json:"url"` +} + +// 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"` + + // MCPServers lists MCP server endpoints to configure as Goose extensions. + // +kubebuilder:validation:Optional + MCPServers []MCPServerRef `json:"mcpServers,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..daff556ffd --- /dev/null +++ b/api/assistant/v1beta1/zz_generated.deepcopy.go @@ -0,0 +1,229 @@ +//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 + } + if in.MCPServers != nil { + in, out := &in.MCPServers, &out.MCPServers + *out = make([]MCPServerRef, len(*in)) + copy(*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 *MCPServerRef) DeepCopyInto(out *MCPServerRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPServerRef. +func (in *MCPServerRef) DeepCopy() *MCPServerRef { + if in == nil { + return nil + } + out := new(MCPServerRef) + 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..cc7550048b --- /dev/null +++ b/api/bases/assistant.openstack.org_openstackassistants.yaml @@ -0,0 +1,320 @@ +--- +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 + mcpServers: + description: MCPServers lists MCP server endpoints to configure + as Goose extensions. + items: + description: MCPServerRef references an MCP server endpoint + to configure as a Goose extension + properties: + name: + description: Name is the extension name in Goose config + type: string + url: + description: |- + URL is the MCP server's Streamable HTTP endpoint + (e.g. http://openstackclient-mcp.openstack.svc:8080/openstack/) + type: string + required: + - name + - url + type: object + type: array + 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/api/bases/client.openstack.org_openstackclients.yaml b/api/bases/client.openstack.org_openstackclients.yaml index 5f305cf9e5..eff0c0d724 100644 --- a/api/bases/client.openstack.org_openstackclients.yaml +++ b/api/bases/client.openstack.org_openstackclients.yaml @@ -180,6 +180,23 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + mcp: + description: |- + MCP is the optional MCP server sidecar configuration. + When enabled, the rhos-mcps server runs alongside the openstackclient + container and is exposed via a k8s Service. + properties: + containerImage: + description: ContainerImage for the rhos-mcps MCP server container. + type: string + enabled: + default: false + description: Enabled controls whether the MCP server sidecar is + added to the pod. + type: boolean + required: + - containerImage + type: object nodeSelector: additionalProperties: type: string diff --git a/api/bases/core.openstack.org_openstackcontrolplanes.yaml b/api/bases/core.openstack.org_openstackcontrolplanes.yaml index 81a216d318..28f68eab73 100644 --- a/api/bases/core.openstack.org_openstackcontrolplanes.yaml +++ b/api/bases/core.openstack.org_openstackcontrolplanes.yaml @@ -12982,6 +12982,16 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + mcp: + properties: + containerImage: + type: string + enabled: + default: false + type: boolean + required: + - containerImage + type: object nodeSelector: additionalProperties: type: string diff --git a/api/client/v1beta1/openstackclient_types.go b/api/client/v1beta1/openstackclient_types.go index d6592717ba..4d928389ce 100644 --- a/api/client/v1beta1/openstackclient_types.go +++ b/api/client/v1beta1/openstackclient_types.go @@ -37,6 +37,18 @@ type OpenStackClientSpec struct { ContainerImage string `json:"containerImage"` } +// MCPConfig defines optional MCP server sidecar configuration +type MCPConfig struct { + // Enabled controls whether the MCP server sidecar is added to the pod. + // +kubebuilder:validation:Optional + // +kubebuilder:default=false + Enabled bool `json:"enabled"` + + // ContainerImage for the rhos-mcps MCP server container. + // +kubebuilder:validation:Required + ContainerImage string `json:"containerImage"` +} + // OpenStackClientSpecCore defines the desired state of OpenStackClient type OpenStackClientSpecCore struct { // +kubebuilder:validation:Required @@ -67,6 +79,12 @@ type OpenStackClientSpecCore struct { // +optional // List of environment variables to set in the container. Env []corev1.EnvVar `json:"env,omitempty" patchMergeKey:"name" patchStrategy:"merge"` + + // MCP is the optional MCP server sidecar configuration. + // When enabled, the rhos-mcps server runs alongside the openstackclient + // container and is exposed via a k8s Service. + // +kubebuilder:validation:Optional + MCP *MCPConfig `json:"mcp,omitempty"` } // OpenStackClientStatus defines the observed state of OpenStackClient diff --git a/api/client/v1beta1/zz_generated.deepcopy.go b/api/client/v1beta1/zz_generated.deepcopy.go index 1f3aa80216..ae23bf70a9 100644 --- a/api/client/v1beta1/zz_generated.deepcopy.go +++ b/api/client/v1beta1/zz_generated.deepcopy.go @@ -26,6 +26,21 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MCPConfig) DeepCopyInto(out *MCPConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MCPConfig. +func (in *MCPConfig) DeepCopy() *MCPConfig { + if in == nil { + return nil + } + out := new(MCPConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OpenStackClient) DeepCopyInto(out *OpenStackClient) { *out = *in @@ -148,6 +163,11 @@ func (in *OpenStackClientSpecCore) DeepCopyInto(out *OpenStackClientSpecCore) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.MCP != nil { + in, out := &in.MCP, &out.MCP + *out = new(MCPConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackClientSpecCore. diff --git a/bindata/crds/crds.yaml b/bindata/crds/crds.yaml index 38aed21500..e4edb4ad42 100644 --- a/bindata/crds/crds.yaml +++ b/bindata/crds/crds.yaml @@ -1,5 +1,325 @@ 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 + mcpServers: + description: MCPServers lists MCP server endpoints to configure + as Goose extensions. + items: + description: MCPServerRef references an MCP server endpoint + to configure as a Goose extension + properties: + name: + description: Name is the extension name in Goose config + type: string + url: + description: |- + URL is the MCP server's Streamable HTTP endpoint + (e.g. http://openstackclient-mcp.openstack.svc:8080/openstack/) + type: string + required: + - name + - url + type: object + type: array + 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 @@ -448,6 +768,23 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + mcp: + description: |- + MCP is the optional MCP server sidecar configuration. + When enabled, the rhos-mcps server runs alongside the openstackclient + container and is exposed via a k8s Service. + properties: + containerImage: + description: ContainerImage for the rhos-mcps MCP server container. + type: string + enabled: + default: false + description: Enabled controls whether the MCP server sidecar is + added to the pod. + type: boolean + required: + - containerImage + type: object nodeSelector: additionalProperties: type: string @@ -13516,6 +13853,16 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + mcp: + properties: + containerImage: + type: string + enabled: + default: false + type: boolean + required: + - containerImage + type: object nodeSelector: additionalProperties: type: string 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 6480aa0e4b..ac7d09a458 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..cc7550048b --- /dev/null +++ b/config/crd/bases/assistant.openstack.org_openstackassistants.yaml @@ -0,0 +1,320 @@ +--- +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 + mcpServers: + description: MCPServers lists MCP server endpoints to configure + as Goose extensions. + items: + description: MCPServerRef references an MCP server endpoint + to configure as a Goose extension + properties: + name: + description: Name is the extension name in Goose config + type: string + url: + description: |- + URL is the MCP server's Streamable HTTP endpoint + (e.g. http://openstackclient-mcp.openstack.svc:8080/openstack/) + type: string + required: + - name + - url + type: object + type: array + 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/bases/client.openstack.org_openstackclients.yaml b/config/crd/bases/client.openstack.org_openstackclients.yaml index 5f305cf9e5..eff0c0d724 100644 --- a/config/crd/bases/client.openstack.org_openstackclients.yaml +++ b/config/crd/bases/client.openstack.org_openstackclients.yaml @@ -180,6 +180,23 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + mcp: + description: |- + MCP is the optional MCP server sidecar configuration. + When enabled, the rhos-mcps server runs alongside the openstackclient + container and is exposed via a k8s Service. + properties: + containerImage: + description: ContainerImage for the rhos-mcps MCP server container. + type: string + enabled: + default: false + description: Enabled controls whether the MCP server sidecar is + added to the pod. + type: boolean + required: + - containerImage + type: object nodeSelector: additionalProperties: type: string diff --git a/config/crd/bases/core.openstack.org_openstackcontrolplanes.yaml b/config/crd/bases/core.openstack.org_openstackcontrolplanes.yaml index 81a216d318..28f68eab73 100644 --- a/config/crd/bases/core.openstack.org_openstackcontrolplanes.yaml +++ b/config/crd/bases/core.openstack.org_openstackcontrolplanes.yaml @@ -12982,6 +12982,16 @@ spec: x-kubernetes-list-map-keys: - name x-kubernetes-list-type: map + mcp: + properties: + containerImage: + type: string + enabled: + default: false + type: boolean + required: + - containerImage + type: object nodeSelector: additionalProperties: type: string 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 5eb5ab7f75..93f36784dc 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/controller/client/openstackclient_controller.go b/internal/controller/client/openstackclient_controller.go index 3823d5dc8a..448fa9e416 100644 --- a/internal/controller/client/openstackclient_controller.go +++ b/internal/controller/client/openstackclient_controller.go @@ -41,7 +41,9 @@ import ( "sigs.k8s.io/controller-runtime/pkg/reconcile" keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1" + "github.com/openstack-k8s-operators/lib-common/modules/certmanager" "github.com/openstack-k8s-operators/lib-common/modules/common" + "github.com/openstack-k8s-operators/lib-common/modules/common/clusterdns" 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" @@ -73,7 +75,7 @@ func (r *OpenStackClientReconciler) GetLogger(ctx context.Context) logr.Logger { // +kubebuilder:rbac:groups=client.openstack.org,resources=openstackclients/finalizers,verbs=update // +kubebuilder:rbac:groups=keystone.openstack.org,resources=keystoneapis,verbs=get;list;watch // +kubebuilder:rbac:groups=telemetry.openstack.org,resources=metricstorages,verbs=get;list;watch -// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch; +// +kubebuilder:rbac:groups=core,resources=configmaps,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups=core,resources=secrets,verbs=get;list;watch; // service account, role, rolebinding // +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch @@ -82,6 +84,9 @@ func (r *OpenStackClientReconciler) GetLogger(ctx context.Context) logr.Logger { // service account permissions that are needed to grant permission to the above // +kubebuilder:rbac:groups="security.openshift.io",resourceNames=anyuid,resources=securitycontextconstraints,verbs=use // +kubebuilder:rbac:groups="",resources=pods,verbs=create;delete;get;list;patch;update;watch;patch +// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch +// +kubebuilder:rbac:groups=cert-manager.io,resources=issuers,verbs=get;list;watch +// +kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=get;list;watch;create;update;patch;delete // Reconcile - func (r *OpenStackClientReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, _err error) { @@ -306,11 +311,122 @@ func (r *OpenStackClientReconciler) Reconcile(ctx context.Context, req ctrl.Requ // all cert input checks out so report InputReady instance.Status.Conditions.MarkTrue(condition.TLSInputReadyCondition, condition.InputReadyMessage) + // Reconcile MCP sidecar resources when enabled + mcpTLSSecretName := "" + if instance.Spec.MCP != nil && instance.Spec.MCP.Enabled { + mcpTLSEnabled := instance.Spec.CaBundleSecretName != "" + + if mcpTLSEnabled { + issuer, err := certmanager.GetIssuerByLabels( + ctx, helper, + instance.Namespace, + map[string]string{certmanager.RootCAIssuerInternalLabel: ""}, + ) + if err != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + clientv1.OpenStackClientReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + clientv1.OpenStackClientReadyErrorMessage, + err.Error())) + return ctrl.Result{}, err + } + + clusterDomain := clusterdns.GetDNSClusterDomain() + mcpSvcName := instance.Name + "-mcp" + certRequest := certmanager.CertificateRequest{ + IssuerName: issuer.Name, + CertName: mcpSvcName + "-tls", + Hostnames: []string{ + fmt.Sprintf("%s.%s.svc", mcpSvcName, instance.Namespace), + fmt.Sprintf("%s.%s.svc.%s", mcpSvcName, instance.Namespace, clusterDomain), + }, + Labels: map[string]string{}, + } + certSecret, ctrlResult, err := certmanager.EnsureCert(ctx, helper, certRequest, instance) + if err != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + clientv1.OpenStackClientReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + clientv1.OpenStackClientReadyErrorMessage, + err.Error())) + return ctrlResult, err + } else if (ctrlResult != ctrl.Result{}) { + return ctrlResult, nil + } + mcpTLSSecretName = certSecret.Name + configVars[mcpTLSSecretName] = env.SetValue(certSecret.ResourceVersion) + } + + mcpConfigCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: instance.Name + "-mcp-config", + Namespace: instance.Namespace, + }, + } + _, err = controllerutil.CreateOrPatch(ctx, r.Client, mcpConfigCM, func() error { + mcpConfigCM.Data = map[string]string{ + "config.yaml": openstackclient.MCPConfigYAML(instance.Spec.CaBundleSecretName, mcpTLSEnabled), + } + return controllerutil.SetControllerReference(instance, mcpConfigCM, r.Scheme) + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error creating MCP config ConfigMap: %w", err) + } + configVars[instance.Name+"-mcp-config"] = env.SetValue(openstackclient.MCPConfigYAML(instance.Spec.CaBundleSecretName, mcpTLSEnabled)) + + } + configVarsHash, err := util.HashOfInputHashes(configVars) if err != nil { return ctrl.Result{}, err } + // Reconcile MCP Service after configVarsHash so the hash annotation captures all config changes + if instance.Spec.MCP != nil && instance.Spec.MCP.Enabled { + mcpTLSEnabled := instance.Spec.CaBundleSecretName != "" + mcpPort := int32(8080) + if mcpTLSEnabled { + mcpPort = 8443 + } + + mcpService := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Name: instance.Name + "-mcp", + Namespace: instance.Namespace, + }, + } + mcpServiceHash, err := util.ObjectHash(map[string]interface{}{ + "containerImage": instance.Spec.ContainerImage, + "mcpContainerImage": instance.Spec.MCP.ContainerImage, + "mcpConfig": openstackclient.MCPConfigYAML(instance.Spec.CaBundleSecretName, mcpTLSEnabled), + "configVarsHash": configVarsHash, + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error calculating MCP Service hash: %w", err) + } + _, err = controllerutil.CreateOrPatch(ctx, r.Client, mcpService, func() error { + if mcpService.Annotations == nil { + mcpService.Annotations = map[string]string{} + } + mcpService.Annotations["client.openstack.org/config-hash"] = mcpServiceHash + mcpService.Spec.Selector = clientLabels + mcpService.Spec.Ports = []corev1.ServicePort{ + { + Name: "mcp", + Port: mcpPort, + Protocol: corev1.ProtocolTCP, + }, + } + return controllerutil.SetControllerReference(instance, mcpService, r.Scheme) + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error creating MCP Service: %w", err) + } + + } + osclient := &corev1.Pod{ ObjectMeta: metav1.ObjectMeta{ Name: instance.Name, @@ -318,7 +434,7 @@ func (r *OpenStackClientReconciler) Reconcile(ctx context.Context, req ctrl.Requ }, } - spec := openstackclient.ClientPodSpec(ctx, instance, helper, configVarsHash) + spec := openstackclient.ClientPodSpec(ctx, instance, helper, configVarsHash, mcpTLSSecretName) podSpecHash, err := util.ObjectHash(spec) if err != nil { @@ -510,6 +626,8 @@ func (r *OpenStackClientReconciler) SetupWithManager( For(&clientv1.OpenStackClient{}). Owns(&corev1.Pod{}). Owns(&corev1.ServiceAccount{}). + Owns(&corev1.ConfigMap{}). + Owns(&corev1.Service{}). Owns(&rbacv1.Role{}). Owns(&rbacv1.RoleBinding{}). Watches( diff --git a/internal/openstackassistant/funcs.go b/internal/openstackassistant/funcs.go new file mode 100644 index 0000000000..f396306ac8 --- /dev/null +++ b/internal/openstackassistant/funcs.go @@ -0,0 +1,285 @@ +/* +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 + +# Discover and register MCP servers from environment variables +# MCP_SERVER_= entries are set by the controller +env | grep '^MCP_SERVER_' | while IFS='=' read -r varname url; do + name="${varname#MCP_SERVER_}" + # Convert to lowercase for the extension key + name=$(echo "$name" | tr '[:upper:]' '[:lower:]') + cat >> $HOME/.config/goose/config.yaml <", 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/openstackclient/funcs.go b/internal/openstackclient/funcs.go index ba60c18c1a..2e49e6c38a 100644 --- a/internal/openstackclient/funcs.go +++ b/internal/openstackclient/funcs.go @@ -34,6 +34,7 @@ func ClientPodSpec( instance *clientv1.OpenStackClient, helper *helper.Helper, configHash string, + mcpTLSSecretName string, ) corev1.PodSpec { envVars := map[string]env.Setter{} envVars["OS_CLOUD"] = env.SetValue("default") @@ -112,6 +113,83 @@ func ClientPodSpec( }, } + if instance.Spec.MCP != nil && instance.Spec.MCP.Enabled { + mcpVolumeMounts := []corev1.VolumeMount{ + { + Name: "openstack-config", + MountPath: "/home/cloud-admin/.config/openstack/clouds.yaml", + SubPath: "clouds.yaml", + }, + { + Name: "openstack-config-secret", + MountPath: "/home/cloud-admin/.config/openstack/secure.yaml", + SubPath: "secure.yaml", + }, + { + Name: "mcp-config", + MountPath: "/tmp/mcp-config", + ReadOnly: true, + }, + } + + if instance.Spec.CaBundleSecretName != "" { + mcpVolumeMounts = append(mcpVolumeMounts, instance.Spec.CreateVolumeMounts(nil)...) + } + + mcpPort := int32(8080) + if mcpTLSSecretName != "" { + mcpPort = 8443 + mcpVolumeMounts = append(mcpVolumeMounts, corev1.VolumeMount{ + Name: "mcp-tls", + MountPath: "/etc/pki/tls/mcp", + ReadOnly: true, + }) + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: "mcp-tls", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: mcpTLSSecretName, + }, + }, + }) + } + + podSpec.Volumes = append(podSpec.Volumes, corev1.Volume{ + Name: "mcp-config", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: instance.Name + "-mcp-config", + }, + }, + }, + }) + + podSpec.Containers = append(podSpec.Containers, corev1.Container{ + Name: "mcp-server", + Image: instance.Spec.MCP.ContainerImage, + Command: []string{"rhos-ls-mcps"}, + Env: []corev1.EnvVar{ + {Name: "RHOS_MCPS_CONFIG", Value: "/tmp/mcp-config/config.yaml"}, + {Name: "OS_CLOUD", Value: "default"}, + {Name: "OS_CLIENT_CONFIG_FILE", Value: "/home/cloud-admin/.config/openstack/clouds.yaml"}, + }, + Ports: []corev1.ContainerPort{ + {Name: "mcp", ContainerPort: mcpPort, Protocol: corev1.ProtocolTCP}, + }, + SecurityContext: &corev1.SecurityContext{ + RunAsUser: ptr.To[int64](42401), + RunAsGroup: ptr.To[int64](42401), + RunAsNonRoot: ptr.To(true), + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{"ALL"}, + }, + }, + VolumeMounts: mcpVolumeMounts, + }) + } + if instance.Spec.NodeSelector != nil { podSpec.NodeSelector = *instance.Spec.NodeSelector } @@ -119,6 +197,38 @@ func ClientPodSpec( return podSpec } +// MCPConfigYAML returns the rhos-mcps config.yaml content for the MCP sidecar +func MCPConfigYAML(caBundleSecretName string, tlsEnabled bool) string { + caCert := "" + if caBundleSecretName != "" { + caCert = fmt.Sprintf("\n ca_cert: %s", tls.DownstreamTLSCABundlePath) + } + port := "8080" + tlsConfig := "" + allowedOriginScheme := "http" + if tlsEnabled { + port = "8443" + tlsConfig = ` +tls: + cert_file: /etc/pki/tls/mcp/tls.crt + key_file: /etc/pki/tls/mcp/tls.key` + allowedOriginScheme = "https" + } + return fmt.Sprintf(`port: %s +openstack: + enabled: true + allow_write: false%s +openshift: + enabled: false%s +mcp_transport_security: + enable_dns_rebinding_protection: false + allowed_hosts: + - "*:*" + allowed_origins: + - "%s://*:*" +`, port, caCert, tlsConfig, allowedOriginScheme) +} + func clientPodVolumeMounts() []corev1.VolumeMount { return []corev1.VolumeMount{ { 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() +}