diff --git a/Dockerfile b/Dockerfile index 733605b..02f4fce 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ RUN go mod download COPY cmd/main.go cmd/main.go COPY api/ api/ COPY internal/controller/ internal/controller/ +COPY internal/assistant/ internal/assistant/ # Build # the GOARCH has not a default value to allow the binary be built according to the host where the command diff --git a/PROJECT b/PROJECT index 3a7f081..cb14618 100644 --- a/PROJECT +++ b/PROJECT @@ -21,4 +21,13 @@ resources: kind: OpenStackLightspeed path: github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1 version: v1beta1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: openstack.org + group: lightspeed + kind: OpenStackAssistant + path: github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1 + version: v1beta1 version: "3" diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index 6ca80de..60ccb9e 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -31,6 +31,9 @@ const ( // OpenStackLightspeedMCPServerReadyCondition is set to True when the MCP server // deployment succeeds. False indicates a failure during MCP server deployment. OpenStackLightspeedMCPServerReadyCondition condition.Type = "OpenStackLightspeedMCPServerReady" + + // OpenStackAssistantReadyCondition Status=True condition which indicates if OpenStackAssistant is configured and operational + OpenStackAssistantReadyCondition condition.Type = "OpenStackAssistantReady" ) // Common Messages used by API objects. @@ -73,4 +76,31 @@ const ( // DeploymentsNotReadyMessage DeploymentsNotReadyMessage = "Waiting for deployments to be ready: %s" + + // 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" + + // OpenStackAssistantSkillsWaitingMessage + OpenStackAssistantSkillsWaitingMessage = "Waiting for Goose skills ConfigMap" + + // OpenStackAssistantServiceAccountWaitingMessage + OpenStackAssistantServiceAccountWaitingMessage = "Waiting for openstack-operator to create the assistant ServiceAccount" ) diff --git a/api/v1beta1/openstackassistant_types.go b/api/v1beta1/openstackassistant_types.go new file mode 100644 index 0000000..0630237 --- /dev/null +++ b/api/v1beta1/openstackassistant_types.go @@ -0,0 +1,240 @@ +/* +Copyright 2025. + +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" + 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@sha256:07d7200f62bc2e8082de7a58396f8699b5f33fade1dbecc6a5b4ca03ab2f1d33" + + // OpenStackAssistantGooseServiceAccountName is the name of the ServiceAccount + // that lightspeed-operator creates and owns for the assistant/goose pod + // itself. Unlike OpenStackAssistantServiceAccountName, it carries no k8s API + // resource RBAC grants - it exists so the pod can be bound to the nonroot-v2 + // SecurityContextConstraint (the goose image home directory is owned by + // fixed UID 1000) and so goose can authenticate to Lightspeed via its SA + // token. Lightspeed's k8s auth module requires GET on the /ls-access + // non-resource URL; that single grant is created alongside this SA. + OpenStackAssistantGooseServiceAccountName = "openstackassistant-goose" + + // OpenStackAssistantGooseSCCName is the SecurityContextConstraint that the + // goose ServiceAccount is bound to, permitting the pod to run as the + // fixed, non-root UID the goose container image expects. + OpenStackAssistantGooseSCCName = "nonroot-v2" + + // OpenStackAssistantGooseLSAccessPath is the non-resource URL that + // Lightspeed's k8s authentication module SubjectAccessReviews before + // allowing a caller to use the /v1/responses API. + OpenStackAssistantGooseLSAccessPath = "/ls-access" +) + +// OpenStackAssistantGooseLSAccessClusterRoleName returns the namespace-scoped +// name of the ClusterRole that grants the goose SA GET /ls-access. The name is +// namespace-scoped because ClusterRole objects are cluster-scoped: multiple +// OpenStackAssistant instances in different namespaces each own their own role. +func OpenStackAssistantGooseLSAccessClusterRoleName(namespace string) string { + return OpenStackAssistantGooseServiceAccountName + "-ls-access-" + namespace +} + +// OpenStackAssistantGooseLSAccessClusterRoleBindingName returns the +// namespace-scoped name of the ClusterRoleBinding for the goose /ls-access +// grant. See OpenStackAssistantGooseLSAccessClusterRoleName for why this is +// namespace-scoped. +func OpenStackAssistantGooseLSAccessClusterRoleBindingName(namespace string) string { + return OpenStackAssistantGooseLSAccessClusterRoleName(namespace) + "-binding" +} + +// 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. +// Either URL or OpenStackClientRef must be specified, but not both. +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. + // Mutually exclusive with OpenStackClientRef. + // +kubebuilder:validation:Optional + URL string `json:"url,omitempty"` + + // OpenStackClientRef is the name of an OpenStackClient CR, managed by + // openstack-operator in the same namespace, that has MCP enabled. The + // controller auto-computes the service URL by convention + // (http(s)://-mcp..svc:8080/openstack/) + // and TLS CA configuration. + // Mutually exclusive with URL. + // +kubebuilder:validation:Optional + OpenStackClientRef string `json:"openstackClientRef,omitempty"` +} + +// GooseConfig defines Goose-specific provider configuration +type GooseConfig struct { + // Model is the model identifier for the Goose AI agent + // (e.g., "gemini/models/gemini-2.5-flash"). Sets the GOOSE_MODEL env var. + // +kubebuilder:validation:Optional + Model string `json:"model,omitempty"` + + // Recipes is a ConfigMap name containing Goose recipe YAML files. + // Each key in the ConfigMap becomes a recipe file registered as a + // Goose slash command (e.g., /cluster-health). + // +kubebuilder:validation:Optional + Recipes *string `json:"recipes,omitempty"` + + // Skills is a ConfigMap name containing Goose Agent Skill files. + // Each key in the ConfigMap becomes a skill named after the key + // (extension stripped), written as ~/.config/goose/skills//SKILL.md. + // Unlike Recipes, skills are not explicitly invoked - Goose loads + // them automatically when their description matches the task at hand. + // +kubebuilder:validation:Optional + Skills *string `json:"skills,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 (will be set to environmental default if empty). + // +kubebuilder:validation:Optional + ContainerImage string `json:"containerImage,omitempty"` + + // Provider is the AI agent provider type. Currently only "goose" is supported. + // +kubebuilder:validation:Optional + // +kubebuilder:default=goose + Provider ProviderType `json:"provider"` + + // 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" +// +operator-sdk:csv:customresourcedefinitions:resources={{ServiceAccount,v1,openstackassistant-goose}} +// +operator-sdk:csv:customresourcedefinitions:resources={{RoleBinding,v1,openstackassistant-goose-nonroot-v2}} +// +operator-sdk:csv:customresourcedefinitions:resources={{ClusterRole,v1,openstackassistant-goose-ls-access}} +// +operator-sdk:csv:customresourcedefinitions:resources={{ClusterRoleBinding,v1,openstackassistant-goose-ls-access-binding}} +// +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) +} + +// OpenStackAssistantDefaults holds defaults for the assistant +type OpenStackAssistantDefaults struct { + ContainerImageURL string +} + +var openStackAssistantDefaults OpenStackAssistantDefaults + +// Default implements webhook.Defaulter +func (r *OpenStackAssistant) Default() { + if r.Spec.ContainerImage == "" { + r.Spec.ContainerImage = openStackAssistantDefaults.ContainerImageURL + } +} diff --git a/api/v1beta1/openstacklightspeed_types.go b/api/v1beta1/openstacklightspeed_types.go index db81b28..234aefa 100644 --- a/api/v1beta1/openstacklightspeed_types.go +++ b/api/v1beta1/openstacklightspeed_types.go @@ -30,7 +30,10 @@ const ( OpenStackLightspeedContainerImage = "quay.io/openstack-lightspeed/rag-content:os-docs-2026.1-ogx" // LCoreContainerImage is the fall-back container image for LCore - LCoreContainerImage = "quay.io/lightspeed-core/lightspeed-stack:latest" + // Pinned to the 0.6.1 digest: newer tags (including latest/dev-latest) + // have migrated to the OGX rename (llama_stack -> ogx), which + // llama_startup_wrapper.py does not support yet. + LCoreContainerImage = "quay.io/lightspeed-core/lightspeed-stack@sha256:b8de9b9507bbf2c667c833751987e0385ea30a5c9276c4832f70e3de105c91eb" // ExporterContainerImage is the fall-back container image for the Dataverse Exporter ExporterContainerImage = "quay.io/lightspeed-core/lightspeed-to-dataverse-exporter:latest" @@ -370,4 +373,9 @@ func SetupDefaults() { } OpenStackLightspeedDefaultValues = openStackLightspeedDefaults + + openStackAssistantDefaults = OpenStackAssistantDefaults{ + ContainerImageURL: util.GetEnvVar( + "RELATED_IMAGE_OPENSTACK_ASSISTANT_IMAGE_URL_DEFAULT", OpenStackAssistantContainerImage), + } } diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index feddfb7..e541166 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -22,6 +22,7 @@ package v1beta1 import ( "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -87,6 +88,56 @@ func (in *DevSpec) DeepCopy() *DevSpec { return out } +// 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.Skills != nil { + in, out := &in.Skills, &out.Skills + *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 *LoggingConfig) DeepCopyInto(out *LoggingConfig) { *out = *in @@ -102,6 +153,21 @@ func (in *LoggingConfig) DeepCopy() *LoggingConfig { 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 *OKPSpec) DeepCopyInto(out *OKPSpec) { *out = *in @@ -122,6 +188,148 @@ func (in *OKPSpec) DeepCopy() *OKPSpec { 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 +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OpenStackLightspeed) DeepCopyInto(out *OpenStackLightspeed) { *out = *in diff --git a/bundle.Dockerfile b/bundle.Dockerfile index 3b92ba1..8ebc981 100644 --- a/bundle.Dockerfile +++ b/bundle.Dockerfile @@ -6,7 +6,7 @@ LABEL operators.operatorframework.io.bundle.manifests.v1=manifests/ LABEL operators.operatorframework.io.bundle.metadata.v1=metadata/ LABEL operators.operatorframework.io.bundle.package.v1=openstack-lightspeed-operator LABEL operators.operatorframework.io.bundle.channels.v1=alpha -LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.38.0 +LABEL operators.operatorframework.io.metrics.builder=operator-sdk-v1.41.1 LABEL operators.operatorframework.io.metrics.mediatype.v1=metrics+v1 LABEL operators.operatorframework.io.metrics.project_layout=go.kubebuilder.io/v4 diff --git a/bundle/manifests/lightspeed.openstack.org_openstackassistants.yaml b/bundle/manifests/lightspeed.openstack.org_openstackassistants.yaml new file mode 100644 index 0000000..2ee8890 --- /dev/null +++ b/bundle/manifests/lightspeed.openstack.org_openstackassistants.yaml @@ -0,0 +1,343 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.5 + creationTimestamp: null + name: openstackassistants.lightspeed.openstack.org +spec: + group: lightspeed.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 (will be set + to environmental default if empty). + 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. + Either URL or OpenStackClientRef must be specified, but not both. + properties: + name: + description: Name is the extension name in Goose config + type: string + openstackClientRef: + description: |- + OpenStackClientRef is the name of an OpenStackClient CR, managed by + openstack-operator in the same namespace, that has MCP enabled. The + controller auto-computes the service URL by convention + (http(s)://-mcp..svc:8080/openstack/) + and TLS CA configuration. + Mutually exclusive with URL. + type: string + url: + description: |- + URL is the MCP server's Streamable HTTP endpoint. + Mutually exclusive with OpenStackClientRef. + type: string + required: + - name + 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 + skills: + description: |- + Skills is a ConfigMap name containing Goose Agent Skill files. + Each key in the ConfigMap becomes a skill named after the key + (extension stripped), written as ~/.config/goose/skills//SKILL.md. + Unlike Recipes, skills are not explicitly invoked - Goose loads + them automatically when their description matches the task at hand. + 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: + - 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: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null diff --git a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml index ef72ab9..f107cfb 100644 --- a/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/bundle/manifests/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -206,7 +206,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -274,7 +274,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -342,7 +342,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -409,7 +409,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -476,7 +476,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -543,7 +543,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. diff --git a/bundle/manifests/openstack-lightspeed-operator-openstackassistant-admin-role_rbac.authorization.k8s.io_v1_clusterrole.yaml b/bundle/manifests/openstack-lightspeed-operator-openstackassistant-admin-role_rbac.authorization.k8s.io_v1_clusterrole.yaml new file mode 100644 index 0000000..3c7b454 --- /dev/null +++ b/bundle/manifests/openstack-lightspeed-operator-openstackassistant-admin-role_rbac.authorization.k8s.io_v1_clusterrole.yaml @@ -0,0 +1,21 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: openstack-lightspeed-operator + name: openstack-lightspeed-operator-openstackassistant-admin-role +rules: +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants + verbs: + - '*' +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/bundle/manifests/openstack-lightspeed-operator-openstackassistant-editor-role_rbac.authorization.k8s.io_v1_clusterrole.yaml b/bundle/manifests/openstack-lightspeed-operator-openstackassistant-editor-role_rbac.authorization.k8s.io_v1_clusterrole.yaml new file mode 100644 index 0000000..817d225 --- /dev/null +++ b/bundle/manifests/openstack-lightspeed-operator-openstackassistant-editor-role_rbac.authorization.k8s.io_v1_clusterrole.yaml @@ -0,0 +1,27 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: openstack-lightspeed-operator + name: openstack-lightspeed-operator-openstackassistant-editor-role +rules: +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/bundle/manifests/openstack-lightspeed-operator-openstackassistant-viewer-role_rbac.authorization.k8s.io_v1_clusterrole.yaml b/bundle/manifests/openstack-lightspeed-operator-openstackassistant-viewer-role_rbac.authorization.k8s.io_v1_clusterrole.yaml new file mode 100644 index 0000000..318c18e --- /dev/null +++ b/bundle/manifests/openstack-lightspeed-operator-openstackassistant-viewer-role_rbac.authorization.k8s.io_v1_clusterrole.yaml @@ -0,0 +1,23 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + creationTimestamp: null + labels: + app.kubernetes.io/managed-by: kustomize + app.kubernetes.io/name: openstack-lightspeed-operator + name: openstack-lightspeed-operator-openstackassistant-viewer-role +rules: +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants + verbs: + - get + - list + - watch +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml index 0b9dcc0..8a8903a 100644 --- a/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/bundle/manifests/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -4,6 +4,45 @@ metadata: annotations: alm-examples: |- [ + { + "apiVersion": "lightspeed.openstack.org/v1beta1", + "kind": "OpenStackAssistant", + "metadata": { + "labels": { + "app.kubernetes.io/managed-by": "kustomize", + "app.kubernetes.io/name": "openstack-lightspeed-operator" + }, + "name": "assistant" + }, + "spec": { + "containerImage": "quay.io/dprince/goose:oc-fedora", + "env": [ + { + "name": "GOOSE_MODEL", + "value": "gemini/models/gemini-2.5-flash" + }, + { + "name": "LIGHTSPEED_API_KEY", + "value": "dummy" + } + ], + "goose": { + "hints": "assistant-hints", + "mcpServers": [ + { + "name": "openstack", + "openstackClientRef": "openstackclient" + } + ], + "recipes": "assistant-recipes" + }, + "lightspeedStack": { + "caBundleSecretName": "lightspeed-ca-bundle", + "providerSecret": "lightspeed-provider-config" + }, + "provider": "goose" + } + }, { "apiVersion": "lightspeed.openstack.org/v1beta1", "kind": "OpenStackLightspeed", @@ -25,7 +64,7 @@ metadata: ] capabilities: Basic Install categories: AI/Machine Learning - createdAt: "2026-08-10T12:06:44Z" + createdAt: "2026-08-14T12:17:08Z" description: AI-powered virtual assistant for Red Hat OpenStack Services on OpenShift features.operators.openshift.io/cnf: "false" features.operators.openshift.io/cni: "false" @@ -38,7 +77,7 @@ metadata: features.operators.openshift.io/token-auth-azure: "false" features.operators.openshift.io/token-auth-gcp: "false" operatorframework.io/suggested-namespace: openstack-lightspeed - operators.operatorframework.io/builder: operator-sdk-v1.38.0 + operators.operatorframework.io/builder: operator-sdk-v1.41.1 operators.operatorframework.io/project_layout: go.kubebuilder.io/v4 repository: https://github.com/openstack-k8s-operators/lightspeed-operator name: openstack-lightspeed-operator.v0.0.1 @@ -47,6 +86,24 @@ spec: apiservicedefinitions: {} customresourcedefinitions: owned: + - description: OpenStackAssistant is the Schema for the openstackassistants API. + displayName: OpenStack Assistant + kind: OpenStackAssistant + name: openstackassistants.lightspeed.openstack.org + resources: + - kind: ServiceAccount + name: openstackassistant-goose + version: v1 + - kind: ClusterRole + name: openstackassistant-goose-ls-access + version: v1 + - kind: ClusterRoleBinding + name: openstackassistant-goose-ls-access-binding + version: v1 + - kind: RoleBinding + name: openstackassistant-goose-nonroot-v2 + version: v1 + version: v1beta1 - description: OpenStackLightspeed is the Schema for the openstacklightspeeds API displayName: Open Stack Lightspeed @@ -169,12 +226,34 @@ spec: spec: clusterPermissions: - rules: + - nonResourceURLs: + - /ls-access + verbs: + - get - apiGroups: - "" resources: - configmaps + - serviceaccounts verbs: + - create - get + - list + - patch + - update + - watch + - apiGroups: + - "" + resources: + - pods + verbs: + - create + - delete + - get + - list + - patch + - update + - watch - apiGroups: - "" resources: @@ -182,7 +261,9 @@ spec: verbs: - create - get + - list - update + - watch - apiGroups: - "" resourceNames: @@ -209,6 +290,14 @@ spec: - get - list - watch + - apiGroups: + - client.openstack.org + resources: + - openstackclients + verbs: + - get + - list + - watch - apiGroups: - config.openshift.io resources: @@ -227,6 +316,7 @@ spec: - get - list - patch + - update - watch - apiGroups: - core.openstack.org @@ -257,18 +347,39 @@ spec: - apiGroups: - lightspeed.openstack.org resources: - - openstacklightspeeds + - openstackassistants verbs: + - create + - delete - get - list - patch + - update - watch - apiGroups: - lightspeed.openstack.org resources: + - openstackassistants/finalizers - openstacklightspeeds/finalizers verbs: - update + - apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants/status + verbs: + - get + - patch + - update + - apiGroups: + - lightspeed.openstack.org + resources: + - openstacklightspeeds + verbs: + - get + - list + - patch + - watch - apiGroups: - lightspeed.openstack.org resources: @@ -291,11 +402,32 @@ spec: - clusterroles verbs: - create + - delete - deletecollection - get - list - patch + - update + - watch + - apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + verbs: + - create + - get + - list + - patch + - update - watch + - apiGroups: + - security.openshift.io + resourceNames: + - nonroot-v2 + resources: + - securitycontextconstraints + verbs: + - use - apiGroups: - authentication.k8s.io resources: @@ -343,7 +475,7 @@ spec: - name: RELATED_IMAGE_OPENSTACK_LIGHTSPEED_IMAGE_URL_DEFAULT value: quay.io/openstack-lightspeed/rag-content:os-docs-2026.1-ogx - name: RELATED_IMAGE_LCORE_IMAGE_URL_DEFAULT - value: quay.io/lightspeed-core/lightspeed-stack:latest + value: quay.io/lightspeed-core/lightspeed-stack@sha256:b8de9b9507bbf2c667c833751987e0385ea30a5c9276c4832f70e3de105c91eb - name: RELATED_IMAGE_EXPORTER_IMAGE_URL_DEFAULT value: quay.io/lightspeed-core/lightspeed-to-dataverse-exporter:latest - name: RELATED_IMAGE_POSTGRES_IMAGE_URL_DEFAULT @@ -372,11 +504,11 @@ spec: periodSeconds: 10 resources: limits: - cpu: 500m - memory: 128Mi + cpu: "1" + memory: 512Mi requests: - cpu: 10m - memory: 64Mi + cpu: 20m + memory: 128Mi securityContext: allowPrivilegeEscalation: false capabilities: @@ -442,13 +574,12 @@ spec: - "" resources: - persistentvolumeclaims - - serviceaccounts - - services verbs: - create - get - list - patch + - update - watch - apiGroups: - "" @@ -462,6 +593,17 @@ spec: - list - patch - watch + - apiGroups: + - "" + resources: + - serviceaccounts + - services + verbs: + - create + - get + - list + - patch + - watch - apiGroups: - apps resources: @@ -516,7 +658,7 @@ spec: relatedImages: - image: quay.io/openstack-lightspeed/rag-content:os-docs-2026.1-ogx name: openstack-lightspeed-image-url-default - - image: quay.io/lightspeed-core/lightspeed-stack:latest + - image: quay.io/lightspeed-core/lightspeed-stack@sha256:b8de9b9507bbf2c667c833751987e0385ea30a5c9276c4832f70e3de105c91eb name: lcore-image-url-default - image: quay.io/lightspeed-core/lightspeed-to-dataverse-exporter:latest name: exporter-image-url-default diff --git a/bundle/metadata/annotations.yaml b/bundle/metadata/annotations.yaml index 828f4f1..142fb46 100644 --- a/bundle/metadata/annotations.yaml +++ b/bundle/metadata/annotations.yaml @@ -5,7 +5,7 @@ annotations: operators.operatorframework.io.bundle.metadata.v1: metadata/ operators.operatorframework.io.bundle.package.v1: openstack-lightspeed-operator operators.operatorframework.io.bundle.channels.v1: alpha - operators.operatorframework.io.metrics.builder: operator-sdk-v1.38.0 + operators.operatorframework.io.metrics.builder: operator-sdk-v1.41.1 operators.operatorframework.io.metrics.mediatype.v1: metrics+v1 operators.operatorframework.io.metrics.project_layout: go.kubebuilder.io/v4 diff --git a/cmd/main.go b/cmd/main.go index 71c5c63..5a41f14 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -47,6 +47,7 @@ import ( operatorsv1alpha1 "github.com/operator-framework/api/pkg/operators/v1alpha1" apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" + lightspeedv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" "github.com/openstack-k8s-operators/lightspeed-operator/internal/controller" // +kubebuilder:scaffold:imports ) @@ -68,6 +69,8 @@ func init() { utilruntime.Must(openshiftv1.AddToScheme(scheme)) utilruntime.Must(apiextensionsv1.AddToScheme(scheme)) + + utilruntime.Must(lightspeedv1beta1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -210,6 +213,15 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "OpenStackLightspeed") os.Exit(1) } + + if err := (&controller.OpenStackAssistantReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Kclient: kclient, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "OpenStackAssistant") + os.Exit(1) + } // +kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { @@ -240,6 +252,9 @@ func getWatchNamespaces() ([]string, error) { if !found { return []string{}, fmt.Errorf("%s must be set", watchNamespaceEnvVar) } + if ns == "" { + return []string{}, nil + } return strings.Split(ns, ","), nil } diff --git a/config/crd/bases/lightspeed.openstack.org_openstackassistants.yaml b/config/crd/bases/lightspeed.openstack.org_openstackassistants.yaml new file mode 100644 index 0000000..19772ba --- /dev/null +++ b/config/crd/bases/lightspeed.openstack.org_openstackassistants.yaml @@ -0,0 +1,337 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.5 + name: openstackassistants.lightspeed.openstack.org +spec: + group: lightspeed.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 (will be set + to environmental default if empty). + 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. + Either URL or OpenStackClientRef must be specified, but not both. + properties: + name: + description: Name is the extension name in Goose config + type: string + openstackClientRef: + description: |- + OpenStackClientRef is the name of an OpenStackClient CR, managed by + openstack-operator in the same namespace, that has MCP enabled. The + controller auto-computes the service URL by convention + (http(s)://-mcp..svc:8080/openstack/) + and TLS CA configuration. + Mutually exclusive with URL. + type: string + url: + description: |- + URL is the MCP server's Streamable HTTP endpoint. + Mutually exclusive with OpenStackClientRef. + type: string + required: + - name + 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 + skills: + description: |- + Skills is a ConfigMap name containing Goose Agent Skill files. + Each key in the ConfigMap becomes a skill named after the key + (extension stripped), written as ~/.config/goose/skills//SKILL.md. + Unlike Recipes, skills are not explicitly invoked - Goose loads + them automatically when their description matches the task at hand. + 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: + - 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/lightspeed.openstack.org_openstacklightspeeds.yaml b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml index 52dd00d..59837dc 100644 --- a/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml +++ b/config/crd/bases/lightspeed.openstack.org_openstacklightspeeds.yaml @@ -206,7 +206,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -274,7 +274,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -342,7 +342,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -409,7 +409,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -476,7 +476,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -543,7 +543,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This field depends on the + This is an alpha field and requires enabling the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 47705b8..309b011 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -3,6 +3,7 @@ # It should be run by config/default resources: - bases/lightspeed.openstack.org_openstacklightspeeds.yaml +- bases/lightspeed.openstack.org_openstackassistants.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 092ca58..07adde3 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -76,7 +76,7 @@ spec: - name: RELATED_IMAGE_OPENSTACK_LIGHTSPEED_IMAGE_URL_DEFAULT value: quay.io/openstack-lightspeed/rag-content:os-docs-2026.1-ogx - name: RELATED_IMAGE_LCORE_IMAGE_URL_DEFAULT - value: quay.io/lightspeed-core/lightspeed-stack:latest + value: quay.io/lightspeed-core/lightspeed-stack@sha256:b8de9b9507bbf2c667c833751987e0385ea30a5c9276c4832f70e3de105c91eb - name: RELATED_IMAGE_EXPORTER_IMAGE_URL_DEFAULT value: quay.io/lightspeed-core/lightspeed-to-dataverse-exporter:latest - name: RELATED_IMAGE_POSTGRES_IMAGE_URL_DEFAULT @@ -110,11 +110,11 @@ spec: # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ resources: limits: - cpu: 500m - memory: 128Mi + cpu: 1000m + memory: 512Mi requests: - cpu: 10m - memory: 64Mi + cpu: 20m + memory: 128Mi volumeMounts: - name: cert mountPath: /tmp/k8s-metrics-server/serving-certs diff --git a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml index 6d9e89e..916a388 100644 --- a/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml +++ b/config/manifests/bases/openstack-lightspeed-operator.clusterserviceversion.yaml @@ -24,6 +24,24 @@ spec: apiservicedefinitions: {} customresourcedefinitions: owned: + - description: OpenStackAssistant is the Schema for the openstackassistants API. + displayName: OpenStack Assistant + kind: OpenStackAssistant + name: openstackassistants.lightspeed.openstack.org + resources: + - kind: ServiceAccount + name: openstackassistant-goose + version: v1 + - kind: ClusterRole + name: openstackassistant-goose-ls-access + version: v1 + - kind: ClusterRoleBinding + name: openstackassistant-goose-ls-access-binding + version: v1 + - kind: RoleBinding + name: openstackassistant-goose-nonroot-v2 + version: v1 + version: v1beta1 - description: OpenStackLightspeed is the Schema for the openstacklightspeeds API displayName: Open Stack Lightspeed diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 4c75b00..16d97fa 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -26,3 +26,10 @@ resources: - openstacklightspeed_editor_role.yaml - openstacklightspeed_viewer_role.yaml +# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by +# default, aiding admins in cluster management. Those roles are +# not used by the openstack-lightspeed-operator itself. You can comment the following lines +# if you do not want those helpers be installed with your Project. +- openstackassistant_admin_role.yaml +- openstackassistant_editor_role.yaml +- openstackassistant_viewer_role.yaml \ No newline at end of file diff --git a/config/rbac/openstackassistant_admin_role.yaml b/config/rbac/openstackassistant_admin_role.yaml new file mode 100644 index 0000000..9fb084a --- /dev/null +++ b/config/rbac/openstackassistant_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project openstack-lightspeed-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over lightspeed.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-lightspeed-operator + app.kubernetes.io/managed-by: kustomize + name: openstackassistant-admin-role +rules: +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants + verbs: + - '*' +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/config/rbac/openstackassistant_editor_role.yaml b/config/rbac/openstackassistant_editor_role.yaml new file mode 100644 index 0000000..d538d2a --- /dev/null +++ b/config/rbac/openstackassistant_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project openstack-lightspeed-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 lightspeed.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-lightspeed-operator + app.kubernetes.io/managed-by: kustomize + name: openstackassistant-editor-role +rules: +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/config/rbac/openstackassistant_viewer_role.yaml b/config/rbac/openstackassistant_viewer_role.yaml new file mode 100644 index 0000000..667f756 --- /dev/null +++ b/config/rbac/openstackassistant_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project openstack-lightspeed-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to lightspeed.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-lightspeed-operator + app.kubernetes.io/managed-by: kustomize + name: openstackassistant-viewer-role +rules: +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants + verbs: + - get + - list + - watch +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 8d511d5..afe14cc 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,12 +4,34 @@ kind: ClusterRole metadata: name: manager-role rules: +- nonResourceURLs: + - /ls-access + verbs: + - get - apiGroups: - "" resources: - configmaps + - serviceaccounts + verbs: + - create + - get + - list + - patch + - update + - watch +- apiGroups: + - "" + resources: + - pods verbs: + - create + - delete - get + - list + - patch + - update + - watch - apiGroups: - "" resources: @@ -17,7 +39,9 @@ rules: verbs: - create - get + - list - update + - watch - apiGroups: - "" resourceNames: @@ -44,6 +68,14 @@ rules: - get - list - watch +- apiGroups: + - client.openstack.org + resources: + - openstackclients + verbs: + - get + - list + - watch - apiGroups: - config.openshift.io resources: @@ -62,6 +94,7 @@ rules: - get - list - patch + - update - watch - apiGroups: - core.openstack.org @@ -92,18 +125,39 @@ rules: - apiGroups: - lightspeed.openstack.org resources: - - openstacklightspeeds + - openstackassistants verbs: + - create + - delete - get - list - patch + - update - watch - apiGroups: - lightspeed.openstack.org resources: + - openstackassistants/finalizers - openstacklightspeeds/finalizers verbs: - update +- apiGroups: + - lightspeed.openstack.org + resources: + - openstackassistants/status + verbs: + - get + - patch + - update +- apiGroups: + - lightspeed.openstack.org + resources: + - openstacklightspeeds + verbs: + - get + - list + - patch + - watch - apiGroups: - lightspeed.openstack.org resources: @@ -126,11 +180,32 @@ rules: - clusterroles verbs: - create + - delete - deletecollection - get - list - patch + - update - watch +- apiGroups: + - rbac.authorization.k8s.io + resources: + - rolebindings + verbs: + - create + - get + - list + - patch + - update + - watch +- apiGroups: + - security.openshift.io + resourceNames: + - nonroot-v2 + resources: + - securitycontextconstraints + verbs: + - use --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role @@ -153,13 +228,12 @@ rules: - "" resources: - persistentvolumeclaims - - serviceaccounts - - services verbs: - create - get - list - patch + - update - watch - apiGroups: - "" @@ -173,6 +247,17 @@ rules: - list - patch - watch +- apiGroups: + - "" + resources: + - serviceaccounts + - services + verbs: + - create + - get + - list + - patch + - watch - apiGroups: - apps resources: diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index bc7a348..907048a 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,4 +1,5 @@ ## Append samples of your project ## resources: - api_v1beta1_openstacklightspeed.yaml +- lightspeed_v1beta1_openstackassistant.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/lightspeed_v1beta1_openstackassistant.yaml b/config/samples/lightspeed_v1beta1_openstackassistant.yaml new file mode 100644 index 0000000..a5dec13 --- /dev/null +++ b/config/samples/lightspeed_v1beta1_openstackassistant.yaml @@ -0,0 +1,24 @@ +apiVersion: lightspeed.openstack.org/v1beta1 +kind: OpenStackAssistant +metadata: + labels: + app.kubernetes.io/name: openstack-lightspeed-operator + app.kubernetes.io/managed-by: kustomize + name: assistant +spec: + containerImage: quay.io/dprince/goose:oc-fedora + provider: goose + lightspeedStack: + providerSecret: lightspeed-provider-config + caBundleSecretName: lightspeed-ca-bundle + goose: + recipes: assistant-recipes + hints: assistant-hints + mcpServers: + - name: openstack + openstackClientRef: openstackclient + env: + - name: GOOSE_MODEL + value: "gemini/models/gemini-2.5-flash" + - name: LIGHTSPEED_API_KEY + value: "dummy" diff --git a/go.mod b/go.mod index f29d840..fa00c9e 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,8 @@ require ( // must be consistent within modules and service operators replace github.com/openshift/api => github.com/openshift/api v0.0.0-20250711200046-c86d80652a9e +require k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 + require ( cel.dev/expr v0.25.1 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -105,7 +107,6 @@ require ( k8s.io/component-base v0.33.13 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250610211856-8b98d1ed966a // indirect - k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/hack/env.sh b/hack/env.sh index 007a16b..378e531 100644 --- a/hack/env.sh +++ b/hack/env.sh @@ -1,5 +1,5 @@ #!/bin/bash -export RELATED_IMAGE_LCORE_IMAGE_URL_DEFAULT="quay.io/lightspeed-core/lightspeed-stack:latest" +export RELATED_IMAGE_LCORE_IMAGE_URL_DEFAULT="quay.io/lightspeed-core/lightspeed-stack@sha256:b8de9b9507bbf2c667c833751987e0385ea30a5c9276c4832f70e3de105c91eb" export RELATED_IMAGE_EXPORTER_IMAGE_URL_DEFAULT="quay.io/lightspeed-core/lightspeed-to-dataverse-exporter:latest" export RELATED_IMAGE_POSTGRES_IMAGE_URL_DEFAULT="registry.redhat.io/rhel9/postgresql-16:latest" # TODO(lpiwowar): Replace this with a stable (non-alpha) image version once diff --git a/internal/assistant/funcs.go b/internal/assistant/funcs.go new file mode 100644 index 0000000..2a37d90 --- /dev/null +++ b/internal/assistant/funcs.go @@ -0,0 +1,375 @@ +/* +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 provides functionality for managing OpenStack assistant resources +package assistant + +import ( + env "github.com/openstack-k8s-operators/lib-common/modules/common/env" + apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/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 install Agent Skills. Goose auto-discovers skills from +# ~/.config/goose/skills//SKILL.md via its built-in Skills +# platform extension (enabled by default) - unlike recipes, skills are +# not registered as slash commands and don't need a config.yaml entry. +if [ -d /tmp/skills ]; then + mkdir -p $HOME/.config/goose/skills + for skill in /tmp/skills/*; do + [ -f "$skill" ] || continue + basename=$(basename "$skill") + name="${basename%.*}" + mkdir -p "$HOME/.config/goose/skills/${name}" + cp "$skill" "$HOME/.config/goose/skills/${name}/SKILL.md" + 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 < "$MERGED_CA" + export SSL_CERT_FILE="$MERGED_CA" +fi + +# Set the API key in the current process environment so it propagates +# to the sleep process and is visible to oc exec/rsh sessions. +export LIGHTSPEED_API_KEY="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" + +# Write env snippets so oc rsh / exec sessions pick up the API key, +# SSL trust, and any other assistant-specific env vars. +# The container spec sets ENV and BASH_ENV to this path so every +# shell (sh and bash, interactive or not) sources it automatically. +GOOSE_ENV='export LIGHTSPEED_API_KEY="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token 2>/dev/null)"' +if [ -n "${SSL_CERT_FILE:-}" ]; then + GOOSE_ENV="${GOOSE_ENV} +export SSL_CERT_FILE=\"${SSL_CERT_FILE}\"" +fi +echo "$GOOSE_ENV" > /tmp/assistant-env.sh +echo "$GOOSE_ENV" >> "$HOME/.bashrc" +echo "$GOOSE_ENV" >> "$HOME/.profile" + +exec sleep infinity +` +} + +const combinedCAMountPath = "/etc/ssl/certs/combined-ca.crt" + +// AssistantPodSpec returns the PodSpec for the assistant pod. +// resolvedMCPServers maps extension name to URL for all MCP servers +// (both manually specified and auto-resolved from OpenStackClientRef). +// hasCombinedCA indicates the entrypoint ConfigMap contains a combined-ca.crt +// key with pre-merged CA bundles from both lightspeed and MCP sources. +func AssistantPodSpec( + instance *apiv1beta1.OpenStackAssistant, + configHash string, + resolvedMCPServers map[string]string, + hasCombinedCA bool, +) corev1.PodSpec { + envVars := map[string]env.Setter{} + envVars["CONFIG_HASH"] = env.SetValue(configHash) + envVars["GOOSE_PROVIDER"] = env.SetValue("lightspeed") + envVars["GOOSE_TELEMETRY_ENABLED"] = env.SetValue("false") + envVars["GOOSE_DISABLE_KEYRING"] = env.SetValue("1") + envVars["ENV"] = env.SetValue("/tmp/assistant-env.sh") + envVars["BASH_ENV"] = env.SetValue("/tmp/assistant-env.sh") + + if hasCombinedCA { + envVars["SSL_CERT_FILE"] = env.SetValue(combinedCAMountPath) + } else if instance.Spec.LightspeedStack.CaBundleSecretName != "" { + envVars["SSL_CERT_FILE"] = env.SetValue("/etc/ssl/certs/ca-certificates.crt") + } + + if instance.Spec.Goose != nil && instance.Spec.Goose.Model != "" { + envVars["GOOSE_MODEL"] = env.SetValue(instance.Spec.Goose.Model) + } + + for name, url := range resolvedMCPServers { + envVars["MCP_SERVER_"+name] = env.SetValue(url) + } + + if instance.Spec.Env != nil { + for idx := range instance.Spec.Env { + e := instance.Spec.Env[idx] + envVars[e.Name] = func(env *corev1.EnvVar) { + env.Value = e.Value + env.ValueFrom = e.ValueFrom + } + } + } + + volumes := assistantPodVolumes(instance, hasCombinedCA) + volumeMounts := assistantPodVolumeMounts(instance, hasCombinedCA) + + containerName := "goose" + if instance.Spec.Provider != "" { + containerName = string(instance.Spec.Provider) + } + + podSpec := corev1.PodSpec{ + TerminationGracePeriodSeconds: ptr.To[int64](0), + // ServiceAccountName references the ServiceAccount this operator + // reconciles for the goose pod, including its SCC and non-resource URL + // RBAC wiring. + ServiceAccountName: apiv1beta1.OpenStackAssistantGooseServiceAccountName, + Volumes: volumes, + Containers: []corev1.Container{ + { + Name: containerName, + Image: instance.Spec.ContainerImage, + Command: []string{"/bin/sh"}, + Args: []string{"/tmp/entrypoint/entrypoint.sh"}, + SecurityContext: &corev1.SecurityContext{ + RunAsNonRoot: ptr.To(true), + AllowPrivilegeEscalation: ptr.To(false), + Capabilities: &corev1.Capabilities{ + Drop: []corev1.Capability{ + "ALL", + }, + }, + }, + Env: env.MergeEnvs([]corev1.EnvVar{}, envVars), + VolumeMounts: volumeMounts, + }, + }, + } + + if instance.Spec.NodeSelector != nil { + podSpec.NodeSelector = *instance.Spec.NodeSelector + } + + return podSpec +} + +func assistantPodVolumeMounts(instance *apiv1beta1.OpenStackAssistant, hasCombinedCA bool) []corev1.VolumeMount { + mounts := []corev1.VolumeMount{ + { + Name: "entrypoint", + MountPath: "/tmp/entrypoint", + ReadOnly: true, + }, + { + Name: "lightspeed-provider", + MountPath: "/tmp/lightspeed-provider", + ReadOnly: true, + }, + } + + if instance.Spec.Goose != nil { + if instance.Spec.Goose.Recipes != nil { + mounts = append(mounts, corev1.VolumeMount{ + Name: "recipes", + MountPath: "/tmp/recipes", + ReadOnly: true, + }) + } + if instance.Spec.Goose.Skills != nil { + mounts = append(mounts, corev1.VolumeMount{ + Name: "skills", + MountPath: "/tmp/skills", + ReadOnly: true, + }) + } + if instance.Spec.Goose.Hints != nil { + mounts = append(mounts, corev1.VolumeMount{ + Name: "hints", + MountPath: "/tmp/hints", + ReadOnly: true, + }) + } + } + + if hasCombinedCA { + mounts = append(mounts, corev1.VolumeMount{ + Name: "entrypoint", + MountPath: combinedCAMountPath, + SubPath: "combined-ca.crt", + ReadOnly: true, + }) + } else if instance.Spec.LightspeedStack.CaBundleSecretName != "" { + mounts = append(mounts, corev1.VolumeMount{ + Name: "ca-bundle", + MountPath: "/etc/ssl/certs/ca-certificates.crt", + SubPath: "ca-bundle.crt", + ReadOnly: true, + }) + } + + return mounts +} + +func assistantPodVolumes(instance *apiv1beta1.OpenStackAssistant, hasCombinedCA bool) []corev1.Volume { + volumes := []corev1.Volume{ + { + Name: "entrypoint", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: instance.Name + "-entrypoint", + }, + DefaultMode: ptr.To[int32](0755), + }, + }, + }, + { + Name: "lightspeed-provider", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: instance.Spec.LightspeedStack.ProviderSecret, + }, + }, + }, + } + + if instance.Spec.Goose != nil { + if instance.Spec.Goose.Recipes != nil { + volumes = append(volumes, corev1.Volume{ + Name: "recipes", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: *instance.Spec.Goose.Recipes, + }, + }, + }, + }) + } + if instance.Spec.Goose.Skills != nil { + volumes = append(volumes, corev1.Volume{ + Name: "skills", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: *instance.Spec.Goose.Skills, + }, + }, + }, + }) + } + if instance.Spec.Goose.Hints != nil { + volumes = append(volumes, corev1.Volume{ + Name: "hints", + VolumeSource: corev1.VolumeSource{ + ConfigMap: &corev1.ConfigMapVolumeSource{ + LocalObjectReference: corev1.LocalObjectReference{ + Name: *instance.Spec.Goose.Hints, + }, + }, + }, + }) + } + } + + if !hasCombinedCA && instance.Spec.LightspeedStack.CaBundleSecretName != "" { + volumes = append(volumes, corev1.Volume{ + Name: "ca-bundle", + VolumeSource: corev1.VolumeSource{ + Secret: &corev1.SecretVolumeSource{ + SecretName: instance.Spec.LightspeedStack.CaBundleSecretName, + }, + }, + }) + } + + return volumes +} diff --git a/internal/assistant/funcs_test.go b/internal/assistant/funcs_test.go new file mode 100644 index 0000000..5e598c3 --- /dev/null +++ b/internal/assistant/funcs_test.go @@ -0,0 +1,461 @@ +/* +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 ( + "strings" + "testing" + + "github.com/onsi/gomega" + + apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" +) + +func newTestInstance() *apiv1beta1.OpenStackAssistant { + return &apiv1beta1.OpenStackAssistant{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-assistant", + Namespace: "openstack", + }, + Spec: apiv1beta1.OpenStackAssistantSpec{ + ContainerImage: "quay.io/dprince/goose@sha256:07d7200f62bc2e8082de7a58396f8699b5f33fade1dbecc6a5b4ca03ab2f1d33", + Provider: apiv1beta1.ProviderGoose, + LightspeedStack: apiv1beta1.LightspeedStackSpec{ + ProviderSecret: "lightspeed-provider-config", + }, + }, + } +} + +func TestEntrypointScript(t *testing.T) { + g := gomega.NewWithT(t) + + script := EntrypointScript() + + g.Expect(script).To(gomega.ContainSubstring("#!/bin/sh")) + g.Expect(script).To(gomega.ContainSubstring("mkdir -p $HOME/.config/goose/custom_providers")) + g.Expect(script).To(gomega.ContainSubstring("config.yaml")) + g.Expect(script).To(gomega.ContainSubstring("developer:")) + g.Expect(script).To(gomega.ContainSubstring("enabled: true")) + g.Expect(script).To(gomega.ContainSubstring("/tmp/recipes/")) + g.Expect(script).To(gomega.ContainSubstring("/tmp/hints/hints")) + g.Expect(script).To(gomega.ContainSubstring("/tmp/lightspeed-provider/lightspeed.json")) + g.Expect(script).To(gomega.ContainSubstring("sleep infinity")) + + g.Expect(script).To(gomega.ContainSubstring("ca-bundle.crt")) + g.Expect(script).To(gomega.ContainSubstring("service-ca.crt")) + g.Expect(script).NotTo(gomega.ContainSubstring("update-ca-trust")) + g.Expect(script).To(gomega.ContainSubstring(`export LIGHTSPEED_API_KEY="`)) + g.Expect(script).To(gomega.ContainSubstring(`export SSL_CERT_FILE="`)) + g.Expect(script).To(gomega.ContainSubstring(`if [ -n "${SSL_CERT_FILE:-}" ]; then`)) + g.Expect(script).NotTo(gomega.ContainSubstring("/etc/profile.d/goose.sh")) + g.Expect(script).To(gomega.ContainSubstring("/tmp/assistant-env.sh")) + g.Expect(script).To(gomega.ContainSubstring(".bashrc")) + g.Expect(script).To(gomega.ContainSubstring(".profile")) +} + +func TestAssistantPodSpec_BasicFields(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "testhash123", nil, false) + + g.Expect(spec.ServiceAccountName).To(gomega.Equal(apiv1beta1.OpenStackAssistantGooseServiceAccountName)) + g.Expect(*spec.TerminationGracePeriodSeconds).To(gomega.Equal(int64(0))) + g.Expect(spec.Containers).To(gomega.HaveLen(1)) + + container := spec.Containers[0] + g.Expect(container.Name).To(gomega.Equal("goose")) + g.Expect(container.Image).To(gomega.Equal("quay.io/dprince/goose@sha256:07d7200f62bc2e8082de7a58396f8699b5f33fade1dbecc6a5b4ca03ab2f1d33")) + g.Expect(container.Command).To(gomega.Equal([]string{"/bin/sh"})) + g.Expect(container.Args).To(gomega.Equal([]string{"/tmp/entrypoint/entrypoint.sh"})) +} + +func TestAssistantPodSpec_SecurityContext(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash", nil, false) + sc := spec.Containers[0].SecurityContext + + g.Expect(*sc.RunAsNonRoot).To(gomega.BeTrue()) + g.Expect(sc.RunAsUser).To(gomega.BeNil()) + g.Expect(*sc.AllowPrivilegeEscalation).To(gomega.BeFalse()) + g.Expect(sc.Capabilities.Drop).To(gomega.ContainElement(corev1.Capability("ALL"))) +} + +func TestAssistantPodSpec_DefaultEnvVars(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "somehash", nil, false) + envVars := spec.Containers[0].Env + + envMap := make(map[string]string) + for _, e := range envVars { + envMap[e.Name] = e.Value + } + + g.Expect(envMap).To(gomega.HaveKeyWithValue("CONFIG_HASH", "somehash")) + g.Expect(envMap).To(gomega.HaveKeyWithValue("GOOSE_PROVIDER", "lightspeed")) + g.Expect(envMap).To(gomega.HaveKeyWithValue("GOOSE_TELEMETRY_ENABLED", "false")) + g.Expect(envMap).To(gomega.HaveKeyWithValue("GOOSE_DISABLE_KEYRING", "1")) + g.Expect(envMap).To(gomega.HaveKeyWithValue("ENV", "/tmp/assistant-env.sh")) + g.Expect(envMap).To(gomega.HaveKeyWithValue("BASH_ENV", "/tmp/assistant-env.sh")) +} + +func TestAssistantPodSpec_GooseModel(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &apiv1beta1.GooseConfig{ + Model: "gemini/models/gemini-2.5-flash", + } + + spec := AssistantPodSpec(instance, "hash", nil, false) + envVars := spec.Containers[0].Env + + envMap := make(map[string]string) + for _, e := range envVars { + envMap[e.Name] = e.Value + } + + g.Expect(envMap).To(gomega.HaveKeyWithValue("GOOSE_MODEL", "gemini/models/gemini-2.5-flash")) + g.Expect(envMap).To(gomega.HaveKeyWithValue("GOOSE_PROVIDER", "lightspeed")) +} + +func TestAssistantPodSpec_CustomEnvVars(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.Env = []corev1.EnvVar{ + {Name: "MY_CUSTOM_VAR", Value: "myvalue"}, + } + + spec := AssistantPodSpec(instance, "hash", nil, false) + envVars := spec.Containers[0].Env + + envMap := make(map[string]string) + for _, e := range envVars { + envMap[e.Name] = e.Value + } + + g.Expect(envMap).To(gomega.HaveKeyWithValue("MY_CUSTOM_VAR", "myvalue")) + g.Expect(envMap).To(gomega.HaveKeyWithValue("GOOSE_PROVIDER", "lightspeed")) +} + +func TestAssistantPodSpec_MinimalVolumes(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash", nil, false) + + g.Expect(spec.Volumes).To(gomega.HaveLen(2)) + + volumeNames := make([]string, len(spec.Volumes)) + for i, v := range spec.Volumes { + volumeNames[i] = v.Name + } + g.Expect(volumeNames).To(gomega.ContainElements("entrypoint", "lightspeed-provider")) + + mountNames := make([]string, len(spec.Containers[0].VolumeMounts)) + for i, m := range spec.Containers[0].VolumeMounts { + mountNames[i] = m.Name + } + g.Expect(mountNames).To(gomega.ContainElements("entrypoint", "lightspeed-provider")) +} + +func TestAssistantPodSpec_WithRecipesAndHints(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &apiv1beta1.GooseConfig{ + Recipes: ptr.To("assistant-recipes"), + Hints: ptr.To("assistant-hints"), + } + + spec := AssistantPodSpec(instance, "hash", nil, false) + + g.Expect(spec.Volumes).To(gomega.HaveLen(4)) + volumeNames := make([]string, len(spec.Volumes)) + for i, v := range spec.Volumes { + volumeNames[i] = v.Name + } + g.Expect(volumeNames).To(gomega.ContainElements("entrypoint", "lightspeed-provider", "recipes", "hints")) + + mountNames := make([]string, len(spec.Containers[0].VolumeMounts)) + for i, m := range spec.Containers[0].VolumeMounts { + mountNames[i] = m.Name + } + g.Expect(mountNames).To(gomega.ContainElements("entrypoint", "lightspeed-provider", "recipes", "hints")) +} + +func TestAssistantPodSpec_WithCaBundle(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.LightspeedStack.CaBundleSecretName = "lightspeed-ca-bundle" + + spec := AssistantPodSpec(instance, "hash", nil, false) + + g.Expect(spec.Volumes).To(gomega.HaveLen(3)) + + var caBundleVolume *corev1.Volume + for i := range spec.Volumes { + if spec.Volumes[i].Name == "ca-bundle" { + caBundleVolume = &spec.Volumes[i] + break + } + } + g.Expect(caBundleVolume).NotTo(gomega.BeNil()) + g.Expect(caBundleVolume.Secret.SecretName).To(gomega.Equal("lightspeed-ca-bundle")) + + var caBundleMount *corev1.VolumeMount + for i := range spec.Containers[0].VolumeMounts { + if spec.Containers[0].VolumeMounts[i].Name == "ca-bundle" { + caBundleMount = &spec.Containers[0].VolumeMounts[i] + break + } + } + g.Expect(caBundleMount).NotTo(gomega.BeNil()) + g.Expect(caBundleMount.MountPath).To(gomega.Equal("/etc/ssl/certs/ca-certificates.crt")) + g.Expect(caBundleMount.SubPath).To(gomega.Equal("ca-bundle.crt")) + g.Expect(caBundleMount.ReadOnly).To(gomega.BeTrue()) + + envMap := map[string]string{} + for _, e := range spec.Containers[0].Env { + envMap[e.Name] = e.Value + } + g.Expect(envMap).To(gomega.HaveKeyWithValue("SSL_CERT_FILE", "/etc/ssl/certs/ca-certificates.crt")) +} + +func TestAssistantPodSpec_WithNodeSelector(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.NodeSelector = &map[string]string{ + "node-role.kubernetes.io/worker": "", + } + + spec := AssistantPodSpec(instance, "hash", nil, false) + + g.Expect(spec.NodeSelector).To(gomega.HaveKeyWithValue("node-role.kubernetes.io/worker", "")) +} + +func TestAssistantPodSpec_WithoutNodeSelector(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash", nil, false) + + g.Expect(spec.NodeSelector).To(gomega.BeNil()) +} + +func TestAssistantPodSpec_EntrypointConfigMapName(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash", nil, false) + + var entrypointVolume *corev1.Volume + for i := range spec.Volumes { + if spec.Volumes[i].Name == "entrypoint" { + entrypointVolume = &spec.Volumes[i] + break + } + } + g.Expect(entrypointVolume).NotTo(gomega.BeNil()) + g.Expect(entrypointVolume.ConfigMap.Name).To(gomega.Equal("test-assistant-entrypoint")) + g.Expect(*entrypointVolume.ConfigMap.DefaultMode).To(gomega.Equal(int32(0755))) +} + +func TestAssistantPodSpec_LightspeedProviderSecretName(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + spec := AssistantPodSpec(instance, "hash", nil, false) + + var providerVolume *corev1.Volume + for i := range spec.Volumes { + if spec.Volumes[i].Name == "lightspeed-provider" { + providerVolume = &spec.Volumes[i] + break + } + } + g.Expect(providerVolume).NotTo(gomega.BeNil()) + g.Expect(providerVolume.Secret.SecretName).To(gomega.Equal("lightspeed-provider-config")) +} + +func TestAssistantPodSpec_AllVolumeMountsReadOnly(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &apiv1beta1.GooseConfig{ + Recipes: ptr.To("recipes-cm"), + Skills: ptr.To("skills-cm"), + Hints: ptr.To("hints-cm"), + } + instance.Spec.LightspeedStack.CaBundleSecretName = "ca-secret" + + spec := AssistantPodSpec(instance, "hash", nil, false) + + for _, mount := range spec.Containers[0].VolumeMounts { + g.Expect(mount.ReadOnly).To(gomega.BeTrue(), "VolumeMount %s should be read-only", mount.Name) + } +} + +func TestAssistantPodSpec_RecipesOnlyNoHints(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &apiv1beta1.GooseConfig{ + Recipes: ptr.To("recipes-cm"), + } + + spec := AssistantPodSpec(instance, "hash", nil, false) + + g.Expect(spec.Volumes).To(gomega.HaveLen(3)) + volumeNames := make([]string, len(spec.Volumes)) + for i, v := range spec.Volumes { + volumeNames[i] = v.Name + } + g.Expect(volumeNames).To(gomega.ContainElement("recipes")) + g.Expect(volumeNames).NotTo(gomega.ContainElement("hints")) +} + +func TestAssistantPodSpec_SkillsOnly(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.Goose = &apiv1beta1.GooseConfig{ + Skills: ptr.To("skills-cm"), + } + + spec := AssistantPodSpec(instance, "hash", nil, false) + + g.Expect(spec.Volumes).To(gomega.HaveLen(3)) + volumeNames := make([]string, len(spec.Volumes)) + for i, v := range spec.Volumes { + volumeNames[i] = v.Name + } + g.Expect(volumeNames).To(gomega.ContainElement("skills")) + + var skillsVolume *corev1.Volume + for i := range spec.Volumes { + if spec.Volumes[i].Name == "skills" { + skillsVolume = &spec.Volumes[i] + break + } + } + g.Expect(skillsVolume).NotTo(gomega.BeNil()) + g.Expect(skillsVolume.ConfigMap.Name).To(gomega.Equal("skills-cm")) + + mountNames := make([]string, len(spec.Containers[0].VolumeMounts)) + for i, m := range spec.Containers[0].VolumeMounts { + mountNames[i] = m.Name + } + g.Expect(mountNames).To(gomega.ContainElement("skills")) +} + +func TestAssistantPodSpec_MCPServers(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + resolvedMCPServers := map[string]string{ + "openstack": "http://openstackclient-mcp.openstack.svc:8080/openstack/", + } + + spec := AssistantPodSpec(instance, "hash", resolvedMCPServers, false) + envVars := spec.Containers[0].Env + + envMap := make(map[string]string) + for _, e := range envVars { + envMap[e.Name] = e.Value + } + + g.Expect(envMap).To(gomega.HaveKeyWithValue("MCP_SERVER_openstack", "http://openstackclient-mcp.openstack.svc:8080/openstack/")) +} + +func TestAssistantPodSpec_MCPServersHTTPS(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + + resolvedMCPServers := map[string]string{ + "openstack": "https://openstackclient-mcp.openstack.svc:8080/openstack/", + } + + spec := AssistantPodSpec(instance, "hash", resolvedMCPServers, false) + envVars := spec.Containers[0].Env + + envMap := make(map[string]string) + for _, e := range envVars { + envMap[e.Name] = e.Value + } + + g.Expect(envMap).To(gomega.HaveKeyWithValue("MCP_SERVER_openstack", "https://openstackclient-mcp.openstack.svc:8080/openstack/")) +} + +func TestAssistantPodSpec_CombinedCA(t *testing.T) { + g := gomega.NewWithT(t) + instance := newTestInstance() + instance.Spec.LightspeedStack.CaBundleSecretName = "lightspeed-ca" + + spec := AssistantPodSpec(instance, "hash", nil, true) + + // Combined CA should be mounted from the entrypoint ConfigMap + var combinedMount *corev1.VolumeMount + for i := range spec.Containers[0].VolumeMounts { + if spec.Containers[0].VolumeMounts[i].SubPath == "combined-ca.crt" { + combinedMount = &spec.Containers[0].VolumeMounts[i] + break + } + } + g.Expect(combinedMount).NotTo(gomega.BeNil()) + g.Expect(combinedMount.Name).To(gomega.Equal("entrypoint")) + g.Expect(combinedMount.MountPath).To(gomega.Equal("/etc/ssl/certs/combined-ca.crt")) + g.Expect(combinedMount.ReadOnly).To(gomega.BeTrue()) + + // SSL_CERT_FILE should point to combined CA + envMap := map[string]string{} + for _, e := range spec.Containers[0].Env { + envMap[e.Name] = e.Value + } + g.Expect(envMap).To(gomega.HaveKeyWithValue("SSL_CERT_FILE", "/etc/ssl/certs/combined-ca.crt")) + + // No separate ca-bundle volume when combined CA is used + for _, v := range spec.Volumes { + g.Expect(v.Name).NotTo(gomega.Equal("ca-bundle")) + } +} + +func TestEntrypointScript_MCPServerDiscovery(t *testing.T) { + g := gomega.NewWithT(t) + + script := EntrypointScript() + + g.Expect(script).To(gomega.ContainSubstring("MCP_SERVER_")) + g.Expect(script).To(gomega.ContainSubstring("streamable_http")) +} + +func TestEntrypointScript_DisabledExtensions(t *testing.T) { + g := gomega.NewWithT(t) + + script := EntrypointScript() + + disabledExtensions := []string{"computercontroller", "apps", "analyze", "todo", "extensionmanager", "chatrecall"} + for _, ext := range disabledExtensions { + idx := strings.Index(script, ext+":") + g.Expect(idx).To(gomega.BeNumerically(">", 0), "should contain %s", ext) + g.Expect(script).To(gomega.ContainSubstring(ext)) + } + + enabledExtensions := []string{"developer", "summarize", "summon"} + for _, ext := range enabledExtensions { + g.Expect(script).To(gomega.ContainSubstring(ext)) + } +} diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 4e60dfe..aefd6a1 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -18,6 +18,7 @@ package controller import ( _ "embed" + "fmt" "time" ) @@ -27,8 +28,6 @@ const ( // Application Server OpenStackLightspeedAppServerServiceAccountName = "lightspeed-app-server" - OpenStackLightspeedAppServerSARRoleName = OpenStackLightspeedAppServerServiceAccountName + "-sar-role" - OpenStackLightspeedAppServerSARRoleBindingName = OpenStackLightspeedAppServerSARRoleName + "-binding" OpenStackLightspeedAppServerContainerPort = 8443 OpenStackLightspeedAppServerServicePort = 8443 OpenStackLightspeedAppServerServiceName = "lightspeed-app-server" @@ -354,6 +353,23 @@ const ( OpenStackLightspeedChecksumAnnotation = "openstack.org/checksum" ) +// OpenStackLightspeedAppServerSARRoleName returns the name of the SAR +// ClusterRole for the app server running in the given namespace. The name is +// namespace-scoped because ClusterRole/ClusterRoleBinding objects are +// cluster-scoped: multiple OpenStackLightspeed instances (one per namespace) +// must not share a single SAR role, or reconciling/deleting one instance +// would clobber another's RBAC. +func OpenStackLightspeedAppServerSARRoleName(namespace string) string { + return fmt.Sprintf("%s-sar-role-%s", OpenStackLightspeedAppServerServiceAccountName, namespace) +} + +// OpenStackLightspeedAppServerSARRoleBindingName returns the name of the SAR +// ClusterRoleBinding for the app server running in the given namespace. See +// OpenStackLightspeedAppServerSARRoleName for why this is namespace-scoped. +func OpenStackLightspeedAppServerSARRoleBindingName(namespace string) string { + return OpenStackLightspeedAppServerSARRoleName(namespace) + "-binding" +} + // PostgreSQL Bootstrap Script - creates database, extensions, and schemas // //go:embed assets/postgres_bootstrap.sh diff --git a/internal/controller/lcore_reconciler.go b/internal/controller/lcore_reconciler.go index e62a6d4..38be3eb 100644 --- a/internal/controller/lcore_reconciler.go +++ b/internal/controller/lcore_reconciler.go @@ -29,7 +29,6 @@ import ( rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/apimachinery/pkg/util/wait" "sigs.k8s.io/controller-runtime/pkg/client" @@ -99,7 +98,7 @@ func reconcileSARRole(h *common_helper.Helper, ctx context.Context, instance *ap role := &rbacv1.ClusterRole{ ObjectMeta: metav1.ObjectMeta{ - Name: OpenStackLightspeedAppServerSARRoleName, + Name: OpenStackLightspeedAppServerSARRoleName(instance.Namespace), Labels: generateAppServerSelectorLabels(), }, } @@ -147,7 +146,7 @@ func reconcileSARRoleBinding(h *common_helper.Helper, ctx context.Context, insta rb := &rbacv1.ClusterRoleBinding{ ObjectMeta: metav1.ObjectMeta{ - Name: OpenStackLightspeedAppServerSARRoleBindingName, + Name: OpenStackLightspeedAppServerSARRoleBindingName(instance.Namespace), Labels: generateAppServerSelectorLabels(), }, } @@ -164,7 +163,7 @@ func reconcileSARRoleBinding(h *common_helper.Helper, ctx context.Context, insta rb.RoleRef = rbacv1.RoleRef{ APIGroup: "rbac.authorization.k8s.io", Kind: "ClusterRole", - Name: OpenStackLightspeedAppServerSARRoleName, + Name: OpenStackLightspeedAppServerSARRoleName(instance.Namespace), } // Note: ClusterRoleBinding is cluster-scoped, no owner reference needed return nil @@ -480,42 +479,42 @@ func reconcileTLSSecret(h *common_helper.Helper, ctx context.Context, _ *apiv1be return nil } -// reconcileDeleteClusterRoleBindingByLabels deletes ClusterRoleBinding resources by labels. -func reconcileDeleteClusterRoleBindingByLabels(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { +// reconcileDeleteClusterRoleBindingByLabels deletes this instance's SAR +// ClusterRoleBinding by its namespace-scoped name. Deletion is scoped to a +// single name (not a shared label selector) because the SAR ClusterRoleBinding +// is cluster-scoped: multiple OpenStackLightspeed instances each own their own +// binding, and deleting one instance must never remove another's RBAC. +func reconcileDeleteClusterRoleBindingByLabels(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error { logger := h.GetLogger() - labelSelector := labels.Set(generateAppServerSelectorLabels()).AsSelector() - matchingLabels := client.MatchingLabelsSelector{Selector: labelSelector} - deleteOptions := &client.DeleteAllOfOptions{ - ListOptions: client.ListOptions{ - LabelSelector: matchingLabels, + rb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: OpenStackLightspeedAppServerSARRoleBindingName(instance.Namespace), }, } - - if err := h.GetClient().DeleteAllOf(ctx, &rbacv1.ClusterRoleBinding{}, deleteOptions); err != nil { + if err := client.IgnoreNotFound(h.GetClient().Delete(ctx, rb)); err != nil { return fmt.Errorf("%w: %v", ErrDeleteSARClusterRoleBinding, err) } - logger.Info("SAR ClusterRoleBinding deleted successfully") + logger.Info("SAR ClusterRoleBinding deleted successfully", "name", rb.Name) return nil } -// reconcileDeleteClusterRoleByLabels deletes ClusterRole resources by labels. -func reconcileDeleteClusterRoleByLabels(h *common_helper.Helper, ctx context.Context, _ *apiv1beta1.OpenStackLightspeed) error { +// reconcileDeleteClusterRoleByLabels deletes this instance's SAR ClusterRole +// by its namespace-scoped name. See reconcileDeleteClusterRoleBindingByLabels +// for why this must not use a shared label selector. +func reconcileDeleteClusterRoleByLabels(h *common_helper.Helper, ctx context.Context, instance *apiv1beta1.OpenStackLightspeed) error { logger := h.GetLogger() - labelSelector := labels.Set(generateAppServerSelectorLabels()).AsSelector() - matchingLabels := client.MatchingLabelsSelector{Selector: labelSelector} - deleteOptions := &client.DeleteAllOfOptions{ - ListOptions: client.ListOptions{ - LabelSelector: matchingLabels, + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: OpenStackLightspeedAppServerSARRoleName(instance.Namespace), }, } - - if err := h.GetClient().DeleteAllOf(ctx, &rbacv1.ClusterRole{}, deleteOptions); err != nil { + if err := client.IgnoreNotFound(h.GetClient().Delete(ctx, role)); err != nil { return fmt.Errorf("%w: %v", ErrDeleteSARClusterRole, err) } - logger.Info("SAR ClusterRole deleted successfully") + logger.Info("SAR ClusterRole deleted successfully", "name", role.Name) return nil } diff --git a/internal/controller/openstackassistant_controller.go b/internal/controller/openstackassistant_controller.go new file mode 100644 index 0000000..cefc1fc --- /dev/null +++ b/internal/controller/openstackassistant_controller.go @@ -0,0 +1,807 @@ +/* +Copyright 2026 + +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 controller + +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" + uns "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "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" + "github.com/openstack-k8s-operators/lib-common/modules/common/secret" + "github.com/openstack-k8s-operators/lib-common/modules/common/util" + + "github.com/openstack-k8s-operators/lightspeed-operator/internal/assistant" + + apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" +) + +// openStackClientGVK is the GroupVersionKind of the OpenStackClient CR, which +// is owned and reconciled by openstack-operator (not this operator). The MCP +// sidecar it creates is read here via the unstructured/dynamic client so that +// lightspeed-operator does not take a Go module dependency on +// openstack-operator's API types (and their large transitive dependency graph). +// +// Cross-repo contract with openstack-operator's OpenStackClient controller +// (internal/controller/client/openstackclient_controller.go): +// - spec.mcp.enabled must be true for the MCP sidecar/Service to exist. +// - The MCP Service is named "-mcp" in the same +// namespace, listening on port 8080 at path "/openstack/". +// - If spec.caBundleSecretName is set on the OpenStackClient, the MCP +// endpoint is TLS (https) and that Secret contains the CA bundle +// (key "tls-ca-bundle.pem") needed to validate it. +var openStackClientGVK = schema.GroupVersionKind{ + Group: "client.openstack.org", + Version: "v1beta1", + Kind: "OpenStackClient", +} + +// 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=lightspeed.openstack.org,resources=openstackassistants,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=lightspeed.openstack.org,resources=openstackassistants/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=lightspeed.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=client.openstack.org,resources=openstackclients,verbs=get;list;watch +// +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 +// +kubebuilder:rbac:urls=/ls-access,verbs=get +// +kubebuilder:rbac:groups=security.openshift.io,resources=securitycontextconstraints,verbs=use,resourceNames=nonroot-v2 + +// Reconcile - +func (r *OpenStackAssistantReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, _err error) { + Log := r.GetLogger(ctx) + + instance := &apiv1beta1.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 + } + instance.Default() + 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 + } + }() + + cl := condition.CreateList( + condition.UnknownCondition(apiv1beta1.OpenStackAssistantReadyCondition, condition.InitReason, apiv1beta1.OpenStackAssistantReadyInitMessage), + ) + instance.Status.Conditions.Init(&cl) + instance.Status.ObservedGeneration = instance.Generation + + if !instance.DeletionTimestamp.IsZero() { + if err := r.reconcileDelete(ctx, helper, instance); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + if instance.DeletionTimestamp.IsZero() && controllerutil.AddFinalizer(instance, helper.GetFinalizer()) { + return ctrl.Result{}, nil + } + + if err := r.reconcileGooseServiceAccount(ctx, helper, instance); err != nil { + return ctrl.Result{}, err + } + + assistantLabels := map[string]string{ + // Must match the label openstack-operator's OpenStackClient + // controller uses as the NetworkPolicy ingress-peer selector for + // its MCP server (port 8080), so the assistant pod stays able to + // reach it after moving to this operator. + 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( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.OpenStackAssistantProviderSecretWaitingMessage)) + return ctrl.Result{RequeueAfter: time.Duration(10) * time.Second}, nil + } + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + apiv1beta1.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( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + apiv1beta1.OpenStackAssistantReadyErrorMessage, + "CA bundle secret "+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( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.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 Skills ConfigMap + if instance.Spec.Goose != nil && instance.Spec.Goose.Skills != nil { + _, skillsHash, err := configmap.GetConfigMapAndHashWithName(ctx, helper, *instance.Spec.Goose.Skills, instance.Namespace) + if err != nil { + if k8s_errors.IsNotFound(err) { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.OpenStackAssistantSkillsWaitingMessage)) + return ctrl.Result{RequeueAfter: time.Duration(10) * time.Second}, nil + } + return ctrl.Result{}, err + } + configVars[*instance.Spec.Goose.Skills] = env.SetValue(skillsHash) + } + + // 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( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.OpenStackAssistantHintsWaitingMessage)) + return ctrl.Result{RequeueAfter: time.Duration(10) * time.Second}, nil + } + return ctrl.Result{}, err + } + configVars[*instance.Spec.Goose.Hints] = env.SetValue(hintsHash) + } + + // Resolve MCP servers (auto-discover from OpenStackClientRef or use manual URL) + resolvedMCPServers := make(map[string]string) + mcpCaBundleSecretName := "" + if instance.Spec.Goose != nil { + for _, mcp := range instance.Spec.Goose.MCPServers { + if mcp.OpenStackClientRef != "" { + osclient := &uns.Unstructured{} + osclient.SetGroupVersionKind(openStackClientGVK) + err := r.Get(ctx, types.NamespacedName{ + Name: mcp.OpenStackClientRef, + Namespace: instance.Namespace, + }, osclient) + if err != nil { + if k8s_errors.IsNotFound(err) { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + "Waiting for OpenStackClient %s", mcp.OpenStackClientRef)) + return ctrl.Result{RequeueAfter: time.Duration(10) * time.Second}, nil + } + return ctrl.Result{}, fmt.Errorf("error looking up OpenStackClient %s: %w", mcp.OpenStackClientRef, err) + } + + mcpEnabled, _, _ := uns.NestedBool(osclient.Object, "spec", "mcp", "enabled") + if !mcpEnabled { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + apiv1beta1.OpenStackAssistantReadyErrorMessage, + "OpenStackClient "+mcp.OpenStackClientRef+" does not have MCP enabled")) + return ctrl.Result{}, nil + } + + caBundleSecretName, _, _ := uns.NestedString(osclient.Object, "spec", "caBundleSecretName") + + mcpSvcName := mcp.OpenStackClientRef + "-mcp" + scheme := "http" + if caBundleSecretName != "" { + mcpCaBundleSecretName = caBundleSecretName + scheme = "https" + } + mcpURL := fmt.Sprintf("%s://%s.%s.svc:8080/openstack/", scheme, mcpSvcName, instance.Namespace) + resolvedMCPServers[mcp.Name] = mcpURL + Log.Info("Auto-resolved MCP server", "name", mcp.Name, "url", mcpURL, "openstackClientRef", mcp.OpenStackClientRef) + } else if mcp.URL != "" { + resolvedMCPServers[mcp.Name] = mcp.URL + } + } + } + + // Validate MCP CA bundle secret if auto-discovered + if mcpCaBundleSecretName != "" && mcpCaBundleSecretName != instance.Spec.LightspeedStack.CaBundleSecretName { + _, mcpCaHash, err := secret.GetSecret(ctx, helper, mcpCaBundleSecretName, instance.Namespace) + if err != nil { + if k8s_errors.IsNotFound(err) { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + apiv1beta1.OpenStackAssistantReadyErrorMessage, + "MCP CA bundle secret "+mcpCaBundleSecretName+" not found")) + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + configVars["mcp-ca-bundle"] = env.SetValue(mcpCaHash) + } + + // Build combined CA bundle when MCP TLS is in use, merging the internal CA + // (tls-ca-bundle.pem) with the lightspeed CA (ca-bundle.crt) if present. + // This handles same-secret, different-secret, and MCP-only cases. + hasCombinedCA := false + combinedCAPEM := "" + if mcpCaBundleSecretName != "" { + var lightspeedCA, mcpCA string + + if instance.Spec.LightspeedStack.CaBundleSecretName != "" { + lightspeedCASecret, _, err := secret.GetSecret(ctx, helper, instance.Spec.LightspeedStack.CaBundleSecretName, instance.Namespace) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error reading lightspeed CA secret: %w", err) + } + lightspeedCA = string(lightspeedCASecret.Data["ca-bundle.crt"]) + + if mcpCaBundleSecretName == instance.Spec.LightspeedStack.CaBundleSecretName { + mcpCA = string(lightspeedCASecret.Data["tls-ca-bundle.pem"]) + } + } + + if mcpCA == "" { + mcpCASecret, _, err := secret.GetSecret(ctx, helper, mcpCaBundleSecretName, instance.Namespace) + if err != nil { + return ctrl.Result{}, fmt.Errorf("error reading MCP CA secret: %w", err) + } + mcpCA = string(mcpCASecret.Data["tls-ca-bundle.pem"]) + } + + if mcpCA != "" { + combinedCAPEM = mcpCA + if lightspeedCA != "" { + combinedCAPEM = lightspeedCA + "\n" + mcpCA + } + hasCombinedCA = true + } + } + + // 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": assistant.EntrypointScript(), + } + if hasCombinedCA { + entrypointCM.Data["combined-ca.crt"] = combinedCAPEM + } + 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 := assistant.AssistantPodSpec(instance, configVarsHash, resolvedMCPServers, hasCombinedCA) + + 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] + podServiceAccountDrifted := assistantPod.Spec.ServiceAccountName != spec.ServiceAccountName + if !isPodUpdate || currentPodSpecHash != podSpecHash || podServiceAccountDrifted { + 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( + apiv1beta1.OpenStackAssistantReadyCondition, + apiv1beta1.OpenStackAssistantReadyMessage, + ) + } else { + instance.Status.Conditions.Set(condition.FalseCondition( + apiv1beta1.OpenStackAssistantReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + apiv1beta1.OpenStackAssistantReadyRunningMessage)) + } + + return ctrl.Result{}, nil +} + +// reconcileGooseServiceAccount ensures the ServiceAccount that the assistant/ +// goose pod runs as exists, is bound to the nonroot-v2 SecurityContextConstraint, +// and is granted GET /ls-access so Lightspeed's k8s auth module will accept its +// SA token. Unlike the ServiceAccount openstack-operator creates for the MCP +// sidecar (apiv1beta1.OpenStackAssistantServiceAccountName), this SA carries no +// k8s API resource RBAC grants. +func (r *OpenStackAssistantReconciler) reconcileGooseServiceAccount( + ctx context.Context, + helper *helper.Helper, + instance *apiv1beta1.OpenStackAssistant, +) error { + sa := &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiv1beta1.OpenStackAssistantGooseServiceAccountName, + Namespace: instance.Namespace, + }, + } + + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, sa, func() error { + return controllerutil.SetControllerReference(instance, sa, r.Scheme) + }); err != nil { + return fmt.Errorf("error creating goose ServiceAccount: %w", err) + } + + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiv1beta1.OpenStackAssistantGooseServiceAccountName + "-" + apiv1beta1.OpenStackAssistantGooseSCCName, + Namespace: instance.Namespace, + }, + } + + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, rb, func() error { + rb.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: "system:openshift:scc:" + apiv1beta1.OpenStackAssistantGooseSCCName, + } + rb.Subjects = []rbacv1.Subject{ + { + Kind: rbacv1.ServiceAccountKind, + Name: sa.Name, + Namespace: instance.Namespace, + }, + } + return controllerutil.SetControllerReference(instance, rb, r.Scheme) + }); err != nil { + return fmt.Errorf("error creating goose SCC RoleBinding: %w", err) + } + + // ClusterRole/ClusterRoleBinding are cluster-scoped and cannot be owned by + // the namespaced OpenStackAssistant CR, so they are cleaned up explicitly + // in reconcileDelete. + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiv1beta1.OpenStackAssistantGooseLSAccessClusterRoleName(instance.Namespace), + }, + } + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, role, func() error { + role.Rules = []rbacv1.PolicyRule{ + { + NonResourceURLs: []string{apiv1beta1.OpenStackAssistantGooseLSAccessPath}, + Verbs: []string{"get"}, + }, + } + return nil + }); err != nil { + return fmt.Errorf("error creating goose /ls-access ClusterRole: %w", err) + } + + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiv1beta1.OpenStackAssistantGooseLSAccessClusterRoleBindingName(instance.Namespace), + }, + } + if _, err := controllerutil.CreateOrPatch(ctx, r.Client, crb, func() error { + crb.RoleRef = rbacv1.RoleRef{ + APIGroup: rbacv1.GroupName, + Kind: "ClusterRole", + Name: role.Name, + } + crb.Subjects = []rbacv1.Subject{ + { + Kind: rbacv1.ServiceAccountKind, + Name: sa.Name, + Namespace: instance.Namespace, + }, + } + return nil + }); err != nil { + return fmt.Errorf("error creating goose /ls-access ClusterRoleBinding: %w", err) + } + + util.LogForObject(helper, "Goose ServiceAccount, SCC RoleBinding, and /ls-access RBAC reconciled", instance) + + return nil +} + +// reconcileDelete removes cluster-scoped resources owned by this +// OpenStackAssistant that cannot be garbage-collected via owner references, +// then drops the finalizer so the CR can finish deleting. +func (r *OpenStackAssistantReconciler) reconcileDelete( + ctx context.Context, + helper *helper.Helper, + instance *apiv1beta1.OpenStackAssistant, +) error { + Log := r.GetLogger(ctx) + Log.Info("OpenStackAssistant Reconciling Delete") + + crb := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiv1beta1.OpenStackAssistantGooseLSAccessClusterRoleBindingName(instance.Namespace), + }, + } + if err := client.IgnoreNotFound(r.Client.Delete(ctx, crb)); err != nil { + return fmt.Errorf("error deleting goose /ls-access ClusterRoleBinding: %w", err) + } + + role := &rbacv1.ClusterRole{ + ObjectMeta: metav1.ObjectMeta{ + Name: apiv1beta1.OpenStackAssistantGooseLSAccessClusterRoleName(instance.Namespace), + }, + } + if err := client.IgnoreNotFound(r.Client.Delete(ctx, role)); err != nil { + return fmt.Errorf("error deleting goose /ls-access ClusterRole: %w", err) + } + + controllerutil.RemoveFinalizer(instance, helper.GetFinalizer()) + Log.Info("OpenStackAssistant Reconciling Delete completed") + return nil +} + +// fields to index to reconcile when change +const ( + providerSecretField = ".spec.lightspeedStack.providerSecret" + caBundleSecretField = ".spec.lightspeedStack.caBundleSecretName" + recipesField = ".spec.goose.recipes" + skillsField = ".spec.goose.skills" + hintsField = ".spec.goose.hints" +) + +var allWatchFields = []string{ + providerSecretField, + caBundleSecretField, + recipesField, + skillsField, + hintsField, +} + +// SetupWithManager sets up the controller with the Manager. +func (r *OpenStackAssistantReconciler) SetupWithManager(mgr ctrl.Manager) error { + ctx := context.Background() + + if err := mgr.GetFieldIndexer().IndexField(ctx, &apiv1beta1.OpenStackAssistant{}, providerSecretField, func(rawObj client.Object) []string { + cr := rawObj.(*apiv1beta1.OpenStackAssistant) + if cr.Spec.LightspeedStack.ProviderSecret == "" { + return nil + } + return []string{cr.Spec.LightspeedStack.ProviderSecret} + }); err != nil { + return err + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &apiv1beta1.OpenStackAssistant{}, caBundleSecretField, func(rawObj client.Object) []string { + cr := rawObj.(*apiv1beta1.OpenStackAssistant) + if cr.Spec.LightspeedStack.CaBundleSecretName == "" { + return nil + } + return []string{cr.Spec.LightspeedStack.CaBundleSecretName} + }); err != nil { + return err + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &apiv1beta1.OpenStackAssistant{}, recipesField, func(rawObj client.Object) []string { + cr := rawObj.(*apiv1beta1.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, &apiv1beta1.OpenStackAssistant{}, skillsField, func(rawObj client.Object) []string { + cr := rawObj.(*apiv1beta1.OpenStackAssistant) + if cr.Spec.Goose == nil || cr.Spec.Goose.Skills == nil || *cr.Spec.Goose.Skills == "" { + return nil + } + return []string{*cr.Spec.Goose.Skills} + }); err != nil { + return err + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &apiv1beta1.OpenStackAssistant{}, hintsField, func(rawObj client.Object) []string { + cr := rawObj.(*apiv1beta1.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 + } + + openStackClientWatch := &uns.Unstructured{} + openStackClientWatch.SetGroupVersionKind(openStackClientGVK) + + return ctrl.NewControllerManagedBy(mgr). + For(&apiv1beta1.OpenStackAssistant{}). + Named("openstackassistant"). + Owns(&corev1.Pod{}). + Owns(&corev1.ConfigMap{}). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + &corev1.ConfigMap{}, + handler.EnqueueRequestsFromMapFunc(r.findObjectsForSrc), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Watches( + openStackClientWatch, + handler.EnqueueRequestsFromMapFunc(r.findAssistantsForOpenStackClient), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). + Complete(r) +} + +func (r *OpenStackAssistantReconciler) findAssistantsForOpenStackClient(ctx context.Context, src client.Object) []reconcile.Request { + Log := r.GetLogger(ctx) + requests := []reconcile.Request{} + + crList := &apiv1beta1.OpenStackAssistantList{} + if err := r.List(ctx, crList, client.InNamespace(src.GetNamespace())); err != nil { + Log.Error(err, "listing OpenStackAssistants for OpenStackClient change") + return requests + } + + for _, item := range crList.Items { + if item.Spec.Goose == nil { + continue + } + for _, mcp := range item.Spec.Goose.MCPServers { + if mcp.OpenStackClientRef == src.GetName() { + Log.Info("OpenStackClient changed, reconciling assistant", + "openstackClient", src.GetName(), + "assistant", item.GetName()) + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: item.GetName(), + Namespace: item.GetNamespace(), + }, + }) + break + } + } + } + + return requests +} + +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 := &apiv1beta1.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/openstackassistant_controller_test.go b/internal/controller/openstackassistant_controller_test.go new file mode 100644 index 0000000..2087586 --- /dev/null +++ b/internal/controller/openstackassistant_controller_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2025. + +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 controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + lightspeedv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" +) + +var _ = Describe("OpenStackAssistant Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + openstackassistant := &lightspeedv1beta1.OpenStackAssistant{} + + BeforeEach(func() { + By("creating the custom resource for the Kind OpenStackAssistant") + err := k8sClient.Get(ctx, typeNamespacedName, openstackassistant) + if err != nil && errors.IsNotFound(err) { + resource := &lightspeedv1beta1.OpenStackAssistant{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + Spec: lightspeedv1beta1.OpenStackAssistantSpec{ + ContainerImage: "quay.io/dprince/goose@sha256:07d7200f62bc2e8082de7a58396f8699b5f33fade1dbecc6a5b4ca03ab2f1d33", + Provider: lightspeedv1beta1.ProviderGoose, + LightspeedStack: lightspeedv1beta1.LightspeedStackSpec{ + ProviderSecret: "lightspeed-provider-config", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &lightspeedv1beta1.OpenStackAssistant{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance OpenStackAssistant") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &OpenStackAssistantReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) diff --git a/internal/controller/openstacklightspeed_controller.go b/internal/controller/openstacklightspeed_controller.go index a42374f..4acdda8 100644 --- a/internal/controller/openstacklightspeed_controller.go +++ b/internal/controller/openstacklightspeed_controller.go @@ -100,9 +100,9 @@ func (r *OpenStackLightspeedReconciler) GetLogger(ctx context.Context) logr.Logg // +kubebuilder:rbac:groups="",resources=secrets,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;delete;deletecollection // +kubebuilder:rbac:groups="",resources=services,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch // +kubebuilder:rbac:groups="",resources=serviceaccounts,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch -// +kubebuilder:rbac:groups=console.openshift.io,resources=consoleplugins,verbs=get;list;watch;create;patch;delete +// +kubebuilder:rbac:groups=console.openshift.io,resources=consoleplugins,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=operator.openshift.io,resources=consoles,verbs=get;list;watch;update -// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch +// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,namespace=openstack-lightspeed,verbs=get;list;watch;create;patch;update func (r *OpenStackLightspeedReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, e error) { Log := r.GetLogger(ctx) diff --git a/internal/controller/postgres_reconciler.go b/internal/controller/postgres_reconciler.go index a4b4193..69c6a18 100644 --- a/internal/controller/postgres_reconciler.go +++ b/internal/controller/postgres_reconciler.go @@ -116,10 +116,12 @@ func reconcilePostgresBootstrapSecret(h *common_helper.Helper, ctx context.Conte } result, err := controllerutil.CreateOrPatch(ctx, h.GetClient(), secret, func() error { - // Set bootstrap script data - secret.StringData = map[string]string{ - PostgresBootstrapScript: PostgresBootStrapScriptContent, - PostgresBootstrapSQLScript: PostgresBootStrapSQLContent, + // Set bootstrap script data via Data (not StringData, which never + // round-trips through GET and would cause a spurious diff/update on + // every reconcile). + secret.Data = map[string][]byte{ + PostgresBootstrapScript: []byte(PostgresBootStrapScriptContent), + PostgresBootstrapSQLScript: []byte(PostgresBootStrapSQLContent), } return controllerutil.SetControllerReference(h.GetBeforeObject(), secret, h.GetScheme()) }) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index be044bf..ef32bd8 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -35,6 +35,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" apiv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" + lightspeedv1beta1 "github.com/openstack-k8s-operators/lightspeed-operator/api/v1beta1" // +kubebuilder:scaffold:imports ) @@ -83,6 +84,9 @@ var _ = BeforeSuite(func() { err = openshiftv1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = lightspeedv1beta1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + // +kubebuilder:scaffold:scheme k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})