From a98c235b94a96fc0f13d927ef6d0c133456247e5 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Thu, 12 Feb 2026 14:16:53 -0800 Subject: [PATCH 1/4] handle bicep missing inputs --- cli/azd/cmd/middleware/ux.go | 8 + .../internal/cmd/mocks_azdinput_test.go | 14 + .../provisioning/bicep/bicep_provider.go | 5 + .../provisioning/bicep/missing_inputs.go | 219 +++++++++++++ .../provisioning/bicep/missing_inputs_test.go | 309 ++++++++++++++++++ cli/azd/pkg/input/console.go | 6 + cli/azd/test/mocks/mockinput/mock_console.go | 4 + 7 files changed, 565 insertions(+) create mode 100644 cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go create mode 100644 cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index 793ad969fe3..97dec0f1396 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -108,6 +108,14 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes } m.console.Message(ctx, errMessage) + + // Print out additional text for errors that have it. + var uxItemErr ux.UxItem + if errors.As(err, &uxItemErr) { + m.console.Message(ctx, "") + m.console.MessageUxItem(ctx, uxItemErr) + return actionResult, err + } } if actionResult != nil && actionResult.Message != nil { diff --git a/cli/azd/extensions/azure.coding-agent/internal/cmd/mocks_azdinput_test.go b/cli/azd/extensions/azure.coding-agent/internal/cmd/mocks_azdinput_test.go index 898cf6e0eb4..63c08318497 100644 --- a/cli/azd/extensions/azure.coding-agent/internal/cmd/mocks_azdinput_test.go +++ b/cli/azd/extensions/azure.coding-agent/internal/cmd/mocks_azdinput_test.go @@ -335,6 +335,20 @@ func (mr *MockConsoleMockRecorder) StopSpinner(ctx, lastMessage, format any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StopSpinner", reflect.TypeOf((*MockConsole)(nil).StopSpinner), ctx, lastMessage, format) } +// IsNoPromptMode mocks base method. +func (m *MockConsole) IsNoPromptMode() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsNoPromptMode") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsNoPromptMode indicates an expected call of IsNoPromptMode. +func (mr *MockConsoleMockRecorder) IsNoPromptMode() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsNoPromptMode", reflect.TypeOf((*MockConsole)(nil).IsNoPromptMode)) +} + // SupportsPromptDialog mocks base method. func (m *MockConsole) SupportsPromptDialog() bool { m.ctrl.T.Helper() diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index bbdb434fa85..5ef5fa587f8 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -2356,6 +2356,11 @@ func (p *BicepProvider) ensureParameters( }{key: key, param: param}) } + // If in no-prompt mode and there are missing parameters, return an error with all missing inputs + if len(parameterPrompts) > 0 && p.console.IsNoPromptMode() { + return nil, p.buildMissingInputsError(parameterPrompts, parametersResult.envMapping) + } + if len(parameterPrompts) > 0 { if p.console.SupportsPromptDialog() { diff --git a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go new file mode 100644 index 00000000000..1af9204326a --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azure" +) + +// InputConstraints captures the numeric/length constraints from an ARM parameter definition. +type InputConstraints struct { + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + MinValue *int `json:"minValue,omitempty"` + MaxValue *int `json:"maxValue,omitempty"` +} + +// MissingInput represents a missing required input for infrastructure provisioning. +type MissingInput struct { + Name string `json:"name"` + Type string `json:"type"` + Secure bool `json:"secure"` + Description string `json:"description"` + EnvVarNames []string `json:"envVarNames"` + ConfigKey string `json:"configKey"` + AllowedValues []string `json:"allowedValues"` + Constraints InputConstraints `json:"constraints"` +} + +// MissingInputsError is an error that contains information about all missing required inputs. +type MissingInputsError struct { + Inputs []MissingInput +} + +// Error implements the error interface for MissingInputsError. +func (e *MissingInputsError) Error() string { + return "missing required inputs" +} + +// ToString returns a formatted message with the missing inputs and resolution guidance. +func (e *MissingInputsError) ToString(currentIndentation string) string { + var buf strings.Builder + separator := "──────────────────────────────────────────────────────────────" + + buf.WriteString(separator + "\n") + buf.WriteString("Provision cannot continue (interactive prompts disabled)\n") + buf.WriteString(separator + "\n\n") + + count := len(e.Inputs) + if count == 1 { + buf.WriteString("1 required input is missing.\n") + } else { + buf.WriteString(fmt.Sprintf("%d required inputs are missing.\n", count)) + } + + buf.WriteString("\nMissing required inputs:\n\n") + + for _, input := range e.Inputs { + buf.WriteString(fmt.Sprintf("• %s\n", input.Name)) + + if len(input.EnvVarNames) > 0 { + buf.WriteString(fmt.Sprintf(" Environment variable: %s\n", strings.Join(input.EnvVarNames, ", "))) + } + + buf.WriteString(fmt.Sprintf(" Environment configuration key: %s\n", input.ConfigKey)) + + if input.Type != "" { + buf.WriteString(fmt.Sprintf(" Type: %s\n", input.Type)) + } + + details := constraintDetails(input) + if len(details) > 0 { + buf.WriteString(" Constraints:\n") + for _, detail := range details { + buf.WriteString(fmt.Sprintf(" %s\n", detail)) + } + } + + if input.Description != "" { + buf.WriteString(fmt.Sprintf(" Description: %s\n", input.Description)) + } + + buf.WriteString("\n") + } + + buf.WriteString(separator + "\n\n") + buf.WriteString("You can resolve these by:\n\n") + + if e.hasEnvVars() { + buf.WriteString("1) Setting environment variables\n") + buf.WriteString(" azd env set \n\n") + } + + buf.WriteString("2) Setting environment configuration\n") + buf.WriteString(" azd env config set infra.parameters. \n\n") + + buf.WriteString("Then re-run:\n") + buf.WriteString(" azd provision\n") + + return buf.String() +} + +// constraintDetails returns human-readable constraint lines for text output. +func constraintDetails(input MissingInput) []string { + var details []string + + if len(input.AllowedValues) > 0 { + details = append(details, fmt.Sprintf("Allowed values: %s", strings.Join(input.AllowedValues, ", "))) + } + + c := input.Constraints + if c.MinLength != nil && c.MaxLength != nil { + details = append(details, fmt.Sprintf("Length: %d–%d", *c.MinLength, *c.MaxLength)) + } else if c.MinLength != nil { + details = append(details, fmt.Sprintf("Min length: %d", *c.MinLength)) + } else if c.MaxLength != nil { + details = append(details, fmt.Sprintf("Max length: %d", *c.MaxLength)) + } + + if c.MinValue != nil && c.MaxValue != nil { + details = append(details, fmt.Sprintf("Value: %d–%d", *c.MinValue, *c.MaxValue)) + } else if c.MinValue != nil { + details = append(details, fmt.Sprintf("Min value: %d", *c.MinValue)) + } else if c.MaxValue != nil { + details = append(details, fmt.Sprintf("Max value: %d", *c.MaxValue)) + } + + if input.Secure { + details = append(details, "Secure: true") + } + + return details +} + +// hasEnvVars returns true if at least one input has environment variable mappings. +func (e *MissingInputsError) hasEnvVars() bool { + for _, input := range e.Inputs { + if len(input.EnvVarNames) > 0 { + return true + } + } + return false +} + +// MarshalJSON implements json.Marshaler for MissingInputsError. +func (e *MissingInputsError) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Error string `json:"error"` + Message string `json:"message"` + Inputs []MissingInput `json:"inputs"` + }{ + Error: e.Error(), + Message: "Provision cannot continue (interactive prompts disabled)", + Inputs: e.Inputs, + }) +} + +// buildMissingInputsError creates a MissingInputsError from parameter prompts and environment mapping. +func (p *BicepProvider) buildMissingInputsError( + parameterPrompts []struct { + key string + param azure.ArmTemplateParameterDefinition + }, + envMapping map[string][]string, +) *MissingInputsError { + var inputs []MissingInput + + for _, prompt := range parameterPrompts { + param := prompt.param + + // Normalize type for display (securestring → string, secureobject → object) + displayType := param.Type + if strings.EqualFold(displayType, "securestring") { + displayType = "string" + } else if strings.EqualFold(displayType, "secureobject") { + displayType = "object" + } + + // Get description if available + description := "" + if desc, ok := param.Description(); ok { + description = desc + } + + // Get allowed values if specified + var allowedValues []string + if param.AllowedValues != nil { + for _, val := range *param.AllowedValues { + allowedValues = append(allowedValues, fmt.Sprintf("%v", val)) + } + } + + input := MissingInput{ + Name: prompt.key, + Type: displayType, + Secure: param.Secure(), + Description: description, + EnvVarNames: envMapping[prompt.key], + ConfigKey: fmt.Sprintf("infra.parameters.%s", prompt.key), + AllowedValues: allowedValues, + Constraints: InputConstraints{ + MinLength: param.MinLength, + MaxLength: param.MaxLength, + MinValue: param.MinValue, + MaxValue: param.MaxValue, + }, + } + + inputs = append(inputs, input) + } + + return &MissingInputsError{ + Inputs: inputs, + } +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go new file mode 100644 index 00000000000..99ba3a47f52 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go @@ -0,0 +1,309 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "encoding/json" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azure" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMissingInputsError_Error_SingleInput(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "location", + Type: "string", + EnvVarNames: []string{"AZURE_LOCATION"}, + ConfigKey: "infra.parameters.location", + AllowedValues: []string{"eastus", "westus"}, + Description: "The Azure region for resources", + Secure: false, + }, + }, + } + + assert.Equal(t, "missing required inputs", err.Error()) + + output := err.ToString("") + + assert.Contains(t, output, "Provision cannot continue (interactive prompts disabled)") + assert.Contains(t, output, "1 required input is missing") + assert.Contains(t, output, "• location") + assert.Contains(t, output, "Environment variable: AZURE_LOCATION") + assert.Contains(t, output, "Environment configuration key: infra.parameters.location") + assert.Contains(t, output, "Type: string") + assert.Contains(t, output, "Allowed values: eastus, westus") + assert.Contains(t, output, "Description: The Azure region for resources") + assert.Contains(t, output, "You can resolve these by:") + assert.Contains(t, output, "azd env set") + assert.Contains(t, output, "azd env config set") + assert.Contains(t, output, "azd provision") +} + +func TestMissingInputsError_Error_MultipleInputs(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "location", + Type: "string", + EnvVarNames: []string{"AZURE_LOCATION"}, + ConfigKey: "infra.parameters.location", + }, + { + Name: "apiKey", + Type: "string", + EnvVarNames: []string{"API_KEY"}, + ConfigKey: "infra.parameters.apiKey", + Secure: true, + }, + }, + } + + output := err.ToString("") + + assert.Contains(t, output, "2 required inputs are missing") + assert.Contains(t, output, "• location") + assert.Contains(t, output, "• apiKey") + assert.Contains(t, output, "Environment variable: AZURE_LOCATION") + assert.Contains(t, output, "Environment variable: API_KEY") +} + +func TestMissingInputsError_Error_NoEnvVars(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "param1", + Type: "string", + ConfigKey: "infra.parameters.param1", + }, + }, + } + + output := err.ToString("") + + assert.NotContains(t, output, "1) Setting environment variables") + assert.Contains(t, output, "2) Setting environment configuration") +} + +func TestConstraintDetails(t *testing.T) { + tests := []struct { + name string + input MissingInput + expected []string + }{ + { + name: "AllowedValues", + input: MissingInput{ + AllowedValues: []string{"a", "b", "c"}, + }, + expected: []string{"Allowed values: a, b, c"}, + }, + { + name: "MinAndMaxLength", + input: MissingInput{ + Constraints: InputConstraints{MinLength: intPtr(5), MaxLength: intPtr(20)}, + }, + expected: []string{"Length: 5–20"}, + }, + { + name: "MinLengthOnly", + input: MissingInput{ + Constraints: InputConstraints{MinLength: intPtr(3)}, + }, + expected: []string{"Min length: 3"}, + }, + { + name: "MaxLengthOnly", + input: MissingInput{ + Constraints: InputConstraints{MaxLength: intPtr(100)}, + }, + expected: []string{"Max length: 100"}, + }, + { + name: "MinAndMaxValue", + input: MissingInput{ + Constraints: InputConstraints{MinValue: intPtr(1), MaxValue: intPtr(100)}, + }, + expected: []string{"Value: 1–100"}, + }, + { + name: "Secure", + input: MissingInput{ + Secure: true, + }, + expected: []string{"Secure: true"}, + }, + { + name: "NoConstraints", + input: MissingInput{}, + expected: nil, + }, + { + name: "AllConstraints", + input: MissingInput{ + AllowedValues: []string{"x"}, + Secure: true, + Constraints: InputConstraints{MinLength: intPtr(1), MaxLength: intPtr(50)}, + }, + expected: []string{"Allowed values: x", "Length: 1–50", "Secure: true"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := constraintDetails(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestBuildConstraints_SecureType(t *testing.T) { + param := azure.ArmTemplateParameterDefinition{ + Type: "securestring", + } + + prompts := []struct { + key string + param azure.ArmTemplateParameterDefinition + }{{key: "secret", param: param}} + + p := &BicepProvider{} + result := p.buildMissingInputsError(prompts, nil) + + require.Len(t, result.Inputs, 1) + assert.Equal(t, "string", result.Inputs[0].Type) + assert.True(t, result.Inputs[0].Secure) +} + +func TestBuildConstraints_SecureObjectType(t *testing.T) { + param := azure.ArmTemplateParameterDefinition{ + Type: "secureobject", + } + + prompts := []struct { + key string + param azure.ArmTemplateParameterDefinition + }{{key: "obj", param: param}} + + p := &BicepProvider{} + result := p.buildMissingInputsError(prompts, nil) + + require.Len(t, result.Inputs, 1) + assert.Equal(t, "object", result.Inputs[0].Type) + assert.True(t, result.Inputs[0].Secure) +} + +func TestMissingInputsError_Error_WithAllDetails(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "resourceGroupLocation", + Type: "string", + EnvVarNames: []string{"AZURE_LOCATION", "AZURE_DEPLOYMENT_REGION"}, + ConfigKey: "infra.parameters.resourceGroupLocation", + AllowedValues: []string{"eastus", "westus", "centralus"}, + Description: "Location for all resources", + Constraints: InputConstraints{MinLength: intPtr(1), MaxLength: intPtr(50)}, + }, + { + Name: "storageAccountKey", + Type: "string", + EnvVarNames: []string{"STORAGE_KEY"}, + ConfigKey: "infra.parameters.storageAccountKey", + Description: "Storage account access key", + Secure: true, + }, + }, + } + + output := err.ToString("") + + assert.Contains(t, output, "Provision cannot continue (interactive prompts disabled)") + assert.Contains(t, output, "2 required inputs are missing") + + assert.Contains(t, output, "• resourceGroupLocation") + assert.Contains(t, output, "Environment variable: AZURE_LOCATION, AZURE_DEPLOYMENT_REGION") + assert.Contains(t, output, "Environment configuration key: infra.parameters.resourceGroupLocation") + assert.Contains(t, output, "Allowed values: eastus, westus, centralus") + assert.Contains(t, output, "Length: 1–50") + + assert.Contains(t, output, "• storageAccountKey") + assert.Contains(t, output, "Environment variable: STORAGE_KEY") + assert.Contains(t, output, "Secure: true") + + assert.Contains(t, output, "You can resolve these by:") + assert.Contains(t, output, "1) Setting environment variables") + assert.Contains(t, output, "2) Setting environment configuration") + assert.Contains(t, output, "Then re-run:") + assert.Contains(t, output, "azd provision") +} + +func TestMissingInputsError_MarshalJSON(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "location", + Type: "string", + EnvVarNames: []string{"AZURE_LOCATION"}, + ConfigKey: "infra.parameters.location", + Description: "The Azure region", + Constraints: InputConstraints{MinLength: intPtr(1)}, + }, + }, + } + + jsonData, marshalErr := err.MarshalJSON() + require.NoError(t, marshalErr) + + var result struct { + Error string `json:"error"` + Message string `json:"message"` + Inputs []MissingInput `json:"inputs"` + } + require.NoError(t, json.Unmarshal(jsonData, &result)) + + assert.Equal(t, "missing required inputs", result.Error) + assert.Equal(t, "Provision cannot continue (interactive prompts disabled)", result.Message) + require.Len(t, result.Inputs, 1) + + input := result.Inputs[0] + assert.Equal(t, "location", input.Name) + assert.Equal(t, "string", input.Type) + assert.Equal(t, "infra.parameters.location", input.ConfigKey) + assert.Equal(t, []string{"AZURE_LOCATION"}, input.EnvVarNames) + assert.Equal(t, "The Azure region", input.Description) + require.NotNil(t, input.Constraints.MinLength) + assert.Equal(t, 1, *input.Constraints.MinLength) + assert.Nil(t, input.Constraints.MaxLength) +} + +func TestMissingInputsError_MarshalJSON_OmitsEmptyConstraints(t *testing.T) { + err := &MissingInputsError{ + Inputs: []MissingInput{ + { + Name: "flag", + Type: "bool", + ConfigKey: "infra.parameters.flag", + }, + }, + } + + jsonData, marshalErr := err.MarshalJSON() + require.NoError(t, marshalErr) + + // Verify that constraint fields with zero values are omitted + raw := string(jsonData) + assert.NotContains(t, raw, "minLength") + assert.NotContains(t, raw, "maxLength") + assert.NotContains(t, raw, "minValue") + assert.NotContains(t, raw, "maxValue") +} + +func intPtr(v int) *int { + return &v +} diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 1356ffc1670..6d1e224637e 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -107,6 +107,8 @@ type Console interface { // If false, the spinner is non-interactive, which means messages are rendered as a new console message on each // call to ShowSpinner, even when the title is unchanged. IsSpinnerInteractive() bool + // IsNoPromptMode returns true when --no-prompt is active and interactive prompts are disabled. + IsNoPromptMode() bool SupportsPromptDialog() bool PromptDialog(ctx context.Context, dialog PromptDialog) (map[string]any, error) // Prompts the user for a single value @@ -558,6 +560,10 @@ func promptFromOptions(options ConsoleOptions) survey.Prompt { // 0 in the sentinel), followed by a new line. const afterIoSentinel = "0\n" +func (c *AskerConsole) IsNoPromptMode() bool { + return c.noPrompt +} + func (c *AskerConsole) SupportsPromptDialog() bool { return c.promptClient != nil && !c.noPromptDialog } diff --git a/cli/azd/test/mocks/mockinput/mock_console.go b/cli/azd/test/mocks/mockinput/mock_console.go index 861cfdbe90a..1ed29867378 100644 --- a/cli/azd/test/mocks/mockinput/mock_console.go +++ b/cli/azd/test/mocks/mockinput/mock_console.go @@ -138,6 +138,10 @@ func (c *MockConsole) WaitForEnter() { func (c *MockConsole) EnsureBlankLine(context context.Context) { } +func (c *MockConsole) IsNoPromptMode() bool { + return false +} + func (c *MockConsole) SupportsPromptDialog() bool { return false } From abca73cf3fdecd79d075ec7f4e9f535db6ab0c5e Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 2 Mar 2026 12:07:16 -0800 Subject: [PATCH 2/4] add guidance for init flow --- cli/azd/cmd/init.go | 66 +++++++++++++++++++ cli/azd/cmd/init_test.go | 45 +++++++++++++ .../testdata/samples/funcapp/infra/main.bicep | 9 +++ .../funcapp/infra/main.parameters.json | 3 + cli/azd/test/mocks/mockinput/mock_console.go | 7 +- 5 files changed, 129 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index e288d14f7b5..748558fa4e2 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -263,6 +264,8 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { if existingProject { // only initialize environment when no mode is set explicitly initTypeSelect = initEnvironment + } else if i.console.IsNoPromptMode() { + return nil, &initModeRequiredError{} } else { // Prompt for init type for new projects initTypeSelect, err = promptInitType(i.console, ctx, i.featuresManager, i.configManager) @@ -880,3 +883,66 @@ func getCmdInitHelpFooter(*cobra.Command) string { ), }) } + +// initModeRequiredError is returned when azd init requires interactive prompts for initialization mode +// but --no-prompt is set. +type initModeRequiredError struct{} + +func (e *initModeRequiredError) Error() string { + return "initialization mode required when --no-prompt is set" +} + +func (e *initModeRequiredError) ToString(currentIndentation string) string { + var buf strings.Builder + separator := "──────────────────────────────────────────────────────────────" + + buf.WriteString(separator + "\n") + buf.WriteString("Init cannot continue (interactive prompts disabled)\n") + buf.WriteString(separator + "\n\n") + + buf.WriteString("azd init requires an initialization mode when --no-prompt is set.\n\n") + + buf.WriteString("Choose one:\n\n") + + buf.WriteString(" • Minimal (no template)\n") + buf.WriteString(" Creates required azd project files in the current directory.\n") + buf.WriteString(" azd init --minimal\n\n") + + buf.WriteString(" • From template\n") + buf.WriteString(" Creates a new project from an azd template.\n") + buf.WriteString(" azd template list\n") + buf.WriteString(" azd init --template --environment \n\n") + + buf.WriteString("Environment name must be globally unique (for example: myapp-dev).\n") + + return buf.String() +} + +func (e *initModeRequiredError) MarshalJSON() ([]byte, error) { + return json.Marshal(struct { + Error string `json:"error"` + Message string `json:"message"` + Options []initModeRequiredErrorOptions `json:"options"` + }{ + Error: e.Error(), + Message: "Init cannot continue (interactive prompts disabled)", + Options: []initModeRequiredErrorOptions{ + { + Name: "minimal", + Description: "Creates required azd project files in the current directory.", + Command: "azd init --minimal", + }, + { + Name: "template", + Description: "Creates a new project from an azd template.", + Command: "azd init --template --environment ", + }, + }, + }) +} + +type initModeRequiredErrorOptions struct { + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` +} diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go index 78939fecaa9..58b9dbaf06d 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -68,6 +69,50 @@ func runActionSafe(ctx context.Context, action *initAction) (retErr error) { return err } +func TestInitNoPromptRequiresMode(t *testing.T) { + t.Run("ReturnsInitNoPromptErrorWhenNoMode", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + + flags := &initFlags{ + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + action := setupInitAction(t, mockContext, flags) + + result, err := action.Run(*mockContext.Context) + require.Error(t, err) + require.Nil(t, result) + + var noPromptErr *initModeRequiredError + require.ErrorAs(t, err, &noPromptErr) + + output := noPromptErr.ToString("") + require.Contains(t, output, "Init cannot continue (interactive prompts disabled)") + require.Contains(t, output, "azd init --minimal") + require.Contains(t, output, "azd init --template") + }) + + t.Run("DoesNotErrorWhenMinimalFlagSet", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + + flags := &initFlags{ + minimal: true, + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + + action := setupInitAction(t, mockContext, flags) + + err := runActionSafe(*mockContext.Context, action) + if err != nil { + var noPromptErr *initModeRequiredError + require.False(t, errors.As(err, &noPromptErr), + "should not return InitNoPromptError when --minimal is set") + } + }) +} + func TestInitFailFastMissingEnvNonInteractive(t *testing.T) { t.Run("FailsWhenNoPromptWithTemplateAndNoEnv", func(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) diff --git a/cli/azd/test/functional/testdata/samples/funcapp/infra/main.bicep b/cli/azd/test/functional/testdata/samples/funcapp/infra/main.bicep index 463f8acc716..aa671aa1df9 100644 --- a/cli/azd/test/functional/testdata/samples/funcapp/infra/main.bicep +++ b/cli/azd/test/functional/testdata/samples/funcapp/infra/main.bicep @@ -6,6 +6,15 @@ param environmentName string @description('Primary location for all resources') param location string +param vnetEnabled bool + +@minLength(10) +param resourceGroupOverride string + +@sys.secure() +@maxLength(100) +param secure string + @description('A time to mark on created resource groups, so they can be cleaned up via an automated process.') param deleteAfterTime string = dateTimeAdd(utcNow('o'), 'PT1H') diff --git a/cli/azd/test/functional/testdata/samples/funcapp/infra/main.parameters.json b/cli/azd/test/functional/testdata/samples/funcapp/infra/main.parameters.json index 8f7787beb16..4e80334a279 100644 --- a/cli/azd/test/functional/testdata/samples/funcapp/infra/main.parameters.json +++ b/cli/azd/test/functional/testdata/samples/funcapp/infra/main.parameters.json @@ -7,6 +7,9 @@ }, "location": { "value": "${AZURE_LOCATION}" + }, + "resourceGroupOverride": { + "value": "${AZURE_RESOURCE_GROUP}" } } } \ No newline at end of file diff --git a/cli/azd/test/mocks/mockinput/mock_console.go b/cli/azd/test/mocks/mockinput/mock_console.go index 1ed29867378..a01abed1aa5 100644 --- a/cli/azd/test/mocks/mockinput/mock_console.go +++ b/cli/azd/test/mocks/mockinput/mock_console.go @@ -37,6 +37,7 @@ type MockConsole struct { expressions []*MockConsoleExpression log []string spinnerOps []SpinnerOp + noPrompt bool } func NewMockConsole() *MockConsole { @@ -138,8 +139,12 @@ func (c *MockConsole) WaitForEnter() { func (c *MockConsole) EnsureBlankLine(context context.Context) { } +func (c *MockConsole) SetNoPromptMode(noPrompt bool) { + c.noPrompt = noPrompt +} + func (c *MockConsole) IsNoPromptMode() bool { - return false + return c.noPrompt } func (c *MockConsole) SupportsPromptDialog() bool { From 05f253fdaef59115e24e2b57e167f340e7464cdb Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Tue, 3 Mar 2026 16:38:45 -0800 Subject: [PATCH 3/4] address feedback --- cli/azd/cmd/init.go | 39 +++++++++------ cli/azd/cmd/init_test.go | 20 ++++++++ .../provisioning/bicep/missing_inputs.go | 50 +++++++++++++------ .../provisioning/bicep/missing_inputs_test.go | 37 ++++++++------ 4 files changed, 99 insertions(+), 47 deletions(-) diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index 748558fa4e2..b6fc4009aac 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -900,8 +900,6 @@ func (e *initModeRequiredError) ToString(currentIndentation string) string { buf.WriteString("Init cannot continue (interactive prompts disabled)\n") buf.WriteString(separator + "\n\n") - buf.WriteString("azd init requires an initialization mode when --no-prompt is set.\n\n") - buf.WriteString("Choose one:\n\n") buf.WriteString(" • Minimal (no template)\n") @@ -920,22 +918,31 @@ func (e *initModeRequiredError) ToString(currentIndentation string) string { func (e *initModeRequiredError) MarshalJSON() ([]byte, error) { return json.Marshal(struct { - Error string `json:"error"` - Message string `json:"message"` - Options []initModeRequiredErrorOptions `json:"options"` + Code string `json:"code"` + Message string `json:"message"` + Details struct { + Type string `json:"type"` + Options []initModeRequiredErrorOptions `json:"options"` + } `json:"details"` }{ - Error: e.Error(), + Code: "initModeRequired", Message: "Init cannot continue (interactive prompts disabled)", - Options: []initModeRequiredErrorOptions{ - { - Name: "minimal", - Description: "Creates required azd project files in the current directory.", - Command: "azd init --minimal", - }, - { - Name: "template", - Description: "Creates a new project from an azd template.", - Command: "azd init --template --environment ", + Details: struct { + Type string `json:"type"` + Options []initModeRequiredErrorOptions `json:"options"` + }{ + Type: "initModeRequired", + Options: []initModeRequiredErrorOptions{ + { + Name: "minimal", + Description: "Creates required azd project files in the current directory.", + Command: "azd init --minimal", + }, + { + Name: "template", + Description: "Creates a new project from an azd template.", + Command: "azd init --template --environment ", + }, }, }, }) diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go index 58b9dbaf06d..9c6c54d9a3b 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -111,6 +111,26 @@ func TestInitNoPromptRequiresMode(t *testing.T) { "should not return InitNoPromptError when --minimal is set") } }) + + t.Run("DoesNotErrorWhenTemplateAndEnvironmentProvided", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + flags.EnvironmentName = "myenv" + + action := setupInitAction(t, mockContext, flags) + + err := runActionSafe(*mockContext.Context, action) + if err != nil { + var noPromptErr *initModeRequiredError + require.False(t, errors.As(err, &noPromptErr), + "should not return InitNoPromptError when --template and --environment are both set") + } + }) } func TestInitFailFastMissingEnvNonInteractive(t *testing.T) { diff --git a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go index 1af9204326a..347a7b94adc 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go +++ b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs.go @@ -21,14 +21,14 @@ type InputConstraints struct { // MissingInput represents a missing required input for infrastructure provisioning. type MissingInput struct { - Name string `json:"name"` - Type string `json:"type"` - Secure bool `json:"secure"` - Description string `json:"description"` - EnvVarNames []string `json:"envVarNames"` - ConfigKey string `json:"configKey"` - AllowedValues []string `json:"allowedValues"` - Constraints InputConstraints `json:"constraints"` + Name string `json:"name"` + Type string `json:"type"` + Secure bool `json:"secure"` + Description string `json:"description,omitempty"` + EnvVarNames []string `json:"envVarNames,omitempty"` + ConfigKey string `json:"configKey,omitempty"` + AllowedValues []string `json:"allowedValues,omitempty"` + Constraints *InputConstraints `json:"constraints,omitempty"` } // MissingInputsError is an error that contains information about all missing required inputs. @@ -90,12 +90,14 @@ func (e *MissingInputsError) ToString(currentIndentation string) string { buf.WriteString(separator + "\n\n") buf.WriteString("You can resolve these by:\n\n") + optionNum := 1 if e.hasEnvVars() { - buf.WriteString("1) Setting environment variables\n") + buf.WriteString(fmt.Sprintf("%d) Setting environment variables\n", optionNum)) buf.WriteString(" azd env set \n\n") + optionNum = 2 } - buf.WriteString("2) Setting environment configuration\n") + buf.WriteString(fmt.Sprintf("%d) Setting environment configuration\n", optionNum)) buf.WriteString(" azd env config set infra.parameters. \n\n") buf.WriteString("Then re-run:\n") @@ -113,6 +115,13 @@ func constraintDetails(input MissingInput) []string { } c := input.Constraints + if c == nil { + if input.Secure { + details = append(details, "Secure: true") + } + return details + } + if c.MinLength != nil && c.MaxLength != nil { details = append(details, fmt.Sprintf("Length: %d–%d", *c.MinLength, *c.MaxLength)) } else if c.MinLength != nil { @@ -149,13 +158,22 @@ func (e *MissingInputsError) hasEnvVars() bool { // MarshalJSON implements json.Marshaler for MissingInputsError. func (e *MissingInputsError) MarshalJSON() ([]byte, error) { return json.Marshal(struct { - Error string `json:"error"` - Message string `json:"message"` - Inputs []MissingInput `json:"inputs"` + Code string `json:"code"` + Message string `json:"message"` + Details struct { + Type string `json:"type"` + Inputs []MissingInput `json:"inputs"` + } `json:"details"` }{ - Error: e.Error(), + Code: "provisionMissingInputs", Message: "Provision cannot continue (interactive prompts disabled)", - Inputs: e.Inputs, + Details: struct { + Type string `json:"type"` + Inputs []MissingInput `json:"inputs"` + }{ + Type: "provisionMissingInputs", + Inputs: e.Inputs, + }, }) } @@ -202,7 +220,7 @@ func (p *BicepProvider) buildMissingInputsError( EnvVarNames: envMapping[prompt.key], ConfigKey: fmt.Sprintf("infra.parameters.%s", prompt.key), AllowedValues: allowedValues, - Constraints: InputConstraints{ + Constraints: &InputConstraints{ MinLength: param.MinLength, MaxLength: param.MaxLength, MinValue: param.MinValue, diff --git a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go index 99ba3a47f52..6fca9c6264d 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go @@ -87,7 +87,8 @@ func TestMissingInputsError_Error_NoEnvVars(t *testing.T) { output := err.ToString("") assert.NotContains(t, output, "1) Setting environment variables") - assert.Contains(t, output, "2) Setting environment configuration") + assert.Contains(t, output, "1) Setting environment configuration") + assert.NotContains(t, output, "2) Setting environment configuration") } func TestConstraintDetails(t *testing.T) { @@ -106,28 +107,28 @@ func TestConstraintDetails(t *testing.T) { { name: "MinAndMaxLength", input: MissingInput{ - Constraints: InputConstraints{MinLength: intPtr(5), MaxLength: intPtr(20)}, + Constraints: &InputConstraints{MinLength: intPtr(5), MaxLength: intPtr(20)}, }, expected: []string{"Length: 5–20"}, }, { name: "MinLengthOnly", input: MissingInput{ - Constraints: InputConstraints{MinLength: intPtr(3)}, + Constraints: &InputConstraints{MinLength: intPtr(3)}, }, expected: []string{"Min length: 3"}, }, { name: "MaxLengthOnly", input: MissingInput{ - Constraints: InputConstraints{MaxLength: intPtr(100)}, + Constraints: &InputConstraints{MaxLength: intPtr(100)}, }, expected: []string{"Max length: 100"}, }, { name: "MinAndMaxValue", input: MissingInput{ - Constraints: InputConstraints{MinValue: intPtr(1), MaxValue: intPtr(100)}, + Constraints: &InputConstraints{MinValue: intPtr(1), MaxValue: intPtr(100)}, }, expected: []string{"Value: 1–100"}, }, @@ -148,7 +149,7 @@ func TestConstraintDetails(t *testing.T) { input: MissingInput{ AllowedValues: []string{"x"}, Secure: true, - Constraints: InputConstraints{MinLength: intPtr(1), MaxLength: intPtr(50)}, + Constraints: &InputConstraints{MinLength: intPtr(1), MaxLength: intPtr(50)}, }, expected: []string{"Allowed values: x", "Length: 1–50", "Secure: true"}, }, @@ -208,7 +209,7 @@ func TestMissingInputsError_Error_WithAllDetails(t *testing.T) { ConfigKey: "infra.parameters.resourceGroupLocation", AllowedValues: []string{"eastus", "westus", "centralus"}, Description: "Location for all resources", - Constraints: InputConstraints{MinLength: intPtr(1), MaxLength: intPtr(50)}, + Constraints: &InputConstraints{MinLength: intPtr(1), MaxLength: intPtr(50)}, }, { Name: "storageAccountKey", @@ -252,7 +253,7 @@ func TestMissingInputsError_MarshalJSON(t *testing.T) { EnvVarNames: []string{"AZURE_LOCATION"}, ConfigKey: "infra.parameters.location", Description: "The Azure region", - Constraints: InputConstraints{MinLength: intPtr(1)}, + Constraints: &InputConstraints{MinLength: intPtr(1)}, }, }, } @@ -261,22 +262,27 @@ func TestMissingInputsError_MarshalJSON(t *testing.T) { require.NoError(t, marshalErr) var result struct { - Error string `json:"error"` - Message string `json:"message"` - Inputs []MissingInput `json:"inputs"` + Code string `json:"code"` + Message string `json:"message"` + Details struct { + Type string `json:"type"` + Inputs []MissingInput `json:"inputs"` + } `json:"details"` } require.NoError(t, json.Unmarshal(jsonData, &result)) - assert.Equal(t, "missing required inputs", result.Error) + assert.Equal(t, "missingInputs", result.Code) assert.Equal(t, "Provision cannot continue (interactive prompts disabled)", result.Message) - require.Len(t, result.Inputs, 1) + assert.Equal(t, "missingInputs", result.Details.Type) + require.Len(t, result.Details.Inputs, 1) - input := result.Inputs[0] + input := result.Details.Inputs[0] assert.Equal(t, "location", input.Name) assert.Equal(t, "string", input.Type) assert.Equal(t, "infra.parameters.location", input.ConfigKey) assert.Equal(t, []string{"AZURE_LOCATION"}, input.EnvVarNames) assert.Equal(t, "The Azure region", input.Description) + require.NotNil(t, input.Constraints) require.NotNil(t, input.Constraints.MinLength) assert.Equal(t, 1, *input.Constraints.MinLength) assert.Nil(t, input.Constraints.MaxLength) @@ -296,8 +302,9 @@ func TestMissingInputsError_MarshalJSON_OmitsEmptyConstraints(t *testing.T) { jsonData, marshalErr := err.MarshalJSON() require.NoError(t, marshalErr) - // Verify that constraint fields with zero values are omitted + // Verify that constraints key is omitted entirely when nil raw := string(jsonData) + assert.NotContains(t, raw, "constraints") assert.NotContains(t, raw, "minLength") assert.NotContains(t, raw, "maxLength") assert.NotContains(t, raw, "minValue") From bc4f2b90059a71908a31d944f41cfcafb66ce6ff Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Wed, 4 Mar 2026 10:37:31 -0800 Subject: [PATCH 4/4] update test --- .../pkg/infra/provisioning/bicep/missing_inputs_test.go | 4 ++-- .../functional/testdata/samples/funcapp/infra/main.bicep | 9 --------- .../testdata/samples/funcapp/infra/main.parameters.json | 3 --- 3 files changed, 2 insertions(+), 14 deletions(-) diff --git a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go index 6fca9c6264d..917e8839e74 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/missing_inputs_test.go @@ -271,9 +271,9 @@ func TestMissingInputsError_MarshalJSON(t *testing.T) { } require.NoError(t, json.Unmarshal(jsonData, &result)) - assert.Equal(t, "missingInputs", result.Code) + assert.Equal(t, "provisionMissingInputs", result.Code) assert.Equal(t, "Provision cannot continue (interactive prompts disabled)", result.Message) - assert.Equal(t, "missingInputs", result.Details.Type) + assert.Equal(t, "provisionMissingInputs", result.Details.Type) require.Len(t, result.Details.Inputs, 1) input := result.Details.Inputs[0] diff --git a/cli/azd/test/functional/testdata/samples/funcapp/infra/main.bicep b/cli/azd/test/functional/testdata/samples/funcapp/infra/main.bicep index aa671aa1df9..463f8acc716 100644 --- a/cli/azd/test/functional/testdata/samples/funcapp/infra/main.bicep +++ b/cli/azd/test/functional/testdata/samples/funcapp/infra/main.bicep @@ -6,15 +6,6 @@ param environmentName string @description('Primary location for all resources') param location string -param vnetEnabled bool - -@minLength(10) -param resourceGroupOverride string - -@sys.secure() -@maxLength(100) -param secure string - @description('A time to mark on created resource groups, so they can be cleaned up via an automated process.') param deleteAfterTime string = dateTimeAdd(utcNow('o'), 'PT1H') diff --git a/cli/azd/test/functional/testdata/samples/funcapp/infra/main.parameters.json b/cli/azd/test/functional/testdata/samples/funcapp/infra/main.parameters.json index 4e80334a279..8f7787beb16 100644 --- a/cli/azd/test/functional/testdata/samples/funcapp/infra/main.parameters.json +++ b/cli/azd/test/functional/testdata/samples/funcapp/infra/main.parameters.json @@ -7,9 +7,6 @@ }, "location": { "value": "${AZURE_LOCATION}" - }, - "resourceGroupOverride": { - "value": "${AZURE_RESOURCE_GROUP}" } } } \ No newline at end of file