diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 6b7facee0c7..f5fa809dc62 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -23,6 +23,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 c2e900d25ed..be3cab7a05a 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) @@ -226,6 +244,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) { @@ -433,6 +477,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. @@ -449,6 +494,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), @@ -460,6 +506,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), @@ -560,6 +607,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": @@ -603,6 +652,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" @@ -1053,6 +1107,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(`'([^']*)':`) @@ -1087,6 +1157,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 { @@ -1214,6 +1294,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, @@ -1373,7 +1469,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) } @@ -1399,6 +1499,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 677c8dd8756..bed93072440 100644 --- a/cli/azd/pkg/apphost/generate_types.go +++ b/cli/azd/pkg/apphost/generate_types.go @@ -73,6 +73,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 @@ -122,9 +128,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 bd1d6a3ad1d..0a7578b991d 100644 --- a/cli/azd/pkg/project/framework_service_docker.go +++ b/cli/azd/pkg/project/framework_service_docker.go @@ -553,10 +553,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 3ed765d7cd3..1b8034b70c5 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 b1af653b911..e1aec0d3e89 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -55,6 +55,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 f21f68335d9..5e88159d2f6 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 @@ -258,41 +279,76 @@ func (at *dotnetContainerAppTarget) Deploy( ApiVersion: serviceConfig.ApiVersion, } - err = at.containerAppService.DeployYaml( - ctx, - targetResource.SubscriptionId(), - targetResource.ResourceGroupName(), - serviceConfig.Name, - []byte(builder.String()), - &containerAppOptions, - ) - 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()), + &containerAppOptions, + ) + 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