cli/azd/pkg/project/service_manager.go (505 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package project import ( "context" "encoding/json" "errors" "fmt" "io" "log" "os" "path/filepath" "strings" "github.com/azure/azure-dev/cli/azd/pkg/alpha" "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/environment" "github.com/azure/azure-dev/cli/azd/pkg/ext" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/swa" ) const ( ServiceEventEnvUpdated ext.Event = "environment updated" ServiceEventRestore ext.Event = "restore" ServiceEventBuild ext.Event = "build" ServiceEventPackage ext.Event = "package" ServiceEventDeploy ext.Event = "deploy" ) var ( ServiceEvents []ext.Event = []ext.Event{ ServiceEventEnvUpdated, ServiceEventRestore, ServiceEventPackage, ServiceEventDeploy, } ) // ServiceManager provides a management layer for performing operations against an azd service within a project // The component performs all of the heavy lifting for executing all lifecycle operations for a service. // // All service lifecycle command leverage our async Task library to expose a common interface for handling // long running operations including how we handle incremental progress updates and error handling. type ServiceManager interface { // Gets all of the required framework/service target tools for the specified service config GetRequiredTools(ctx context.Context, serviceConfig *ServiceConfig) ([]tools.ExternalTool, error) // Initializes the service configuration and dependent framework & service target // This allows frameworks & service targets to hook into a services lifecycle events Initialize(ctx context.Context, serviceConfig *ServiceConfig) error // Restores the code dependencies for the specified service config Restore( ctx context.Context, serviceConfig *ServiceConfig, progress *async.Progress[ServiceProgress], ) (*ServiceRestoreResult, error) // Builds the code for the specified service config // Will call the language compile for compiled languages or // may copy build artifacts to a configured output folder Build( ctx context.Context, serviceConfig *ServiceConfig, restoreOutput *ServiceRestoreResult, progress *async.Progress[ServiceProgress], ) (*ServiceBuildResult, error) // Packages the code for the specified service config // Depending on the service configuration this will generate an artifact // that can be consumed by the hosting Azure service. // Common examples could be a zip archive for app service or // Docker images for container apps and AKS Package( ctx context.Context, serviceConfig *ServiceConfig, buildOutput *ServiceBuildResult, progress *async.Progress[ServiceProgress], options *PackageOptions, ) (*ServicePackageResult, error) // Deploys the generated artifacts to the Azure resource that will // host the service application // Common examples would be uploading zip archive using ZipDeploy deployment or // pushing container images to a container registry. Deploy( ctx context.Context, serviceConfig *ServiceConfig, packageOutput *ServicePackageResult, progress *async.Progress[ServiceProgress], ) (*ServiceDeployResult, error) // Gets the framework service for the specified service config // The framework service performs the restoration and building of the service app code GetFrameworkService(ctx context.Context, serviceConfig *ServiceConfig) (FrameworkService, error) // Gets the service target service for the specified service config // The service target is responsible for packaging & deploying the service app code // to the destination Azure resource GetServiceTarget(ctx context.Context, serviceConfig *ServiceConfig) (ServiceTarget, error) } // ServiceOperationCache is an alias to map used for internal caching of service operation results // The ServiceManager is a scoped component since it depends on the current environment // The ServiceOperationCache is used as a singleton cache for all service manager instances type ServiceOperationCache map[string]any type serviceManager struct { env *environment.Environment resourceManager ResourceManager serviceLocator ioc.ServiceLocator operationCache ServiceOperationCache alphaFeatureManager *alpha.FeatureManager initialized map[*ServiceConfig]map[any]bool } // NewServiceManager creates a new instance of the ServiceManager component func NewServiceManager( env *environment.Environment, resourceManager ResourceManager, serviceLocator ioc.ServiceLocator, operationCache ServiceOperationCache, alphaFeatureManager *alpha.FeatureManager, ) ServiceManager { return &serviceManager{ env: env, resourceManager: resourceManager, serviceLocator: serviceLocator, operationCache: operationCache, alphaFeatureManager: alphaFeatureManager, initialized: map[*ServiceConfig]map[any]bool{}, } } // Gets all of the required framework/service target tools for the specified service config func (sm *serviceManager) GetRequiredTools(ctx context.Context, serviceConfig *ServiceConfig) ([]tools.ExternalTool, error) { frameworkService, err := sm.GetFrameworkService(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("getting framework service: %w", err) } serviceTarget, err := sm.GetServiceTarget(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("getting service target: %w", err) } requiredTools := []tools.ExternalTool{} requiredTools = append(requiredTools, frameworkService.RequiredExternalTools(ctx, serviceConfig)...) requiredTools = append(requiredTools, serviceTarget.RequiredExternalTools(ctx, serviceConfig)...) return tools.Unique(requiredTools), nil } // Initializes the service configuration and dependent framework & service target // This allows frameworks & service targets to hook into a services lifecycle events func (sm *serviceManager) Initialize(ctx context.Context, serviceConfig *ServiceConfig) error { frameworkService, err := sm.GetFrameworkService(ctx, serviceConfig) if err != nil { return fmt.Errorf("getting framework service: %w", err) } serviceTarget, err := sm.GetServiceTarget(ctx, serviceConfig) if err != nil { return fmt.Errorf("getting service target: %w", err) } if ok := sm.isComponentInitialized(serviceConfig, frameworkService); !ok { if err := frameworkService.Initialize(ctx, serviceConfig); err != nil { return err } sm.initialized[serviceConfig][frameworkService] = true } if ok := sm.isComponentInitialized(serviceConfig, serviceTarget); !ok { if err := serviceTarget.Initialize(ctx, serviceConfig); err != nil { return err } sm.initialized[serviceConfig][serviceTarget] = true } return nil } // Restores the code dependencies for the specified service config func (sm *serviceManager) Restore( ctx context.Context, serviceConfig *ServiceConfig, progress *async.Progress[ServiceProgress], ) (*ServiceRestoreResult, error) { cachedResult, ok := sm.getOperationResult(serviceConfig, string(ServiceEventRestore)) if ok && cachedResult != nil { return cachedResult.(*ServiceRestoreResult), nil } frameworkService, err := sm.GetFrameworkService(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("getting framework services: %w", err) } restoreResult, err := runCommand( ctx, ServiceEventRestore, serviceConfig, func() (*ServiceRestoreResult, error) { return frameworkService.Restore(ctx, serviceConfig, progress) }, ) if err != nil { return nil, fmt.Errorf("failed restoring service '%s': %w", serviceConfig.Name, err) } sm.setOperationResult(serviceConfig, string(ServiceEventRestore), restoreResult) return restoreResult, nil } // Builds the code for the specified service config // Will call the language compile for compiled languages or may copy build artifacts to a configured output folder func (sm *serviceManager) Build( ctx context.Context, serviceConfig *ServiceConfig, restoreOutput *ServiceRestoreResult, progress *async.Progress[ServiceProgress], ) (*ServiceBuildResult, error) { cachedResult, ok := sm.getOperationResult(serviceConfig, string(ServiceEventBuild)) if ok && cachedResult != nil { return cachedResult.(*ServiceBuildResult), nil } if restoreOutput == nil { cachedResult, ok := sm.getOperationResult(serviceConfig, string(ServiceEventRestore)) if ok && cachedResult != nil { restoreOutput = cachedResult.(*ServiceRestoreResult) } } frameworkService, err := sm.GetFrameworkService(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("getting framework services: %w", err) } buildResult, err := runCommand( ctx, ServiceEventBuild, serviceConfig, func() (*ServiceBuildResult, error) { return frameworkService.Build(ctx, serviceConfig, restoreOutput, progress) }, ) if err != nil { return nil, fmt.Errorf("failed building service '%s': %w", serviceConfig.Name, err) } sm.setOperationResult(serviceConfig, string(ServiceEventBuild), buildResult) return buildResult, nil } // Packages the code for the specified service config // Depending on the service configuration this will generate an artifact that can be consumed by the hosting Azure service. // Common examples could be a zip archive for app service or Docker images for container apps and AKS func (sm *serviceManager) Package( ctx context.Context, serviceConfig *ServiceConfig, buildOutput *ServiceBuildResult, progress *async.Progress[ServiceProgress], options *PackageOptions, ) (*ServicePackageResult, error) { if options == nil { options = &PackageOptions{} } cachedResult, ok := sm.getOperationResult(serviceConfig, string(ServiceEventPackage)) if ok && cachedResult != nil { return cachedResult.(*ServicePackageResult), nil } if buildOutput == nil { cachedResult, ok := sm.getOperationResult(serviceConfig, string(ServiceEventBuild)) if ok && cachedResult != nil { buildOutput = cachedResult.(*ServiceBuildResult) } } frameworkService, err := sm.GetFrameworkService(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("getting framework service: %w", err) } serviceTarget, err := sm.GetServiceTarget(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("getting service target: %w", err) } eventArgs := ServiceLifecycleEventArgs{ Project: serviceConfig.Project, Service: serviceConfig, } hasBuildOutput := buildOutput != nil restoreResult := &ServiceRestoreResult{} // Get the language / framework requirements frameworkRequirements := frameworkService.Requirements() // When a previous restore result was not provided, and we require it // Then we need to restore the dependencies if frameworkRequirements.Package.RequireRestore && (!hasBuildOutput || buildOutput.Restore == nil) { restoreTaskResult, err := sm.Restore(ctx, serviceConfig, progress) if err != nil { return nil, err } restoreResult = restoreTaskResult } buildResult := &ServiceBuildResult{} // When a previous build result was not provided, and we require it // Then we need to build the project if frameworkRequirements.Package.RequireBuild && !hasBuildOutput { buildTaskResult, err := sm.Build(ctx, serviceConfig, restoreResult, progress) if err != nil { return nil, err } buildResult = buildTaskResult } if !hasBuildOutput { buildOutput = buildResult buildOutput.Restore = restoreResult } var packageResult *ServicePackageResult err = serviceConfig.Invoke(ctx, ServiceEventPackage, eventArgs, func() error { frameworkPackageResult, err := frameworkService.Package(ctx, serviceConfig, buildOutput, progress) if err != nil { return err } serviceTargetPackageResult, err := serviceTarget.Package(ctx, serviceConfig, frameworkPackageResult, progress) if err != nil { return err } packageResult = serviceTargetPackageResult sm.setOperationResult(serviceConfig, string(ServiceEventPackage), packageResult) return nil }) if err != nil { return nil, fmt.Errorf("failed packaging service '%s': %w", serviceConfig.Name, err) } // Package path can be a file path or a container image name // We only move to desired output path for file based packages _, err = os.Stat(packageResult.PackagePath) hasPackageFile := err == nil if hasPackageFile && options.OutputPath != "" { var destFilePath string var destDirectory string isFilePath := filepath.Ext(options.OutputPath) != "" if isFilePath { destFilePath = options.OutputPath destDirectory = filepath.Dir(options.OutputPath) } else { destFilePath = filepath.Join(options.OutputPath, filepath.Base(packageResult.PackagePath)) destDirectory = options.OutputPath } _, err := os.Stat(destDirectory) if errors.Is(err, os.ErrNotExist) { // Create the desired output directory if it does not already exist if err := os.MkdirAll(destDirectory, osutil.PermissionDirectory); err != nil { return nil, fmt.Errorf("failed creating output directory '%s': %w", destDirectory, err) } } // Move the package file to the desired path // We can't use os.Rename here since that does not work across disks if err := moveFile(packageResult.PackagePath, destFilePath); err != nil { return nil, fmt.Errorf( "failed moving package file '%s' to '%s': %w", packageResult.PackagePath, destFilePath, err) } packageResult.PackagePath = destFilePath } return packageResult, nil } // Deploys the generated artifacts to the Azure resource that will host the service application // Common examples would be uploading zip archive using ZipDeploy deployment or // pushing container images to a container registry. func (sm *serviceManager) Deploy( ctx context.Context, serviceConfig *ServiceConfig, packageResult *ServicePackageResult, progress *async.Progress[ServiceProgress], ) (*ServiceDeployResult, error) { cachedResult, ok := sm.getOperationResult(serviceConfig, string(ServiceEventDeploy)) if ok && cachedResult != nil { return cachedResult.(*ServiceDeployResult), nil } if packageResult == nil { cachedResult, ok := sm.getOperationResult(serviceConfig, string(ServiceEventPackage)) if ok && cachedResult != nil { packageResult = cachedResult.(*ServicePackageResult) } } serviceTarget, err := sm.GetServiceTarget(ctx, serviceConfig) if err != nil { return nil, fmt.Errorf("getting service target: %w", err) } var targetResource *environment.TargetResource if serviceConfig.Host == DotNetContainerAppTarget { containerEnvName := sm.env.GetServiceProperty(serviceConfig.Name, "CONTAINER_ENVIRONMENT_NAME") // AZURE_CONTAINER_APPS_ENVIRONMENT_ID is not required for Aspire (serviceConfig.DotNetContainerApp != nil) // because it uses a bicep deployment. if containerEnvName == "" && serviceConfig.DotNetContainerApp == nil { containerEnvName = sm.env.Getenv("AZURE_CONTAINER_APPS_ENVIRONMENT_ID") if containerEnvName == "" { return nil, fmt.Errorf( "could not determine container app environment for service %s, "+ "have you set AZURE_CONTAINER_ENVIRONMENT_NAME or "+ "SERVICE_%s_CONTAINER_ENVIRONMENT_NAME as an output of your "+ "infrastructure?", serviceConfig.Name, strings.ToUpper(serviceConfig.Name)) } parts := strings.Split(containerEnvName, "/") containerEnvName = parts[len(parts)-1] } // Get any explicitly configured resource group name // 1. Service level override // 2. Project level override resourceGroupNameTemplate := serviceConfig.ResourceGroupName if resourceGroupNameTemplate.Empty() { resourceGroupNameTemplate = serviceConfig.Project.ResourceGroupName } resourceGroupName, err := sm.resourceManager.GetResourceGroupName( ctx, sm.env.GetSubscriptionId(), resourceGroupNameTemplate, ) if err != nil { return nil, fmt.Errorf("getting resource group name: %w", err) } targetResource = environment.NewTargetResource( sm.env.GetSubscriptionId(), resourceGroupName, containerEnvName, string(azapi.AzureResourceTypeContainerAppEnvironment), ) } else { targetResource, err = sm.resourceManager.GetTargetResource(ctx, sm.env.GetSubscriptionId(), serviceConfig) if err != nil { return nil, fmt.Errorf("getting target resource: %w", err) } } deployResult, err := runCommand( ctx, ServiceEventDeploy, serviceConfig, func() (*ServiceDeployResult, error) { return serviceTarget.Deploy(ctx, serviceConfig, packageResult, targetResource, progress) }, ) if err != nil { return nil, fmt.Errorf("failed deploying service '%s': %w", serviceConfig.Name, err) } // Allow users to specify their own endpoints, in cases where they've configured their own front-end load balancers, // reverse proxies or DNS host names outside of the service target (and prefer that to be used instead). overriddenEndpoints := OverriddenEndpoints(ctx, serviceConfig, sm.env) if len(overriddenEndpoints) > 0 { deployResult.Endpoints = overriddenEndpoints } sm.setOperationResult(serviceConfig, string(ServiceEventDeploy), deployResult) return deployResult, nil } // GetServiceTarget constructs a ServiceTarget from the underlying service configuration func (sm *serviceManager) GetServiceTarget(ctx context.Context, serviceConfig *ServiceConfig) (ServiceTarget, error) { var target ServiceTarget host := string(serviceConfig.Host) if alphaFeatureId, isAlphaFeature := alpha.IsFeatureKey(host); isAlphaFeature { if !sm.alphaFeatureManager.IsEnabled(alphaFeatureId) { return nil, fmt.Errorf( "service host '%s' is currently in alpha and needs to be enabled explicitly."+ " Run `%s` to enable the feature.", host, alpha.GetEnableCommand(alphaFeatureId), ) } } if err := sm.serviceLocator.ResolveNamed(host, &target); err != nil { return nil, fmt.Errorf( "failed to resolve service host '%s' for service '%s', %w", serviceConfig.Host, serviceConfig.Name, err, ) } return target, nil } // GetFrameworkService constructs a framework service from the underlying service configuration func (sm *serviceManager) GetFrameworkService(ctx context.Context, serviceConfig *ServiceConfig) (FrameworkService, error) { var frameworkService FrameworkService // Publishing from an existing image currently follows the same lifecycle as a docker project if serviceConfig.Language == ServiceLanguageNone && !serviceConfig.Image.Empty() { serviceConfig.Language = ServiceLanguageDocker } if err := sm.serviceLocator.ResolveNamed(string(serviceConfig.Language), &frameworkService); err != nil { return nil, fmt.Errorf( "failed to resolve language '%s' for service '%s', %w", serviceConfig.Language, serviceConfig.Name, err, ) } var compositeFramework CompositeFrameworkService // For hosts which run in containers, if the source project is not already a container, we need to wrap it in a docker // project that handles the containerization. requiresLanguage := serviceConfig.Language != ServiceLanguageDocker && serviceConfig.Language != ServiceLanguageNone if serviceConfig.Host.RequiresContainer() && requiresLanguage { if err := sm.serviceLocator.ResolveNamed(string(ServiceLanguageDocker), &compositeFramework); err != nil { return nil, fmt.Errorf( "failed resolving composite framework service for '%s', language '%s': %w", serviceConfig.Name, serviceConfig.Language, err, ) } } else if serviceConfig.Host == StaticWebAppTarget { withSwaConfig, err := swa.ContainsSwaConfig(serviceConfig.Path()) if err != nil { return nil, fmt.Errorf("checking for swa-cli.config.json: %w", err) } if withSwaConfig { if err := sm.serviceLocator.ResolveNamed(string(ServiceLanguageSwa), &compositeFramework); err != nil { return nil, fmt.Errorf( "failed resolving composite framework service for '%s', language '%s': %w", serviceConfig.Name, serviceConfig.Language, err, ) } log.Println("Using swa-cli for build and deploy because swa-cli.config.json was found in the service path") } } if compositeFramework != nil { compositeFramework.SetSource(frameworkService) frameworkService = compositeFramework } return frameworkService, nil } func OverriddenEndpoints(ctx context.Context, serviceConfig *ServiceConfig, env *environment.Environment) []string { overriddenEndpoints := env.GetServiceProperty(serviceConfig.Name, "ENDPOINTS") if overriddenEndpoints != "" { var endpoints []string err := json.Unmarshal([]byte(overriddenEndpoints), &endpoints) if err != nil { // This can only happen if the environment output was not a valid JSON array, which would be due to an authoring // error. For typical infra provider output passthrough, the infra provider would guarantee well-formed syntax log.Printf( "failed to unmarshal endpoints override for service '%s' as JSON array of strings: %v, skipping override", serviceConfig.Name, err) } return endpoints } return nil } // Attempts to retrieve the result of a previous operation from the cache func (sm *serviceManager) getOperationResult(serviceConfig *ServiceConfig, operationName string) (any, bool) { key := fmt.Sprintf("%s:%s:%s", sm.env.Name(), serviceConfig.Name, operationName) value, ok := sm.operationCache[key] return value, ok } // Sets the result of an operation in the cache func (sm *serviceManager) setOperationResult(serviceConfig *ServiceConfig, operationName string, result any) { key := fmt.Sprintf("%s:%s:%s", sm.env.Name(), serviceConfig.Name, operationName) sm.operationCache[key] = result } // isComponentInitialized Checks if a component has been initialized for a service configuration func (sm *serviceManager) isComponentInitialized(serviceConfig *ServiceConfig, component any) bool { if componentMap, has := sm.initialized[serviceConfig]; has && len(componentMap) > 0 { initialized := false if ok, has := componentMap[component]; has && ok { initialized = ok } return initialized } sm.initialized[serviceConfig] = map[any]bool{} return false } func runCommand[T any]( ctx context.Context, eventName ext.Event, serviceConfig *ServiceConfig, fn func() (T, error), ) (T, error) { eventArgs := ServiceLifecycleEventArgs{ Project: serviceConfig.Project, Service: serviceConfig, } var result T err := serviceConfig.Invoke(ctx, eventName, eventArgs, func() error { res, err := fn() result = res return err }) return result, err } // Copies a file from the source path to the destination path // Deletes the source file after the copy is complete func moveFile(sourcePath string, destinationPath string) error { sourceFile, err := os.Open(sourcePath) if err != nil { return fmt.Errorf("opening source file: %w", err) } defer sourceFile.Close() // Create or truncate the destination file destinationFile, err := os.Create(destinationPath) if err != nil { return fmt.Errorf("creating destination file: %w", err) } defer destinationFile.Close() // Copy the contents of the source file to the destination file _, err = io.Copy(destinationFile, sourceFile) if err != nil { return fmt.Errorf("copying file: %w", err) } // Remove the source file (optional) defer os.Remove(sourcePath) return nil }