From 83c673c7018aa23ed131d10e42c196b67d5d380c Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 3 Jun 2024 18:38:23 -0700 Subject: [PATCH] aspire: Support Azure Functions Prototype The Aspire team has started to prototype Azure Functions support and this PR is the start of the support in `azd` for this deployment target under Aspire. In the current prototype, Aspire models this as a new `function.v0` resource type, which `azd` has special knowledge of. We need to do two special things with this resource type: 1. When generating IaC for the project, we need to generate IaC that allows us to use the Functions on ACA feature (where we can create a `Microsoft.Web/sites` resource with correct configuration to run a functions app on an existing container app environment) 2. Since function projects do not support using `dotnet publish` to produce a container image (there is work in flight to support this), we require a `Dockerfile` at the root of the project, which we `docker build`. In the next phase of the prototype, we will replace `function.v0` with `project.v0`, so the `function.v0` manifest type will not live forever, but until, `dotnet run` and `dotnet publish` works as expected for Azure Functions projects, we'll continue to model this as its own type in the manifest. Note that there are still a lot of rough edges here... The initial `azd up` works if you ensure your function app name in the app host is unique across all of `azure` but future updates seem to hang. Also, none of the corresponding aspire support is in tree yet, so this is not something that anyone can just pick up and use right now, but I want to get the work out from behind our `-pr` repository and rebased on top of the latest main as we are starting to pick up steam on this work. --- cli/azd/cmd/container.go | 2 + cli/azd/pkg/apphost/generate.go | 105 ++++++++- cli/azd/pkg/apphost/generate_types.go | 8 + cli/azd/pkg/appservice/functionapp.go | 218 ++++++++++++++++++ cli/azd/pkg/project/dotnet_importer.go | 83 ++++++- .../pkg/project/framework_service_docker.go | 4 - .../project/framework_service_docker_test.go | 6 - cli/azd/pkg/project/service_config.go | 2 + .../service_target_dotnet_containerapp.go | 132 ++++++++--- cli/azd/pkg/tools/docker/docker.go | 9 +- cli/azd/pkg/tools/docker/docker_test.go | 6 - .../apphost/templates/funcApp.tmpl.yamlt | 39 ++++ .../resources/apphost/templates/main.bicept | 6 + .../apphost/templates/resources.bicept | 39 ++++ 14 files changed, 589 insertions(+), 70 deletions(-) create mode 100644 cli/azd/pkg/appservice/functionapp.go create mode 100644 cli/azd/resources/apphost/templates/funcApp.tmpl.yamlt diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 9551d3882d7..7315970fcd1 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -22,6 +22,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/ai" "github.com/azure/azure-dev/cli/azd/pkg/alpha" + "github.com/azure/azure-dev/cli/azd/pkg/appservice" "github.com/azure/azure-dev/cli/azd/pkg/auth" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azd" @@ -571,6 +572,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterSingleton(entraid.NewEntraIdService) container.MustRegisterSingleton(azcli.NewContainerRegistryService) container.MustRegisterSingleton(containerapps.NewContainerAppService) + container.MustRegisterSingleton(appservice.NewFunctionAppService) container.MustRegisterSingleton(containerregistry.NewRemoteBuildManager) container.MustRegisterSingleton(keyvault.NewKeyVaultService) container.MustRegisterSingleton(storage.NewFileShareService) diff --git a/cli/azd/pkg/apphost/generate.go b/cli/azd/pkg/apphost/generate.go index efa94837288..3ea596210dd 100644 --- a/cli/azd/pkg/apphost/generate.go +++ b/cli/azd/pkg/apphost/generate.go @@ -140,6 +140,24 @@ func Dockerfiles(manifest *Manifest) map[string]genDockerfile { return res } +// Functions returns information about all function.v0 resources from a manifest. +func Functions(manifest *Manifest) map[string]genFunction { + res := make(map[string]genFunction) + + for name, comp := range manifest.Resources { + switch comp.Type { + case "function.v0": + res[name] = genFunction{ + Path: *comp.Path, + Env: comp.Env, + Bindings: comp.Bindings, + } + } + } + + return res +} + // Containers returns information about all container.v0 resources from a manifest. func Containers(manifest *Manifest) map[string]genContainer { res := make(map[string]genContainer) @@ -225,6 +243,32 @@ func ContainerAppManifestTemplateForProject( return buf.String(), nil } +// FunctionAppManifestTemplateForProject returns the function app manifest template for a given project. +// It can be used (after evaluation) to deploy the service to a container app environment. +func FunctionAppManifestTemplateForProject( + manifest *Manifest, projectName string, options AppHostOptions) (string, error) { + generator := newInfraGenerator() + + if err := generator.LoadManifest(manifest); err != nil { + return "", err + } + + if err := generator.Compile(); err != nil { + return "", err + } + + var buf bytes.Buffer + + tmplCtx := generator.containerAppTemplateContexts[projectName] + + err := genTemplates.ExecuteTemplate(&buf, "funcApp.tmpl.yaml", tmplCtx) + if err != nil { + return "", fmt.Errorf("executing template: %w", err) + } + + return buf.String(), nil +} + // BicepTemplate returns a filesystem containing the generated bicep files for the given manifest. These files represent // the shared infrastructure that would normally be under the `infra/` folder for the given manifest. func BicepTemplate(name string, manifest *Manifest, options AppHostOptions) (*memfs.FS, error) { @@ -434,6 +478,7 @@ type infraGenerator struct { containers map[string]genContainer dapr map[string]genDapr dockerfiles map[string]genDockerfile + functions map[string]genFunction projects map[string]genProject connectionStrings map[string]string // keeps the value from value.v0 resources if provided. @@ -450,6 +495,7 @@ func newInfraGenerator() *infraGenerator { return &infraGenerator{ bicepContext: genBicepTemplateContext{ ContainerAppEnvironmentServices: make(map[string]genContainerAppEnvironmentServices), + Functions: make([]string, 0), KeyVaults: make(map[string]genKeyVault), ContainerApps: make(map[string]genContainerApp), DaprComponents: make(map[string]genDaprComponent), @@ -461,6 +507,7 @@ func newInfraGenerator() *infraGenerator { containers: make(map[string]genContainer), dapr: make(map[string]genDapr), dockerfiles: make(map[string]genDockerfile), + functions: make(map[string]genFunction), projects: make(map[string]genProject), connectionStrings: make(map[string]string), resourceTypes: make(map[string]string), @@ -561,6 +608,8 @@ func (b *infraGenerator) LoadManifest(m *Manifest) error { if err != nil { return err } + case "function.v0": + b.addFunctionApp(name, *comp.Path, comp.Env, comp.Bindings) case "dockerfile.v0": b.addDockerfile(name, *comp.Path, *comp.Context, comp.Env, comp.Bindings, comp.BuildArgs, comp.Args) case "parameter.v0": @@ -604,6 +653,11 @@ func (b *infraGenerator) requireContainerRegistry() { b.bicepContext.HasContainerRegistry = true } +func (b *infraGenerator) requireAppInsights() { + b.requireLogAnalyticsWorkspace() + b.bicepContext.HasAppInsights = true +} + func (b *infraGenerator) requireDaprStore() string { daprStoreName := "daprStore" @@ -1052,6 +1106,22 @@ func (b *infraGenerator) addDockerfile( } } +func (b *infraGenerator) addFunctionApp(name string, path string, env map[string]string, + bindings custommaps.WithOrder[Binding]) { + b.requireCluster() + b.requireContainerRegistry() + b.requireAppInsights() + + app := genFunction{ + Path: path, + Env: env, + Bindings: bindings, + } + + b.bicepContext.Functions = append(b.bicepContext.Functions, name) + b.functions[name] = app +} + // singleQuotedStringRegex is a regular expression pattern used to match single-quoted strings. var singleQuotedStringRegex = regexp.MustCompile(`'[^']*'`) var propertyNameRegex = regexp.MustCompile(`'([^']*)':`) @@ -1086,6 +1156,16 @@ func (b *infraGenerator) compileIngress() error { ingressBindings: bindingsFromIngress, } } + for name, function := range b.functions { + ingress, bindingsFromIngress, err := buildAcaIngress(function.Bindings, 80) + if err != nil { + return fmt.Errorf("configuring ingress for resource %s: %w", name, err) + } + result[name] = ingressDetails{ + ingress: ingress, + ingressBindings: bindingsFromIngress, + } + } for name, project := range b.projects { ingress, bindingsFromIngress, err := buildAcaIngress(project.Bindings, 8080) if err != nil { @@ -1205,6 +1285,22 @@ func (b *infraGenerator) Compile() error { b.containerAppTemplateContexts[resourceName] = projectTemplateCtx } + for resourceName, funcapp := range b.functions { + projectTemplateCtx := genContainerAppManifestTemplateContext{ + Name: resourceName, + Env: make(map[string]string), + Secrets: make(map[string]string), + KeyVaultSecrets: make(map[string]string), + Ingress: b.allServicesIngress[resourceName].ingress, + } + + if err := b.buildEnvBlock(funcapp.Env, &projectTemplateCtx); err != nil { + return fmt.Errorf("configuring environment for resource %s: %w", resourceName, err) + } + + b.containerAppTemplateContexts[resourceName] = projectTemplateCtx + } + for resourceName, project := range b.projects { projectTemplateCtx := genContainerAppManifestTemplateContext{ Name: resourceName, @@ -1364,7 +1460,11 @@ func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string } switch { - case targetType == "project.v0" || targetType == "container.v0" || targetType == "dockerfile.v0": + case targetType == "project.v0" || + targetType == "container.v0" || + targetType == "dockerfile.v0" || + targetType == "function.v0": + if !strings.HasPrefix(prop, "bindings.") { return "", fmt.Errorf("unsupported property referenced in binding expression: %s for %s", prop, targetType) } @@ -1390,6 +1490,9 @@ func (b infraGenerator) evalBindingRef(v string, emitType inputEmitType) (string } else if targetType == "dockerfile.v0" { bindings := b.dockerfiles[resource].Bindings binding, has = bindings.Get(bindingName) + } else if targetType == "function.v0" { + bindings := b.functions[resource].Bindings + binding, has = bindings.Get(bindingName) } if !has { diff --git a/cli/azd/pkg/apphost/generate_types.go b/cli/azd/pkg/apphost/generate_types.go index 2de66fe9771..8b292cba6e3 100644 --- a/cli/azd/pkg/apphost/generate_types.go +++ b/cli/azd/pkg/apphost/generate_types.go @@ -72,6 +72,12 @@ type genBuildContainerDetails struct { Secrets map[string]ContainerV1BuildSecrets } +type genFunction struct { + Path string + Env map[string]string + Bindings custommaps.WithOrder[Binding] +} + type genProject struct { Path string Env map[string]string @@ -121,9 +127,11 @@ type genBicepTemplateContext struct { HasContainerEnvironment bool HasDaprStore bool HasLogAnalyticsWorkspace bool + HasAppInsights bool RequiresPrincipalId bool RequiresStorageVolume bool HasBindMounts bool + Functions []string KeyVaults map[string]genKeyVault ContainerAppEnvironmentServices map[string]genContainerAppEnvironmentServices ContainerApps map[string]genContainerApp diff --git a/cli/azd/pkg/appservice/functionapp.go b/cli/azd/pkg/appservice/functionapp.go new file mode 100644 index 00000000000..fc0acad5021 --- /dev/null +++ b/cli/azd/pkg/appservice/functionapp.go @@ -0,0 +1,218 @@ +package appservice + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "slices" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/streaming" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2" + "github.com/azure/azure-dev/cli/azd/pkg/account" + "github.com/benbjohnson/clock" + "gopkg.in/yaml.v3" +) + +// apiVersionKey is the key that can be set in the root of a deployment yaml to control the API version used when creating +// or updating the app service app. When unset, we use the default API version of the armappservice.WevAppsClient. +const apiVersionKey = "api-version" + +type FunctionAppService struct { + credentialProvider account.SubscriptionCredentialProvider + transport policy.Transporter + clock clock.Clock + armClientOptions *arm.ClientOptions +} + +func NewFunctionAppService( + credentialProvider account.SubscriptionCredentialProvider, + transport policy.Transporter, + clock clock.Clock, + armClientOptions *arm.ClientOptions, +) *FunctionAppService { + return &FunctionAppService{ + credentialProvider: credentialProvider, + transport: transport, + clock: clock, + armClientOptions: armClientOptions, + } +} + +func (fas *FunctionAppService) DeployYAML(ctx context.Context, + subscriptionId string, + resourceGroupName string, + appName string, + containerAppYaml []byte) error { + + var obj map[string]any + if err := yaml.Unmarshal(containerAppYaml, &obj); err != nil { + return fmt.Errorf("decoding yaml: %w", err) + } + + var poller *runtime.Poller[armappservice.WebAppsClientCreateOrUpdateResponse] + + // The way we make the initial request depends on whether the apiVersion is specified in the YAML. + if apiVersion, ok := obj[apiVersionKey].(string); ok { + // When the apiVersion is specified, we need to use a custom policy to inject the apiVersion and body into the + // request. This is because the ContainerAppsClient is built for a specific api version and does not allow us to + // change it. The custom policy allows us to use the parts of the SDK around building the request URL and using + // the standard pipeline - but we have to use a policy to change the api-version header and inject the body since + // the armappcontainers.ContainerApp{} is also built for a specific api version. + customPolicy := &customApiVersionAndBodyPolicy{ + apiVersion: apiVersion, + } + + appClient, err := fas.createWebAppsClientWithPolicy(ctx, subscriptionId, customPolicy) + if err != nil { + return err + } + + // Remove the apiVersion field from the object so it doesn't get injected into the request body. On the wire this + // is in a query parameter, not the body. + delete(obj, apiVersionKey) + + functionAppJson, err := json.Marshal(obj) + if err != nil { + panic("should not have failed") + } + + // Set the body injected by the policy to be the full container app JSON from the YAML. + customPolicy.body = (*json.RawMessage)(&functionAppJson) + + // It doesn't matter what we configure here - the value is going to be overwritten by the custom policy. But we need + // to pass in a value, so use the zero value. + emptyApp := armappservice.Site{} + + p, err := appClient.BeginCreateOrUpdate(ctx, resourceGroupName, appName, emptyApp, nil) + if err != nil { + return fmt.Errorf("applying manifest: %w", err) + } + poller = p + + // Now that we've sent the request, clear the body so it is not injected on any subsequent requests (e.g. ones made + // by the poller when we poll). + customPolicy.body = nil + } else { + // When the apiVersion field is unset in the YAML, we can use the standard SDK to build the request and send it + // like normal. + appClient, err := fas.createWebAppsClient(ctx, subscriptionId) + if err != nil { + return err + } + + containerAppJson, err := json.Marshal(obj) + if err != nil { + panic("should not have failed") + } + + var site armappservice.Site + if err := json.Unmarshal(containerAppJson, &site); err != nil { + return fmt.Errorf("converting to container app type: %w", err) + } + + p, err := appClient.BeginCreateOrUpdate(ctx, resourceGroupName, appName, site, nil) + if err != nil { + return fmt.Errorf("applying manifest: %w", err) + } + + poller = p + } + + _, err := poller.PollUntilDone(ctx, nil) + if err != nil { + return fmt.Errorf("polling for container app update completion: %w", err) + } + + return nil +} + +func (fas *FunctionAppService) Endpoints(ctx context.Context, + subscriptionId string, + resourceGroupName string, + appName string, +) ([]string, error) { + appClient, err := fas.createWebAppsClient(ctx, subscriptionId) + if err != nil { + return nil, err + } + + res, err := appClient.Get(ctx, resourceGroupName, appName, nil) + if err != nil { + return nil, fmt.Errorf("getting web app: %w", err) + } + + var endpoints []string + for _, hostName := range res.Properties.HostNames { + if hostName != nil && *hostName != "" { + endpoints = append(endpoints, fmt.Sprintf("https://%s/", *hostName)) + } + } + + return endpoints, nil +} + +func (fas *FunctionAppService) createWebAppsClient( + ctx context.Context, + subscriptionId string, +) (*armappservice.WebAppsClient, error) { + credential, err := fas.credentialProvider.CredentialForSubscription(ctx, subscriptionId) + if err != nil { + return nil, err + } + + client, err := armappservice.NewWebAppsClient(subscriptionId, credential, fas.armClientOptions) + if err != nil { + return nil, fmt.Errorf("creating ContainerApps client: %w", err) + } + + return client, nil +} + +func (fas *FunctionAppService) createWebAppsClientWithPolicy( + ctx context.Context, + subscriptionId string, + policy policy.Policy, +) (*armappservice.WebAppsClient, error) { + credential, err := fas.credentialProvider.CredentialForSubscription(ctx, subscriptionId) + if err != nil { + return nil, err + } + + // Clone the options so we don't modify the original - we don't want to inject this custom policy into every request. + options := *fas.armClientOptions + options.PerCallPolicies = append(slices.Clone(options.PerCallPolicies), policy) + + client, err := armappservice.NewWebAppsClient(subscriptionId, credential, &options) + if err != nil { + return nil, fmt.Errorf("creating WebApps client: %w", err) + } + + return client, nil +} + +type customApiVersionAndBodyPolicy struct { + apiVersion string + body *json.RawMessage +} + +func (p *customApiVersionAndBodyPolicy) Do(req *policy.Request) (*http.Response, error) { + if p.body != nil { + reqQP := req.Raw().URL.Query() + reqQP.Set("api-version", p.apiVersion) + req.Raw().URL.RawQuery = reqQP.Encode() + + log.Printf("setting body to %s", string(*p.body)) + + if err := req.SetBody(streaming.NopCloser(bytes.NewReader(*p.body)), "application/json"); err != nil { + return nil, fmt.Errorf("updating request body: %w", err) + } + } + + return req.Next() +} diff --git a/cli/azd/pkg/project/dotnet_importer.go b/cli/azd/pkg/project/dotnet_importer.go index 524072debd9..8b6cf2e2170 100644 --- a/cli/azd/pkg/project/dotnet_importer.go +++ b/cli/azd/pkg/project/dotnet_importer.go @@ -256,6 +256,49 @@ func (ai *DotNetImporter) Services( services[svc.Name] = svc } + functions := apphost.Functions(manifest) + for name, function := range functions { + function.Path = filepath.Join(filepath.Dir(function.Path), "Dockerfile") + + relPath, err := filepath.Rel(p.Path, filepath.Dir(function.Path)) + if err != nil { + return nil, err + } + + // TODO(ellismg): Some of this code is duplicated from project.Parse, we should centralize this logic long term. + svc := &ServiceConfig{ + RelativePath: relPath, + Language: ServiceLanguageDocker, + Host: DotNetContainerAppTarget, + Docker: DockerProjectOptions{ + Path: function.Path, + // TODO(ellismg): This is a hack - the function.v0 resources is backed by a Dockerfile since we can not + // `dotnet publish` it. We need the context to include the Service Defaults project which is a sibling of + // this. We should either have a `context` field on function.v0 and set this in the apphost or just make + // `dotnet publish` work for function app csproj's. + Context: filepath.Dir(filepath.Dir(function.Path)), + }, + } + + svc.Name = name + svc.Project = p + svc.EventDispatcher = ext.NewEventDispatcher[ServiceLifecycleEventArgs]() + + svc.Infra.Provider, err = provisioning.ParseProvider(svc.Infra.Provider) + if err != nil { + return nil, fmt.Errorf("parsing service %s: %w", svc.Name, err) + } + + svc.DotNetContainerApp = &DotNetContainerAppOptions{ + Manifest: manifest, + ProjectName: name, + AppHostPath: svcConfig.Path(), + FunctionApp: true, + } + + services[svc.Name] = svc + } + containers := apphost.Containers(manifest) for name, container := range containers { // TODO(ellismg): Some of this code is duplicated from project.Parse, we should centralize this logic long term. @@ -510,11 +553,25 @@ func (ai *DotNetImporter) SynthAllInfrastructure( // writeManifestForResource writes the containerApp.tmpl.yaml for the given resource to the generated filesystem. The // manifest is written to a file name "containerApp.tmpl.yaml" in the same directory as the project that produces the // container we will deploy. - writeManifestForResource := func(name string) error { - containerAppManifest, err := apphost.ContainerAppManifestTemplateForProject( - manifest, name, apphost.AppHostOptions{}) - if err != nil { - return fmt.Errorf("generating containerApp.tmpl.yaml for resource %s: %w", name, err) + writeManifestForResource := func(name string, isFunc bool) error { + var manifestText string + + if isFunc { + functionAppManifest, err := apphost.FunctionAppManifestTemplateForProject( + manifest, name, apphost.AppHostOptions{}) + if err != nil { + return fmt.Errorf("generating containerApp.tmpl.yaml for resource %s: %w", name, err) + } + + manifestText = functionAppManifest + } else { + containerAppManifest, err := apphost.ContainerAppManifestTemplateForProject( + manifest, name, apphost.AppHostOptions{}) + if err != nil { + return fmt.Errorf("generating containerApp.tmpl.yaml for resource %s: %w", name, err) + } + + manifestText = containerAppManifest } normalPath, err := filepath.EvalSymlinks(svcConfig.Path()) @@ -533,23 +590,29 @@ func (ai *DotNetImporter) SynthAllInfrastructure( return err } - return generatedFS.WriteFile(manifestPath, []byte(containerAppManifest), osutil.PermissionFileOwnerOnly) + return generatedFS.WriteFile(manifestPath, []byte(manifestText), osutil.PermissionFileOwnerOnly) } for name := range apphost.ProjectPaths(manifest) { - if err := writeManifestForResource(name); err != nil { + if err := writeManifestForResource(name, false); err != nil { return nil, err } } for name := range apphost.Dockerfiles(manifest) { - if err := writeManifestForResource(name); err != nil { + if err := writeManifestForResource(name, false); err != nil { return nil, err } } for name := range apphost.Containers(manifest) { - if err := writeManifestForResource(name); err != nil { + if err := writeManifestForResource(name, false); err != nil { + return nil, err + } + } + + for name := range apphost.Functions(manifest) { + if err := writeManifestForResource(name, true); err != nil { return nil, err } } @@ -559,7 +622,7 @@ func (ai *DotNetImporter) SynthAllInfrastructure( return nil, err } for name := range bcs { - if err := writeManifestForResource(name); err != nil { + if err := writeManifestForResource(name, false); err != nil { return nil, err } } diff --git a/cli/azd/pkg/project/framework_service_docker.go b/cli/azd/pkg/project/framework_service_docker.go index fd3cf190c27..bfbd69db1da 100644 --- a/cli/azd/pkg/project/framework_service_docker.go +++ b/cli/azd/pkg/project/framework_service_docker.go @@ -548,10 +548,6 @@ func getDockerOptionsWithDefaults(options DockerProjectOptions) DockerProjectOpt options.Path = "./Dockerfile" } - if options.Platform == "" { - options.Platform = docker.DefaultPlatform - } - if options.Context == "" { options.Context = "." } diff --git a/cli/azd/pkg/project/framework_service_docker_test.go b/cli/azd/pkg/project/framework_service_docker_test.go index 72f2a8ce9d1..eea9ba17c6e 100644 --- a/cli/azd/pkg/project/framework_service_docker_test.go +++ b/cli/azd/pkg/project/framework_service_docker_test.go @@ -72,7 +72,6 @@ services: require.Equal(t, []string{ "build", "-f", "./Dockerfile", - "--platform", docker.DefaultPlatform, "-t", "test-proj-web", ".", }, argsNoFile) @@ -176,7 +175,6 @@ services: require.Equal(t, []string{ "build", "-f", "./Dockerfile.dev", - "--platform", docker.DefaultPlatform, "-t", "test-proj-web", "../", }, argsNoFile) @@ -258,8 +256,6 @@ func Test_DockerProject_Build(t *testing.T) { "build", "-f", "./Dockerfile", - "--platform", - "linux/amd64", "-t", "test-app-api", ".", @@ -312,8 +308,6 @@ func Test_DockerProject_Build(t *testing.T) { "build", "-f", "./Dockerfile", - "--platform", - "linux/amd64", "-t", "test-app-api", ".", diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index 0b774d0b45e..5afb8efdfa6 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -53,6 +53,8 @@ type DotNetContainerAppOptions struct { ProjectName string // ContainerImage is non-empty when a prebuilt container image is being used. ContainerImage string + // FunctionApp is true when this container app is a azure functions function app. + FunctionApp bool } // Path returns the fully qualified path to the project diff --git a/cli/azd/pkg/project/service_target_dotnet_containerapp.go b/cli/azd/pkg/project/service_target_dotnet_containerapp.go index e09b72168cc..08a5e02504a 100644 --- a/cli/azd/pkg/project/service_target_dotnet_containerapp.go +++ b/cli/azd/pkg/project/service_target_dotnet_containerapp.go @@ -15,6 +15,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/apphost" + "github.com/azure/azure-dev/cli/azd/pkg/appservice" "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azure" @@ -31,6 +32,7 @@ type dotnetContainerAppTarget struct { env *environment.Environment containerHelper *ContainerHelper containerAppService containerapps.ContainerAppService + functionAppService *appservice.FunctionAppService resourceManager ResourceManager dotNetCli *dotnet.Cli cosmosDbService cosmosdb.CosmosDbService @@ -51,6 +53,7 @@ func NewDotNetContainerAppTarget( env *environment.Environment, containerHelper *ContainerHelper, containerAppService containerapps.ContainerAppService, + functionAppService *appservice.FunctionAppService, resourceManager ResourceManager, dotNetCli *dotnet.Cli, cosmosDbService cosmosdb.CosmosDbService, @@ -62,6 +65,7 @@ func NewDotNetContainerAppTarget( env: env, containerHelper: containerHelper, containerAppService: containerAppService, + functionAppService: functionAppService, resourceManager: resourceManager, dotNetCli: dotNetCli, cosmosDbService: cosmosDbService, @@ -162,7 +166,12 @@ func (at *dotnetContainerAppTarget) Deploy( remoteImageName = fmt.Sprintf("%s/%s", dockerCreds.LoginServer, imageName) } - progress.SetProgress(NewServiceProgress("Updating container app")) + appType := "container" + if serviceConfig.DotNetContainerApp.FunctionApp { + appType = "function" + } + + progress.SetProgress(NewServiceProgress(fmt.Sprintf("Updating %s app", appType))) var manifest string @@ -187,15 +196,27 @@ func (at *dotnetContainerAppTarget) Deploy( serviceConfig.DotNetContainerApp.AppHostPath, serviceConfig.DotNetContainerApp.ProjectName) - generatedManifest, err := apphost.ContainerAppManifestTemplateForProject( - serviceConfig.DotNetContainerApp.Manifest, - serviceConfig.DotNetContainerApp.ProjectName, - apphost.AppHostOptions{}, - ) - if err != nil { - return nil, fmt.Errorf("generating container app manifest: %w", err) + if !serviceConfig.DotNetContainerApp.FunctionApp { + generatedManifest, err := apphost.ContainerAppManifestTemplateForProject( + serviceConfig.DotNetContainerApp.Manifest, + serviceConfig.DotNetContainerApp.ProjectName, + apphost.AppHostOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("generating container app manifest: %w", err) + } + manifest = generatedManifest + } else { + generatedManifest, err := apphost.FunctionAppManifestTemplateForProject( + serviceConfig.DotNetContainerApp.Manifest, + serviceConfig.DotNetContainerApp.ProjectName, + apphost.AppHostOptions{}, + ) + if err != nil { + return nil, fmt.Errorf("generating container app manifest: %w", err) + } + manifest = generatedManifest } - manifest = generatedManifest } fns := &containerAppTemplateManifestFuncs{ @@ -209,7 +230,7 @@ func (at *dotnetContainerAppTarget) Deploy( keyvaultService: at.keyvaultService, } - tmpl, err := template.New("containerApp.tmpl.yaml"). + tmpl, err := template.New(""). Option("missingkey=error"). Funcs(template.FuncMap{ "urlHost": fns.UrlHost, @@ -229,7 +250,7 @@ func (at *dotnetContainerAppTarget) Deploy( }). Parse(manifest) if err != nil { - return nil, fmt.Errorf("failing parsing containerApp.tmpl.yaml: %w", err) + return nil, fmt.Errorf("failing parsing manifest template: %w", err) } var inputs map[string]any @@ -254,40 +275,75 @@ func (at *dotnetContainerAppTarget) Deploy( return nil, fmt.Errorf("failed executing template file: %w", err) } - err = at.containerAppService.DeployYaml( - ctx, - targetResource.SubscriptionId(), - targetResource.ResourceGroupName(), - serviceConfig.Name, - []byte(builder.String()), - ) - if err != nil { - return nil, fmt.Errorf("updating container app service: %w", err) - } + if !serviceConfig.DotNetContainerApp.FunctionApp { + err = at.containerAppService.DeployYaml( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + serviceConfig.Name, + []byte(builder.String()), + ) + if err != nil { + return nil, fmt.Errorf("updating container app service: %w", err) + } - progress.SetProgress(NewServiceProgress("Fetching endpoints for container app service")) + progress.SetProgress(NewServiceProgress("Fetching endpoints for container app service")) - containerAppTarget := environment.NewTargetResource( - targetResource.SubscriptionId(), - targetResource.ResourceGroupName(), - serviceConfig.Name, - string(azapi.AzureResourceTypeContainerApp)) + containerAppTarget := environment.NewTargetResource( + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + serviceConfig.Name, + string(azapi.AzureResourceTypeContainerApp)) - endpoints, err := at.Endpoints(ctx, serviceConfig, containerAppTarget) - if err != nil { - return nil, err - } + endpoints, err := at.Endpoints(ctx, serviceConfig, containerAppTarget) + if err != nil { + return nil, err + } - return &ServiceDeployResult{ - Package: packageOutput, - TargetResourceId: azure.ContainerAppRID( + return &ServiceDeployResult{ + Package: packageOutput, + TargetResourceId: azure.ContainerAppRID( + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + serviceConfig.Name, + ), + Kind: ContainerAppTarget, + Endpoints: endpoints, + }, nil + } else { + err = at.functionAppService.DeployYAML( + ctx, targetResource.SubscriptionId(), targetResource.ResourceGroupName(), serviceConfig.Name, - ), - Kind: ContainerAppTarget, - Endpoints: endpoints, - }, nil + []byte(builder.String()), + ) + if err != nil { + return nil, fmt.Errorf("updating container app service: %w", err) + } + progress.SetProgress(NewServiceProgress("Fetching endpoints")) + + endpoints, err := at.functionAppService.Endpoints( + ctx, + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + serviceConfig.Name, + ) + if err != nil { + return nil, err + } + + return &ServiceDeployResult{ + Package: packageOutput, + TargetResourceId: azure.WebsiteRID( + targetResource.SubscriptionId(), + targetResource.ResourceGroupName(), + serviceConfig.Name, + ), + Kind: ContainerAppTarget, + Endpoints: endpoints, + }, nil + } } // Gets endpoint for the container app service diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index e524468bcef..dd99bc3c649 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -62,10 +62,6 @@ func (d *Cli) Build( buildEnv []string, buildProgress io.Writer, ) (string, error) { - if strings.TrimSpace(platform) == "" { - platform = DefaultPlatform - } - tmpFolder, err := os.MkdirTemp(os.TempDir(), "azd-docker-build") defer func() { // fail to remove tmp files is not so bad as the OS will delete it @@ -81,7 +77,10 @@ func (d *Cli) Build( args := []string{ "build", "-f", dockerFilePath, - "--platform", platform, + } + + if platform != "" { + args = append(args, "--platform", platform) } if target != "" { diff --git a/cli/azd/pkg/tools/docker/docker_test.go b/cli/azd/pkg/tools/docker/docker_test.go index 21a3151b5b0..8e926187684 100644 --- a/cli/azd/pkg/tools/docker/docker_test.go +++ b/cli/azd/pkg/tools/docker/docker_test.go @@ -147,7 +147,6 @@ func Test_DockerBuildEmptyPlatform(t *testing.T) { cwd := "." dockerFile := "./Dockerfile" dockerContext := "../" - platform := DefaultPlatform imageName := "IMAGE_NAME" buildArgs := []string{"foo=bar"} @@ -167,7 +166,6 @@ func Test_DockerBuildEmptyPlatform(t *testing.T) { require.Equal(t, []string{ "build", "-f", dockerFile, - "--platform", platform, "-t", imageName, "--build-arg", buildArgs[0], dockerContext, @@ -197,7 +195,6 @@ func Test_DockerBuildArgsEmpty(t *testing.T) { cwd := "." dockerFile := "./Dockerfile" dockerContext := "../" - platform := DefaultPlatform imageName := "IMAGE_NAME" buildArgs := []string{} @@ -217,7 +214,6 @@ func Test_DockerBuildArgsEmpty(t *testing.T) { require.Equal(t, []string{ "build", "-f", dockerFile, - "--platform", platform, "-t", imageName, dockerContext, }, argsNoFile) @@ -246,7 +242,6 @@ func Test_DockerBuildArgsMultiple(t *testing.T) { cwd := "." dockerFile := "./Dockerfile" dockerContext := "../" - platform := DefaultPlatform imageName := "IMAGE_NAME" buildArgs := []string{"foo=bar", "bar=baz"} @@ -266,7 +261,6 @@ func Test_DockerBuildArgsMultiple(t *testing.T) { require.Equal(t, []string{ "build", "-f", dockerFile, - "--platform", platform, "-t", imageName, "--build-arg", buildArgs[0], "--build-arg", buildArgs[1], diff --git a/cli/azd/resources/apphost/templates/funcApp.tmpl.yamlt b/cli/azd/resources/apphost/templates/funcApp.tmpl.yamlt new file mode 100644 index 00000000000..b47882d1da2 --- /dev/null +++ b/cli/azd/resources/apphost/templates/funcApp.tmpl.yamlt @@ -0,0 +1,39 @@ +{{define "funcApp.tmpl.yaml" -}} +api-version: "2023-12-01" +location: {{ "{{ .Env.AZURE_LOCATION }}" }} +kind: functionapp,linux,container,azurecontainerapps +identity: + type: UserAssigned + userAssignedIdentities: + ? {{ `"{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}"` }} + : {} +properties: + workloadProfileName: Consumption + managedEnvironmentId: {{ "{{ .Env.AZURE_CONTAINER_APPS_ENVIRONMENT_ID }}" }} + siteConfig: + linuxFxVersion: {{ "DOCKER|{{ .Image }}" }} + acrUseManagedIdentityCreds: true + acrUserManagedIdentityID: {{ "{{ .Env.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID }}" }} + appSettings: + - name: APPLICATIONINSIGHTS_CONNECTION_STRING + value: {{ "{{ .Env.AZURE_APP_INSIGHTS_CONNECTION_STRING }}" }} + - name: AZURE_CLIENT_ID + value: {{ "{{ .Env.MANAGED_IDENTITY_CLIENT_ID }}" }} + - name: DOCKER_REGISTRY_SERVER_URL + value: {{ "{{ .Env.AZURE_CONTAINER_REGISTRY_ENDPOINT }}" }} + - name: AzureWebJobsStorage__credential + value: managedidentity + - name: AzureWebJobsStorage__clientId + value: {{ "{{ .Env.MANAGED_IDENTITY_CLIENT_ID }}" }} + - name: AzureWebJobsStorage__accountName + value: {{ "{{ .Env.SERVICE_" }}{{ alphaSnakeUpper $.Name}}_WEBJOBS_STORAGE{{ "_NAME }} "}} + - name: FUNCTIONS_EXTENSION_VERSION + value: "~4" +{{- range $name, $value := .Env}} + - name: {{$name}} + value: {{$value}} +{{- end}} +tags: + azd-service-name: {{ .Name }} + aspire-resource-name: {{ .Name }} +{{ end}} \ No newline at end of file diff --git a/cli/azd/resources/apphost/templates/main.bicept b/cli/azd/resources/apphost/templates/main.bicept index 43969c44094..c5da16b6422 100644 --- a/cli/azd/resources/apphost/templates/main.bicept +++ b/cli/azd/resources/apphost/templates/main.bicept @@ -67,6 +67,9 @@ output MANAGED_IDENTITY_NAME string = resources.outputs.MANAGED_IDENTITY_NAME {{if .HasLogAnalyticsWorkspace -}} output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = resources.outputs.AZURE_LOG_ANALYTICS_WORKSPACE_NAME {{end -}} +{{if .HasAppInsights -}} +output AZURE_APP_INSIGHTS_CONNECTION_STRING string = resources.outputs.AZURE_APP_INSIGHTS_CONNECTION_STRING +{{end -}} {{if .HasContainerRegistry -}} output AZURE_CONTAINER_REGISTRY_ENDPOINT string = resources.outputs.AZURE_CONTAINER_REGISTRY_ENDPOINT output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = resources.outputs.AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID @@ -76,6 +79,9 @@ output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = resources.outputs.AZURE_CO output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_ID output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = resources.outputs.AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN {{end -}} +{{range $name := .Functions -}} +output SERVICE_{{alphaSnakeUpper $name}}_WEBJOBS_STORAGE_NAME string = resources.outputs.SERVICE_{{alphaSnakeUpper $name}}_WEBJOBS_STORAGE_NAME +{{end -}} {{range $name, $value := .ContainerApps -}} {{range $volume := $value.Volumes -}} output SERVICE_{{alphaSnakeUpper $name}}_VOLUME_{{removeDot $volume.Name | alphaSnakeUpper}}_NAME string = resources.outputs.SERVICE_{{alphaSnakeUpper $name}}_VOLUME_{{removeDot $volume.Name | alphaSnakeUpper}}_NAME diff --git a/cli/azd/resources/apphost/templates/resources.bicept b/cli/azd/resources/apphost/templates/resources.bicept index ef475c7910e..6aba4ca46dc 100644 --- a/cli/azd/resources/apphost/templates/resources.bicept +++ b/cli/azd/resources/apphost/templates/resources.bicept @@ -55,6 +55,18 @@ resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10 tags: tags } {{end -}} +{{if .HasAppInsights}} +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: 'ai-${resourceToken}' + location: location + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalyticsWorkspace.id + } + tags: tags +} +{{end -}} {{if .RequiresStorageVolume}} resource storageVolume 'Microsoft.Storage/storageAccounts@2022-05-01' = { name: 'vol${resourceToken}' @@ -239,6 +251,27 @@ resource {{bicepName $name}} 'Microsoft.App/containerApps@2023-05-02-preview' = tags: union(tags, {'aspire-resource-name': '{{$name}}'}) } {{end -}} +{{range $name := .Functions}} +resource {{bicepName $name}}WebJobsStorage 'Microsoft.Storage/storageAccounts@2022-05-01' = { + name: replace('sa{{$name}}wj-${resourceToken}', '-', '') + location: location + sku: { + name: 'Standard_LRS' + } + kind: 'StorageV2' + tags: tags +} + +resource {{bicepName $name}}WebJobsStorageBlobsRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid({{bicepName $name}}WebJobsStorage.id, managedIdentity.id, subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b')) + scope: {{bicepName $name}}WebJobsStorage + properties: { + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b') + } +} +{{end -}} {{range $name, $value := .KeyVaults}} resource {{bicepName $name}} 'Microsoft.KeyVault/vaults@2023-07-01' = { name: replace('{{$name}}-${resourceToken}', '-', '') @@ -283,6 +316,9 @@ output MANAGED_IDENTITY_PRINCIPAL_ID string = managedIdentity.properties.princip output AZURE_LOG_ANALYTICS_WORKSPACE_NAME string = logAnalyticsWorkspace.name output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = logAnalyticsWorkspace.id {{end -}} +{{if .HasAppInsights -}} +output AZURE_APP_INSIGHTS_CONNECTION_STRING string = appInsights.properties.ConnectionString +{{end -}} {{if .HasContainerRegistry -}} output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.properties.loginServer output AZURE_CONTAINER_REGISTRY_MANAGED_IDENTITY_ID string = managedIdentity.id @@ -292,6 +328,9 @@ output AZURE_CONTAINER_APPS_ENVIRONMENT_NAME string = containerAppEnvironment.na output AZURE_CONTAINER_APPS_ENVIRONMENT_ID string = containerAppEnvironment.id output AZURE_CONTAINER_APPS_ENVIRONMENT_DEFAULT_DOMAIN string = containerAppEnvironment.properties.defaultDomain {{end -}} +{{range $name := .Functions -}} +output SERVICE_{{alphaSnakeUpper $name}}_WEBJOBS_STORAGE_NAME string = {{bicepName $name}}WebJobsStorage.name +{{end -}} {{range $name, $value := .ContainerApps -}} {{range $volume := $value.Volumes -}} output SERVICE_{{alphaSnakeUpper $name}}_VOLUME_{{removeDot $volume.Name | alphaSnakeUpper}}_NAME string = {{mergeBicepName $name $volume.Name}}Store.name