From 9a0a217186b0bc4ccad58e6a6b1dd746c5dbc0ba Mon Sep 17 00:00:00 2001 From: lakshmimsft Date: Tue, 21 Jul 2026 18:21:58 -0700 Subject: [PATCH] preview for kube install Signed-off-by: lakshmimsft --- pkg/cli/cmd/install/kubernetes/kubernetes.go | 97 +++++++++- .../cmd/install/kubernetes/kubernetes_test.go | 171 ++++++++++++++++++ 2 files changed, 263 insertions(+), 5 deletions(-) diff --git a/pkg/cli/cmd/install/kubernetes/kubernetes.go b/pkg/cli/cmd/install/kubernetes/kubernetes.go index 80fca45e4bc..b37214451a5 100644 --- a/pkg/cli/cmd/install/kubernetes/kubernetes.go +++ b/pkg/cli/cmd/install/kubernetes/kubernetes.go @@ -19,17 +19,24 @@ package kubernetes import ( "context" "fmt" + "os" + "strings" + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/cli/clients" "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd" "github.com/radius-project/radius/pkg/cli/cmd/commonflags" "github.com/radius-project/radius/pkg/cli/connections" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/helm" cli_kubernetes "github.com/radius-project/radius/pkg/cli/kubernetes" "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/recipepack" "github.com/radius-project/radius/pkg/cli/setup" "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/pkg/version" "github.com/spf13/cobra" ) @@ -58,16 +65,23 @@ By default 'rad install kubernetes' will install Radius with the version matchin Radius will be installed in the 'radius-system' namespace. For more information visit https://docs.radapp.io/concepts#technical-architecture -This command also ensures that a default resource group named 'default' and a default environment -named 'default' (using the 'default' Kubernetes namespace) exist so the cluster is immediately -ready to deploy applications. If either resource already exists, it is left unchanged; user -customizations are never overwritten, even with '--reinstall'. +On install or reinstall, this command also ensures that a default resource group named 'default' +and a default environment named 'default' (using the 'default' Kubernetes namespace) exist so the +cluster is immediately ready to deploy applications. If either resource already exists, it is left +unchanged (GET-first); user customizations are never overwritten, even with '--reinstall'. + +By default the environment is created using the 'Applications.Core/environments' resource type. +Pass '--preview' (or set RADIUS_PREVIEW=true) to create it using the new 'Radius.Core/environments' +resource type with the default recipe pack instead. Overrides can be set by specifying Helm chart values with the '--set' flag. For more information visit https://docs.radapp.io/guides/operations/kubernetes/install/. `, Example: `# Install Radius with default settings in current Kubernetes context rad install kubernetes +# Install Radius and create the default environment using the Radius.Core/environments type +rad install kubernetes --preview + # Install Radius with default settings in specified Kubernetes context rad install kubernetes --kubecontext mycluster @@ -119,6 +133,7 @@ rad install kubernetes --set global.terraform.loglevel=DEBUG } commonflags.AddKubeContextFlagVar(cmd, &runner.KubeContext) + cmd.Flags().Bool("preview", false, "Create the default environment using the Radius.Core/environments resource type instead of Applications.Core/environments (can also be set via RADIUS_PREVIEW=true)") cmd.Flags().BoolVar(&runner.Reinstall, "reinstall", false, "Specify to force reinstallation of Radius") cmd.Flags().StringVar(&runner.Chart, "chart", "", "Specify a file path to a helm chart to install Radius from") cmd.Flags().StringArrayVar(&runner.Set, "set", []string{}, "Set values on the command line (can specify multiple or separate values with commas: key1=val1,key2=val2)") @@ -153,6 +168,15 @@ type Runner struct { ContourSetFile []string Reinstall bool + + // Preview selects the new Radius.Core/environments resource type (with the default recipe pack) + // for the default environment instead of the legacy Applications.Core/environments type. + Preview bool + + // RadiusCoreClientFactory is the Radius.Core (v20250801preview) client factory used to create the + // default environment and recipe pack when Preview is set. It is initialized lazily from the + // workspace when nil; tests inject a fake factory. + RadiusCoreClientFactory *corerpv20250801.ClientFactory } // NewRunner creates an instance of the runner for the `rad install kubernetes` command. @@ -171,6 +195,16 @@ func NewRunner(factory framework.Factory) *Runner { // Validate runs validation for the `rad install kubernetes` command. func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + preview, err := cmd.Flags().GetBool("preview") + if err != nil { + return err + } + // The --preview flag takes precedence; fall back to RADIUS_PREVIEW only when the flag is unset, + // mirroring resolveUsePreview used by the other preview-enabled commands. + if !cmd.Flags().Changed("preview") { + preview = strings.EqualFold(os.Getenv("RADIUS_PREVIEW"), "true") + } + r.Preview = preview return nil } @@ -252,10 +286,13 @@ func (r *Runner) createDefaultGroupAndEnvironment(ctx context.Context) error { if err := r.ensureDefaultResourceGroup(ctx, client); err != nil { return err } + + if r.Preview { + return r.ensureDefaultEnvironmentPreview(ctx, &workspace) + } return r.ensureDefaultEnvironment(ctx, client) } - // ensureDefaultResourceGroup creates the "default" resource group only if it does not already exist. func (r *Runner) ensureDefaultResourceGroup(ctx context.Context, client clients.ApplicationsManagementClient) error { _, err := client.GetResourceGroup(ctx, defaultUCPPlane, defaultResourceGroupName) @@ -291,3 +328,53 @@ func (r *Runner) ensureDefaultEnvironment(ctx context.Context, client clients.Ap } return nil } + +// ensureDefaultEnvironmentPreview creates the "default" environment using the new +// Radius.Core/environments resource type only if it does not already exist. It first ensures the +// Radius-managed default recipe pack exists (in the default resource group) and attaches it to the +// environment, mirroring `rad env create --preview`. If the environment already exists it is left +// untouched so user customizations are preserved across reinstalls. +func (r *Runner) ensureDefaultEnvironmentPreview(ctx context.Context, workspace *workspaces.Workspace) error { + if r.RadiusCoreClientFactory == nil { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, workspace) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to connect to the Radius control plane. Radius was installed successfully; you can create the default environment manually with 'rad env create default --preview'.") + } + r.RadiusCoreClientFactory = clientFactory + } + + envClient := r.RadiusCoreClientFactory.NewEnvironmentsClient() + + _, err := envClient.Get(ctx, workspace.Scope, defaultEnvironmentName, nil) + if err == nil { + r.Output.LogInfo("Default environment %q already exists; leaving it unchanged.", defaultEnvironmentName) + return nil + } + if !clients.Is404Error(err) { + return clierrors.MessageWithCause(err, "Failed to check for the default environment. Radius was installed successfully; you can retry with 'rad env create default --preview'.") + } + + // The default recipe pack always lives in the default resource group scope, regardless of the + // current workspace scope. Create it (or reuse the existing one) before referencing it. + recipePackID, err := recipepack.GetOrCreateDefaultRecipePack(ctx, r.RadiusCoreClientFactory.NewRecipePacksClient()) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to create the default recipe pack. Radius was installed successfully; you can retry with 'rad env create default --preview'.") + } + + r.Output.LogInfo("Creating default environment %q in namespace %q...", defaultEnvironmentName, defaultEnvironmentNamespace) + resource := corerpv20250801.EnvironmentResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &corerpv20250801.EnvironmentProperties{ + RecipePacks: []*string{to.Ptr(recipePackID)}, + Providers: &corerpv20250801.Providers{ + Kubernetes: &corerpv20250801.ProvidersKubernetes{ + Namespace: to.Ptr(defaultEnvironmentNamespace), + }, + }, + }, + } + if _, err := envClient.CreateOrUpdate(ctx, workspace.Scope, defaultEnvironmentName, resource, nil); err != nil { + return clierrors.MessageWithCause(err, "Failed to create the default environment. Radius was installed successfully; you can retry with 'rad env create default --preview'.") + } + return nil +} diff --git a/pkg/cli/cmd/install/kubernetes/kubernetes_test.go b/pkg/cli/cmd/install/kubernetes/kubernetes_test.go index a28196191aa..608da57b896 100644 --- a/pkg/cli/cmd/install/kubernetes/kubernetes_test.go +++ b/pkg/cli/cmd/install/kubernetes/kubernetes_test.go @@ -29,11 +29,18 @@ import ( "github.com/radius-project/radius/pkg/cli/helm" cli_kubernetes "github.com/radius-project/radius/pkg/cli/kubernetes" "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/recipepack" + "github.com/radius-project/radius/pkg/cli/test_client_factory" corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + corerpfake "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + "github.com/radius-project/radius/pkg/to" ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/radius-project/radius/test/radcli" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" ) func Test_CommandValidation(t *testing.T) { @@ -64,6 +71,11 @@ func Test_Validate(t *testing.T) { Input: []string{"--skip-contour-install"}, ExpectedValid: true, }, + { + Name: "preview", + Input: []string{"--preview"}, + ExpectedValid: true, + }, } radcli.SharedValidateValidation(t, NewCommand, testcases) } @@ -547,4 +559,163 @@ func Test_Run(t *testing.T) { require.Contains(t, err.Error(), "Failed to create the default environment") require.ErrorIs(t, err, boom) }) + + t.Run("Success: Install with --preview creates a Radius.Core environment", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + helmMock := helm.NewMockInterface(ctrl) + outputMock := &output.MockOutput{} + + notFound := &azcore.ResponseError{StatusCode: http.StatusNotFound} + mgmtMock := clients.NewMockApplicationsManagementClient(ctrl) + mgmtMock.EXPECT(). + GetResourceGroup(gomock.Any(), "local", "default"). + Return(ucp.ResourceGroupResource{}, notFound). + Times(1) + mgmtMock.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + // The preview path uses the Radius.Core client factory for the environment, so the legacy + // management client must not receive any environment calls. + + // Capture the Radius.Core environment that gets created so we can assert its shape. + var capturedScope, capturedName string + var capturedEnv corerpv20250801.EnvironmentResource + envServer := func() corerpfake.EnvironmentsServer { + return corerpfake.EnvironmentsServer{ + Get: func(_ context.Context, _ string, _ string, _ *corerpv20250801.EnvironmentsClientGetOptions) (resp azfake.Responder[corerpv20250801.EnvironmentsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetResponseError(http.StatusNotFound, "Not Found") + return + }, + CreateOrUpdate: func(_ context.Context, rootScope string, environmentName string, resource corerpv20250801.EnvironmentResource, _ *corerpv20250801.EnvironmentsClientCreateOrUpdateOptions) (resp azfake.Responder[corerpv20250801.EnvironmentsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + capturedScope = rootScope + capturedName = environmentName + capturedEnv = resource + resp.SetResponse(http.StatusOK, corerpv20250801.EnvironmentsClientCreateOrUpdateResponse{EnvironmentResource: resource}, nil) + return + }, + } + } + radiusCoreFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + "/planes/radius/local/resourceGroups/default", + envServer, + test_client_factory.WithRecipePackServer404OnGet, + ) + require.NoError(t, err) + + ctx := context.Background() + runner := &Runner{ + Helm: helmMock, + Output: outputMock, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mgmtMock}, + KubernetesInterface: cli_kubernetes.NewMockInterface(ctrl), + RadiusCoreClientFactory: radiusCoreFactory, + + KubeContext: "test-context", + Chart: "test-chart", + Preview: true, + } + + helmMock.EXPECT().CheckRadiusInstall("test-context"). + Return(helm.InstallState{}, nil). + Times(1) + expectedOptions := helm.PopulateDefaultClusterOptions(helm.CLIClusterOptions{ + Radius: helm.ChartOptions{ChartPath: "test-chart"}, + }) + helmMock.EXPECT().InstallRadius(ctx, expectedOptions, "test-context"). + Return(nil). + Times(1) + + err = runner.Run(ctx) + require.NoError(t, err) + + require.Equal(t, "planes/radius/local/resourceGroups/default", capturedScope) + require.Equal(t, "default", capturedName) + require.NotNil(t, capturedEnv.Properties) + require.Equal(t, []*string{to.Ptr(recipepack.DefaultRecipePackID())}, capturedEnv.Properties.RecipePacks) + require.NotNil(t, capturedEnv.Properties.Providers) + require.NotNil(t, capturedEnv.Properties.Providers.Kubernetes) + require.Equal(t, "default", *capturedEnv.Properties.Providers.Kubernetes.Namespace) + + expectedWrites := []any{ + output.LogOutput{ + Format: "Installing Radius version %s to namespace: %s...", + Params: []any{"edge", "radius-system"}, + }, + output.LogOutput{ + Format: "Creating default resource group %q...", + Params: []any{"default"}, + }, + output.LogOutput{ + Format: "Creating default environment %q in namespace %q...", + Params: []any{"default", "default"}, + }, + } + require.Equal(t, expectedWrites, outputMock.Writes) + }) + + t.Run("Success: Reinstall with --preview leaves an existing Radius.Core environment unchanged", func(t *testing.T) { + t.Parallel() + ctrl := gomock.NewController(t) + helmMock := helm.NewMockInterface(ctrl) + outputMock := &output.MockOutput{} + + mgmtMock := clients.NewMockApplicationsManagementClient(ctrl) + mgmtMock.EXPECT(). + GetResourceGroup(gomock.Any(), "local", "default"). + Return(ucp.ResourceGroupResource{}, nil). + Times(1) + + // The environment already exists: Get succeeds and no CreateOrUpdate is issued. + radiusCoreFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + "/planes/radius/local/resourceGroups/default", + test_client_factory.WithEnvironmentServerNoError, + nil, + ) + require.NoError(t, err) + + ctx := context.Background() + runner := &Runner{ + Helm: helmMock, + Output: outputMock, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mgmtMock}, + KubernetesInterface: cli_kubernetes.NewMockInterface(ctrl), + RadiusCoreClientFactory: radiusCoreFactory, + + KubeContext: "test-context", + Chart: "test-chart", + Preview: true, + Reinstall: true, + } + + helmMock.EXPECT().CheckRadiusInstall("test-context"). + Return(helm.InstallState{RadiusInstalled: true, RadiusVersion: "test-version"}, nil). + Times(1) + expectedOptions := helm.PopulateDefaultClusterOptions(helm.CLIClusterOptions{ + Radius: helm.ChartOptions{ChartPath: "test-chart", Reinstall: true}, + }) + helmMock.EXPECT().InstallRadius(ctx, expectedOptions, "test-context"). + Return(nil). + Times(1) + + err = runner.Run(ctx) + require.NoError(t, err) + + expectedWrites := []any{ + output.LogOutput{ + Format: "Reinstalling Radius version %s to namespace: %s...", + Params: []any{"edge", "radius-system"}, + }, + output.LogOutput{ + Format: "Default resource group %q already exists; leaving it unchanged.", + Params: []any{"default"}, + }, + output.LogOutput{ + Format: "Default environment %q already exists; leaving it unchanged.", + Params: []any{"default"}, + }, + } + require.Equal(t, expectedWrites, outputMock.Writes) + }) }