Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions cli/azd/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,11 +363,14 @@ This project uses Go 1.26. Use modern standard library features:
- **Search for prior art before adding helpers**: Search `test/`, `pkg/osutil`, and the package under test before adding cleanup, retry, process, or platform-specific helpers. Reuse or promote existing behavior and extract shared test utilities instead of creating parallel implementations
- **Isolate persistent user state**: Functional tests that install extensions or mutate config, auth, cache, or other user-level state must set `AZD_CONFIG_DIR` to a dedicated `tempDirWithDiagnostics(t)` directory on the CLI env, preserving both the parent environment and existing CLI env (`append(os.Environ(), cli.Env...)`) and setting `AZURE_DEV_COLLECT_TELEMETRY=no` so the telemetry background writer doesn't hold files open in the temp dir. Tests that only use the existing login should leave `AZD_CONFIG_DIR` unset
- **Make cleanup process-safe**: Wait for spawned commands to finish, register cleanup when state is created, and report failures. Use `osutil.RemoveAll` for directories containing executed binaries because Windows may retain transient locks; keep retries at the filesystem boundary instead of adding test sleeps. Since `t.Context()` is canceled before cleanup runs, use a separate bounded context when cleanup needs one
- **Isolate Docker client state**: Functional tests that run local Docker builds or logins should set `DOCKER_CONFIG` to a directory under `tempDirWithDiagnostics(t)`. This prevents parallel tests and Docker Desktop from sharing mutable context metadata or credentials
- **Guard fixture type assertions**: Check presence and type with `require` before casting values loaded from maps, JSON, environment files, or recordings. A raw type assertion panic can abort the package and hide the initiating failure
- **Use correct env vars for testing**:
- Non-interactive mode: `AZD_FORCE_TTY=false` (not `AZD_DEBUG_FORCE_NO_TTY`, which doesn't exist)
- No-prompt mode: use the `--no-prompt` flag for core azd commands; `AZD_NO_PROMPT=true` is only used for propagating no-prompt into extension subprocesses
- Suppress color: `NO_COLOR=1` — always set in test environments to prevent ANSI escape codes from breaking assertions
- Recording setup: use `AZD_TEST_AZURE_SUBSCRIPTION_ID`, `AZD_TEST_TENANT_ID`, and `AZD_TEST_AZURE_LOCATION`. For multi-tenant user logins, also set `AZURE_TENANT_ID` to prevent azd's tenant picker
- **Keep provision validation consistent between record and playback**: `validation.provision` is a user config key, not an environment variable. Do not disable it only while recording a cassette; either keep it enabled and handle possible warning prompts deterministically or ensure playback uses the same config
- **TypeScript test patterns**: Use `catch (e: unknown)` with type assertions, not `catch (e: any)` which bypasses strict mode
- **Reasonable timeouts**: Set test timeouts proportional to expected execution time. Don't use 5-minute timeouts for tests that shell out to `azd --help` (which completes in seconds)
- **Efficient directory checks**: To check if a directory is empty, use `os.Open` + `f.Readdirnames(1)` instead of `os.ReadDir` which reads the entire listing into memory
Expand Down
34 changes: 34 additions & 0 deletions cli/azd/docs/recording-functional-tests-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,40 @@ Config fallbacks are only consulted when the `CI` environment variable is unset.
| Tenant | `AZD_TEST_TENANT_ID` | `defaults.test.tenant` |
| Location | `AZD_TEST_AZURE_LOCATION` | `defaults.test.location` |

#### Recommended Local Recording Environment

Set the test-specific variables before recording so the functional harness does not depend on your regular azd defaults. If your login can access subscriptions in multiple tenants, also set `AZURE_TENANT_ID` to the same tenant. `AZD_TEST_TENANT_ID` configures the test harness, while `AZURE_TENANT_ID` filters azd's own tenant and subscription prompts.

PowerShell:

```powershell
$env:AZD_TEST_AZURE_SUBSCRIPTION_ID = "<subscription-id>"
$env:AZD_TEST_TENANT_ID = "<tenant-id>"
$env:AZD_TEST_AZURE_LOCATION = "eastus2"
$env:AZURE_TENANT_ID = $env:AZD_TEST_TENANT_ID
$env:AZURE_RECORD_MODE = "record"
```

Bash:

```bash
export AZD_TEST_AZURE_SUBSCRIPTION_ID="<subscription-id>"
export AZD_TEST_TENANT_ID="<tenant-id>"
export AZD_TEST_AZURE_LOCATION="eastus2"
export AZURE_TENANT_ID="$AZD_TEST_TENANT_ID"
export AZURE_RECORD_MODE="record"
```

#### Provision Validation Prompts

Provision-validation warnings can vary with the recording principal's permissions. There is no environment variable that disables local provision validation; use the user-config switch:

```bash
azd config set validation.provision off
```

Keep this setting consistent between recording and playback.

### Manual Re-recording

If you prefer manual control:
Expand Down
149 changes: 148 additions & 1 deletion cli/azd/pkg/azapi/azure_client_functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func Test_GetFunctionAppProperties(t *testing.T) {
response := armappservice.WebAppsClientGetResponse{
Site: armappservice.Site{
Location: new("eastus2"),
Kind: new("funcapp"),
Kind: new("functionapp,linux,container"),
Name: new("FUNC_APP_NAME"),
Properties: &armappservice.SiteProperties{
DefaultHostName: new("FUNC_APP_NAME.azurewebsites.net"),
Expand All @@ -45,6 +45,9 @@ func Test_GetFunctionAppProperties(t *testing.T) {
Name: new("FUNC_APP_NAME.scm.azurewebsites.net"),
},
},
SiteConfig: &armappservice.SiteConfig{
LinuxFxVersion: new("DOCKER|registry.azurecr.io/function:latest"),
},
},
},
}
Expand All @@ -60,6 +63,15 @@ func Test_GetFunctionAppProperties(t *testing.T) {
)
require.NoError(t, err)
require.Equal(t, []string{"FUNC_APP_NAME.azurewebsites.net"}, props.HostNames)
require.Equal(t,
"/subscriptions/SUBSCRIPTION_ID/resourceGroups/RESOURCE_GROUP_ID/"+
"providers/Microsoft.Web/serverfarms/FUNC_APP_PLAN",
props.ServerFarmID,
)
require.Len(t, props.HostNameSslStates, 1)
require.NotNil(t, props.ContainerConfiguration)
require.True(t, props.ContainerConfiguration.IsLinux)
require.True(t, props.ContainerConfiguration.IsContainer)
require.True(t, ran)
})

Expand Down Expand Up @@ -90,6 +102,141 @@ func Test_GetFunctionAppProperties(t *testing.T) {
})
}

func Test_GetFunctionAppPlan(t *testing.T) {
t.Run("Success", func(t *testing.T) {
mockContext := mocks.NewMockContext(t.Context())
azCli := newAzureClientFromMockContext(mockContext)

mockContext.HttpClient.When(func(request *http.Request) bool {
return request.Method == http.MethodGet &&
strings.Contains(request.URL.Path, "/serverfarms/FUNC_APP_PLAN")
}).RespondFn(func(request *http.Request) (*http.Response, error) {
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, armappservice.Plan{
Name: new("FUNC_APP_PLAN"),
SKU: &armappservice.SKUDescription{
Name: new("FC1"),
Tier: new("FlexConsumption"),
},
})
})

plan, err := azCli.GetFunctionAppPlan(*mockContext.Context, &AzCliFunctionAppProperties{
ServerFarmID: "/subscriptions/SUBSCRIPTION_ID/resourceGroups/RESOURCE_GROUP_ID/" +
"providers/Microsoft.Web/serverfarms/FUNC_APP_PLAN",
})

require.NoError(t, err)
require.Equal(t, "FUNC_APP_PLAN", *plan.Name)
require.Equal(t, "FlexConsumption", *plan.SKU.Tier)
})

t.Run("InvalidPlanResourceId", func(t *testing.T) {
azCli := newAzureClientFromMockContext(mocks.NewMockContext(t.Context()))

plan, err := azCli.GetFunctionAppPlan(t.Context(), &AzCliFunctionAppProperties{
ServerFarmID: "not-a-resource-id",
})

require.Nil(t, plan)
require.Error(t, err)
})

t.Run("ApiError", func(t *testing.T) {
mockContext := mocks.NewMockContext(t.Context())
azCli := newAzureClientFromMockContext(mockContext)

mockContext.HttpClient.When(func(request *http.Request) bool {
return request.Method == http.MethodGet &&
strings.Contains(request.URL.Path, "/serverfarms/FUNC_APP_PLAN")
}).RespondFn(func(request *http.Request) (*http.Response, error) {
return mocks.CreateEmptyHttpResponse(request, http.StatusNotFound)
})

plan, err := azCli.GetFunctionAppPlan(*mockContext.Context, &AzCliFunctionAppProperties{
ServerFarmID: "/subscriptions/SUBSCRIPTION_ID/resourceGroups/RESOURCE_GROUP_ID/" +
"providers/Microsoft.Web/serverfarms/FUNC_APP_PLAN",
})

require.Nil(t, plan)
require.Error(t, err)
})
}

func Test_DeployFunctionAppUsingZipFileFlexConsumption(t *testing.T) {
props := &AzCliFunctionAppProperties{
HostNameSslStates: []*armappservice.HostNameSSLState{
{
HostType: to.Ptr(armappservice.HostTypeRepository),
Name: new("FUNC_APP_NAME_SCM_HOST"),
},
},
}

t.Run("Success", func(t *testing.T) {
mockContext := mocks.NewMockContext(t.Context())
azCli := newAzureClientFromMockContext(mockContext)

var remoteBuild string
mockContext.HttpClient.When(func(request *http.Request) bool {
return request.Method == http.MethodPost &&
request.URL.Host == "FUNC_APP_NAME_SCM_HOST" &&
request.URL.Path == "/api/publish"
}).RespondFn(func(request *http.Request) (*http.Response, error) {
remoteBuild = request.URL.Query().Get("RemoteBuild")
return mocks.CreateHttpResponseWithBody(request, http.StatusAccepted, "deployment-id")
})
mockContext.HttpClient.When(func(request *http.Request) bool {
return request.Method == http.MethodGet &&
request.URL.Host == "FUNC_APP_NAME_SCM_HOST" &&
request.URL.Path == "/api/deployments/deployment-id"
}).RespondFn(func(request *http.Request) (*http.Response, error) {
return mocks.CreateHttpResponseWithBody(request, http.StatusOK, azsdk.PublishResponse{
Id: "deployment-id",
Status: azsdk.PublishStatusSuccess,
StatusText: "Deployment successful",
})
})

status, err := azCli.DeployFunctionAppUsingZipFileFlexConsumption(
*mockContext.Context,
"SUBSCRIPTION_ID",
props,
"FUNC_APP_NAME",
bytes.NewReader([]byte("zip")),
true,
)

require.NoError(t, err)
require.Equal(t, "Deployment successful", *status)
require.Equal(t, "true", remoteBuild)
})

t.Run("PublishError", func(t *testing.T) {
mockContext := mocks.NewMockContext(t.Context())
azCli := newAzureClientFromMockContext(mockContext)

mockContext.HttpClient.When(func(request *http.Request) bool {
return request.Method == http.MethodPost &&
request.URL.Host == "FUNC_APP_NAME_SCM_HOST" &&
request.URL.Path == "/api/publish"
}).RespondFn(func(request *http.Request) (*http.Response, error) {
return mocks.CreateEmptyHttpResponse(request, http.StatusBadRequest)
})

status, err := azCli.DeployFunctionAppUsingZipFileFlexConsumption(
*mockContext.Context,
"SUBSCRIPTION_ID",
props,
"FUNC_APP_NAME",
bytes.NewReader(nil),
false,
)

require.Nil(t, status)
require.ErrorContains(t, err, "publishing zip file")
})
}

func Test_DeployFunctionAppUsingZipFileRegular(t *testing.T) {
props := &AzCliFunctionAppProperties{
HostNames: []string{"FUNC_APP_NAME.azurewebsites.net"},
Expand Down
14 changes: 8 additions & 6 deletions cli/azd/pkg/azapi/function_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import (

// AzCliFunctionAppProperties contains properties for a Function App.
type AzCliFunctionAppProperties struct {
HostNames []string
ServerFarmID string
HostNameSslStates []*armappservice.HostNameSSLState
HostNames []string
ServerFarmID string
HostNameSslStates []*armappservice.HostNameSSLState
ContainerConfiguration *AppServiceContainerConfiguration
}

// GetFunctionAppProperties retrieves properties for a function app.
Expand All @@ -33,9 +34,10 @@ func (cli *AzureClient) GetFunctionAppProperties(
}

return &AzCliFunctionAppProperties{
HostNames: []string{*webApp.Properties.DefaultHostName},
ServerFarmID: *webApp.Properties.ServerFarmID,
HostNameSslStates: webApp.Properties.HostNameSSLStates,
HostNames: []string{*webApp.Properties.DefaultHostName},
ServerFarmID: *webApp.Properties.ServerFarmID,
HostNameSslStates: webApp.Properties.HostNameSSLStates,
ContainerConfiguration: appServiceContainerConfiguration(webApp),
}, nil
}

Expand Down
48 changes: 43 additions & 5 deletions cli/azd/pkg/azapi/webapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,46 @@ func (cli *AzureClient) UpdateAppServiceSlotContainerImage(
return nil
}

// AppServiceContainerConfiguration describes whether an App Service site can host a container image.
type AppServiceContainerConfiguration struct {
IsLinux bool
IsContainer bool
}

// GetAppServiceContainerConfiguration returns the container-related configuration for an App Service site.
func (cli *AzureClient) GetAppServiceContainerConfiguration(
ctx context.Context,
subscriptionId string,
resourceGroup string,
appName string,
) (*AppServiceContainerConfiguration, error) {
response, err := cli.appService(ctx, subscriptionId, resourceGroup, appName)
if err != nil {
return nil, err
}

return appServiceContainerConfiguration(response), nil
}

func appServiceContainerConfiguration(
response *armappservice.WebAppsClientGetResponse,
) *AppServiceContainerConfiguration {
configuration := &AppServiceContainerConfiguration{}
if response.Kind != nil {
configuration.IsLinux = strings.Contains(strings.ToLower(*response.Kind), "linux")
}
if response.Properties != nil &&
response.Properties.SiteConfig != nil &&
response.Properties.SiteConfig.LinuxFxVersion != nil {
configuration.IsContainer = strings.HasPrefix(
strings.ToUpper(*response.Properties.SiteConfig.LinuxFxVersion),
"DOCKER|",
)
}

return configuration
}

// ValidateAppServiceForContainerDeploy checks that the App Service is configured for container
// deployment (Linux kind with an existing DOCKER| linuxFxVersion). Returns an error with
// actionable suggestions if the site is not ready for container deployment.
Expand All @@ -486,22 +526,20 @@ func (cli *AzureClient) ValidateAppServiceForContainerDeploy(
resourceGroup string,
appName string,
) error {
response, err := cli.appService(ctx, subscriptionId, resourceGroup, appName)
configuration, err := cli.GetAppServiceContainerConfiguration(ctx, subscriptionId, resourceGroup, appName)
if err != nil {
return err
}

if response.Kind == nil || !strings.Contains(*response.Kind, "linux") {
if !configuration.IsLinux {
return fmt.Errorf(
"app service '%s' is not configured as a Linux app. "+
"Container deployment requires a Linux App Service Plan. "+
"Set 'kind: linux' in your bicep/terraform configuration",
appName)
}

if response.Properties == nil || response.Properties.SiteConfig == nil ||
response.Properties.SiteConfig.LinuxFxVersion == nil ||
!strings.HasPrefix(strings.ToUpper(*response.Properties.SiteConfig.LinuxFxVersion), "DOCKER|") {
if !configuration.IsContainer {
return fmt.Errorf(
"app service '%s' is not configured for container deployment. "+
"Ensure your infrastructure sets linuxFxVersion to a DOCKER| image "+
Expand Down
Loading
Loading