diff --git a/cmd/mapt/cmd/aws/services/snc.go b/cmd/mapt/cmd/aws/services/snc.go index 5cbd4b254..cbf1b82a2 100644 --- a/cmd/mapt/cmd/aws/services/snc.go +++ b/cmd/mapt/cmd/aws/services/snc.go @@ -5,6 +5,7 @@ import ( maptContext "github.com/redhat-developer/mapt/pkg/manager/context" openshiftsnc "github.com/redhat-developer/mapt/pkg/provider/aws/action/snc" sncApi "github.com/redhat-developer/mapt/pkg/target/service/snc" + "github.com/redhat-developer/mapt/pkg/target/service/snc/profile" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -24,7 +25,7 @@ const ( disableClusterReadinessDesc = "If this flag is set it will skip the checks for the cluster readiness. In this case the kubeconfig can not be generated" sncProfile = "profile" - sncProfileDesc = "comma separated list of profiles to apply on the SNC cluster. Profiles available: virtualization" + sncProfileDesc = "comma separated list of profiles to apply on the SNC cluster. Profiles available: virtualization, serverless-serving, serverless-eventing, serverless" ) func GetOpenshiftSNCCmd() *cobra.Command { @@ -59,7 +60,7 @@ func createSNC() *cobra.Command { } profiles := viper.GetStringSlice(sncProfile) computeReq := params.ComputeRequestArgs() - if sncApi.ProfilesRequireNestedVirt(profiles) { + if profile.RequireNestedVirt(profiles) { computeReq.NestedVirt = true } if _, err := openshiftsnc.Create( diff --git a/docs/aws/openshift-snc.md b/docs/aws/openshift-snc.md index c49b97f5a..a32efcac6 100644 --- a/docs/aws/openshift-snc.md +++ b/docs/aws/openshift-snc.md @@ -62,13 +62,16 @@ mapt aws openshift-snc create \ --profile virtualization ``` -Multiple profiles can be specified as a comma-separated list (e.g., `--profile virtualization,serverless`). +Multiple profiles can be specified as a comma-separated list (e.g., `--profile virtualization,serverless-serving`). ### Available profiles | Profile | Description | |---------|-------------| | `virtualization` | Installs [OpenShift Virtualization](https://docs.openshift.com/container-platform/latest/virt/about_virt/about-virt.html) (CNV) on the cluster, enabling virtual machines to run on the single-node cluster. When this profile is selected, nested virtualization is automatically enabled on the cloud instance. Because standard Nitro-based instances do not expose `/dev/kvm`, a bare metal instance is required.| +| `serverless-serving` | Installs [OpenShift Serverless](https://docs.openshift.com/serverless/latest/about/about-serverless.html) and creates a KnativeServing instance, enabling serverless workloads (Knative Serving) on the cluster.| +| `serverless-eventing` | Installs [OpenShift Serverless](https://docs.openshift.com/serverless/latest/about/about-serverless.html) and creates a KnativeEventing instance, enabling event-driven workloads (Knative Eventing) on the cluster.| +| `serverless` | Installs [OpenShift Serverless](https://docs.openshift.com/serverless/latest/about/about-serverless.html) and creates both KnativeServing and KnativeEventing instances.| ### Adding new profiles diff --git a/pkg/provider/aws/action/snc/snc.go b/pkg/provider/aws/action/snc/snc.go index 10aba5c62..b68e76d01 100644 --- a/pkg/provider/aws/action/snc/snc.go +++ b/pkg/provider/aws/action/snc/snc.go @@ -28,6 +28,7 @@ import ( "github.com/redhat-developer/mapt/pkg/provider/util/command" "github.com/redhat-developer/mapt/pkg/provider/util/security" apiSNC "github.com/redhat-developer/mapt/pkg/target/service/snc" + "github.com/redhat-developer/mapt/pkg/target/service/snc/profile" "github.com/redhat-developer/mapt/pkg/util" "github.com/redhat-developer/mapt/pkg/util/logging" resourcesUtil "github.com/redhat-developer/mapt/pkg/util/resources" @@ -65,7 +66,7 @@ func Create(mCtxArgs *mc.ContextArgs, args *apiSNC.SNCArgs) (_ *apiSNC.SNCResult return nil, err } // Validate profiles - if err := apiSNC.ValidateProfiles(args.Profiles); err != nil { + if err := profile.Validate(args.Profiles); err != nil { return nil, err } // Compose request @@ -266,18 +267,16 @@ func (r *openshiftSNCRequest) deploy(ctx *pulumi.Context) error { pulumi.ToSecret(kubeconfig)) // Deploy profiles using Kubernetes provider if len(r.profiles) > 0 { - k8sProvider, err := apiSNC.NewK8sProvider(ctx, "k8s-provider", kubeconfig) + k8sProvider, err := profile.NewK8sProvider(ctx, "k8s-provider", kubeconfig) if err != nil { return err } - for _, profileName := range r.profiles { - if _, err := apiSNC.DeployProfile(ctx, profileName, &apiSNC.ProfileDeployArgs{ - K8sProvider: k8sProvider, - Kubeconfig: kubeconfig, - Prefix: *r.prefix, - }); err != nil { - return err - } + if err := profile.Deploy(ctx, r.profiles, &profile.DeployArgs{ + K8sProvider: k8sProvider, + Kubeconfig: kubeconfig, + Prefix: *r.prefix, + }); err != nil { + logging.Warnf("profile deployment failed: %v", err) } } return nil diff --git a/pkg/target/service/snc/client.go b/pkg/target/service/snc/profile/client.go similarity index 89% rename from pkg/target/service/snc/client.go rename to pkg/target/service/snc/profile/client.go index 1d1bc2d20..a621a984b 100644 --- a/pkg/target/service/snc/client.go +++ b/pkg/target/service/snc/profile/client.go @@ -1,4 +1,4 @@ -package snc +package profile import ( "context" @@ -6,8 +6,6 @@ import ( "strings" "time" - "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" logging "github.com/redhat-developer/mapt/pkg/util/logging" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -16,13 +14,6 @@ import ( "k8s.io/client-go/tools/clientcmd" ) -// NewK8sProvider creates a Pulumi Kubernetes provider from a kubeconfig string output. -func NewK8sProvider(ctx *pulumi.Context, name string, kubeconfig pulumi.StringOutput) (*kubernetes.Provider, error) { - return kubernetes.NewProvider(ctx, name, &kubernetes.ProviderArgs{ - Kubeconfig: kubeconfig, - }) -} - // waitForCRCondition polls a custom resource until a nested field matches the expected value. // jsonPath is a dot-separated path into the object (e.g. "status.phase" or // "status.conditions[?(@.type==\"Available\")].status") but here we use explicit diff --git a/pkg/target/service/snc/profile/profile.go b/pkg/target/service/snc/profile/profile.go new file mode 100644 index 000000000..5b1b0f474 --- /dev/null +++ b/pkg/target/service/snc/profile/profile.go @@ -0,0 +1,96 @@ +package profile + +import ( + "fmt" + "slices" + + "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" +) + +const ( + ProfileVirtualization = "virtualization" + ProfileServerlessServing = "serverless-serving" + ProfileServerlessEventing = "serverless-eventing" + ProfileServerless = "serverless" +) + +// validProfiles is the single source of truth for supported profile names. +var validProfiles = []string{ProfileVirtualization, ProfileServerlessServing, ProfileServerlessEventing, ProfileServerless} + +// DeployArgs holds the arguments needed by a profile to deploy +// its resources on the SNC cluster. +type DeployArgs struct { + K8sProvider *kubernetes.Provider + Kubeconfig pulumi.StringOutput + Prefix string + Deps []pulumi.Resource +} + +// Validate checks that all requested profiles are supported. +func Validate(profiles []string) error { + for _, p := range profiles { + if !slices.Contains(validProfiles, p) { + return fmt.Errorf("profile %q is not supported for SNC. Supported profiles: %v", p, validProfiles) + } + } + return nil +} + +// Deploy deploys all requested profiles on the SNC cluster. +// It ensures shared dependencies (e.g. the Serverless operator) are only +// installed once, even when multiple serverless profiles are requested. +func Deploy(ctx *pulumi.Context, profiles []string, args *DeployArgs) error { + needServing := false + needEventing := false + + for _, p := range profiles { + switch p { + case ProfileVirtualization: + if _, err := deployVirtualization(ctx, args); err != nil { + return err + } + case ProfileServerlessServing: + needServing = true + case ProfileServerlessEventing: + needEventing = true + case ProfileServerless: + needServing = true + needEventing = true + default: + return fmt.Errorf("profile %q has no deploy function", p) + } + } + + if needServing || needEventing { + operatorReady, err := deployServerlessOperator(ctx, args) + if err != nil { + return err + } + if needServing { + if _, err := deployKnativeServing(ctx, args, operatorReady); err != nil { + return err + } + } + if needEventing { + if _, err := deployKnativeEventing(ctx, args, operatorReady); err != nil { + return err + } + } + } + + return nil +} + +// RequireNestedVirt returns true if any of the given profiles +// requires nested virtualization on the compute instance. +func RequireNestedVirt(profiles []string) bool { + return slices.Contains(profiles, ProfileVirtualization) +} + +// NewK8sProvider creates a Pulumi Kubernetes provider from a kubeconfig string output. +func NewK8sProvider(ctx *pulumi.Context, name string, kubeconfig pulumi.StringOutput) (*kubernetes.Provider, error) { + return kubernetes.NewProvider(ctx, name, &kubernetes.ProviderArgs{ + Kubeconfig: kubeconfig, + }) +} diff --git a/pkg/target/service/snc/profile/serverless.go b/pkg/target/service/snc/profile/serverless.go new file mode 100644 index 000000000..16beb424c --- /dev/null +++ b/pkg/target/service/snc/profile/serverless.go @@ -0,0 +1,229 @@ +package profile + +import ( + "fmt" + "time" + + "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apiextensions" + corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1" + metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1" + "github.com/pulumi/pulumi/sdk/v3/go/pulumi" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + serverlessNamespace = "openshift-serverless" + knativeServingNamespace = "knative-serving" + knativeEventingNamespace = "knative-eventing" +) + +var ( + knativeServingGVR = schema.GroupVersionResource{ + Group: "operator.knative.dev", + Version: "v1beta1", + Resource: "knativeservings", + } + + knativeEventingGVR = schema.GroupVersionResource{ + Group: "operator.knative.dev", + Version: "v1beta1", + Resource: "knativeeventings", + } +) + +// deployServerlessOperator installs the OpenShift Serverless operator and waits +// for the CSV to succeed. It returns a pulumi.StringOutput that resolves after +// the operator is ready, suitable for threading namespace names through ApplyT. +func deployServerlessOperator(ctx *pulumi.Context, args *DeployArgs) (pulumi.StringOutput, error) { + goCtx := ctx.Context() + rn := func(suffix string) string { + return fmt.Sprintf("%s-serverless-%s", args.Prefix, suffix) + } + + // Create openshift-serverless namespace + ns, err := corev1.NewNamespace(ctx, rn("ns"), + &corev1.NamespaceArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String(serverlessNamespace), + }, + }, + pulumi.Provider(args.K8sProvider), + pulumi.DependsOn(args.Deps)) + if err != nil { + return pulumi.StringOutput{}, err + } + + // Create OperatorGroup (AllNamespaces — empty spec) + og, err := apiextensions.NewCustomResource(ctx, rn("og"), + &apiextensions.CustomResourceArgs{ + ApiVersion: pulumi.String("operators.coreos.com/v1"), + Kind: pulumi.String("OperatorGroup"), + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("serverless-operators"), + Namespace: pulumi.String(serverlessNamespace), + }, + OtherFields: map[string]interface{}{ + "spec": map[string]interface{}{}, + }, + }, + pulumi.Provider(args.K8sProvider), + pulumi.DependsOn([]pulumi.Resource{ns})) + if err != nil { + return pulumi.StringOutput{}, err + } + + // Create Subscription + sub, err := apiextensions.NewCustomResource(ctx, rn("sub"), + &apiextensions.CustomResourceArgs{ + ApiVersion: pulumi.String("operators.coreos.com/v1alpha1"), + Kind: pulumi.String("Subscription"), + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("serverless-operator"), + Namespace: pulumi.String(serverlessNamespace), + }, + OtherFields: map[string]interface{}{ + "spec": map[string]interface{}{ + "source": "redhat-operators", + "sourceNamespace": "openshift-marketplace", + "name": "serverless-operator", + "channel": "stable", + "installPlanApproval": "Automatic", + }, + }, + }, + pulumi.Provider(args.K8sProvider), + pulumi.DependsOn([]pulumi.Resource{og})) + if err != nil { + return pulumi.StringOutput{}, err + } + + // Wait for CSV to succeed (operator fully installed). + operatorReady := pulumi.All(sub.ID(), args.Kubeconfig).ApplyT( + func(allArgs []interface{}) (string, error) { + kc := allArgs[1].(string) + if err := waitForCRCondition(goCtx, kc, csvGVR, + serverlessNamespace, "serverless-operator", + "", "Succeeded", 20*time.Minute, true); err != nil { + return "", fmt.Errorf("waiting for Serverless CSV: %w", err) + } + return "ready", nil + }).(pulumi.StringOutput) + + return operatorReady, nil +} + +// deployKnativeServing creates a KnativeServing CR and waits for it to be ready. +// The operatorReady output is used to chain the dependency on the operator installation. +func deployKnativeServing(ctx *pulumi.Context, args *DeployArgs, operatorReady pulumi.StringOutput) (pulumi.Resource, error) { + goCtx := ctx.Context() + rn := func(suffix string) string { + return fmt.Sprintf("%s-serverless-%s", args.Prefix, suffix) + } + + // Thread the wait into the namespace name via ApplyT + ksNSName := operatorReady.ApplyT(func(_ string) string { + return knativeServingNamespace + }).(pulumi.StringOutput) + + // Create knative-serving namespace + ksNS, err := corev1.NewNamespace(ctx, rn("ks-ns"), + &corev1.NamespaceArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: ksNSName, + }, + }, + pulumi.Provider(args.K8sProvider)) + if err != nil { + return nil, err + } + + // Create KnativeServing CR + ks, err := apiextensions.NewCustomResource(ctx, rn("ks"), + &apiextensions.CustomResourceArgs{ + ApiVersion: pulumi.String("operator.knative.dev/v1beta1"), + Kind: pulumi.String("KnativeServing"), + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("knative-serving"), + Namespace: pulumi.String(knativeServingNamespace), + }, + }, + pulumi.Provider(args.K8sProvider), + pulumi.DependsOn([]pulumi.Resource{ksNS})) + if err != nil { + return nil, err + } + + // Wait for KnativeServing to be ready. + ksReady := pulumi.All(ks.ID(), args.Kubeconfig).ApplyT( + func(allArgs []interface{}) (string, error) { + kc := allArgs[1].(string) + if err := waitForCRCondition(goCtx, kc, knativeServingGVR, + knativeServingNamespace, "knative-serving", + "Ready", "True", 20*time.Minute, false); err != nil { + return "", fmt.Errorf("waiting for KnativeServing: %w", err) + } + return "ready", nil + }).(pulumi.StringOutput) + + ctx.Export("knativeServingReady", ksReady) + + return ks, nil +} + +// deployKnativeEventing creates a KnativeEventing CR and waits for it to be ready. +// The operatorReady output is used to chain the dependency on the operator installation. +func deployKnativeEventing(ctx *pulumi.Context, args *DeployArgs, operatorReady pulumi.StringOutput) (pulumi.Resource, error) { + goCtx := ctx.Context() + rn := func(suffix string) string { + return fmt.Sprintf("%s-serverless-%s", args.Prefix, suffix) + } + + // Thread the wait into the namespace name via ApplyT + keNSName := operatorReady.ApplyT(func(_ string) string { + return knativeEventingNamespace + }).(pulumi.StringOutput) + + // Create knative-eventing namespace + keNS, err := corev1.NewNamespace(ctx, rn("ke-ns"), + &corev1.NamespaceArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: keNSName, + }, + }, + pulumi.Provider(args.K8sProvider)) + if err != nil { + return nil, err + } + + // Create KnativeEventing CR + ke, err := apiextensions.NewCustomResource(ctx, rn("ke"), + &apiextensions.CustomResourceArgs{ + ApiVersion: pulumi.String("operator.knative.dev/v1beta1"), + Kind: pulumi.String("KnativeEventing"), + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String("knative-eventing"), + Namespace: pulumi.String(knativeEventingNamespace), + }, + }, + pulumi.Provider(args.K8sProvider), + pulumi.DependsOn([]pulumi.Resource{keNS})) + if err != nil { + return nil, err + } + + // Wait for KnativeEventing to be ready. + keReady := pulumi.All(ke.ID(), args.Kubeconfig).ApplyT( + func(allArgs []interface{}) (string, error) { + kc := allArgs[1].(string) + if err := waitForCRCondition(goCtx, kc, knativeEventingGVR, + knativeEventingNamespace, "knative-eventing", + "Ready", "True", 20*time.Minute, false); err != nil { + return "", fmt.Errorf("waiting for KnativeEventing: %w", err) + } + return "ready", nil + }).(pulumi.StringOutput) + + ctx.Export("knativeEventingReady", keReady) + + return ke, nil +} diff --git a/pkg/target/service/snc/profile_virtualization.go b/pkg/target/service/snc/profile/virtualization.go similarity index 97% rename from pkg/target/service/snc/profile_virtualization.go rename to pkg/target/service/snc/profile/virtualization.go index c029240e5..37c93deee 100644 --- a/pkg/target/service/snc/profile_virtualization.go +++ b/pkg/target/service/snc/profile/virtualization.go @@ -1,4 +1,4 @@ -package snc +package profile import ( "fmt" @@ -28,7 +28,7 @@ var ( } ) -func deployVirtualization(ctx *pulumi.Context, args *ProfileDeployArgs) (pulumi.Resource, error) { +func deployVirtualization(ctx *pulumi.Context, args *DeployArgs) (pulumi.Resource, error) { goCtx := ctx.Context() rn := func(suffix string) string { return fmt.Sprintf("%s-virt-%s", args.Prefix, suffix) diff --git a/pkg/target/service/snc/profiles.go b/pkg/target/service/snc/profiles.go deleted file mode 100644 index c2be6b640..000000000 --- a/pkg/target/service/snc/profiles.go +++ /dev/null @@ -1,52 +0,0 @@ -package snc - -import ( - "fmt" - "slices" - - "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes" - "github.com/pulumi/pulumi/sdk/v3/go/pulumi" -) - -const ( - ProfileVirtualization = "virtualization" -) - -// validProfiles is the single source of truth for supported profile names. -var validProfiles = []string{ProfileVirtualization} - -// ProfileDeployArgs holds the arguments needed by a profile to deploy -// its resources on the SNC cluster. -type ProfileDeployArgs struct { - K8sProvider *kubernetes.Provider - Kubeconfig pulumi.StringOutput - Prefix string - Deps []pulumi.Resource -} - -// ValidateProfiles checks that all requested profiles are supported. -func ValidateProfiles(profiles []string) error { - for _, p := range profiles { - if !slices.Contains(validProfiles, p) { - return fmt.Errorf("profile %q is not supported for SNC. Supported profiles: %v", p, validProfiles) - } - } - return nil -} - -// DeployProfile deploys the resources for a given profile on the SNC cluster. -// It returns the last resource created for dependency chaining. -func DeployProfile(ctx *pulumi.Context, profile string, args *ProfileDeployArgs) (pulumi.Resource, error) { - switch profile { - case ProfileVirtualization: - return deployVirtualization(ctx, args) - default: - return nil, fmt.Errorf("profile %q has no deploy function", profile) - } -} - -// ProfilesRequireNestedVirt returns true if any of the given profiles -// requires nested virtualization on the compute instance. -func ProfilesRequireNestedVirt(profiles []string) bool { - return slices.Contains(profiles, ProfileVirtualization) -} diff --git a/tkn/infra-aws-ocp-snc.yaml b/tkn/infra-aws-ocp-snc.yaml index 4f02b3c21..2daa7b936 100644 --- a/tkn/infra-aws-ocp-snc.yaml +++ b/tkn/infra-aws-ocp-snc.yaml @@ -126,7 +126,7 @@ spec: description: If this flag is set it will skip the checks for the cluster readiness. In this case the kubeconfig can not be generated. default: 'false' - name: profile - description: Comma-separated list of profiles to install on the cluster (e.g. virtualization). When virtualization is selected, a bare metal instance is used. + description: Comma-separated list of profiles to install on the cluster (e.g. virtualization, serverless-serving, serverless-eventing, serverless). When virtualization is selected, a bare metal instance is used. default: "''" # Metadata params diff --git a/tkn/template/infra-aws-ocp-snc.yaml b/tkn/template/infra-aws-ocp-snc.yaml index 6aab389fd..6eb174fcf 100644 --- a/tkn/template/infra-aws-ocp-snc.yaml +++ b/tkn/template/infra-aws-ocp-snc.yaml @@ -126,7 +126,7 @@ spec: description: If this flag is set it will skip the checks for the cluster readiness. In this case the kubeconfig can not be generated. default: 'false' - name: profile - description: Comma-separated list of profiles to install on the cluster (e.g. virtualization). When virtualization is selected, a bare metal instance is used. + description: Comma-separated list of profiles to install on the cluster (e.g. virtualization, serverless-serving, serverless-eventing, serverless). When virtualization is selected, a bare metal instance is used. default: "''" # Metadata params