diff --git a/controllers/k8sgpt_controller.go b/controllers/k8sgpt_controller.go index 8625235d..75e55b23 100644 --- a/controllers/k8sgpt_controller.go +++ b/controllers/k8sgpt_controller.go @@ -106,7 +106,7 @@ func (r *K8sGPTReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr if utils.ContainsString(k8sgptConfig.GetFinalizers(), FinalizerName) { // Delete any external resources associated with the instance - err := resources.Sync(ctx, r.Client, *k8sgptConfig, resources.Destroy) + err := resources.Sync(ctx, r.Client, *k8sgptConfig, resources.DestroyOp) if err != nil { k8sgptReconcileErrorCount.Inc() return r.finishReconcile(err, false) @@ -125,13 +125,14 @@ func (r *K8sGPTReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr deployment := v1.Deployment{} err = r.Get(ctx, client.ObjectKey{Namespace: k8sgptConfig.Namespace, Name: "k8sgpt-deployment"}, &deployment) + if client.IgnoreNotFound(err) != nil { + k8sgptReconcileErrorCount.Inc() + return r.finishReconcile(err, false) + } + err = resources.Sync(ctx, r.Client, *k8sgptConfig, resources.SyncOp) if err != nil { - - err = resources.Sync(ctx, r.Client, *k8sgptConfig, resources.Create) - if err != nil { - k8sgptReconcileErrorCount.Inc() - return r.finishReconcile(err, false) - } + k8sgptReconcileErrorCount.Inc() + return r.finishReconcile(err, false) } if deployment.Status.ReadyReplicas > 0 { diff --git a/go.mod b/go.mod index 8bfcea28..bd365a76 100644 --- a/go.mod +++ b/go.mod @@ -14,13 +14,14 @@ require ( k8s.io/cli-runtime v0.27.4 k8s.io/client-go v0.27.4 k8s.io/kubectl v0.27.4 + k8s.io/utils v0.0.0-20230313181309-38a27ef9d749 sigs.k8s.io/controller-runtime v0.15.0 ) require ( github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 // indirect github.com/google/pprof v0.0.0-20220608213341-c488b8fa1db3 // indirect - github.com/stretchr/testify v1.8.2 // indirect + github.com/stretchr/testify v1.8.2 golang.org/x/tools v0.9.3 // indirect ) @@ -68,6 +69,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.4.0 // indirect github.com/prometheus/common v0.42.0 // indirect github.com/prometheus/procfs v0.10.1 // indirect @@ -96,7 +98,6 @@ require ( k8s.io/component-base v0.27.4 // indirect k8s.io/klog/v2 v2.90.1 // indirect k8s.io/kube-openapi v0.0.0-20230501164219-8b0f38b5fd1f // indirect - k8s.io/utils v0.0.0-20230313181309-38a27ef9d749 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/kustomize/api v0.13.2 // indirect sigs.k8s.io/kustomize/kyaml v0.14.1 // indirect diff --git a/pkg/resources/k8sgpt.go b/pkg/resources/k8sgpt.go index 561bb41b..7425bbe5 100644 --- a/pkg/resources/k8sgpt.go +++ b/pkg/resources/k8sgpt.go @@ -21,29 +21,30 @@ import ( "github.com/k8sgpt-ai/k8sgpt-operator/api/v1alpha1" "github.com/k8sgpt-ai/k8sgpt-operator/pkg/utils" appsv1 "k8s.io/api/apps/v1" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" r1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" ) -// enum create or destroy -type CreateOrDestroy int +// SyncOrDestroy enum create or destroy +type SyncOrDestroy int const ( - Create CreateOrDestroy = iota - Destroy + SyncOp SyncOrDestroy = iota + DestroyOp DeploymentName = "k8sgpt-deployment" ) // GetService Create service for K8sGPT -func GetService(config v1alpha1.K8sGPT) (*v1.Service, error) { - +func GetService(config v1alpha1.K8sGPT) (*corev1.Service, error) { // Create service - service := v1.Service{ + service := corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Name: "k8sgpt", Namespace: config.Namespace, @@ -58,11 +59,11 @@ func GetService(config v1alpha1.K8sGPT) (*v1.Service, error) { }, }, }, - Spec: v1.ServiceSpec{ + Spec: corev1.ServiceSpec{ Selector: map[string]string{ "app": DeploymentName, }, - Ports: []v1.ServicePort{ + Ports: []corev1.ServicePort{ { Port: 8080, }, @@ -73,11 +74,10 @@ func GetService(config v1alpha1.K8sGPT) (*v1.Service, error) { return &service, nil } -// GetServiceAccount Create a Service Account for K8sGPT and bind it to K8sGPT role -func GetServiceAccount(config v1alpha1.K8sGPT) (*v1.ServiceAccount, error) { - +// GetServiceAccount Create Service Account for K8sGPT and bind it to K8sGPT role +func GetServiceAccount(config v1alpha1.K8sGPT) (*corev1.ServiceAccount, error) { // Create service account - serviceAccount := v1.ServiceAccount{ + serviceAccount := corev1.ServiceAccount{ ObjectMeta: metav1.ObjectMeta{ Name: "k8sgpt", Namespace: config.Namespace, @@ -189,23 +189,23 @@ func GetDeployment(config v1alpha1.K8sGPT) (*appsv1.Deployment, error) { "app": DeploymentName, }, }, - Template: v1.PodTemplateSpec{ + Template: corev1.PodTemplateSpec{ ObjectMeta: metav1.ObjectMeta{ Labels: map[string]string{ "app": DeploymentName, }, }, - Spec: v1.PodSpec{ + Spec: corev1.PodSpec{ ServiceAccountName: "k8sgpt", - Containers: []v1.Container{ + Containers: []corev1.Container{ { Name: "k8sgpt", - ImagePullPolicy: v1.PullAlways, + ImagePullPolicy: corev1.PullAlways, Image: "ghcr.io/k8sgpt-ai/k8sgpt:" + config.Spec.Version, Args: []string{ "serve", }, - Env: []v1.EnvVar{ + Env: []corev1.EnvVar{ { Name: "K8SGPT_MODEL", Value: config.Spec.AI.Model, @@ -223,22 +223,22 @@ func GetDeployment(config v1alpha1.K8sGPT) (*appsv1.Deployment, error) { Value: "/k8sgpt-data/.cache", }, }, - Ports: []v1.ContainerPort{ + Ports: []corev1.ContainerPort{ { ContainerPort: 8080, }, }, - Resources: v1.ResourceRequirements{ - Limits: v1.ResourceList{ - v1.ResourceCPU: resource.MustParse("1"), - v1.ResourceMemory: resource.MustParse("512Mi"), + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("1"), + corev1.ResourceMemory: resource.MustParse("512Mi"), }, - Requests: v1.ResourceList{ - v1.ResourceCPU: resource.MustParse("0.2"), - v1.ResourceMemory: resource.MustParse("156Mi"), + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("0.2"), + corev1.ResourceMemory: resource.MustParse("156Mi"), }, }, - VolumeMounts: []v1.VolumeMount{ + VolumeMounts: []corev1.VolumeMount{ { MountPath: "/k8sgpt-data", Name: "k8sgpt-vol", @@ -246,9 +246,9 @@ func GetDeployment(config v1alpha1.K8sGPT) (*appsv1.Deployment, error) { }, }, }, - Volumes: []v1.Volume{ + Volumes: []corev1.Volume{ { - VolumeSource: v1.VolumeSource{EmptyDir: &v1.EmptyDirVolumeSource{}}, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, Name: "k8sgpt-vol", }, }, @@ -257,11 +257,11 @@ func GetDeployment(config v1alpha1.K8sGPT) (*appsv1.Deployment, error) { }, } if config.Spec.AI.Secret != nil { - password := v1.EnvVar{ + password := corev1.EnvVar{ Name: "K8SGPT_PASSWORD", - ValueFrom: &v1.EnvVarSource{ - SecretKeyRef: &v1.SecretKeySelector{ - LocalObjectReference: v1.LocalObjectReference{ + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: &corev1.SecretKeySelector{ + LocalObjectReference: corev1.LocalObjectReference{ Name: config.Spec.AI.Secret.Name, }, Key: config.Spec.AI.Secret.Key, @@ -273,7 +273,7 @@ func GetDeployment(config v1alpha1.K8sGPT) (*appsv1.Deployment, error) { ) } if config.Spec.AI.BaseUrl != "" { - baseUrl := v1.EnvVar{ + baseUrl := corev1.EnvVar{ Name: "K8SGPT_BASEURL", Value: config.Spec.AI.BaseUrl, } @@ -283,7 +283,7 @@ func GetDeployment(config v1alpha1.K8sGPT) (*appsv1.Deployment, error) { } // Engine is required only when azureopenai is the ai backend if config.Spec.AI.Engine != "" && config.Spec.AI.Backend == v1alpha1.AzureOpenAI { - engine := v1.EnvVar{ + engine := corev1.EnvVar{ Name: "K8SGPT_ENGINE", Value: config.Spec.AI.Engine, } @@ -297,7 +297,7 @@ func GetDeployment(config v1alpha1.K8sGPT) (*appsv1.Deployment, error) { } func Sync(ctx context.Context, c client.Client, - config v1alpha1.K8sGPT, i CreateOrDestroy) error { + config v1alpha1.K8sGPT, i SyncOrDestroy) error { var objs []client.Object @@ -339,12 +339,12 @@ func Sync(ctx context.Context, c client.Client, // for each object, create or destroy for _, obj := range objs { switch i { - case Create: + case SyncOp: - // before creation we will check to see if the secret exists if used as a ref + // before creation, we will check to see if the secret exists if used as a ref if config.Spec.AI.Secret != nil { - secret := &v1.Secret{} + secret := &corev1.Secret{} er := c.Get(ctx, types.NamespacedName{Name: config.Spec.AI.Secret.Name, Namespace: config.Namespace}, secret) if er != nil { @@ -352,14 +352,14 @@ func Sync(ctx context.Context, c client.Client, } } - err := c.Create(ctx, obj) + err := doSync(ctx, c, obj) if err != nil { // If the object already exists, ignore the error if !errors.IsAlreadyExists(err) { return err } } - case Destroy: + case DestroyOp: err := c.Delete(ctx, obj) if err != nil { // if the object is not found, ignore the error @@ -372,3 +372,37 @@ func Sync(ctx context.Context, c client.Client, return nil } + +func doSync(ctx context.Context, clt client.Client, obj client.Object) error { + var mutateFn controllerutil.MutateFn + switch expect := obj.(type) { + case *appsv1.Deployment: + exist := &appsv1.Deployment{} + err := clt.Get(context.Background(), client.ObjectKeyFromObject(obj), exist) + if err != nil && !errors.IsNotFound(err) { + return err + } else if err == nil { + mutateFn = func() error { + exist.Spec = expect.Spec + return nil + } + obj = exist + } + case *corev1.Service: + exist := &corev1.Service{} + err := clt.Get(context.Background(), client.ObjectKeyFromObject(obj), exist) + if err != nil && !errors.IsNotFound(err) { + return err + } else if err == nil { + mutateFn = func() error { + exist.Spec = expect.Spec + return nil + } + obj = exist + } + } + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + _, err := controllerutil.CreateOrPatch(ctx, clt, obj, mutateFn) + return err + }) +} diff --git a/pkg/resources/k8sgpt_test.go b/pkg/resources/k8sgpt_test.go new file mode 100644 index 00000000..205c1ebe --- /dev/null +++ b/pkg/resources/k8sgpt_test.go @@ -0,0 +1,117 @@ +package resources + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/pointer" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +func Test_DeploymentShouldBeSynced(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, appsv1.AddToScheme(scheme)) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + ctx := context.Background() + + // + // create deployment + // + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-k8sgpt", + }, + Spec: appsv1.DeploymentSpec{ + Replicas: pointer.Int32(1), + Template: v1.PodTemplateSpec{ + Spec: v1.PodSpec{ + Containers: []v1.Container{ + { + Name: "k8sgpt", + Image: "ghcr.io/k8sgpt-ai/k8sgpt:v0.3.8", + }, + }, + }, + }, + }, + } + + // test + err := doSync(ctx, fakeClient, deployment) + require.NoError(t, err) + + existDeployment := &appsv1.Deployment{} + err = fakeClient.Get(ctx, client.ObjectKeyFromObject(deployment), existDeployment) + + // verify + require.NoError(t, err) + assert.NotNil(t, existDeployment) + + // + // patch deployment + // + deploymentUpdated := deployment.DeepCopy() + updatedImage := "ghcr.io/k8sgpt-ai/k8sgpt:latest" + deploymentUpdated.Spec.Template.Spec.Containers[0].Image = updatedImage + + // test + err = doSync(ctx, fakeClient, deploymentUpdated) + require.NoError(t, err) + err = fakeClient.Get(ctx, client.ObjectKeyFromObject(deployment), existDeployment) + require.NoError(t, err) + + // verify + assert.NotNil(t, existDeployment) + assert.Equal(t, updatedImage, existDeployment.Spec.Template.Spec.Containers[0].Image) +} + +func Test_ServiceAccountShouldNotBeSynced(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, v1.AddToScheme(scheme)) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() + ctx := context.Background() + + // + // create ServiceAccount + // + serviceAccount := &v1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "k8sgpt", + }, + AutomountServiceAccountToken: pointer.Bool(true), + } + + // test + err := doSync(ctx, fakeClient, serviceAccount) + require.NoError(t, err) + + existSA := &v1.ServiceAccount{} + err = fakeClient.Get(ctx, client.ObjectKeyFromObject(serviceAccount), existSA) + + // verify + require.NoError(t, err) + assert.NotNil(t, existSA) + + // + // patch ServiceAccount + // + saUpdated := existSA.DeepCopy() + saUpdated.AutomountServiceAccountToken = nil + + // test + err = doSync(ctx, fakeClient, saUpdated) + require.NoError(t, err) + err = fakeClient.Get(ctx, client.ObjectKeyFromObject(saUpdated), existSA) + require.NoError(t, err) + + // verify + assert.NotNil(t, existSA) + assert.NotNil(t, existSA.AutomountServiceAccountToken) +}