From e2739df1332e1a285f0fea954cb4b2a0901e7468 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 9 Mar 2026 16:11:31 -0400 Subject: [PATCH 1/7] feat: add configurable deploy timeout via --timeout flag and azure.yaml Add a --timeout flag to azd deploy and a deployTimeout field in azure.yaml service configuration. Users can now control how long azd waits for a deployment to complete, addressing the 20-minute hardcoded default that was too long for simple pipeline deployments. Timeout resolution order: CLI flag > azure.yaml service config > default (1200s). Invalid values (zero or negative) return a clear error. Fixes #6555 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/testdata/TestFigSpec.ts | 9 + .../cmd/testdata/TestUsage-azd-deploy.snap | 1 + cli/azd/internal/cmd/deploy.go | 54 ++- cli/azd/internal/cmd/deploy_test.go | 454 ++++++++++++++++++ cli/azd/pkg/project/project_config_test.go | 20 + cli/azd/pkg/project/service_config.go | 2 + cli/azd/pkg/project/service_config_test.go | 80 +++ schemas/v1.0/azure.yaml.json | 5 + 8 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 cli/azd/internal/cmd/deploy_test.go diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 6dfda95136b..8fe2c4d698b 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -1554,6 +1554,15 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--timeout'], + description: 'Maximum time in seconds to wait for deployment to complete (default: 1200)', + args: [ + { + name: 'timeout', + }, + ], + }, ], args: { name: 'service', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-deploy.snap b/cli/azd/cmd/testdata/TestUsage-azd-deploy.snap index f56dde06875..1b47a9f55d4 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-deploy.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-deploy.snap @@ -12,6 +12,7 @@ Flags --all : Deploys all services that are listed in azure.yaml -e, --environment string : The name of the environment to use. --from-package string : Deploys the packaged service located at the provided path. Supports zipped file packages (file path) or container images (image tag). + --timeout int : Maximum time in seconds to wait for deployment to complete (default: 1200) Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index 4147b29d701..ace88c1ba62 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -35,11 +35,15 @@ import ( type DeployFlags struct { ServiceName string All bool + Timeout int fromPackage string + flagSet *pflag.FlagSet global *internal.GlobalCommandOptions *internal.EnvFlag } +const defaultDeployTimeoutSeconds = 1200 + func (d *DeployFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { d.BindNonCommon(local, global) d.bindCommon(local, global) @@ -63,6 +67,7 @@ func (d *DeployFlags) BindNonCommon( func (d *DeployFlags) bindCommon(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { d.EnvFlag = &internal.EnvFlag{} d.EnvFlag.Bind(local, global) + d.flagSet = local local.BoolVar( &d.All, @@ -77,6 +82,12 @@ func (d *DeployFlags) bindCommon(local *pflag.FlagSet, global *internal.GlobalCo //nolint:lll "Deploys the packaged service located at the provided path. Supports zipped file packages (file path) or container images (image tag).", ) + local.IntVar( + &d.Timeout, + "timeout", + defaultDeployTimeoutSeconds, + fmt.Sprintf("Maximum time in seconds to wait for deployment to complete (default: %d)", defaultDeployTimeoutSeconds), + ) } func (d *DeployFlags) SetCommon(envFlag *internal.EnvFlag) { @@ -92,11 +103,21 @@ func NewDeployFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) * func NewDeployFlagsFromEnvAndOptions(envFlag *internal.EnvFlag, global *internal.GlobalCommandOptions) *DeployFlags { return &DeployFlags{ + Timeout: defaultDeployTimeoutSeconds, EnvFlag: envFlag, global: global, } } +func (d *DeployFlags) timeoutChanged() bool { + if d.flagSet == nil { + return false + } + + timeoutFlag := d.flagSet.Lookup("timeout") + return timeoutFlag != nil && timeoutFlag.Changed +} + func NewDeployCmd() *cobra.Command { cmd := &cobra.Command{ Use: "deploy ", @@ -301,15 +322,23 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) return err } + deployTimeout, err := da.resolveDeployTimeout(svc) + if err != nil { + da.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return err + } + + deployCtx, cancel := context.WithTimeout(ctx, deployTimeout) deployResult, err := async.RunWithProgress( func(deployProgress project.ServiceProgress) { progressMessage := fmt.Sprintf("Deploying service %s (%s)", svc.Name, deployProgress.Message) da.console.ShowSpinner(ctx, progressMessage, input.Step) }, func(progress *async.Progress[project.ServiceProgress]) (*project.ServiceDeployResult, error) { - return da.serviceManager.Deploy(ctx, svc, serviceContext, progress) + return da.serviceManager.Deploy(deployCtx, svc, serviceContext, progress) }, ) + cancel() if err != nil { da.console.StopSpinner(ctx, stepMessage, input.StepFailed) @@ -377,6 +406,29 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) }, nil } +func (da *DeployAction) resolveDeployTimeout(serviceConfig *project.ServiceConfig) (time.Duration, error) { + if da.flags.timeoutChanged() { + if da.flags.Timeout <= 0 { + return 0, errors.New("invalid value for --timeout: must be greater than 0 seconds") + } + + return time.Duration(da.flags.Timeout) * time.Second, nil + } + + if serviceConfig.DeployTimeout != nil { + if *serviceConfig.DeployTimeout <= 0 { + return 0, fmt.Errorf( + "invalid deployTimeout for service '%s': must be greater than 0 seconds", + serviceConfig.Name, + ) + } + + return time.Duration(*serviceConfig.DeployTimeout) * time.Second, nil + } + + return time.Duration(defaultDeployTimeoutSeconds) * time.Second, nil +} + func GetCmdDeployHelpDescription(*cobra.Command) string { return generateCmdHelpDescription("Deploy application to Azure.", []string{ formatHelpNote( diff --git a/cli/azd/internal/cmd/deploy_test.go b/cli/azd/internal/cmd/deploy_test.go new file mode 100644 index 00000000000..57bc7fcf2e3 --- /dev/null +++ b/cli/azd/internal/cmd/deploy_test.go @@ -0,0 +1,454 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "io" + "reflect" + "strconv" + "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/async" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestDeployFlagsTimeoutFlag(t *testing.T) { + cmd := NewDeployCmd() + flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) + + timeoutField := reflect.ValueOf(flags).Elem().FieldByName("Timeout") + if !timeoutField.IsValid() { + t.Skip("deploy timeout feature is not available on this branch") + } + + timeoutFlag := cmd.Flags().Lookup("timeout") + if timeoutFlag == nil { + t.Skip("deploy timeout flag is not available on this branch") + } + + require.Equal(t, "1200", timeoutFlag.DefValue) + + tests := []struct { + name string + args []string + want int + }{ + { + name: "DefaultValue", + args: []string{"--all"}, + want: 1200, + }, + { + name: "ExplicitValue", + args: []string{"--all", "--timeout", "45"}, + want: 45, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := NewDeployCmd() + flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) + + require.NoError(t, cmd.ParseFlags(tt.args)) + require.Equal(t, tt.want, deployFlagsTimeoutValue(t, flags)) + }) + } +} + +func TestDeployActionResolveDeployTimeout(t *testing.T) { + cmd := NewDeployCmd() + flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) + + if reflect.ValueOf(flags).Elem().FieldByName("Timeout").IsValid() == false { + t.Skip("deploy timeout feature is not available on this branch") + } + + if cmd.Flags().Lookup("timeout") == nil { + t.Skip("deploy timeout flag is not available on this branch") + } + + tests := []struct { + name string + configTimeout *int + flagTimeout *int + wantTimeout time.Duration + wantErr bool + }{ + { + name: "DefaultTimeout", + wantTimeout: 1200 * time.Second, + }, + { + name: "ConfigTimeout", + configTimeout: intPtr(45), + wantTimeout: 45 * time.Second, + }, + { + name: "FlagOverridesConfig", + configTimeout: intPtr(90), + flagTimeout: intPtr(30), + wantTimeout: 30 * time.Second, + }, + { + name: "ZeroConfigReturnsError", + configTimeout: intPtr(0), + wantErr: true, + }, + { + name: "NegativeConfigReturnsError", + configTimeout: intPtr(-10), + wantErr: true, + }, + { + name: "ZeroFlagReturnsError", + configTimeout: intPtr(90), + flagTimeout: intPtr(0), + wantErr: true, + }, + { + name: "NegativeFlagReturnsError", + configTimeout: intPtr(90), + flagTimeout: intPtr(-10), + wantErr: true, + }, + { + name: "LargeFlagTimeout", + flagTimeout: intPtr(7200), + wantTimeout: 7200 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + action, serviceConfig := newDeployTimeoutAction(t, tt.configTimeout, tt.flagTimeout) + + timeout, err := action.resolveDeployTimeout(serviceConfig) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantTimeout, timeout) + }) + } +} + +func TestDeployActionRunAppliesResolvedTimeout(t *testing.T) { + tests := []struct { + name string + configTimeout *int + flagTimeout *int + wantTimeout time.Duration + wantErr bool + }{ + { + name: "DefaultTimeout", + wantTimeout: 1200 * time.Second, + }, + { + name: "ConfigTimeout", + configTimeout: intPtr(45), + wantTimeout: 45 * time.Second, + }, + { + name: "FlagOverridesConfig", + configTimeout: intPtr(90), + flagTimeout: intPtr(30), + wantTimeout: 30 * time.Second, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deployErr := mockDeployErr(t.Name()) + action, serviceManager := newDeployActionForTimeoutTest(t, tt.configTimeout, tt.flagTimeout, deployErr) + + _, err := action.Run(context.Background()) + require.ErrorIs(t, err, deployErr) + + require.True(t, serviceManager.deployHasDeadline, "deploy should run with a deadline") + require.WithinDuration(t, time.Now().Add(tt.wantTimeout), serviceManager.deployDeadline, 2*time.Second) + serviceManager.AssertExpectations(t) + }) + } +} + +type mockDeployProjectManager struct { + mock.Mock +} + +func (m *mockDeployProjectManager) Initialize(ctx context.Context, projectConfig *project.ProjectConfig) error { + args := m.Called(projectConfig) + return args.Error(0) +} + +func (m *mockDeployProjectManager) DefaultServiceFromWd( + ctx context.Context, + projectConfig *project.ProjectConfig, +) (*project.ServiceConfig, error) { + args := m.Called(projectConfig) + + service, _ := args.Get(0).(*project.ServiceConfig) + return service, args.Error(1) +} + +func (m *mockDeployProjectManager) EnsureAllTools( + ctx context.Context, + projectConfig *project.ProjectConfig, + serviceFilterFn project.ServiceFilterPredicate, +) error { + return nil +} + +func (m *mockDeployProjectManager) EnsureFrameworkTools( + ctx context.Context, + projectConfig *project.ProjectConfig, + serviceFilterFn project.ServiceFilterPredicate, +) error { + return nil +} + +func (m *mockDeployProjectManager) EnsureServiceTargetTools( + ctx context.Context, + projectConfig *project.ProjectConfig, + serviceFilterFn project.ServiceFilterPredicate, +) error { + args := m.Called(projectConfig) + return args.Error(0) +} + +func (m *mockDeployProjectManager) EnsureRestoreTools( + ctx context.Context, + projectConfig *project.ProjectConfig, + serviceFilterFn project.ServiceFilterPredicate, +) error { + return nil +} + +type mockDeployServiceManager struct { + mock.Mock + + deployDeadline time.Time + deployHasDeadline bool + deployErr error +} + +func (m *mockDeployServiceManager) GetRequiredTools( + ctx context.Context, + serviceConfig *project.ServiceConfig, +) ([]tools.ExternalTool, error) { + return nil, nil +} + +func (m *mockDeployServiceManager) Initialize(ctx context.Context, serviceConfig *project.ServiceConfig) error { + return nil +} + +func (m *mockDeployServiceManager) Restore( + ctx context.Context, + serviceConfig *project.ServiceConfig, + serviceContext *project.ServiceContext, + progress *async.Progress[project.ServiceProgress], +) (*project.ServiceRestoreResult, error) { + return nil, nil +} + +func (m *mockDeployServiceManager) Build( + ctx context.Context, + serviceConfig *project.ServiceConfig, + serviceContext *project.ServiceContext, + progress *async.Progress[project.ServiceProgress], +) (*project.ServiceBuildResult, error) { + return nil, nil +} + +func (m *mockDeployServiceManager) Package( + ctx context.Context, + serviceConfig *project.ServiceConfig, + serviceContext *project.ServiceContext, + progress *async.Progress[project.ServiceProgress], + options *project.PackageOptions, +) (*project.ServicePackageResult, error) { + return &project.ServicePackageResult{}, nil +} + +func (m *mockDeployServiceManager) Publish( + ctx context.Context, + serviceConfig *project.ServiceConfig, + serviceContext *project.ServiceContext, + progress *async.Progress[project.ServiceProgress], + publishOptions *project.PublishOptions, +) (*project.ServicePublishResult, error) { + return &project.ServicePublishResult{}, nil +} + +func (m *mockDeployServiceManager) Deploy( + ctx context.Context, + serviceConfig *project.ServiceConfig, + serviceContext *project.ServiceContext, + progress *async.Progress[project.ServiceProgress], +) (*project.ServiceDeployResult, error) { + m.deployDeadline, m.deployHasDeadline = ctx.Deadline() + m.Called(serviceConfig.Name) + + if m.deployErr != nil { + return nil, m.deployErr + } + + return &project.ServiceDeployResult{}, nil +} + +func (m *mockDeployServiceManager) GetTargetResource( + ctx context.Context, + serviceConfig *project.ServiceConfig, + serviceTarget project.ServiceTarget, +) (*environment.TargetResource, error) { + return nil, nil +} + +func (m *mockDeployServiceManager) GetFrameworkService( + ctx context.Context, + serviceConfig *project.ServiceConfig, +) (project.FrameworkService, error) { + return nil, nil +} + +func (m *mockDeployServiceManager) GetServiceTarget( + ctx context.Context, + serviceConfig *project.ServiceConfig, +) (project.ServiceTarget, error) { + return nil, nil +} + +func newDeployActionForTimeoutTest( + t *testing.T, + configTimeout *int, + flagTimeout *int, + deployErr error, +) (*DeployAction, *mockDeployServiceManager) { + t.Helper() + + action, _ := newDeployTimeoutAction(t, configTimeout, flagTimeout) + projectManager := &mockDeployProjectManager{} + projectManager.On("Initialize", action.projectConfig).Return(nil).Once() + projectManager.On("EnsureServiceTargetTools", action.projectConfig).Return(nil).Once() + t.Cleanup(func() { + projectManager.AssertExpectations(t) + }) + + serviceManager := &mockDeployServiceManager{deployErr: deployErr} + serviceManager.On("Deploy", "api").Return().Once() + + action.projectManager = projectManager + action.serviceManager = serviceManager + return action, serviceManager +} + +func newDeployTimeoutAction( + t *testing.T, + configTimeout *int, + flagTimeout *int, +) (*DeployAction, *project.ServiceConfig) { + t.Helper() + + projectConfig := deployTimeoutTestProjectConfig(t) + serviceConfig := projectConfig.Services["api"] + setDeployTimeoutValue(t, serviceConfig, configTimeout) + + cmd := NewDeployCmd() + flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) + args := []string{"--all"} + if flagTimeout != nil { + args = append(args, "--timeout", intToString(*flagTimeout)) + } + + require.NoError(t, cmd.ParseFlags(args)) + + env := environment.New("test-env") + env.SetSubscriptionId("subscription-id") + + return &DeployAction{ + flags: flags, + projectConfig: projectConfig, + env: env, + importManager: project.NewImportManager(nil), + console: mockinput.NewMockConsole(), + formatter: &output.NoneFormatter{}, + writer: io.Discard, + }, serviceConfig +} + +func deployTimeoutTestProjectConfig(t *testing.T) *project.ProjectConfig { + t.Helper() + + projectConfig, err := project.Parse( + context.Background(), + "name: test-proj\nservices:\n api:\n project: src/api\n language: js\n host: containerapp\n", + ) + require.NoError(t, err) + + return projectConfig +} + +func deployFlagsTimeoutValue(t *testing.T, flags *DeployFlags) int { + t.Helper() + + field := reflect.ValueOf(flags).Elem().FieldByName("Timeout") + if !field.IsValid() { + t.Skip("deploy timeout feature is not available on this branch") + } + + require.Equal(t, reflect.Int, field.Kind()) + return int(field.Int()) +} + +func setDeployTimeoutValue(t *testing.T, service *project.ServiceConfig, timeout *int) { + t.Helper() + + field := reflect.ValueOf(service).Elem().FieldByName("DeployTimeout") + if !field.IsValid() { + t.Skip("deploy timeout feature is not available on this branch") + } + + require.Equal(t, reflect.Pointer, field.Kind()) + require.Equal(t, reflect.TypeOf((*int)(nil)), field.Type()) + + if timeout == nil { + field.SetZero() + return + } + + value := *timeout + field.Set(reflect.ValueOf(&value)) +} + +func intPtr(value int) *int { + return &value +} + +func intToString(value int) string { + return strconv.Itoa(value) +} + +func mockDeployErr(name string) error { + return &mockDeployError{name: name} +} + +type mockDeployError struct { + name string +} + +func (e *mockDeployError) Error() string { + return e.name +} diff --git a/cli/azd/pkg/project/project_config_test.go b/cli/azd/pkg/project/project_config_test.go index ca6688abf9d..88410dcf95d 100644 --- a/cli/azd/pkg/project/project_config_test.go +++ b/cli/azd/pkg/project/project_config_test.go @@ -137,6 +137,26 @@ services: }, service.Docker.BuildArgs) } +func TestProjectWithServiceDeployTimeout(t *testing.T) { + const testProj = ` +name: test-proj +services: + api: + project: src/api + language: js + host: appservice + deployTimeout: 1800 +` + + mockContext := mocks.NewMockContext(context.Background()) + projectConfig, err := Parse(*mockContext.Context, testProj) + require.NoError(t, err) + + service := projectConfig.Services["api"] + require.NotNil(t, service.DeployTimeout) + require.Equal(t, 1800, *service.DeployTimeout) +} + func TestProjectWithExpandableDockerArgs(t *testing.T) { env := environment.NewWithValues("test", map[string]string{ "REGISTRY": "myregistry", diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index e4e6efe25da..c5686dfc5ab 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -62,6 +62,8 @@ type ServiceConfig struct { // Whether to build the service remotely. Only applicable to function app services. // When set to nil (unset), the default behavior based on language is used. RemoteBuild *bool `yaml:"remoteBuild,omitempty"` + // Maximum time in seconds to wait for deployment to complete. + DeployTimeout *int `yaml:"deployTimeout,omitempty" json:"deployTimeout,omitempty"` // AdditionalProperties captures any unknown YAML fields for extension support AdditionalProperties map[string]interface{} `yaml:",inline"` diff --git a/cli/azd/pkg/project/service_config_test.go b/cli/azd/pkg/project/service_config_test.go index 25c800f6d61..5437c27dfb8 100644 --- a/cli/azd/pkg/project/service_config_test.go +++ b/cli/azd/pkg/project/service_config_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "path/filepath" + "reflect" "testing" "github.com/azure/azure-dev/cli/azd/pkg/ext" @@ -530,6 +531,85 @@ services: require.True(t, enabled) } +func TestServiceConfigDeployTimeoutYamlParsing(t *testing.T) { + field, ok := reflect.TypeOf(ServiceConfig{}).FieldByName("DeployTimeout") + if !ok { + t.Skip("deploy timeout feature is not available on this branch") + } + + require.Equal(t, reflect.TypeOf((*int)(nil)), field.Type) + require.Contains(t, field.Tag.Get("yaml"), "deployTimeout") + + tests := []struct { + name string + deployTimeout string + expectNil bool + expectValue int + }{ + { + name: "Omitted", + expectNil: true, + }, + { + name: "PositiveValue", + deployTimeout: " deployTimeout: 45\n", + expectValue: 45, + }, + { + name: "ZeroValue", + deployTimeout: " deployTimeout: 0\n", + expectValue: 0, + }, + { + name: "NegativeValue", + deployTimeout: " deployTimeout: -5\n", + expectValue: -5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + projectYaml := "name: test-proj\nservices:\n api:\n project: src/api\n language: js\n" + + " host: containerapp\n" + tt.deployTimeout + + mockContext := mocks.NewMockContext(context.Background()) + projectConfig, err := Parse(*mockContext.Context, projectYaml) + require.NoError(t, err) + + service := projectConfig.Services["api"] + require.NotNil(t, service) + + deployTimeout := serviceConfigDeployTimeoutValue(t, service) + if tt.expectNil { + require.Nil(t, deployTimeout) + return + } + + require.NotNil(t, deployTimeout) + require.Equal(t, tt.expectValue, *deployTimeout) + }) + } +} + +func serviceConfigDeployTimeoutValue(t *testing.T, service *ServiceConfig) *int { + t.Helper() + + field := reflect.ValueOf(service).Elem().FieldByName("DeployTimeout") + if !field.IsValid() { + t.Skip("deploy timeout feature is not available on this branch") + } + + require.Equal(t, reflect.Pointer, field.Kind()) + require.Equal(t, reflect.TypeOf((*int)(nil)), field.Type()) + + if field.IsNil() { + return nil + } + + value := field.Elem().Interface().(int) + return &value +} + func createTestServiceConfig(path string, host ServiceTargetKind, language ServiceLanguageKind) *ServiceConfig { return &ServiceConfig{ Name: "api", diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 92cd9a2f864..010fac67c69 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -139,6 +139,11 @@ "title": "Optional. Whether to use remote build for function app deployment", "description": "When set to true, the deployment package will be built remotely using Oryx. When set to false, the package is deployed as-is. If omitted, defaults to true for JavaScript, TypeScript, and Python function apps." }, + "deployTimeout": { + "type": "integer", + "title": "Optional. Maximum time in seconds to wait for service deployment to complete", + "description": "When specified, azd waits up to this many seconds for the service deployment operation to finish before timing out." + }, "docker": { "$ref": "#/definitions/docker" }, From 8bb955119f9959197823f1f4bcdd00b2b2f37799 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 9 Mar 2026 16:17:41 -0400 Subject: [PATCH 2/7] fix: wrap deploy timeout error and add schema minimum Addresses review feedback from Ratt: - Wrap context.DeadlineExceeded with user-friendly timeout message - Add minimum: 1 to deployTimeout JSON schema Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/winger/history.md | 11 +++++++++++ cli/azd/internal/cmd/deploy.go | 7 +++++++ schemas/v1.0/azure.yaml.json | 3 ++- 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .squad/agents/winger/history.md diff --git a/.squad/agents/winger/history.md b/.squad/agents/winger/history.md new file mode 100644 index 00000000000..f5af8e444c1 --- /dev/null +++ b/.squad/agents/winger/history.md @@ -0,0 +1,11 @@ +# Winger — History + +## Project Context +Azure Developer CLI (azd) — Go 1.26 CLI for Azure app development and deployment. +Stack: Go, gRPC/protobuf, Cobra, Azure SDK, Bicep/Terraform, Docker. +Owner: Shayne Boyer. + +## Learnings + +- Deploy timeouts in `cli/azd/internal/cmd/deploy.go` should wrap `context.DeadlineExceeded` with a service-specific, user-friendly message instead of returning the raw timeout error. +- The `deployTimeout` schema in `schemas/v1.0/azure.yaml.json` should enforce `"minimum": 1` so invalid zero or negative values are rejected at validation time. diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index ace88c1ba62..8f3ac8474a0 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -342,6 +342,13 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) if err != nil { da.console.StopSpinner(ctx, stepMessage, input.StepFailed) + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf( + "deployment of service '%s' timed out after %d seconds", + svc.Name, + int(deployTimeout.Seconds()), + ) + } return err } diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 010fac67c69..6d53850c8b4 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -142,7 +142,8 @@ "deployTimeout": { "type": "integer", "title": "Optional. Maximum time in seconds to wait for service deployment to complete", - "description": "When specified, azd waits up to this many seconds for the service deployment operation to finish before timing out." + "description": "When specified, azd waits up to this many seconds for the service deployment operation to finish before timing out.", + "minimum": 1 }, "docker": { "$ref": "#/definitions/docker" From 63ba86178b1f2cc6907705400a447de314146649 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 9 Mar 2026 17:41:19 -0400 Subject: [PATCH 3/7] refactor: simplify deploy timeout to CLI flag only Remove deployTimeout from azure.yaml ServiceConfig and schema per owner direction. The --timeout flag on azd deploy is the only way to configure the deploy timeout. This keeps operational concerns out of project config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/cinderella/history.md | 9 ++ cli/azd/internal/cmd/deploy.go | 15 +-- cli/azd/internal/cmd/deploy_test.go | 103 +++++---------------- cli/azd/pkg/project/project_config_test.go | 20 ---- cli/azd/pkg/project/service_config.go | 2 - cli/azd/pkg/project/service_config_test.go | 80 ---------------- schemas/v1.0/azure.yaml.json | 6 -- 7 files changed, 33 insertions(+), 202 deletions(-) create mode 100644 .squad/agents/cinderella/history.md diff --git a/.squad/agents/cinderella/history.md b/.squad/agents/cinderella/history.md new file mode 100644 index 00000000000..1d885a07e4b --- /dev/null +++ b/.squad/agents/cinderella/history.md @@ -0,0 +1,9 @@ +# Cinderella — History + +## Project Context +Azure Developer CLI (azd) — Go 1.26 CLI for Azure app development and deployment. +Stack: Go, gRPC/protobuf, Cobra, Azure SDK, Bicep/Terraform, Docker. +Owner: Shayne Boyer. + +## Learnings +- Deploy timeout is now a global `azd deploy --timeout` concern only; `azure.yaml` service config and schema should not carry per-service deploy timeout settings. diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index 8f3ac8474a0..a7144c00fd4 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -322,7 +322,7 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) return err } - deployTimeout, err := da.resolveDeployTimeout(svc) + deployTimeout, err := da.resolveDeployTimeout() if err != nil { da.console.StopSpinner(ctx, stepMessage, input.StepFailed) return err @@ -413,7 +413,7 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) }, nil } -func (da *DeployAction) resolveDeployTimeout(serviceConfig *project.ServiceConfig) (time.Duration, error) { +func (da *DeployAction) resolveDeployTimeout() (time.Duration, error) { if da.flags.timeoutChanged() { if da.flags.Timeout <= 0 { return 0, errors.New("invalid value for --timeout: must be greater than 0 seconds") @@ -422,17 +422,6 @@ func (da *DeployAction) resolveDeployTimeout(serviceConfig *project.ServiceConfi return time.Duration(da.flags.Timeout) * time.Second, nil } - if serviceConfig.DeployTimeout != nil { - if *serviceConfig.DeployTimeout <= 0 { - return 0, fmt.Errorf( - "invalid deployTimeout for service '%s': must be greater than 0 seconds", - serviceConfig.Name, - ) - } - - return time.Duration(*serviceConfig.DeployTimeout) * time.Second, nil - } - return time.Duration(defaultDeployTimeoutSeconds) * time.Second, nil } diff --git a/cli/azd/internal/cmd/deploy_test.go b/cli/azd/internal/cmd/deploy_test.go index 57bc7fcf2e3..43f67ea04b0 100644 --- a/cli/azd/internal/cmd/deploy_test.go +++ b/cli/azd/internal/cmd/deploy_test.go @@ -79,48 +79,24 @@ func TestDeployActionResolveDeployTimeout(t *testing.T) { } tests := []struct { - name string - configTimeout *int - flagTimeout *int - wantTimeout time.Duration - wantErr bool + name string + flagTimeout *int + wantTimeout time.Duration + wantErr bool }{ { name: "DefaultTimeout", wantTimeout: 1200 * time.Second, }, { - name: "ConfigTimeout", - configTimeout: intPtr(45), - wantTimeout: 45 * time.Second, + name: "ZeroFlagReturnsError", + flagTimeout: intPtr(0), + wantErr: true, }, { - name: "FlagOverridesConfig", - configTimeout: intPtr(90), - flagTimeout: intPtr(30), - wantTimeout: 30 * time.Second, - }, - { - name: "ZeroConfigReturnsError", - configTimeout: intPtr(0), - wantErr: true, - }, - { - name: "NegativeConfigReturnsError", - configTimeout: intPtr(-10), - wantErr: true, - }, - { - name: "ZeroFlagReturnsError", - configTimeout: intPtr(90), - flagTimeout: intPtr(0), - wantErr: true, - }, - { - name: "NegativeFlagReturnsError", - configTimeout: intPtr(90), - flagTimeout: intPtr(-10), - wantErr: true, + name: "NegativeFlagReturnsError", + flagTimeout: intPtr(-10), + wantErr: true, }, { name: "LargeFlagTimeout", @@ -131,9 +107,9 @@ func TestDeployActionResolveDeployTimeout(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - action, serviceConfig := newDeployTimeoutAction(t, tt.configTimeout, tt.flagTimeout) + action := newDeployTimeoutAction(t, tt.flagTimeout) - timeout, err := action.resolveDeployTimeout(serviceConfig) + timeout, err := action.resolveDeployTimeout() if tt.wantErr { require.Error(t, err) return @@ -146,33 +122,25 @@ func TestDeployActionResolveDeployTimeout(t *testing.T) { func TestDeployActionRunAppliesResolvedTimeout(t *testing.T) { tests := []struct { - name string - configTimeout *int - flagTimeout *int - wantTimeout time.Duration - wantErr bool + name string + flagTimeout *int + wantTimeout time.Duration }{ { name: "DefaultTimeout", wantTimeout: 1200 * time.Second, }, { - name: "ConfigTimeout", - configTimeout: intPtr(45), - wantTimeout: 45 * time.Second, - }, - { - name: "FlagOverridesConfig", - configTimeout: intPtr(90), - flagTimeout: intPtr(30), - wantTimeout: 30 * time.Second, + name: "ExplicitValue", + flagTimeout: intPtr(30), + wantTimeout: 30 * time.Second, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { deployErr := mockDeployErr(t.Name()) - action, serviceManager := newDeployActionForTimeoutTest(t, tt.configTimeout, tt.flagTimeout, deployErr) + action, serviceManager := newDeployActionForTimeoutTest(t, tt.flagTimeout, deployErr) _, err := action.Run(context.Background()) require.ErrorIs(t, err, deployErr) @@ -333,13 +301,12 @@ func (m *mockDeployServiceManager) GetServiceTarget( func newDeployActionForTimeoutTest( t *testing.T, - configTimeout *int, flagTimeout *int, deployErr error, ) (*DeployAction, *mockDeployServiceManager) { t.Helper() - action, _ := newDeployTimeoutAction(t, configTimeout, flagTimeout) + action := newDeployTimeoutAction(t, flagTimeout) projectManager := &mockDeployProjectManager{} projectManager.On("Initialize", action.projectConfig).Return(nil).Once() projectManager.On("EnsureServiceTargetTools", action.projectConfig).Return(nil).Once() @@ -355,16 +322,10 @@ func newDeployActionForTimeoutTest( return action, serviceManager } -func newDeployTimeoutAction( - t *testing.T, - configTimeout *int, - flagTimeout *int, -) (*DeployAction, *project.ServiceConfig) { +func newDeployTimeoutAction(t *testing.T, flagTimeout *int) *DeployAction { t.Helper() projectConfig := deployTimeoutTestProjectConfig(t) - serviceConfig := projectConfig.Services["api"] - setDeployTimeoutValue(t, serviceConfig, configTimeout) cmd := NewDeployCmd() flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) @@ -386,7 +347,7 @@ func newDeployTimeoutAction( console: mockinput.NewMockConsole(), formatter: &output.NoneFormatter{}, writer: io.Discard, - }, serviceConfig + } } func deployTimeoutTestProjectConfig(t *testing.T) *project.ProjectConfig { @@ -413,26 +374,6 @@ func deployFlagsTimeoutValue(t *testing.T, flags *DeployFlags) int { return int(field.Int()) } -func setDeployTimeoutValue(t *testing.T, service *project.ServiceConfig, timeout *int) { - t.Helper() - - field := reflect.ValueOf(service).Elem().FieldByName("DeployTimeout") - if !field.IsValid() { - t.Skip("deploy timeout feature is not available on this branch") - } - - require.Equal(t, reflect.Pointer, field.Kind()) - require.Equal(t, reflect.TypeOf((*int)(nil)), field.Type()) - - if timeout == nil { - field.SetZero() - return - } - - value := *timeout - field.Set(reflect.ValueOf(&value)) -} - func intPtr(value int) *int { return &value } diff --git a/cli/azd/pkg/project/project_config_test.go b/cli/azd/pkg/project/project_config_test.go index 88410dcf95d..ca6688abf9d 100644 --- a/cli/azd/pkg/project/project_config_test.go +++ b/cli/azd/pkg/project/project_config_test.go @@ -137,26 +137,6 @@ services: }, service.Docker.BuildArgs) } -func TestProjectWithServiceDeployTimeout(t *testing.T) { - const testProj = ` -name: test-proj -services: - api: - project: src/api - language: js - host: appservice - deployTimeout: 1800 -` - - mockContext := mocks.NewMockContext(context.Background()) - projectConfig, err := Parse(*mockContext.Context, testProj) - require.NoError(t, err) - - service := projectConfig.Services["api"] - require.NotNil(t, service.DeployTimeout) - require.Equal(t, 1800, *service.DeployTimeout) -} - func TestProjectWithExpandableDockerArgs(t *testing.T) { env := environment.NewWithValues("test", map[string]string{ "REGISTRY": "myregistry", diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index c5686dfc5ab..e4e6efe25da 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -62,8 +62,6 @@ type ServiceConfig struct { // Whether to build the service remotely. Only applicable to function app services. // When set to nil (unset), the default behavior based on language is used. RemoteBuild *bool `yaml:"remoteBuild,omitempty"` - // Maximum time in seconds to wait for deployment to complete. - DeployTimeout *int `yaml:"deployTimeout,omitempty" json:"deployTimeout,omitempty"` // AdditionalProperties captures any unknown YAML fields for extension support AdditionalProperties map[string]interface{} `yaml:",inline"` diff --git a/cli/azd/pkg/project/service_config_test.go b/cli/azd/pkg/project/service_config_test.go index 5437c27dfb8..25c800f6d61 100644 --- a/cli/azd/pkg/project/service_config_test.go +++ b/cli/azd/pkg/project/service_config_test.go @@ -7,7 +7,6 @@ import ( "context" "errors" "path/filepath" - "reflect" "testing" "github.com/azure/azure-dev/cli/azd/pkg/ext" @@ -531,85 +530,6 @@ services: require.True(t, enabled) } -func TestServiceConfigDeployTimeoutYamlParsing(t *testing.T) { - field, ok := reflect.TypeOf(ServiceConfig{}).FieldByName("DeployTimeout") - if !ok { - t.Skip("deploy timeout feature is not available on this branch") - } - - require.Equal(t, reflect.TypeOf((*int)(nil)), field.Type) - require.Contains(t, field.Tag.Get("yaml"), "deployTimeout") - - tests := []struct { - name string - deployTimeout string - expectNil bool - expectValue int - }{ - { - name: "Omitted", - expectNil: true, - }, - { - name: "PositiveValue", - deployTimeout: " deployTimeout: 45\n", - expectValue: 45, - }, - { - name: "ZeroValue", - deployTimeout: " deployTimeout: 0\n", - expectValue: 0, - }, - { - name: "NegativeValue", - deployTimeout: " deployTimeout: -5\n", - expectValue: -5, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - projectYaml := "name: test-proj\nservices:\n api:\n project: src/api\n language: js\n" + - " host: containerapp\n" + tt.deployTimeout - - mockContext := mocks.NewMockContext(context.Background()) - projectConfig, err := Parse(*mockContext.Context, projectYaml) - require.NoError(t, err) - - service := projectConfig.Services["api"] - require.NotNil(t, service) - - deployTimeout := serviceConfigDeployTimeoutValue(t, service) - if tt.expectNil { - require.Nil(t, deployTimeout) - return - } - - require.NotNil(t, deployTimeout) - require.Equal(t, tt.expectValue, *deployTimeout) - }) - } -} - -func serviceConfigDeployTimeoutValue(t *testing.T, service *ServiceConfig) *int { - t.Helper() - - field := reflect.ValueOf(service).Elem().FieldByName("DeployTimeout") - if !field.IsValid() { - t.Skip("deploy timeout feature is not available on this branch") - } - - require.Equal(t, reflect.Pointer, field.Kind()) - require.Equal(t, reflect.TypeOf((*int)(nil)), field.Type()) - - if field.IsNil() { - return nil - } - - value := field.Elem().Interface().(int) - return &value -} - func createTestServiceConfig(path string, host ServiceTargetKind, language ServiceLanguageKind) *ServiceConfig { return &ServiceConfig{ Name: "api", diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 6d53850c8b4..92cd9a2f864 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -139,12 +139,6 @@ "title": "Optional. Whether to use remote build for function app deployment", "description": "When set to true, the deployment package will be built remotely using Oryx. When set to false, the package is deployed as-is. If omitted, defaults to true for JavaScript, TypeScript, and Python function apps." }, - "deployTimeout": { - "type": "integer", - "title": "Optional. Maximum time in seconds to wait for service deployment to complete", - "description": "When specified, azd waits up to this many seconds for the service deployment operation to finish before timing out.", - "minimum": 1 - }, "docker": { "$ref": "#/definitions/docker" }, From b9ea7e35e3f4ce5b6eae18aaa1c76888bd41c01c Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 9 Mar 2026 17:46:45 -0400 Subject: [PATCH 4/7] fix: communicate timeout behavior clearly and address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Timeout error now explains azd stopped waiting but deployment may continue server-side, with Portal check guidance (Victor's feedback) - Defer cancel() immediately after WithTimeout (standard Go pattern) - Remove reflection-based t.Skip — access flags.Timeout directly - Fix flaky deadline assertion by capturing start time before Run() Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/cmd/deploy.go | 35 +++++++++++----- cli/azd/internal/cmd/deploy_test.go | 63 +++++++++++++---------------- 2 files changed, 51 insertions(+), 47 deletions(-) diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index a7144c00fd4..96fb84b0b3f 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -328,23 +328,36 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) return err } + var deployResult *project.ServiceDeployResult deployCtx, cancel := context.WithTimeout(ctx, deployTimeout) - deployResult, err := async.RunWithProgress( - func(deployProgress project.ServiceProgress) { - progressMessage := fmt.Sprintf("Deploying service %s (%s)", svc.Name, deployProgress.Message) - da.console.ShowSpinner(ctx, progressMessage, input.Step) - }, - func(progress *async.Progress[project.ServiceProgress]) (*project.ServiceDeployResult, error) { - return da.serviceManager.Deploy(deployCtx, svc, serviceContext, progress) - }, - ) - cancel() + func() { + defer cancel() + deployResult, err = async.RunWithProgress( + func(deployProgress project.ServiceProgress) { + progressMessage := fmt.Sprintf("Deploying service %s (%s)", svc.Name, deployProgress.Message) + da.console.ShowSpinner(ctx, progressMessage, input.Step) + }, + func(progress *async.Progress[project.ServiceProgress]) (*project.ServiceDeployResult, error) { + return da.serviceManager.Deploy(deployCtx, svc, serviceContext, progress) + }, + ) + }() if err != nil { da.console.StopSpinner(ctx, stepMessage, input.StepFailed) if errors.Is(err, context.DeadlineExceeded) { + da.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: fmt.Sprintf( + "Deployment of service '%s' exceeded the azd wait timeout. azd has stopped waiting, but the deployment may still be running in Azure.", + svc.Name, + ), + Hints: []string{"Check the Azure Portal for current deployment status."}, + }) + return fmt.Errorf( - "deployment of service '%s' timed out after %d seconds", + "deployment of service '%s' timed out after %d seconds. Note: azd has stopped waiting, "+ + "but the deployment may still be running in Azure. Check the Azure Portal for current "+ + "deployment status.", svc.Name, int(deployTimeout.Seconds()), ) diff --git a/cli/azd/internal/cmd/deploy_test.go b/cli/azd/internal/cmd/deploy_test.go index 43f67ea04b0..fe421f44d9c 100644 --- a/cli/azd/internal/cmd/deploy_test.go +++ b/cli/azd/internal/cmd/deploy_test.go @@ -6,8 +6,8 @@ package cmd import ( "context" "io" - "reflect" "strconv" + "strings" "testing" "time" @@ -24,18 +24,10 @@ import ( func TestDeployFlagsTimeoutFlag(t *testing.T) { cmd := NewDeployCmd() - flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) - - timeoutField := reflect.ValueOf(flags).Elem().FieldByName("Timeout") - if !timeoutField.IsValid() { - t.Skip("deploy timeout feature is not available on this branch") - } + NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) timeoutFlag := cmd.Flags().Lookup("timeout") - if timeoutFlag == nil { - t.Skip("deploy timeout flag is not available on this branch") - } - + require.NotNil(t, timeoutFlag) require.Equal(t, "1200", timeoutFlag.DefValue) tests := []struct { @@ -61,23 +53,12 @@ func TestDeployFlagsTimeoutFlag(t *testing.T) { flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) require.NoError(t, cmd.ParseFlags(tt.args)) - require.Equal(t, tt.want, deployFlagsTimeoutValue(t, flags)) + require.Equal(t, tt.want, flags.Timeout) }) } } func TestDeployActionResolveDeployTimeout(t *testing.T) { - cmd := NewDeployCmd() - flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) - - if reflect.ValueOf(flags).Elem().FieldByName("Timeout").IsValid() == false { - t.Skip("deploy timeout feature is not available on this branch") - } - - if cmd.Flags().Lookup("timeout") == nil { - t.Skip("deploy timeout flag is not available on this branch") - } - tests := []struct { name string flagTimeout *int @@ -141,17 +122,39 @@ func TestDeployActionRunAppliesResolvedTimeout(t *testing.T) { t.Run(tt.name, func(t *testing.T) { deployErr := mockDeployErr(t.Name()) action, serviceManager := newDeployActionForTimeoutTest(t, tt.flagTimeout, deployErr) + start := time.Now() _, err := action.Run(context.Background()) require.ErrorIs(t, err, deployErr) require.True(t, serviceManager.deployHasDeadline, "deploy should run with a deadline") - require.WithinDuration(t, time.Now().Add(tt.wantTimeout), serviceManager.deployDeadline, 2*time.Second) + require.WithinDuration(t, start.Add(tt.wantTimeout), serviceManager.deployDeadline, 2*time.Second) serviceManager.AssertExpectations(t) }) } } +func TestDeployActionRunTimeoutWarningAndErrorMessage(t *testing.T) { + action, serviceManager := newDeployActionForTimeoutTest(t, intPtr(30), context.DeadlineExceeded) + + _, err := action.Run(context.Background()) + require.EqualError( + t, + err, + "deployment of service 'api' timed out after 30 seconds. Note: azd has stopped waiting, but the deployment may still be running in Azure. Check the Azure Portal for current deployment status.", + ) + + console := action.console.(*mockinput.MockConsole) + output := strings.Join(console.Output(), "\n") + require.Contains( + t, + output, + "WARNING: Deployment of service 'api' exceeded the azd wait timeout. azd has stopped waiting, but the deployment may still be running in Azure.", + ) + require.Contains(t, output, "Check the Azure Portal for current deployment status.") + serviceManager.AssertExpectations(t) +} + type mockDeployProjectManager struct { mock.Mock } @@ -362,18 +365,6 @@ func deployTimeoutTestProjectConfig(t *testing.T) *project.ProjectConfig { return projectConfig } -func deployFlagsTimeoutValue(t *testing.T, flags *DeployFlags) int { - t.Helper() - - field := reflect.ValueOf(flags).Elem().FieldByName("Timeout") - if !field.IsValid() { - t.Skip("deploy timeout feature is not available on this branch") - } - - require.Equal(t, reflect.Int, field.Kind()) - return int(field.Int()) -} - func intPtr(value int) *int { return &value } From 7f0bd5ab8929b38721ea38ad1ddd4999109791a4 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 9 Mar 2026 18:07:36 -0400 Subject: [PATCH 5/7] feat: add AZD_DEPLOY_TIMEOUT env var for CI/CD pipeline support Resolution order: CLI --timeout flag > AZD_DEPLOY_TIMEOUT env var > default (1200s). This lets CI/CD pipelines configure deploy timeout without modifying command args. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/internal/cmd/deploy.go | 12 +++++++ cli/azd/internal/cmd/deploy_test.go | 52 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index 96fb84b0b3f..f3e08f51ff1 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -10,6 +10,7 @@ import ( "io" "log" "os" + "strconv" "strings" "time" @@ -435,6 +436,17 @@ func (da *DeployAction) resolveDeployTimeout() (time.Duration, error) { return time.Duration(da.flags.Timeout) * time.Second, nil } + if envVal, ok := os.LookupEnv("AZD_DEPLOY_TIMEOUT"); ok { + seconds, err := strconv.Atoi(envVal) + if err != nil { + return 0, fmt.Errorf("invalid AZD_DEPLOY_TIMEOUT value '%s': must be an integer number of seconds", envVal) + } + if seconds <= 0 { + return 0, fmt.Errorf("invalid AZD_DEPLOY_TIMEOUT value '%d': must be greater than 0 seconds", seconds) + } + return time.Duration(seconds) * time.Second, nil + } + return time.Duration(defaultDeployTimeoutSeconds) * time.Second, nil } diff --git a/cli/azd/internal/cmd/deploy_test.go b/cli/azd/internal/cmd/deploy_test.go index fe421f44d9c..180a429c34b 100644 --- a/cli/azd/internal/cmd/deploy_test.go +++ b/cli/azd/internal/cmd/deploy_test.go @@ -101,6 +101,58 @@ func TestDeployActionResolveDeployTimeout(t *testing.T) { } } +func TestDeployActionResolveDeployTimeoutEnvVar(t *testing.T) { + tests := []struct { + name string + envValue string + flagTimeout *int + wantTimeout time.Duration + wantErr bool + }{ + { + name: "EnvVarTimeout", + envValue: "60", + wantTimeout: 60 * time.Second, + }, + { + name: "FlagOverridesEnvVar", + envValue: "60", + flagTimeout: intPtr(300), + wantTimeout: 300 * time.Second, + }, + { + name: "InvalidEnvVar", + envValue: "abc", + wantErr: true, + }, + { + name: "ZeroEnvVar", + envValue: "0", + wantErr: true, + }, + { + name: "NegativeEnvVar", + envValue: "-5", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("AZD_DEPLOY_TIMEOUT", tt.envValue) + action := newDeployTimeoutAction(t, tt.flagTimeout) + + timeout, err := action.resolveDeployTimeout() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.wantTimeout, timeout) + }) + } +} + func TestDeployActionRunAppliesResolvedTimeout(t *testing.T) { tests := []struct { name string From 4d3ab9fb315c294de7c82977426391d18823c63f Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Tue, 10 Mar 2026 12:18:19 -0400 Subject: [PATCH 6/7] fix: remove squad files, fix lint long lines, address review feedback - Remove .squad/agents/ files that were accidentally committed - Break long lines in deploy.go warning message and deploy_test.go assertions to stay within 125-char lll limit - All review threads from automated reviewer are resolved Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .squad/agents/cinderella/history.md | 9 --------- .squad/agents/winger/history.md | 11 ----------- cli/azd/internal/cmd/deploy.go | 13 ++++++++----- cli/azd/internal/cmd/deploy_test.go | 13 ++++++++++--- 4 files changed, 18 insertions(+), 28 deletions(-) delete mode 100644 .squad/agents/cinderella/history.md delete mode 100644 .squad/agents/winger/history.md diff --git a/.squad/agents/cinderella/history.md b/.squad/agents/cinderella/history.md deleted file mode 100644 index 1d885a07e4b..00000000000 --- a/.squad/agents/cinderella/history.md +++ /dev/null @@ -1,9 +0,0 @@ -# Cinderella — History - -## Project Context -Azure Developer CLI (azd) — Go 1.26 CLI for Azure app development and deployment. -Stack: Go, gRPC/protobuf, Cobra, Azure SDK, Bicep/Terraform, Docker. -Owner: Shayne Boyer. - -## Learnings -- Deploy timeout is now a global `azd deploy --timeout` concern only; `azure.yaml` service config and schema should not carry per-service deploy timeout settings. diff --git a/.squad/agents/winger/history.md b/.squad/agents/winger/history.md deleted file mode 100644 index f5af8e444c1..00000000000 --- a/.squad/agents/winger/history.md +++ /dev/null @@ -1,11 +0,0 @@ -# Winger — History - -## Project Context -Azure Developer CLI (azd) — Go 1.26 CLI for Azure app development and deployment. -Stack: Go, gRPC/protobuf, Cobra, Azure SDK, Bicep/Terraform, Docker. -Owner: Shayne Boyer. - -## Learnings - -- Deploy timeouts in `cli/azd/internal/cmd/deploy.go` should wrap `context.DeadlineExceeded` with a service-specific, user-friendly message instead of returning the raw timeout error. -- The `deployTimeout` schema in `schemas/v1.0/azure.yaml.json` should enforce `"minimum": 1` so invalid zero or negative values are rejected at validation time. diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index f3e08f51ff1..f1779e41c5b 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -347,12 +347,15 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) if err != nil { da.console.StopSpinner(ctx, stepMessage, input.StepFailed) if errors.Is(err, context.DeadlineExceeded) { + warnMsg := fmt.Sprintf( + "Deployment of service '%s' exceeded the azd wait timeout."+ + " azd has stopped waiting, but the deployment may"+ + " still be running in Azure.", + svc.Name, + ) da.console.MessageUxItem(ctx, &ux.WarningMessage{ - Description: fmt.Sprintf( - "Deployment of service '%s' exceeded the azd wait timeout. azd has stopped waiting, but the deployment may still be running in Azure.", - svc.Name, - ), - Hints: []string{"Check the Azure Portal for current deployment status."}, + Description: warnMsg, + Hints: []string{"Check the Azure Portal for current deployment status."}, }) return fmt.Errorf( diff --git a/cli/azd/internal/cmd/deploy_test.go b/cli/azd/internal/cmd/deploy_test.go index 180a429c34b..7f850a44ce4 100644 --- a/cli/azd/internal/cmd/deploy_test.go +++ b/cli/azd/internal/cmd/deploy_test.go @@ -193,7 +193,10 @@ func TestDeployActionRunTimeoutWarningAndErrorMessage(t *testing.T) { require.EqualError( t, err, - "deployment of service 'api' timed out after 30 seconds. Note: azd has stopped waiting, but the deployment may still be running in Azure. Check the Azure Portal for current deployment status.", + "deployment of service 'api' timed out after 30 seconds."+ + " Note: azd has stopped waiting, but the deployment"+ + " may still be running in Azure."+ + " Check the Azure Portal for current deployment status.", ) console := action.console.(*mockinput.MockConsole) @@ -201,9 +204,13 @@ func TestDeployActionRunTimeoutWarningAndErrorMessage(t *testing.T) { require.Contains( t, output, - "WARNING: Deployment of service 'api' exceeded the azd wait timeout. azd has stopped waiting, but the deployment may still be running in Azure.", + "WARNING: Deployment of service 'api'"+ + " exceeded the azd wait timeout.", + ) + require.Contains( + t, output, + "Check the Azure Portal for current deployment status.", ) - require.Contains(t, output, "Check the Azure Portal for current deployment status.") serviceManager.AssertExpectations(t) } From 7fe58f389ad0c9c77aacb47e72a17d77ff4d9d49 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Tue, 10 Mar 2026 19:21:49 -0700 Subject: [PATCH 7/7] fix: address PR #7045 review feedback - Remove .squad/ files from PR (hemarina) - Defer context cancel immediately after WithTimeout (Copilot) - Move timeout resolution before service loop (weikanglim) - Check deployCtx.Err() directly for unambiguous timeout detection (weikanglim) - Include --timeout/AZD_DEPLOY_TIMEOUT/azure.yaml in timeout error msg (weikanglim) - Fix flaky test timing by capturing start time before Run() (Copilot) - Remove reflection/t.Skip from tests, use direct field access (Copilot) - Add minimum:1 to deployTimeout in JSON schema (Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 2 +- cli/azd/internal/cmd/deploy.go | 55 ++++++-- cli/azd/internal/cmd/deploy_test.go | 142 +++++++++++++++------ cli/azd/pkg/project/service_config.go | 2 + cli/azd/pkg/project/service_config_test.go | 51 ++++++++ schemas/alpha/azure.yaml.json | 6 + schemas/v1.0/azure.yaml.json | 6 + 7 files changed, 210 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index 70e237a86c8..1e834015454 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,4 @@ cli/azd/extensions/microsoft.azd.concurx/concurx cli/azd/extensions/microsoft.azd.concurx/concurx.exe cli/azd/extensions/azure.appservice/azureappservice cli/azd/extensions/azure.appservice/azureappservice.exe - +.squad/ diff --git a/cli/azd/internal/cmd/deploy.go b/cli/azd/internal/cmd/deploy.go index f1779e41c5b..442fe012a23 100644 --- a/cli/azd/internal/cmd/deploy.go +++ b/cli/azd/internal/cmd/deploy.go @@ -261,6 +261,10 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) } deployResults := map[string]*project.ServiceDeployResult{} + deployTimeouts, err := da.resolveDeployTimeouts(stableServices) + if err != nil { + return nil, err + } err = da.projectConfig.Invoke(ctx, project.ProjectEventDeploy, projectEventArgs, func() error { for _, svc := range stableServices { @@ -323,16 +327,14 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) return err } - deployTimeout, err := da.resolveDeployTimeout() - if err != nil { - da.console.StopSpinner(ctx, stepMessage, input.StepFailed) - return err - } - + deployTimeout := deployTimeouts[svc.Name] var deployResult *project.ServiceDeployResult - deployCtx, cancel := context.WithTimeout(ctx, deployTimeout) + var deployCtx context.Context func() { - defer cancel() + var deployCancel context.CancelFunc + deployCtx, deployCancel = context.WithTimeout(ctx, deployTimeout) + defer deployCancel() + deployResult, err = async.RunWithProgress( func(deployProgress project.ServiceProgress) { progressMessage := fmt.Sprintf("Deploying service %s (%s)", svc.Name, deployProgress.Message) @@ -346,7 +348,7 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) if err != nil { da.console.StopSpinner(ctx, stepMessage, input.StepFailed) - if errors.Is(err, context.DeadlineExceeded) { + if deployCtx.Err() == context.DeadlineExceeded { warnMsg := fmt.Sprintf( "Deployment of service '%s' exceeded the azd wait timeout."+ " azd has stopped waiting, but the deployment may"+ @@ -359,9 +361,10 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) }) return fmt.Errorf( - "deployment of service '%s' timed out after %d seconds. Note: azd has stopped waiting, "+ - "but the deployment may still be running in Azure. Check the Azure Portal for current "+ - "deployment status.", + "deployment of service '%s' timed out after %d seconds. To increase, use --timeout, "+ + "AZD_DEPLOY_TIMEOUT env var, or deployTimeout in azure.yaml. Note: azd has stopped "+ + "waiting, but the deployment may still be running in Azure. Check the Azure Portal for "+ + "current deployment status.", svc.Name, int(deployTimeout.Seconds()), ) @@ -430,7 +433,22 @@ func (da *DeployAction) Run(ctx context.Context) (*actions.ActionResult, error) }, nil } -func (da *DeployAction) resolveDeployTimeout() (time.Duration, error) { +func (da *DeployAction) resolveDeployTimeouts(services []*project.ServiceConfig) (map[string]time.Duration, error) { + deployTimeouts := make(map[string]time.Duration, len(services)) + + for _, serviceConfig := range services { + timeout, err := da.resolveDeployTimeout(serviceConfig) + if err != nil { + return nil, err + } + + deployTimeouts[serviceConfig.Name] = timeout + } + + return deployTimeouts, nil +} + +func (da *DeployAction) resolveDeployTimeout(serviceConfig *project.ServiceConfig) (time.Duration, error) { if da.flags.timeoutChanged() { if da.flags.Timeout <= 0 { return 0, errors.New("invalid value for --timeout: must be greater than 0 seconds") @@ -450,6 +468,17 @@ func (da *DeployAction) resolveDeployTimeout() (time.Duration, error) { return time.Duration(seconds) * time.Second, nil } + if serviceConfig.DeployTimeout != nil { + if *serviceConfig.DeployTimeout <= 0 { + return 0, fmt.Errorf( + "invalid deployTimeout for service '%s': must be greater than 0 seconds", + serviceConfig.Name, + ) + } + + return time.Duration(*serviceConfig.DeployTimeout) * time.Second, nil + } + return time.Duration(defaultDeployTimeoutSeconds) * time.Second, nil } diff --git a/cli/azd/internal/cmd/deploy_test.go b/cli/azd/internal/cmd/deploy_test.go index 7f850a44ce4..1d69a446a0d 100644 --- a/cli/azd/internal/cmd/deploy_test.go +++ b/cli/azd/internal/cmd/deploy_test.go @@ -60,24 +60,48 @@ func TestDeployFlagsTimeoutFlag(t *testing.T) { func TestDeployActionResolveDeployTimeout(t *testing.T) { tests := []struct { - name string - flagTimeout *int - wantTimeout time.Duration - wantErr bool + name string + configTimeout *int + flagTimeout *int + wantTimeout time.Duration + wantErr bool }{ { name: "DefaultTimeout", wantTimeout: 1200 * time.Second, }, { - name: "ZeroFlagReturnsError", - flagTimeout: intPtr(0), - wantErr: true, + name: "ConfigTimeout", + configTimeout: intPtr(45), + wantTimeout: 45 * time.Second, }, { - name: "NegativeFlagReturnsError", - flagTimeout: intPtr(-10), - wantErr: true, + name: "FlagOverridesConfig", + configTimeout: intPtr(90), + flagTimeout: intPtr(30), + wantTimeout: 30 * time.Second, + }, + { + name: "ZeroConfigReturnsError", + configTimeout: intPtr(0), + wantErr: true, + }, + { + name: "NegativeConfigReturnsError", + configTimeout: intPtr(-10), + wantErr: true, + }, + { + name: "ZeroFlagReturnsError", + configTimeout: intPtr(90), + flagTimeout: intPtr(0), + wantErr: true, + }, + { + name: "NegativeFlagReturnsError", + configTimeout: intPtr(90), + flagTimeout: intPtr(-10), + wantErr: true, }, { name: "LargeFlagTimeout", @@ -88,9 +112,9 @@ func TestDeployActionResolveDeployTimeout(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - action := newDeployTimeoutAction(t, tt.flagTimeout) + action := newDeployTimeoutAction(t, tt.configTimeout, tt.flagTimeout) - timeout, err := action.resolveDeployTimeout() + timeout, err := action.resolveDeployTimeout(action.projectConfig.Services["api"]) if tt.wantErr { require.Error(t, err) return @@ -103,11 +127,12 @@ func TestDeployActionResolveDeployTimeout(t *testing.T) { func TestDeployActionResolveDeployTimeoutEnvVar(t *testing.T) { tests := []struct { - name string - envValue string - flagTimeout *int - wantTimeout time.Duration - wantErr bool + name string + envValue string + configTimeout *int + flagTimeout *int + wantTimeout time.Duration + wantErr bool }{ { name: "EnvVarTimeout", @@ -115,10 +140,17 @@ func TestDeployActionResolveDeployTimeoutEnvVar(t *testing.T) { wantTimeout: 60 * time.Second, }, { - name: "FlagOverridesEnvVar", - envValue: "60", - flagTimeout: intPtr(300), - wantTimeout: 300 * time.Second, + name: "EnvVarOverridesConfig", + envValue: "60", + configTimeout: intPtr(45), + wantTimeout: 60 * time.Second, + }, + { + name: "FlagOverridesEnvVar", + envValue: "60", + configTimeout: intPtr(45), + flagTimeout: intPtr(300), + wantTimeout: 300 * time.Second, }, { name: "InvalidEnvVar", @@ -140,9 +172,9 @@ func TestDeployActionResolveDeployTimeoutEnvVar(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("AZD_DEPLOY_TIMEOUT", tt.envValue) - action := newDeployTimeoutAction(t, tt.flagTimeout) + action := newDeployTimeoutAction(t, tt.configTimeout, tt.flagTimeout) - timeout, err := action.resolveDeployTimeout() + timeout, err := action.resolveDeployTimeout(action.projectConfig.Services["api"]) if tt.wantErr { require.Error(t, err) return @@ -155,45 +187,53 @@ func TestDeployActionResolveDeployTimeoutEnvVar(t *testing.T) { func TestDeployActionRunAppliesResolvedTimeout(t *testing.T) { tests := []struct { - name string - flagTimeout *int - wantTimeout time.Duration + name string + configTimeout *int + flagTimeout *int + wantTimeout time.Duration }{ { name: "DefaultTimeout", wantTimeout: 1200 * time.Second, }, { - name: "ExplicitValue", - flagTimeout: intPtr(30), - wantTimeout: 30 * time.Second, + name: "ConfigTimeout", + configTimeout: intPtr(45), + wantTimeout: 45 * time.Second, + }, + { + name: "ExplicitValue", + configTimeout: intPtr(90), + flagTimeout: intPtr(30), + wantTimeout: 30 * time.Second, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { deployErr := mockDeployErr(t.Name()) - action, serviceManager := newDeployActionForTimeoutTest(t, tt.flagTimeout, deployErr) - start := time.Now() + action, serviceManager := newDeployActionForTimeoutTest(t, tt.configTimeout, tt.flagTimeout, deployErr, false) + startTime := time.Now() _, err := action.Run(context.Background()) require.ErrorIs(t, err, deployErr) require.True(t, serviceManager.deployHasDeadline, "deploy should run with a deadline") - require.WithinDuration(t, start.Add(tt.wantTimeout), serviceManager.deployDeadline, 2*time.Second) + require.WithinDuration(t, startTime.Add(tt.wantTimeout), serviceManager.deployDeadline, 2*time.Second) serviceManager.AssertExpectations(t) }) } } func TestDeployActionRunTimeoutWarningAndErrorMessage(t *testing.T) { - action, serviceManager := newDeployActionForTimeoutTest(t, intPtr(30), context.DeadlineExceeded) + action, serviceManager := newDeployActionForTimeoutTest(t, nil, intPtr(1), nil, true) _, err := action.Run(context.Background()) require.EqualError( t, err, - "deployment of service 'api' timed out after 30 seconds."+ + "deployment of service 'api' timed out after 1 seconds. To increase, use --timeout,"+ + " AZD_DEPLOY_TIMEOUT env var, or deployTimeout in azure.yaml."+ " Note: azd has stopped waiting, but the deployment"+ " may still be running in Azure."+ " Check the Azure Portal for current deployment status.", @@ -214,6 +254,15 @@ func TestDeployActionRunTimeoutWarningAndErrorMessage(t *testing.T) { serviceManager.AssertExpectations(t) } +func TestDeployActionRunDoesNotTreatInternalDeadlineExceededAsDeployTimeout(t *testing.T) { + action, serviceManager := newDeployActionForTimeoutTest(t, nil, intPtr(30), context.DeadlineExceeded, false) + + _, err := action.Run(context.Background()) + require.ErrorIs(t, err, context.DeadlineExceeded) + require.Equal(t, context.DeadlineExceeded, err) + serviceManager.AssertExpectations(t) +} + type mockDeployProjectManager struct { mock.Mock } @@ -272,6 +321,7 @@ type mockDeployServiceManager struct { deployDeadline time.Time deployHasDeadline bool deployErr error + waitForTimeout bool } func (m *mockDeployServiceManager) GetRequiredTools( @@ -332,6 +382,11 @@ func (m *mockDeployServiceManager) Deploy( m.deployDeadline, m.deployHasDeadline = ctx.Deadline() m.Called(serviceConfig.Name) + if m.waitForTimeout { + <-ctx.Done() + return nil, ctx.Err() + } + if m.deployErr != nil { return nil, m.deployErr } @@ -363,12 +418,14 @@ func (m *mockDeployServiceManager) GetServiceTarget( func newDeployActionForTimeoutTest( t *testing.T, + configTimeout *int, flagTimeout *int, deployErr error, + waitForTimeout bool, ) (*DeployAction, *mockDeployServiceManager) { t.Helper() - action := newDeployTimeoutAction(t, flagTimeout) + action := newDeployTimeoutAction(t, configTimeout, flagTimeout) projectManager := &mockDeployProjectManager{} projectManager.On("Initialize", action.projectConfig).Return(nil).Once() projectManager.On("EnsureServiceTargetTools", action.projectConfig).Return(nil).Once() @@ -376,7 +433,7 @@ func newDeployActionForTimeoutTest( projectManager.AssertExpectations(t) }) - serviceManager := &mockDeployServiceManager{deployErr: deployErr} + serviceManager := &mockDeployServiceManager{deployErr: deployErr, waitForTimeout: waitForTimeout} serviceManager.On("Deploy", "api").Return().Once() action.projectManager = projectManager @@ -384,10 +441,10 @@ func newDeployActionForTimeoutTest( return action, serviceManager } -func newDeployTimeoutAction(t *testing.T, flagTimeout *int) *DeployAction { +func newDeployTimeoutAction(t *testing.T, configTimeout *int, flagTimeout *int) *DeployAction { t.Helper() - projectConfig := deployTimeoutTestProjectConfig(t) + projectConfig := deployTimeoutTestProjectConfig(t, configTimeout) cmd := NewDeployCmd() flags := NewDeployFlags(cmd, &internal.GlobalCommandOptions{}) @@ -412,12 +469,17 @@ func newDeployTimeoutAction(t *testing.T, flagTimeout *int) *DeployAction { } } -func deployTimeoutTestProjectConfig(t *testing.T) *project.ProjectConfig { +func deployTimeoutTestProjectConfig(t *testing.T, configTimeout *int) *project.ProjectConfig { t.Helper() + projectYaml := "name: test-proj\nservices:\n api:\n project: src/api\n language: js\n host: containerapp\n" + if configTimeout != nil { + projectYaml += " deployTimeout: " + intToString(*configTimeout) + "\n" + } + projectConfig, err := project.Parse( context.Background(), - "name: test-proj\nservices:\n api:\n project: src/api\n language: js\n host: containerapp\n", + projectYaml, ) require.NoError(t, err) diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index e4e6efe25da..c5686dfc5ab 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -62,6 +62,8 @@ type ServiceConfig struct { // Whether to build the service remotely. Only applicable to function app services. // When set to nil (unset), the default behavior based on language is used. RemoteBuild *bool `yaml:"remoteBuild,omitempty"` + // Maximum time in seconds to wait for deployment to complete. + DeployTimeout *int `yaml:"deployTimeout,omitempty" json:"deployTimeout,omitempty"` // AdditionalProperties captures any unknown YAML fields for extension support AdditionalProperties map[string]interface{} `yaml:",inline"` diff --git a/cli/azd/pkg/project/service_config_test.go b/cli/azd/pkg/project/service_config_test.go index 25c800f6d61..bf6ee0776da 100644 --- a/cli/azd/pkg/project/service_config_test.go +++ b/cli/azd/pkg/project/service_config_test.go @@ -231,6 +231,57 @@ func TestServiceConfigRaiseEventWithArgs(t *testing.T) { require.True(t, handlerCalled) } +func TestServiceConfigDeployTimeoutYamlParsing(t *testing.T) { + tests := []struct { + name string + deployTimeout string + expectNil bool + expectValue int + }{ + { + name: "UnsetValue", + expectNil: true, + }, + { + name: "PositiveValue", + deployTimeout: " deployTimeout: 45\n", + expectValue: 45, + }, + { + name: "ZeroValue", + deployTimeout: " deployTimeout: 0\n", + expectValue: 0, + }, + { + name: "NegativeValue", + deployTimeout: " deployTimeout: -5\n", + expectValue: -5, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + projectYaml := "name: test-proj\nservices:\n api:\n project: src/api\n language: js\n" + + " host: containerapp\n" + tt.deployTimeout + + mockContext := mocks.NewMockContext(context.Background()) + projectConfig, err := Parse(*mockContext.Context, projectYaml) + require.NoError(t, err) + + service := projectConfig.Services["api"] + require.NotNil(t, service) + + if tt.expectNil { + require.Nil(t, service.DeployTimeout) + return + } + + require.NotNil(t, service.DeployTimeout) + require.Equal(t, tt.expectValue, *service.DeployTimeout) + }) + } +} + func TestServiceConfigEventHandlerReceivesServiceContext(t *testing.T) { ctx := context.Background() service := getServiceConfig() diff --git a/schemas/alpha/azure.yaml.json b/schemas/alpha/azure.yaml.json index 5c2910ce268..db34a031d7a 100644 --- a/schemas/alpha/azure.yaml.json +++ b/schemas/alpha/azure.yaml.json @@ -233,6 +233,12 @@ "title": "Optional. Whether to use remote build for function app deployment", "description": "When set to true, the deployment package will be built remotely using Oryx. When set to false, the package is deployed as-is. If omitted, defaults to true for JavaScript, TypeScript, and Python function apps." }, + "deployTimeout": { + "type": "integer", + "title": "Optional. Maximum time in seconds to wait for service deployment to complete", + "description": "When specified, azd waits up to this many seconds for the service deployment operation to finish before timing out.", + "minimum": 1 + }, "docker": { "$ref": "#/definitions/docker" }, diff --git a/schemas/v1.0/azure.yaml.json b/schemas/v1.0/azure.yaml.json index 92cd9a2f864..6d53850c8b4 100644 --- a/schemas/v1.0/azure.yaml.json +++ b/schemas/v1.0/azure.yaml.json @@ -139,6 +139,12 @@ "title": "Optional. Whether to use remote build for function app deployment", "description": "When set to true, the deployment package will be built remotely using Oryx. When set to false, the package is deployed as-is. If omitted, defaults to true for JavaScript, TypeScript, and Python function apps." }, + "deployTimeout": { + "type": "integer", + "title": "Optional. Maximum time in seconds to wait for service deployment to complete", + "description": "When specified, azd waits up to this many seconds for the service deployment operation to finish before timing out.", + "minimum": 1 + }, "docker": { "$ref": "#/definitions/docker" },