diff --git a/api/v1alpha1/spinappexecutor_types.go b/api/v1alpha1/spinappexecutor_types.go index 2953145a..d27f2983 100644 --- a/api/v1alpha1/spinappexecutor_types.go +++ b/api/v1alpha1/spinappexecutor_types.go @@ -36,8 +36,15 @@ type SpinAppExecutorSpec struct { type ExecutorDeploymentConfig struct { // RuntimeClassName is the runtime class name that should be used by pods created - // as part of a deployment. - RuntimeClassName string `json:"runtimeClassName"` + // as part of a deployment. This should only be defined when SpintainerImage is not defined. + RuntimeClassName *string `json:"runtimeClassName,omitempty"` + + // SpinImage points to an image that will run Spin in a container to execute + // your SpinApp. This is an alternative to using the shim to execute your + // SpinApp. This should only be defined when RuntimeClassName is not + // defined. When specified, application images must be available without + // authentication. + SpinImage *string `json:"spinImage,omitempty"` // CACertSecret specifies the name of the secret containing the CA // certificates to be mounted to the deployment. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 41201234..93d3663c 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -29,6 +29,16 @@ import ( // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ExecutorDeploymentConfig) DeepCopyInto(out *ExecutorDeploymentConfig) { *out = *in + if in.RuntimeClassName != nil { + in, out := &in.RuntimeClassName, &out.RuntimeClassName + *out = new(string) + **out = **in + } + if in.SpinImage != nil { + in, out := &in.SpinImage, &out.SpinImage + *out = new(string) + **out = **in + } if in.Otel != nil { in, out := &in.Otel, &out.Otel *out = new(OtelConfig) diff --git a/config/crd/bases/core.spinoperator.dev_spinappexecutors.yaml b/config/crd/bases/core.spinoperator.dev_spinappexecutors.yaml index c5451298..e6b1d180 100644 --- a/config/crd/bases/core.spinoperator.dev_spinappexecutors.yaml +++ b/config/crd/bases/core.spinoperator.dev_spinappexecutors.yaml @@ -84,10 +84,16 @@ spec: runtimeClassName: description: |- RuntimeClassName is the runtime class name that should be used by pods created - as part of a deployment. + as part of a deployment. This should only be defined when SpintainerImage is not defined. + type: string + spinImage: + description: |- + SpinImage points to an image that will run Spin in a container to execute + your SpinApp. This is an alternative to using the shim to execute your + SpinApp. This should only be defined when RuntimeClassName is not + defined. When specified, application images must be available without + authentication. type: string - required: - - runtimeClassName type: object required: - createDeployment diff --git a/config/samples/spintainer-executor.yaml b/config/samples/spintainer-executor.yaml new file mode 100644 index 00000000..0d12872e --- /dev/null +++ b/config/samples/spintainer-executor.yaml @@ -0,0 +1,9 @@ +apiVersion: core.spinoperator.dev/v1alpha1 +kind: SpinAppExecutor +metadata: + name: spintainer +spec: + createDeployment: true + deploymentConfig: + installDefaultCACerts: true + spinImage: ghcr.io/fermyon/spin:v2.7.0 diff --git a/config/samples/spintainer.yaml b/config/samples/spintainer.yaml new file mode 100644 index 00000000..c3e6b310 --- /dev/null +++ b/config/samples/spintainer.yaml @@ -0,0 +1,8 @@ +apiVersion: core.spinoperator.dev/v1alpha1 +kind: SpinApp +metadata: + name: spintainer-spinapp +spec: + image: "ghcr.io/spinkube/spin-operator/hello-world:20240708-130250-gfefd2b1" + replicas: 1 + executor: spintainer diff --git a/e2e/crd_installed_test.go b/e2e/crd_installed_test.go index 7a86e5e3..d8665b3d 100644 --- a/e2e/crd_installed_test.go +++ b/e2e/crd_installed_test.go @@ -14,7 +14,7 @@ func TestCRDInstalled(t *testing.T) { Assess("spinapp crd installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { client := cfg.Client() if err := apiextensionsV1.AddToScheme(client.Resources().GetScheme()); err != nil { - t.Fatalf("failed to register the v1 API extension types with Kuberenets scheme: %s", err) + t.Fatalf("failed to register the v1 API extension types with Kubernetes scheme: %s", err) } name := "spinapps.core.spinoperator.dev" var crd apiextensionsV1.CustomResourceDefinition @@ -31,7 +31,7 @@ func TestCRDInstalled(t *testing.T) { Assess("spinappexecutor crd installed", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { client := cfg.Client() if err := apiextensionsV1.AddToScheme(client.Resources().GetScheme()); err != nil { - t.Fatalf("failed to register the v1 API extension types with Kuberenets scheme: %s", err) + t.Fatalf("failed to register the v1 API extension types with Kubernetes scheme: %s", err) } name := "spinappexecutors.core.spinoperator.dev" diff --git a/e2e/default_test.go b/e2e/default_test.go index 354774a1..d586fed3 100644 --- a/e2e/default_test.go +++ b/e2e/default_test.go @@ -30,7 +30,7 @@ func TestDefaultSetup(t *testing.T) { Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { client = cfg.Client() - testSpinApp := newSpinAppCR(testSpinAppName, helloWorldImage) + testSpinApp := newSpinAppCR(testSpinAppName, helloWorldImage, "containerd-shim-spin") if err := client.Resources().Create(ctx, testSpinApp); err != nil { t.Fatalf("Failed to create spinapp: %s", err) } @@ -69,7 +69,7 @@ func TestDefaultSetup(t *testing.T) { testEnv.Test(t, defaultTest) } -func newSpinAppCR(name, image string) *spinapps_v1alpha1.SpinApp { +func newSpinAppCR(name, image, executor string) *spinapps_v1alpha1.SpinApp { return &spinapps_v1alpha1.SpinApp{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -78,7 +78,7 @@ func newSpinAppCR(name, image string) *spinapps_v1alpha1.SpinApp { Spec: spinapps_v1alpha1.SpinAppSpec{ Replicas: 1, Image: image, - Executor: "containerd-shim-spin", + Executor: executor, }, } } diff --git a/e2e/main_test.go b/e2e/main_test.go index 9b7ad867..f9a07f4f 100644 --- a/e2e/main_test.go +++ b/e2e/main_test.go @@ -144,7 +144,7 @@ func newContainerdShimExecutor(namespace string) *spinapps_v1alpha1.SpinAppExecu Spec: spinapps_v1alpha1.SpinAppExecutorSpec{ CreateDeployment: true, DeploymentConfig: &spinapps_v1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: runtimeClassName, + RuntimeClassName: &runtimeClassName, InstallDefaultCACerts: true, CACertSecret: testCACertSecret, }, diff --git a/e2e/spintainer_test.go b/e2e/spintainer_test.go new file mode 100644 index 00000000..720c1a33 --- /dev/null +++ b/e2e/spintainer_test.go @@ -0,0 +1,107 @@ +package e2e + +import ( + "context" + "testing" + "time" + + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/e2e-framework/klient" + "sigs.k8s.io/e2e-framework/klient/k8s" + "sigs.k8s.io/e2e-framework/klient/wait" + "sigs.k8s.io/e2e-framework/klient/wait/conditions" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + spinapps_v1alpha1 "github.com/spinkube/spin-operator/api/v1alpha1" + "github.com/spinkube/spin-operator/internal/generics" +) + +// TestSpintainer is a test that checks that the minimal setup works +// with the spintainer executor +func TestSpintainer(t *testing.T) { + var client klient.Client + + helloWorldImage := "ghcr.io/spinkube/spin-operator/hello-world:20240708-130250-gfefd2b1" + testSpinAppName := "test-spintainer-app" + + defaultTest := features.New("default and most minimal setup"). + Setup(func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + + client = cfg.Client() + + if err := spinapps_v1alpha1.AddToScheme(client.Resources(testNamespace).GetScheme()); err != nil { + t.Fatalf("failed to register the spinapps_v1alpha1 types with Kubernetes scheme: %s", err) + } + + return ctx + }). + Assess("spin app custom resource is created", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + testSpinApp := newSpinAppCR(testSpinAppName, helloWorldImage, "spintainer") + + if err := client.Resources().Create(ctx, newSpintainerExecutor(testNamespace)); err != nil { + t.Fatalf("Failed to create spinappexecutor: %s", err) + } + + if err := client.Resources().Create(ctx, testSpinApp); err != nil { + t.Fatalf("Failed to create spinapp: %s", err) + } + // wait for spinapp to be created + if err := wait.For( + conditions.New(client.Resources()).ResourceMatch(testSpinApp, func(object k8s.Object) bool { + return true + }), + wait.WithTimeout(3*time.Minute), + wait.WithInterval(30*time.Second), + ); err != nil { + t.Fatal(err) + } + + return ctx + }). + Assess("spin app deployment and service are available", func(ctx context.Context, t *testing.T, cfg *envconf.Config) context.Context { + // wait for deployment to be ready + if err := wait.For( + conditions.New(client.Resources()).DeploymentAvailable(testSpinAppName, testNamespace), + wait.WithTimeout(3*time.Minute), + wait.WithInterval(30*time.Second), + ); err != nil { + t.Fatal(err) + } + + svc := &v1.ServiceList{ + Items: []v1.Service{ + {ObjectMeta: metav1.ObjectMeta{Name: testSpinAppName, Namespace: testNamespace}}, + }, + } + + if err := wait.For( + conditions.New(client.Resources()).ResourcesFound(svc), + wait.WithTimeout(3*time.Minute), + wait.WithInterval(30*time.Second), + ); err != nil { + t.Fatal(err) + } + return ctx + }). + Feature() + testEnv.Test(t, defaultTest) +} + +func newSpintainerExecutor(namespace string) *spinapps_v1alpha1.SpinAppExecutor { + var testSpinAppExecutor = &spinapps_v1alpha1.SpinAppExecutor{ + ObjectMeta: metav1.ObjectMeta{ + Name: "spintainer", + Namespace: namespace, + }, + Spec: spinapps_v1alpha1.SpinAppExecutorSpec{ + CreateDeployment: true, + DeploymentConfig: &spinapps_v1alpha1.ExecutorDeploymentConfig{ + SpinImage: generics.Ptr("ghcr.io/fermyon/spin:v2.7.0"), + }, + }, + } + + return testSpinAppExecutor +} diff --git a/internal/controller/deployment.go b/internal/controller/deployment.go index 7ea337b7..93b21e9d 100644 --- a/internal/controller/deployment.go +++ b/internal/controller/deployment.go @@ -20,7 +20,7 @@ func constructRuntimeConfigSecretMount(_ctx context.Context, secretName string) VolumeSource: corev1.VolumeSource{ Secret: &corev1.SecretVolumeSource{ SecretName: secretName, - Optional: ptr(true), + Optional: generics.Ptr(true), Items: []corev1.KeyToPath{ { Key: "runtime-config.toml", @@ -46,7 +46,7 @@ func constructCASecretMount(_ context.Context, caSecretName string) (corev1.Volu VolumeSource: corev1.VolumeSource{ Secret: &corev1.SecretVolumeSource{ SecretName: caSecretName, - Optional: ptr(true), + Optional: generics.Ptr(true), Items: []corev1.KeyToPath{{ Key: "ca-certificates.crt", Path: "ca-certificates.crt", diff --git a/internal/controller/deployment_test.go b/internal/controller/deployment_test.go index 1ee0adc1..72780c78 100644 --- a/internal/controller/deployment_test.go +++ b/internal/controller/deployment_test.go @@ -13,6 +13,7 @@ import ( clientgoscheme "k8s.io/client-go/kubernetes/scheme" spinv1alpha1 "github.com/spinkube/spin-operator/api/v1alpha1" + "github.com/spinkube/spin-operator/internal/generics" "github.com/spinkube/spin-operator/pkg/spinapp" ) @@ -280,7 +281,16 @@ func TestSpinHealthCheckToCoreProbe(t *testing.T) { func TestDeploymentLabel(t *testing.T) { scheme := registerAndGetScheme() app := minimalSpinApp() - deployment, err := constructDeployment(context.Background(), app, &spinv1alpha1.ExecutorDeploymentConfig{}, "", "", scheme) + deployment, err := constructDeployment( + context.Background(), + app, + &spinv1alpha1.ExecutorDeploymentConfig{ + RuntimeClassName: generics.Ptr("containerd-shim-spin"), + }, + "", + "", + scheme, + ) require.Nil(t, err) require.NotNil(t, deployment.ObjectMeta.Labels) diff --git a/internal/controller/spinapp_controller.go b/internal/controller/spinapp_controller.go index 4983c18b..4930fd85 100644 --- a/internal/controller/spinapp_controller.go +++ b/internal/controller/spinapp_controller.go @@ -18,6 +18,7 @@ package controller import ( "context" + "errors" "fmt" "hash/adler32" "maps" @@ -37,6 +38,7 @@ import ( spinv1alpha1 "github.com/spinkube/spin-operator/api/v1alpha1" "github.com/spinkube/spin-operator/internal/cacerts" + "github.com/spinkube/spin-operator/internal/generics" "github.com/spinkube/spin-operator/internal/logging" "github.com/spinkube/spin-operator/internal/runtimeconfig" "github.com/spinkube/spin-operator/pkg/spinapp" @@ -326,7 +328,7 @@ func (r *SpinAppReconciler) reconcileDeployment(ctx context.Context, app *spinv1 // We want to use server-side apply https://kubernetes.io/docs/reference/using-api/server-side-apply patchMethod := client.Apply patchOptions := &client.PatchOptions{ - Force: ptr(true), // Force b/c any fields we are setting need to be owned by the spin-operator + Force: generics.Ptr(true), // Force b/c any fields we are setting need to be owned by the spin-operator FieldManager: FieldManager, } @@ -354,7 +356,7 @@ func (r *SpinAppReconciler) reconcileService(ctx context.Context, app *spinv1alp // We want to use server-side apply https://kubernetes.io/docs/reference/using-api/server-side-apply patchMethod := client.Apply patchOptions := &client.PatchOptions{ - Force: ptr(true), // Force b/c any fields we are setting need to be owned by the spin-operator + Force: generics.Ptr(true), // Force b/c any fields we are setting need to be owned by the spin-operator FieldManager: FieldManager, } // Note that we reconcile even if the service is in a good state. We rely on controller-runtime to rate limit us. @@ -390,7 +392,7 @@ func constructDeployment(ctx context.Context, app *spinv1alpha1.SpinApp, config if app.Spec.EnableAutoscaling { replicas = nil } else { - replicas = ptr(app.Spec.Replicas) + replicas = generics.Ptr(app.Spec.Replicas) } volumes, volumeMounts, err := ConstructVolumeMountsForApp(ctx, app, generatedRuntimeConfigSecretName, caSecretName) @@ -435,6 +437,41 @@ func constructDeployment(ctx context.Context, app *spinv1alpha1.SpinApp, config labels := constructAppLabels(app) + var container corev1.Container + if config.RuntimeClassName != nil { + container = corev1.Container{ + Name: app.Name, + Image: app.Spec.Image, + Command: []string{"/"}, + Ports: []corev1.ContainerPort{{ + Name: spinapp.HTTPPortName, + ContainerPort: spinapp.DefaultHTTPPort, + }}, + Env: env, + VolumeMounts: volumeMounts, + Resources: resources, + LivenessProbe: livenessProbe, + ReadinessProbe: readinessProbe, + } + } else if config.SpinImage != nil { + container = corev1.Container{ + Name: app.Name, + Image: *config.SpinImage, + Args: []string{"up", "--listen", fmt.Sprintf("0.0.0.0:%d", spinapp.DefaultHTTPPort), "-f", app.Spec.Image}, + Ports: []corev1.ContainerPort{{ + Name: spinapp.HTTPPortName, + ContainerPort: spinapp.DefaultHTTPPort, + }}, + Env: env, + VolumeMounts: volumeMounts, + Resources: resources, + LivenessProbe: livenessProbe, + ReadinessProbe: readinessProbe, + } + } else { + return nil, errors.New("must specify either runtimeClassName or spinImage") + } + dep := &appsv1.Deployment{ TypeMeta: metav1.TypeMeta{ Kind: "Deployment", @@ -457,23 +494,8 @@ func constructDeployment(ctx context.Context, app *spinv1alpha1.SpinApp, config Annotations: templateAnnotations, }, Spec: corev1.PodSpec{ - RuntimeClassName: &config.RuntimeClassName, - Containers: []corev1.Container{ - { - Name: app.Name, - Image: app.Spec.Image, - Command: []string{"/"}, - Ports: []corev1.ContainerPort{{ - Name: spinapp.HTTPPortName, - ContainerPort: spinapp.DefaultHTTPPort, - }}, - Env: env, - VolumeMounts: volumeMounts, - Resources: resources, - LivenessProbe: livenessProbe, - ReadinessProbe: readinessProbe, - }, - }, + RuntimeClassName: config.RuntimeClassName, + Containers: []corev1.Container{container}, ImagePullSecrets: app.Spec.ImagePullSecrets, Volumes: volumes, }, @@ -503,7 +525,3 @@ func (r *SpinAppReconciler) findDeploymentForApp(ctx context.Context, app *spinv } return &deployment, nil } - -func ptr[T any](v T) *T { - return &v -} diff --git a/internal/controller/spinapp_controller_test.go b/internal/controller/spinapp_controller_test.go index cab0f356..23bc7e6c 100644 --- a/internal/controller/spinapp_controller_test.go +++ b/internal/controller/spinapp_controller_test.go @@ -11,6 +11,7 @@ import ( "time" spinv1alpha1 "github.com/spinkube/spin-operator/api/v1alpha1" + "github.com/spinkube/spin-operator/internal/generics" "github.com/stretchr/testify/require" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" @@ -149,7 +150,7 @@ func TestReconcile_Integration_Deployment_Respects_Executor_Config(t *testing.T) Spec: spinv1alpha1.SpinAppExecutorSpec{ CreateDeployment: true, DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "a-runtime-class", + RuntimeClassName: generics.Ptr("a-runtime-class"), }, }, } @@ -212,7 +213,7 @@ func TestReconcile_Integration_RuntimeConfig(t *testing.T) { Spec: spinv1alpha1.SpinAppExecutorSpec{ CreateDeployment: true, DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "a-runtime-class", + RuntimeClassName: generics.Ptr("a-runtime-class"), }, }, } @@ -311,7 +312,7 @@ func TestReconcile_Integration_RuntimeConfig_SecretAlreadyExists(t *testing.T) { Spec: spinv1alpha1.SpinAppExecutorSpec{ CreateDeployment: true, DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "a-runtime-class", + RuntimeClassName: generics.Ptr("a-runtime-class"), }, }, } @@ -401,16 +402,16 @@ func TestConstructDeployment_MinimalApp(t *testing.T) { app := minimalSpinApp() cfg := &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "bananarama", + RuntimeClassName: generics.Ptr("bananarama"), } deployment, err := constructDeployment(context.Background(), app, cfg, "", "", nil) require.NoError(t, err) require.NotNil(t, deployment) - require.Equal(t, ptr(int32(1)), deployment.Spec.Replicas) + require.Equal(t, generics.Ptr(int32(1)), deployment.Spec.Replicas) require.Len(t, deployment.Spec.Template.Spec.Containers, 1) require.Equal(t, app.Spec.Image, deployment.Spec.Template.Spec.Containers[0].Image) - require.Equal(t, ptr("bananarama"), deployment.Spec.Template.Spec.RuntimeClassName) + require.Equal(t, generics.Ptr("bananarama"), deployment.Spec.Template.Spec.RuntimeClassName) } func TestConstructDeployment_WithPodLabels(t *testing.T) { @@ -422,13 +423,13 @@ func TestConstructDeployment_WithPodLabels(t *testing.T) { }) cfg := &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "bananarama", + RuntimeClassName: generics.Ptr("bananarama"), } deployment, err := constructDeployment(context.Background(), app, cfg, "", "", nil) require.NoError(t, err) require.NotNil(t, deployment) - require.Equal(t, ptr(int32(1)), deployment.Spec.Replicas) + require.Equal(t, generics.Ptr(int32(1)), deployment.Spec.Replicas) require.Len(t, deployment.Spec.Template.Labels, 3) require.Equal(t, deployment.Spec.Template.Labels[key], value) } @@ -457,7 +458,7 @@ func TestReconcile_Integration_AnnotationAndLabelPropagataion(t *testing.T) { Spec: spinv1alpha1.SpinAppExecutorSpec{ CreateDeployment: true, DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "a-runtime-class", + RuntimeClassName: generics.Ptr("a-runtime-class"), }, }, } @@ -537,7 +538,7 @@ func TestReconcile_Integration_Deployment_SpinCAInjection(t *testing.T) { Spec: spinv1alpha1.SpinAppExecutorSpec{ CreateDeployment: true, DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "foobar", + RuntimeClassName: generics.Ptr("foobar"), InstallDefaultCACerts: true, }, }, @@ -553,7 +554,7 @@ func TestReconcile_Integration_Deployment_SpinCAInjection(t *testing.T) { Spec: spinv1alpha1.SpinAppExecutorSpec{ CreateDeployment: true, DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "foobar", + RuntimeClassName: generics.Ptr("foobar"), CACertSecret: "my-custom-secret-name", InstallDefaultCACerts: true, }, diff --git a/internal/controller/spinappexecutor_controller_test.go b/internal/controller/spinappexecutor_controller_test.go index 1f514946..f5e5b2b7 100644 --- a/internal/controller/spinappexecutor_controller_test.go +++ b/internal/controller/spinappexecutor_controller_test.go @@ -23,6 +23,7 @@ import ( "time" spinv1alpha1 "github.com/spinkube/spin-operator/api/v1alpha1" + "github.com/spinkube/spin-operator/internal/generics" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -134,7 +135,7 @@ func testContainerdShimSpinExecutor() *spinv1alpha1.SpinAppExecutor { Spec: spinv1alpha1.SpinAppExecutorSpec{ CreateDeployment: true, DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ - RuntimeClassName: "test-runtime", + RuntimeClassName: generics.Ptr("test-runtime"), }, }, } diff --git a/internal/generics/generics.go b/internal/generics/generics.go index bca5fc71..1f242bdf 100644 --- a/internal/generics/generics.go +++ b/internal/generics/generics.go @@ -23,3 +23,7 @@ func AssociateBy[A ~[]X, X any, Y comparable](input A, assocBy func(X) Y) map[Y] } return result } + +func Ptr[T any](v T) *T { + return &v +} diff --git a/internal/webhook/spinappexecutor_validating.go b/internal/webhook/spinappexecutor_validating.go index 2d52347a..a5069480 100644 --- a/internal/webhook/spinappexecutor_validating.go +++ b/internal/webhook/spinappexecutor_validating.go @@ -5,7 +5,10 @@ import ( spinv1alpha1 "github.com/spinkube/spin-operator/api/v1alpha1" "github.com/spinkube/spin-operator/internal/logging" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/webhook/admission" @@ -50,5 +53,34 @@ func (v *SpinAppExecutorValidator) ValidateDelete(ctx context.Context, obj runti } func (v *SpinAppExecutorValidator) validateSpinAppExecutor(executor *spinv1alpha1.SpinAppExecutor) error { + var allErrs field.ErrorList + + if err := validateRuntimeClassAndSpinImage(&executor.Spec); err != nil { + allErrs = append(allErrs, err) + } + if len(allErrs) == 0 { + return nil + } + + return apierrors.NewInvalid( + schema.GroupKind{Group: "core.spinoperator.dev", Kind: "SpinAppExecutor"}, + executor.Name, allErrs) +} + +func validateRuntimeClassAndSpinImage(spec *spinv1alpha1.SpinAppExecutorSpec) *field.Error { + if spec.DeploymentConfig == nil { + return nil + } + + if spec.DeploymentConfig.RuntimeClassName != nil && spec.DeploymentConfig.SpinImage != nil { + return field.Invalid(field.NewPath("spec").Child("deploymentConfig").Child("runtimeClassName"), spec.DeploymentConfig.RuntimeClassName, + "runtimeClassName and spinImage are mutually exclusive") + } + + if spec.DeploymentConfig.RuntimeClassName == nil && spec.DeploymentConfig.SpinImage == nil { + return field.Invalid(field.NewPath("spec").Child("deploymentConfig").Child("runtimeClassName"), spec.DeploymentConfig.RuntimeClassName, + "either runtimeClassName or spinImage must be set") + } + return nil } diff --git a/internal/webhook/spinappexecutor_validating_test.go b/internal/webhook/spinappexecutor_validating_test.go index 28bed6e9..f637d420 100644 --- a/internal/webhook/spinappexecutor_validating_test.go +++ b/internal/webhook/spinappexecutor_validating_test.go @@ -1,3 +1,49 @@ package webhook -// Currently the validating webhook is a no-op so nothing to test +import ( + "testing" + + spinv1alpha1 "github.com/spinkube/spin-operator/api/v1alpha1" + "github.com/spinkube/spin-operator/internal/generics" + "github.com/stretchr/testify/require" +) + +func TestValidateRuntimeClassAndSpinImage(t *testing.T) { + t.Parallel() + + fldErr := validateRuntimeClassAndSpinImage(&spinv1alpha1.SpinAppExecutorSpec{ + CreateDeployment: true, + DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ + RuntimeClassName: generics.Ptr("foo"), + SpinImage: generics.Ptr("bar"), + }, + }) + require.EqualError(t, fldErr, "spec.deploymentConfig.runtimeClassName: Invalid value: \"foo\": runtimeClassName and spinImage are mutually exclusive") + + fldErr = validateRuntimeClassAndSpinImage(&spinv1alpha1.SpinAppExecutorSpec{ + CreateDeployment: true, + DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ + RuntimeClassName: generics.Ptr("foo"), + SpinImage: nil, + }, + }) + require.Nil(t, fldErr) + + fldErr = validateRuntimeClassAndSpinImage(&spinv1alpha1.SpinAppExecutorSpec{ + CreateDeployment: true, + DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ + RuntimeClassName: nil, + SpinImage: generics.Ptr("bar"), + }, + }) + require.Nil(t, fldErr) + + fldErr = validateRuntimeClassAndSpinImage(&spinv1alpha1.SpinAppExecutorSpec{ + CreateDeployment: true, + DeploymentConfig: &spinv1alpha1.ExecutorDeploymentConfig{ + RuntimeClassName: nil, + SpinImage: nil, + }, + }) + require.EqualError(t, fldErr, "spec.deploymentConfig.runtimeClassName: Invalid value: \"null\": either runtimeClassName or spinImage must be set") +}