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