cli/azd/pkg/apphost/manifest.go (217 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package apphost
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"github.com/azure/azure-dev/cli/azd/pkg/custommaps"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet"
"github.com/psanford/memfs"
)
type Manifest struct {
Schema string `json:"$schema"`
Resources map[string]*Resource `json:"resources"`
// BicepFiles holds any bicep files generated by Aspire next to the manifest file.
BicepFiles *memfs.FS `json:"-"`
}
type Resource struct {
// Type is present on all resource types
Type string `json:"type"`
// Path is present on a project.v0 resource and is the path to the project file, and on a dockerfile.v0
// resource and is the path to the Dockerfile (including the "Dockerfile" filename).
// For a bicep.v0 resource, it is the path to the bicep file.
Path *string `json:"path,omitempty"`
// Context is present on a dockerfile.v0 resource and is the path to the context directory.
Context *string `json:"context,omitempty"`
// BuildArgs is present on a dockerfile.v0 resource and is the --build-arg for building the docker image.
BuildArgs map[string]string `json:"buildArgs,omitempty"`
// Args is optionally present on project.v0 and dockerfile.v0 resources and are the arguments to pass to the container.
Args []string `json:"args,omitempty"`
// Parent is present on a resource which is a child of another. It is the name of the parent resource. For example, a
// postgres.database.v0 is a child of a postgres.server.v0, and so it would have a parent of which is the name of
// the server resource.
Parent *string `json:"parent,omitempty"`
// Image is present on a container.v0 resource and is the image to use for the container.
Image *string `json:"image,omitempty"`
// Bindings is present on container.v0, project.v0 and dockerfile.v0 resources, and is a map of binding names to
// binding details.
Bindings custommaps.WithOrder[Binding] `json:"bindings,omitempty"`
// Env is present on project.v0, container.v0 and dockerfile.v0 resources, and is a map of environment variable
// names to value expressions. The value expressions are simple expressions like "{redis.connectionString}" or
// "{postgres.port}" to allow referencing properties of other resources. The set of properties supported in these
// expressions depends on the type of resource you are referencing.
Env map[string]string `json:"env,omitempty"`
// Queues is optionally present on a azure.servicebus.v0 resource, and is a list of queue names to create.
Queues *[]string `json:"queues,omitempty"`
// Topics is optionally present on a azure.servicebus.v0 resource, and is a list of topic names to create.
Topics *[]string `json:"topics,omitempty"`
// Some resources just represent connections to existing resources that need not be provisioned. These resources have
// a "connectionString" property which is the connection string that should be used during binding.
ConnectionString *string `json:"connectionString,omitempty"`
// Dapr is present on dapr.v0 resources.
Dapr *DaprResourceMetadata `json:"dapr,omitempty"`
// DaprComponent is present on dapr.component.v0 resources.
DaprComponent *DaprComponentResourceMetadata `json:"daprComponent,omitempty"`
// Inputs is present on resources that need inputs from during the provisioning process (e.g asking for an API key, or
// a password for a database).
Inputs map[string]Input `json:"inputs,omitempty"`
// For a bicep.v0 resource, defines the input parameters for the bicep file.
Params map[string]any `json:"params,omitempty"`
// parameter.v0 uses value field to define the value of the parameter.
Value string
// container.v0 uses volumes field to define the volumes of the container.
Volumes []*Volume `json:"volumes,omitempty"`
// The entrypoint to use for the container image when executed.
Entrypoint string `json:"entrypoint,omitempty"`
// An object that captures properties that control the building of a container image.
Build *ContainerV1Build `json:"build,omitempty"`
// container.v0 uses bind mounts field to define the volumes with initial data of the container.
BindMounts []*BindMount `json:"bindMounts,omitempty"`
// project.v1 and container.v1 uses deployment when the AppHost owns the ACA bicep definitions.
Deployment *DeploymentMetadata `json:"deployment,omitempty"`
// Present on bicep modules to control the scope of the module.
Scope *BicepModuleScope `json:"scope,omitempty"`
}
// BicepModuleScope is the scope of a bicep module.
type BicepModuleScope struct {
ResourceGroup *string `json:"resourceGroup,omitempty"`
}
type DeploymentMetadata struct {
// Type is the type of deployment. For now, only bicep.v0 is supported.
Type string `json:"type"`
// Path is present for a bicep.v0 deployment type, and the path to the bicep file.
Path *string `json:"path,omitempty"`
// For a bicep.v0 deployment type, defines the input parameters for the bicep file.
Params map[string]any `json:"params,omitempty"`
}
type ContainerV1Build struct {
// The path to the context directory for the container build.
// Can be relative of absolute. If relative it is relative to the location of the manifest file.
Context string `json:"context"`
// The path to the Dockerfile. Can be relative or absolute. If relative it is relative to the manifest file.
Dockerfile string `json:"dockerfile"`
// Args is optionally present on project.v0 and dockerfile.v0 resources and are the arguments to pass to the container.
Args map[string]string `json:"args,omitempty"`
// A list of build arguments which are used during container build."
Secrets map[string]ContainerV1BuildSecrets `json:"secrets,omitempty"`
}
type ContainerV1BuildSecrets struct {
// "env" (will come with value) or "file" (will come with source).
Type string `json:"type"`
// If provided use as the value for the environment variable when docker build is run.
Value *string `json:"value,omitempty"`
// Path to secret file. If relative, the path is relative to the manifest file.
Source *string `json:"source,omitempty"`
}
type DaprResourceMetadata struct {
AppId *string `json:"appId,omitempty"`
Application *string `json:"application,omitempty"`
AppPort *int `json:"appPort,omitempty"`
AppProtocol *string `json:"appProtocol,omitempty"`
DaprHttpMaxRequestSize *int `json:"daprHttpMaxRequestSize,omitempty"`
DaprHttpReadBufferSize *int `json:"daprHttpReadBufferSize,omitempty"`
EnableApiLogging *bool `json:"enableApiLogging,omitempty"`
LogLevel *string `json:"logLevel,omitempty"`
}
type DaprComponentResourceMetadata struct {
Type *string `json:"type"`
}
type Reference struct {
Bindings []string `json:"bindings,omitempty"`
}
type Binding struct {
TargetPort *int `json:"targetPort,omitempty"`
Port *int `json:"port,omitempty"`
Scheme string `json:"scheme"`
Protocol string `json:"protocol"`
Transport string `json:"transport"`
External bool `json:"external"`
}
type Volume struct {
Name string `json:"name,omitempty"`
Target string `json:"target"`
ReadOnly bool `json:"readOnly"`
}
type BindMount struct {
Name string `json:"-"`
Source string `json:"source,omitempty"`
Target string `json:"target"`
ReadOnly bool `json:"readOnly"`
}
type Input struct {
Type string `json:"type"`
Secret bool `json:"secret"`
Default *InputDefault `json:"default,omitempty"`
// When the input is used to set a bicep module scope, the scope is set here.
// This allows generation to add azdMetadata to the bicep parameter.
scope *string
}
type InputDefaultGenerate struct {
MinLength *uint `json:"minLength,omitempty"`
Lower *bool `json:"lower,omitempty"`
Upper *bool `json:"upper,omitempty"`
Numeric *bool `json:"numeric,omitempty"`
Special *bool `json:"special,omitempty"`
MinLower *uint `json:"minLower,omitempty"`
MinUpper *uint `json:"minUpper,omitempty"`
MinNumeric *uint `json:"minNumeric,omitempty"`
MinSpecial *uint `json:"minSpecial,omitempty"`
}
type InputDefault struct {
Generate *InputDefaultGenerate `json:"generate,omitempty"`
Value *string `json:"value,omitempty"`
}
// ManifestFromAppHost returns the Manifest from the given app host.
func ManifestFromAppHost(
ctx context.Context, appHostProject string, dotnetCli *dotnet.Cli, dotnetEnv string,
) (*Manifest, error) {
tempDir, err := os.MkdirTemp("", "azd-provision")
if err != nil {
return nil, fmt.Errorf("creating temp directory for apphost-manifest.json: %w", err)
}
defer os.RemoveAll(tempDir)
manifestPath := filepath.Join(tempDir, "apphost-manifest.json")
if err := dotnetCli.PublishAppHostManifest(ctx, appHostProject, manifestPath, dotnetEnv); err != nil {
return nil, fmt.Errorf("generating app host manifest: %w", err)
}
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
return nil, err
}
var manifest Manifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return nil, fmt.Errorf("unmarshalling manifest: %w", err)
}
// Make all paths absolute, to simplify logic for consumers.
// Note that since we created a temp dir, and `dotnet run --publisher` returns relative paths to the temp dir,
// the resulting path may be a symlinked path that isn't safe for Rel comparisons with the azd root directory.
manifestDir := filepath.Dir(manifestPath)
// The manifest writer writes paths relative to the manifest file. When we use a fixed manifest, the manifest is
// located SxS with the appHostProject.
if enabled, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_DOTNET_APPHOST_USE_FIXED_MANIFEST")); err == nil && enabled {
manifestDir = filepath.Dir(appHostProject)
}
manifest.BicepFiles = memfs.New()
for resourceName, res := range manifest.Resources {
if res.Path != nil {
if res.Type == "azure.bicep.v0" || res.Type == "azure.bicep.v1" {
e := manifest.BicepFiles.MkdirAll(resourceName, osutil.PermissionDirectory)
if e != nil {
return nil, e
}
// try reading as a generated bicep adding the tem-manifest dir
content, e := os.ReadFile(filepath.Join(manifestDir, *res.Path))
if e != nil {
// second try reading as relative (external bicep reference)
content, e = os.ReadFile(*res.Path)
if e != nil {
return nil, fmt.Errorf("did not find bicep at generated path or at: %s. Error: %w", *res.Path, e)
}
}
*res.Path = filepath.Join(resourceName, filepath.Base(*res.Path))
e = manifest.BicepFiles.WriteFile(*res.Path, content, osutil.PermissionFile)
if e != nil {
return nil, e
}
// move on to the next resource
continue
}
if !filepath.IsAbs(*res.Path) {
*res.Path = filepath.Join(manifestDir, *res.Path)
}
}
if res.Deployment != nil {
if res.Deployment.Type != "azure.bicep.v0" && res.Deployment.Type != "azure.bicep.v1" {
return nil, fmt.Errorf(
"unexpected deployment type %q. Supported types: [azure.bicep.v0, azure.bicep.v1]", res.Deployment.Type)
}
// use a folder with the name of the resource
e := manifest.BicepFiles.MkdirAll(resourceName, osutil.PermissionDirectory)
if e != nil {
return nil, e
}
content, e := os.ReadFile(filepath.Join(manifestDir, *res.Deployment.Path))
if e != nil {
return nil, fmt.Errorf("reading bicep file from deployment property: %w", e)
}
*res.Deployment.Path = filepath.Join(resourceName, filepath.Base(*res.Deployment.Path))
e = manifest.BicepFiles.WriteFile(*res.Deployment.Path, content, osutil.PermissionFile)
if e != nil {
return nil, e
}
}
if res.Type == "dockerfile.v0" {
if !filepath.IsAbs(*res.Context) {
*res.Context = filepath.Join(manifestDir, *res.Context)
}
}
if res.BindMounts != nil {
for _, bindMount := range res.BindMounts {
if !filepath.IsAbs(bindMount.Source) {
bindMount.Source = filepath.Join(manifestDir, bindMount.Source)
}
}
}
if res.Type == "container.v1" {
if res.Build != nil {
if !filepath.IsAbs(res.Build.Dockerfile) {
res.Build.Dockerfile = filepath.Join(manifestDir, res.Build.Dockerfile)
}
if !filepath.IsAbs(res.Build.Context) {
res.Build.Context = filepath.Join(manifestDir, res.Build.Context)
}
for _, secret := range res.Build.Secrets {
if secret.Source != nil && !filepath.IsAbs(*secret.Source) {
*secret.Source = filepath.Join(manifestDir, *secret.Source)
}
}
}
}
}
return &manifest, nil
}