Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 92 additions & 5 deletions pkg/cli/cmd/install/kubernetes/kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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.
Comment thread
lakshmimsft marked this conversation as resolved.

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

Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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.
Expand All @@ -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
}

Expand Down Expand Up @@ -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)
}

Comment thread
lakshmimsft marked this conversation as resolved.

// 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)
Expand Down Expand Up @@ -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
}
171 changes: 171 additions & 0 deletions pkg/cli/cmd/install/kubernetes/kubernetes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
})
}
Loading