diff --git a/cmd/rad/cmd/root.go b/cmd/rad/cmd/root.go index 105fcf0900..26712db09d 100644 --- a/cmd/rad/cmd/root.go +++ b/cmd/rad/cmd/root.go @@ -58,6 +58,7 @@ import ( "github.com/radius-project/radius/pkg/cli/cmd/install" install_kubernetes "github.com/radius-project/radius/pkg/cli/cmd/install/kubernetes" "github.com/radius-project/radius/pkg/cli/cmd/radinit" + radinit_preview "github.com/radius-project/radius/pkg/cli/cmd/radinit/preview" recipe_list "github.com/radius-project/radius/pkg/cli/cmd/recipe/list" recipe_register "github.com/radius-project/radius/pkg/cli/cmd/recipe/register" recipe_show "github.com/radius-project/radius/pkg/cli/cmd/recipe/show" @@ -342,6 +343,8 @@ func initSubCommands() { RootCmd.AddCommand(groupCmd) initCmd, _ := radinit.NewCommand(framework) + previewInitCmd, _ := radinit_preview.NewCommand(framework) + wirePreviewSubcommand(initCmd, previewInitCmd) RootCmd.AddCommand(initCmd) envCreateCmd, _ := env_create.NewCommand(framework) diff --git a/pkg/cli/bicep/resources.go b/pkg/cli/bicep/resources.go index 8a2e2b67b3..b371b1ae8a 100644 --- a/pkg/cli/bicep/resources.go +++ b/pkg/cli/bicep/resources.go @@ -39,6 +39,9 @@ var radiusNamespacePatterns = []string{ type TemplateInspectionResult struct { // ContainsEnvironmentResource indicates whether the template contains an environment resource. ContainsEnvironmentResource bool + + // EnvironmentResources contains the list of environment resources found in the template. + EnvironmentResources []map[string]any } // ResourceTypeEntry represents a parsed resource type from a compiled Bicep/ARM template. @@ -169,6 +172,11 @@ func InspectTemplateResources(template map[string]any) TemplateInspectionResult strings.HasPrefix(resourceTypeLower, legacyEnvironmentResourceType) { result.ContainsEnvironmentResource = true } + + // add Radius.Core environment resources to the result list + if strings.HasPrefix(resourceTypeLower, environmentResourceType) { + result.EnvironmentResources = append(result.EnvironmentResources, resource) + } } return result @@ -182,3 +190,9 @@ func InspectTemplateResources(template map[string]any) TemplateInspectionResult func ContainsEnvironmentResource(template map[string]any) bool { return InspectTemplateResources(template).ContainsEnvironmentResource } + +// GetEnvironmentResources inspects the compiled Radius Bicep template's resources and returns +// all environment resources found as maps. +func GetEnvironmentResources(template map[string]any) []map[string]any { + return InspectTemplateResources(template).EnvironmentResources +} diff --git a/pkg/cli/clients/clients.go b/pkg/cli/clients/clients.go index 8cb7a7b389..412d16e426 100644 --- a/pkg/cli/clients/clients.go +++ b/pkg/cli/clients/clients.go @@ -203,6 +203,9 @@ type ApplicationsManagementClient interface { // ListEnvironmentsAll lists all environments across resource groups. ListEnvironmentsAll(ctx context.Context) ([]corerp.EnvironmentResource, error) + // ListRadiusCoreEnvironmentsAll lists all Radius.Core environments across resource groups. + ListRadiusCoreEnvironmentsAll(ctx context.Context) ([]radiuscore.EnvironmentResource, error) + // ListRecipePacksInResourceGroup lists all recipe packs in the configured scope (assumes configured scope is a resource group). ListRecipePacksInResourceGroup(ctx context.Context) ([]radiuscore.RecipePackResource, error) diff --git a/pkg/cli/clients/management.go b/pkg/cli/clients/management.go index 0ffc95fc81..a37002b2f3 100644 --- a/pkg/cli/clients/management.go +++ b/pkg/cli/clients/management.go @@ -39,18 +39,19 @@ import ( ) type UCPApplicationsManagementClient struct { - RootScope string - ClientOptions *arm.ClientOptions - genericResourceClientFactory func(scope string, resourceType string) (genericResourceClient, error) - applicationResourceClientFactory func(scope string) (applicationResourceClient, error) - environmentResourceClientFactory func(scope string) (environmentResourceClient, error) - recipePackResourceClientFactory func(scope string) (recipePackResourceClient, error) - resourceGroupClientFactory func() (resourceGroupClient, error) - resourceProviderClientFactory func() (resourceProviderClient, error) - resourceTypeClientFactory func() (resourceTypeClient, error) - apiVersionClientFactory func() (apiVersionClient, error) - locationClientFactory func() (locationClient, error) - capture func(ctx context.Context, capture **http.Response) context.Context + RootScope string + ClientOptions *arm.ClientOptions + genericResourceClientFactory func(scope string, resourceType string) (genericResourceClient, error) + applicationResourceClientFactory func(scope string) (applicationResourceClient, error) + environmentResourceClientFactory func(scope string) (environmentResourceClient, error) + recipePackResourceClientFactory func(scope string) (recipePackResourceClient, error) + radiusCoreEnvironmentResourceClientFactory func(scope string) (radiusCoreEnvironmentResourceClient, error) + resourceGroupClientFactory func() (resourceGroupClient, error) + resourceProviderClientFactory func() (resourceProviderClient, error) + resourceTypeClientFactory func() (resourceTypeClient, error) + apiVersionClientFactory func() (apiVersionClient, error) + locationClientFactory func() (locationClient, error) + capture func(ctx context.Context, capture **http.Response) context.Context } var _ ApplicationsManagementClient = (*UCPApplicationsManagementClient)(nil) @@ -519,6 +520,46 @@ func (amc *UCPApplicationsManagementClient) ListEnvironmentsAll(ctx context.Cont return environments, nil } +// ListRadiusCoreEnvironmentsAll queries the plane scope for all Radius.Core environment resources +// across all resource groups and returns them as a slice. It mirrors ListEnvironmentsAll, but +// targets Radius.Core/environments (v20250801preview) instead of Applications.Core/environments +// (v20231001preview). +func (amc *UCPApplicationsManagementClient) ListRadiusCoreEnvironmentsAll(ctx context.Context) ([]corerpv20250801.EnvironmentResource, error) { + scope, err := resources.ParseScope(amc.RootScope) + if err != nil { + return []corerpv20250801.EnvironmentResource{}, err + } + + // Query at plane scope, not resource group scope. We don't enforce the exact structure of the scope, so handle both cases. + // + // - /planes/radius/local + // - /planes/radius/local/resourceGroups/my-group + if scope.FindScope(resources_radius.ScopeResourceGroups) != "" { + scope = scope.Truncate() + } + + // Generated client doesn't like the leading '/' in the scope. + client, err := amc.createRadiusCoreEnvironmentClient(scope.String()) + if err != nil { + return []corerpv20250801.EnvironmentResource{}, err + } + + environments := []corerpv20250801.EnvironmentResource{} + pager := client.NewListByScopePager(&corerpv20250801.EnvironmentsClientListByScopeOptions{}) + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return []corerpv20250801.EnvironmentResource{}, err + } + + for _, environment := range page.EnvironmentResourceListResult.Value { + environments = append(environments, *environment) + } + } + + return environments, nil +} + // GetEnvironment retrieves an environment by its name (in the configured scope) or resource ID. func (amc *UCPApplicationsManagementClient) GetEnvironment(ctx context.Context, environmentNameOrID string) (corerpv20231001.EnvironmentResource, error) { scope, name, err := amc.extractScopeAndName(environmentNameOrID) @@ -1223,6 +1264,15 @@ func (amc *UCPApplicationsManagementClient) createEnvironmentClient(scope string return amc.environmentResourceClientFactory(scope) } +func (amc *UCPApplicationsManagementClient) createRadiusCoreEnvironmentClient(scope string) (radiusCoreEnvironmentResourceClient, error) { + if amc.radiusCoreEnvironmentResourceClientFactory == nil { + // Generated client doesn't like the leading '/' in the scope. + return corerpv20250801.NewEnvironmentsClient(strings.TrimPrefix(scope, resources.SegmentSeparator), &aztoken.AnonymousCredential{}, amc.ClientOptions) + } + + return amc.radiusCoreEnvironmentResourceClientFactory(scope) +} + func (amc *UCPApplicationsManagementClient) createGenericClient(scope string, resourceType string, apiVersion ...string) (genericResourceClient, error) { if amc.genericResourceClientFactory == nil { clientOptions := *amc.ClientOptions diff --git a/pkg/cli/clients/management_mocks.go b/pkg/cli/clients/management_mocks.go index a235dbf354..1ef9e4a22d 100644 --- a/pkg/cli/clients/management_mocks.go +++ b/pkg/cli/clients/management_mocks.go @@ -35,7 +35,7 @@ import ( // Because these interfaces are non-exported, they MUST be defined in their own file // and we MUST use -source on mockgen to generate mocks for them. -//go:generate mockgen -typed -source=./management_mocks.go -destination=./mock_management_wrapped_clients.go -package=clients -self_package github.com/radius-project/radius/pkg/cli/clients github.com/radius-project/radius/pkg/cli/clients genericResourceClient,applicationResourceClient,environmentResourceClient,resourceGroupClient,resourceProviderClient,resourceTypeClient,apiVersonClient,locationClient,recipePackResourceClient +//go:generate mockgen -typed -source=./management_mocks.go -destination=./mock_management_wrapped_clients.go -package=clients -self_package github.com/radius-project/radius/pkg/cli/clients github.com/radius-project/radius/pkg/cli/clients genericResourceClient,applicationResourceClient,environmentResourceClient,resourceGroupClient,resourceProviderClient,resourceTypeClient,apiVersonClient,locationClient,recipePackResourceClient,radiusCoreEnvironmentResourceClient // genericResourceClient is an interface for mocking the generated SDK client for any resource. type genericResourceClient interface { @@ -106,3 +106,11 @@ type recipePackResourceClient interface { Get(ctx context.Context, recipePackName string, options *corerpv20250801.RecipePacksClientGetOptions) (corerpv20250801.RecipePacksClientGetResponse, error) NewListByScopePager(options *corerpv20250801.RecipePacksClientListByScopeOptions) *runtime.Pager[corerpv20250801.RecipePacksClientListByScopeResponse] } + +// radiusCoreEnvironmentResourceClient is an interface for mocking the generated SDK client for Radius.Core/environments resources. +type radiusCoreEnvironmentResourceClient interface { + CreateOrUpdate(ctx context.Context, environmentName string, resource corerpv20250801.EnvironmentResource, options *corerpv20250801.EnvironmentsClientCreateOrUpdateOptions) (corerpv20250801.EnvironmentsClientCreateOrUpdateResponse, error) + Delete(ctx context.Context, environmentName string, options *corerpv20250801.EnvironmentsClientDeleteOptions) (corerpv20250801.EnvironmentsClientDeleteResponse, error) + Get(ctx context.Context, environmentName string, options *corerpv20250801.EnvironmentsClientGetOptions) (corerpv20250801.EnvironmentsClientGetResponse, error) + NewListByScopePager(options *corerpv20250801.EnvironmentsClientListByScopeOptions) *runtime.Pager[corerpv20250801.EnvironmentsClientListByScopeResponse] +} diff --git a/pkg/cli/clients/management_test.go b/pkg/cli/clients/management_test.go index 9bda729b1e..9df92d8cc6 100644 --- a/pkg/cli/clients/management_test.go +++ b/pkg/cli/clients/management_test.go @@ -29,6 +29,7 @@ import ( v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/cli/clients_new/generated" corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" "github.com/radius-project/radius/pkg/to" ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/stretchr/testify/require" @@ -1362,6 +1363,75 @@ func Test_Environment(t *testing.T) { }) } +func Test_RadiusCoreEnvironment(t *testing.T) { + t.Parallel() + createClient := func(wrapped radiusCoreEnvironmentResourceClient) *UCPApplicationsManagementClient { + return &UCPApplicationsManagementClient{ + RootScope: testScope, + radiusCoreEnvironmentResourceClientFactory: func(scope string) (radiusCoreEnvironmentResourceClient, error) { + return wrapped, nil + }, + capture: testCapture, + } + } + + testResourceType := "Radius.Core/environments" + + listPages := []corerpv20250801.EnvironmentsClientListByScopeResponse{ + { + EnvironmentResourceListResult: corerpv20250801.EnvironmentResourceListResult{ + Value: []*corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr(testScope + "/providers/" + testResourceType + "/" + "test1"), + Name: to.Ptr("test1"), + Type: &testResourceType, + Location: to.Ptr(v1.LocationGlobal), + }, + { + ID: to.Ptr(testScope + "/providers/" + testResourceType + "/" + "test2"), + Name: to.Ptr("test2"), + Type: &testResourceType, + Location: to.Ptr(v1.LocationGlobal), + }, + }, + NextLink: to.Ptr("0"), + }, + }, + { + EnvironmentResourceListResult: corerpv20250801.EnvironmentResourceListResult{ + Value: []*corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr(testScope + "/providers/" + testResourceType + "/" + "test3"), + Name: to.Ptr("test3"), + Type: &testResourceType, + Location: to.Ptr(v1.LocationGlobal), + }, + }, + NextLink: to.Ptr("1"), + }, + }, + } + + t.Run("ListRadiusCoreEnvironmentsAll", func(t *testing.T) { + mock := NewMockradiusCoreEnvironmentResourceClient(gomock.NewController(t)) + client := createClient(mock) + + mock.EXPECT(). + NewListByScopePager(gomock.Any()). + Return(pager(listPages)) + + expected := []corerpv20250801.EnvironmentResource{ + *listPages[0].Value[0], + *listPages[0].Value[1], + *listPages[1].Value[0], + } + + resources, err := client.ListRadiusCoreEnvironmentsAll(context.Background()) + require.NoError(t, err) + require.Equal(t, expected, resources) + }) +} + func Test_ResourceGroup(t *testing.T) { t.Parallel() createClient := func(wrapped resourceGroupClient) *UCPApplicationsManagementClient { diff --git a/pkg/cli/clients/mock_applicationsclient.go b/pkg/cli/clients/mock_applicationsclient.go index e90cc45d92..551b1b47b7 100644 --- a/pkg/cli/clients/mock_applicationsclient.go +++ b/pkg/cli/clients/mock_applicationsclient.go @@ -1170,6 +1170,45 @@ func (c *MockApplicationsManagementClientListEnvironmentsAllCall) DoAndReturn(f return c } +// ListRadiusCoreEnvironmentsAll mocks base method. +func (m *MockApplicationsManagementClient) ListRadiusCoreEnvironmentsAll(arg0 context.Context) ([]v20250801preview.EnvironmentResource, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRadiusCoreEnvironmentsAll", arg0) + ret0, _ := ret[0].([]v20250801preview.EnvironmentResource) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListRadiusCoreEnvironmentsAll indicates an expected call of ListRadiusCoreEnvironmentsAll. +func (mr *MockApplicationsManagementClientMockRecorder) ListRadiusCoreEnvironmentsAll(arg0 any) *MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRadiusCoreEnvironmentsAll", reflect.TypeOf((*MockApplicationsManagementClient)(nil).ListRadiusCoreEnvironmentsAll), arg0) + return &MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall{Call: call} +} + +// MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall wrap *gomock.Call +type MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall) Return(arg0 []v20250801preview.EnvironmentResource, arg1 error) *MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall) Do(f func(context.Context) ([]v20250801preview.EnvironmentResource, error)) *MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall) DoAndReturn(f func(context.Context) ([]v20250801preview.EnvironmentResource, error)) *MockApplicationsManagementClientListRadiusCoreEnvironmentsAllCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + // ListRecipePacks mocks base method. func (m *MockApplicationsManagementClient) ListRecipePacks(arg0 context.Context) ([]v20250801preview.RecipePackResource, error) { m.ctrl.T.Helper() diff --git a/pkg/cli/clients/mock_management_wrapped_clients.go b/pkg/cli/clients/mock_management_wrapped_clients.go index 256c8c0c96..75ff897c76 100644 --- a/pkg/cli/clients/mock_management_wrapped_clients.go +++ b/pkg/cli/clients/mock_management_wrapped_clients.go @@ -3,7 +3,7 @@ // // Generated by this command: // -// mockgen -typed -source=./management_mocks.go -destination=./mock_management_wrapped_clients.go -package=clients -self_package github.com/radius-project/radius/pkg/cli/clients github.com/radius-project/radius/pkg/cli/clients genericResourceClient,applicationResourceClient,environmentResourceClient,resourceGroupClient,resourceProviderClient,resourceTypeClient,apiVersonClient,locationClient,recipePackResourceClient +// mockgen -typed -source=./management_mocks.go -destination=./mock_management_wrapped_clients.go -package=clients -self_package github.com/radius-project/radius/pkg/cli/clients github.com/radius-project/radius/pkg/cli/clients genericResourceClient,applicationResourceClient,environmentResourceClient,resourceGroupClient,resourceProviderClient,resourceTypeClient,apiVersonClient,locationClient,recipePackResourceClient,radiusCoreEnvironmentResourceClient // // Package clients is a generated GoMock package. @@ -1468,3 +1468,181 @@ func (c *MockrecipePackResourceClientNewListByScopePagerCall) DoAndReturn(f func c.Call = c.Call.DoAndReturn(f) return c } + +// MockradiusCoreEnvironmentResourceClient is a mock of radiusCoreEnvironmentResourceClient interface. +type MockradiusCoreEnvironmentResourceClient struct { + ctrl *gomock.Controller + recorder *MockradiusCoreEnvironmentResourceClientMockRecorder +} + +// MockradiusCoreEnvironmentResourceClientMockRecorder is the mock recorder for MockradiusCoreEnvironmentResourceClient. +type MockradiusCoreEnvironmentResourceClientMockRecorder struct { + mock *MockradiusCoreEnvironmentResourceClient +} + +// NewMockradiusCoreEnvironmentResourceClient creates a new mock instance. +func NewMockradiusCoreEnvironmentResourceClient(ctrl *gomock.Controller) *MockradiusCoreEnvironmentResourceClient { + mock := &MockradiusCoreEnvironmentResourceClient{ctrl: ctrl} + mock.recorder = &MockradiusCoreEnvironmentResourceClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockradiusCoreEnvironmentResourceClient) EXPECT() *MockradiusCoreEnvironmentResourceClientMockRecorder { + return m.recorder +} + +// CreateOrUpdate mocks base method. +func (m *MockradiusCoreEnvironmentResourceClient) CreateOrUpdate(ctx context.Context, environmentName string, resource v20250801preview.EnvironmentResource, options *v20250801preview.EnvironmentsClientCreateOrUpdateOptions) (v20250801preview.EnvironmentsClientCreateOrUpdateResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CreateOrUpdate", ctx, environmentName, resource, options) + ret0, _ := ret[0].(v20250801preview.EnvironmentsClientCreateOrUpdateResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CreateOrUpdate indicates an expected call of CreateOrUpdate. +func (mr *MockradiusCoreEnvironmentResourceClientMockRecorder) CreateOrUpdate(ctx, environmentName, resource, options any) *MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateOrUpdate", reflect.TypeOf((*MockradiusCoreEnvironmentResourceClient)(nil).CreateOrUpdate), ctx, environmentName, resource, options) + return &MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall{Call: call} +} + +// MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall wrap *gomock.Call +type MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall) Return(arg0 v20250801preview.EnvironmentsClientCreateOrUpdateResponse, arg1 error) *MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall) Do(f func(context.Context, string, v20250801preview.EnvironmentResource, *v20250801preview.EnvironmentsClientCreateOrUpdateOptions) (v20250801preview.EnvironmentsClientCreateOrUpdateResponse, error)) *MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall) DoAndReturn(f func(context.Context, string, v20250801preview.EnvironmentResource, *v20250801preview.EnvironmentsClientCreateOrUpdateOptions) (v20250801preview.EnvironmentsClientCreateOrUpdateResponse, error)) *MockradiusCoreEnvironmentResourceClientCreateOrUpdateCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Delete mocks base method. +func (m *MockradiusCoreEnvironmentResourceClient) Delete(ctx context.Context, environmentName string, options *v20250801preview.EnvironmentsClientDeleteOptions) (v20250801preview.EnvironmentsClientDeleteResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", ctx, environmentName, options) + ret0, _ := ret[0].(v20250801preview.EnvironmentsClientDeleteResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Delete indicates an expected call of Delete. +func (mr *MockradiusCoreEnvironmentResourceClientMockRecorder) Delete(ctx, environmentName, options any) *MockradiusCoreEnvironmentResourceClientDeleteCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockradiusCoreEnvironmentResourceClient)(nil).Delete), ctx, environmentName, options) + return &MockradiusCoreEnvironmentResourceClientDeleteCall{Call: call} +} + +// MockradiusCoreEnvironmentResourceClientDeleteCall wrap *gomock.Call +type MockradiusCoreEnvironmentResourceClientDeleteCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockradiusCoreEnvironmentResourceClientDeleteCall) Return(arg0 v20250801preview.EnvironmentsClientDeleteResponse, arg1 error) *MockradiusCoreEnvironmentResourceClientDeleteCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockradiusCoreEnvironmentResourceClientDeleteCall) Do(f func(context.Context, string, *v20250801preview.EnvironmentsClientDeleteOptions) (v20250801preview.EnvironmentsClientDeleteResponse, error)) *MockradiusCoreEnvironmentResourceClientDeleteCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockradiusCoreEnvironmentResourceClientDeleteCall) DoAndReturn(f func(context.Context, string, *v20250801preview.EnvironmentsClientDeleteOptions) (v20250801preview.EnvironmentsClientDeleteResponse, error)) *MockradiusCoreEnvironmentResourceClientDeleteCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// Get mocks base method. +func (m *MockradiusCoreEnvironmentResourceClient) Get(ctx context.Context, environmentName string, options *v20250801preview.EnvironmentsClientGetOptions) (v20250801preview.EnvironmentsClientGetResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", ctx, environmentName, options) + ret0, _ := ret[0].(v20250801preview.EnvironmentsClientGetResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockradiusCoreEnvironmentResourceClientMockRecorder) Get(ctx, environmentName, options any) *MockradiusCoreEnvironmentResourceClientGetCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockradiusCoreEnvironmentResourceClient)(nil).Get), ctx, environmentName, options) + return &MockradiusCoreEnvironmentResourceClientGetCall{Call: call} +} + +// MockradiusCoreEnvironmentResourceClientGetCall wrap *gomock.Call +type MockradiusCoreEnvironmentResourceClientGetCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockradiusCoreEnvironmentResourceClientGetCall) Return(arg0 v20250801preview.EnvironmentsClientGetResponse, arg1 error) *MockradiusCoreEnvironmentResourceClientGetCall { + c.Call = c.Call.Return(arg0, arg1) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockradiusCoreEnvironmentResourceClientGetCall) Do(f func(context.Context, string, *v20250801preview.EnvironmentsClientGetOptions) (v20250801preview.EnvironmentsClientGetResponse, error)) *MockradiusCoreEnvironmentResourceClientGetCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockradiusCoreEnvironmentResourceClientGetCall) DoAndReturn(f func(context.Context, string, *v20250801preview.EnvironmentsClientGetOptions) (v20250801preview.EnvironmentsClientGetResponse, error)) *MockradiusCoreEnvironmentResourceClientGetCall { + c.Call = c.Call.DoAndReturn(f) + return c +} + +// NewListByScopePager mocks base method. +func (m *MockradiusCoreEnvironmentResourceClient) NewListByScopePager(options *v20250801preview.EnvironmentsClientListByScopeOptions) *runtime.Pager[v20250801preview.EnvironmentsClientListByScopeResponse] { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewListByScopePager", options) + ret0, _ := ret[0].(*runtime.Pager[v20250801preview.EnvironmentsClientListByScopeResponse]) + return ret0 +} + +// NewListByScopePager indicates an expected call of NewListByScopePager. +func (mr *MockradiusCoreEnvironmentResourceClientMockRecorder) NewListByScopePager(options any) *MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall { + mr.mock.ctrl.T.Helper() + call := mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewListByScopePager", reflect.TypeOf((*MockradiusCoreEnvironmentResourceClient)(nil).NewListByScopePager), options) + return &MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall{Call: call} +} + +// MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall wrap *gomock.Call +type MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall struct { + *gomock.Call +} + +// Return rewrite *gomock.Call.Return +func (c *MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall) Return(arg0 *runtime.Pager[v20250801preview.EnvironmentsClientListByScopeResponse]) *MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall { + c.Call = c.Call.Return(arg0) + return c +} + +// Do rewrite *gomock.Call.Do +func (c *MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall) Do(f func(*v20250801preview.EnvironmentsClientListByScopeOptions) *runtime.Pager[v20250801preview.EnvironmentsClientListByScopeResponse]) *MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall { + c.Call = c.Call.Do(f) + return c +} + +// DoAndReturn rewrite *gomock.Call.DoAndReturn +func (c *MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall) DoAndReturn(f func(*v20250801preview.EnvironmentsClientListByScopeOptions) *runtime.Pager[v20250801preview.EnvironmentsClientListByScopeResponse]) *MockradiusCoreEnvironmentResourceClientNewListByScopePagerCall { + c.Call = c.Call.DoAndReturn(f) + return c +} diff --git a/pkg/cli/cmd/deploy/deploy.go b/pkg/cli/cmd/deploy/deploy.go index 7355bc824e..db9a8b508f 100644 --- a/pkg/cli/cmd/deploy/deploy.go +++ b/pkg/cli/cmd/deploy/deploy.go @@ -39,6 +39,7 @@ import ( "github.com/radius-project/radius/pkg/cli/filesystem" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/recipepack" "github.com/radius-project/radius/pkg/cli/workspaces" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" @@ -142,6 +143,9 @@ type Runner struct { RadiusCoreClientFactory *v20250801preview.ClientFactory Deploy deploy.Interface Output output.Interface + // DefaultScopeClientFactory is the client factory scoped to the default resource group. + // The default recipe pack is always created/queried in the default scope. + DefaultScopeClientFactory *v20250801preview.ClientFactory ApplicationName string EnvironmentNameOrID string @@ -339,6 +343,14 @@ func (r *Runner) Run(ctx context.Context) error { "Deployment In Progress... ", r.FilePath, r.ApplicationName, r.EnvironmentNameOrID, r.Workspace.Name) } + // Before deploying, set up recipe packs for any Radius.Core environments in the + // template. This creates default recipe pack resource if not found and injects its + // ID into the template. + err = r.setupRecipePack(ctx, template) + if err != nil { + return err + } + _, err = r.Deploy.DeployWithProgress(ctx, deploy.Options{ ConnectionFactory: r.ConnectionFactory, Workspace: *r.Workspace, @@ -648,6 +660,100 @@ func (r *Runner) setupCloudProviders(properties any) { } } +// setupRecipePack ensures recipe pack(s) for all Radius.Core/environments resources in the template. +// If a Radius.Core environment resource has no recipe +// packs set by the user, Radius creates(if needed) and fetches the default recipe pack from the default scope and +// injects its ID into the template. If the environment already has any recipe pack +// IDs set (literal or Bicep expression references), no changes are made. +func (r *Runner) setupRecipePack(ctx context.Context, template map[string]any) error { + envResources := bicep.GetEnvironmentResources(template) + if len(envResources) == 0 { + return nil + } + + for _, envResource := range envResources { + if err := r.setupRecipePackForEnvironment(ctx, envResource); err != nil { + return err + } + } + + return nil +} + +// setupRecipePackForEnvironment sets up recipe packs for a single Radius.Core/environments resource. +// If the environment already has any recipe packs set (literal IDs or ARM expression references), +// no changes are made. Otherwise, it fetches or creates the default recipe pack from +// the default scope and injects its ID into the template. +func (r *Runner) setupRecipePackForEnvironment(ctx context.Context, envResource map[string]any) error { + // The compiled ARM template has a double-nested properties structure: + // envResource["properties"]["properties"] is where resource-level fields live. + // Navigate to the inner (resource) properties map. + outerProps, ok := envResource["properties"].(map[string]any) + if !ok { + outerProps = map[string]any{} + envResource["properties"] = outerProps + } + + properties, ok := outerProps["properties"].(map[string]any) + if !ok { + properties = map[string]any{} + outerProps["properties"] = properties + } + + // If the environment already has any recipe packs configured (literal IDs or + // Bicep expression references), leave it as-is — the user is managing packs explicitly. + if hasAnyRecipePacks(properties) { + return nil + } + + // No recipe packs set — provide defaults from the default scope. + // Ensure the default resource group exists before accessing recipe packs. + mgmtClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + if err := recipepack.EnsureDefaultResourceGroup(ctx, mgmtClient.CreateOrUpdateResourceGroup); err != nil { + return err + } + + // Initialize the default scope client factory so we can access default recipe packs. + if r.DefaultScopeClientFactory == nil { + defaultFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return err + } + r.DefaultScopeClientFactory = defaultFactory + } + + recipePackDefaultClient := r.DefaultScopeClientFactory.NewRecipePacksClient() + + // Try to GET the default recipe pack from the default scope. + // If it doesn't exist, create it. + packID, err := recipepack.GetOrCreateDefaultRecipePack(ctx, recipePackDefaultClient) + if err != nil { + return err + } + + // Inject the default recipe pack ID into the template. + properties["recipePacks"] = []any{packID} + + return nil +} + +// hasAnyRecipePacks returns true if the environment properties have any recipe packs +// configured, including both literal string IDs and ARM expression references. +func hasAnyRecipePacks(properties map[string]any) bool { + recipePacks, ok := properties["recipePacks"] + if !ok { + return false + } + packsArray, ok := recipePacks.([]any) + if !ok { + return false + } + return len(packsArray) > 0 +} + // configureProviders configures environment and cloud providers based on the environment and provider type func (r *Runner) configureProviders() error { var env any diff --git a/pkg/cli/cmd/deploy/deploy_test.go b/pkg/cli/cmd/deploy/deploy_test.go index 36e6ef9384..fc36db8367 100644 --- a/pkg/cli/cmd/deploy/deploy_test.go +++ b/pkg/cli/cmd/deploy/deploy_test.go @@ -34,6 +34,7 @@ import ( "github.com/radius-project/radius/pkg/cli/deploy" "github.com/radius-project/radius/pkg/cli/framework" "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" "github.com/radius-project/radius/pkg/cli/workspaces" "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" @@ -902,6 +903,301 @@ func Test_Run(t *testing.T) { }) } +const radiusCoreEnvironmentsType = "Radius.Core/environments@2025-08-01-preview" + +// buildEnvResource creates a Radius.Core/environments resource entry for use in test templates. +// If recipePacks is nil, the environment will have no recipePacks field. +func buildEnvResource(name string, recipePacks []any) map[string]any { + innerProps := map[string]any{} + if recipePacks != nil { + innerProps["recipePacks"] = recipePacks + } + return map[string]any{ + "type": radiusCoreEnvironmentsType, + "properties": map[string]any{ + "name": name, + "properties": innerProps, + }, + } +} + +// getRecipePacks navigates a template resource entry and returns the recipePacks slice. +// It returns (packs, true) when the key exists and is a []any, or (nil, false) otherwise. +func getRecipePacks(t *testing.T, template map[string]any, resourceKey string) ([]any, bool) { + t.Helper() + envRes := template["resources"].(map[string]any)[resourceKey].(map[string]any) + outerProps := envRes["properties"].(map[string]any) + innerProps := outerProps["properties"].(map[string]any) + packs, ok := innerProps["recipePacks"].([]any) + return packs, ok +} + +func Test_setupRecipePacks(t *testing.T) { + scope := "/planes/radius/local/resourceGroups/test-group" + + t.Run("injects default recipe pack into template", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + // Default scope factory — GET succeeds (pack already exists). + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + template := map[string]any{ + "resources": map[string]any{ + "env": buildEnvResource("myenv", nil), + }, + } + + err = runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + + // Verify that the default recipe pack was injected. + packs, ok := getRecipePacks(t, template, "env") + require.True(t, ok) + require.Len(t, packs, 1, "should have the default recipe pack") + require.Equal(t, recipepack.DefaultRecipePackID(), packs[0]) + }) + + t.Run("skips when environment has existing packs", func(t *testing.T) { + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + Output: &output.MockOutput{}, + } + + existingPackID := scope + "/providers/Radius.Core/recipePacks/custom-pack" + + // Since packs are already set, no changes should be made. + template := map[string]any{ + "resources": map[string]any{ + "env": buildEnvResource("myenv", []any{existingPackID}), + }, + } + + err := runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + + packs, _ := getRecipePacks(t, template, "env") + // Only the original pack — no default pack added + require.Len(t, packs, 1) + require.Equal(t, existingPackID, packs[0]) + }) + + t.Run("no-op when template has no environment resource", func(t *testing.T) { + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + Output: &output.MockOutput{}, + } + + template := map[string]any{ + "resources": map[string]any{ + "app": map[string]any{ + "type": "Radius.Core/applications@2025-08-01-preview", + }, + }, + } + + err := runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + }) + + t.Run("no-op for Applications.Core environment", func(t *testing.T) { + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + Output: &output.MockOutput{}, + } + + template := map[string]any{ + "resources": map[string]any{ + "env": map[string]any{ + "type": "Applications.Core/environments@2023-10-01-preview", + }, + }, + } + + // Should be a no-op since we only handle Radius.Core environments + err := runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + }) + + t.Run("injects packs only for environment without packs in mixed template", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + // Only one env needs packs, so only one EnsureDefaultResourceGroup call. + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerUniqueTypes, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + existingPackID := scope + "/providers/Radius.Core/recipePacks/custom-pack" + + // Two environments: envWithPacks already has a pack, envWithout has none. + template := map[string]any{ + "resources": map[string]any{ + "envWithPacks": buildEnvResource("envWithPacks", []any{existingPackID}), + "envWithout": buildEnvResource("envWithout", nil), + }, + } + + err = runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + + // envWithPacks should be untouched — still just 1 pack. + wpPacks, _ := getRecipePacks(t, template, "envWithPacks") + require.Len(t, wpPacks, 1, "envWithPacks should keep its original pack only") + require.Equal(t, existingPackID, wpPacks[0]) + + // envWithout should have received the default pack. + woPacks, ok := getRecipePacks(t, template, "envWithout") + require.True(t, ok, "expected recipePacks on envWithout") + require.Len(t, woPacks, 1, "envWithout should have the default recipe pack") + require.Equal(t, recipepack.DefaultRecipePackID(), woPacks[0]) + }) + + t.Run("creates default pack when not found in default scope", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + // Default scope factory returns 404 on GET (packs don't exist yet) + // but succeeds on CreateOrUpdate. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServer404OnGet, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + template := map[string]any{ + "resources": map[string]any{ + "env": buildEnvResource("myenv", nil), + }, + } + + err = runner.setupRecipePack(context.Background(), template) + require.NoError(t, err) + + packs, ok := getRecipePacks(t, template, "env") + require.True(t, ok) + require.Len(t, packs, 1, "should have the default recipe pack") + require.Equal(t, recipepack.DefaultRecipePackID(), packs[0]) + }) + + t.Run("returns error when default scope GET fails with non-404", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + // Default scope factory returns 500 on GET (unexpected error). + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerInternalError, + ) + require.NoError(t, err) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + template := map[string]any{ + "resources": map[string]any{ + "env": buildEnvResource("myenv", nil), + }, + } + + err = runner.setupRecipePack(context.Background(), template) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to get default recipe pack from default scope") + }) + + t.Run("returns error when CreateOrUpdateResourceGroup fails", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(fmt.Errorf("resource group creation failed")). + Times(1) + + runner := &Runner{ + Workspace: &workspaces.Workspace{ + Scope: scope, + }, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: &output.MockOutput{}, + } + + template := map[string]any{ + "resources": map[string]any{ + "env": buildEnvResource("myenv", nil), + }, + } + + err := runner.setupRecipePack(context.Background(), template) + require.Error(t, err) + require.Contains(t, err.Error(), "resource group creation failed") + }) +} + func Test_injectAutomaticParameters(t *testing.T) { template := map[string]any{ "parameters": map[string]any{ diff --git a/pkg/cli/cmd/env/create/preview/create.go b/pkg/cli/cmd/env/create/preview/create.go index 9d5d723a78..52e090c0f1 100644 --- a/pkg/cli/cmd/env/create/preview/create.go +++ b/pkg/cli/cmd/env/create/preview/create.go @@ -30,6 +30,7 @@ import ( "github.com/radius-project/radius/pkg/cli/connections" "github.com/radius-project/radius/pkg/cli/framework" "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/recipepack" "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" @@ -70,8 +71,12 @@ type Runner struct { EnvironmentName string ResourceGroupName string RadiusCoreClientFactory *corerpv20250801.ClientFactory - ConfigFileInterface framework.ConfigFileInterface - ConnectionFactory connections.Factory + // DefaultScopeClientFactory is a client factory scoped to the default resource group. + // The default recipe pack is always created in this scope. If nil, it will be + // initialized automatically. + DefaultScopeClientFactory *corerpv20250801.ClientFactory + ConfigFileInterface framework.ConfigFileInterface + ConnectionFactory connections.Factory } // NewRunner creates a new instance of the `rad env create` runner. @@ -128,10 +133,9 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { return nil } -// Run runs the `rad env create` command. - -// Run creates an environment in the specified resource group using the provided environment name and -// returns an error if unsuccessful. +// Run runs the `rad env create --preview` command. +// +// Run creates a new Radius.Core environment with the default recipe pack func (r *Runner) Run(ctx context.Context) error { if r.RadiusCoreClientFactory == nil { clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) @@ -141,19 +145,46 @@ func (r *Runner) Run(ctx context.Context) error { r.RadiusCoreClientFactory = clientFactory } - r.Output.LogInfo("Creating Radius Core Environment...") + r.Output.LogInfo("Creating Radius Core Environment %q...", r.EnvironmentName) - resource := &corerpv20250801.EnvironmentResource{ - Location: to.Ptr(v1.LocationGlobal), - Properties: &corerpv20250801.EnvironmentProperties{}, + // Ensure the default resource group exists before creating recipe pack in it. + mgmtClient, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + if err := recipepack.EnsureDefaultResourceGroup(ctx, mgmtClient.CreateOrUpdateResourceGroup); err != nil { + return err } - _, err := r.RadiusCoreClientFactory.NewEnvironmentsClient().CreateOrUpdate(ctx, r.EnvironmentName, *resource, &corerpv20250801.EnvironmentsClientCreateOrUpdateOptions{}) + // Create the default recipe pack in the default resource group. + // The default pack lives in the default scope regardless of the current workspace scope. + if r.DefaultScopeClientFactory == nil { + defaultClientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return err + } + r.DefaultScopeClientFactory = defaultClientFactory + } + + recipePackClient := r.DefaultScopeClientFactory.NewRecipePacksClient() + _, err = recipepack.GetOrCreateDefaultRecipePack(ctx, recipePackClient) + if err != nil { + return err + } + + resource := &corerpv20250801.EnvironmentResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &corerpv20250801.EnvironmentProperties{ + RecipePacks: []*string{to.Ptr(recipepack.DefaultRecipePackID())}, + }, + } + envClient := r.RadiusCoreClientFactory.NewEnvironmentsClient() + _, err = envClient.CreateOrUpdate(ctx, r.EnvironmentName, *resource, nil) if err != nil { return err } - r.Output.LogInfo("Successfully created environment %q in resource group %q", r.EnvironmentName, r.ResourceGroupName) + r.Output.LogInfo("Successfully created environment %q in resource group %q with default recipe pack.", r.EnvironmentName, r.ResourceGroupName) return nil } diff --git a/pkg/cli/cmd/env/create/preview/create_test.go b/pkg/cli/cmd/env/create/preview/create_test.go index 09749fd05a..f874640f5b 100644 --- a/pkg/cli/cmd/env/create/preview/create_test.go +++ b/pkg/cli/cmd/env/create/preview/create_test.go @@ -24,8 +24,10 @@ import ( "go.uber.org/mock/gomock" "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/connections" "github.com/radius-project/radius/pkg/cli/framework" "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" "github.com/radius-project/radius/pkg/cli/workspaces" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" @@ -144,43 +146,137 @@ func Test_Validate(t *testing.T) { } func Test_Run(t *testing.T) { - t.Run("Success: environment created", func(t *testing.T) { - workspace := &workspaces.Workspace{ - Name: "test-workspace", - Scope: "/planes/radius/local/resourceGroups/test-resource-group", - Connection: map[string]any{ - "kind": "kubernetes", - "context": "kind-kind", - }, - } + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-resource-group", + Connection: map[string]any{ + "kind": "kubernetes", + "context": "kind-kind", + }, + } + + t.Run("creates environment with default recipe pack", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) - factory, err := test_client_factory.NewRadiusCoreTestClientFactory(workspace.Scope, test_client_factory.WithEnvironmentServerNoError, nil) + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + workspace.Scope, + test_client_factory.WithEnvironmentServer404OnGet, + nil, + ) require.NoError(t, err) + + // Default recipe pack is created in the default scope. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + nil, + ) + require.NoError(t, err) + outputSink := &output.MockOutput{} runner := &Runner{ - RadiusCoreClientFactory: factory, - Output: outputSink, - Workspace: workspace, - EnvironmentName: "testenv", - ResourceGroupName: "test-resource-group", + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: outputSink, + Workspace: workspace, + EnvironmentName: "testenv", + ResourceGroupName: "test-resource-group", } - expectedOutput := []any{ - output.LogOutput{ - Format: "Creating Radius Core Environment...", - }, - output.LogOutput{ - Format: "Successfully created environment %q in resource group %q", - Params: []any{ - "testenv", - "test-resource-group", - }, - }, + err = runner.Run(context.Background()) + require.NoError(t, err) + + require.Contains(t, outputSink.Writes, output.LogOutput{ + Format: "Creating Radius Core Environment %q...", + Params: []interface{}{"testenv"}, + }) + require.Contains(t, outputSink.Writes, output.LogOutput{ + Format: "Successfully created environment %q in resource group %q with default recipe pack.", + Params: []interface{}{"testenv", "test-resource-group"}, + }) + }) + + t.Run("creates default recipe pack when not found", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + workspace.Scope, + test_client_factory.WithEnvironmentServer404OnGet, + nil, + ) + require.NoError(t, err) + + // Default scope factory returns 404 on GET, succeeds on CreateOrUpdate. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServer404OnGet, + ) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: outputSink, + Workspace: workspace, + EnvironmentName: "testenv", + ResourceGroupName: "test-resource-group", } err = runner.Run(context.Background()) require.NoError(t, err) - require.Equal(t, expectedOutput, outputSink.Writes) + }) + + t.Run("returns error when default recipe pack GET fails with non-404", func(t *testing.T) { + ctrl := gomock.NewController(t) + mockAppClient := clients.NewMockApplicationsManagementClient(ctrl) + mockAppClient.EXPECT(). + CreateOrUpdateResourceGroup(gomock.Any(), "local", "default", gomock.Any()). + Return(nil). + Times(1) + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + workspace.Scope, + test_client_factory.WithEnvironmentServer404OnGet, + nil, + ) + require.NoError(t, err) + + // Default scope factory returns 500 on GET. + defaultScopeFactory, err := test_client_factory.NewRadiusCoreTestClientFactory( + recipepack.DefaultResourceGroupScope, + nil, + test_client_factory.WithRecipePackServerInternalError, + ) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + RadiusCoreClientFactory: factory, + DefaultScopeClientFactory: defaultScopeFactory, + ConnectionFactory: &connections.MockFactory{ApplicationsManagementClient: mockAppClient}, + Output: outputSink, + Workspace: workspace, + EnvironmentName: "testenv", + ResourceGroupName: "test-resource-group", + } + + err = runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to get default recipe pack from default scope") }) } diff --git a/pkg/cli/cmd/env/update/preview/update.go b/pkg/cli/cmd/env/update/preview/update.go index 215a2841a7..69030d18f6 100644 --- a/pkg/cli/cmd/env/update/preview/update.go +++ b/pkg/cli/cmd/env/update/preview/update.go @@ -30,6 +30,7 @@ import ( "github.com/radius-project/radius/pkg/cli/output" "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/ucp/resources" ) @@ -85,7 +86,7 @@ rad env update myenv --clear-kubernetes cmd.Flags().Bool(commonflags.ClearEnvAzureFlag, false, "Specify if azure provider needs to be cleared on env") cmd.Flags().Bool(commonflags.ClearEnvAWSFlag, false, "Specify if aws provider needs to be cleared on env") cmd.Flags().Bool(commonflags.ClearEnvKubernetesFlag, false, "Specify if kubernetes provider needs to be cleared on env (--preview)") - cmd.Flags().StringArrayP("recipe-packs", "", []string{}, "Specify recipe packs to be added to the environment (--preview)") + cmd.Flags().StringSliceP("recipe-packs", "", []string{}, "Specify recipe packs to replace the environment's recipe pack list (--preview). Accepts comma-separated values.") commonflags.AddAzureScopeFlags(cmd) commonflags.AddAWSScopeFlags(cmd) commonflags.AddKubernetesScopeFlags(cmd) @@ -205,7 +206,7 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { return err } - r.recipePacks, err = cmd.Flags().GetStringArray("recipe-packs") + r.recipePacks, err = cmd.Flags().GetStringSlice("recipe-packs") if err != nil { return err } @@ -273,12 +274,15 @@ func (r *Runner) Run(ctx context.Context) error { env.Properties.Providers.Kubernetes = r.providers.Kubernetes } - // add recipe packs if any + // replace recipe packs if any are specified if len(r.recipePacks) > 0 { - if env.Properties.RecipePacks == nil { - env.Properties.RecipePacks = []*string{} + if len(env.Properties.RecipePacks) > 0 { + r.Output.LogInfo("WARNING: The existing recipe pack list will be replaced with the specified packs.") } + // Create a new list to replace the existing recipe packs + newRecipePacks := []*string{} + for _, recipePack := range r.recipePacks { ID, err := resources.Parse(recipePack) rClientFactory := r.RadiusCoreClientFactory @@ -303,13 +307,14 @@ func (r *Runner) Run(ctx context.Context) error { cfclient := rClientFactory.NewRecipePacksClient() _, err = cfclient.Get(ctx, ID.Name(), &corerpv20250801.RecipePacksClientGetOptions{}) if err != nil { - return clierrors.Message("Recipe pack %q does not exist. Please provide a valid recipe pack to add to the environment.", recipePack) + return clierrors.Message("Recipe pack %q does not exist. Please provide a valid recipe pack to set on the environment.", recipePack) } - if !recipePackExists(env.Properties.RecipePacks, ID.String()) { - env.Properties.RecipePacks = append(env.Properties.RecipePacks, new(ID.String())) - } + newRecipePacks = append(newRecipePacks, to.Ptr(ID.String())) } + + // Replace the entire recipe packs list + env.Properties.RecipePacks = newRecipePacks } r.Output.LogInfo("Updating Environment...") @@ -349,12 +354,3 @@ func (r *Runner) Run(ctx context.Context) error { return nil } - -func recipePackExists(packs []*string, id string) bool { - for _, p := range packs { - if p != nil && *p == id { - return true - } - } - return false -} diff --git a/pkg/cli/cmd/env/update/preview/update_test.go b/pkg/cli/cmd/env/update/preview/update_test.go index d98f59474e..5b5abc95af 100644 --- a/pkg/cli/cmd/env/update/preview/update_test.go +++ b/pkg/cli/cmd/env/update/preview/update_test.go @@ -18,14 +18,17 @@ package preview import ( "context" + "net/http" "testing" + azfake "github.com/Azure/azure-sdk-for-go/sdk/azcore/fake" "github.com/radius-project/radius/pkg/cli/framework" "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" "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" "github.com/radius-project/radius/pkg/corerp/api/v20250801preview/fake" + "github.com/radius-project/radius/pkg/to" "github.com/radius-project/radius/test/radcli" "github.com/stretchr/testify/require" ) @@ -107,6 +110,9 @@ func Test_Run(t *testing.T) { envName: "test-env", serverFactory: test_client_factory.WithEnvironmentServerNoError, expectedOutput: []any{ + output.LogOutput{ + Format: "WARNING: The existing recipe pack list will be replaced with the specified packs.", + }, output.LogOutput{ Format: "Updating Environment...", }, @@ -114,7 +120,7 @@ func Test_Run(t *testing.T) { Format: "table", Obj: environmentForDisplay{ Name: "test-env", - RecipePacks: 3, + RecipePacks: 2, Providers: 3, }, Options: environmentFormat(), @@ -165,3 +171,85 @@ func Test_Run(t *testing.T) { }) } } + +func Test_Run_RecipePacksReplaced(t *testing.T) { + workspace := &workspaces.Workspace{ + Name: "test-workspace", + Scope: "/planes/radius/local/resourceGroups/test-group", + } + + // Track the resource sent to CreateOrUpdate so we can assert on its recipe packs. + var capturedEnv v20250801preview.EnvironmentResource + + existingPackID := "/planes/radius/local/resourceGroups/test-group/providers/Radius.Core/recipePacks/old-pack" + + envServer := func() fake.EnvironmentsServer { + return fake.EnvironmentsServer{ + Get: func( + _ context.Context, + environmentName string, + _ *v20250801preview.EnvironmentsClientGetOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientGetResponse], errResp azfake.ErrorResponder) { + result := v20250801preview.EnvironmentsClientGetResponse{ + EnvironmentResource: v20250801preview.EnvironmentResource{ + Name: to.Ptr(environmentName), + Properties: &v20250801preview.EnvironmentProperties{ + RecipePacks: []*string{to.Ptr(existingPackID)}, + }, + }, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + CreateOrUpdate: func( + _ context.Context, + _ string, + resource v20250801preview.EnvironmentResource, + _ *v20250801preview.EnvironmentsClientCreateOrUpdateOptions, + ) (resp azfake.Responder[v20250801preview.EnvironmentsClientCreateOrUpdateResponse], errResp azfake.ErrorResponder) { + capturedEnv = resource + result := v20250801preview.EnvironmentsClientCreateOrUpdateResponse{ + EnvironmentResource: resource, + } + resp.SetResponse(http.StatusOK, result, nil) + return + }, + } + } + + factory, err := test_client_factory.NewRadiusCoreTestClientFactory( + workspace.Scope, + envServer, + nil, // uses default RecipePacksServer (accepts any name) + ) + require.NoError(t, err) + + outputSink := &output.MockOutput{} + runner := &Runner{ + ConfigHolder: &framework.ConfigHolder{}, + Output: outputSink, + Workspace: workspace, + EnvironmentName: "test-env", + RadiusCoreClientFactory: factory, + recipePacks: []string{"new-pack-a", "new-pack-b"}, + providers: &v20250801preview.Providers{}, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + // The old pack should be gone — only the two new packs should remain. + require.Len(t, capturedEnv.Properties.RecipePacks, 2, "recipe packs list should be replaced, not appended") + packIDs := []string{*capturedEnv.Properties.RecipePacks[0], *capturedEnv.Properties.RecipePacks[1]} + require.NotContains(t, packIDs, existingPackID, "old pack should not be in the updated list") + + // Verify the replacement warning was emitted. + foundWarning := false + for _, w := range outputSink.Writes { + if logOut, ok := w.(output.LogOutput); ok && logOut.Format == "WARNING: The existing recipe pack list will be replaced with the specified packs." { + foundWarning = true + break + } + } + require.True(t, foundWarning, "expected replacement warning in output") +} diff --git a/pkg/cli/cmd/radinit/application.go b/pkg/cli/cmd/radinit/application.go index dd4f341a57..a7d3fee159 100644 --- a/pkg/cli/cmd/radinit/application.go +++ b/pkg/cli/cmd/radinit/application.go @@ -17,70 +17,15 @@ limitations under the License. package radinit import ( - "os" - "path/filepath" - - "github.com/radius-project/radius/pkg/cli/prompt" -) - -const ( - confirmSetupApplicationPrompt = "Setup application in the current directory?" - enterApplicationNamePrompt = "Enter an application name" - enterApplicationNamePlaceholder = "Enter application name..." + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" ) func (r *Runner) enterApplicationOptions(options *initOptions) error { - var err error - options.Application.Scaffold, err = prompt.YesOrNoPrompt(confirmSetupApplicationPrompt, prompt.ConfirmYes, r.Prompter) - if err != nil { - return err - } - - if !options.Application.Scaffold { - return nil - } - - chooseDefault := func() (string, error) { - wd, err := os.Getwd() - if err != nil { - return "", err - } - - return filepath.Base(wd), nil - } - - options.Application.Name, err = r.enterApplicationName(chooseDefault) + scaffold, name, err := common.EnterApplicationOptions(r.Prompter) if err != nil { return err } - + options.Application.Scaffold = scaffold + options.Application.Name = name return nil } - -// enterApplicationName returns the application name based on the chooseDefault function. If the value returned by -// chooseDefault is not a valid application name, the user will be prompted. chooseDefault is provided for testing -// purposes. -func (r *Runner) enterApplicationName(chooseDefault func() (string, error)) (string, error) { - // We might have to prompt for an application name if the current directory is not a valid application name. - // These cases should be rare but just in case... - name, err := chooseDefault() - if err != nil { - return "", err - } - - err = prompt.ValidateApplicationName(name) - if err == nil { - // Default name is a valid application name. - return name, nil - } - - name, err = r.Prompter.GetTextInput(enterApplicationNamePrompt, prompt.TextInputOptions{ - Placeholder: enterApplicationNamePlaceholder, - Validate: prompt.ValidateApplicationName, - }) - if err != nil { - return "", err - } - - return name, nil -} diff --git a/pkg/cli/cmd/radinit/application_test.go b/pkg/cli/cmd/radinit/application_test.go index 084566e090..7880fa7434 100644 --- a/pkg/cli/cmd/radinit/application_test.go +++ b/pkg/cli/cmd/radinit/application_test.go @@ -52,64 +52,3 @@ func Test_enterApplicationOptions(t *testing.T) { require.Equal(t, applicationOptions{Scaffold: false, Name: ""}, options.Application) }) } - -func Test_enterApplicationName(t *testing.T) { - t.Run("default is valid", func(t *testing.T) { - ctrl := gomock.NewController(t) - prompter := prompt.NewMockInterface(ctrl) - runner := Runner{Prompter: prompter} - - name, err := runner.enterApplicationName(func() (string, error) { return "valid", nil }) - require.NoError(t, err) - require.Equal(t, "valid", name) - }) - t.Run("user is prompted", func(t *testing.T) { - ctrl := gomock.NewController(t) - prompter := prompt.NewMockInterface(ctrl) - runner := Runner{Prompter: prompter} - - setApplicationNamePrompt(prompter, "another-name") - - name, err := runner.enterApplicationName(func() (string, error) { return "invalid-0-----", nil }) - require.NoError(t, err) - require.Equal(t, "another-name", name) - }) - t.Run("user is prompted when application name contains uppercase", func(t *testing.T) { - ctrl := gomock.NewController(t) - prompter := prompt.NewMockInterface(ctrl) - runner := Runner{Prompter: prompter} - - setApplicationNamePrompt(prompter, "another-name") - - name, err := runner.enterApplicationName(func() (string, error) { return "Invalid-Name", nil }) - require.NoError(t, err) - require.Equal(t, "another-name", name) - }) - - t.Run("user is prompted when application name does not end with alphanumeric", func(t *testing.T) { - ctrl := gomock.NewController(t) - prompter := prompt.NewMockInterface(ctrl) - runner := Runner{Prompter: prompter} - - setApplicationNamePrompt(prompter, "another-name") - - name, err := runner.enterApplicationName(func() (string, error) { return "test-application-", nil }) - require.NoError(t, err) - require.Equal(t, "another-name", name) - }) - - t.Run("user is prompted when application name is too long", func(t *testing.T) { - ctrl := gomock.NewController(t) - prompter := prompt.NewMockInterface(ctrl) - runner := Runner{Prompter: prompter} - - setApplicationNamePrompt(prompter, "another-name") - - name, err := runner.enterApplicationName(func() (string, error) { - return "this-is-a-very-long-environment-name-that-is-invalid-this-is-a-very-long-application-name-that-is-invalid", nil - }) - require.NoError(t, err) - require.Equal(t, "another-name", name) - }) - -} diff --git a/pkg/cli/cmd/radinit/aws.go b/pkg/cli/cmd/radinit/aws.go index 1bac374808..abd2b5d383 100644 --- a/pkg/cli/cmd/radinit/aws.go +++ b/pkg/cli/cmd/radinit/aws.go @@ -18,166 +18,21 @@ package radinit import ( "context" - "fmt" - "github.com/aws/aws-sdk-go-v2/service/ec2" - "github.com/charmbracelet/bubbles/textinput" "github.com/radius-project/radius/pkg/cli/aws" - "github.com/radius-project/radius/pkg/cli/clierrors" - "github.com/radius-project/radius/pkg/cli/prompt" -) - -const ( - selectAWSRegionPrompt = "Select the region you would like to deploy AWS resources to:" - selectAWSCredentialKindPrompt = "Select a credential kind for the AWS credential:" - enterAWSIAMAcessKeyIDPrompt = "Enter the IAM access key id:" - enterAWSRoleARNPrompt = "Enter the role ARN:" - enterAWSRoleARNPlaceholder = "Enter IAM role ARN..." - enterAWSIAMAcessKeyIDPlaceholder = "Enter IAM access key id..." - enterAWSIAMSecretAccessKeyPrompt = "Enter your IAM Secret Access Key:" - enterAWSIAMSecretAccessKeyPlaceholder = "Enter IAM secret access key..." - errNotEmptyTemplate = "%s cannot be empty" - confirmAWSAccountIDPromptFmt = "Use account id '%v'?" - enterAWSAccountIDPrompt = "Enter the account ID:" - enterAWSAccountIDPlaceholder = "Enter the account ID you want to use..." - - awsAccessKeysCreateInstructionFmt = "\nAWS IAM Access keys (Access key ID and Secret access key) are required to access and create AWS resources.\n\nFor example, you can create one using the following command:\n\033[36maws iam create-access-key\033[0m\n\nFor more information refer to https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html.\n\n" - awsIRSACredentialKind = "IRSA" - awsAccessKeyCredentialKind = "Access Key" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" ) func (r *Runner) enterAWSCloudProvider(ctx context.Context, options *initOptions) (*aws.Provider, error) { - credentialKind, err := r.selectAWSCredentialKind() + provider, err := common.EnterAWSCloudProvider(ctx, r.Prompter, r.Output, r.awsClient) if err != nil { return nil, err } - switch credentialKind { - case awsAccessKeyCredentialKind: - r.Output.LogInfo(awsAccessKeysCreateInstructionFmt) - - accessKeyID, err := r.Prompter.GetTextInput(enterAWSIAMAcessKeyIDPrompt, prompt.TextInputOptions{Placeholder: enterAWSIAMAcessKeyIDPlaceholder}) - if err != nil { - return nil, err - } - - secretAccessKey, err := r.Prompter.GetTextInput(enterAWSIAMSecretAccessKeyPrompt, prompt.TextInputOptions{Placeholder: enterAWSIAMSecretAccessKeyPlaceholder, EchoMode: textinput.EchoPassword}) - if err != nil { - return nil, err - } - - accountId, err := r.getAccountId(ctx) - if err != nil { - return nil, err - } - - region, err := r.selectAWSRegion(ctx) - if err != nil { - return nil, err - } - - return &aws.Provider{ - AccessKey: &aws.AccessKeyCredential{ - AccessKeyID: accessKeyID, - SecretAccessKey: secretAccessKey, - }, - CredentialKind: aws.AWSCredentialKindAccessKey, - AccountID: accountId, - Region: region, - }, nil - case awsIRSACredentialKind: - r.Output.LogInfo(awsAccessKeysCreateInstructionFmt) - - roleARN, err := r.Prompter.GetTextInput(enterAWSRoleARNPrompt, prompt.TextInputOptions{Placeholder: enterAWSRoleARNPlaceholder}) - if err != nil { - return nil, err - } - - accountId, err := r.getAccountId(ctx) - if err != nil { - return nil, err - } - - region, err := r.selectAWSRegion(ctx) - if err != nil { - return nil, err - } - - // Set the value for the Helm chart + if provider.CredentialKind == aws.AWSCredentialKindIRSA { + // Set the value for the Helm chart. options.SetValues = append(options.SetValues, "global.aws.irsa.enabled=true") - return &aws.Provider{ - AccountID: accountId, - Region: region, - CredentialKind: aws.AWSCredentialKindIRSA, - IRSA: &aws.IRSACredential{ - RoleARN: roleARN, - }, - }, nil - default: - return nil, clierrors.Message("Invalid AWS credential kind: %s", credentialKind) - } -} - -func (r *Runner) getAccountId(ctx context.Context) (string, error) { - callerIdentityOutput, err := r.awsClient.GetCallerIdentity(ctx) - if err != nil { - return "", clierrors.MessageWithCause(err, "AWS Cloud Provider setup failed, please use aws configure to set up the configuration. More information :https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html") - } - - if callerIdentityOutput.Account == nil { - return "", clierrors.MessageWithCause(err, "AWS credential verification failed: Account ID is nil.") - } - - accountID := *callerIdentityOutput.Account - addAlternateAccountID, err := prompt.YesOrNoPrompt(fmt.Sprintf(confirmAWSAccountIDPromptFmt, accountID), prompt.ConfirmYes, r.Prompter) - if err != nil { - return "", err - } - - if !addAlternateAccountID { - accountID, err = r.Prompter.GetTextInput(enterAWSAccountIDPrompt, prompt.TextInputOptions{Placeholder: enterAWSAccountIDPlaceholder}) - if err != nil { - return "", err - } - } - - return accountID, nil -} - -// selectAWSRegion prompts the user to select an AWS region from a list of available regions. -// Region list is retrieved using the locally configured AWS account. -func (r *Runner) selectAWSRegion(ctx context.Context) (string, error) { - listRegionsOutput, err := r.awsClient.ListRegions(ctx) - if err != nil { - return "", clierrors.MessageWithCause(err, "Listing AWS regions failed.") - } - - regions := r.buildAWSRegionsList(listRegionsOutput) - selectedRegion, err := r.Prompter.GetListInput(regions, selectAWSRegionPrompt) - if err != nil { - return "", err - } - - return selectedRegion, nil -} - -func (r *Runner) buildAWSRegionsList(listRegionsOutput *ec2.DescribeRegionsOutput) []string { - regions := []string{} - for _, region := range listRegionsOutput.Regions { - regions = append(regions, *region.RegionName) } - return regions -} - -func (r *Runner) selectAWSCredentialKind() (string, error) { - credentialKinds := r.buildAWSCredentialKind() - return r.Prompter.GetListInput(credentialKinds, selectAWSCredentialKindPrompt) -} - -func (r *Runner) buildAWSCredentialKind() []string { - return []string{ - awsAccessKeyCredentialKind, - awsIRSACredentialKind, - } + return provider, nil } diff --git a/pkg/cli/cmd/radinit/aws_test.go b/pkg/cli/cmd/radinit/aws_test.go index ad2b9de112..ddfba8f47c 100644 --- a/pkg/cli/cmd/radinit/aws_test.go +++ b/pkg/cli/cmd/radinit/aws_test.go @@ -24,6 +24,7 @@ import ( ec2_types "github.com/aws/aws-sdk-go-v2/service/ec2/types" "github.com/aws/aws-sdk-go-v2/service/sts" "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" "github.com/radius-project/radius/pkg/cli/output" "github.com/radius-project/radius/pkg/cli/prompt" "github.com/stretchr/testify/require" @@ -64,7 +65,7 @@ func Test_enterAWSCloudProvider_AccessKey(t *testing.T) { AccountID: "account-id", } require.Equal(t, expected, provider) - require.Equal(t, []any{output.LogOutput{Format: awsAccessKeysCreateInstructionFmt}}, outputSink.Writes) + require.Equal(t, []any{output.LogOutput{Format: common.AWSAccessKeysCreateInstructionFmt}}, outputSink.Writes) } func Test_enterAWSCloudProvider_IRSA(t *testing.T) { @@ -99,5 +100,5 @@ func Test_enterAWSCloudProvider_IRSA(t *testing.T) { AccountID: "account-id", } require.Equal(t, expected, provider) - require.Equal(t, []any{output.LogOutput{Format: awsAccessKeysCreateInstructionFmt}}, outputSink.Writes) + require.Equal(t, []any{output.LogOutput{Format: common.AWSAccessKeysCreateInstructionFmt}}, outputSink.Writes) } diff --git a/pkg/cli/cmd/radinit/azure.go b/pkg/cli/cmd/radinit/azure.go index d28011f21f..2abd4937a8 100644 --- a/pkg/cli/cmd/radinit/azure.go +++ b/pkg/cli/cmd/radinit/azure.go @@ -18,295 +18,21 @@ package radinit import ( "context" - "fmt" - "sort" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" - "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions" - "github.com/charmbracelet/bubbles/textinput" "github.com/radius-project/radius/pkg/cli/azure" - "github.com/radius-project/radius/pkg/cli/clierrors" - "github.com/radius-project/radius/pkg/cli/prompt" -) - -const ( - confirmAzureSubscriptionPromptFmt = "Use subscription '%v'?" - selectAzureSubscriptionPrompt = "Select a subscription:" - confirmAzureCreateResourceGroupPrompt = "Create a new resource group?" - enterAzureResourceGroupNamePrompt = "Enter a resource group name" - enterAzureResourceGroupNamePlaceholder = "Enter resource group name" - selectAzureResourceGroupLocationPrompt = "Select a location for the resource group:" - selectAzureResourceGroupPrompt = "Select a resource group:" - selectAzureCredentialKindPrompt = "Select a credential kind for the Azure credential:" - enterAzureServicePrincipalAppIDPrompt = "Enter the `appId` of the service principal used to create Azure resources" - enterAzureServicePrincipalAppIDPlaceholder = "Enter appId..." - enterAzureServicePrincipalPasswordPrompt = "Enter the `password` of the service principal used to create Azure resources" - enterAzureServicePrincipalPasswordPlaceholder = "Enter password..." - enterAzureServicePrincipalTenantIDPrompt = "Enter the `tenantId` of the service principal used to create Azure resources" - enterAzureServicePrincipalTenantIDPlaceholder = "Enter tenantId..." - enterAzureWorkloadIdentityAppIDPrompt = "Enter the `appId` of the Entra ID Application" - enterAzureWorkloadIdentityAppIDPlaceholder = "Enter appId..." - enterAzureWorkloadIdentityTenantIDPrompt = "Enter the `tenantId` of the Entra ID Application" - enterAzureWorkloadIdentityTenantIDPlaceholder = "Enter tenantId..." - azureWorkloadIdentityCreateInstructionsFmt = "\nA workload identity federated credential is required to create Azure resources. Please follow the guidance at aka.ms/rad-workload-identity to set up workload identity for Radius.\n\n" - azureServicePrincipalCreateInstructionsFmt = "\nAn Azure service principal with a corresponding role assignment on your resource group is required to create Azure resources.\n\nFor example, you can create one using the following command:\n\033[36maz ad sp create-for-rbac --role Owner --scope /subscriptions/%s/resourceGroups/%s\033[0m\n\nFor more information refer to https://docs.microsoft.com/cli/azure/ad/sp?view=azure-cli-latest#az-ad-sp-create-for-rbac and https://aka.ms/azadsp-more\n\n" - azureServicePrincipalCredentialKind = "Service Principal" - azureWorkloadIdenityCredentialKind = "Workload Identity" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" ) func (r *Runner) enterAzureCloudProvider(ctx context.Context, options *initOptions) (*azure.Provider, error) { - subscription, err := r.selectAzureSubscription(ctx) - if err != nil { - return nil, err - } - - resourceGroup, err := r.selectAzureResourceGroup(ctx, *subscription) + provider, err := common.EnterAzureCloudProvider(ctx, r.Prompter, r.Output, r.azureClient) if err != nil { return nil, err } - credentialKind, err := r.selectAzureCredentialKind() - if err != nil { - return nil, err - } - - switch credentialKind { - case azureServicePrincipalCredentialKind: - r.Output.LogInfo(azureServicePrincipalCreateInstructionsFmt, subscription.ID, resourceGroup) - - clientID, err := r.Prompter.GetTextInput(enterAzureServicePrincipalAppIDPrompt, prompt.TextInputOptions{ - Placeholder: enterAzureServicePrincipalAppIDPlaceholder, - Validate: prompt.ValidateUUIDv4, - }) - if err != nil { - return nil, err - } - - clientSecret, err := r.Prompter.GetTextInput(enterAzureServicePrincipalPasswordPrompt, prompt.TextInputOptions{Placeholder: enterAzureServicePrincipalPasswordPlaceholder, EchoMode: textinput.EchoPassword}) - if err != nil { - return nil, err - } - - tenantID, err := r.Prompter.GetTextInput(enterAzureServicePrincipalTenantIDPrompt, prompt.TextInputOptions{ - Placeholder: enterAzureServicePrincipalTenantIDPlaceholder, - Validate: prompt.ValidateUUIDv4, - }) - if err != nil { - return nil, err - } - - return &azure.Provider{ - SubscriptionID: subscription.ID, - ResourceGroup: resourceGroup, - CredentialKind: azure.AzureCredentialKindServicePrincipal, - ServicePrincipal: &azure.ServicePrincipalCredential{ - ClientID: clientID, - ClientSecret: clientSecret, - TenantID: tenantID, - }, - }, nil - case azureWorkloadIdenityCredentialKind: - r.Output.LogInfo(azureWorkloadIdentityCreateInstructionsFmt) - - clientID, err := r.Prompter.GetTextInput(enterAzureWorkloadIdentityAppIDPrompt, prompt.TextInputOptions{ - Placeholder: enterAzureWorkloadIdentityAppIDPlaceholder, - Validate: prompt.ValidateUUIDv4, - }) - if err != nil { - return nil, err - } - - tenantID, err := r.Prompter.GetTextInput(enterAzureWorkloadIdentityTenantIDPrompt, prompt.TextInputOptions{ - Placeholder: enterAzureWorkloadIdentityTenantIDPlaceholder, - Validate: prompt.ValidateUUIDv4, - }) - if err != nil { - return nil, err - } - - // Set the value for the Helm chart + if provider.CredentialKind == azure.AzureCredentialKindWorkloadIdentity { + // Set the value for the Helm chart. options.SetValues = append(options.SetValues, "global.azureWorkloadIdentity.enabled=true") - - return &azure.Provider{ - SubscriptionID: subscription.ID, - ResourceGroup: resourceGroup, - CredentialKind: azure.AzureCredentialKindWorkloadIdentity, - WorkloadIdentity: &azure.WorkloadIdentityCredential{ - ClientID: clientID, - TenantID: tenantID, - }, - }, nil - default: - return nil, clierrors.Message("Invalid Azure credential kind: %s", credentialKind) - } -} - -func (r *Runner) selectAzureSubscription(ctx context.Context) (*azure.Subscription, error) { - subscriptions, err := r.azureClient.Subscriptions(ctx) - if err != nil { - return nil, clierrors.MessageWithCause(err, "Failed to list Azure subscriptions.") - } - - // Users can configure a default subscription with `az account set`. If they did, then ask about that first. - if subscriptions.Default != nil { - confirmed, err := prompt.YesOrNoPrompt(fmt.Sprintf(confirmAzureSubscriptionPromptFmt, subscriptions.Default.Name), prompt.ConfirmYes, r.Prompter) - if err != nil { - return nil, err - } - - if confirmed { - return subscriptions.Default, nil - } - } - - names, subscriptionMap := r.buildAzureSubscriptionListAndMap(subscriptions) - name, err := r.Prompter.GetListInput(names, selectAzureSubscriptionPrompt) - if err != nil { - return nil, err - } - - subscription := subscriptionMap[name] - return &subscription, nil -} - -func (r *Runner) selectAzureCredentialKind() (string, error) { - credentialKinds, err := r.buildAzureCredentialKind() - if err != nil { - return "", err - } - - return r.Prompter.GetListInput(credentialKinds, selectAzureCredentialKindPrompt) -} - -// buildSubscriptionListAndMap builds a list of subscription names, as well as a map of name => subcription. We need the list -// to build the prompt, and the map to look up the subscription object by name after the user makes a selection. -func (r *Runner) buildAzureSubscriptionListAndMap(subscriptions *azure.SubscriptionResult) ([]string, map[string]azure.Subscription) { - subscriptionMap := map[string]azure.Subscription{} - names := []string{} - for _, s := range subscriptions.Subscriptions { - subscriptionMap[s.Name] = s - names = append(names, s.Name) - } - - sort.Strings(names) - - return names, subscriptionMap -} - -func (r *Runner) selectAzureResourceGroup(ctx context.Context, subscription azure.Subscription) (string, error) { - create, err := prompt.YesOrNoPrompt(confirmAzureCreateResourceGroupPrompt, prompt.ConfirmYes, r.Prompter) - if err != nil { - return "", err - } - - if !create { - return r.selectExistingAzureResourceGroup(ctx, subscription) } - name, err := r.enterAzureResourceGroupName() - if err != nil { - return "", err - } - - exists, err := r.azureClient.CheckResourceGroupExistence(ctx, subscription.ID, name) - if err != nil { - return "", err - } - - // Nothing left to do. - if exists { - return name, nil - } - - r.Output.LogInfo("Resource Group '%v' will be created...", name) - - location, err := r.selectAzureResourceGroupLocation(ctx, subscription) - if err != nil { - return "", err - } - - err = r.azureClient.CreateOrUpdateResourceGroup(ctx, subscription.ID, name, *location.Name) - if err != nil { - return "", clierrors.MessageWithCause(err, "Failed to create Azure resource group.") - } - - return name, nil -} - -func (r *Runner) selectExistingAzureResourceGroup(ctx context.Context, subscription azure.Subscription) (string, error) { - groups, err := r.azureClient.ResourceGroups(ctx, subscription.ID) - if err != nil { - return "", clierrors.MessageWithCause(err, "Failed to get list Azure resource groups.") - } - - names := r.buildAzureResourceGroupList(groups) - name, err := r.Prompter.GetListInput(names, selectAzureResourceGroupPrompt) - if err != nil { - return "", err - } - - return name, nil -} - -func (r *Runner) buildAzureResourceGroupList(groups []armresources.ResourceGroup) []string { - names := []string{} - for _, s := range groups { - names = append(names, *s.Name) - } - - sort.Strings(names) - - return names -} - -func (r *Runner) enterAzureResourceGroupName() (string, error) { - name, err := r.Prompter.GetTextInput(enterAzureResourceGroupNamePrompt, prompt.TextInputOptions{ - Placeholder: enterAzureResourceGroupNamePlaceholder, - Validate: prompt.ValidateResourceName, - }) - if err != nil { - return "", err - } - - return name, nil -} - -func (r *Runner) selectAzureResourceGroupLocation(ctx context.Context, subscription azure.Subscription) (*armsubscriptions.Location, error) { - // Use the display name for the prompt - // alphabetize so the list is stable and scannable - locations, err := r.azureClient.Locations(ctx, subscription.ID) - if err != nil { - return nil, clierrors.MessageWithCause(err, "Failed to get list Azure locations.") - } - - names, locationMap := r.buildAzureResourceGroupLocationListAndMap(locations) - name, err := r.Prompter.GetListInput(names, selectAzureResourceGroupLocationPrompt) - if err != nil { - return nil, err - } - - location := locationMap[name] - return &location, nil -} - -// buildAzureResourceGroupLocationListAndMap builds a list of location names, as well as a map of name => location. We need the list -// to build the prompt, and the map to look up the location object by name after the user makes a selection. -func (r *Runner) buildAzureResourceGroupLocationListAndMap(locations []armsubscriptions.Location) ([]string, map[string]armsubscriptions.Location) { - locationMap := map[string]armsubscriptions.Location{} - names := []string{} - for _, location := range locations { - names = append(names, *location.DisplayName) - locationMap[*location.DisplayName] = location - } - - sort.Strings(names) - - return names, locationMap -} - -func (r *Runner) buildAzureCredentialKind() ([]string, error) { - return []string{ - azureServicePrincipalCredentialKind, - azureWorkloadIdenityCredentialKind, - }, nil + return provider, nil } diff --git a/pkg/cli/cmd/radinit/azure_test.go b/pkg/cli/cmd/radinit/azure_test.go index 4e03465718..6113cf4882 100644 --- a/pkg/cli/cmd/radinit/azure_test.go +++ b/pkg/cli/cmd/radinit/azure_test.go @@ -24,6 +24,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions" "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" "github.com/radius-project/radius/pkg/cli/output" "github.com/radius-project/radius/pkg/cli/prompt" "github.com/stretchr/testify/require" @@ -77,7 +78,7 @@ func Test_enterAzureCloudProvider_ServicePrincipal(t *testing.T) { require.Equal(t, expected, provider) expectedOutput := []any{output.LogOutput{ - Format: azureServicePrincipalCreateInstructionsFmt, + Format: common.AzureServicePrincipalCreateInstructionsFmt, Params: []any{subscription.ID, *resourceGroup.Name}, }} require.Equal(t, expectedOutput, outputSink.Writes) @@ -131,7 +132,7 @@ func Test_enterAzureCloudProvider_WorkloadIdentity(t *testing.T) { require.Equal(t, expected, provider) expectedOutput := []any{output.LogOutput{ - Format: azureWorkloadIdentityCreateInstructionsFmt, + Format: common.AzureWorkloadIdentityCreateInstructionsFmt, }} require.Equal(t, expectedOutput, outputSink.Writes) @@ -169,7 +170,6 @@ func Test_selectAzureSubscription(t *testing.T) { ctrl := gomock.NewController(t) prompter := prompt.NewMockInterface(ctrl) client := azure.NewMockClient(ctrl) - runner := Runner{Prompter: prompter, azureClient: client} subscriptions := subscriptions subscriptions.Default = &subscriptions.Subscriptions[1] @@ -177,7 +177,7 @@ func Test_selectAzureSubscription(t *testing.T) { setAzureSubscriptions(client, &subscriptions) setAzureSubscriptionConfirmPrompt(prompter, subscriptions.Default.Name, prompt.ConfirmYes) - selected, err := runner.selectAzureSubscription(context.Background()) + selected, err := common.SelectAzureSubscription(context.Background(), prompter, client) require.NoError(t, err) require.NotNil(t, selected) @@ -188,7 +188,6 @@ func Test_selectAzureSubscription(t *testing.T) { ctrl := gomock.NewController(t) prompter := prompt.NewMockInterface(ctrl) client := azure.NewMockClient(ctrl) - runner := Runner{Prompter: prompter, azureClient: client} subscriptions := subscriptions subscriptions.Default = &subscriptions.Subscriptions[1] @@ -197,7 +196,7 @@ func Test_selectAzureSubscription(t *testing.T) { setAzureSubscriptionConfirmPrompt(prompter, subscriptions.Default.Name, prompt.ConfirmNo) setAzureSubsubscriptionPrompt(prompter, subscriptionNames, subscriptions.Subscriptions[2].Name) - selected, err := runner.selectAzureSubscription(context.Background()) + selected, err := common.SelectAzureSubscription(context.Background(), prompter, client) require.NoError(t, err) require.NotNil(t, selected) @@ -208,7 +207,6 @@ func Test_selectAzureSubscription(t *testing.T) { ctrl := gomock.NewController(t) prompter := prompt.NewMockInterface(ctrl) client := azure.NewMockClient(ctrl) - runner := Runner{Prompter: prompter, azureClient: client} subscriptions := subscriptions subscriptions.Default = nil @@ -216,7 +214,7 @@ func Test_selectAzureSubscription(t *testing.T) { setAzureSubscriptions(client, &subscriptions) setAzureSubsubscriptionPrompt(prompter, subscriptionNames, subscriptions.Subscriptions[2].Name) - selected, err := runner.selectAzureSubscription(context.Background()) + selected, err := common.SelectAzureSubscription(context.Background(), prompter, client) require.NoError(t, err) require.NotNil(t, selected) @@ -250,8 +248,7 @@ func Test_buildAzureSubscriptionListAndMap(t *testing.T) { "c-test-subscription2": subscriptions.Subscriptions[1], } - runner := Runner{} - names, subscriptionMap := runner.buildAzureSubscriptionListAndMap(&subscriptions) + names, subscriptionMap := common.BuildAzureSubscriptionListAndMap(&subscriptions) require.Equal(t, expectedNames, names) require.Equal(t, expectedMap, subscriptionMap) } @@ -304,13 +301,12 @@ func Test_selectAzureResourceGroup(t *testing.T) { prompter := prompt.NewMockInterface(ctrl) client := azure.NewMockClient(ctrl) outputSink := output.MockOutput{} - runner := Runner{Prompter: prompter, azureClient: client, Output: &outputSink} setAzureResourceGroupCreatePrompt(prompter, prompt.ConfirmNo) setAzureResourceGroups(client, subscription.ID, resourceGroups) setAzureResourceGroupPrompt(prompter, resourceGroupNames, *resourceGroups[1].Name) - name, err := runner.selectAzureResourceGroup(context.Background(), subscription) + name, err := common.SelectAzureResourceGroup(context.Background(), prompter, &outputSink, client, subscription) require.NoError(t, err) require.Equal(t, *resourceGroups[1].Name, name) @@ -322,7 +318,6 @@ func Test_selectAzureResourceGroup(t *testing.T) { prompter := prompt.NewMockInterface(ctrl) client := azure.NewMockClient(ctrl) outputSink := output.MockOutput{} - runner := Runner{Prompter: prompter, azureClient: client, Output: &outputSink} setAzureResourceGroupCreatePrompt(prompter, prompt.ConfirmYes) setAzureResourceGroupNamePrompt(prompter, "test-resource-group") @@ -331,7 +326,7 @@ func Test_selectAzureResourceGroup(t *testing.T) { setSelectAzureResourceGroupLocationPrompt(prompter, locationDisplayNames, *locations[1].DisplayName) setAzureCreateOrUpdateResourceGroup(client, subscription.ID, "test-resource-group", *locations[1].Name) - name, err := runner.selectAzureResourceGroup(context.Background(), subscription) + name, err := common.SelectAzureResourceGroup(context.Background(), prompter, &outputSink, client, subscription) require.NoError(t, err) require.Equal(t, "test-resource-group", name) @@ -350,13 +345,12 @@ func Test_selectAzureResourceGroup(t *testing.T) { prompter := prompt.NewMockInterface(ctrl) client := azure.NewMockClient(ctrl) outputSink := output.MockOutput{} - runner := Runner{Prompter: prompter, azureClient: client, Output: &outputSink} setAzureResourceGroupCreatePrompt(prompter, prompt.ConfirmYes) setAzureResourceGroupNamePrompt(prompter, "test-resource-group") setAzureCheckResourceGroupExistence(client, subscription.ID, "test-resource-group", true) - name, err := runner.selectAzureResourceGroup(context.Background(), subscription) + name, err := common.SelectAzureResourceGroup(context.Background(), prompter, &outputSink, client, subscription) require.NoError(t, err) require.Equal(t, "test-resource-group", name) @@ -393,12 +387,11 @@ func Test_selectExistingAzureResourceGroup(t *testing.T) { ctrl := gomock.NewController(t) prompter := prompt.NewMockInterface(ctrl) client := azure.NewMockClient(ctrl) - runner := Runner{Prompter: prompter, azureClient: client} setAzureResourceGroups(client, subscription.ID, resourceGroups) setAzureResourceGroupPrompt(prompter, resourceGroupNames, *resourceGroups[1].Name) - name, err := runner.selectExistingAzureResourceGroup(context.Background(), subscription) + name, err := common.SelectExistingAzureResourceGroup(context.Background(), prompter, client, subscription) require.NoError(t, err) require.Equal(t, *resourceGroups[1].Name, name) @@ -420,20 +413,17 @@ func Test_buildAzureResourceGroupList(t *testing.T) { expectedNames := []string{"a-test-resource-group2", "b-test-resource-group1", "c-test-resource-group3"} - runner := Runner{} - names := runner.buildAzureResourceGroupList(resourceGroups) + names := common.BuildAzureResourceGroupList(resourceGroups) require.Equal(t, expectedNames, names) } func Test_enterAzureResourceGroupName(t *testing.T) { ctrl := gomock.NewController(t) prompter := prompt.NewMockInterface(ctrl) - client := azure.NewMockClient(ctrl) - runner := Runner{Prompter: prompter, azureClient: client} setAzureResourceGroupNamePrompt(prompter, "test-resource-group") - name, err := runner.enterAzureResourceGroupName() + name, err := common.EnterAzureResourceGroupName(prompter) require.NoError(t, err) require.Equal(t, "test-resource-group", name) } @@ -484,12 +474,11 @@ func Test_selectAzureResourceGroupLocation(t *testing.T) { ctrl := gomock.NewController(t) prompter := prompt.NewMockInterface(ctrl) client := azure.NewMockClient(ctrl) - runner := Runner{Prompter: prompter, azureClient: client} setAzureLocations(client, subscription.ID, locations) setSelectAzureResourceGroupLocationPrompt(prompter, locationDisplayNames, *locations[1].DisplayName) - location, err := runner.selectAzureResourceGroupLocation(context.Background(), subscription) + location, err := common.SelectAzureResourceGroupLocation(context.Background(), prompter, client, subscription) require.NoError(t, err) require.Equal(t, locations[1], *location) } @@ -513,8 +502,7 @@ func Test_buildAzureResourceGroupLocationListAndMap(t *testing.T) { "West US": locations[0], } - runner := Runner{} - names, locationMap := runner.buildAzureResourceGroupLocationListAndMap(locations) + names, locationMap := common.BuildAzureResourceGroupLocationListAndMap(locations) require.Equal(t, expectedNames, names) require.Equal(t, expectedMap, locationMap) } diff --git a/pkg/cli/cmd/radinit/cloud.go b/pkg/cli/cmd/radinit/cloud.go index 4e0a2e3ff4..51e819f84e 100644 --- a/pkg/cli/cmd/radinit/cloud.go +++ b/pkg/cli/cmd/radinit/cloud.go @@ -18,69 +18,31 @@ package radinit import ( "context" - "errors" "github.com/radius-project/radius/pkg/cli/aws" "github.com/radius-project/radius/pkg/cli/azure" - "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" ) const ( - confirmCloudProviderBackNavigationSentinel = "[back]" - confirmCloudProviderPrompt = "Add cloud providers for cloud resources?" - confirmCloudProviderAdditionalPrompt = "Add additional cloud providers for cloud resources?" - selectCloudProviderPrompt = "Select your cloud provider" + confirmCloudProviderBackNavigationSentinel = common.ConfirmCloudProviderBackNavigationSentinel + confirmCloudProviderPrompt = common.ConfirmCloudProviderPrompt + confirmCloudProviderAdditionalPrompt = common.ConfirmCloudProviderAdditionalPrompt + selectCloudProviderPrompt = common.SelectCloudProviderPrompt ) func (r *Runner) enterCloudProviderOptions(ctx context.Context, options *initOptions) error { - // When no flags are specified we don't want to ask about cloud providers. - if !r.Full { - return nil - } - - // If we're creating an environment we can't change cloud providers. - if !options.Environment.Create { - return nil - } - - addingCloudProvider, err := prompt.YesOrNoPrompt(confirmCloudProviderPrompt, prompt.ConfirmNo, r.Prompter) + result, err := common.EnterCloudProviderOptions( + r.Prompter, + r.Full, + options.Environment.Create, + func() (*azure.Provider, error) { return r.enterAzureCloudProvider(ctx, options) }, + func() (*aws.Provider, error) { return r.enterAWSCloudProvider(ctx, options) }, + ) if err != nil { return err } - - for addingCloudProvider { - choices := []string{azure.ProviderDisplayName, aws.ProviderDisplayName, confirmCloudProviderBackNavigationSentinel} - cloudProvider, err := r.Prompter.GetListInput(choices, selectCloudProviderPrompt) - if err != nil { - return err - } - - switch cloudProvider { - case azure.ProviderDisplayName: - provider, err := r.enterAzureCloudProvider(ctx, options) - if err != nil { - return err - } - - options.CloudProviders.Azure = provider - case aws.ProviderDisplayName: - provider, err := r.enterAWSCloudProvider(ctx, options) - if err != nil { - return err - } - - options.CloudProviders.AWS = provider - case confirmCloudProviderBackNavigationSentinel: - return nil - default: - return errors.New("unsupported Cloud Provider") - } - - addingCloudProvider, err = prompt.YesOrNoPrompt(confirmCloudProviderAdditionalPrompt, prompt.ConfirmNo, r.Prompter) - if err != nil { - return err - } - } - + options.CloudProviders.Azure = result.Azure + options.CloudProviders.AWS = result.AWS return nil } diff --git a/pkg/cli/cmd/radinit/cloud_test.go b/pkg/cli/cmd/radinit/cloud_test.go index 3d161eb6be..a5ee389bf9 100644 --- a/pkg/cli/cmd/radinit/cloud_test.go +++ b/pkg/cli/cmd/radinit/cloud_test.go @@ -22,6 +22,7 @@ import ( "github.com/radius-project/radius/pkg/cli/aws" "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" "github.com/radius-project/radius/pkg/cli/output" "github.com/radius-project/radius/pkg/cli/prompt" "github.com/stretchr/testify/require" @@ -147,7 +148,7 @@ func Test_enterCloudProviderOptions(t *testing.T) { expectedWrites := []any{ output.LogOutput{ - Format: awsAccessKeysCreateInstructionFmt, + Format: common.AWSAccessKeysCreateInstructionFmt, }, } require.Equal(t, expectedWrites, outputSink.Writes) @@ -174,7 +175,7 @@ func Test_enterCloudProviderOptions(t *testing.T) { expectedWrites := []any{ output.LogOutput{ - Format: awsAccessKeysCreateInstructionFmt, + Format: common.AWSAccessKeysCreateInstructionFmt, }, } require.Equal(t, expectedWrites, outputSink.Writes) @@ -201,7 +202,7 @@ func Test_enterCloudProviderOptions(t *testing.T) { expectedWrites := []any{ output.LogOutput{ - Format: azureServicePrincipalCreateInstructionsFmt, + Format: common.AzureServicePrincipalCreateInstructionsFmt, Params: []any{azureProviderServicePrincipal.SubscriptionID, azureProviderServicePrincipal.ResourceGroup}, }, } @@ -229,7 +230,7 @@ func Test_enterCloudProviderOptions(t *testing.T) { expectedWrites := []any{ output.LogOutput{ - Format: azureWorkloadIdentityCreateInstructionsFmt, + Format: common.AzureWorkloadIdentityCreateInstructionsFmt, }, } require.Equal(t, expectedWrites, outputSink.Writes) @@ -261,10 +262,10 @@ func Test_enterCloudProviderOptions(t *testing.T) { expectedWrites := []any{ output.LogOutput{ - Format: awsAccessKeysCreateInstructionFmt, + Format: common.AWSAccessKeysCreateInstructionFmt, }, output.LogOutput{ - Format: azureServicePrincipalCreateInstructionsFmt, + Format: common.AzureServicePrincipalCreateInstructionsFmt, Params: []any{azureProviderServicePrincipal.SubscriptionID, azureProviderServicePrincipal.ResourceGroup}, }, } @@ -301,10 +302,10 @@ func Test_enterCloudProviderOptions(t *testing.T) { expectedWrites := []any{ output.LogOutput{ - Format: awsAccessKeysCreateInstructionFmt, + Format: common.AWSAccessKeysCreateInstructionFmt, }, output.LogOutput{ - Format: awsAccessKeysCreateInstructionFmt, + Format: common.AWSAccessKeysCreateInstructionFmt, }, } require.Equal(t, expectedWrites, outputSink.Writes) diff --git a/pkg/cli/cmd/radinit/cluster.go b/pkg/cli/cmd/radinit/cluster.go index 1877d4909d..a86554993d 100644 --- a/pkg/cli/cmd/radinit/cluster.go +++ b/pkg/cli/cmd/radinit/cluster.go @@ -17,79 +17,17 @@ limitations under the License. package radinit import ( - "sort" - - "github.com/radius-project/radius/pkg/cli/clierrors" - "github.com/radius-project/radius/pkg/version" - "k8s.io/client-go/tools/clientcmd/api" -) - -const ( - selectClusterPrompt = "Select the kubeconfig context to install Radius into" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" ) func (r *Runner) enterClusterOptions(options *initOptions) error { - var err error - options.Cluster.Context, err = r.selectCluster() + result, err := common.EnterClusterOptions(r.KubernetesInterface, r.HelmInterface, r.Prompter, r.Full) if err != nil { return err } - - state, err := r.HelmInterface.CheckRadiusInstall(options.Cluster.Context) - if err != nil { - return clierrors.MessageWithCause(err, "Unable to verify Radius installation.") - } - options.Cluster.Install = !state.RadiusInstalled - - if state.RadiusInstalled { - options.Cluster.Install = false - options.Cluster.Version = state.RadiusVersion - } - - if options.Cluster.Install { - options.Cluster.Install = true - options.Cluster.Version = version.Version() // This may not be the precise version we install for a pre-release. - options.Cluster.Namespace = "radius-system" - } - + options.Cluster.Install = result.Install + options.Cluster.Namespace = result.Namespace + options.Cluster.Context = result.Context + options.Cluster.Version = result.Version return nil } - -func (r *Runner) selectCluster() (string, error) { - kubeContextList, err := r.KubernetesInterface.GetKubeContext() - if err != nil { - return "", clierrors.MessageWithCause(err, "Failed to read Kubernetes config.") - } - - // When no flags are specified we will just take the default kubecontext - if !r.Full { - return kubeContextList.CurrentContext, nil - } - - choices := r.buildClusterList(kubeContextList) - cluster, err := r.Prompter.GetListInput(choices, selectClusterPrompt) - if err != nil { - return "", err - } - - return cluster, nil -} - -func (r *Runner) buildClusterList(config *api.Config) []string { - // Ensure current context is at the top as the default - // otherwise, sort the contexts alphabetically - others := []string{} - for k := range config.Contexts { - if k != config.CurrentContext { - others = append(others, k) - } - } - - sort.Strings(others) - - // Ensure current context is at the top as the default - choices := []string{config.CurrentContext} - choices = append(choices, others...) - - return choices -} diff --git a/pkg/cli/cmd/radinit/cluster_test.go b/pkg/cli/cmd/radinit/cluster_test.go index 2b97b615ab..27fced7300 100644 --- a/pkg/cli/cmd/radinit/cluster_test.go +++ b/pkg/cli/cmd/radinit/cluster_test.go @@ -24,7 +24,6 @@ import ( "github.com/radius-project/radius/pkg/cli/prompt" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" - "k8s.io/client-go/tools/clientcmd/api" ) func Test_enterClusterOptions(t *testing.T) { @@ -44,32 +43,3 @@ func Test_enterClusterOptions(t *testing.T) { require.Equal(t, "kind-kind", options.Cluster.Context) require.Equal(t, true, options.Cluster.Install) } - -func Test_selectCluster(t *testing.T) { - ctrl := gomock.NewController(t) - prompter := prompt.NewMockInterface(ctrl) - k8s := kubernetes.NewMockInterface(ctrl) - runner := Runner{Prompter: prompter, KubernetesInterface: k8s, Full: true} - - initGetKubeContextSuccess(k8s) - initKubeContextWithKind(prompter) - - name, err := runner.selectCluster() - require.NoError(t, err) - require.Equal(t, "kind-kind", name) -} - -func Test_buildClusterList(t *testing.T) { - config := &api.Config{ - CurrentContext: "c-test-cluster", - Contexts: map[string]*api.Context{ - "b-test-cluster": {}, - "a-test-cluster": {}, - "c-test-cluster": {}, - }, - } - runner := Runner{Full: true} - - names := runner.buildClusterList(config) - require.Equal(t, []string{"c-test-cluster", "a-test-cluster", "b-test-cluster"}, names) -} diff --git a/pkg/cli/cmd/radinit/common/application.go b/pkg/cli/cmd/radinit/common/application.go new file mode 100644 index 0000000000..e57d6d3ccb --- /dev/null +++ b/pkg/cli/cmd/radinit/common/application.go @@ -0,0 +1,82 @@ +/* +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 common + +import ( + "os" + "path/filepath" + + "github.com/radius-project/radius/pkg/cli/prompt" +) + +const ( + ConfirmSetupApplicationPrompt = "Setup application in the current directory?" + EnterApplicationNamePrompt = "Enter an application name" + enterApplicationNamePlaceholder = "Enter application name..." +) + +// EnterApplicationOptions prompts the user to scaffold an application and returns the scaffold flag and app name. +func EnterApplicationOptions(prompter prompt.Interface) (scaffold bool, name string, err error) { + scaffold, err = prompt.YesOrNoPrompt(ConfirmSetupApplicationPrompt, prompt.ConfirmYes, prompter) + if err != nil { + return false, "", err + } + + if !scaffold { + return false, "", nil + } + + chooseDefault := func() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + + return filepath.Base(wd), nil + } + + name, err = EnterApplicationName(prompter, chooseDefault) + if err != nil { + return false, "", err + } + + return scaffold, name, nil +} + +// EnterApplicationName returns the application name based on the chooseDefault function. If the value returned by +// chooseDefault is not a valid application name, the user will be prompted. +func EnterApplicationName(prompter prompt.Interface, chooseDefault func() (string, error)) (string, error) { + name, err := chooseDefault() + if err != nil { + return "", err + } + + err = prompt.ValidateApplicationName(name) + if err == nil { + return name, nil + } + + name, err = prompter.GetTextInput(EnterApplicationNamePrompt, prompt.TextInputOptions{ + Placeholder: enterApplicationNamePlaceholder, + Validate: prompt.ValidateApplicationName, + }) + if err != nil { + return "", err + } + + return name, nil +} diff --git a/pkg/cli/cmd/radinit/common/application_test.go b/pkg/cli/cmd/radinit/common/application_test.go new file mode 100644 index 0000000000..82839d2e96 --- /dev/null +++ b/pkg/cli/cmd/radinit/common/application_test.go @@ -0,0 +1,120 @@ +/* +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 common + +import ( + "testing" + + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func Test_EnterApplicationOptions(t *testing.T) { + t.Run("scaffold yes", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + prompter.EXPECT(). + GetListInput(gomock.Any(), ConfirmSetupApplicationPrompt). + Return(prompt.ConfirmYes, nil).Times(1) + + scaffold, name, err := EnterApplicationOptions(prompter) + require.NoError(t, err) + require.True(t, scaffold) + require.NotEmpty(t, name) + }) + + t.Run("scaffold no", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + prompter.EXPECT(). + GetListInput(gomock.Any(), ConfirmSetupApplicationPrompt). + Return(prompt.ConfirmNo, nil).Times(1) + + scaffold, name, err := EnterApplicationOptions(prompter) + require.NoError(t, err) + require.False(t, scaffold) + require.Empty(t, name) + }) +} + +func Test_EnterApplicationName(t *testing.T) { + t.Run("default is valid", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + name, err := EnterApplicationName(prompter, func() (string, error) { return "valid", nil }) + require.NoError(t, err) + require.Equal(t, "valid", name) + }) + + t.Run("user is prompted when default is invalid", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + prompter.EXPECT(). + GetTextInput(EnterApplicationNamePrompt, gomock.Any()). + Return("another-name", nil).Times(1) + + name, err := EnterApplicationName(prompter, func() (string, error) { return "invalid-0-----", nil }) + require.NoError(t, err) + require.Equal(t, "another-name", name) + }) + + t.Run("user is prompted when application name contains uppercase", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + prompter.EXPECT(). + GetTextInput(EnterApplicationNamePrompt, gomock.Any()). + Return("another-name", nil).Times(1) + + name, err := EnterApplicationName(prompter, func() (string, error) { return "Invalid-Name", nil }) + require.NoError(t, err) + require.Equal(t, "another-name", name) + }) + + t.Run("user is prompted when application name does not end with alphanumeric", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + prompter.EXPECT(). + GetTextInput(EnterApplicationNamePrompt, gomock.Any()). + Return("another-name", nil).Times(1) + + name, err := EnterApplicationName(prompter, func() (string, error) { return "test-application-", nil }) + require.NoError(t, err) + require.Equal(t, "another-name", name) + }) + + t.Run("user is prompted when application name is too long", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + prompter.EXPECT(). + GetTextInput(EnterApplicationNamePrompt, gomock.Any()). + Return("another-name", nil).Times(1) + + name, err := EnterApplicationName(prompter, func() (string, error) { + return "this-is-a-very-long-environment-name-that-is-invalid-this-is-a-very-long-application-name-that-is-invalid", nil + }) + require.NoError(t, err) + require.Equal(t, "another-name", name) + }) +} diff --git a/pkg/cli/cmd/radinit/common/aws.go b/pkg/cli/cmd/radinit/common/aws.go new file mode 100644 index 0000000000..f608c0f653 --- /dev/null +++ b/pkg/cli/cmd/radinit/common/aws.go @@ -0,0 +1,189 @@ +/* +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 common + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/service/ec2" + "github.com/charmbracelet/bubbles/textinput" + "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/prompt" +) + +const ( + SelectAWSRegionPrompt = "Select the region you would like to deploy AWS resources to:" + SelectAWSCredentialKindPrompt = "Select a credential kind for the AWS credential:" + EnterAWSIAMAcessKeyIDPrompt = "Enter the IAM access key id:" + EnterAWSRoleARNPrompt = "Enter the role ARN:" + EnterAWSRoleARNPlaceholder = "Enter IAM role ARN..." + EnterAWSIAMAcessKeyIDPlaceholder = "Enter IAM access key id..." + EnterAWSIAMSecretAccessKeyPrompt = "Enter your IAM Secret Access Key:" + EnterAWSIAMSecretAccessKeyPlaceholder = "Enter IAM secret access key..." + ErrNotEmptyTemplate = "%s cannot be empty" + ConfirmAWSAccountIDPromptFmt = "Use account id '%v'?" + EnterAWSAccountIDPrompt = "Enter the account ID:" + EnterAWSAccountIDPlaceholder = "Enter the account ID you want to use..." + + AWSAccessKeysCreateInstructionFmt = "\nAWS IAM Access keys (Access key ID and Secret access key) are required to access and create AWS resources.\n\nFor example, you can create one using the following command:\n\033[36maws iam create-access-key\033[0m\n\nFor more information refer to https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html.\n\n" + AWSIRSACredentialKind = "IRSA" + AWSAccessKeyCredentialKind = "Access Key" +) + +// EnterAWSCloudProvider prompts the user for AWS cloud provider configuration. +// The caller is responsible for any post-processing such as enabling IRSA Helm +// values based on the returned provider's CredentialKind. +func EnterAWSCloudProvider(ctx context.Context, prompter prompt.Interface, out output.Interface, awsClient aws.Client) (*aws.Provider, error) { + credentialKind, err := SelectAWSCredentialKind(prompter) + if err != nil { + return nil, err + } + + switch credentialKind { + case AWSAccessKeyCredentialKind: + out.LogInfo(AWSAccessKeysCreateInstructionFmt) + + accessKeyID, err := prompter.GetTextInput(EnterAWSIAMAcessKeyIDPrompt, prompt.TextInputOptions{Placeholder: EnterAWSIAMAcessKeyIDPlaceholder}) + if err != nil { + return nil, err + } + + secretAccessKey, err := prompter.GetTextInput(EnterAWSIAMSecretAccessKeyPrompt, prompt.TextInputOptions{Placeholder: EnterAWSIAMSecretAccessKeyPlaceholder, EchoMode: textinput.EchoPassword}) + if err != nil { + return nil, err + } + + accountID, err := GetAWSAccountID(ctx, prompter, awsClient) + if err != nil { + return nil, err + } + + region, err := SelectAWSRegion(ctx, prompter, awsClient) + if err != nil { + return nil, err + } + + return &aws.Provider{ + AccessKey: &aws.AccessKeyCredential{ + AccessKeyID: accessKeyID, + SecretAccessKey: secretAccessKey, + }, + CredentialKind: aws.AWSCredentialKindAccessKey, + AccountID: accountID, + Region: region, + }, nil + case AWSIRSACredentialKind: + out.LogInfo(AWSAccessKeysCreateInstructionFmt) + + roleARN, err := prompter.GetTextInput(EnterAWSRoleARNPrompt, prompt.TextInputOptions{Placeholder: EnterAWSRoleARNPlaceholder}) + if err != nil { + return nil, err + } + + accountID, err := GetAWSAccountID(ctx, prompter, awsClient) + if err != nil { + return nil, err + } + + region, err := SelectAWSRegion(ctx, prompter, awsClient) + if err != nil { + return nil, err + } + + return &aws.Provider{ + AccountID: accountID, + Region: region, + CredentialKind: aws.AWSCredentialKindIRSA, + IRSA: &aws.IRSACredential{ + RoleARN: roleARN, + }, + }, nil + default: + return nil, clierrors.Message("Invalid AWS credential kind: %s", credentialKind) + } +} + +// GetAWSAccountID retrieves the AWS account ID via the configured AWS client and +// optionally allows the user to override it. +func GetAWSAccountID(ctx context.Context, prompter prompt.Interface, awsClient aws.Client) (string, error) { + callerIdentityOutput, err := awsClient.GetCallerIdentity(ctx) + if err != nil { + return "", clierrors.MessageWithCause(err, "AWS Cloud Provider setup failed, please use aws configure to set up the configuration. More information :https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html") + } + + if callerIdentityOutput.Account == nil { + return "", clierrors.MessageWithCause(err, "AWS credential verification failed: Account ID is nil.") + } + + accountID := *callerIdentityOutput.Account + useDetectedAccount, err := prompt.YesOrNoPrompt(fmt.Sprintf(ConfirmAWSAccountIDPromptFmt, accountID), prompt.ConfirmYes, prompter) + if err != nil { + return "", err + } + + if !useDetectedAccount { + accountID, err = prompter.GetTextInput(EnterAWSAccountIDPrompt, prompt.TextInputOptions{Placeholder: EnterAWSAccountIDPlaceholder}) + if err != nil { + return "", err + } + } + + return accountID, nil +} + +// SelectAWSRegion prompts the user to select an AWS region from the list of +// regions available to the configured AWS account. +func SelectAWSRegion(ctx context.Context, prompter prompt.Interface, awsClient aws.Client) (string, error) { + listRegionsOutput, err := awsClient.ListRegions(ctx) + if err != nil { + return "", clierrors.MessageWithCause(err, "Listing AWS regions failed.") + } + + regions := BuildAWSRegionsList(listRegionsOutput) + selectedRegion, err := prompter.GetListInput(regions, SelectAWSRegionPrompt) + if err != nil { + return "", err + } + + return selectedRegion, nil +} + +// BuildAWSRegionsList extracts region names from the AWS DescribeRegions output. +func BuildAWSRegionsList(listRegionsOutput *ec2.DescribeRegionsOutput) []string { + regions := []string{} + for _, region := range listRegionsOutput.Regions { + regions = append(regions, *region.RegionName) + } + + return regions +} + +// SelectAWSCredentialKind prompts the user to select an AWS credential kind. +func SelectAWSCredentialKind(prompter prompt.Interface) (string, error) { + return prompter.GetListInput(BuildAWSCredentialKindList(), SelectAWSCredentialKindPrompt) +} + +// BuildAWSCredentialKindList returns the list of supported AWS credential kinds. +func BuildAWSCredentialKindList() []string { + return []string{ + AWSAccessKeyCredentialKind, + AWSIRSACredentialKind, + } +} diff --git a/pkg/cli/cmd/radinit/common/azure.go b/pkg/cli/cmd/radinit/common/azure.go new file mode 100644 index 0000000000..91c4cbbf7c --- /dev/null +++ b/pkg/cli/cmd/radinit/common/azure.go @@ -0,0 +1,313 @@ +/* +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 common + +import ( + "context" + "fmt" + "sort" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions" + "github.com/charmbracelet/bubbles/textinput" + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/output" + "github.com/radius-project/radius/pkg/cli/prompt" +) + +const ( + ConfirmAzureSubscriptionPromptFmt = "Use subscription '%v'?" + SelectAzureSubscriptionPrompt = "Select a subscription:" + ConfirmAzureCreateResourceGroupPrompt = "Create a new resource group?" + EnterAzureResourceGroupNamePrompt = "Enter a resource group name" + EnterAzureResourceGroupNamePlaceholder = "Enter resource group name" + SelectAzureResourceGroupLocationPrompt = "Select a location for the resource group:" + SelectAzureResourceGroupPrompt = "Select a resource group:" + SelectAzureCredentialKindPrompt = "Select a credential kind for the Azure credential:" + EnterAzureServicePrincipalAppIDPrompt = "Enter the `appId` of the service principal used to create Azure resources" + EnterAzureServicePrincipalAppIDPlaceholder = "Enter appId..." + EnterAzureServicePrincipalPasswordPrompt = "Enter the `password` of the service principal used to create Azure resources" + EnterAzureServicePrincipalPasswordPlaceholder = "Enter password..." + EnterAzureServicePrincipalTenantIDPrompt = "Enter the `tenantId` of the service principal used to create Azure resources" + EnterAzureServicePrincipalTenantIDPlaceholder = "Enter tenantId..." + EnterAzureWorkloadIdentityAppIDPrompt = "Enter the `appId` of the Entra ID Application" + EnterAzureWorkloadIdentityAppIDPlaceholder = "Enter appId..." + EnterAzureWorkloadIdentityTenantIDPrompt = "Enter the `tenantId` of the Entra ID Application" + EnterAzureWorkloadIdentityTenantIDPlaceholder = "Enter tenantId..." + AzureWorkloadIdentityCreateInstructionsFmt = "\nA workload identity federated credential is required to create Azure resources. Please follow the guidance at aka.ms/rad-workload-identity to set up workload identity for Radius.\n\n" + AzureServicePrincipalCreateInstructionsFmt = "\nAn Azure service principal with a corresponding role assignment on your resource group is required to create Azure resources.\n\nFor example, you can create one using the following command:\n\033[36maz ad sp create-for-rbac --role Owner --scope /subscriptions/%s/resourceGroups/%s\033[0m\n\nFor more information refer to https://docs.microsoft.com/cli/azure/ad/sp?view=azure-cli-latest#az-ad-sp-create-for-rbac and https://aka.ms/azadsp-more\n\n" + AzureServicePrincipalCredentialKind = "Service Principal" + AzureWorkloadIdenityCredentialKind = "Workload Identity" +) + +// EnterAzureCloudProvider prompts the user for Azure cloud provider configuration. +// The caller is responsible for any post-processing such as enabling workload +// identity Helm values based on the returned provider's CredentialKind. +func EnterAzureCloudProvider(ctx context.Context, prompter prompt.Interface, out output.Interface, azureClient azure.Client) (*azure.Provider, error) { + subscription, err := SelectAzureSubscription(ctx, prompter, azureClient) + if err != nil { + return nil, err + } + + resourceGroup, err := SelectAzureResourceGroup(ctx, prompter, out, azureClient, *subscription) + if err != nil { + return nil, err + } + + credentialKind, err := SelectAzureCredentialKind(prompter) + if err != nil { + return nil, err + } + + switch credentialKind { + case AzureServicePrincipalCredentialKind: + out.LogInfo(AzureServicePrincipalCreateInstructionsFmt, subscription.ID, resourceGroup) + + clientID, err := prompter.GetTextInput(EnterAzureServicePrincipalAppIDPrompt, prompt.TextInputOptions{ + Placeholder: EnterAzureServicePrincipalAppIDPlaceholder, + Validate: prompt.ValidateUUIDv4, + }) + if err != nil { + return nil, err + } + + clientSecret, err := prompter.GetTextInput(EnterAzureServicePrincipalPasswordPrompt, prompt.TextInputOptions{Placeholder: EnterAzureServicePrincipalPasswordPlaceholder, EchoMode: textinput.EchoPassword}) + if err != nil { + return nil, err + } + + tenantID, err := prompter.GetTextInput(EnterAzureServicePrincipalTenantIDPrompt, prompt.TextInputOptions{ + Placeholder: EnterAzureServicePrincipalTenantIDPlaceholder, + Validate: prompt.ValidateUUIDv4, + }) + if err != nil { + return nil, err + } + + return &azure.Provider{ + SubscriptionID: subscription.ID, + ResourceGroup: resourceGroup, + CredentialKind: azure.AzureCredentialKindServicePrincipal, + ServicePrincipal: &azure.ServicePrincipalCredential{ + ClientID: clientID, + ClientSecret: clientSecret, + TenantID: tenantID, + }, + }, nil + case AzureWorkloadIdenityCredentialKind: + out.LogInfo(AzureWorkloadIdentityCreateInstructionsFmt) + + clientID, err := prompter.GetTextInput(EnterAzureWorkloadIdentityAppIDPrompt, prompt.TextInputOptions{ + Placeholder: EnterAzureWorkloadIdentityAppIDPlaceholder, + Validate: prompt.ValidateUUIDv4, + }) + if err != nil { + return nil, err + } + + tenantID, err := prompter.GetTextInput(EnterAzureWorkloadIdentityTenantIDPrompt, prompt.TextInputOptions{ + Placeholder: EnterAzureWorkloadIdentityTenantIDPlaceholder, + Validate: prompt.ValidateUUIDv4, + }) + if err != nil { + return nil, err + } + + return &azure.Provider{ + SubscriptionID: subscription.ID, + ResourceGroup: resourceGroup, + CredentialKind: azure.AzureCredentialKindWorkloadIdentity, + WorkloadIdentity: &azure.WorkloadIdentityCredential{ + ClientID: clientID, + TenantID: tenantID, + }, + }, nil + default: + return nil, clierrors.Message("Invalid Azure credential kind: %s", credentialKind) + } +} + +// SelectAzureSubscription prompts the user to select an Azure subscription. If +// a default subscription is configured the user is asked whether to use it. +func SelectAzureSubscription(ctx context.Context, prompter prompt.Interface, azureClient azure.Client) (*azure.Subscription, error) { + subscriptions, err := azureClient.Subscriptions(ctx) + if err != nil { + return nil, clierrors.MessageWithCause(err, "Failed to list Azure subscriptions.") + } + + // Users can configure a default subscription with `az account set`. If they did, then ask about that first. + if subscriptions.Default != nil { + confirmed, err := prompt.YesOrNoPrompt(fmt.Sprintf(ConfirmAzureSubscriptionPromptFmt, subscriptions.Default.Name), prompt.ConfirmYes, prompter) + if err != nil { + return nil, err + } + + if confirmed { + return subscriptions.Default, nil + } + } + + names, subscriptionMap := BuildAzureSubscriptionListAndMap(subscriptions) + name, err := prompter.GetListInput(names, SelectAzureSubscriptionPrompt) + if err != nil { + return nil, err + } + + subscription := subscriptionMap[name] + return &subscription, nil +} + +// SelectAzureCredentialKind prompts the user to select an Azure credential kind. +func SelectAzureCredentialKind(prompter prompt.Interface) (string, error) { + return prompter.GetListInput(BuildAzureCredentialKindList(), SelectAzureCredentialKindPrompt) +} + +// BuildAzureSubscriptionListAndMap builds a list of subscription names and a +// map of name => subscription for use by the prompt. +func BuildAzureSubscriptionListAndMap(subscriptions *azure.SubscriptionResult) ([]string, map[string]azure.Subscription) { + subscriptionMap := map[string]azure.Subscription{} + names := []string{} + for _, s := range subscriptions.Subscriptions { + subscriptionMap[s.Name] = s + names = append(names, s.Name) + } + + sort.Strings(names) + + return names, subscriptionMap +} + +// SelectAzureResourceGroup either creates a new resource group or prompts the +// user to choose an existing one. +func SelectAzureResourceGroup(ctx context.Context, prompter prompt.Interface, out output.Interface, azureClient azure.Client, subscription azure.Subscription) (string, error) { + create, err := prompt.YesOrNoPrompt(ConfirmAzureCreateResourceGroupPrompt, prompt.ConfirmYes, prompter) + if err != nil { + return "", err + } + + if !create { + return SelectExistingAzureResourceGroup(ctx, prompter, azureClient, subscription) + } + + name, err := EnterAzureResourceGroupName(prompter) + if err != nil { + return "", err + } + + exists, err := azureClient.CheckResourceGroupExistence(ctx, subscription.ID, name) + if err != nil { + return "", err + } + + // Nothing left to do. + if exists { + return name, nil + } + + out.LogInfo("Resource Group '%v' will be created...", name) + + location, err := SelectAzureResourceGroupLocation(ctx, prompter, azureClient, subscription) + if err != nil { + return "", err + } + + err = azureClient.CreateOrUpdateResourceGroup(ctx, subscription.ID, name, *location.Name) + if err != nil { + return "", clierrors.MessageWithCause(err, "Failed to create Azure resource group.") + } + + return name, nil +} + +// SelectExistingAzureResourceGroup prompts the user to pick from existing +// resource groups in the given subscription. +func SelectExistingAzureResourceGroup(ctx context.Context, prompter prompt.Interface, azureClient azure.Client, subscription azure.Subscription) (string, error) { + groups, err := azureClient.ResourceGroups(ctx, subscription.ID) + if err != nil { + return "", clierrors.MessageWithCause(err, "Failed to get list Azure resource groups.") + } + + names := BuildAzureResourceGroupList(groups) + name, err := prompter.GetListInput(names, SelectAzureResourceGroupPrompt) + if err != nil { + return "", err + } + + return name, nil +} + +// BuildAzureResourceGroupList builds a sorted list of resource group names. +func BuildAzureResourceGroupList(groups []armresources.ResourceGroup) []string { + names := []string{} + for _, s := range groups { + names = append(names, *s.Name) + } + + sort.Strings(names) + + return names +} + +// EnterAzureResourceGroupName prompts the user for a resource group name. +func EnterAzureResourceGroupName(prompter prompt.Interface) (string, error) { + return prompter.GetTextInput(EnterAzureResourceGroupNamePrompt, prompt.TextInputOptions{ + Placeholder: EnterAzureResourceGroupNamePlaceholder, + Validate: prompt.ValidateResourceName, + }) +} + +// SelectAzureResourceGroupLocation prompts the user to pick a location for a +// new resource group. +func SelectAzureResourceGroupLocation(ctx context.Context, prompter prompt.Interface, azureClient azure.Client, subscription azure.Subscription) (*armsubscriptions.Location, error) { + locations, err := azureClient.Locations(ctx, subscription.ID) + if err != nil { + return nil, clierrors.MessageWithCause(err, "Failed to get list Azure locations.") + } + + names, locationMap := BuildAzureResourceGroupLocationListAndMap(locations) + name, err := prompter.GetListInput(names, SelectAzureResourceGroupLocationPrompt) + if err != nil { + return nil, err + } + + location := locationMap[name] + return &location, nil +} + +// BuildAzureResourceGroupLocationListAndMap builds a sorted list of location +// display names and a map of display name => location. +func BuildAzureResourceGroupLocationListAndMap(locations []armsubscriptions.Location) ([]string, map[string]armsubscriptions.Location) { + locationMap := map[string]armsubscriptions.Location{} + names := []string{} + for _, location := range locations { + names = append(names, *location.DisplayName) + locationMap[*location.DisplayName] = location + } + + sort.Strings(names) + + return names, locationMap +} + +// BuildAzureCredentialKindList returns the list of supported Azure credential kinds. +func BuildAzureCredentialKindList() []string { + return []string{ + AzureServicePrincipalCredentialKind, + AzureWorkloadIdenityCredentialKind, + } +} diff --git a/pkg/cli/cmd/radinit/common/cloud.go b/pkg/cli/cmd/radinit/common/cloud.go new file mode 100644 index 0000000000..c8f00f1cca --- /dev/null +++ b/pkg/cli/cmd/radinit/common/cloud.go @@ -0,0 +1,97 @@ +/* +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 common + +import ( + "errors" + + "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/prompt" +) + +const ( + ConfirmCloudProviderBackNavigationSentinel = "[back]" + ConfirmCloudProviderPrompt = "Add cloud providers for cloud resources?" + ConfirmCloudProviderAdditionalPrompt = "Add additional cloud providers for cloud resources?" + SelectCloudProviderPrompt = "Select your cloud provider" +) + +// CloudProviderResult holds the results of gathering cloud provider options. +type CloudProviderResult struct { + Azure *azure.Provider + AWS *aws.Provider +} + +// AzureProviderFunc is a callback to gather Azure provider config. +type AzureProviderFunc func() (*azure.Provider, error) + +// AWSProviderFunc is a callback to gather AWS provider config. +type AWSProviderFunc func() (*aws.Provider, error) + +// EnterCloudProviderOptions prompts the user to add cloud providers. +// If full is false or environmentCreate is false, it returns immediately with no providers. +func EnterCloudProviderOptions(prompter prompt.Interface, full bool, environmentCreate bool, enterAzure AzureProviderFunc, enterAWS AWSProviderFunc) (CloudProviderResult, error) { + result := CloudProviderResult{} + + if !full { + return result, nil + } + + if !environmentCreate { + return result, nil + } + + addingCloudProvider, err := prompt.YesOrNoPrompt(ConfirmCloudProviderPrompt, prompt.ConfirmNo, prompter) + if err != nil { + return result, err + } + + for addingCloudProvider { + choices := []string{azure.ProviderDisplayName, aws.ProviderDisplayName, ConfirmCloudProviderBackNavigationSentinel} + cloudProvider, err := prompter.GetListInput(choices, SelectCloudProviderPrompt) + if err != nil { + return result, err + } + + switch cloudProvider { + case azure.ProviderDisplayName: + provider, err := enterAzure() + if err != nil { + return result, err + } + result.Azure = provider + case aws.ProviderDisplayName: + provider, err := enterAWS() + if err != nil { + return result, err + } + result.AWS = provider + case ConfirmCloudProviderBackNavigationSentinel: + return result, nil + default: + return result, errors.New("unsupported Cloud Provider") + } + + addingCloudProvider, err = prompt.YesOrNoPrompt(ConfirmCloudProviderAdditionalPrompt, prompt.ConfirmNo, prompter) + if err != nil { + return result, err + } + } + + return result, nil +} diff --git a/pkg/cli/cmd/radinit/common/cloud_test.go b/pkg/cli/cmd/radinit/common/cloud_test.go new file mode 100644 index 0000000000..90763056a1 --- /dev/null +++ b/pkg/cli/cmd/radinit/common/cloud_test.go @@ -0,0 +1,86 @@ +/* +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 common + +import ( + "testing" + + "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func Test_EnterCloudProviderOptions(t *testing.T) { + t.Run("not full mode returns empty", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + result, err := EnterCloudProviderOptions(prompter, false, true, nil, nil) + require.NoError(t, err) + require.Nil(t, result.Azure) + require.Nil(t, result.AWS) + }) + + t.Run("not creating environment returns empty", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + result, err := EnterCloudProviderOptions(prompter, true, false, nil, nil) + require.NoError(t, err) + require.Nil(t, result.Azure) + require.Nil(t, result.AWS) + }) + + t.Run("user declines cloud provider", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + prompter.EXPECT(). + GetListInput(gomock.Any(), gomock.Any()). + Return(prompt.ConfirmNo, nil).Times(1) + + result, err := EnterCloudProviderOptions(prompter, true, true, nil, nil) + require.NoError(t, err) + require.Nil(t, result.Azure) + require.Nil(t, result.AWS) + }) + + t.Run("user navigates back", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + + // First: confirm yes to add cloud provider + prompter.EXPECT(). + GetListInput(gomock.Any(), gomock.Any()). + Return(prompt.ConfirmYes, nil).Times(1) + + // Second: select [back] + prompter.EXPECT(). + GetListInput(gomock.Any(), SelectCloudProviderPrompt). + Return(ConfirmCloudProviderBackNavigationSentinel, nil).Times(1) + + result, err := EnterCloudProviderOptions(prompter, true, true, + func() (*azure.Provider, error) { return nil, nil }, + func() (*aws.Provider, error) { return nil, nil }, + ) + require.NoError(t, err) + require.Nil(t, result.Azure) + require.Nil(t, result.AWS) + }) +} diff --git a/pkg/cli/cmd/radinit/common/cluster.go b/pkg/cli/cmd/radinit/common/cluster.go new file mode 100644 index 0000000000..972710b87c --- /dev/null +++ b/pkg/cli/cmd/radinit/common/cluster.go @@ -0,0 +1,103 @@ +/* +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 common + +import ( + "sort" + + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/helm" + "github.com/radius-project/radius/pkg/cli/kubernetes" + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/radius-project/radius/pkg/version" + "k8s.io/client-go/tools/clientcmd/api" +) + +const ( + SelectClusterPrompt = "Select the kubeconfig context to install Radius into" +) + +// ClusterResult holds the results of gathering cluster options. +type ClusterResult struct { + Install bool + Namespace string + Context string + Version string +} + +// EnterClusterOptions gathers cluster options by selecting a kube context and checking Radius install state. +func EnterClusterOptions(k8s kubernetes.Interface, helmClient helm.Interface, prompter prompt.Interface, full bool) (ClusterResult, error) { + clusterContext, err := SelectCluster(k8s, prompter, full) + if err != nil { + return ClusterResult{}, err + } + + state, err := helmClient.CheckRadiusInstall(clusterContext) + if err != nil { + return ClusterResult{}, clierrors.MessageWithCause(err, "Unable to verify Radius installation.") + } + + result := ClusterResult{Context: clusterContext} + + if state.RadiusInstalled { + result.Install = false + result.Version = state.RadiusVersion + } else { + result.Install = true + result.Version = version.Version() + result.Namespace = "radius-system" + } + + return result, nil +} + +// SelectCluster selects a kube context. If full is false, the current context is used automatically. +func SelectCluster(k8s kubernetes.Interface, prompter prompt.Interface, full bool) (string, error) { + kubeContextList, err := k8s.GetKubeContext() + if err != nil { + return "", clierrors.MessageWithCause(err, "Failed to read Kubernetes config.") + } + + if !full { + return kubeContextList.CurrentContext, nil + } + + choices := BuildClusterList(kubeContextList) + cluster, err := prompter.GetListInput(choices, SelectClusterPrompt) + if err != nil { + return "", err + } + + return cluster, nil +} + +// BuildClusterList builds a sorted list of cluster contexts with the current context first. +func BuildClusterList(config *api.Config) []string { + others := []string{} + for k := range config.Contexts { + if k != config.CurrentContext { + others = append(others, k) + } + } + + sort.Strings(others) + + choices := []string{config.CurrentContext} + choices = append(choices, others...) + + return choices +} diff --git a/pkg/cli/cmd/radinit/common/cluster_test.go b/pkg/cli/cmd/radinit/common/cluster_test.go new file mode 100644 index 0000000000..8df91ece25 --- /dev/null +++ b/pkg/cli/cmd/radinit/common/cluster_test.go @@ -0,0 +1,114 @@ +/* +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 common + +import ( + "testing" + + "github.com/radius-project/radius/pkg/cli/helm" + "github.com/radius-project/radius/pkg/cli/kubernetes" + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "k8s.io/client-go/tools/clientcmd/api" +) + +func getTestKubeConfig() *api.Config { + return &api.Config{ + CurrentContext: "kind-kind", + Contexts: map[string]*api.Context{ + "docker-desktop": {Cluster: "docker-desktop"}, + "k3d-radius-dev": {Cluster: "k3d-radius-dev"}, + "kind-kind": {Cluster: "kind-kind"}, + }, + } +} + +func Test_SelectCluster(t *testing.T) { + t.Run("full mode prompts user", func(t *testing.T) { + ctrl := gomock.NewController(t) + k8s := kubernetes.NewMockInterface(ctrl) + prompter := prompt.NewMockInterface(ctrl) + + k8s.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + prompter.EXPECT().GetListInput(gomock.Any(), SelectClusterPrompt).Return("kind-kind", nil).Times(1) + + name, err := SelectCluster(k8s, prompter, true) + require.NoError(t, err) + require.Equal(t, "kind-kind", name) + }) + + t.Run("non-full mode uses current context", func(t *testing.T) { + ctrl := gomock.NewController(t) + k8s := kubernetes.NewMockInterface(ctrl) + prompter := prompt.NewMockInterface(ctrl) + + k8s.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + + name, err := SelectCluster(k8s, prompter, false) + require.NoError(t, err) + require.Equal(t, "kind-kind", name) + }) +} + +func Test_BuildClusterList(t *testing.T) { + config := &api.Config{ + CurrentContext: "c-test-cluster", + Contexts: map[string]*api.Context{ + "b-test-cluster": {}, + "a-test-cluster": {}, + "c-test-cluster": {}, + }, + } + + names := BuildClusterList(config) + require.Equal(t, []string{"c-test-cluster", "a-test-cluster", "b-test-cluster"}, names) +} + +func Test_EnterClusterOptions(t *testing.T) { + t.Run("radius installed", func(t *testing.T) { + ctrl := gomock.NewController(t) + k8s := kubernetes.NewMockInterface(ctrl) + helmMock := helm.NewMockInterface(ctrl) + prompter := prompt.NewMockInterface(ctrl) + + k8s.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + helmMock.EXPECT().CheckRadiusInstall(gomock.Any()).Return(helm.InstallState{RadiusInstalled: true, RadiusVersion: "0.40"}, nil).Times(1) + + result, err := EnterClusterOptions(k8s, helmMock, prompter, false) + require.NoError(t, err) + require.False(t, result.Install) + require.Equal(t, "0.40", result.Version) + require.Equal(t, "kind-kind", result.Context) + }) + + t.Run("radius not installed", func(t *testing.T) { + ctrl := gomock.NewController(t) + k8s := kubernetes.NewMockInterface(ctrl) + helmMock := helm.NewMockInterface(ctrl) + prompter := prompt.NewMockInterface(ctrl) + + k8s.EXPECT().GetKubeContext().Return(getTestKubeConfig(), nil).Times(1) + helmMock.EXPECT().CheckRadiusInstall(gomock.Any()).Return(helm.InstallState{RadiusInstalled: false}, nil).Times(1) + + result, err := EnterClusterOptions(k8s, helmMock, prompter, false) + require.NoError(t, err) + require.True(t, result.Install) + require.Equal(t, "radius-system", result.Namespace) + require.Equal(t, "kind-kind", result.Context) + }) +} diff --git a/pkg/cli/cmd/radinit/common/display.go b/pkg/cli/cmd/radinit/common/display.go new file mode 100644 index 0000000000..1a42de2c13 --- /dev/null +++ b/pkg/cli/cmd/radinit/common/display.go @@ -0,0 +1,433 @@ +/* +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 common + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/prompt" +) + +// Display constants used to render the summary and progress views shown by +// `rad init` (and its preview variant). +const ( + SummaryIndent = " - " + SummaryHeading = "You've selected the following:\n\n" + SummaryFooter = "\n(press enter to confirm or esc to restart)\n" + SummaryKubernetesHeadingIcon = "🔧 " + SummaryKubernetesInstallHeadingFmt = "Install Radius %s\n" + SummaryIndent + "Kubernetes cluster: %s\n" + SummaryIndent + "Kubernetes namespace: %s\n" + SummaryKubernetesInstallAWSCloudProviderFmt = SummaryIndent + "AWS credential: %s\n" + SummaryKubernetesInstallAzureCloudProviderFmt = SummaryIndent + "Azure credential: %s\n" + SummaryKubernetesExistingHeadingFmt = "Use existing Radius %s install on %s\n" + SummaryEnvironmentHeadingIcon = "🌏 " + SummaryEnvironmentCreateHeadingFmt = "Create new environment %s\n" + SummaryIndent + "Kubernetes namespace: %s\n" + SummaryEnvironmentCreateAWSCloudProviderFmt = SummaryIndent + "AWS: account %s and region %s\n" + SummaryEnvironmentCreateAzureCloudProviderFmt = SummaryIndent + "Azure: subscription %s and resource group %s\n" + SummaryEnvironmentCreateRecipePackyFmt = SummaryIndent + "Recipe pack: %s\n" + SummaryEnvironmentExistingHeadingFmt = "Use existing environment %s\n" + SummaryApplicationHeadingIcon = "🚧 " + SummaryApplicationScaffoldHeadingFmt = "Scaffold application %s\n" + SummaryApplicationScaffoldFile = SummaryIndent + "Create %s\n" + SummaryConfigurationHeadingIcon = "📋 " + SummaryConfigurationUpdateHeading = "Update local configuration\n" + ProgressHeading = "Initializing Radius. This may take a minute or two...\n\n" + ProgressCompleteFooter = "\nInitialization complete! Have a RAD time 😎\n\n" + ProgressStepCompleteIcon = "✅ " + ProgressStepWaitingIcon = "⏳ " +) + +var ( + progressSpinner = spinner.Spinner{ + Frames: []string{"🕐 ", "🕑 ", "🕒 ", "🕓 ", "🕔 ", "🕕 ", "🕖 ", "🕗 ", "🕘 ", "🕙 ", "🕚 ", "🕛 "}, + FPS: time.Second / 4, + } + + foregroundBrightStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#111111", Dark: "#EEEEEE"}).Bold(true) +) + +// DisplayOptions is the data model rendered by the summary and progress views. +// +// Callers convert their package-specific options struct into a DisplayOptions +// before invoking ConfirmOptions or ShowProgress. +type DisplayOptions struct { + Cluster ClusterDisplay + Environment EnvironmentDisplay + CloudProviders CloudProvidersDisplay + Application ApplicationDisplay + + // RecipePackLabel is the label of the recipe pack to display in the summary. + // An empty value omits the recipe pack line entirely. + RecipePackLabel string +} + +// ClusterDisplay holds the cluster fields rendered by the summary and progress views. +type ClusterDisplay struct { + Install bool + Namespace string + Context string + Version string +} + +// EnvironmentDisplay holds the environment fields rendered by the summary and progress views. +type EnvironmentDisplay struct { + Create bool + Name string + Namespace string +} + +// CloudProvidersDisplay holds the cloud provider fields rendered by the summary and progress views. +type CloudProvidersDisplay struct { + Azure *azure.Provider + AWS *aws.Provider +} + +// ApplicationDisplay holds the application fields rendered by the summary and progress views. +type ApplicationDisplay struct { + Scaffold bool + Name string + // ScaffoldFiles are the files to list under the scaffold application heading. + ScaffoldFiles []string +} + +// ProgressMsg is a message sent to the progress display to update the status of the installation. +type ProgressMsg struct { + InstallComplete bool + EnvironmentComplete bool + ApplicationComplete bool + ConfigComplete bool +} + +// SummaryResult represents the user's choice on the summary screen. +type SummaryResult string + +const ( + ResultConfirmed SummaryResult = "confirmed" + ResultCanceled SummaryResult = "canceled" + ResultQuit SummaryResult = "quit" +) + +// ConfirmOptions shows a summary of the user's selections and prompts for confirmation. +func ConfirmOptions(ctx context.Context, prompter prompt.Interface, options DisplayOptions) (bool, error) { + model := NewSummaryModel(options) + program := tea.NewProgram(model, tea.WithContext(ctx)) + + model, err := prompter.RunProgram(program) + if err != nil { + return false, err + } + + switch model.(*SummaryModel).Result { + case ResultConfirmed: + return true, nil + case ResultCanceled: + return false, nil + case ResultQuit: + return false, &prompt.ErrExitConsole{} + default: + panic("unknown result " + model.(*SummaryModel).Result) + } +} + +// ShowProgress shows an updating progress display while the user's selections are being applied. +// +// This function should be called from a goroutine while installation proceeds in the background. +// Progress updates are received on progressChan; the loop also exits when ctx is canceled. +func ShowProgress(ctx context.Context, prompter prompt.Interface, options DisplayOptions, progressChan <-chan ProgressMsg) error { + model := NewProgressModel(options) + program := tea.NewProgram(model, tea.WithContext(ctx)) + + go func() { + for { + select { + case <-ctx.Done(): + program.Send(tea.Quit) + return + case msg, ok := <-progressChan: + if !ok { + program.Send(tea.Quit) + return + } + + program.Send(msg) + } + } + }() + + _, err := prompter.RunProgram(program) + return err +} + +var _ tea.Model = &SummaryModel{} + +// SummaryModel is the bubble tea model for the options summary shown during 'rad init'. +type SummaryModel struct { + style lipgloss.Style + Result SummaryResult + Options DisplayOptions + width int +} + +// NewSummaryModel creates a new model for the options summary shown during 'rad init'. +func NewSummaryModel(options DisplayOptions) tea.Model { + return &SummaryModel{ + style: lipgloss.NewStyle().Margin(1, 0), + Options: options, + } +} + +// Init implements tea.Model. +func (m *SummaryModel) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. Pressing Ctrl+C quits, Esc cancels, and Enter confirms. +func (m *SummaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + case tea.KeyMsg: + if msg.Type == tea.KeyCtrlC { + copy := *m + copy.Result = ResultQuit + return ©, tea.Quit + } + if msg.Type == tea.KeyEsc { + copy := *m + copy.Result = ResultCanceled + return ©, tea.Quit + } + if msg.Type == tea.KeyEnter { + copy := *m + copy.Result = ResultConfirmed + return ©, tea.Quit + } + } + + return m, nil +} + +// View implements tea.Model. It renders the summary of selected options. +func (m *SummaryModel) View() string { + if m.Result != "" { + return "" + } + + options := m.Options + + message := &strings.Builder{} + message.WriteString(SummaryHeading) + + message.WriteString(SummaryKubernetesHeadingIcon) + if options.Cluster.Install { + message.WriteString(fmt.Sprintf(SummaryKubernetesInstallHeadingFmt, highlight(options.Cluster.Version), highlight(options.Cluster.Context), highlight(options.Cluster.Namespace))) + writeCloudProviderInstallSummary(message, options.CloudProviders) + } else { + message.WriteString(fmt.Sprintf(SummaryKubernetesExistingHeadingFmt, highlight(options.Cluster.Version), highlight(options.Cluster.Context))) + } + + message.WriteString(SummaryEnvironmentHeadingIcon) + writeEnvironmentSummary(message, options) + + if options.Application.Scaffold { + message.WriteString(SummaryApplicationHeadingIcon) + message.WriteString(fmt.Sprintf(SummaryApplicationScaffoldHeadingFmt, highlight(options.Application.Name))) + for _, file := range options.Application.ScaffoldFiles { + message.WriteString(fmt.Sprintf(SummaryApplicationScaffoldFile, highlight(file))) + } + } + + message.WriteString(SummaryConfigurationHeadingIcon) + message.WriteString(SummaryConfigurationUpdateHeading) + + message.WriteString(SummaryFooter) + + return m.style.Render(ansi.Hardwrap(message.String(), m.width, true)) +} + +var _ tea.Model = &ProgressModel{} + +// NewProgressModel creates a new model for the initialization progress dialog shown during 'rad init'. +func NewProgressModel(options DisplayOptions) tea.Model { + return &ProgressModel{ + Options: options, + spinner: spinner.New(spinner.WithSpinner(progressSpinner)), + + // Setting a height here to avoid double-printing issues when the + // height of the output changes. + style: lipgloss.NewStyle().Margin(1, 0), + } +} + +// ProgressModel is the bubble tea model for the progress display shown during 'rad init'. +type ProgressModel struct { + Options DisplayOptions + Progress ProgressMsg + spinner spinner.Model + style lipgloss.Style + + // SuppressSpinner is used to suppress the ticking of the spinner for testing. + SuppressSpinner bool + width int +} + +// Init implements tea.Model. +func (m *ProgressModel) Init() tea.Cmd { + if m.SuppressSpinner { + return nil + } + + return m.spinner.Tick +} + +// Update implements tea.Model. It updates the model state on progress updates and spinner ticks. +func (m *ProgressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + return m, nil + case ProgressMsg: + m.Progress = msg + if m.isComplete() { + return m, tea.Quit + } + + return m, nil + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + default: + return m, nil + } +} + +// View implements tea.Model. It renders the progress of the initialization steps. +func (m *ProgressModel) View() string { + options := m.Options + + message := &strings.Builder{} + message.WriteString(ProgressHeading) + + waiting := false + + m.writeProgressIcon(message, m.Progress.InstallComplete, &waiting) + if options.Cluster.Install { + message.WriteString(fmt.Sprintf(SummaryKubernetesInstallHeadingFmt, highlight(options.Cluster.Version), highlight(options.Cluster.Context), highlight(options.Cluster.Namespace))) + writeCloudProviderInstallSummary(message, options.CloudProviders) + } else { + message.WriteString(fmt.Sprintf(SummaryKubernetesExistingHeadingFmt, highlight(options.Cluster.Version), highlight(options.Cluster.Context))) + } + + m.writeProgressIcon(message, m.Progress.EnvironmentComplete, &waiting) + writeEnvironmentSummary(message, options) + + if options.Application.Scaffold { + m.writeProgressIcon(message, m.Progress.ApplicationComplete, &waiting) + message.WriteString(fmt.Sprintf(SummaryApplicationScaffoldHeadingFmt, highlight(options.Application.Name))) + } + + m.writeProgressIcon(message, m.Progress.ConfigComplete, &waiting) + message.WriteString(SummaryConfigurationUpdateHeading) + + if !waiting { + message.WriteString(ProgressCompleteFooter) + } + + return m.style.Render(ansi.Hardwrap(message.String(), m.width, true)) +} + +func (m *ProgressModel) isComplete() bool { + return m.Progress.InstallComplete && m.Progress.EnvironmentComplete && m.Progress.ApplicationComplete && m.Progress.ConfigComplete +} + +// writeProgressIcon writes the correct icon for the progress step depending on the current step. +// +// Logic: +// - If the step is complete, write the complete icon. +// - If we're waiting based on a previous step not being complete, write the waiting icon. +// - If we're not waiting then this is the current step: +// - Show the spinner. +// - Set waiting to true so that we show the waiting icon for the following steps. +func (m *ProgressModel) writeProgressIcon(message *strings.Builder, condition bool, waiting *bool) { + if condition { + message.WriteString(ProgressStepCompleteIcon) + } else if *waiting { + message.WriteString(ProgressStepWaitingIcon) + } else if m.SuppressSpinner { + // We can't render the *real* spinner without starting it, so just render a static glyph. + message.WriteString(progressSpinner.Frames[0]) + *waiting = true + } else { + message.WriteString(m.spinner.View()) + *waiting = true + } +} + +func writeCloudProviderInstallSummary(message *strings.Builder, providers CloudProvidersDisplay) { + if providers.AWS != nil { + message.WriteString(fmt.Sprintf(SummaryKubernetesInstallAWSCloudProviderFmt, highlight(string(providers.AWS.CredentialKind)))) + switch providers.AWS.CredentialKind { + case aws.AWSCredentialKindAccessKey: + message.WriteString(fmt.Sprintf(SummaryIndent+"AccessKey ID: %s\n", highlight(providers.AWS.AccessKey.AccessKeyID))) + case aws.AWSCredentialKindIRSA: + message.WriteString(fmt.Sprintf(SummaryIndent+"IAM Role ARN: %s\n", highlight(providers.AWS.IRSA.RoleARN))) + } + } + if providers.Azure != nil { + message.WriteString(fmt.Sprintf(SummaryKubernetesInstallAzureCloudProviderFmt, highlight(string(providers.Azure.CredentialKind)))) + switch providers.Azure.CredentialKind { + case azure.AzureCredentialKindServicePrincipal: + message.WriteString(fmt.Sprintf(SummaryIndent+"Client ID: %s\n", highlight(providers.Azure.ServicePrincipal.ClientID))) + case azure.AzureCredentialKindWorkloadIdentity: + message.WriteString(fmt.Sprintf(SummaryIndent+"Client ID: %s\n", highlight(providers.Azure.WorkloadIdentity.ClientID))) + } + } +} + +func writeEnvironmentSummary(message *strings.Builder, options DisplayOptions) { + if options.Environment.Create { + message.WriteString(fmt.Sprintf(SummaryEnvironmentCreateHeadingFmt, highlight(options.Environment.Name), highlight(options.Environment.Namespace))) + + if options.CloudProviders.AWS != nil { + message.WriteString(fmt.Sprintf(SummaryEnvironmentCreateAWSCloudProviderFmt, highlight(options.CloudProviders.AWS.AccountID), highlight(options.CloudProviders.AWS.Region))) + } + + if options.CloudProviders.Azure != nil { + message.WriteString(fmt.Sprintf(SummaryEnvironmentCreateAzureCloudProviderFmt, highlight(options.CloudProviders.Azure.SubscriptionID), highlight(options.CloudProviders.Azure.ResourceGroup))) + } + + if options.RecipePackLabel != "" { + message.WriteString(fmt.Sprintf(SummaryEnvironmentCreateRecipePackyFmt, highlight(options.RecipePackLabel))) + } + } else { + message.WriteString(fmt.Sprintf(SummaryEnvironmentExistingHeadingFmt, highlight(options.Environment.Name))) + } +} + +func highlight(text string) string { + return foregroundBrightStyle.Render(text) +} diff --git a/pkg/cli/cmd/radinit/display.go b/pkg/cli/cmd/radinit/display.go index 4c448f1af1..b67dcb7e41 100644 --- a/pkg/cli/cmd/radinit/display.go +++ b/pkg/cli/cmd/radinit/display.go @@ -18,423 +18,56 @@ package radinit import ( "context" - "fmt" - "strings" - "time" - "github.com/charmbracelet/bubbles/spinner" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/x/ansi" - "github.com/radius-project/radius/pkg/cli/aws" - "github.com/radius-project/radius/pkg/cli/azure" - "github.com/radius-project/radius/pkg/cli/prompt" -) - -const ( - summaryIndent = " - " - summaryHeading = "You've selected the following:\n\n" - summaryFooter = "\n(press enter to confirm or esc to restart)\n" - summaryKubernetesHeadingIcon = "🔧 " - summaryKubernetesInstallHeadingFmt = "Install Radius %s\n" + summaryIndent + "Kubernetes cluster: %s\n" + summaryIndent + "Kubernetes namespace: %s\n" - summaryKubernetesInstallAWSCloudProviderFmt = summaryIndent + "AWS credential: %s\n" - summaryKubernetesInstallAzureCloudProviderFmt = summaryIndent + "Azure credential: %s\n" - summaryKubernetesExistingHeadingFmt = "Use existing Radius %s install on %s\n" - summaryEnvironmentHeadingIcon = "🌏 " - summaryEnvironmentCreateHeadingFmt = "Create new environment %s\n" + summaryIndent + "Kubernetes namespace: %s\n" - summaryEnvironmentCreateAWSCloudProviderFmt = summaryIndent + "AWS: account %s and region %s\n" - summaryEnvironmentCreateAzureCloudProviderFmt = summaryIndent + "Azure: subscription %s and resource group %s\n" - summaryEnvironmentCreateRecipePackyFmt = summaryIndent + "Recipe pack: %s\n" - summaryEnvironmentExistingHeadingFmt = "Use existing environment %s\n" - summaryApplicationHeadingIcon = "🚧 " - summaryApplicationScaffoldHeadingFmt = "Scaffold application %s\n" - summaryApplicationScaffoldFile = summaryIndent + "Create %s\n" - summaryConfigurationHeadingIcon = "📋 " - summaryConfigurationUpdateHeading = "Update local configuration\n" - progressHeading = "Initializing Radius. This may take a minute or two...\n\n" - progressCompleteFooter = "\nInitialization complete! Have a RAD time 😎\n\n" - progressStepCompleteIcon = "✅ " - progressStepWaitingIcon = "⏳ " -) - -var ( - progressSpinner = spinner.Spinner{ - Frames: []string{"🕐 ", "🕑 ", "🕒 ", "🕓 ", "🕔 ", "🕕 ", "🕖 ", "🕗 ", "🕘 ", "🕙 ", "🕚 ", "🕛 "}, - FPS: time.Second / 4, - } - - foregroundBrightStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#111111", Dark: "#EEEEEE"}).Bold(true) + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" ) // confirmOptions shows a summary of the user's selections and prompts for confirmation. func (r *Runner) confirmOptions(ctx context.Context, options *initOptions) (bool, error) { - model := NewSummaryModel(*options) - program := tea.NewProgram(model, tea.WithContext(ctx)) - - model, err := r.Prompter.RunProgram(program) - if err != nil { - return false, err - } - - switch model.(*summaryModel).result { - case resultConfimed: - return true, nil - case resultCanceled: - return false, nil - case resultQuit: - return false, &prompt.ErrExitConsole{} - default: - panic("unknown result " + model.(*summaryModel).result) - } + return common.ConfirmOptions(ctx, r.Prompter, toDisplayOptions(options)) } // showProgress shows an updating progress display while the user's selections are being applied. // // This function should be called from a goroutine while installation proceeds in the background. -// provide a channel to update progress. -func (r *Runner) showProgress(ctx context.Context, options *initOptions, progressChan <-chan progressMsg) error { - model := NewProgessModel(*options) - program := tea.NewProgram(model, tea.WithContext(ctx)) - - go func() { - for msg := range progressChan { - program.Send(msg) - } - - program.Send(tea.Quit) - }() - - _, err := r.Prompter.RunProgram(program) - if err != nil { - return err - } - - return err -} - -// progressMsg is a message sent to the progress display to update the status of the installation. -type progressMsg struct { - InstallComplete bool - EnvironmentComplete bool - ApplicationComplete bool - ConfigComplete bool -} - -type summaryResult string - -const ( - resultConfimed = "confirmed" - resultCanceled = "canceled" - resultQuit = "quit" -) - -var _ tea.Model = &summaryModel{} - -type summaryModel struct { - style lipgloss.Style - result summaryResult - options initOptions - width int -} - -// NewSummaryModel creates a new model for the options summary shown during 'rad init'. -func NewSummaryModel(options initOptions) tea.Model { - return &summaryModel{ - style: lipgloss.NewStyle().Margin(1, 0), - options: options, - } -} - -// Init implements the init function for tea.Model. This will be called when the model is started, before View or -// Update are called. -func (m *summaryModel) Init() tea.Cmd { - return nil +func (r *Runner) showProgress(ctx context.Context, options *initOptions, progressChan <-chan common.ProgressMsg) error { + return common.ShowProgress(ctx, r.Prompter, toDisplayOptions(options), progressChan) } -// Update implements the update function for tea.Model. This will be called when a message is received by the model. -// -// It's safe to update internal state inside this function. View will be called afterwards to draw the UI. -// - -// "summaryModel.Update" handles messages and state transitions, and returns the next model and command based on the type -// of message received. If the message is a KeyCtrlC, KeyEsc, or KeyEnter, the result is set accordingly and the command is -// -// set to Quit. Otherwise, the message is ignored and no command is returned. -func (m *summaryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // This function handles messages and state transitions. We don't need to update - // any UI here, just return the next model and command. - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - case tea.KeyMsg: - if msg.Type == tea.KeyCtrlC { - // User is quitting - copy := *m - copy.result = resultQuit - return ©, tea.Quit - } - if msg.Type == tea.KeyEsc { - // User is canceling - copy := *m - copy.result = resultCanceled - return ©, tea.Quit - } - if msg.Type == tea.KeyEnter { - // User has confirmed - copy := *m - copy.result = resultConfimed - return ©, tea.Quit - } - } - - // Ignore other messages - return m, nil -} - -// View implments the view function for tea.Model. This will be called after Init and after each call to Update to -// draw the UI. -func (m *summaryModel) View() string { - // Hide when summary is dismissed - if m.result != "" { - return "" - } - - options := m.options - - message := &strings.Builder{} - message.WriteString(summaryHeading) - - message.WriteString(summaryKubernetesHeadingIcon) - if options.Cluster.Install { - message.WriteString(fmt.Sprintf(summaryKubernetesInstallHeadingFmt, highlight(options.Cluster.Version), highlight(options.Cluster.Context), highlight(options.Cluster.Namespace))) - - if options.CloudProviders.AWS != nil { - message.WriteString(fmt.Sprintf(summaryKubernetesInstallAWSCloudProviderFmt, highlight(string(options.CloudProviders.AWS.CredentialKind)))) - switch options.CloudProviders.AWS.CredentialKind { - case aws.AWSCredentialKindAccessKey: - message.WriteString(fmt.Sprintf(summaryIndent+"AccessKey ID: %s\n", highlight(options.CloudProviders.AWS.AccessKey.AccessKeyID))) - case aws.AWSCredentialKindIRSA: - message.WriteString(fmt.Sprintf(summaryIndent+"IAM Role ARN: %s\n", highlight(options.CloudProviders.AWS.IRSA.RoleARN))) - } - - } - if options.CloudProviders.Azure != nil { - message.WriteString(fmt.Sprintf(summaryKubernetesInstallAzureCloudProviderFmt, highlight(string(options.CloudProviders.Azure.CredentialKind)))) - switch options.CloudProviders.Azure.CredentialKind { - case azure.AzureCredentialKindServicePrincipal: - message.WriteString(fmt.Sprintf(summaryIndent+"Client ID: %s\n", highlight(options.CloudProviders.Azure.ServicePrincipal.ClientID))) - case azure.AzureCredentialKindWorkloadIdentity: - message.WriteString(fmt.Sprintf(summaryIndent+"Client ID: %s\n", highlight(options.CloudProviders.Azure.WorkloadIdentity.ClientID))) - } - } - } else { - message.WriteString(fmt.Sprintf(summaryKubernetesExistingHeadingFmt, highlight(options.Cluster.Version), highlight(options.Cluster.Context))) - } - - message.WriteString(summaryEnvironmentHeadingIcon) - if options.Environment.Create { - message.WriteString(fmt.Sprintf(summaryEnvironmentCreateHeadingFmt, highlight(options.Environment.Name), highlight(options.Environment.Namespace))) - - if options.CloudProviders.AWS != nil { - message.WriteString(fmt.Sprintf(summaryEnvironmentCreateAWSCloudProviderFmt, highlight(options.CloudProviders.AWS.AccountID), highlight(options.CloudProviders.AWS.Region))) - } - - if options.CloudProviders.Azure != nil { - message.WriteString(fmt.Sprintf(summaryEnvironmentCreateAzureCloudProviderFmt, highlight(options.CloudProviders.Azure.SubscriptionID), highlight(options.CloudProviders.Azure.ResourceGroup))) - } - - if options.Recipes.DevRecipes { - message.WriteString(fmt.Sprintf(summaryEnvironmentCreateRecipePackyFmt, highlight("local-dev"))) - } - } else { - message.WriteString(fmt.Sprintf(summaryEnvironmentExistingHeadingFmt, highlight(options.Environment.Name))) +// toDisplayOptions converts the package-local initOptions into the common +// DisplayOptions consumed by the shared summary and progress views. +func toDisplayOptions(options *initOptions) common.DisplayOptions { + recipePackLabel := "" + if options.Recipes.DevRecipes { + recipePackLabel = "local-dev" } + var scaffoldFiles []string if options.Application.Scaffold { - message.WriteString(summaryApplicationHeadingIcon) - message.WriteString(fmt.Sprintf(summaryApplicationScaffoldHeadingFmt, highlight(options.Application.Name))) - message.WriteString(fmt.Sprintf(summaryApplicationScaffoldFile, highlight("app.bicep"))) - message.WriteString(fmt.Sprintf(summaryApplicationScaffoldFile, highlight("bicepconfig.json"))) + scaffoldFiles = []string{"app.bicep", "bicepconfig.json"} + } + + return common.DisplayOptions{ + Cluster: common.ClusterDisplay{ + Install: options.Cluster.Install, + Namespace: options.Cluster.Namespace, + Context: options.Cluster.Context, + Version: options.Cluster.Version, + }, + Environment: common.EnvironmentDisplay{ + Create: options.Environment.Create, + Name: options.Environment.Name, + Namespace: options.Environment.Namespace, + }, + CloudProviders: common.CloudProvidersDisplay{ + Azure: options.CloudProviders.Azure, + AWS: options.CloudProviders.AWS, + }, + Application: common.ApplicationDisplay{ + Scaffold: options.Application.Scaffold, + Name: options.Application.Name, + ScaffoldFiles: scaffoldFiles, + }, + RecipePackLabel: recipePackLabel, } - - message.WriteString(summaryConfigurationHeadingIcon) - message.WriteString(summaryConfigurationUpdateHeading) - - message.WriteString(summaryFooter) - - return m.style.Render(ansi.Hardwrap(message.String(), m.width, true)) -} - -var _ tea.Model = &progressModel{} - -// NewProgessModel creates a new model for the initialization progress dialog shown during 'rad init'. -func NewProgessModel(options initOptions) tea.Model { - return &progressModel{ - options: options, - spinner: spinner.New(spinner.WithSpinner(progressSpinner)), - - // Setting a height here to avoid double-printing issues when the - // hight of the output changes. - style: lipgloss.NewStyle().Margin(1, 0), - } -} - -type progressModel struct { - options initOptions - progress progressMsg - spinner spinner.Model - style lipgloss.Style - - // suppressSpinner is used to suppress the ticking of the spinner for testing. - suppressSpinner bool - width int -} - -// Init implements the init function for tea.Model. This will be called when the model is started, before View or -// Update are called. -func (m *progressModel) Init() tea.Cmd { - if m.suppressSpinner { - return nil - } - - // Start the spinner - return m.spinner.Tick -} - -// Update implements the update function for tea.Model. This will be called when a message is received by the model. -// -// It's safe to update internal state inside this function. View will be called afterwards to draw the UI. -// - -// Update updates the internal state of the progressModel when it receives a progressMsg or spinner.TickMsg, -// and returns a tea.Cmd to quit the program if the progress is complete. -func (m *progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - return m, nil - // Update our internal state when we receive a progress update message. - case progressMsg: - m.progress = msg - if m.isComplete() { - return m, tea.Quit - } - - return m, nil - - // Update spinner internal state when we receive a tick. - case spinner.TickMsg: - var cmd tea.Cmd - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - default: - return m, nil - } -} - -// View implments the view function for tea.Model. This will be called after Init and after each call to Update to -// draw the UI. -// - -// View builds a string containing a summary of the progress of a GO program, including the installation of -// Kubernetes, the creation of an environment, the scaffolding of an application, and the updating of configuration. -func (m *progressModel) View() string { - options := m.options - - message := &strings.Builder{} - message.WriteString(progressHeading) - - waiting := false // It's the hardest part. - - m.writeProgressIcon(message, m.progress.InstallComplete, &waiting) - if options.Cluster.Install { - message.WriteString(fmt.Sprintf(summaryKubernetesInstallHeadingFmt, highlight(options.Cluster.Version), highlight(options.Cluster.Context), highlight(options.Cluster.Namespace))) - - if options.CloudProviders.AWS != nil { - message.WriteString(fmt.Sprintf(summaryKubernetesInstallAWSCloudProviderFmt, highlight(string(options.CloudProviders.AWS.CredentialKind)))) - switch options.CloudProviders.AWS.CredentialKind { - case aws.AWSCredentialKindAccessKey: - message.WriteString(fmt.Sprintf(summaryIndent+"AccessKey ID: %s\n", highlight(options.CloudProviders.AWS.AccessKey.AccessKeyID))) - case aws.AWSCredentialKindIRSA: - message.WriteString(fmt.Sprintf(summaryIndent+"IAM Role ARN: %s\n", highlight(options.CloudProviders.AWS.IRSA.RoleARN))) - } - } - - if options.CloudProviders.Azure != nil { - message.WriteString(fmt.Sprintf(summaryKubernetesInstallAzureCloudProviderFmt, highlight(string(options.CloudProviders.Azure.CredentialKind)))) - switch options.CloudProviders.Azure.CredentialKind { - case azure.AzureCredentialKindServicePrincipal: - message.WriteString(fmt.Sprintf(summaryIndent+"Client ID: %s\n", highlight(options.CloudProviders.Azure.ServicePrincipal.ClientID))) - case azure.AzureCredentialKindWorkloadIdentity: - message.WriteString(fmt.Sprintf(summaryIndent+"Client ID: %s\n", highlight(options.CloudProviders.Azure.WorkloadIdentity.ClientID))) - } - } - } else { - message.WriteString(fmt.Sprintf(summaryKubernetesExistingHeadingFmt, highlight(options.Cluster.Version), highlight(options.Cluster.Context))) - } - - m.writeProgressIcon(message, m.progress.EnvironmentComplete, &waiting) - if options.Environment.Create { - message.WriteString(fmt.Sprintf(summaryEnvironmentCreateHeadingFmt, highlight(options.Environment.Name), highlight(options.Environment.Namespace))) - - if options.CloudProviders.AWS != nil { - message.WriteString(fmt.Sprintf(summaryEnvironmentCreateAWSCloudProviderFmt, highlight(options.CloudProviders.AWS.AccountID), highlight(options.CloudProviders.AWS.Region))) - } - - if options.CloudProviders.Azure != nil { - message.WriteString(fmt.Sprintf(summaryEnvironmentCreateAzureCloudProviderFmt, highlight(options.CloudProviders.Azure.SubscriptionID), highlight(options.CloudProviders.Azure.ResourceGroup))) - } - - if options.Recipes.DevRecipes { - message.WriteString(fmt.Sprintf(summaryEnvironmentCreateRecipePackyFmt, highlight("local-dev"))) - } - } else { - message.WriteString(fmt.Sprintf(summaryEnvironmentExistingHeadingFmt, highlight(options.Environment.Name))) - } - - if options.Application.Scaffold { - m.writeProgressIcon(message, m.progress.ApplicationComplete, &waiting) - message.WriteString(fmt.Sprintf(summaryApplicationScaffoldHeadingFmt, highlight(options.Application.Name))) - } - - m.writeProgressIcon(message, m.progress.ConfigComplete, &waiting) - message.WriteString(summaryConfigurationUpdateHeading) - - if !waiting { - // Everything is complete, so we're done. - message.WriteString(progressCompleteFooter) - } - - return m.style.Render(ansi.Hardwrap(message.String(), m.width, true)) -} - -func (m *progressModel) isComplete() bool { - return m.progress.InstallComplete && m.progress.EnvironmentComplete && m.progress.ApplicationComplete && m.progress.ConfigComplete -} - -// writeProgressIcon writes the correct icon for the progress step depending on the current step. -func (m *progressModel) writeProgressIcon(message *strings.Builder, condition bool, waiting *bool) { - // Logic: - // - // - If the step is complete, write the complete icon. - // - If we're waiting based on a previous step not being complete, write the waiting icon. - // - If we're not waiting then this is the current step: - // - Show the spinner - // - Set waiting to true so that we show the waiting icon for the following steps. - if condition { - message.WriteString(progressStepCompleteIcon) - } else if *waiting { - message.WriteString(progressStepWaitingIcon) - } else if m.suppressSpinner { - // We can't render the *real* spinner without starting it, so just render a static glyph. - message.WriteString(progressSpinner.Frames[0]) - *waiting = true - } else { - message.WriteString(m.spinner.View()) - *waiting = true - } -} - -func highlight(text string) string { - return foregroundBrightStyle.Render(text) } diff --git a/pkg/cli/cmd/radinit/display_test.go b/pkg/cli/cmd/radinit/display_test.go index c865eab641..ca624f4ccf 100644 --- a/pkg/cli/cmd/radinit/display_test.go +++ b/pkg/cli/cmd/radinit/display_test.go @@ -25,6 +25,7 @@ import ( "github.com/acarl005/stripansi" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/x/exp/teatest" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,16 +40,16 @@ func Test_summaryModel(t *testing.T) { normalized := "" teatest.WaitFor(t, reader, func(bts []byte) bool { normalized = stripansi.Strip(strings.ReplaceAll(string(bts), "\r\n", "\n")) - return strings.Contains(normalized, strings.Trim(summaryFooter, "\n")) + return strings.Contains(normalized, strings.Trim(common.SummaryFooter, "\n")) }, teatest.WithDuration(waitTimeout)) return normalized } - resultTest := func(t *testing.T, expected summaryResult, key tea.KeyType) { + resultTest := func(t *testing.T, expected common.SummaryResult, key tea.KeyType) { options := initOptions{} - model := &summaryModel{ - options: options, + model := &common.SummaryModel{ + Options: toDisplayOptions(&options), } tm := teatest.NewTestModel(t, model) @@ -66,25 +67,25 @@ func Test_summaryModel(t *testing.T) { // FinalModel only returns once the program has finished running or when it times out. // Please see: https://github.com/charmbracelet/x/blob/20117e9c8cd5ad229645f1bca3422b7e4110c96c/exp/teatest/teatest.go#L220. // That is why we call tm.Quit() before tm.FinalModel(). - model = tm.FinalModel(t).(*summaryModel) - require.Equal(t, expected, model.result) + model = tm.FinalModel(t).(*common.SummaryModel) + require.Equal(t, expected, model.Result) } t.Run("Result: Confirm", func(t *testing.T) { - resultTest(t, resultConfimed, tea.KeyEnter) + resultTest(t, common.ResultConfirmed, tea.KeyEnter) }) t.Run("Result: Cancel", func(t *testing.T) { - resultTest(t, resultCanceled, tea.KeyEscape) + resultTest(t, common.ResultCanceled, tea.KeyEscape) }) t.Run("Result: Quit", func(t *testing.T) { - resultTest(t, resultQuit, tea.KeyCtrlC) + resultTest(t, common.ResultQuit, tea.KeyCtrlC) }) viewTest := func(t *testing.T, options initOptions, expected string) { - model := &summaryModel{ - options: options, + model := &common.SummaryModel{ + Options: toDisplayOptions(&options), } tm := teatest.NewTestModel(t, model) @@ -103,8 +104,8 @@ func Test_summaryModel(t *testing.T) { // FinalModel only returns once the program has finished running or when it times out. // Please see: https://github.com/charmbracelet/x/blob/20117e9c8cd5ad229645f1bca3422b7e4110c96c/exp/teatest/teatest.go#L220. // That is why we call tm.Quit() before tm.FinalModel(). - model = tm.FinalModel(t).(*summaryModel) - assert.Equal(t, summaryResult(resultConfimed), model.result) + model = tm.FinalModel(t).(*common.SummaryModel) + assert.Equal(t, common.SummaryResult(common.ResultConfirmed), model.Result) } t.Run("View: existing options", func(t *testing.T) { diff --git a/pkg/cli/cmd/radinit/init.go b/pkg/cli/cmd/radinit/init.go index 6f3b29172f..07981db29a 100644 --- a/pkg/cli/cmd/radinit/init.go +++ b/pkg/cli/cmd/radinit/init.go @@ -28,6 +28,7 @@ import ( "github.com/radius-project/radius/pkg/cli/azure" "github.com/radius-project/radius/pkg/cli/clierrors" "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" "github.com/radius-project/radius/pkg/cli/connections" cli_credential "github.com/radius-project/radius/pkg/cli/credential" "github.com/radius-project/radius/pkg/cli/framework" @@ -229,9 +230,9 @@ func (r *Runner) Run(ctx context.Context) error { config := r.ConfigFileInterface.ConfigFromContext(ctx) // Use this channel to send progress updates to the UI. - progressChan := make(chan progressMsg) + progressChan := make(chan common.ProgressMsg) progressCompleteChan := make(chan error) - progress := progressMsg{} + progress := common.ProgressMsg{} go func() { // Show dynamic UI. diff --git a/pkg/cli/cmd/radinit/init_test.go b/pkg/cli/cmd/radinit/init_test.go index cbfc01d17d..8316e659e6 100644 --- a/pkg/cli/cmd/radinit/init_test.go +++ b/pkg/cli/cmd/radinit/init_test.go @@ -37,6 +37,7 @@ import ( "github.com/radius-project/radius/pkg/cli/aws" "github.com/radius-project/radius/pkg/cli/azure" "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" "github.com/radius-project/radius/pkg/cli/connections" cli_credential "github.com/radius-project/radius/pkg/cli/credential" "github.com/radius-project/radius/pkg/cli/framework" @@ -128,7 +129,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -159,7 +160,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -194,7 +195,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -224,7 +225,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -264,7 +265,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -299,7 +300,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -334,7 +335,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -369,7 +370,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -404,7 +405,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -436,7 +437,7 @@ func Test_Validate(t *testing.T) { setScaffoldApplicationPromptYes(mocks.Prompter) setApplicationNamePrompt(mocks.Prompter, "valid") - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -650,7 +651,7 @@ func Test_Validate(t *testing.T) { // No application setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, resultConfimed) + setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, { @@ -1135,19 +1136,19 @@ func getTestKubeConfig() *api.Config { func initKubeContextWithKind(prompter *prompt.MockInterface) { prompter.EXPECT(). - GetListInput(gomock.Any(), selectClusterPrompt). + GetListInput(gomock.Any(), common.SelectClusterPrompt). Return("kind-kind", nil).Times(1) } func initKubeContextSelectionError(prompter *prompt.MockInterface) { prompter.EXPECT(). - GetListInput(gomock.Any(), selectClusterPrompt). + GetListInput(gomock.Any(), common.SelectClusterPrompt). Return("", errors.New("cannot read selection")).Times(1) } func initKubeContextWithInterruptSignal(prompter *prompt.MockInterface) { prompter.EXPECT(). - GetListInput(gomock.Any(), selectClusterPrompt). + GetListInput(gomock.Any(), common.SelectClusterPrompt). Return("", &prompt.ErrExitConsole{}).Times(1) } @@ -1236,38 +1237,38 @@ func initExistingEnvironmentSelection(prompter *prompt.MockInterface, choice str func setScaffoldApplicationPromptNo(prompter *prompt.MockInterface) { prompter.EXPECT(). - GetListInput(gomock.Any(), confirmSetupApplicationPrompt). + GetListInput(gomock.Any(), common.ConfirmSetupApplicationPrompt). Return(prompt.ConfirmNo, nil).Times(1) } func setScaffoldApplicationPromptYes(prompter *prompt.MockInterface) { prompter.EXPECT(). - GetListInput(gomock.Any(), confirmSetupApplicationPrompt). + GetListInput(gomock.Any(), common.ConfirmSetupApplicationPrompt). Return(prompt.ConfirmYes, nil).Times(1) } func setApplicationNamePrompt(prompter *prompt.MockInterface, applicationName string) { prompter.EXPECT(). - GetTextInput(enterApplicationNamePrompt, gomock.Any()). + GetTextInput(common.EnterApplicationNamePrompt, gomock.Any()). Return(applicationName, nil).Times(1) } func setAWSRegionPrompt(prompter *prompt.MockInterface, regions []string, region string) { prompter.EXPECT(). - GetListInput(regions, selectAWSRegionPrompt). + GetListInput(regions, common.SelectAWSRegionPrompt). Return(region, nil). Times(1) } func setAWSAccessKeyIDPrompt(prompter *prompt.MockInterface, accessKeyID string) { prompter.EXPECT(). - GetTextInput(enterAWSIAMAcessKeyIDPrompt, gomock.Any()). + GetTextInput(common.EnterAWSIAMAcessKeyIDPrompt, gomock.Any()). Return(accessKeyID, nil).Times(1) } func setAWSSecretAccessKeyPrompt(prompter *prompt.MockInterface, secretAccessKey string) { prompter.EXPECT(). - GetTextInput(enterAWSIAMSecretAccessKeyPrompt, gomock.Any()). + GetTextInput(common.EnterAWSIAMSecretAccessKeyPrompt, gomock.Any()). Return(secretAccessKey, nil).Times(1) } @@ -1280,7 +1281,7 @@ func setAWSCallerIdentity(client *aws.MockClient, callerIdentityOutput *sts.GetC func setAWSAccountIDConfirmPrompt(prompter *prompt.MockInterface, accountName string, choice string) { prompter.EXPECT(). - GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, fmt.Sprintf(confirmAWSAccountIDPromptFmt, accountName)). + GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, fmt.Sprintf(common.ConfirmAWSAccountIDPromptFmt, accountName)). Return(choice, nil). Times(1) } @@ -1350,98 +1351,98 @@ func setAzureLocations(client *azure.MockClient, subscriptionID string, location func setAzureSubscriptionConfirmPrompt(prompter *prompt.MockInterface, subscriptionName string, choice string) { prompter.EXPECT(). - GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, fmt.Sprintf(confirmAzureSubscriptionPromptFmt, subscriptionName)). + GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, fmt.Sprintf(common.ConfirmAzureSubscriptionPromptFmt, subscriptionName)). Return(choice, nil). Times(1) } func setAzureSubsubscriptionPrompt(prompter *prompt.MockInterface, names []string, name string) { prompter.EXPECT(). - GetListInput(names, selectAzureSubscriptionPrompt). + GetListInput(names, common.SelectAzureSubscriptionPrompt). Return(name, nil). Times(1) } func setAzureResourceGroupCreatePrompt(prompter *prompt.MockInterface, choice string) { prompter.EXPECT(). - GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, confirmAzureCreateResourceGroupPrompt). + GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, common.ConfirmAzureCreateResourceGroupPrompt). Return(choice, nil). Times(1) } func setAzureResourceGroupPrompt(prompter *prompt.MockInterface, names []string, name string) { prompter.EXPECT(). - GetListInput(names, selectAzureResourceGroupPrompt). + GetListInput(names, common.SelectAzureResourceGroupPrompt). Return(name, nil). Times(1) } func setAzureResourceGroupNamePrompt(prompter *prompt.MockInterface, name string) { prompter.EXPECT(). - GetTextInput(enterAzureResourceGroupNamePrompt, gomock.Any()). + GetTextInput(common.EnterAzureResourceGroupNamePrompt, gomock.Any()). Return(name, nil). Times(1) } func setSelectAzureResourceGroupLocationPrompt(prompter *prompt.MockInterface, locations []string, location string) { prompter.EXPECT(). - GetListInput(locations, selectAzureResourceGroupLocationPrompt). + GetListInput(locations, common.SelectAzureResourceGroupLocationPrompt). Return(location, nil). Times(1) } func setAzureServicePrincipalAppIDPrompt(prompter *prompt.MockInterface, appID string) { prompter.EXPECT(). - GetTextInput(enterAzureServicePrincipalAppIDPrompt, gomock.Any()). + GetTextInput(common.EnterAzureServicePrincipalAppIDPrompt, gomock.Any()). Return(appID, nil). Times(1) } func setAzureServicePrincipalPasswordPrompt(prompter *prompt.MockInterface, password string) { prompter.EXPECT(). - GetTextInput(enterAzureServicePrincipalPasswordPrompt, gomock.Any()). + GetTextInput(common.EnterAzureServicePrincipalPasswordPrompt, gomock.Any()). Return(password, nil). Times(1) } func setAzureServicePrincipalTenantIDPrompt(prompter *prompt.MockInterface, tenantID string) { prompter.EXPECT(). - GetTextInput(enterAzureServicePrincipalTenantIDPrompt, gomock.Any()). + GetTextInput(common.EnterAzureServicePrincipalTenantIDPrompt, gomock.Any()). Return(tenantID, nil). Times(1) } func setAzureWorkloadIdentityAppIDPrompt(prompter *prompt.MockInterface, appID string) { prompter.EXPECT(). - GetTextInput(enterAzureWorkloadIdentityAppIDPrompt, gomock.Any()). + GetTextInput(common.EnterAzureWorkloadIdentityAppIDPrompt, gomock.Any()). Return(appID, nil). Times(1) } func setAzureWorkloadIdentityTenantIDPrompt(prompter *prompt.MockInterface, tenantID string) { prompter.EXPECT(). - GetTextInput(enterAzureWorkloadIdentityTenantIDPrompt, gomock.Any()). + GetTextInput(common.EnterAzureWorkloadIdentityTenantIDPrompt, gomock.Any()). Return(tenantID, nil). Times(1) } func setAzureCredentialKindPrompt(prompter *prompt.MockInterface, choice string) { prompter.EXPECT(). - GetListInput([]string{"Service Principal", "Workload Identity"}, selectAzureCredentialKindPrompt). + GetListInput([]string{"Service Principal", "Workload Identity"}, common.SelectAzureCredentialKindPrompt). Return(choice, nil). Times(1) } func setAWSCredentialKindPrompt(prompter *prompt.MockInterface, choice string) { prompter.EXPECT(). - GetListInput([]string{"Access Key", "IRSA"}, selectAWSCredentialKindPrompt). + GetListInput([]string{"Access Key", "IRSA"}, common.SelectAWSCredentialKindPrompt). Return(choice, nil). Times(1) } func setAwsIRSARoleARNPrompt(prompter *prompt.MockInterface, roleARN string) { prompter.EXPECT(). - GetTextInput(enterAWSRoleARNPrompt, gomock.Any()). + GetTextInput(common.EnterAWSRoleARNPrompt, gomock.Any()). Return(roleARN, nil). Times(1) } @@ -1489,10 +1490,10 @@ func setAzureCloudProviderWorkloadIdentity(prompter *prompt.MockInterface, clien setAzureWorkloadIdentityTenantIDPrompt(prompter, provider.WorkloadIdentity.TenantID) } -func setConfirmOption(prompter *prompt.MockInterface, choice summaryResult) { +func setConfirmOption(prompter *prompt.MockInterface, choice common.SummaryResult) { prompter.EXPECT(). RunProgram(gomock.Any()). - Return(&summaryModel{result: choice}, nil). + Return(&common.SummaryModel{Result: choice}, nil). Times(1) } @@ -1501,7 +1502,7 @@ func setProgressHandler(prompter *prompt.MockInterface) { RunProgram(gomock.Any()). DoAndReturn(func(program *tea.Program) (tea.Model, error) { program.Kill() // Quit the program immediately - return &progressModel{}, nil + return &common.ProgressModel{}, nil }). Times(1) } diff --git a/pkg/cli/cmd/radinit/preview/application.go b/pkg/cli/cmd/radinit/preview/application.go new file mode 100644 index 0000000000..4d873fdacc --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/application.go @@ -0,0 +1,31 @@ +/* +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 ( + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" +) + +func (r *Runner) enterApplicationOptions(options *initOptions) error { + scaffold, name, err := common.EnterApplicationOptions(r.Prompter) + if err != nil { + return err + } + options.Application.Scaffold = scaffold + options.Application.Name = name + return nil +} diff --git a/pkg/cli/cmd/radinit/preview/application_test.go b/pkg/cli/cmd/radinit/preview/application_test.go new file mode 100644 index 0000000000..5d9b0b80c1 --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/application_test.go @@ -0,0 +1,54 @@ +/* +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 ( + "testing" + + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" +) + +func Test_enterApplicationOptions(t *testing.T) { + t.Run("create application: Yes", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + runner := Runner{Prompter: prompter} + + setScaffoldApplicationPromptYes(prompter) + + options := initOptions{} + err := runner.enterApplicationOptions(&options) + require.NoError(t, err) + + require.Equal(t, applicationOptions{Scaffold: true, Name: "preview"}, options.Application) + }) + t.Run("create application: No", func(t *testing.T) { + ctrl := gomock.NewController(t) + prompter := prompt.NewMockInterface(ctrl) + runner := Runner{Prompter: prompter} + + setScaffoldApplicationPromptNo(prompter) + + options := initOptions{} + err := runner.enterApplicationOptions(&options) + require.NoError(t, err) + + require.Equal(t, applicationOptions{Scaffold: false, Name: ""}, options.Application) + }) +} diff --git a/pkg/cli/cmd/radinit/preview/aws.go b/pkg/cli/cmd/radinit/preview/aws.go new file mode 100644 index 0000000000..57c4d93ef3 --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/aws.go @@ -0,0 +1,38 @@ +/* +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" + + "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" +) + +func (r *Runner) enterAWSCloudProvider(ctx context.Context, options *initOptions) (*aws.Provider, error) { + provider, err := common.EnterAWSCloudProvider(ctx, r.Prompter, r.Output, r.awsClient) + if err != nil { + return nil, err + } + + if provider.CredentialKind == aws.AWSCredentialKindIRSA { + // Set the value for the Helm chart. + options.SetValues = append(options.SetValues, "global.aws.irsa.enabled=true") + } + + return provider, nil +} diff --git a/pkg/cli/cmd/radinit/preview/azure.go b/pkg/cli/cmd/radinit/preview/azure.go new file mode 100644 index 0000000000..cf25db6c53 --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/azure.go @@ -0,0 +1,38 @@ +/* +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" + + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" +) + +func (r *Runner) enterAzureCloudProvider(ctx context.Context, options *initOptions) (*azure.Provider, error) { + provider, err := common.EnterAzureCloudProvider(ctx, r.Prompter, r.Output, r.azureClient) + if err != nil { + return nil, err + } + + if provider.CredentialKind == azure.AzureCredentialKindWorkloadIdentity { + // Set the value for the Helm chart. + options.SetValues = append(options.SetValues, "global.azureWorkloadIdentity.enabled=true") + } + + return provider, nil +} diff --git a/pkg/cli/cmd/radinit/preview/cloud.go b/pkg/cli/cmd/radinit/preview/cloud.go new file mode 100644 index 0000000000..0f678edf30 --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/cloud.go @@ -0,0 +1,48 @@ +/* +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" + + "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" +) + +const ( + confirmCloudProviderBackNavigationSentinel = common.ConfirmCloudProviderBackNavigationSentinel + confirmCloudProviderPrompt = common.ConfirmCloudProviderPrompt + confirmCloudProviderAdditionalPrompt = common.ConfirmCloudProviderAdditionalPrompt + selectCloudProviderPrompt = common.SelectCloudProviderPrompt +) + +func (r *Runner) enterCloudProviderOptions(ctx context.Context, options *initOptions) error { + result, err := common.EnterCloudProviderOptions( + r.Prompter, + r.Full, + options.Environment.Create, + func() (*azure.Provider, error) { return r.enterAzureCloudProvider(ctx, options) }, + func() (*aws.Provider, error) { return r.enterAWSCloudProvider(ctx, options) }, + ) + if err != nil { + return err + } + options.CloudProviders.Azure = result.Azure + options.CloudProviders.AWS = result.AWS + return nil +} diff --git a/pkg/cli/cmd/radinit/preview/cluster.go b/pkg/cli/cmd/radinit/preview/cluster.go new file mode 100644 index 0000000000..f3d36b53e3 --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/cluster.go @@ -0,0 +1,33 @@ +/* +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 ( + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" +) + +func (r *Runner) enterClusterOptions(options *initOptions) error { + result, err := common.EnterClusterOptions(r.KubernetesInterface, r.HelmInterface, r.Prompter, r.Full) + if err != nil { + return err + } + options.Cluster.Install = result.Install + options.Cluster.Namespace = result.Namespace + options.Cluster.Context = result.Context + options.Cluster.Version = result.Version + return nil +} diff --git a/pkg/cli/cmd/radinit/preview/display.go b/pkg/cli/cmd/radinit/preview/display.go new file mode 100644 index 0000000000..315d6bde7b --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/display.go @@ -0,0 +1,74 @@ +/* +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" + "path/filepath" + + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" +) + +// confirmOptions shows a summary of the user's selections and prompts for confirmation. +func (r *Runner) confirmOptions(ctx context.Context, options *initOptions) (bool, error) { + return common.ConfirmOptions(ctx, r.Prompter, toDisplayOptions(options)) +} + +// showProgress shows an updating progress display while the user's selections are being applied. +// +// This function should be called from a goroutine while installation proceeds in the background. +func (r *Runner) showProgress(ctx context.Context, options *initOptions, progressChan <-chan common.ProgressMsg) error { + return common.ShowProgress(ctx, r.Prompter, toDisplayOptions(options), progressChan) +} + +// toDisplayOptions converts the package-local initOptions into the common +// DisplayOptions consumed by the shared summary and progress views. +func toDisplayOptions(options *initOptions) common.DisplayOptions { + recipePackLabel := "" + if options.Recipes.DefaultRecipePack { + recipePackLabel = "default recipe pack" + } + + var scaffoldFiles []string + if options.Application.Scaffold { + scaffoldFiles = []string{"app.bicep", "bicepconfig.json", filepath.Join(".rad", "rad.yaml")} + } + + return common.DisplayOptions{ + Cluster: common.ClusterDisplay{ + Install: options.Cluster.Install, + Namespace: options.Cluster.Namespace, + Context: options.Cluster.Context, + Version: options.Cluster.Version, + }, + Environment: common.EnvironmentDisplay{ + Create: options.Environment.Create, + Name: options.Environment.Name, + Namespace: options.Environment.Namespace, + }, + CloudProviders: common.CloudProvidersDisplay{ + Azure: options.CloudProviders.Azure, + AWS: options.CloudProviders.AWS, + }, + Application: common.ApplicationDisplay{ + Scaffold: options.Application.Scaffold, + Name: options.Application.Name, + ScaffoldFiles: scaffoldFiles, + }, + RecipePackLabel: recipePackLabel, + } +} diff --git a/pkg/cli/cmd/radinit/preview/environment.go b/pkg/cli/cmd/radinit/preview/environment.go new file mode 100644 index 0000000000..08f97f237e --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/environment.go @@ -0,0 +1,306 @@ +/* +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" + "sort" + "strings" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd" + "github.com/radius-project/radius/pkg/cli/prompt" + "github.com/radius-project/radius/pkg/cli/recipepack" + "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" + ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/resources" + resources_radius "github.com/radius-project/radius/pkg/ucp/resources/radius" +) + +const ( + selectExistingEnvironmentPrompt = "Select an existing environment or create a new one" + selectExistingEnvironmentCreateSentinel = "[create new]" + enterNamespacePrompt = "Enter a namespace name to deploy apps into. The namespace must exist in the Kubernetes cluster." + enterEnvironmentNamePrompt = "Enter an environment name" + defaultEnvironmentName = "default" + defaultEnvironmentNamespace = "default" +) + +// CreateEnvironment creates a Radius.Core environment with the default recipe pack. +func (r *Runner) CreateEnvironment(ctx context.Context) error { + client, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + + err = client.CreateOrUpdateResourceGroup(ctx, "local", r.Options.Environment.Name, &ucp.ResourceGroupResource{ + Location: to.Ptr(v1.LocationGlobal), + }) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to create a resource group.") + } + + // Build providers for the new Radius.Core/environments resource type + providers := &corerpv20250801.Providers{} + + if r.Options.Environment.Namespace != "" { + providers.Kubernetes = &corerpv20250801.ProvidersKubernetes{ + Namespace: to.Ptr(r.Options.Environment.Namespace), + } + } + + if r.Options.CloudProviders.Azure != nil { + providers.Azure = &corerpv20250801.ProvidersAzure{ + SubscriptionID: to.Ptr(r.Options.CloudProviders.Azure.SubscriptionID), + ResourceGroupName: to.Ptr(r.Options.CloudProviders.Azure.ResourceGroup), + } + } + + if r.Options.CloudProviders.AWS != nil { + providers.Aws = &corerpv20250801.ProvidersAws{ + AccountID: to.Ptr(r.Options.CloudProviders.AWS.AccountID), + Region: to.Ptr(r.Options.CloudProviders.AWS.Region), + } + } + + envProperties := corerpv20250801.EnvironmentProperties{ + Providers: providers, + } + + // Initialize the Radius.Core client factory if not already set + if r.RadiusCoreClientFactory == nil { + clientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, r.Workspace.Scope) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to initialize Radius Core client.") + } + r.RadiusCoreClientFactory = clientFactory + } + + // Ensure the default resource group exists before creating recipe packs in it. + if err := recipepack.EnsureDefaultResourceGroup(ctx, client.CreateOrUpdateResourceGroup); err != nil { + return clierrors.MessageWithCause(err, "Failed to create default resource group for recipe packs.") + } + + // Create the default recipe pack and link it to the environment. + // The default pack lives in the default resource group scope. + if r.DefaultScopeClientFactory == nil { + if r.Workspace.Scope == recipepack.DefaultResourceGroupScope { + r.DefaultScopeClientFactory = r.RadiusCoreClientFactory + } else { + defaultClientFactory, err := cmd.InitializeRadiusCoreClientFactory(ctx, r.Workspace, recipepack.DefaultResourceGroupScope) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to initialize Radius Core client for default scope.") + } + r.DefaultScopeClientFactory = defaultClientFactory + } + } + + defaultPack := recipepack.NewDefaultRecipePackResource() + _, err = r.DefaultScopeClientFactory.NewRecipePacksClient().CreateOrUpdate(ctx, recipepack.DefaultRecipePackResourceName, defaultPack, nil) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to create default recipe pack.") + } + + // Link the default recipe pack to the environment. + envProperties.RecipePacks = []*string{to.Ptr(recipepack.DefaultRecipePackID())} + + // Create the Radius.Core/environments resource + _, err = r.RadiusCoreClientFactory.NewEnvironmentsClient().CreateOrUpdate(ctx, r.Options.Environment.Name, corerpv20250801.EnvironmentResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &envProperties, + }, &corerpv20250801.EnvironmentsClientCreateOrUpdateOptions{}) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to create environment.") + } + + credentialClient, err := r.ConnectionFactory.CreateCredentialManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + + if r.Options.CloudProviders.Azure != nil { + credential, err := r.getAzureCredential() + if err != nil { + return clierrors.MessageWithCause(err, "Failed to configure Azure credentials.") + } + + err = credentialClient.PutAzure(ctx, credential) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to configure Azure credentials.") + } + } + + if r.Options.CloudProviders.AWS != nil { + credential, err := r.getAWSCredential() + if err != nil { + return clierrors.MessageWithCause(err, "Failed to configure AWS credentials.") + } + + err = credentialClient.PutAWS(ctx, credential) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to configure AWS credentials.") + } + } + + return nil +} + +func (r *Runner) enterEnvironmentOptions(ctx context.Context, workspace *workspaces.Workspace, options *initOptions) error { + options.Environment.Create = true + if !options.Cluster.Install { + // If Radius is already installed then look for an existing environment first. + existing, err := r.selectExistingEnvironment(ctx, workspace) + if err != nil { + return err + } + + // For an existing environment we won't make changes, so we're done gathering options. + if existing != nil { + options.Environment.Name = *existing.Name + options.Environment.Create = false + + // Derive the resource group from the existing environment's resource ID so we + // don't assume the resource group name matches the environment name. + id, err := resources.ParseResource(*existing.ID) + if err != nil { + return err + } + options.Environment.ResourceGroup = id.FindScope(resources_radius.ScopeResourceGroups) + return nil + } + } + + var err error + options.Environment.Name, err = r.enterEnvironmentName() + if err != nil { + return err + } + + options.Environment.Namespace, err = r.enterEnvironmentNamespace() + if err != nil { + return err + } + + // For a newly-created environment we put it in a resource group whose name matches the environment name. + options.Environment.ResourceGroup = options.Environment.Name + + return nil +} + +func (r *Runner) selectExistingEnvironment(ctx context.Context, workspace *workspaces.Workspace) (*corerpv20250801.EnvironmentResource, error) { + client, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *workspace) + if err != nil { + return nil, err + } + + environments, err := client.ListRadiusCoreEnvironmentsAll(ctx) + if err != nil { + return nil, err + } + + // If there are any existing environments ask to use one of those first. + if len(environments) == 0 { + return nil, nil + } + + // Without any flags we take the default without asking if it's an option. + if !r.Full { + for i, env := range environments { + if strings.EqualFold(defaultEnvironmentName, *env.Name) { + return &environments[i], nil + } + } + } + + items := r.buildExistingEnvironmentList(environments) + name, err := r.Prompter.GetListInput(items, selectExistingEnvironmentPrompt) + if err != nil { + return nil, err + } + + if name == selectExistingEnvironmentCreateSentinel { + return nil, nil + } + + for i, env := range environments { + if env.Name != nil && *env.Name == name { + return &environments[i], nil + } + } + + return nil, nil +} + +func (r *Runner) buildExistingEnvironmentList(existing []corerpv20250801.EnvironmentResource) []string { + others := []string{} + defaultExists := false + for _, env := range existing { + if strings.EqualFold(defaultEnvironmentName, *env.Name) { + defaultExists = true + continue + } + + others = append(others, *env.Name) + } + sort.Strings(others) + + items := []string{} + if defaultExists { + items = append(items, defaultEnvironmentName) + } + items = append(items, others...) + items = append(items, selectExistingEnvironmentCreateSentinel) + + return items +} + +func (r *Runner) enterEnvironmentName() (string, error) { + if !r.Full { + return defaultEnvironmentName, nil + } + + name, err := r.Prompter.GetTextInput(enterEnvironmentNamePrompt, prompt.TextInputOptions{ + Default: defaultEnvironmentName, + Placeholder: defaultEnvironmentName, + Validate: prompt.ValidateResourceNameOrDefault, + }) + if err != nil { + return "", err + } + + return name, nil +} + +func (r *Runner) enterEnvironmentNamespace() (string, error) { + if !r.Full { + return defaultEnvironmentNamespace, nil + } + + namespace, err := r.Prompter.GetTextInput(enterNamespacePrompt, prompt.TextInputOptions{ + Default: defaultEnvironmentNamespace, + Placeholder: defaultEnvironmentNamespace, + Validate: prompt.ValidateResourceNameOrDefault, + }) + if err != nil { + return "", err + } + + return namespace, nil +} diff --git a/pkg/cli/cmd/radinit/preview/init.go b/pkg/cli/cmd/radinit/preview/init.go new file mode 100644 index 0000000000..bd9e0e61bb --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/init.go @@ -0,0 +1,341 @@ +/* +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" + "fmt" + "os" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/cli" + "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/cmd/commonflags" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" + "github.com/radius-project/radius/pkg/cli/connections" + cli_credential "github.com/radius-project/radius/pkg/cli/credential" + "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/prompt" + "github.com/radius-project/radius/pkg/cli/setup" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/to" + ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/spf13/cobra" +) + +// NewCommand creates an instance of the command and runner for the `rad init --preview` command. +func NewCommand(factory framework.Factory) (*cobra.Command, framework.Runner) { + runner := NewRunner(factory) + + cmd := &cobra.Command{ + Use: "initialize", + Aliases: []string{"init"}, + Short: "Initialize Radius", + Long: ` +Interactively install the Radius control-plane and setup a Radius.Core environment. + +If an environment already exists, 'rad init --preview' will prompt the user to use the existing environment or create a new one. + +By default, 'rad init --preview' will optimize for a developer-focused environment with an environment named "default" and a default recipe pack that includes recipes to support prototyping, development and testing in Kubernetes. These environments are great for building and testing your application. + +Specifying the '--full' flag will cause 'rad init --preview --full' to prompt the user for all available configuration options such as Kubernetes context, environment name, and cloud providers. This is useful for fully customizing your environment. +`, + Example: ` +## Create a new Radius.Core environment named "default" which includes a default recipe pack for prototyping, development, and testing Kubernetes resources. +rad init --preview + +## Prompt the user for all available options to create a new environment +rad init --preview --full +`, + Args: cobra.ExactArgs(0), + RunE: framework.RunCommand(runner), + } + + commonflags.AddOutputFlag(cmd) + cmd.Flags().Bool("full", false, "Prompt user for all available configuration options") + 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)") + cmd.Flags().StringArrayVar(&runner.SetFile, "set-file", []string{}, "Set values from files on the command line (can specify multiple or separate files with commas: key1=filename1,key2=filename2)") + return cmd, runner +} + +// Runner is the runner implementation for the `rad init --preview` command. +type Runner struct { + azureClient azure.Client + awsClient aws.Client + + // ConfigFileInterface is the interface for the config file. + ConfigFileInterface framework.ConfigFileInterface + + // ConfigHolder is the interface for the config holder. + ConfigHolder *framework.ConfigHolder + + // ConnectionFactory is the interface for the connection factory. + ConnectionFactory connections.Factory + + // HelmInterface is the interface for the helm client. + HelmInterface helm.Interface + + // KubernetesInterface is the interface for the kubernetes client. + KubernetesInterface kubernetes.Interface + + // Output is the interface for console output. + Output output.Interface + + // Prompter is the interface for the prompter. + Prompter prompt.Interface + + // RadiusCoreClientFactory is the client factory for Radius.Core resources. + // If nil, it will be initialized during Run. + RadiusCoreClientFactory *corerpv20250801.ClientFactory + + // DefaultScopeClientFactory is the client factory scoped to the default resource group. + // The default recipe pack is always created/queried in the default scope. + DefaultScopeClientFactory *corerpv20250801.ClientFactory + + // Format is the output format. + Format string + + // Workspace is the workspace to use. This will be populated by Validate. + Workspace *workspaces.Workspace + + // Full determines whether or not we ask the user for all options. + Full bool + + // Set is the list of additional Helm values to set. + Set []string + + // SetFile is the list of additional Helm values from files. + SetFile []string + + // Options provides the options to used for Radius initialization. This will be populated by Validate. + Options *initOptions +} + +// NewRunner creates a new instance of the `rad init --preview` runner. +func NewRunner(factory framework.Factory) *Runner { + return &Runner{ + ConfigHolder: factory.GetConfigHolder(), + Output: factory.GetOutput(), + ConnectionFactory: factory.GetConnectionFactory(), + Prompter: factory.GetPrompter(), + ConfigFileInterface: factory.GetConfigFileInterface(), + KubernetesInterface: factory.GetKubernetesInterface(), + HelmInterface: factory.GetHelmInterface(), + awsClient: factory.GetAWSClient(), + azureClient: factory.GetAzureClient(), + } +} + +// Validate runs validation for the `rad init --preview` command. +func (r *Runner) Validate(cmd *cobra.Command, args []string) error { + format, err := cli.RequireOutput(cmd) + if err != nil { + return err + } + r.Format = format + + r.Full, err = cmd.Flags().GetBool("full") + if err != nil { + return err + } + + for { + options, workspace, err := r.enterInitOptions(cmd.Context()) + if err != nil { + return err + } + + // Show a confirmation screen if we're in full mode. + confirmed := true + if r.Full { + confirmed, err = r.confirmOptions(cmd.Context(), options) + if err != nil { + return err + } + } + + if confirmed { + r.Options = options + r.Workspace = workspace + return nil + } + + // User did not confirm the summary, so gather input again. + } +} + +// Run runs the `rad init --preview` command. +func (r *Runner) Run(ctx context.Context) error { + config := r.ConfigFileInterface.ConfigFromContext(ctx) + + // Use this channel to send progress updates to the UI. + progressChan := make(chan common.ProgressMsg) + defer close(progressChan) + progressCompleteChan := make(chan error) + progress := common.ProgressMsg{} + + go func() { + // Show dynamic UI. + err := r.showProgress(ctx, r.Options, progressChan) + if err != nil { + progressCompleteChan <- err + } + close(progressCompleteChan) + }() + + if r.Options.Cluster.Install { + cliOptions := helm.CLIClusterOptions{ + Radius: helm.ChartOptions{ + SetArgs: append(r.Options.SetValues, r.Set...), + SetFileArgs: r.SetFile, + }, + } + + clusterOptions := helm.PopulateDefaultClusterOptions(cliOptions) + + err := r.HelmInterface.InstallRadius(ctx, clusterOptions, r.Options.Cluster.Context) + if err != nil { + return clierrors.MessageWithCause(err, "Failed to install Radius.") + } + } + progress.InstallComplete = true + progressChan <- progress + + if r.Options.Environment.Create { + err := r.CreateEnvironment(ctx) + if err != nil { + return err + } + } + progress.EnvironmentComplete = true + progressChan <- progress + + if r.Options.Application.Scaffold { + client, err := r.ConnectionFactory.CreateApplicationsManagementClient(ctx, *r.Workspace) + if err != nil { + return err + } + + // Initialize the application resource if it's not found. + err = client.CreateApplicationIfNotFound(ctx, r.Options.Application.Name, &corerp.ApplicationResource{ + Location: to.Ptr(v1.LocationGlobal), + Properties: &corerp.ApplicationProperties{ + Environment: &r.Workspace.Environment, + }, + }) + if err != nil { + return err + } + + // Scaffold application files in the current directory + wd, err := os.Getwd() + if err != nil { + return err + } + + err = setup.ScaffoldApplication(wd) + if err != nil { + return err + } + } + progress.ApplicationComplete = true + progressChan <- progress + + err := r.ConfigFileInterface.EditWorkspaces(ctx, config, r.Workspace) + if err != nil { + return err + } + progress.ConfigComplete = true + progressChan <- progress + + // Wait for UI to complete. + err = <-progressCompleteChan + if err != nil { + return err + } + + return nil +} + +func (r *Runner) getAzureCredential() (ucp.AzureCredentialResource, error) { + switch r.Options.CloudProviders.Azure.CredentialKind { + case azure.AzureCredentialKindServicePrincipal: + return ucp.AzureCredentialResource{ + Location: to.Ptr(v1.LocationGlobal), + Type: to.Ptr(cli_credential.AzureCredential), + Properties: &ucp.AzureServicePrincipalProperties{ + Storage: &ucp.CredentialStorageProperties{ + Kind: to.Ptr(ucp.CredentialStorageKindInternal), + }, + TenantID: &r.Options.CloudProviders.Azure.ServicePrincipal.TenantID, + ClientID: &r.Options.CloudProviders.Azure.ServicePrincipal.ClientID, + ClientSecret: &r.Options.CloudProviders.Azure.ServicePrincipal.ClientSecret, + }, + }, nil + case azure.AzureCredentialKindWorkloadIdentity: + return ucp.AzureCredentialResource{ + Location: to.Ptr(v1.LocationGlobal), + Type: to.Ptr(cli_credential.AzureCredential), + Properties: &ucp.AzureWorkloadIdentityProperties{ + Storage: &ucp.CredentialStorageProperties{ + Kind: to.Ptr(ucp.CredentialStorageKindInternal), + }, + TenantID: &r.Options.CloudProviders.Azure.WorkloadIdentity.TenantID, + ClientID: &r.Options.CloudProviders.Azure.WorkloadIdentity.ClientID, + }, + }, nil + default: + return ucp.AzureCredentialResource{}, fmt.Errorf("unsupported Azure credential kind: %s", r.Options.CloudProviders.Azure.CredentialKind) + } +} + +func (r *Runner) getAWSCredential() (ucp.AwsCredentialResource, error) { + switch r.Options.CloudProviders.AWS.CredentialKind { + case aws.AWSCredentialKindAccessKey: + return ucp.AwsCredentialResource{ + Location: to.Ptr(v1.LocationGlobal), + Type: to.Ptr(cli_credential.AWSCredential), + Properties: &ucp.AwsAccessKeyCredentialProperties{ + Storage: &ucp.CredentialStorageProperties{ + Kind: to.Ptr(ucp.CredentialStorageKindInternal), + }, + AccessKeyID: &r.Options.CloudProviders.AWS.AccessKey.AccessKeyID, + SecretAccessKey: &r.Options.CloudProviders.AWS.AccessKey.SecretAccessKey, + }, + }, nil + case aws.AWSCredentialKindIRSA: + return ucp.AwsCredentialResource{ + Location: to.Ptr(v1.LocationGlobal), + Type: to.Ptr(cli_credential.AWSCredential), + Properties: &ucp.AwsIRSACredentialProperties{ + Storage: &ucp.CredentialStorageProperties{ + Kind: to.Ptr(ucp.CredentialStorageKindInternal), + }, + RoleARN: &r.Options.CloudProviders.AWS.IRSA.RoleARN, + }, + }, nil + default: + return ucp.AwsCredentialResource{}, fmt.Errorf("unsupported AWS credential kind: %s", r.Options.CloudProviders.AWS.CredentialKind) + } +} diff --git a/pkg/cli/cmd/radinit/preview/init_test.go b/pkg/cli/cmd/radinit/preview/init_test.go new file mode 100644 index 0000000000..4a6f8b6e8f --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/init_test.go @@ -0,0 +1,1445 @@ +/* +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" + "errors" + "fmt" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" + "github.com/aws/aws-sdk-go-v2/service/ec2" + ec2_types "github.com/aws/aws-sdk-go-v2/service/ec2/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "k8s.io/client-go/tools/clientcmd/api" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/clients" + "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" + "github.com/radius-project/radius/pkg/cli/connections" + cli_credential "github.com/radius-project/radius/pkg/cli/credential" + "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/prompt" + "github.com/radius-project/radius/pkg/cli/test_client_factory" + "github.com/radius-project/radius/pkg/cli/workspaces" + corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/recipes" + "github.com/radius-project/radius/pkg/to" + "github.com/radius-project/radius/test/radcli" + + ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" +) + +func Test_CommandValidation(t *testing.T) { + radcli.SharedCommandValidation(t, NewCommand) +} + +func Test_Validate(t *testing.T) { + config := radcli.LoadConfigWithWorkspace(t) + + azureProviderServicePrincipal := azure.Provider{ + SubscriptionID: "test-subscription-id", + ResourceGroup: "test-resource-group", + CredentialKind: "ServicePrincipal", + ServicePrincipal: &azure.ServicePrincipalCredential{ + ClientID: "test-client-id", + ClientSecret: "test-client-secret", + TenantID: "test-tenant-id", + }, + } + + azureProviderWorkloadIdentity := azure.Provider{ + SubscriptionID: "test-subscription-id", + ResourceGroup: "test-resource-group", + CredentialKind: "WorkloadIdentity", + WorkloadIdentity: &azure.WorkloadIdentityCredential{ + ClientID: "test-client-id", + TenantID: "test-tenant-id", + }, + } + + awsProviderAccessKey := aws.Provider{ + Region: "test-region", + CredentialKind: "AccessKey", + AccessKey: &aws.AccessKeyCredential{ + AccessKeyID: "test-access-key-id", + SecretAccessKey: "test-secret-access-key", + }, + AccountID: "test-account-id", + } + + awsProviderIRSA := aws.Provider{ + Region: "test-region", + CredentialKind: "IRSA", + IRSA: &aws.IRSACredential{ + RoleARN: "test-role-arn", + }, + AccountID: "test-account-id", + } + + testcases := []radcli.ValidateInput{ + { + Name: "Valid Init --full Command", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // Use default env name and namespace + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePrompt(mocks.Prompter, "default") + + // No cloud providers + initAddCloudProviderPromptNo(mocks.Prompter) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Valid Init --full Command Without Radius installed", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusNotInstalled(mocks.Helm) + + // We do not prompt for reinstall if Radius is not yet installed + + // We do not check for existing environments if Radius is not installed + + // Use default env name and namespace + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePrompt(mocks.Prompter, "default") + + // No cloud providers + initAddCloudProviderPromptNo(mocks.Prompter) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Initialize --full with existing environment, choose to create new", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // Configure an existing environment - but then choose to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr("/planes/radius/local/resourceGroups/cool-existing-env/providers/Radius.Core/environments/cool-existing-env"), + Name: to.Ptr("cool-existing-env"), + }, + }) + initExistingEnvironmentSelection(mocks.Prompter, selectExistingEnvironmentCreateSentinel) + + // Use default env name and namespace + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePrompt(mocks.Prompter, "default") + + // No cloud providers + initAddCloudProviderPromptNo(mocks.Prompter) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Initialize --full with existing environment, choose existing", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // Configure an existing environment - but then choose to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr("/planes/radius/local/resourceGroups/cool-existing-env/providers/Radius.Core/environments/cool-existing-env"), + Name: to.Ptr("cool-existing-env"), + }, + }) + initExistingEnvironmentSelection(mocks.Prompter, "cool-existing-env") + + // No need to choose env settings since we're using existing + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Initialize --full with existing environment, choose existing, with Cloud Providers", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // Configure an existing environment - but then choose to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr("/planes/radius/local/resourceGroups/cool-existing-env/providers/Radius.Core/environments/cool-existing-env"), + Name: to.Ptr("cool-existing-env"), + }, + }) + initExistingEnvironmentSelection(mocks.Prompter, "cool-existing-env") + + // No need to choose env settings since we're using existing + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Init --full Command With Azure Cloud Provider - Service Principal", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // Choose default name and namespace + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePrompt(mocks.Prompter, "default") + + // Add azure provider + initAddCloudProviderPromptYes(mocks.Prompter) + initSelectCloudProvider(mocks.Prompter, azure.ProviderDisplayName) + setAzureCloudProviderServicePrincipal(mocks.Prompter, mocks.AzureClient, azureProviderServicePrincipal) + + // Don't add any other cloud providers + initAddCloudProviderPromptNo(mocks.Prompter) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Init --full Command With Azure Cloud Provider - Workload Identity", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // Choose default name and namespace + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePrompt(mocks.Prompter, "default") + + // Add azure provider + initAddCloudProviderPromptYes(mocks.Prompter) + initSelectCloudProvider(mocks.Prompter, azure.ProviderDisplayName) + setAzureCloudProviderWorkloadIdentity(mocks.Prompter, mocks.AzureClient, azureProviderWorkloadIdentity) + + // Don't add any other cloud providers + initAddCloudProviderPromptNo(mocks.Prompter) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Init --full Command With AWS Cloud Provider - Access Key", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // Choose default name and namespace + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePrompt(mocks.Prompter, "default") + + // Add aws provider + initAddCloudProviderPromptYes(mocks.Prompter) + initSelectCloudProvider(mocks.Prompter, aws.ProviderDisplayName) + setAWSCloudProviderAccessKey(mocks.Prompter, mocks.AWSClient, awsProviderAccessKey) + + // Don't add any other cloud providers + initAddCloudProviderPromptNo(mocks.Prompter) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Init --full Command With AWS Cloud Provider - IRSA", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // Choose default name and namespace + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePrompt(mocks.Prompter, "default") + + // Add aws provider + initAddCloudProviderPromptYes(mocks.Prompter) + initSelectCloudProvider(mocks.Prompter, aws.ProviderDisplayName) + setAWSCloudProviderIRSA(mocks.Prompter, mocks.AWSClient, awsProviderIRSA) + + // Don't add any other cloud providers + initAddCloudProviderPromptNo(mocks.Prompter) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Initialize --full with existing environment create application - initial appname is invalid", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + CreateTempDirectory: "in.valid", // Invalid app name + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // Configure an existing environment - but then choose to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr("/planes/radius/local/resourceGroups/cool-existing-env/providers/Radius.Core/environments/cool-existing-env"), + Name: to.Ptr("cool-existing-env"), + }, + }) + initExistingEnvironmentSelection(mocks.Prompter, "cool-existing-env") + + // No need to choose env settings since we're using existing + + // Create Application + setScaffoldApplicationPromptYes(mocks.Prompter) + setApplicationNamePrompt(mocks.Prompter, "valid") + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "rad init create new environment", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "rad init without Radius installed", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusNotInstalled(mocks.Helm) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "rad init chooses existing environment without default", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // Configure an existing environment - this will be chosen automatically + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr("/planes/radius/local/resourceGroups/myenv/providers/Radius.Core/environments/myenv"), + Name: to.Ptr("myenv"), + }, + }) + initExistingEnvironmentSelection(mocks.Prompter, "myenv") + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "rad init chooses existing environment with default", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // Configure an existing environment - this will be chosen automatically + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr("/planes/radius/local/resourceGroups/default/providers/Radius.Core/environments/default"), + Name: to.Ptr("default"), + }, + }) + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "rad init prompts for existing environment", + Input: []string{}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // Configure an existing environment - user has to choose + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{ + { + ID: to.Ptr("/planes/radius/local/resourceGroups/dev/providers/Radius.Core/environments/dev"), + Name: to.Ptr("dev"), + }, + { + ID: to.Ptr("/planes/radius/local/resourceGroups/prod/providers/Radius.Core/environments/prod"), + Name: to.Ptr("prod"), + }, + }) + + // prompt the user since there's no 'default' + initExistingEnvironmentSelection(mocks.Prompter, "prod") + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "Init --full Command With Error KubeContext Read", + Input: []string{"--full"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Fail to read Kubernetes context + initGetKubeContextError(mocks.Kubernetes) + }, + }, + { + Name: "Init --full Command With Error KubeContext Selection", + Input: []string{"--full"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Cancel instead of choosing kubernetes context + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextSelectionError(mocks.Prompter) + }, + }, + { + Name: "Init --full Command With Error EnvName Read", + Input: []string{"--full"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // User cancels from environment name prompt + initEnvNamePromptError(mocks.Prompter) + }, + }, + { + Name: "Init --full Command With Error Namespace Read", + Input: []string{"--full"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // Choose default name and cancel out of namespace prompt + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePromptError(mocks.Prompter) + }, + }, + { + Name: "Init --full Command Navigate back while configuring cloud provider", + Input: []string{"--full"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithKind(mocks.Prompter) + initHelmMockRadiusInstalled(mocks.Helm) + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // Choose default name and namespace + initEnvNamePrompt(mocks.Prompter, "default") + initNamespacePrompt(mocks.Prompter, "default") + + // Oops! I don't need to add cloud provider, navigate back to reinstall prompt + initAddCloudProviderPromptYes(mocks.Prompter) + initSelectCloudProvider(mocks.Prompter, confirmCloudProviderBackNavigationSentinel) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + + setConfirmOption(mocks.Prompter, common.ResultConfirmed) + }, + }, + { + Name: "Init --full Command exit console with interrupt signal", + Input: []string{"--full"}, + ExpectedValid: false, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initKubeContextWithInterruptSignal(mocks.Prompter) + }, + }, + { + Name: "Valid Init Command with --set flag", + Input: []string{"--set", "global.imageRegistry=myregistry.io"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "Valid Init Command with multiple --set flags", + Input: []string{"--set", "global.imageRegistry=myregistry.io", "--set", "global.rootCA.cert=test"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "Valid Init Command with --set flag using comma separator", + Input: []string{"--set", "global.imageRegistry=myregistry.io,global.rootCA.cert=test"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "Valid Init Command with --set-file flag", + Input: []string{"--set-file", "global.rootCA.cert=/path/to/cert.crt"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + { + Name: "Valid Init Command with both --set and --set-file flags", + Input: []string{"--set", "global.imageRegistry=myregistry.io", "--set-file", "global.rootCA.cert=/path/to/cert.crt"}, + ExpectedValid: true, + ConfigHolder: framework.ConfigHolder{ + ConfigFilePath: "", + Config: config, + }, + ConfigureMocks: func(mocks radcli.ValidateMocks) { + // Radius is already installed, no reinstall + initGetKubeContextSuccess(mocks.Kubernetes) + initHelmMockRadiusInstalled(mocks.Helm) + + // No existing environment, users will be prompted to create a new one + setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) + + // No application + setScaffoldApplicationPromptNo(mocks.Prompter) + }, + }, + } + radcli.SharedValidateValidation(t, NewCommand, testcases) +} + +func Test_Run_InstallAndCreateEnvironment(t *testing.T) { + testCases := []struct { + name string + full bool + azureProvider *azure.Provider + awsProvider *aws.Provider + recipes map[string]map[string]corerp.RecipePropertiesClassification + expectedOutput []any + set []string + setFile []string + }{ + { + name: "`rad init` with recipes", + full: false, + azureProvider: nil, + awsProvider: nil, + recipes: map[string]map[string]corerp.RecipePropertiesClassification{ + "Applications.Datastores/redisCaches": { + "default": &corerp.BicepRecipeProperties{ + TemplateKind: to.Ptr(recipes.TemplateKindBicep), + TemplatePath: to.Ptr("ghcr.io/radius-project/dev/redis:latest"), + }, + }, + }, + }, + { + name: "`rad init` w/o recipes", + full: false, + azureProvider: nil, + awsProvider: nil, + recipes: map[string]map[string]corerp.RecipePropertiesClassification{}, + expectedOutput: []any{}, + }, + { + name: "`rad init --full` with Azure Provider - Service Principal", + full: true, + azureProvider: &azure.Provider{ + SubscriptionID: "test-subscription", + ResourceGroup: "test-rg", + CredentialKind: "ServicePrincipal", + ServicePrincipal: &azure.ServicePrincipalCredential{ + TenantID: "test-tenantId", + ClientID: "test-clientId", + ClientSecret: "test-clientSecret", + }, + }, + awsProvider: nil, + recipes: nil, + expectedOutput: []any{}, + }, + { + name: "`rad init --full` with Azure Provider - Workload Identity", + full: true, + azureProvider: &azure.Provider{ + SubscriptionID: "test-subscription", + ResourceGroup: "test-rg", + CredentialKind: "WorkloadIdentity", + WorkloadIdentity: &azure.WorkloadIdentityCredential{ + TenantID: "test-tenantId", + ClientID: "test-clientId", + }, + }, + awsProvider: nil, + recipes: nil, + expectedOutput: []any{}, + }, + { + name: "`rad init` with AWS Provider", + full: false, + azureProvider: nil, + awsProvider: &aws.Provider{ + AccessKey: &aws.AccessKeyCredential{ + AccessKeyID: "test-access-key", + SecretAccessKey: "test-secret-access", + }, + CredentialKind: "AccessKey", + Region: "us-west-2", + AccountID: "test-account-id", + }, + recipes: map[string]map[string]corerp.RecipePropertiesClassification{}, + expectedOutput: []any{}, + }, + { + name: "`rad init --full` with AWS Provider - Access Key", + full: true, + azureProvider: nil, + awsProvider: &aws.Provider{ + AccessKey: &aws.AccessKeyCredential{ + AccessKeyID: "test-access-key", + SecretAccessKey: "test-secret-access", + }, + CredentialKind: "AccessKey", + Region: "us-west-2", + AccountID: "test-account-id", + }, + recipes: nil, + expectedOutput: []any{}, + }, + { + name: "`rad init --full` with AWS Provider - IRSA", + full: true, + azureProvider: nil, + awsProvider: &aws.Provider{ + IRSA: &aws.IRSACredential{ + RoleARN: "role-arn", + }, + CredentialKind: "IRSA", + Region: "us-west-2", + AccountID: "test-account-id", + }, + recipes: nil, + expectedOutput: []any{}, + }, + { + name: "`rad init --full` with no providers", + full: true, + azureProvider: nil, + awsProvider: nil, + recipes: nil, + expectedOutput: []any{}, + }, + { + name: "`rad init` with no providers", + full: false, + azureProvider: nil, + awsProvider: nil, + recipes: map[string]map[string]corerp.RecipePropertiesClassification{}, + expectedOutput: []any{}, + }, + { + name: "`rad init` with --set flags", + full: false, + azureProvider: nil, + awsProvider: nil, + recipes: map[string]map[string]corerp.RecipePropertiesClassification{}, + expectedOutput: []any{}, + set: []string{"global.imageRegistry=myregistry.io", "key=value"}, + setFile: nil, + }, + { + name: "`rad init` with --set-file flags", + full: false, + azureProvider: nil, + awsProvider: nil, + recipes: map[string]map[string]corerp.RecipePropertiesClassification{}, + expectedOutput: []any{}, + set: nil, + setFile: []string{"global.rootCA.cert=/path/to/cert.crt"}, + }, + { + name: "`rad init` with both --set and --set-file flags", + full: false, + azureProvider: nil, + awsProvider: nil, + recipes: map[string]map[string]corerp.RecipePropertiesClassification{}, + expectedOutput: []any{}, + set: []string{"global.imageRegistry=myregistry.io"}, + setFile: []string{"global.rootCA.cert=/path/to/cert.crt", "tls.cert=/path/to/tls.crt"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + configFileInterface := framework.NewMockConfigFileInterface(ctrl) + configFileInterface.EXPECT(). + ConfigFromContext(context.Background()). + Return(nil). + Times(1) + + appManagementClient := clients.NewMockApplicationsManagementClient(ctrl) + appManagementClient.EXPECT(). + CreateOrUpdateResourceGroup(context.Background(), "local", "default", gomock.Any()). + Return(nil). + Times(2) + + // Create a RadiusCoreClientFactory for testing + rootScope := "/planes/radius/local/resourceGroups/default" + radiusCoreClientFactory, err := test_client_factory.NewRadiusCoreTestClientFactory(rootScope, nil, nil) + require.NoError(t, err) + + credentialManagementClient := cli_credential.NewMockCredentialManagementClient(ctrl) + if tc.azureProvider != nil { + credentialManagementClient.EXPECT(). + PutAzure(context.Background(), gomock.Any()). + Return(nil). + Times(1) + } + if tc.awsProvider != nil { + if tc.awsProvider.AccessKey != nil { + credentialManagementClient.EXPECT(). + PutAWS(context.Background(), ucp.AwsCredentialResource{ + Location: to.Ptr(v1.LocationGlobal), + Type: to.Ptr(cli_credential.AWSCredential), + Properties: &ucp.AwsAccessKeyCredentialProperties{ + Storage: &ucp.CredentialStorageProperties{ + Kind: to.Ptr(ucp.CredentialStorageKindInternal), + }, + AccessKeyID: to.Ptr(tc.awsProvider.AccessKey.AccessKeyID), + SecretAccessKey: to.Ptr(tc.awsProvider.AccessKey.SecretAccessKey), + }, + }). + Return(nil). + Times(1) + } else { + credentialManagementClient.EXPECT(). + PutAWS(context.Background(), ucp.AwsCredentialResource{ + Location: to.Ptr(v1.LocationGlobal), + Type: to.Ptr(cli_credential.AWSCredential), + Properties: &ucp.AwsIRSACredentialProperties{ + Storage: &ucp.CredentialStorageProperties{ + Kind: to.Ptr(ucp.CredentialStorageKindInternal), + }, + RoleARN: to.Ptr(tc.awsProvider.IRSA.RoleARN), + }, + }). + Return(nil). + Times(1) + } + + } + + configFileInterface.EXPECT(). + EditWorkspaces(context.Background(), gomock.Any(), gomock.Any()). + Return(nil). + Times(1) + + outputSink := &output.MockOutput{} + + helmInterface := helm.NewMockInterface(ctrl) + + // Verify that Set and SetFile values are passed to Helm + expectedClusterOptions := helm.CLIClusterOptions{ + Radius: helm.ChartOptions{ + SetArgs: tc.set, + SetFileArgs: tc.setFile, + }, + } + + helmInterface.EXPECT(). + InstallRadius(context.Background(), gomock.Any(), "kind-kind"). + DoAndReturn(func(ctx context.Context, clusterOptions helm.ClusterOptions, kubeContext string) error { + // Verify the SetArgs and SetFileArgs are passed correctly + assert.Equal(t, expectedClusterOptions.Radius.SetArgs, clusterOptions.Radius.SetArgs) + assert.Equal(t, expectedClusterOptions.Radius.SetFileArgs, clusterOptions.Radius.SetFileArgs) + return nil + }). + Times(1) + + prompter := prompt.NewMockInterface(ctrl) + setProgressHandler(prompter) + + options := initOptions{ + Cluster: clusterOptions{ + Install: true, + Context: "kind-kind", + }, + Environment: environmentOptions{ + Create: true, + Name: "default", + Namespace: "defaultNamespace", + }, + CloudProviders: cloudProviderOptions{ + Azure: tc.azureProvider, + AWS: tc.awsProvider, + }, + Recipes: recipePackOptions{ + DefaultRecipePack: !tc.full, + }, + Application: applicationOptions{ + Scaffold: false, + }, + } + + runner := &Runner{ + ConnectionFactory: &connections.MockFactory{ + ApplicationsManagementClient: appManagementClient, + CredentialManagementClient: credentialManagementClient, + }, + ConfigFileInterface: configFileInterface, + ConfigHolder: &framework.ConfigHolder{ConfigFilePath: "filePath"}, + HelmInterface: helmInterface, + Output: outputSink, + Prompter: prompter, + RadiusCoreClientFactory: radiusCoreClientFactory, + DefaultScopeClientFactory: radiusCoreClientFactory, + Options: &options, + Workspace: &workspaces.Workspace{ + Name: "default", + Scope: "/planes/radius/local/resourceGroups/default", + }, + Set: tc.set, + SetFile: tc.setFile, + } + + err = runner.Run(context.Background()) + require.NoError(t, err) + + if len(tc.expectedOutput) == 0 { + require.Len(t, outputSink.Writes, 0) + } else { + require.Equal(t, tc.expectedOutput, outputSink.Writes) + } + }) + } +} + +func initGetKubeContextSuccess(kubernestesMock *kubernetes.MockInterface) { + kubernestesMock.EXPECT(). + GetKubeContext(). + Return(getTestKubeConfig(), nil).Times(1) +} + +func initGetKubeContextError(kubernestesMock *kubernetes.MockInterface) { + kubernestesMock.EXPECT(). + GetKubeContext(). + Return(nil, errors.New("unable to fetch kube context")).Times(1) +} + +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, + } +} + +func initKubeContextWithKind(prompter *prompt.MockInterface) { + prompter.EXPECT(). + GetListInput(gomock.Any(), common.SelectClusterPrompt). + Return("kind-kind", nil).Times(1) +} + +func initKubeContextSelectionError(prompter *prompt.MockInterface) { + prompter.EXPECT(). + GetListInput(gomock.Any(), common.SelectClusterPrompt). + Return("", errors.New("cannot read selection")).Times(1) +} + +func initKubeContextWithInterruptSignal(prompter *prompt.MockInterface) { + prompter.EXPECT(). + GetListInput(gomock.Any(), common.SelectClusterPrompt). + Return("", &prompt.ErrExitConsole{}).Times(1) +} + +func initEnvNamePrompt(prompter *prompt.MockInterface, name string) { + prompter.EXPECT(). + GetTextInput(enterEnvironmentNamePrompt, gomock.Any()). + Return(name, nil).Times(1) +} + +func initEnvNamePromptError(prompter *prompt.MockInterface) { + prompter.EXPECT(). + GetTextInput(enterEnvironmentNamePrompt, gomock.Any()). + Return("", errors.New("unable to read prompt")).Times(1) +} + +func initNamespacePrompt(prompter *prompt.MockInterface, namespace string) { + prompter.EXPECT(). + GetTextInput(enterNamespacePrompt, gomock.Any()). + Return(namespace, nil).Times(1) +} + +func initNamespacePromptError(prompter *prompt.MockInterface) { + prompter.EXPECT(). + GetTextInput(enterNamespacePrompt, gomock.Any()). + Return("", errors.New("Unable to read namespace")).Times(1) +} + +var _ gomock.Matcher = &cloudProviderPromptMatcher{} + +type cloudProviderPromptMatcher struct { +} + +// Matches implements gomock.Matcher +func (*cloudProviderPromptMatcher) Matches(x interface{}) bool { + return x == confirmCloudProviderPrompt || x == confirmCloudProviderAdditionalPrompt +} + +// String implements gomock.Matcher +func (*cloudProviderPromptMatcher) String() string { + return fmt.Sprintf("Matches either: %s or %s", confirmCloudProviderPrompt, confirmCloudProviderAdditionalPrompt) +} + +func initAddCloudProviderPromptNo(prompter *prompt.MockInterface) { + // We show a different prompt the second time with different phrasing. + prompter.EXPECT(). + GetListInput(gomock.Any(), &cloudProviderPromptMatcher{}). + Return(prompt.ConfirmNo, nil).Times(1) +} + +func initAddCloudProviderPromptYes(prompter *prompt.MockInterface) { + // We show a different prompt the second time with different phrasing. + prompter.EXPECT(). + GetListInput(gomock.Any(), &cloudProviderPromptMatcher{}). + Return(prompt.ConfirmYes, nil).Times(1) +} + +func initSelectCloudProvider(prompter *prompt.MockInterface, value string) { + prompter.EXPECT(). + GetListInput(gomock.Any(), selectCloudProviderPrompt). + Return(value, nil).Times(1) +} + +func initHelmMockRadiusInstalled(helmMock *helm.MockInterface) { + helmMock.EXPECT(). + CheckRadiusInstall(gomock.Any()). + Return(helm.InstallState{RadiusInstalled: true, RadiusVersion: "test-version"}, nil).Times(1) +} + +func initHelmMockRadiusNotInstalled(helmMock *helm.MockInterface) { + helmMock.EXPECT(). + CheckRadiusInstall(gomock.Any()). + Return(helm.InstallState{RadiusInstalled: false}, nil).Times(1) +} + +func setExistingEnvironments(clientMock *clients.MockApplicationsManagementClient, environments []corerpv20250801.EnvironmentResource) { + clientMock.EXPECT(). + ListRadiusCoreEnvironmentsAll(gomock.Any()). + Return(environments, nil).Times(1) +} + +func initExistingEnvironmentSelection(prompter *prompt.MockInterface, choice string) { + prompter.EXPECT(). + GetListInput(gomock.Any(), selectExistingEnvironmentPrompt). + Return(choice, nil).Times(1) +} + +func setScaffoldApplicationPromptNo(prompter *prompt.MockInterface) { + prompter.EXPECT(). + GetListInput(gomock.Any(), common.ConfirmSetupApplicationPrompt). + Return(prompt.ConfirmNo, nil).Times(1) +} + +func setScaffoldApplicationPromptYes(prompter *prompt.MockInterface) { + prompter.EXPECT(). + GetListInput(gomock.Any(), common.ConfirmSetupApplicationPrompt). + Return(prompt.ConfirmYes, nil).Times(1) +} + +func setApplicationNamePrompt(prompter *prompt.MockInterface, applicationName string) { + prompter.EXPECT(). + GetTextInput(common.EnterApplicationNamePrompt, gomock.Any()). + Return(applicationName, nil).Times(1) +} + +func setAWSRegionPrompt(prompter *prompt.MockInterface, regions []string, region string) { + prompter.EXPECT(). + GetListInput(regions, common.SelectAWSRegionPrompt). + Return(region, nil). + Times(1) +} + +func setAWSAccessKeyIDPrompt(prompter *prompt.MockInterface, accessKeyID string) { + prompter.EXPECT(). + GetTextInput(common.EnterAWSIAMAcessKeyIDPrompt, gomock.Any()). + Return(accessKeyID, nil).Times(1) +} + +func setAWSSecretAccessKeyPrompt(prompter *prompt.MockInterface, secretAccessKey string) { + prompter.EXPECT(). + GetTextInput(common.EnterAWSIAMSecretAccessKeyPrompt, gomock.Any()). + Return(secretAccessKey, nil).Times(1) +} + +func setAWSCallerIdentity(client *aws.MockClient, callerIdentityOutput *sts.GetCallerIdentityOutput) { + client.EXPECT(). + GetCallerIdentity(gomock.Any()). + Return(callerIdentityOutput, nil). + Times(1) +} + +func setAWSAccountIDConfirmPrompt(prompter *prompt.MockInterface, accountName string, choice string) { + prompter.EXPECT(). + GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, fmt.Sprintf(common.ConfirmAWSAccountIDPromptFmt, accountName)). + Return(choice, nil). + Times(1) +} + +func setAWSListRegions(client *aws.MockClient, ec2DescribeRegionsOutput *ec2.DescribeRegionsOutput) { + client.EXPECT(). + ListRegions(gomock.Any()). + Return(ec2DescribeRegionsOutput, nil). + Times(1) +} + +// setAWSCloudProviderAccessKey sets up mocks that will configure an AWS cloud provider with access key. +func setAWSCloudProviderAccessKey(prompter *prompt.MockInterface, client *aws.MockClient, provider aws.Provider) { + setAWSCredentialKindPrompt(prompter, "Access Key") + setAWSAccessKeyIDPrompt(prompter, provider.AccessKey.AccessKeyID) + setAWSSecretAccessKeyPrompt(prompter, provider.AccessKey.SecretAccessKey) + setAWSCallerIdentity(client, &sts.GetCallerIdentityOutput{Account: &provider.AccountID}) + setAWSAccountIDConfirmPrompt(prompter, provider.AccountID, prompt.ConfirmYes) + setAWSListRegions(client, &ec2.DescribeRegionsOutput{Regions: getMockAWSRegions()}) + setAWSRegionPrompt(prompter, getMockAWSRegionsString(), provider.Region) +} + +// setAWSCloudProviderIRSA sets up mocks that will configure an AWS cloud provider with IRSA. +func setAWSCloudProviderIRSA(prompter *prompt.MockInterface, client *aws.MockClient, provider aws.Provider) { + setAWSCredentialKindPrompt(prompter, "IRSA") + setAwsIRSARoleARNPrompt(prompter, provider.IRSA.RoleARN) + setAWSCallerIdentity(client, &sts.GetCallerIdentityOutput{Account: &provider.AccountID}) + setAWSAccountIDConfirmPrompt(prompter, provider.AccountID, prompt.ConfirmYes) + setAWSListRegions(client, &ec2.DescribeRegionsOutput{Regions: getMockAWSRegions()}) + setAWSRegionPrompt(prompter, getMockAWSRegionsString(), provider.Region) +} + +func setAzureSubscriptions(client *azure.MockClient, result *azure.SubscriptionResult) { + client.EXPECT(). + Subscriptions(gomock.Any()). + Return(result, nil). + Times(1) +} + +func setAzureResourceGroups(client *azure.MockClient, subscriptionID string, groups []armresources.ResourceGroup) { + client.EXPECT(). + ResourceGroups(gomock.Any(), subscriptionID). + Return(groups, nil). + Times(1) +} + +func setAzureSubscriptionConfirmPrompt(prompter *prompt.MockInterface, subscriptionName string, choice string) { + prompter.EXPECT(). + GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, fmt.Sprintf(common.ConfirmAzureSubscriptionPromptFmt, subscriptionName)). + Return(choice, nil). + Times(1) +} + +func setAzureResourceGroupCreatePrompt(prompter *prompt.MockInterface, choice string) { + prompter.EXPECT(). + GetListInput([]string{prompt.ConfirmYes, prompt.ConfirmNo}, common.ConfirmAzureCreateResourceGroupPrompt). + Return(choice, nil). + Times(1) +} + +func setAzureResourceGroupPrompt(prompter *prompt.MockInterface, names []string, name string) { + prompter.EXPECT(). + GetListInput(names, common.SelectAzureResourceGroupPrompt). + Return(name, nil). + Times(1) +} + +func setAzureServicePrincipalAppIDPrompt(prompter *prompt.MockInterface, appID string) { + prompter.EXPECT(). + GetTextInput(common.EnterAzureServicePrincipalAppIDPrompt, gomock.Any()). + Return(appID, nil). + Times(1) +} + +func setAzureServicePrincipalPasswordPrompt(prompter *prompt.MockInterface, password string) { + prompter.EXPECT(). + GetTextInput(common.EnterAzureServicePrincipalPasswordPrompt, gomock.Any()). + Return(password, nil). + Times(1) +} + +func setAzureServicePrincipalTenantIDPrompt(prompter *prompt.MockInterface, tenantID string) { + prompter.EXPECT(). + GetTextInput(common.EnterAzureServicePrincipalTenantIDPrompt, gomock.Any()). + Return(tenantID, nil). + Times(1) +} + +func setAzureWorkloadIdentityAppIDPrompt(prompter *prompt.MockInterface, appID string) { + prompter.EXPECT(). + GetTextInput(common.EnterAzureWorkloadIdentityAppIDPrompt, gomock.Any()). + Return(appID, nil). + Times(1) +} + +func setAzureWorkloadIdentityTenantIDPrompt(prompter *prompt.MockInterface, tenantID string) { + prompter.EXPECT(). + GetTextInput(common.EnterAzureWorkloadIdentityTenantIDPrompt, gomock.Any()). + Return(tenantID, nil). + Times(1) +} + +func setAzureCredentialKindPrompt(prompter *prompt.MockInterface, choice string) { + prompter.EXPECT(). + GetListInput([]string{"Service Principal", "Workload Identity"}, common.SelectAzureCredentialKindPrompt). + Return(choice, nil). + Times(1) +} + +func setAWSCredentialKindPrompt(prompter *prompt.MockInterface, choice string) { + prompter.EXPECT(). + GetListInput([]string{"Access Key", "IRSA"}, common.SelectAWSCredentialKindPrompt). + Return(choice, nil). + Times(1) +} + +func setAwsIRSARoleARNPrompt(prompter *prompt.MockInterface, roleARN string) { + prompter.EXPECT(). + GetTextInput(common.EnterAWSRoleARNPrompt, gomock.Any()). + Return(roleARN, nil). + Times(1) +} + +// setAzureCloudProviderServicePrincipal sets up mocks that will configure an Azure cloud provider with service principal credential. +func setAzureCloudProviderServicePrincipal(prompter *prompt.MockInterface, client *azure.MockClient, provider azure.Provider) { + subscriptions := &azure.SubscriptionResult{ + Subscriptions: []azure.Subscription{{ID: provider.SubscriptionID, Name: "test-subscription"}}, + } + subscriptions.Default = &subscriptions.Subscriptions[0] + resourceGroups := []armresources.ResourceGroup{{Name: to.Ptr(provider.ResourceGroup)}} + + setAzureSubscriptions(client, subscriptions) + setAzureSubscriptionConfirmPrompt(prompter, subscriptions.Default.Name, prompt.ConfirmYes) + + setAzureResourceGroupCreatePrompt(prompter, prompt.ConfirmNo) + setAzureResourceGroups(client, provider.SubscriptionID, resourceGroups) + setAzureResourceGroupPrompt(prompter, []string{provider.ResourceGroup}, provider.ResourceGroup) + + setAzureCredentialKindPrompt(prompter, "Service Principal") + + setAzureServicePrincipalAppIDPrompt(prompter, provider.ServicePrincipal.ClientID) + setAzureServicePrincipalPasswordPrompt(prompter, provider.ServicePrincipal.ClientSecret) + setAzureServicePrincipalTenantIDPrompt(prompter, provider.ServicePrincipal.TenantID) +} + +// setAzureCloudProviderWorkloadIdentity sets up mocks that will configure an Azure cloud provider with workload identity credential. +func setAzureCloudProviderWorkloadIdentity(prompter *prompt.MockInterface, client *azure.MockClient, provider azure.Provider) { + subscriptions := &azure.SubscriptionResult{ + Subscriptions: []azure.Subscription{{ID: provider.SubscriptionID, Name: "test-subscription"}}, + } + subscriptions.Default = &subscriptions.Subscriptions[0] + resourceGroups := []armresources.ResourceGroup{{Name: to.Ptr(provider.ResourceGroup)}} + + setAzureSubscriptions(client, subscriptions) + setAzureSubscriptionConfirmPrompt(prompter, subscriptions.Default.Name, prompt.ConfirmYes) + + setAzureResourceGroupCreatePrompt(prompter, prompt.ConfirmNo) + setAzureResourceGroups(client, provider.SubscriptionID, resourceGroups) + setAzureResourceGroupPrompt(prompter, []string{provider.ResourceGroup}, provider.ResourceGroup) + + setAzureCredentialKindPrompt(prompter, "Workload Identity") + + setAzureWorkloadIdentityAppIDPrompt(prompter, provider.WorkloadIdentity.ClientID) + setAzureWorkloadIdentityTenantIDPrompt(prompter, provider.WorkloadIdentity.TenantID) +} + +func setConfirmOption(prompter *prompt.MockInterface, choice common.SummaryResult) { + prompter.EXPECT(). + RunProgram(gomock.Any()). + Return(&common.SummaryModel{Result: choice}, nil). + Times(1) +} + +func setProgressHandler(prompter *prompt.MockInterface) { + prompter.EXPECT(). + RunProgram(gomock.Any()). + DoAndReturn(func(program *tea.Program) (tea.Model, error) { + program.Kill() // Quit the program immediately + return &common.ProgressModel{}, nil + }). + Times(1) +} + +func getMockAWSRegions() []ec2_types.Region { + return []ec2_types.Region{ + {RegionName: to.Ptr("test-region")}, + {RegionName: to.Ptr("test-region-2")}, + } +} + +func getMockAWSRegionsString() []string { + return []string{"test-region", "test-region-2"} +} diff --git a/pkg/cli/cmd/radinit/preview/options.go b/pkg/cli/cmd/radinit/preview/options.go new file mode 100644 index 0000000000..9842bec5b2 --- /dev/null +++ b/pkg/cli/cmd/radinit/preview/options.go @@ -0,0 +1,130 @@ +/* +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" + "fmt" + + "github.com/radius-project/radius/pkg/cli" + cli_aws "github.com/radius-project/radius/pkg/cli/aws" + "github.com/radius-project/radius/pkg/cli/azure" + "github.com/radius-project/radius/pkg/cli/workspaces" +) + +// initOptions holds all of the options that will be used to initialize Radius. +type initOptions struct { + Cluster clusterOptions + Environment environmentOptions + CloudProviders cloudProviderOptions + Recipes recipePackOptions + Application applicationOptions + // SetValues is a list of values that will be passed to Helm when installing the application. + SetValues []string +} + +// clusterOptions holds all of the options that will be used to initialize the Kubernetes cluster. +type clusterOptions struct { + Install bool + Namespace string + Context string + Version string +} + +// environmentOptions holds all of the options that will be used to initialize the environment. +type environmentOptions struct { + Create bool + Name string + Namespace string + // ResourceGroup is the name of the resource group that contains (or will contain) the environment. + // For an existing environment this is parsed from the environment's resource ID. For a new + // environment this defaults to the environment name. + ResourceGroup string +} + +// cloudProviderOptions holds all of the options that will be used to initialize cloud providers. +type cloudProviderOptions struct { + Azure *azure.Provider + AWS *cli_aws.Provider +} + +// recipePackOptions holds all of the options that will be used to initialize recipe packs as part of the environment. +type recipePackOptions struct { + DefaultRecipePack bool +} + +// applicationOptions holds all of the options that will be used to initialize an application in the current directory. +type applicationOptions struct { + Scaffold bool + Name string +} + +func (r *Runner) enterInitOptions(ctx context.Context) (*initOptions, *workspaces.Workspace, error) { + options := initOptions{} + + err := r.enterClusterOptions(&options) + if err != nil { + return nil, nil, err + } + + ws, err := cli.GetWorkspace(r.ConfigHolder.Config, "") + if err != nil { + return nil, nil, err + } + + // Set up a connection so we can list environments. + workspace := &workspaces.Workspace{ + Connection: map[string]any{ + "context": options.Cluster.Context, + "kind": workspaces.KindKubernetes, + }, + + // We can't know the scope yet. Setting it up likes this ensures that any code + // that needs a resource group will fail. After we know the env name we will + // update this value. + Scope: "/planes/radius/local", + } + + err = r.enterEnvironmentOptions(ctx, workspace, &options) + if err != nil { + return nil, nil, err + } + + err = r.enterCloudProviderOptions(ctx, &options) + if err != nil { + return nil, nil, err + } + + err = r.enterApplicationOptions(&options) + if err != nil { + return nil, nil, err + } + + options.Recipes.DefaultRecipePack = !r.Full + + // If the user has a current workspace we should overwrite it. + // If the user does not have a current workspace we should create a new one called default and set it as current + // If the user does not have a current workspace and has an existing one called default we should overwrite it and set it as current + if ws == nil { + workspace.Name = "default" + } else { + workspace.Name = ws.Name + } + + workspace.Environment = fmt.Sprintf("/planes/radius/local/resourceGroups/%s/providers/Radius.Core/environments/%s", options.Environment.ResourceGroup, options.Environment.Name) + workspace.Scope = fmt.Sprintf("/planes/radius/local/resourceGroups/%s", options.Environment.ResourceGroup) + return &options, workspace, nil +} diff --git a/pkg/cli/cmd/utils.go b/pkg/cli/cmd/utils.go index 333270dc4d..b7ecfb1cc7 100644 --- a/pkg/cli/cmd/utils.go +++ b/pkg/cli/cmd/utils.go @@ -30,6 +30,7 @@ import ( "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" "github.com/radius-project/radius/pkg/sdk" "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/ucp/resources" ) // CreateEnvProviders forms the provider scope from the given @@ -134,3 +135,30 @@ func InitializeRadiusCoreClientFactory(ctx context.Context, workspace *workspace return clientFactory, nil } + +// PopulateRecipePackClients adds a RecipePacksClient to clientsByScope for +// every scope referenced by packIDs that is not already in the map. +// Callers seed the map with workspace-scope and default-scope clients before +// calling this function. +func PopulateRecipePackClients( + ctx context.Context, + workspace *workspaces.Workspace, + clientsByScope map[string]*v20250801preview.RecipePacksClient, + packIDs []string, +) error { + for _, packIDStr := range packIDs { + // This is the bicep reference for id, and cannot be invalid. + packID, _ := resources.Parse(packIDStr) + scope := packID.RootScope() + if _, exists := clientsByScope[scope]; exists { + continue + } + factory, err := InitializeRadiusCoreClientFactory(ctx, workspace, scope) + if err != nil { + return err + } + clientsByScope[scope] = factory.NewRecipePacksClient() + } + + return nil +} diff --git a/pkg/cli/cmd/utils_test.go b/pkg/cli/cmd/utils_test.go index 962e9597ae..95d6b6b660 100644 --- a/pkg/cli/cmd/utils_test.go +++ b/pkg/cli/cmd/utils_test.go @@ -17,13 +17,16 @@ limitations under the License. package cmd import ( + "context" "errors" "testing" "github.com/radius-project/radius/pkg/cli/aws" "github.com/radius-project/radius/pkg/cli/azure" "github.com/radius-project/radius/pkg/cli/clierrors" + "github.com/radius-project/radius/pkg/cli/workspaces" corerp "github.com/radius-project/radius/pkg/corerp/api/v20231001preview" + v20250801preview "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" "github.com/stretchr/testify/require" ) @@ -139,3 +142,31 @@ func TestCreateEnvProviders(t *testing.T) { }) } } + +func TestPopulateRecipePackClients(t *testing.T) { + scope := "/planes/radius/local/resourceGroups/test-group" + + t.Run("no-op for empty packIDs", func(t *testing.T) { + clientsByScope := map[string]*v20250801preview.RecipePacksClient{ + scope: {}, // placeholder client + } + + err := PopulateRecipePackClients(context.Background(), &workspaces.Workspace{Scope: scope}, clientsByScope, nil) + require.NoError(t, err) + require.Len(t, clientsByScope, 1) + }) + + t.Run("skips packs whose scope is already in the map", func(t *testing.T) { + clientsByScope := map[string]*v20250801preview.RecipePacksClient{ + scope: {}, + } + packIDs := []string{ + scope + "/providers/Radius.Core/recipePacks/pack1", + scope + "/providers/Radius.Core/recipePacks/pack2", + } + + err := PopulateRecipePackClients(context.Background(), &workspaces.Workspace{Scope: scope}, clientsByScope, packIDs) + require.NoError(t, err) + require.Len(t, clientsByScope, 1, "no new scopes should be added") + }) +} diff --git a/pkg/cli/recipepack/recipepack.go b/pkg/cli/recipepack/recipepack.go new file mode 100644 index 0000000000..9d6ecea7d4 --- /dev/null +++ b/pkg/cli/recipepack/recipepack.go @@ -0,0 +1,143 @@ +/* +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 recipepack + +import ( + "context" + "fmt" + + v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" + "github.com/radius-project/radius/pkg/cli/clients" + corerpv20250801 "github.com/radius-project/radius/pkg/corerp/api/v20250801preview" + "github.com/radius-project/radius/pkg/to" + ucpv20231001 "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" + "github.com/radius-project/radius/pkg/version" +) + +const ( + // DefaultRecipePackResourceName is the name of the Radius provided + // recipe pack resource that contains kubernetes recipes for all core resource types. + DefaultRecipePackResourceName = "default" + + // DefaultResourceGroupName is the name of the default resource group where + // the default recipe pack is created and looked up. + DefaultResourceGroupName = "default" + + // DefaultResourceGroupScope is the full scope path for the default resource group. + // default recipe pack that Radius provides always live in this scope. + DefaultResourceGroupScope = "/planes/radius/local/resourceGroups/" + DefaultResourceGroupName +) + +// ResourceGroupCreator is a function that creates or updates a Radius resource group. +// This is typically satisfied by ApplicationsManagementClient.CreateOrUpdateResourceGroup. +type ResourceGroupCreator func(ctx context.Context, planeName string, resourceGroupName string, resource *ucpv20231001.ResourceGroupResource) error + +// NewDefaultRecipePackResource creates a RecipePackResource containing recipes +// for all core resource types. This is the default recipe pack that gets injected into +// environments that have no recipe packs configured. +func NewDefaultRecipePackResource() corerpv20250801.RecipePackResource { + bicepKind := corerpv20250801.RecipeKindBicep + recipes := make(map[string]*corerpv20250801.RecipeDefinition) + for _, def := range GetCoreTypesRecipeInfo() { + recipes[def.ResourceType] = &corerpv20250801.RecipeDefinition{ + RecipeKind: &bicepKind, + RecipeLocation: to.Ptr(def.RecipeLocation), + } + } + return corerpv20250801.RecipePackResource{ + Location: to.Ptr("global"), + Properties: &corerpv20250801.RecipePackProperties{ + Recipes: recipes, + }, + } +} + +// DefaultRecipePackID returns the full resource ID of the default recipe pack +// in the default resource group scope. +func DefaultRecipePackID() string { + return fmt.Sprintf("%s/providers/Radius.Core/recipePacks/%s", DefaultResourceGroupScope, DefaultRecipePackResourceName) +} + +// EnsureDefaultResourceGroup creates the default resource group if it does not already exist. +// This must be called before creating the default recipe pack, because recipe packs are +// stored in the default resource group and the PUT will fail with 404 if the group is missing. +// The group might be missing in a sequence such as below: +// 1. rad install +// 2. rad workspace create kubernetes +// 3. rad group create prod +// 4. rad group switch prod +// 5. .rad deploy