cli/azd/pkg/infra/provisioning/manager.go (374 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package provisioning
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/azsdk/storage"
"github.com/azure/azure-dev/cli/azd/pkg/cloud"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"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/output"
"github.com/azure/azure-dev/cli/azd/pkg/output/ux"
"github.com/azure/azure-dev/cli/azd/pkg/prompt"
"github.com/braydonk/yaml"
)
type DefaultProviderResolver func() (ProviderKind, error)
// Manages the orchestration of infrastructure provisioning
type Manager struct {
serviceLocator ioc.ServiceLocator
defaultProvider DefaultProviderResolver
envManager environment.Manager
env *environment.Environment
console input.Console
provider Provider
alphaFeatureManager *alpha.FeatureManager
projectPath string
options *Options
fileShareService storage.FileShareService
cloud *cloud.Cloud
}
// defaultOptions for this package.
const (
defaultModule = "main"
defaultPath = "infra"
)
func (m *Manager) Initialize(ctx context.Context, projectPath string, options Options) error {
// applied defaults if missing
if options.Module == "" {
options.Module = defaultModule
}
if options.Path == "" {
options.Path = defaultPath
}
m.projectPath = projectPath
m.options = &options
provider, err := m.newProvider(ctx)
if err != nil {
return fmt.Errorf("initializing infrastructure provider: %w", err)
}
m.provider = provider
return m.provider.Initialize(ctx, projectPath, options)
}
// Gets the latest deployment details for the specified scope
func (m *Manager) State(ctx context.Context, options *StateOptions) (*StateResult, error) {
result, err := m.provider.State(ctx, options)
if err != nil {
return nil, fmt.Errorf("error retrieving state: %w", err)
}
return result, nil
}
var AzdOperationsFeatureKey = alpha.MustFeatureKey("azd.operations")
// Deploys the Azure infrastructure for the specified project
func (m *Manager) Deploy(ctx context.Context) (*DeployResult, error) {
// Apply the infrastructure deployment
deployResult, err := m.provider.Deploy(ctx)
if err != nil {
return nil, fmt.Errorf("error deploying infrastructure: %w", err)
}
skippedDueToDeploymentState := deployResult.SkippedReason == DeploymentStateSkipped
if skippedDueToDeploymentState {
m.console.StopSpinner(ctx, "Didn't find new changes.", input.StepSkipped)
}
if err := m.UpdateEnvironment(ctx, deployResult.Deployment.Outputs); err != nil {
return nil, fmt.Errorf("updating environment with deployment outputs: %w", err)
}
infraRoot := m.options.Path
if !filepath.IsAbs(infraRoot) {
infraRoot = filepath.Join(m.projectPath, m.options.Path)
}
bindMountOperations, err := azdFileShareUploadOperations(infraRoot, *m.env)
azdOperationsEnabled := m.alphaFeatureManager.IsEnabled(AzdOperationsFeatureKey)
if !azdOperationsEnabled && len(bindMountOperations) > 0 {
m.console.Message(ctx, ErrBindMountOperationDisabled.Error())
}
if azdOperationsEnabled {
if err != nil {
return nil, fmt.Errorf("looking for azd fileShare upload operations: %w", err)
}
if err := doBindMountOperation(
ctx, bindMountOperations, *m.env, m.console, m.fileShareService, m.cloud.StorageEndpointSuffix); err != nil {
return nil, fmt.Errorf("error running bind mount operation: %w", err)
}
}
// make sure any spinner is stopped
m.console.StopSpinner(ctx, "", input.StepDone)
return deployResult, nil
}
const (
fileShareUploadOperation string = "FileShareUpload"
azdOperationsFileName string = "azd.operations.yaml"
)
type azdOperation struct {
Type string
Description string
Config any
}
type azdOperationFileShareUpload struct {
Description string
StorageAccount string
FileShareName string
Path string
}
type azdOperationsModel struct {
Operations []azdOperation
}
func azdOperations(infraPath string, env environment.Environment) (azdOperationsModel, error) {
path := filepath.Join(infraPath, azdOperationsFileName)
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
// file not found is not an error, there's just nothing to do
return azdOperationsModel{}, nil
}
return azdOperationsModel{}, err
}
// resolve environment variables
expString := osutil.NewExpandableString(string(data))
evaluated, err := expString.Envsubst(env.Getenv)
if err != nil {
return azdOperationsModel{}, err
}
data = []byte(evaluated)
// Unmarshal the file into azdOperationsModel
var operations azdOperationsModel
err = yaml.Unmarshal(data, &operations)
if err != nil {
return azdOperationsModel{}, err
}
return operations, nil
}
func azdFileShareUploadOperations(infraPath string, env environment.Environment) ([]azdOperationFileShareUpload, error) {
model, err := azdOperations(infraPath, env)
if err != nil {
return nil, err
}
var fileShareUploadOperations []azdOperationFileShareUpload
for _, operation := range model.Operations {
if operation.Type == fileShareUploadOperation {
var fileShareUpload azdOperationFileShareUpload
bytes, err := json.Marshal(operation.Config)
if err != nil {
return nil, err
}
err = json.Unmarshal(bytes, &fileShareUpload)
if err != nil {
return nil, err
}
fileShareUpload.Description = operation.Description
fileShareUploadOperations = append(fileShareUploadOperations, fileShareUpload)
}
}
return fileShareUploadOperations, nil
}
var ErrAzdOperationsNotEnabled = fmt.Errorf(
"azd operations (alpha feature) is required but disabled. You can enable azd operations by running: %s",
output.WithGrayFormat("%s", alpha.GetEnableCommand(AzdOperationsFeatureKey)))
var ErrBindMountOperationDisabled = fmt.Errorf(
"%sYour project has bind mounts.\n - %w\n%s\n",
output.WithWarningFormat("*Note: "),
ErrAzdOperationsNotEnabled,
output.WithWarningFormat("Ignoring bind mounts."),
)
func doBindMountOperation(
ctx context.Context,
fileShareUploadOperations []azdOperationFileShareUpload,
env environment.Environment,
console input.Console,
fileShareService storage.FileShareService,
cloudStorageEndpointSuffix string,
) error {
if len(fileShareUploadOperations) > 0 {
console.ShowSpinner(ctx, "uploading files to fileShare", input.StepFailed)
}
for _, op := range fileShareUploadOperations {
if err := bindMountOperation(
ctx,
fileShareService,
cloudStorageEndpointSuffix,
env.GetSubscriptionId(),
op.StorageAccount,
op.FileShareName,
op.Path); err != nil {
return fmt.Errorf("error binding mount: %w", err)
}
console.MessageUxItem(ctx, &ux.DisplayedResource{
Type: fileShareUploadOperation,
Name: op.Description,
State: ux.SucceededState,
})
}
return nil
}
func bindMountOperation(
ctx context.Context,
fileShareService storage.FileShareService,
cloud, subId, storageAccount, fileShareName, source string) error {
shareUrl := fmt.Sprintf("https://%s.file.%s/%s", storageAccount, cloud, fileShareName)
return fileShareService.UploadPath(ctx, subId, shareUrl, source)
}
// Preview generates the list of changes to be applied as part of the provisioning.
func (m *Manager) Preview(ctx context.Context) (*DeployPreviewResult, error) {
// Apply the infrastructure deployment
deployResult, err := m.provider.Preview(ctx)
if err != nil {
return nil, fmt.Errorf("error deploying infrastructure: %w", err)
}
// apply resource mapping
filteredResult := DeployPreviewResult{
Preview: &DeploymentPreview{
Status: deployResult.Preview.Status,
Properties: &DeploymentPreviewProperties{},
},
}
for index, result := range deployResult.Preview.Properties.Changes {
mappingName := azapi.GetResourceTypeDisplayName(azapi.AzureResourceType(result.ResourceType))
if mappingName == "" {
// ignore
continue
}
deployResult.Preview.Properties.Changes[index].ResourceType = mappingName
filteredResult.Preview.Properties.Changes = append(
filteredResult.Preview.Properties.Changes, deployResult.Preview.Properties.Changes[index])
}
// make sure any spinner is stopped
m.console.StopSpinner(ctx, "", input.StepDone)
return &filteredResult, nil
}
// Destroys the Azure infrastructure for the specified project
func (m *Manager) Destroy(ctx context.Context, options DestroyOptions) (*DestroyResult, error) {
destroyResult, err := m.provider.Destroy(ctx, options)
if err != nil {
return nil, fmt.Errorf("error deleting Azure resources: %w", err)
}
// Remove any outputs from the template from the environment since destroying the infrastructure
// invalidated them all.
for _, key := range destroyResult.InvalidatedEnvKeys {
m.env.DotenvDelete(key)
}
// Update environment files to remove invalid infrastructure parameters
if err := m.envManager.Save(ctx, m.env); err != nil {
return nil, fmt.Errorf("saving environment: %w", err)
}
return destroyResult, nil
}
func (m *Manager) UpdateEnvironment(
ctx context.Context,
outputs map[string]OutputParameter,
) error {
if len(outputs) > 0 {
for key, param := range outputs {
// Complex types marshalled as JSON strings, simple types marshalled as simple strings
if param.Type == ParameterTypeArray || param.Type == ParameterTypeObject {
bytes, err := json.Marshal(param.Value)
if err != nil {
return fmt.Errorf("invalid value for output parameter '%s' (%s): %w", key, string(param.Type), err)
}
m.env.DotenvSet(key, string(bytes))
} else {
m.env.DotenvSet(key, fmt.Sprintf("%v", param.Value))
}
}
if err := m.envManager.Save(ctx, m.env); err != nil {
return fmt.Errorf("writing environment: %w", err)
}
}
return nil
}
type EnsureSubscriptionAndLocationOptions struct {
// LocationFilterPredicate is a function to filter the locations being displayed if prompting the user for the location.
LocationFiler prompt.LocationFilterPredicate
// SelectDefaultLocation is the default location that azd mark as selected when prompting the user for the location.
SelectDefaultLocation *string
}
// EnsureSubscriptionAndLocation ensures that that that subscription (AZURE_SUBSCRIPTION_ID) and location (AZURE_LOCATION)
// variables are set in the environment, prompting the user for the values if they do not exist.
// locationFilter, when non-nil, filters the locations being displayed.
func EnsureSubscriptionAndLocation(
ctx context.Context,
envManager environment.Manager,
env *environment.Environment,
prompter prompt.Prompter,
options EnsureSubscriptionAndLocationOptions,
) error {
subId := env.GetSubscriptionId()
if subId == "" {
subscriptionId, err := prompter.PromptSubscription(ctx, "Select an Azure Subscription to use:")
if err != nil {
return err
}
subId = subscriptionId
}
// GetSubscriptionId() can get the value from the .env file or from system environment.
// We want to ensure that, if the value came from the system environment, it is persisted in the .env file.
// By doing this, we ensure that any command depending on .env values does not need to read system env.
// For example, on CI, when running `azd provision`, we want the .env to have the subscription id and location
// so that `azd deploy` can just use the values from .env w/o checking os-env again.
env.SetSubscriptionId(subId)
if err := envManager.Save(ctx, env); err != nil {
return err
}
location := env.GetLocation()
if env.GetLocation() == "" {
loc, err := prompter.PromptLocation(
ctx,
env.GetSubscriptionId(),
"Select an Azure location to use:",
options.LocationFiler,
options.SelectDefaultLocation,
)
if err != nil {
return err
}
location = loc
}
// Same as before, this make sure the location is persisted in the .env file.
env.SetLocation(location)
return envManager.Save(ctx, env)
}
func EnsureSubscription(
ctx context.Context,
envManager environment.Manager,
env *environment.Environment,
prompter prompt.Prompter,
) error {
subId := env.GetSubscriptionId()
if subId == "" {
subscriptionId, err := prompter.PromptSubscription(ctx, "Select an Azure Subscription to use:")
if err != nil {
return err
}
subId = subscriptionId
}
// GetSubscriptionId() can get the value from the .env file or from system environment.
// We want to ensure that, if the value came from the system environment, it is persisted in the .env file.
// By doing this, we ensure that any command depending on .env values does not need to read system env.
// For example, on CI, when running `azd provision`, we want the .env to have the subscription id and location
// so that `azd deploy` can just use the values from .env w/o checking os-env again.
env.SetSubscriptionId(subId)
if err := envManager.Save(ctx, env); err != nil {
return err
}
return envManager.Save(ctx, env)
}
// Creates a new instance of the Provisioning Manager
func NewManager(
serviceLocator ioc.ServiceLocator,
defaultProvider DefaultProviderResolver,
envManager environment.Manager,
env *environment.Environment,
console input.Console,
alphaFeatureManager *alpha.FeatureManager,
fileShareService storage.FileShareService,
cloud *cloud.Cloud,
) *Manager {
return &Manager{
serviceLocator: serviceLocator,
defaultProvider: defaultProvider,
envManager: envManager,
env: env,
console: console,
alphaFeatureManager: alphaFeatureManager,
fileShareService: fileShareService,
cloud: cloud,
}
}
func (m *Manager) newProvider(ctx context.Context) (Provider, error) {
var err error
m.options.Provider, err = ParseProvider(m.options.Provider)
if err != nil {
return nil, err
}
if alphaFeatureId, isAlphaFeature := alpha.IsFeatureKey(string(m.options.Provider)); isAlphaFeature {
if !m.alphaFeatureManager.IsEnabled(alphaFeatureId) {
return nil, fmt.Errorf("provider '%s' is alpha feature and it is not enabled. Run `%s` to enable it.",
m.options.Provider,
alpha.GetEnableCommand(alphaFeatureId),
)
}
m.console.WarnForFeature(ctx, alphaFeatureId)
}
providerKey := m.options.Provider
if providerKey == NotSpecified {
defaultProvider, err := m.defaultProvider()
if err != nil {
return nil, err
}
providerKey = defaultProvider
}
var provider Provider
err = m.serviceLocator.ResolveNamed(string(providerKey), &provider)
if err != nil {
return nil, fmt.Errorf("failed resolving IaC provider '%s': %w", providerKey, err)
}
return provider, nil
}