diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go index fc6d0667be..045ad3e465 100644 --- a/cmd/rad/cmd/root.go +++ b/cmd/rad/cmd/root.go @@ -90,6 +90,7 @@ import ( upgrade_kubernetes "github.com/radius-project/radius/pkg/cli/cmd/upgrade/kubernetes" version "github.com/radius-project/radius/pkg/cli/cmd/version" workspace_create "github.com/radius-project/radius/pkg/cli/cmd/workspace/create" + workspace_create_preview "github.com/radius-project/radius/pkg/cli/cmd/workspace/create/preview" workspace_delete "github.com/radius-project/radius/pkg/cli/cmd/workspace/delete" workspace_list "github.com/radius-project/radius/pkg/cli/cmd/workspace/list" workspace_show "github.com/radius-project/radius/pkg/cli/cmd/workspace/show" @@ -388,6 +389,8 @@ func initSubCommands() { envCmd.AddCommand(envUpdateCmd) workspaceCreateCmd, _ := workspace_create.NewCommand(framework) + previewWorkspaceCreateCmd, _ := workspace_create_preview.NewCommand(framework) + wirePreviewSubcommand(workspaceCreateCmd, previewWorkspaceCreateCmd) workspaceCmd.AddCommand(workspaceCreateCmd) workspaceDeleteCmd, _ := workspace_delete.NewCommand(framework) diff --git a/pkg/cli/cmd/workspace/create/create.go b/pkg/cli/cmd/workspace/create/create.go index fe56291b75..a44e9f94d3 100644 --- a/pkg/cli/cmd/workspace/create/create.go +++ b/pkg/cli/cmd/workspace/create/create.go @@ -31,6 +31,7 @@ import ( "github.com/radius-project/radius/pkg/cli/kubernetes" "github.com/radius-project/radius/pkg/cli/output" "github.com/radius-project/radius/pkg/cli/workspaces" + "github.com/radius-project/radius/pkg/corerp/datamodel" "github.com/spf13/cobra" ) @@ -70,6 +71,13 @@ rad workspace create kubernetes`, return cmd, runner } +// EnvironmentValidator validates that the environment named envName exists in the +// scope of ws, and returns the fully-qualified environment resource ID to be persisted +// on the workspace. mgmtClient is the management client already created for the workspace +// scope; implementations are free to ignore it (e.g. preview implementations that talk +// to the Radius.Core resource provider directly). +type EnvironmentValidator func(ctx context.Context, ws *workspaces.Workspace, mgmtClient clients.ApplicationsManagementClient, envName string) (envID string, err error) + // Runner is the runner implementation for the `rad workspace create` command. type Runner struct { ConfigHolder *framework.ConfigHolder @@ -80,11 +88,16 @@ type Runner struct { Output output.Interface HelmInterface helm.Interface KubernetesInterface kubernetes.Interface + + // EnvironmentValidator is invoked to validate that the requested environment exists + // and to construct the environment resource ID stored on the workspace. When nil, + // the legacy Applications.Core/environments validator is used. + EnvironmentValidator EnvironmentValidator } // NewRunner creates a new instance of the `rad workspace create` runner. func NewRunner(factory framework.Factory) *Runner { - return &Runner{ + r := &Runner{ ConnectionFactory: factory.GetConnectionFactory(), ConfigHolder: factory.GetConfigHolder(), ConfigFileInterface: factory.GetConfigFileInterface(), @@ -92,6 +105,21 @@ func NewRunner(factory framework.Factory) *Runner { HelmInterface: factory.GetHelmInterface(), KubernetesInterface: factory.GetKubernetesInterface(), } + r.EnvironmentValidator = ValidateApplicationsCoreEnvironment + return r +} + +// ValidateApplicationsCoreEnvironment is the default EnvironmentValidator. It targets +// Applications.Core/environments via the management client. +func ValidateApplicationsCoreEnvironment(ctx context.Context, ws *workspaces.Workspace, mgmtClient clients.ApplicationsManagementClient, envName string) (string, error) { + envID := ws.Scope + "/providers/" + datamodel.EnvironmentResourceType + "/" + envName + if _, err := mgmtClient.GetEnvironment(ctx, envName); err != nil { + if clients.Is404Error(err) { + return "", clierrors.Message("The environment %q does not exist. Run `rad env create` and try again.", envID) + } + return "", clierrors.MessageWithCause(err, "Failed to get environment %q.", envID) + } + return envID, nil } // Validate runs validation for the `rad workspace create` command. @@ -191,12 +219,16 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { if r.Workspace.Scope == "" { return clierrors.Message("Cannot set environment for workspace with empty scope. Use --group to set a scope.") } - r.Workspace.Environment = r.Workspace.Scope + "/providers/applications.core/environments/" + env - _, err = client.GetEnvironment(cmd.Context(), env) + validator := r.EnvironmentValidator + if validator == nil { + validator = ValidateApplicationsCoreEnvironment + } + envID, err := validator(cmd.Context(), r.Workspace, client, env) if err != nil { - return clierrors.Message("The environment %q does not exist. Run `rad env create` try again.", r.Workspace.Environment) + return err } + r.Workspace.Environment = envID } return nil diff --git a/pkg/cli/cmd/workspace/create/create_test.go b/pkg/cli/cmd/workspace/create/create_test.go index 02940cf4de..e6249fbae3 100644 --- a/pkg/cli/cmd/workspace/create/create_test.go +++ b/pkg/cli/cmd/workspace/create/create_test.go @@ -21,6 +21,7 @@ import ( "errors" "testing" + "github.com/radius-project/radius/pkg/cli/clients" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/helm" "github.com/radius-project/radius/pkg/cli/output" @@ -132,6 +133,37 @@ func Test_Validate(t *testing.T) { } +func Test_ValidateApplicationsCoreEnvironment(t *testing.T) { + ws := &workspaces.Workspace{Scope: "/planes/radius/local/resourceGroups/rg1"} + wantID := "/planes/radius/local/resourceGroups/rg1/providers/Applications.Core/environments/env1" + + t.Run("404 returns user-friendly does-not-exist message", func(t *testing.T) { + ctrl := gomock.NewController(t) + mgmt := clients.NewMockApplicationsManagementClient(ctrl) + mgmt.EXPECT().GetEnvironment(gomock.Any(), "env1").Return(corerp.EnvironmentResource{}, radcli.Create404Error()).Times(1) + + id, err := ValidateApplicationsCoreEnvironment(context.Background(), ws, mgmt, "env1") + require.Error(t, err) + require.Empty(t, id) + require.Contains(t, err.Error(), wantID) + require.Contains(t, err.Error(), "does not exist") + require.Contains(t, err.Error(), "rad env create") + }) + + t.Run("non-404 error is propagated and not masked as not-found", func(t *testing.T) { + ctrl := gomock.NewController(t) + mgmt := clients.NewMockApplicationsManagementClient(ctrl) + mgmt.EXPECT().GetEnvironment(gomock.Any(), "env1").Return(corerp.EnvironmentResource{}, errors.New("connection refused")).Times(1) + + id, err := ValidateApplicationsCoreEnvironment(context.Background(), ws, mgmt, "env1") + require.Error(t, err) + require.Empty(t, id) + require.Contains(t, err.Error(), "Failed to get environment") + require.Contains(t, err.Error(), wantID) + require.NotContains(t, err.Error(), "does not exist") + }) +} + func Test_Run(t *testing.T) { t.Run("Workspace Create", func(t *testing.T) { diff --git a/pkg/cli/cmd/workspace/create/preview/create.go b/pkg/cli/cmd/workspace/create/preview/create.go new file mode 100644 index 0000000000..8a4e4db70f --- /dev/null +++ b/pkg/cli/cmd/workspace/create/preview/create.go @@ -0,0 +1,117 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package preview implements the `rad workspace create --preview` command. It reuses +// the runner from the parent create package and only overrides the environment +// validation step so that the workspace is bound to a Radius.Core/environments +// resource (v20250801preview) instead of an Applications.Core/environments resource. +package preview + +import ( + "context" + + "github.com/spf13/cobra" + + "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" + workspace_create "github.com/radius-project/radius/pkg/cli/cmd/workspace/create" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/corerp/datamodel" +) + +// NewCommand creates an instance of the command and runner for the `rad workspace create --preview` command. +// +// The preview command behaves identically to `rad workspace create`, but binds the workspace +// to a Radius.Core/environments resource (v20250801preview) instead of an +// Applications.Core/environments resource. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + c := &cobra.Command{ + Use: "create [workspaceType] [workspaceName]", + Short: "Create a workspace (preview)", + Long: `Create a workspace bound to a Radius.Core (preview) environment. + +Available workspaceTypes: kubernetes + +Workspaces allow you to manage multiple Radius platforms and environments using a local configuration file. + +Use this command together with environments created via 'rad env create --preview'.`, + Args: workspace_create.ValidateArgs(), + Example: ` +# Create a workspace bound to a Radius.Core (preview) environment +rad workspace create kubernetes my-workspace --group my-grp --environment my-env --preview + +# Create a workspace using the current kubernetes context as the workspace name +rad workspace create kubernetes --preview`, + RunE: framework.RunCommand(runner), + } + + commonflags.AddWorkspaceFlag(c) + commonflags.AddResourceGroupFlag(c) + commonflags.AddEnvironmentNameFlag(c) + c.Flags().BoolP("force", "f", false, "Overwrite existing workspace if present") + c.Flags().StringP("context", "c", "", "the Kubernetes context to use, will use the default if unset") + + return c, runner +} + +// Runner is the runner implementation for the `rad workspace create --preview` command. +// +// It embeds the legacy create.Runner and overrides only the environment validation +// step. All other workspace-creation behaviour (kube context, install check, group +// lookup, persistence) is inherited unchanged. +type Runner struct { + *workspace_create.Runner + + // RadiusCoreClientFactory validates the Radius.Core/environments resource. When nil, + // it is initialized lazily from the workspace connection. Tests may pre-populate it. + RadiusCoreClientFactory *corerpv20250801.ClientFactory +} + +// NewRunner creates a new instance of the `rad workspace create --preview` runner. +func NewRunner(factory framework.Factory) *Runner { + base := workspace_create.NewRunner(factory) + r := &Runner{Runner: base} + base.EnvironmentValidator = r.validateRadiusCoreEnvironment + return r +} + +// validateRadiusCoreEnvironment validates that the Radius.Core/environments resource +// exists in the workspace scope and returns its fully-qualified resource ID. +func (r *Runner) validateRadiusCoreEnvironment(ctx context.Context, ws *workspaces.Workspace, _ clients.ApplicationsManagementClient, envName string) (string, error) { + envID := ws.Scope + "/providers/" + datamodel.EnvironmentResourceType_v20250801preview + "/" + envName + + if r.RadiusCoreClientFactory == nil { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, ws, ws.Scope) + if err != nil { + return "", err + } + r.RadiusCoreClientFactory = clientFactory + } + + if _, err := r.RadiusCoreClientFactory.NewEnvironmentsClient().Get(ctx, envName, nil); err != nil { + if clients.Is404Error(err) { + return "", clierrors.Message("The environment %q does not exist. Run `rad env create --preview` and try again.", envID) + } + return "", clierrors.MessageWithCause(err, "Failed to get environment %q.", envID) + } + return envID, nil +} diff --git a/pkg/cli/cmd/workspace/create/preview/create_test.go b/pkg/cli/cmd/workspace/create/preview/create_test.go new file mode 100644 index 0000000000..346e9ad78a --- /dev/null +++ b/pkg/cli/cmd/workspace/create/preview/create_test.go @@ -0,0 +1,259 @@ +/* +Copyright 2023 The Radius Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package preview + +import ( + "context" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "k8s.io/client-go/tools/clientcmd/api" + + "github.com/radius-project/radius/pkg/cli/clients" + workspace_create "github.com/radius-project/radius/pkg/cli/cmd/workspace/create" + "github.com/radius-project/radius/pkg/cli/connections" + "github.com/radius-project/radius/pkg/cli/framework" + "github.com/radius-project/radius/pkg/cli/helm" + "github.com/radius-project/radius/pkg/cli/kubernetes" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/test_client_factory" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/test/radcli" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + configWithWorkspace := radcli.LoadConfigWithWorkspace(t) + + testcases := []radcli.ValidateInput{ + { + Name: "preview create command with workspace type not kubernetes", + Input: []string{"notkubernetes", "b"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "preview create command with too many args", + Input: []string{"kubernetes", "rg", "env", "ws"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + }, + { + Name: "preview create command with radius not installed", + Input: []string{"kubernetes", "-w", "ws", "-g", "rg1"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + mocks.Kubernetes.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + mocks.Helm.EXPECT().CheckRadiusInstall(gomock.Any()).Return(helm.InstallState{RadiusInstalled: false}, nil).Times(1) + }, + }, + { + Name: "preview create command with non-existing resource group", + Input: []string{"kubernetes", "-w", "ws", "-g", "rg1"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "filePath", + Config: configWithWorkspace, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + mocks.Kubernetes.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + mocks.Helm.EXPECT().CheckRadiusInstall(gomock.Any()).Return(helm.InstallState{RadiusInstalled: true}, nil).Times(1) + mocks.ApplicationManagementClient.EXPECT().GetResourceGroup(gomock.Any(), "local", "rg1").Return(ucp.ResourceGroupResource{}, radcli.Create404Error()).Times(1) + }, + }, + { + Name: "preview create command with valid resource group and no environment", + Input: []string{"kubernetes", "-w", "ws", "-g", "rg1"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + mocks.Kubernetes.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + mocks.Helm.EXPECT().CheckRadiusInstall(gomock.Any()).Return(helm.InstallState{RadiusInstalled: true}, nil).Times(1) + mocks.ApplicationManagementClient.EXPECT().GetResourceGroup(gomock.Any(), "local", "rg1").Return(ucp.ResourceGroupResource{}, nil).Times(1) + }, + }, + { + Name: "preview create command with environment but empty scope", + Input: []string{"kubernetes", "-w", "ws", "-e", "env1"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: configWithWorkspace, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + mocks.Kubernetes.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + mocks.Helm.EXPECT().CheckRadiusInstall(gomock.Any()).Return(helm.InstallState{RadiusInstalled: true}, nil).Times(1) + }, + }, + } + + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Validate_Environment(t *testing.T) { + scope := "/planes/radius/local/resourceGroups/rg1" + + t.Run("environment exists", func(t *testing.T) { + ctrl := gomock.NewController(t) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory(scope, nil, nil) + require.NoError(t, err) + + runner, cmd := newRunnerForEnvValidation(t, ctrl, factory) + require.NoError(t, cmd.ParseFlags([]string{"kubernetes", "-w", "ws", "-g", "rg1", "-e", "env1"})) + + err = runner.Validate(cmd, []string{"kubernetes"}) + require.NoError(t, err) + require.Equal(t, "/planes/radius/local/resourceGroups/rg1/providers/Radius.Core/environments/env1", runner.Workspace.Environment) + }) + + t.Run("environment not found returns clierror referencing Radius.Core", func(t *testing.T) { + ctrl := gomock.NewController(t) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + scope, + test_client_factory.WithEnvironmentServer404OnGet, + nil, + ) + require.NoError(t, err) + + runner, cmd := newRunnerForEnvValidation(t, ctrl, factory) + require.NoError(t, cmd.ParseFlags([]string{"kubernetes", "-w", "ws", "-g", "rg1", "-e", "env1"})) + + err = runner.Validate(cmd, []string{"kubernetes"}) + require.Error(t, err) + require.Contains(t, err.Error(), "Radius.Core/environments/env1") + require.Contains(t, err.Error(), "rad env create --preview") + }) + + t.Run("non-404 error is propagated and not masked as not-found", func(t *testing.T) { + ctrl := gomock.NewController(t) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + scope, + test_client_factory.WithEnvironmentServer500OnGet, + nil, + ) + require.NoError(t, err) + + runner, cmd := newRunnerForEnvValidation(t, ctrl, factory) + require.NoError(t, cmd.ParseFlags([]string{"kubernetes", "-w", "ws", "-g", "rg1", "-e", "env1"})) + + err = runner.Validate(cmd, []string{"kubernetes"}) + require.Error(t, err) + require.Contains(t, err.Error(), "Failed to get environment") + require.Contains(t, err.Error(), "Radius.Core/environments/env1") + require.NotContains(t, err.Error(), "does not exist") + }) +} + +func Test_Run(t *testing.T) { + t.Run("Workspace Create Preview", func(t *testing.T) { + ctrl := gomock.NewController(t) + outputSink := &output.MockOutput{} + workspace := &workspaces.Workspace{ + Name: "defaultWorkspace", + Connection: map[string]any{ + "kind": "kubernetes", + "context": "kind-kind", + }, + } + + configFileInterface := framework.NewMockConfigFileInterface(ctrl) + configFileInterface.EXPECT(). + EditWorkspaces(context.Background(), gomock.Any(), workspace). + Return(nil).Times(1) + + runner := &Runner{ + Runner: &workspace_create.Runner{ + ConfigFileInterface: configFileInterface, + ConfigHolder: &framework.ConfigHolder{ConfigFilePath: "filePath"}, + Workspace: workspace, + Force: true, + Output: outputSink, + }, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + }) +} + +// newRunnerForEnvValidation builds a Runner and cobra command pre-wired with mocks for +// the kubernetes/helm/resource-group calls so tests can focus on the Radius.Core +// environment validation path. +func newRunnerForEnvValidation(t *testing.T, ctrl *gomock.Controller, factory *corerpv20250801.ClientFactory) (*Runner, *cobra.Command) { + t.Helper() + + configHolder := &framework.ConfigHolder{Config: radcli.LoadConfigWithWorkspace(t)} + + kubeMock := kubernetes.NewMockInterface(ctrl) + kubeMock.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + + helmMock := helm.NewMockInterface(ctrl) + helmMock.EXPECT().CheckRadiusInstall(gomock.Any()).Return(helm.InstallState{RadiusInstalled: true}, nil).Times(1) + + mgmtMock := clients.NewMockApplicationsManagementClient(ctrl) + mgmtMock.EXPECT().GetResourceGroup(gomock.Any(), "local", "rg1").Return(ucp.ResourceGroupResource{}, nil).Times(1) + + fw := &framework.Impl{ + ConfigHolder: configHolder, + Output: &output.MockOutput{}, + KubernetesInterface: kubeMock, + HelmInterface: helmMock, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mgmtMock}, + } + + cmd, runnerIface := NewCommand(fw) + cmd.SetContext(context.Background()) + runner := runnerIface.(*Runner) + runner.RadiusCoreClientFactory = factory + + return runner, cmd +} + +func getTestKubeConfig() *api.Config { + kubeContexts := map[string]*api.Context{ + "docker-desktop": {Cluster: "docker-desktop"}, + "k3d-radius-dev": {Cluster: "k3d-radius-dev"}, + "kind-kind": {Cluster: "kind-kind"}, + } + return &api.Config{ + CurrentContext: "kind-kind", + Contexts: kubeContexts, + } +} diff --git a/pkg/cli/test_client_factory/radius_core.go b/pkg/cli/test_client_factory/radius_core.go index de3b82ba69..bb54947523 100644 --- a/pkg/cli/test_client_factory/radius_core.go +++ b/pkg/cli/test_client_factory/radius_core.go @@ -251,6 +251,22 @@ func WithEnvironmentServer404OnGet() corerpfake.EnvironmentsServer { } } +// WithEnvironmentServer500OnGet returns an EnvironmentsServer that returns a non-404 +// server error on Get, simulating a transient/auth/network failure. +func WithEnvironmentServer500OnGet() corerpfake.EnvironmentsServer { + return corerpfake.EnvironmentsServer{ + Get: func( + ctx context.Context, + environmentName string, + options *v20250801preview.EnvironmentsClientGetOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientGetResponse], errResp azfake.ErrorResponder) { + errResp.SetError(fmt.Errorf("internal server error")) + errResp.SetResponseError(500, "Internal Server Error") + return + }, + } +} + // WithEnvironmentServerNoRecipePacks returns an EnvironmentsServer that returns an existing // environment with no recipe packs on Get, and success on CreateOrUpdate. func WithEnvironmentServerNoRecipePacks() corerpfake.EnvironmentsServer {