From f7504a9a3654f1332203b8d969c555da40ade282 Mon Sep 17 00:00:00 2001 From: Jose Angel Morena Date: Fri, 6 Mar 2026 18:17:28 +0100 Subject: [PATCH] feat(snc): add servicemesh profile with OpenShift Service Mesh 3 support Installs the Red Hat OpenShift Service Mesh 3 operator (servicemeshoperator3) via OLM and deploys cluster-scoped IstioCNI and Istio CRs using the sailoperator.io/v1 API. Also updates findResource to support cluster-scoped resource lookups when namespace is empty. --- cmd/mapt/cmd/aws/services/snc.go | 2 +- pkg/provider/aws/action/snc/snc.go | 6 + pkg/target/service/snc/client.go | 12 +- pkg/target/service/snc/profile_servicemesh.go | 181 ++++++++++++++++++ pkg/target/service/snc/profiles.go | 5 +- 5 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 pkg/target/service/snc/profile_servicemesh.go diff --git a/cmd/mapt/cmd/aws/services/snc.go b/cmd/mapt/cmd/aws/services/snc.go index 5cbd4b254..4af9ea344 100644 --- a/cmd/mapt/cmd/aws/services/snc.go +++ b/cmd/mapt/cmd/aws/services/snc.go @@ -24,7 +24,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, servicemesh" ) func GetOpenshiftSNCCmd() *cobra.Command { diff --git a/pkg/provider/aws/action/snc/snc.go b/pkg/provider/aws/action/snc/snc.go index 10aba5c62..43b8e05fb 100644 --- a/pkg/provider/aws/action/snc/snc.go +++ b/pkg/provider/aws/action/snc/snc.go @@ -264,6 +264,12 @@ func (r *openshiftSNCRequest) deploy(ctx *pulumi.Context) error { } ctx.Export(fmt.Sprintf("%s-%s", *r.prefix, apiSNC.OutputKubeconfig), pulumi.ToSecret(kubeconfig)) + // Write kubeconfig to disk early so it is available even if profile deployment fails + if outputPath := r.mCtx.GetResultsOutputPath(); len(outputPath) > 0 { + kubeconfig.ApplyT(func(kc string) error { + return os.WriteFile(fmt.Sprintf("%s/kubeconfig", outputPath), []byte(kc), 0600) + }) + } // Deploy profiles using Kubernetes provider if len(r.profiles) > 0 { k8sProvider, err := apiSNC.NewK8sProvider(ctx, "k8s-provider", kubeconfig) diff --git a/pkg/target/service/snc/client.go b/pkg/target/service/snc/client.go index 1d1bc2d20..701a00abf 100644 --- a/pkg/target/service/snc/client.go +++ b/pkg/target/service/snc/client.go @@ -69,14 +69,22 @@ func waitForCRCondition(ctx context.Context, kubeconfig string, gvr schema.Group } // findResource returns a single resource by exact name or by name prefix. +// When namespace is empty, the resource is looked up at cluster scope. func findResource(ctx context.Context, dc dynamic.Interface, gvr schema.GroupVersionResource, namespace, name string, prefixMatch bool) (*unstructured.Unstructured, error) { + var ri dynamic.ResourceInterface + if namespace == "" { + ri = dc.Resource(gvr) + } else { + ri = dc.Resource(gvr).Namespace(namespace) + } + if !prefixMatch { - return dc.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + return ri.Get(ctx, name, metav1.GetOptions{}) } - list, err := dc.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) + list, err := ri.List(ctx, metav1.ListOptions{}) if err != nil { return nil, err } diff --git a/pkg/target/service/snc/profile_servicemesh.go b/pkg/target/service/snc/profile_servicemesh.go new file mode 100644 index 000000000..799a53f2b --- /dev/null +++ b/pkg/target/service/snc/profile_servicemesh.go @@ -0,0 +1,181 @@ +package snc + +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 ( + istioSystemNamespace = "istio-system" + istioCNINamespace = "istio-cni" +) + +var ( + sailCSVGVR = schema.GroupVersionResource{ + Group: "operators.coreos.com", + Version: "v1alpha1", + Resource: "clusterserviceversions", + } + istioGVR = schema.GroupVersionResource{ + Group: "sailoperator.io", + Version: "v1", + Resource: "istios", + } + istioCNIGVR = schema.GroupVersionResource{ + Group: "sailoperator.io", + Version: "v1", + Resource: "istiocnis", + } +) + +func deployServiceMesh(ctx *pulumi.Context, args *ProfileDeployArgs) (pulumi.Resource, error) { + goCtx := ctx.Context() + rn := func(suffix string) string { + return fmt.Sprintf("%s-smesh-%s", args.Prefix, suffix) + } + + // Create istio-system namespace + nsSystem, err := corev1.NewNamespace(ctx, rn("ns-system"), + &corev1.NamespaceArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String(istioSystemNamespace), + }, + }, + pulumi.Provider(args.K8sProvider), + pulumi.DependsOn(args.Deps)) + if err != nil { + return nil, err + } + + // Create istio-cni namespace + nsCNI, err := corev1.NewNamespace(ctx, rn("ns-cni"), + &corev1.NamespaceArgs{ + Metadata: &metav1.ObjectMetaArgs{ + Name: pulumi.String(istioCNINamespace), + }, + }, + pulumi.Provider(args.K8sProvider), + pulumi.DependsOn(args.Deps)) + if err != nil { + return nil, err + } + + // Create Subscription for the OpenShift Service Mesh 3 operator + 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("servicemeshoperator3"), + Namespace: pulumi.String("openshift-operators"), + }, + OtherFields: map[string]interface{}{ + "spec": map[string]interface{}{ + "source": "redhat-operators", + "sourceNamespace": "openshift-marketplace", + "name": "servicemeshoperator3", + "channel": "stable", + "installPlanApproval": "Automatic", + }, + }, + }, + pulumi.Provider(args.K8sProvider), + pulumi.DependsOn([]pulumi.Resource{nsSystem, nsCNI})) + if err != nil { + return nil, err + } + + // Wait for the Service Mesh operator CSV to succeed + csvReady := pulumi.All(sub.ID(), args.Kubeconfig).ApplyT( + func(allArgs []interface{}) (string, error) { + kc := allArgs[1].(string) + if err := waitForCRCondition(goCtx, kc, sailCSVGVR, + "openshift-operators", "servicemeshoperator3", + "", "Succeeded", 20*time.Minute, true); err != nil { + return "", fmt.Errorf("waiting for Service Mesh operator CSV: %w", err) + } + return "ready", nil + }).(pulumi.StringOutput) + + // Create IstioCNI CR + istioCNIName := csvReady.ApplyT(func(_ string) string { + return "default" + }).(pulumi.StringOutput) + + // IstioCNI is cluster-scoped + cni, err := apiextensions.NewCustomResource(ctx, rn("istiocni"), + &apiextensions.CustomResourceArgs{ + ApiVersion: pulumi.String("sailoperator.io/v1"), + Kind: pulumi.String("IstioCNI"), + Metadata: &metav1.ObjectMetaArgs{ + Name: istioCNIName, + }, + OtherFields: map[string]interface{}{ + "spec": map[string]interface{}{ + "namespace": istioCNINamespace, + "profile": "openshift", + }, + }, + }, + pulumi.Provider(args.K8sProvider)) + if err != nil { + return nil, err + } + + // Wait for IstioCNI to be ready (cluster-scoped, empty namespace) + cniReady := pulumi.All(cni.ID(), args.Kubeconfig).ApplyT( + func(allArgs []interface{}) (string, error) { + kc := allArgs[1].(string) + if err := waitForCRCondition(goCtx, kc, istioCNIGVR, + "", "default", + "Ready", "True", 20*time.Minute, false); err != nil { + return "", fmt.Errorf("waiting for IstioCNI: %w", err) + } + return "ready", nil + }).(pulumi.StringOutput) + + // Create Istio CR (cluster-scoped, depends on CNI being ready) + istioName := cniReady.ApplyT(func(_ string) string { + return "default" + }).(pulumi.StringOutput) + + istio, err := apiextensions.NewCustomResource(ctx, rn("istio"), + &apiextensions.CustomResourceArgs{ + ApiVersion: pulumi.String("sailoperator.io/v1"), + Kind: pulumi.String("Istio"), + Metadata: &metav1.ObjectMetaArgs{ + Name: istioName, + }, + OtherFields: map[string]interface{}{ + "spec": map[string]interface{}{ + "namespace": istioSystemNamespace, + }, + }, + }, + pulumi.Provider(args.K8sProvider)) + if err != nil { + return nil, err + } + + // Wait for Istio to be ready (cluster-scoped, empty namespace) + istioReady := pulumi.All(istio.ID(), args.Kubeconfig).ApplyT( + func(allArgs []interface{}) (string, error) { + kc := allArgs[1].(string) + if err := waitForCRCondition(goCtx, kc, istioGVR, + "", "default", + "Ready", "True", 20*time.Minute, false); err != nil { + return "", fmt.Errorf("waiting for Istio: %w", err) + } + return "ready", nil + }).(pulumi.StringOutput) + + ctx.Export("istioReady", istioReady) + + return istio, nil +} diff --git a/pkg/target/service/snc/profiles.go b/pkg/target/service/snc/profiles.go index c2be6b640..5d93b386e 100644 --- a/pkg/target/service/snc/profiles.go +++ b/pkg/target/service/snc/profiles.go @@ -10,10 +10,11 @@ import ( const ( ProfileVirtualization = "virtualization" + ProfileServiceMesh = "servicemesh" ) // validProfiles is the single source of truth for supported profile names. -var validProfiles = []string{ProfileVirtualization} +var validProfiles = []string{ProfileVirtualization, ProfileServiceMesh} // ProfileDeployArgs holds the arguments needed by a profile to deploy // its resources on the SNC cluster. @@ -40,6 +41,8 @@ func DeployProfile(ctx *pulumi.Context, profile string, args *ProfileDeployArgs) switch profile { case ProfileVirtualization: return deployVirtualization(ctx, args) + case ProfileServiceMesh: + return deployServiceMesh(ctx, args) default: return nil, fmt.Errorf("profile %q has no deploy function", profile) }