diff --git a/pkg/cli/cmd/radinit/application.go b/pkg/cli/cmd/radinit/application.go deleted file mode 100644 index a7d3fee159..0000000000 --- a/pkg/cli/cmd/radinit/application.go +++ /dev/null @@ -1,31 +0,0 @@ -/* -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 radinit - -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/application_test.go b/pkg/cli/cmd/radinit/application_test.go deleted file mode 100644 index 7880fa7434..0000000000 --- a/pkg/cli/cmd/radinit/application_test.go +++ /dev/null @@ -1,54 +0,0 @@ -/* -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 radinit - -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: "radinit"}, 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/common/application.go b/pkg/cli/cmd/radinit/common/application.go deleted file mode 100644 index e57d6d3ccb..0000000000 --- a/pkg/cli/cmd/radinit/common/application.go +++ /dev/null @@ -1,82 +0,0 @@ -/* -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 deleted file mode 100644 index 82839d2e96..0000000000 --- a/pkg/cli/cmd/radinit/common/application_test.go +++ /dev/null @@ -1,120 +0,0 @@ -/* -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/display.go b/pkg/cli/cmd/radinit/common/display.go index 1a42de2c13..a872114d0c 100644 --- a/pkg/cli/cmd/radinit/common/display.go +++ b/pkg/cli/cmd/radinit/common/display.go @@ -48,9 +48,8 @@ const ( 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" + SummaryBicepConfigHeadingIcon = "🚧 " + SummaryBicepConfigCreateHeadingFmt = "Create %s\n" SummaryConfigurationHeadingIcon = "📋 " SummaryConfigurationUpdateHeading = "Update local configuration\n" ProgressHeading = "Initializing Radius. This may take a minute or two...\n\n" @@ -76,7 +75,7 @@ type DisplayOptions struct { Cluster ClusterDisplay Environment EnvironmentDisplay CloudProviders CloudProvidersDisplay - Application ApplicationDisplay + BicepConfig BicepConfigDisplay // RecipePackLabel is the label of the recipe pack to display in the summary. // An empty value omits the recipe pack line entirely. @@ -104,19 +103,18 @@ type CloudProvidersDisplay struct { 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 +// BicepConfigDisplay holds the bicepconfig.json fields rendered by the summary +// and progress views. When Files is non-empty a dedicated step is shown that +// reports the bicepconfig.json files that will be created. +type BicepConfigDisplay struct { + Files []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 + BicepConfigComplete bool ConfigComplete bool } @@ -251,12 +249,9 @@ func (m *SummaryModel) View() string { 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))) - } + for _, file := range options.BicepConfig.Files { + message.WriteString(SummaryBicepConfigHeadingIcon) + message.WriteString(fmt.Sprintf(SummaryBicepConfigCreateHeadingFmt, highlight(file))) } message.WriteString(SummaryConfigurationHeadingIcon) @@ -345,9 +340,9 @@ func (m *ProgressModel) View() string { 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))) + if len(options.BicepConfig.Files) > 0 { + m.writeProgressIcon(message, m.Progress.BicepConfigComplete, &waiting) + message.WriteString(fmt.Sprintf(SummaryBicepConfigCreateHeadingFmt, highlight(options.BicepConfig.Files[0]))) } m.writeProgressIcon(message, m.Progress.ConfigComplete, &waiting) @@ -361,7 +356,13 @@ func (m *ProgressModel) View() string { } func (m *ProgressModel) isComplete() bool { - return m.Progress.InstallComplete && m.Progress.EnvironmentComplete && m.Progress.ApplicationComplete && m.Progress.ConfigComplete + if !m.Progress.InstallComplete || !m.Progress.EnvironmentComplete || !m.Progress.ConfigComplete { + return false + } + if len(m.Options.BicepConfig.Files) > 0 && !m.Progress.BicepConfigComplete { + return false + } + return true } // writeProgressIcon writes the correct icon for the progress step depending on the current step. diff --git a/pkg/cli/cmd/radinit/display.go b/pkg/cli/cmd/radinit/display.go index b67dcb7e41..133c2e9aaf 100644 --- a/pkg/cli/cmd/radinit/display.go +++ b/pkg/cli/cmd/radinit/display.go @@ -36,17 +36,15 @@ func (r *Runner) showProgress(ctx context.Context, options *initOptions, progres // toDisplayOptions converts the package-local initOptions into the common // DisplayOptions consumed by the shared summary and progress views. +// +// `rad init` always creates a bicepconfig.json in the current directory; the +// shared display renders this as the "BicepConfig" step. func toDisplayOptions(options *initOptions) common.DisplayOptions { recipePackLabel := "" if options.Recipes.DevRecipes { recipePackLabel = "local-dev" } - var scaffoldFiles []string - if options.Application.Scaffold { - scaffoldFiles = []string{"app.bicep", "bicepconfig.json"} - } - return common.DisplayOptions{ Cluster: common.ClusterDisplay{ Install: options.Cluster.Install, @@ -63,10 +61,8 @@ func toDisplayOptions(options *initOptions) common.DisplayOptions { Azure: options.CloudProviders.Azure, AWS: options.CloudProviders.AWS, }, - Application: common.ApplicationDisplay{ - Scaffold: options.Application.Scaffold, - Name: options.Application.Name, - ScaffoldFiles: scaffoldFiles, + BicepConfig: common.BicepConfigDisplay{ + Files: []string{"bicepconfig.json"}, }, RecipePackLabel: recipePackLabel, } diff --git a/pkg/cli/cmd/radinit/display_test.go b/pkg/cli/cmd/radinit/display_test.go index ca624f4ccf..6f14512d1b 100644 --- a/pkg/cli/cmd/radinit/display_test.go +++ b/pkg/cli/cmd/radinit/display_test.go @@ -125,6 +125,7 @@ func Test_summaryModel(t *testing.T) { "\n" + "🔧 Use existing Radius test-version install on test-context\n" + "🌏 Use existing environment test-environment\n" + + "🚧 Create bicepconfig.json\n" + "📋 Update local configuration\n" + "\n" + "(press enter to confirm or esc to restart)\n\r" @@ -145,7 +146,6 @@ func Test_summaryModel(t *testing.T) { }, CloudProviders: cloudProviderOptions{}, Recipes: recipePackOptions{}, - Application: applicationOptions{}, } expected := "\rYou've selected the following:\n" + @@ -154,6 +154,7 @@ func Test_summaryModel(t *testing.T) { " - Kubernetes cluster: test-context\n" + " - Kubernetes namespace: \n" + "🌏 Use existing environment test-environment\n" + + "🚧 Create bicepconfig.json\n" + "📋 Update local configuration\n" + "\n" + "(press enter to confirm or esc to restart)\n\r" diff --git a/pkg/cli/cmd/radinit/init.go b/pkg/cli/cmd/radinit/init.go index 07981db29a..52cc84f3fd 100644 --- a/pkg/cli/cmd/radinit/init.go +++ b/pkg/cli/cmd/radinit/init.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "os" + "path/filepath" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/cli" @@ -38,7 +39,6 @@ import ( "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" "github.com/radius-project/radius/pkg/to" ucp "github.com/radius-project/radius/pkg/ucp/api/v20231001preview" "github.com/spf13/cobra" @@ -225,7 +225,7 @@ func (r *Runner) Validate(cmd *cobra.Command, args []string) error { // // Run creates a progress channel, installs the radius control plane, creates an environment, configures cloud -// providers, scaffolds an application, and updates the config file, all while displaying progress updates to the UI. +// providers, scaffolds a bicepconfig.json, and updates the config file, all while displaying progress updates to the UI. func (r *Runner) Run(ctx context.Context) error { config := r.ConfigFileInterface.ConfigFromContext(ctx) @@ -253,6 +253,11 @@ func (r *Runner) Run(ctx context.Context) error { clusterOptions := helm.PopulateDefaultClusterOptions(cliOptions) + // Silence helm install log messages while the bubbletea progress UI + // owns stdout. Concurrent writes from output.LogInfo would corrupt + // the in-place rendering of the progress display. + clusterOptions.Logger = func(format string, v ...any) {} + err := r.HelmInterface.InstallRadius(ctx, clusterOptions, r.Options.Cluster.Context) if err != nil { return clierrors.MessageWithCause(err, "Failed to install Radius.") @@ -270,39 +275,21 @@ func (r *Runner) Run(ctx context.Context) error { 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. This supports the scenario where the application - // resource is not defined in bicep. - 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 - } + // Always scaffold a bicepconfig.json in the current directory so that users have the + // required Bicep configuration to author Radius applications. + wd, err := os.Getwd() + if err != nil { + return err + } - err = setup.ScaffoldApplication(wd) - if err != nil { - return err - } + bicepConfigExisted, err := setup.ScaffoldBicepConfig(wd) + if err != nil { + return err } - progress.ApplicationComplete = true + progress.BicepConfigComplete = true progressChan <- progress - err := r.ConfigFileInterface.EditWorkspaces(ctx, config, r.Workspace) + err = r.ConfigFileInterface.EditWorkspaces(ctx, config, r.Workspace) if err != nil { return err } @@ -315,6 +302,13 @@ func (r *Runner) Run(ctx context.Context) error { return err } + // Warn the user (after the progress UI has finished) if a bicepconfig.json was already + // present so that they know it was preserved and not overwritten. + if bicepConfigExisted { + bicepConfigPath := filepath.Join(wd, "bicepconfig.json") + r.Output.LogInfo("Warning: An existing bicepconfig.json was found at %s. It was preserved and not modified. Ensure it contains the Radius Bicep extensions (radius, radiusCompute, radiusData, radiusSecurity, aws); otherwise Bicep authoring may fail.", bicepConfigPath) + } + return nil } diff --git a/pkg/cli/cmd/radinit/init_test.go b/pkg/cli/cmd/radinit/init_test.go index 8316e659e6..13847fb044 100644 --- a/pkg/cli/cmd/radinit/init_test.go +++ b/pkg/cli/cmd/radinit/init_test.go @@ -20,6 +20,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "testing" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" @@ -126,9 +128,6 @@ func Test_Validate(t *testing.T) { // No cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -157,9 +156,6 @@ func Test_Validate(t *testing.T) { // No cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -192,9 +188,6 @@ func Test_Validate(t *testing.T) { // No cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -222,9 +215,6 @@ func Test_Validate(t *testing.T) { // No need to choose env settings since we're using existing - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -262,9 +252,6 @@ func Test_Validate(t *testing.T) { // No need to choose env settings since we're using existing - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -297,9 +284,6 @@ func Test_Validate(t *testing.T) { // Don't add any other cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -332,9 +316,6 @@ func Test_Validate(t *testing.T) { // Don't add any other cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -367,9 +348,6 @@ func Test_Validate(t *testing.T) { // Don't add any other cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -402,41 +380,6 @@ func Test_Validate(t *testing.T) { // 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, []corerp.EnvironmentResource{ - { - Name: new("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) }, }, @@ -455,9 +398,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerp.EnvironmentResource{}) - - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -472,9 +412,6 @@ func Test_Validate(t *testing.T) { // Radius is already installed initGetKubeContextSuccess(mocks.Kubernetes) initHelmMockRadiusNotInstalled(mocks.Helm) - - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -497,8 +434,6 @@ func Test_Validate(t *testing.T) { }, }) initExistingEnvironmentSelection(mocks.Prompter, "myenv") - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -520,8 +455,6 @@ func Test_Validate(t *testing.T) { Name: new("default"), }, }) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -549,9 +482,6 @@ func Test_Validate(t *testing.T) { // prompt the user since there's no 'default' initExistingEnvironmentSelection(mocks.Prompter, "prod") - - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -648,9 +578,6 @@ func Test_Validate(t *testing.T) { initAddCloudProviderPromptYes(mocks.Prompter) initSelectCloudProvider(mocks.Prompter, confirmCloudProviderBackNavigationSentinel) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -683,9 +610,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerp.EnvironmentResource{}) - - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -703,9 +627,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerp.EnvironmentResource{}) - - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -723,9 +644,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerp.EnvironmentResource{}) - - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -743,9 +661,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerp.EnvironmentResource{}) - - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -763,9 +678,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerp.EnvironmentResource{}) - - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, } @@ -935,6 +847,11 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + // Run in a temp directory so that ScaffoldBicepConfig does not write to the + // source tree (and so it always creates a new file rather than detecting an + // existing one and emitting a warning). + t.Chdir(t.TempDir()) + ctrl := gomock.NewController(t) configFileInterface := framework.NewMockConfigFileInterface(ctrl) configFileInterface.EXPECT(). @@ -1059,9 +976,6 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { Recipes: recipePackOptions{ DevRecipes: !tc.full, }, - Application: applicationOptions{ - Scaffold: false, - }, } runner := &Runner{ @@ -1095,6 +1009,140 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { } } +func Test_Run_WarnsWhenBicepConfigAlreadyExists(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + // Pre-create a bicepconfig.json so the runner detects it and emits a warning. + existingPath := filepath.Join(tempDir, "bicepconfig.json") + require.NoError(t, os.WriteFile(existingPath, []byte("existing content"), 0644)) + + ctrl := gomock.NewController(t) + configFileInterface := framework.NewMockConfigFileInterface(ctrl) + configFileInterface.EXPECT(). + ConfigFromContext(context.Background()). + Return(nil). + Times(1) + configFileInterface.EXPECT(). + EditWorkspaces(context.Background(), gomock.Any(), gomock.Any()). + Return(nil). + Times(1) + + helmInterface := helm.NewMockInterface(ctrl) + helmInterface.EXPECT(). + InstallRadius(context.Background(), gomock.Any(), "kind-kind"). + Return(nil). + Times(1) + + prompter := prompt.NewMockInterface(ctrl) + setProgressHandler(prompter) + + outputSink := &output.MockOutput{} + + options := initOptions{ + Cluster: clusterOptions{ + Install: true, + Context: "kind-kind", + }, + Environment: environmentOptions{ + Create: false, + Name: "default", + }, + } + + runner := &Runner{ + ConnectionFactory: &connections.MockFactory{}, + ConfigFileInterface: configFileInterface, + ConfigHolder: &framework.ConfigHolder{ConfigFilePath: "filePath"}, + HelmInterface: helmInterface, + Output: outputSink, + Prompter: prompter, + Options: &options, + Workspace: &workspaces.Workspace{ + Name: "default", + }, + } + + err := runner.Run(context.Background()) + require.NoError(t, err) + + // Existing file should not have been modified. + contents, err := os.ReadFile(existingPath) + require.NoError(t, err) + require.Equal(t, "existing content", string(contents)) + + // A warning should have been emitted referencing the existing path. + require.Len(t, outputSink.Writes, 1) + logMsg, ok := outputSink.Writes[0].(output.LogOutput) + require.True(t, ok) + require.Contains(t, logMsg.Format, "Warning") + require.Contains(t, logMsg.Format, "existing bicepconfig.json") + require.Equal(t, []any{existingPath}, logMsg.Params) +} + +func Test_Run_InstallRadiusError(t *testing.T) { + tempDir := t.TempDir() + t.Chdir(tempDir) + + ctrl := gomock.NewController(t) + + helmInterface := helm.NewMockInterface(ctrl) + helmInterface.EXPECT(). + InstallRadius(context.Background(), gomock.Any(), "kind-kind"). + Return(errors.New("boom")). + Times(1) + + configFileInterface := framework.NewMockConfigFileInterface(ctrl) + configFileInterface.EXPECT(). + ConfigFromContext(context.Background()). + Return(nil). + Times(1) + + prompter := prompt.NewMockInterface(ctrl) + // Run starts a showProgress goroutine that calls RunProgram. On the + // install-failure path Run returns early without waiting for the + // goroutine, so RunProgram may or may not have been invoked by the + // time the test ends. Allow either. + prompter.EXPECT(). + RunProgram(gomock.Any()). + DoAndReturn(func(program *tea.Program) (tea.Model, error) { + program.Kill() + return &common.ProgressModel{}, nil + }). + AnyTimes() + + options := initOptions{ + Cluster: clusterOptions{ + Install: true, + Context: "kind-kind", + }, + Environment: environmentOptions{ + Create: false, + Name: "default", + }, + } + + runner := &Runner{ + ConnectionFactory: &connections.MockFactory{}, + ConfigFileInterface: configFileInterface, + ConfigHolder: &framework.ConfigHolder{ConfigFilePath: "filePath"}, + HelmInterface: helmInterface, + Output: &output.MockOutput{}, + Prompter: prompter, + Options: &options, + Workspace: &workspaces.Workspace{ + Name: "default", + }, + } + + err := runner.Run(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "Failed to install Radius.") + // The underlying cause from InstallRadius should be preserved by + // clierrors.MessageWithCause. + require.Contains(t, err.Error(), "boom") +} + func buildProviders(azureProvider *azure.Provider, awsProvider *aws.Provider) *corerp.Providers { providers := &corerp.Providers{} if azureProvider != nil { @@ -1235,24 +1283,6 @@ func initExistingEnvironmentSelection(prompter *prompt.MockInterface, choice str 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). diff --git a/pkg/cli/cmd/radinit/options.go b/pkg/cli/cmd/radinit/options.go index 6c3c981c0a..2fd16458ce 100644 --- a/pkg/cli/cmd/radinit/options.go +++ b/pkg/cli/cmd/radinit/options.go @@ -31,7 +31,6 @@ type initOptions struct { 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 } @@ -64,12 +63,6 @@ type recipePackOptions struct { DevRecipes 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{} @@ -106,11 +99,6 @@ func (r *Runner) enterInitOptions(ctx context.Context) (*initOptions, *workspace return nil, nil, err } - err = r.enterApplicationOptions(&options) - if err != nil { - return nil, nil, err - } - options.Recipes.DevRecipes = !r.Full // If the user has a current workspace we should overwrite it. @@ -168,13 +156,3 @@ func (r *Runner) UpdateRecipePackOptions(devRecipes bool) { r.Options.Recipes.DevRecipes = devRecipes } - -// UpdateApplicationOptions updates the application options with the provided values. -func (r *Runner) UpdateApplicationOptions(scaffold bool, name string) { - if r.Options == nil { - r.Options = &initOptions{} - } - - r.Options.Application.Scaffold = scaffold - r.Options.Application.Name = name -} diff --git a/pkg/cli/cmd/radinit/options_test.go b/pkg/cli/cmd/radinit/options_test.go index cf7d5236d5..58ec221b0c 100644 --- a/pkg/cli/cmd/radinit/options_test.go +++ b/pkg/cli/cmd/radinit/options_test.go @@ -42,7 +42,6 @@ func Test_enterInitOptions(t *testing.T) { initGetKubeContextSuccess(k8s) initHelmMockRadiusNotInstalled(helm) - setScaffoldApplicationPromptNo(prompter) options, workspace, err := runner.enterInitOptions(context.Background()) require.NoError(t, err) @@ -90,7 +89,6 @@ func Test_enterInitOptions(t *testing.T) { initEnvNamePrompt(prompter, "test-env") initNamespacePrompt(prompter, "test-namespace") initAddCloudProviderPromptNo(prompter) - setScaffoldApplicationPromptNo(prompter) options, workspace, err := runner.enterInitOptions(context.Background()) require.NoError(t, err) @@ -149,7 +147,6 @@ workspaces: initEnvNamePrompt(prompter, "test-env") initNamespacePrompt(prompter, "test-namespace") initAddCloudProviderPromptNo(prompter) - setScaffoldApplicationPromptNo(prompter) options, workspace, err := runner.enterInitOptions(context.Background()) require.NoError(t, err) @@ -214,7 +211,6 @@ workspaces: initEnvNamePrompt(prompter, "test-env") initNamespacePrompt(prompter, "test-namespace") initAddCloudProviderPromptNo(prompter) - setScaffoldApplicationPromptNo(prompter) options, workspace, err := runner.enterInitOptions(context.Background()) require.NoError(t, err) diff --git a/pkg/cli/cmd/radinit/preview/application.go b/pkg/cli/cmd/radinit/preview/application.go deleted file mode 100644 index 4d873fdacc..0000000000 --- a/pkg/cli/cmd/radinit/preview/application.go +++ /dev/null @@ -1,31 +0,0 @@ -/* -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 deleted file mode 100644 index 5d9b0b80c1..0000000000 --- a/pkg/cli/cmd/radinit/preview/application_test.go +++ /dev/null @@ -1,54 +0,0 @@ -/* -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/display.go b/pkg/cli/cmd/radinit/preview/display.go index 315d6bde7b..7c04121eff 100644 --- a/pkg/cli/cmd/radinit/preview/display.go +++ b/pkg/cli/cmd/radinit/preview/display.go @@ -18,7 +18,6 @@ package preview import ( "context" - "path/filepath" "github.com/radius-project/radius/pkg/cli/cmd/radinit/common" ) @@ -37,17 +36,15 @@ func (r *Runner) showProgress(ctx context.Context, options *initOptions, progres // toDisplayOptions converts the package-local initOptions into the common // DisplayOptions consumed by the shared summary and progress views. +// +// `rad init --preview` always creates a bicepconfig.json in the current +// directory; the shared display renders this as the "BicepConfig" step. 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, @@ -64,10 +61,8 @@ func toDisplayOptions(options *initOptions) common.DisplayOptions { Azure: options.CloudProviders.Azure, AWS: options.CloudProviders.AWS, }, - Application: common.ApplicationDisplay{ - Scaffold: options.Application.Scaffold, - Name: options.Application.Name, - ScaffoldFiles: scaffoldFiles, + BicepConfig: common.BicepConfigDisplay{ + Files: []string{"bicepconfig.json"}, }, RecipePackLabel: recipePackLabel, } diff --git a/pkg/cli/cmd/radinit/preview/init.go b/pkg/cli/cmd/radinit/preview/init.go index bd9e0e61bb..f189556b42 100644 --- a/pkg/cli/cmd/radinit/preview/init.go +++ b/pkg/cli/cmd/radinit/preview/init.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "os" + "path/filepath" v1 "github.com/radius-project/radius/pkg/armrpc/api/v1" "github.com/radius-project/radius/pkg/cli" @@ -37,7 +38,6 @@ import ( "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" @@ -231,38 +231,21 @@ func (r *Runner) Run(ctx context.Context) error { 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 - } + // Always scaffold a bicepconfig.json in the current directory so that users have the + // required Bicep configuration to author Radius applications. + wd, err := os.Getwd() + if err != nil { + return err + } - err = setup.ScaffoldApplication(wd) - if err != nil { - return err - } + bicepConfigExisted, err := setup.ScaffoldBicepConfig(wd) + if err != nil { + return err } - progress.ApplicationComplete = true + progress.BicepConfigComplete = true progressChan <- progress - err := r.ConfigFileInterface.EditWorkspaces(ctx, config, r.Workspace) + err = r.ConfigFileInterface.EditWorkspaces(ctx, config, r.Workspace) if err != nil { return err } @@ -275,6 +258,13 @@ func (r *Runner) Run(ctx context.Context) error { return err } + // Warn the user (after the progress UI has finished) if a bicepconfig.json was already + // present so that they know it was preserved and not overwritten. + if bicepConfigExisted { + bicepConfigPath := filepath.Join(wd, "bicepconfig.json") + r.Output.LogInfo("Warning: An existing bicepconfig.json was found at %s. It was preserved and not modified. Ensure it contains the Radius Bicep extensions (radius, radiusCompute, radiusData, radiusSecurity, aws); otherwise Bicep authoring may fail.", bicepConfigPath) + } + return nil } diff --git a/pkg/cli/cmd/radinit/preview/init_test.go b/pkg/cli/cmd/radinit/preview/init_test.go index 4a6f8b6e8f..f84fea9671 100644 --- a/pkg/cli/cmd/radinit/preview/init_test.go +++ b/pkg/cli/cmd/radinit/preview/init_test.go @@ -127,9 +127,6 @@ func Test_Validate(t *testing.T) { // No cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -158,9 +155,6 @@ func Test_Validate(t *testing.T) { // No cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -194,9 +188,6 @@ func Test_Validate(t *testing.T) { // No cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -225,9 +216,6 @@ func Test_Validate(t *testing.T) { // No need to choose env settings since we're using existing - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -256,9 +244,6 @@ func Test_Validate(t *testing.T) { // No need to choose env settings since we're using existing - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -291,9 +276,6 @@ func Test_Validate(t *testing.T) { // Don't add any other cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -326,9 +308,6 @@ func Test_Validate(t *testing.T) { // Don't add any other cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -361,9 +340,6 @@ func Test_Validate(t *testing.T) { // Don't add any other cloud providers initAddCloudProviderPromptNo(mocks.Prompter) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -396,42 +372,6 @@ func Test_Validate(t *testing.T) { // 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) }, }, @@ -451,8 +391,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -468,8 +406,6 @@ func Test_Validate(t *testing.T) { initGetKubeContextSuccess(mocks.Kubernetes) initHelmMockRadiusNotInstalled(mocks.Helm) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -493,8 +429,6 @@ func Test_Validate(t *testing.T) { }, }) initExistingEnvironmentSelection(mocks.Prompter, "myenv") - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -517,8 +451,6 @@ func Test_Validate(t *testing.T) { Name: to.Ptr("default"), }, }) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -549,8 +481,6 @@ func Test_Validate(t *testing.T) { // prompt the user since there's no 'default' initExistingEnvironmentSelection(mocks.Prompter, "prod") - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -647,9 +577,6 @@ func Test_Validate(t *testing.T) { initAddCloudProviderPromptYes(mocks.Prompter) initSelectCloudProvider(mocks.Prompter, confirmCloudProviderBackNavigationSentinel) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) - setConfirmOption(mocks.Prompter, common.ResultConfirmed) }, }, @@ -683,8 +610,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -703,8 +628,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -723,8 +646,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -743,8 +664,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, { @@ -763,8 +682,6 @@ func Test_Validate(t *testing.T) { // No existing environment, users will be prompted to create a new one setExistingEnvironments(mocks.ApplicationManagementClient, []corerpv20250801.EnvironmentResource{}) - // No application - setScaffoldApplicationPromptNo(mocks.Prompter) }, }, } @@ -934,6 +851,8 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { + t.Chdir(t.TempDir()) + ctrl := gomock.NewController(t) configFileInterface := framework.NewMockConfigFileInterface(ctrl) configFileInterface.EXPECT(). @@ -1040,9 +959,6 @@ func Test_Run_InstallAndCreateEnvironment(t *testing.T) { Recipes: recipePackOptions{ DefaultRecipePack: !tc.full, }, - Application: applicationOptions{ - Scaffold: false, - }, } runner := &Runner{ @@ -1203,24 +1119,6 @@ func initExistingEnvironmentSelection(prompter *prompt.MockInterface, choice str 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). diff --git a/pkg/cli/cmd/radinit/preview/options.go b/pkg/cli/cmd/radinit/preview/options.go index 9842bec5b2..c867d0e26f 100644 --- a/pkg/cli/cmd/radinit/preview/options.go +++ b/pkg/cli/cmd/radinit/preview/options.go @@ -31,7 +31,6 @@ type initOptions struct { 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 } @@ -66,12 +65,6 @@ 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{} @@ -108,11 +101,6 @@ func (r *Runner) enterInitOptions(ctx context.Context) (*initOptions, *workspace 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. diff --git a/pkg/cli/helm/cluster.go b/pkg/cli/helm/cluster.go index 6b7f1efb37..6bee3bb192 100644 --- a/pkg/cli/helm/cluster.go +++ b/pkg/cli/helm/cluster.go @@ -37,6 +37,27 @@ type CLIClusterOptions struct { type ClusterOptions struct { Radius RadiusChartOptions Contour ContourChartOptions + + // Logger is an optional function used to emit informational messages from + // install/upgrade/rollback operations (e.g. "Installing Contour..."). When + // nil, messages are routed through the global output.LogInfo (which writes + // to stdout). Callers that drive an interactive UI (like 'rad init', which + // renders a Bubble Tea progress display) must set this to a no-op or to a + // sink that does not write to stdout, otherwise concurrent writes will + // corrupt the in-place rendering. + Logger func(format string, v ...any) +} + +// logf logs via c.Logger when it is set; otherwise it uses the global +// output.LogInfo. This allows callers that own stdout (e.g. an interactive +// progress UI) to silence helm install/upgrade messages without affecting +// non-interactive callers. +func (c *ClusterOptions) logf(format string, v ...any) { + if c.Logger != nil { + c.Logger(format, v...) + return + } + output.LogInfo(format, v...) } // NewDefaultClusterOptions sets the default values for the ClusterOptions struct, using the chart version that matches @@ -230,12 +251,12 @@ func (i *Impl) InstallRadius(ctx context.Context, clusterOptions ClusterOptions, } if clusterOptions.Contour.Disabled { - output.LogInfo("Contour is disabled, skipping installation") + clusterOptions.logf("Contour is disabled, skipping installation") return nil } // Install Contour - output.LogInfo("Installing Contour...") + clusterOptions.logf("Installing Contour...") contourHelmChart, contourHelmConf, err := prepareContourChart(helmAction, clusterOptions.Contour, kubeContext) if err != nil { return fmt.Errorf("failed to prepare Contour Helm chart, err: %w", err) diff --git a/pkg/cli/helm/clusteroptions_test.go b/pkg/cli/helm/clusteroptions_test.go new file mode 100644 index 0000000000..ffc3d74289 --- /dev/null +++ b/pkg/cli/helm/clusteroptions_test.go @@ -0,0 +1,52 @@ +/* +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 helm + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_ClusterOptions_logf_UsesCustomLogger(t *testing.T) { + var captured string + options := ClusterOptions{ + Logger: func(format string, v ...any) { + // Capture the raw format/args so we can assert that logf forwards + // them verbatim to the custom logger instead of routing them + // through output.LogInfo. + captured = format + require.Equal(t, []any{"value"}, v) + }, + } + + options.logf("hello %s", "value") + + require.Equal(t, "hello %s", captured) +} + +func Test_ClusterOptions_logf_FallsBackWhenLoggerNil(t *testing.T) { + // When Logger is nil, logf should fall back to output.LogInfo without + // panicking. We don't assert on stdout here (other tests cover + // output.LogInfo); we just ensure the nil-Logger branch is exercised + // safely. + options := ClusterOptions{} + + require.NotPanics(t, func() { + options.logf("hello %s", "world") + }) +} diff --git a/pkg/cli/setup/application.go b/pkg/cli/setup/application.go index 4fc41c8b39..de0da6c9c3 100644 --- a/pkg/cli/setup/application.go +++ b/pkg/cli/setup/application.go @@ -25,27 +25,6 @@ import ( ) const ( - appBicepTemplate = `extension radius - -@description('The Radius Application ID. Injected automatically by the rad CLI.') -param application string - -resource demo 'Applications.Core/containers@2023-10-01-preview' = { - name: 'demo' - properties: { - application: application - container: { - image: 'ghcr.io/radius-project/samples/demo:latest' - ports: { - web: { - containerPort: 3000 - } - } - } - } -} -` // Trailing newline intentional. - bicepConfigTemplate = `{ "extensions": { "radius": "br:biceptypes.azurecr.io/radius:%s", @@ -54,33 +33,24 @@ resource demo 'Applications.Core/containers@2023-10-01-preview' = { }` ) -// ScaffoldApplication creates a working sample application in the provided directory. -func ScaffoldApplication(directory string) error { - // We NEVER overwrite app.bicep or the bicepconfig.json if it exists. We assume the user might have changed it, and don't +// ScaffoldBicepConfig creates a bicepconfig.json file in the provided directory if one does not already exist. +// It returns existed=true if a bicepconfig.json was already present (in which case the file is not modified). +func ScaffoldBicepConfig(directory string) (existed bool, err error) { + // We NEVER overwrite the bicepconfig.json if it exists. We assume the user might have changed it, and don't // want them to lose their content. - appBicepFilepath := filepath.Join(directory, "app.bicep") - _, err := os.Stat(appBicepFilepath) - if os.IsNotExist(err) { - err = os.WriteFile(appBicepFilepath, []byte(appBicepTemplate), 0644) - if err != nil { - return err - } - } else if err != nil { - return err - } - bicepConfigFilepath := filepath.Join(directory, "bicepconfig.json") _, err = os.Stat(bicepConfigFilepath) if os.IsNotExist(err) { err = os.WriteFile(bicepConfigFilepath, []byte(getVersionedBicepConfig()), 0644) if err != nil { - return err + return false, fmt.Errorf("write bicep config file %q: %w", bicepConfigFilepath, err) } + return false, nil } else if err != nil { - return err + return false, err } - return nil + return true, nil } func getVersionedBicepConfig() string { diff --git a/pkg/cli/setup/application_test.go b/pkg/cli/setup/application_test.go index d9e827fbc3..eb0911403a 100644 --- a/pkg/cli/setup/application_test.go +++ b/pkg/cli/setup/application_test.go @@ -27,43 +27,67 @@ import ( const latest = "latest" -func Test_ScaffoldApplication_CreatesBothFiles(t *testing.T) { +func Test_ScaffoldBicepConfig_CreatesFile(t *testing.T) { directory := t.TempDir() - err := ScaffoldApplication(directory) + existed, err := ScaffoldBicepConfig(directory) require.NoError(t, err) + require.False(t, existed) - require.FileExists(t, filepath.Join(directory, "app.bicep")) + require.NoFileExists(t, filepath.Join(directory, "app.bicep")) require.FileExists(t, filepath.Join(directory, "bicepconfig.json")) - b, err := os.ReadFile(filepath.Join(directory, "app.bicep")) - require.NoError(t, err) - require.Equal(t, appBicepTemplate, string(b)) - - b, err = os.ReadFile(filepath.Join(directory, "bicepconfig.json")) + b, err := os.ReadFile(filepath.Join(directory, "bicepconfig.json")) require.NoError(t, err) require.Equal(t, fmt.Sprintf(bicepConfigTemplate, latest, latest), string(b)) } -func Test_ScaffoldApplication_KeepsExistingFiles(t *testing.T) { +func Test_ScaffoldBicepConfig_KeepsExistingFile(t *testing.T) { directory := t.TempDir() - // Pre-create files - err := os.WriteFile(filepath.Join(directory, "app.bicep"), []byte("something else"), 0644) - require.NoError(t, err) - err = os.WriteFile(filepath.Join(directory, "bicepconfig.json"), []byte("something else"), 0644) + // Pre-create file + err := os.WriteFile(filepath.Join(directory, "bicepconfig.json"), []byte("something else"), 0644) require.NoError(t, err) - err = ScaffoldApplication(directory) + existed, err := ScaffoldBicepConfig(directory) require.NoError(t, err) + require.True(t, existed) - require.FileExists(t, filepath.Join(directory, "app.bicep")) - - b, err := os.ReadFile(filepath.Join(directory, "app.bicep")) + b, err := os.ReadFile(filepath.Join(directory, "bicepconfig.json")) require.NoError(t, err) require.Equal(t, "something else", string(b)) +} + +func Test_ScaffoldBicepConfig_DoesNotCreateAppBicep(t *testing.T) { + directory := t.TempDir() - b, err = os.ReadFile(filepath.Join(directory, "bicepconfig.json")) + _, err := ScaffoldBicepConfig(directory) require.NoError(t, err) - require.Equal(t, "something else", string(b)) + + require.NoFileExists(t, filepath.Join(directory, "app.bicep")) +} + +func Test_ScaffoldBicepConfig_WriteFileError(t *testing.T) { + // Pointing at a non-existent parent directory makes os.WriteFile fail + // while os.Stat returns IsNotExist, which exercises the WriteFile + // error branch in ScaffoldBicepConfig. + directory := filepath.Join(t.TempDir(), "does-not-exist") + + existed, err := ScaffoldBicepConfig(directory) + require.Error(t, err) + require.False(t, existed) +} + +func Test_ScaffoldBicepConfig_StatError(t *testing.T) { + // Create a regular file and use it as the "directory" argument. The + // resulting Stat call on "/bicepconfig.json" returns ENOTDIR + // (which is not IsNotExist), exercising the third branch of + // ScaffoldBicepConfig where Stat returns a non-NotExist error. + parent := t.TempDir() + notADir := filepath.Join(parent, "not-a-dir") + require.NoError(t, os.WriteFile(notADir, []byte("file"), 0644)) + + existed, err := ScaffoldBicepConfig(notADir) + require.Error(t, err) + require.False(t, existed) } diff --git a/pkg/controller/reconciler/deployment_reconciler_test.go b/pkg/controller/reconciler/deployment_reconciler_test.go index ee7dea0112..514a2c8b52 100644 --- a/pkg/controller/reconciler/deployment_reconciler_test.go +++ b/pkg/controller/reconciler/deployment_reconciler_test.go @@ -59,7 +59,6 @@ func SetupDeploymentTest(t *testing.T) (*mockRadiusClient, client.Client) { // Shut down the manager when the test exits. ctx, cancel := testcontext.NewWithCancel(t) - t.Cleanup(cancel) mgr, err := ctrl.NewManager(config, ctrl.Options{ Scheme: scheme, @@ -85,13 +84,19 @@ func SetupDeploymentTest(t *testing.T) (*mockRadiusClient, client.Client) { }).SetupWithManager(mgr) require.NoError(t, err) + managerDone := make(chan error, 1) go func() { - // Cannot use require/assert here - accessing testing.T from a non-test goroutine causes a data race. - if err := mgr.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { - panic(fmt.Sprintf("manager exited with error: %v", err)) - } + managerDone <- mgr.Start(ctx) }() + t.Cleanup(func() { + err := <-managerDone + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + require.NoError(t, err) + } + }) + t.Cleanup(cancel) + return radius, mgr.GetClient() } diff --git a/pkg/controller/reconciler/deploymentresource_reconciler_test.go b/pkg/controller/reconciler/deploymentresource_reconciler_test.go index bcd961d9a9..56de5fded4 100644 --- a/pkg/controller/reconciler/deploymentresource_reconciler_test.go +++ b/pkg/controller/reconciler/deploymentresource_reconciler_test.go @@ -64,7 +64,6 @@ func SetupDeploymentResourceTest(t *testing.T) (*mockRadiusClient, *sdkclients.M // Shut down the manager when the test exits. ctx, cancel := testcontext.NewWithCancel(t) - t.Cleanup(cancel) mgr, err := ctrl.NewManager(config, ctrl.Options{ Scheme: scheme, @@ -93,13 +92,19 @@ func SetupDeploymentResourceTest(t *testing.T) (*mockRadiusClient, *sdkclients.M }).SetupWithManager(mgr) require.NoError(t, err) + managerDone := make(chan error, 1) go func() { - // Cannot use require/assert here - accessing testing.T from a non-test goroutine causes a data race. - if err := mgr.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { - panic(fmt.Sprintf("manager exited with error: %v", err)) - } + managerDone <- mgr.Start(ctx) }() + t.Cleanup(func() { + err := <-managerDone + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + require.NoError(t, err) + } + }) + t.Cleanup(cancel) + return mockRadiusClient, mockResourceDeploymentsClient, mgr.GetClient() } diff --git a/pkg/controller/reconciler/deploymenttemplate_reconciler_test.go b/pkg/controller/reconciler/deploymenttemplate_reconciler_test.go index 4ee755bd87..e0be6f52f0 100644 --- a/pkg/controller/reconciler/deploymenttemplate_reconciler_test.go +++ b/pkg/controller/reconciler/deploymenttemplate_reconciler_test.go @@ -20,7 +20,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "os" "path" "testing" @@ -60,7 +59,6 @@ func SetupDeploymentTemplateTest(t *testing.T) (*mockRadiusClient, *sdkclients.M // Shut down the manager when the test exits. ctx, cancel := testcontext.NewWithCancel(t) - t.Cleanup(cancel) mgr, err := ctrl.NewManager(config, ctrl.Options{ Scheme: scheme, @@ -102,13 +100,19 @@ func SetupDeploymentTemplateTest(t *testing.T) (*mockRadiusClient, *sdkclients.M }).SetupWithManager(mgr) require.NoError(t, err) + managerDone := make(chan error, 1) go func() { - // Cannot use require/assert here - accessing testing.T from a non-test goroutine causes a data race. - if err := mgr.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { - panic(fmt.Sprintf("manager exited with error: %v", err)) - } + managerDone <- mgr.Start(ctx) }() + t.Cleanup(func() { + err := <-managerDone + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + require.NoError(t, err) + } + }) + t.Cleanup(cancel) + return mockRadiusClient, mockResourceDeploymentsClient, mgr.GetClient() } diff --git a/pkg/controller/reconciler/recipe_reconciler_test.go b/pkg/controller/reconciler/recipe_reconciler_test.go index d5f7fa6711..0193aac770 100644 --- a/pkg/controller/reconciler/recipe_reconciler_test.go +++ b/pkg/controller/reconciler/recipe_reconciler_test.go @@ -19,7 +19,6 @@ package reconciler import ( "context" "errors" - "fmt" "testing" "github.com/radius-project/radius/pkg/cli/clients_new/generated" @@ -47,7 +46,6 @@ func SetupRecipeTest(t *testing.T) (*mockRadiusClient, client.Client) { // Shut down the manager when the test exits. ctx, cancel := testcontext.NewWithCancel(t) - t.Cleanup(cancel) mgr, err := ctrl.NewManager(config, ctrl.Options{ Scheme: scheme, @@ -73,13 +71,19 @@ func SetupRecipeTest(t *testing.T) (*mockRadiusClient, client.Client) { }).SetupWithManager(mgr) require.NoError(t, err) + managerDone := make(chan error, 1) go func() { - // Cannot use require/assert here - accessing testing.T from a non-test goroutine causes a data race. - if err := mgr.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { - panic(fmt.Sprintf("manager exited with error: %v", err)) - } + managerDone <- mgr.Start(ctx) }() + t.Cleanup(func() { + err := <-managerDone + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + require.NoError(t, err) + } + }) + t.Cleanup(cancel) + return radius, mgr.GetClient() } diff --git a/pkg/controller/reconciler/recipe_webhook_test.go b/pkg/controller/reconciler/recipe_webhook_test.go index a0231dc233..80d070af4b 100644 --- a/pkg/controller/reconciler/recipe_webhook_test.go +++ b/pkg/controller/reconciler/recipe_webhook_test.go @@ -258,7 +258,6 @@ func setupWebhookTest(t *testing.T) (*mockRadiusClient, client.Client) { // Shut down the manager when the test exits. ctx, cancel := testcontext.NewWithCancel(t) - t.Cleanup(cancel) mgr, err := ctrl.NewManager(config, ctrl.Options{ Scheme: scheme, @@ -288,13 +287,19 @@ func setupWebhookTest(t *testing.T) (*mockRadiusClient, client.Client) { err = (&RecipeWebhook{}).SetupWebhookWithManager(mgr) require.NoError(t, err) + managerDone := make(chan error, 1) go func() { - // Cannot use require/assert here - accessing testing.T from a non-test goroutine causes a data race. - if err := mgr.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { - panic(fmt.Sprintf("manager exited with error: %v", err)) - } + managerDone <- mgr.Start(ctx) }() + t.Cleanup(func() { + err := <-managerDone + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + require.NoError(t, err) + } + }) + t.Cleanup(cancel) + // wait for the webhook server to get ready var dialErr error dialer := &net.Dialer{Timeout: time.Second}