cli/azd/pkg/devcenter/provision_provider.go (413 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package devcenter
import (
"context"
"errors"
"fmt"
"log"
"maps"
"os"
"slices"
"strconv"
"time"
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/devcentersdk"
"github.com/azure/azure-dev/cli/azd/pkg/environment"
"github.com/azure/azure-dev/cli/azd/pkg/infra"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/output"
)
const (
ProvisionParametersConfigPath string = "provision.parameters"
ProvisionKindDevCenter provisioning.ProviderKind = "devcenter"
// ADE environment ARM deployment tags
DeploymentTagDevCenterName = "AdeDevCenterName"
DeploymentTagDevCenterProject = "AdeProjectName"
DeploymentTagEnvironmentType = "AdeEnvironmentTypeName"
DeploymentTagEnvironmentName = "AdeEnvironmentName"
)
// ProvisionProvider is a devcenter provider for provisioning ADE environments
type ProvisionProvider struct {
console input.Console
env *environment.Environment
envManager environment.Manager
config *Config
devCenterClient devcentersdk.DevCenterClient
deploymentManager *infra.DeploymentManager
manager Manager
prompter *Prompter
options provisioning.Options
}
// NewProvisionProvider creates a new devcenter provider
func NewProvisionProvider(
console input.Console,
env *environment.Environment,
envManager environment.Manager,
config *Config,
devCenterClient devcentersdk.DevCenterClient,
deploymentManager *infra.DeploymentManager,
manager Manager,
prompter *Prompter,
) provisioning.Provider {
return &ProvisionProvider{
console: console,
env: env,
envManager: envManager,
config: config,
devCenterClient: devCenterClient,
deploymentManager: deploymentManager,
manager: manager,
prompter: prompter,
}
}
// Name returns the name of the provider
func (p *ProvisionProvider) Name() string {
return "Dev Center"
}
// Initialize initializes the provider
func (p *ProvisionProvider) Initialize(ctx context.Context, projectPath string, options provisioning.Options) error {
p.options = options
return p.EnsureEnv(ctx)
}
// State returns the state of the environment from the most recent ARM deployment
func (p *ProvisionProvider) State(
ctx context.Context,
options *provisioning.StateOptions,
) (*provisioning.StateResult, error) {
if err := p.config.EnsureValid(); err != nil {
return nil, fmt.Errorf("invalid devcenter configuration, %w", err)
}
envName := p.env.Name()
environment, err := p.devCenterClient.
DevCenterByName(p.config.Name).
ProjectByName(p.config.Project).
EnvironmentsByUser(p.config.User).
EnvironmentByName(envName).
Get(ctx)
if err != nil {
return nil, fmt.Errorf("failed getting environment: %w", err)
}
outputs, err := p.manager.Outputs(ctx, p.config, environment)
if err != nil {
return nil, fmt.Errorf("failed getting environment outputs: %w", err)
}
return &provisioning.StateResult{
State: &provisioning.State{
Outputs: outputs,
},
}, nil
}
// Deploy deploys the environment from the configured environment definition
func (p *ProvisionProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, error) {
if err := p.config.EnsureValid(); err != nil {
return nil, fmt.Errorf("invalid devcenter configuration, %w", err)
}
if hasInfraTemplates(p.options.Path) {
//nolint:lll
warningMsg := fmt.Sprintf(
"WARNING: IaC templates were found at '%s'. IaC templates are not supported for Dev Center environments and will be ignored.\n",
p.options.Path,
)
p.console.Message(
ctx,
output.WithWarningFormat(warningMsg),
)
}
envDef, err := p.devCenterClient.
DevCenterByName(p.config.Name).
ProjectByName(p.config.Project).
CatalogByName(p.config.Catalog).
EnvironmentDefinitionByName(p.config.EnvironmentDefinition).
Get(ctx)
if err != nil {
return nil, fmt.Errorf("failed getting environment definition: %w", err)
}
paramValues, err := p.prompter.PromptParameters(ctx, p.env, envDef)
if err != nil {
return nil, fmt.Errorf("failed prompting for parameters: %w", err)
}
for key, value := range paramValues {
path := fmt.Sprintf("%s.%s", ProvisionParametersConfigPath, key)
if err := p.env.Config.Set(path, value); err != nil {
return nil, fmt.Errorf("failed setting config value %s: %w", path, err)
}
}
if err := p.envManager.Save(ctx, p.env); err != nil {
return nil, fmt.Errorf("failed saving environment: %w", err)
}
envName := p.env.Name()
// Check to see if an existing devcenter environment already exists
existingEnv, _ := p.devCenterClient.
DevCenterByName(p.config.Name).
ProjectByName(p.config.Project).
EnvironmentsByUser(p.config.User).
EnvironmentByName(envName).
Get(ctx)
var spinnerMessage string
if existingEnv == nil {
spinnerMessage = fmt.Sprintf("Creating devcenter environment %s", output.WithHighLightFormat(envName))
} else {
spinnerMessage = fmt.Sprintf("Updating devcenter environment %s", output.WithHighLightFormat(envName))
}
envSpec := devcentersdk.EnvironmentSpec{
CatalogName: p.config.Catalog,
EnvironmentType: p.config.EnvironmentType,
EnvironmentDefinitionName: p.config.EnvironmentDefinition,
Parameters: paramValues,
}
p.console.ShowSpinner(ctx, spinnerMessage, input.Step)
poller, err := p.devCenterClient.
DevCenterByName(p.config.Name).
ProjectByName(p.config.Project).
EnvironmentsByUser(p.config.User).
EnvironmentByName(envName).
BeginPut(ctx, envSpec)
if err != nil {
p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed)
return nil, fmt.Errorf("failed creating environment: %w", err)
}
p.console.StopSpinner(ctx, spinnerMessage, input.StepDone)
pollingContext, cancel := context.WithCancel(ctx)
defer cancel()
spinnerMessage = "Deploying dev center environment"
p.console.ShowSpinner(ctx, spinnerMessage, input.Step)
go p.pollForEnvironment(pollingContext, envName)
_, err = poller.PollUntilDone(ctx, nil)
if err != nil {
p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed)
return nil, fmt.Errorf("failed creating environment: %w", err)
}
environment, err := p.devCenterClient.
DevCenterByName(p.config.Name).
ProjectByName(p.config.Project).
EnvironmentsByUser(p.config.User).
EnvironmentByName(envName).
Get(ctx)
if err != nil {
p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed)
return nil, fmt.Errorf("failed getting environment: %w", err)
}
p.console.StopSpinner(ctx, spinnerMessage, input.StepDone)
outputs, err := p.manager.Outputs(ctx, p.config, environment)
if err != nil {
return nil, fmt.Errorf("failed getting environment outputs: %w", err)
}
result := &provisioning.DeployResult{
Deployment: &provisioning.Deployment{
Parameters: createInputParameters(envDef, paramValues),
Outputs: outputs,
},
}
return result, nil
}
// Preview previews the deployment of the environment from the configured environment definition
func (p *ProvisionProvider) Preview(ctx context.Context) (*provisioning.DeployPreviewResult, error) {
return nil, fmt.Errorf("preview is not supported for devcenter")
}
// Destroy destroys the environment by deleting the ADE environment
func (p *ProvisionProvider) Destroy(
ctx context.Context,
options provisioning.DestroyOptions,
) (*provisioning.DestroyResult, error) {
if err := p.config.EnsureValid(); err != nil {
return nil, fmt.Errorf("invalid devcenter configuration, %w", err)
}
envName := p.env.Name()
spinnerMessage := fmt.Sprintf("Deleting devcenter environment %s", output.WithHighLightFormat(envName))
if !options.Force() {
warningMessage := output.WithWarningFormat(
"WARNING: This will delete the following Dev Center environment and all of its resources:\n",
)
p.console.Message(ctx, warningMessage)
p.console.Message(ctx, fmt.Sprintf("Dev Center: %s", output.WithHighLightFormat(p.config.Name)))
p.console.Message(ctx, fmt.Sprintf("Project: %s", output.WithHighLightFormat(p.config.Project)))
p.console.Message(ctx, fmt.Sprintf("Environment Type: %s", output.WithHighLightFormat(p.config.EnvironmentType)))
p.console.Message(ctx,
fmt.Sprintf("Environment Definition: %s", output.WithHighLightFormat(p.config.EnvironmentDefinition)),
)
p.console.Message(ctx, fmt.Sprintf("Environment: %s\n", output.WithHighLightFormat(envName)))
confirm, err := p.console.Confirm(ctx, input.ConsoleOptions{
Message: "Are you sure you want to continue?",
DefaultValue: false,
})
if err != nil {
p.console.Message(ctx, "")
p.console.ShowSpinner(ctx, spinnerMessage, input.Step)
p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed)
return nil, fmt.Errorf("destroy operation interrupted: %w", err)
}
p.console.Message(ctx, "\n")
if !confirm {
p.console.ShowSpinner(ctx, spinnerMessage, input.Step)
p.console.StopSpinner(ctx, spinnerMessage, input.StepSkipped)
return nil, fmt.Errorf("destroy operation cancelled")
}
}
devCenterEnv, err := p.devCenterClient.
DevCenterByName(p.config.Name).
ProjectByName(p.config.Project).
EnvironmentsByUser(p.config.User).
EnvironmentByName(envName).
Get(ctx)
if err != nil {
return nil, fmt.Errorf("failed getting devcenter environment: %w", err)
}
// Get environment outputs to invalidate them after destroy
outputs, err := p.manager.Outputs(ctx, p.config, devCenterEnv)
if err != nil {
return nil, fmt.Errorf("failed getting environment outputs: %w", err)
}
p.console.ShowSpinner(ctx, spinnerMessage, input.Step)
poller, err := p.devCenterClient.
DevCenterByName(p.config.Name).
ProjectByName(p.config.Project).
EnvironmentsByUser(p.config.User).
EnvironmentByName(envName).
BeginDelete(ctx)
if err != nil {
p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed)
return nil, fmt.Errorf("failed deleting environment: %w", err)
}
_, err = poller.PollUntilDone(ctx, nil)
if err != nil {
p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed)
return nil, fmt.Errorf("failed deleting environment: %w", err)
}
p.console.StopSpinner(ctx, spinnerMessage, input.StepDone)
result := &provisioning.DestroyResult{
InvalidatedEnvKeys: slices.Collect(maps.Keys(outputs)),
}
return result, nil
}
// EnsureEnv ensures that the environment is configured for the Dev Center provider.
// Require selection for devcenter, project, catalog, environment type, and environment definition
func (p *ProvisionProvider) EnsureEnv(ctx context.Context) error {
// Cache config values prior to prompting user so we can compare what is missing later
currentConfig := *p.config
err := p.prompter.PromptForConfig(ctx, p.config)
if err != nil {
return err
}
if p.config.EnvironmentType == "" {
envType, err := p.prompter.PromptEnvironmentType(ctx, p.config.Name, p.config.Project)
if err != nil {
return err
}
p.config.EnvironmentType = envType.Name
}
if p.config.User == "" {
p.config.User = "me"
}
// Set any missing config values in environment configuration for future use
// Some values are set at the global / project level so we only want to set missing values in the environment config
if currentConfig.Name == "" {
if err := p.env.Config.Set(DevCenterNamePath, p.config.Name); err != nil {
return err
}
}
if currentConfig.Project == "" {
if err := p.env.Config.Set(DevCenterProjectPath, p.config.Project); err != nil {
return err
}
}
if currentConfig.Catalog == "" {
if err := p.env.Config.Set(DevCenterCatalogPath, p.config.Catalog); err != nil {
return err
}
}
if currentConfig.EnvironmentType == "" {
if err := p.env.Config.Set(DevCenterEnvTypePath, p.config.EnvironmentType); err != nil {
return err
}
}
if currentConfig.EnvironmentDefinition == "" {
if err := p.env.Config.Set(DevCenterEnvDefinitionPath, p.config.EnvironmentDefinition); err != nil {
return err
}
}
if currentConfig.User == "" {
if err := p.env.Config.Set(DevCenterUserPath, p.config.User); err != nil {
return err
}
}
if err := p.envManager.Save(ctx, p.env); err != nil {
return fmt.Errorf("failed saving environment: %w", err)
}
return nil
}
// Polls for the ADE environment and ARM deployment to be created
func (p *ProvisionProvider) pollForEnvironment(ctx context.Context, envName string) {
// Disable reporting progress if needed
if use, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_PROVISION_PROGRESS_DISABLE")); err == nil && use {
log.Println("Disabling progress reporting since AZD_DEBUG_PROVISION_PROGRESS_DISABLE was set")
return
}
initialDelay := 3 * time.Second
regularDelay := 5 * time.Second
timer := time.NewTimer(initialDelay)
pollStartTime := time.Now()
for {
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
environment, err := p.devCenterClient.
DevCenterByName(p.config.Name).
ProjectByName(p.config.Project).
EnvironmentsByUser(p.config.User).
EnvironmentByName(envName).
Get(ctx)
// We need to wait until the ADE environment has created the resource group
if err != nil ||
environment == nil ||
environment.ProvisioningState == devcentersdk.ProvisioningStateCreating ||
environment.ResourceGroupId == "" {
timer.Reset(regularDelay)
continue
}
// After the resource group has been created
// We can start polling for a new deployment that started after we started polling
deployment, err := p.manager.Deployment(
ctx,
p.config,
environment,
func(d *azapi.ResourceDeployment) bool {
return d.ProvisioningState == azapi.DeploymentProvisioningStateRunning &&
d.Timestamp.After(pollStartTime)
},
)
if err != nil || deployment == nil {
timer.Reset(regularDelay)
continue
}
timer.Stop()
// Finally polling for provisioning progress
go p.pollForProgress(ctx, deployment)
}
}
}
// Polls the ARM deployment triggered by ADE and start reporting incremental provisioning progress
func (p *ProvisionProvider) pollForProgress(ctx context.Context, deployment infra.Deployment) {
// Disable reporting progress if needed
if use, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_PROVISION_PROGRESS_DISABLE")); err == nil && use {
log.Println("Disabling progress reporting since AZD_DEBUG_PROVISION_PROGRESS_DISABLE was set")
return
}
// Report incremental progress
progressDisplay := p.deploymentManager.ProgressDisplay(deployment)
initialDelay := 3 * time.Second
regularDelay := 10 * time.Second
timer := time.NewTimer(initialDelay)
queryStartTime := time.Now()
for {
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
if err := progressDisplay.ReportProgress(ctx, &queryStartTime); err != nil {
// We don't want to fail the whole deployment if a progress reporting error occurs
log.Printf("error while reporting progress: %s", err.Error())
}
timer.Reset(regularDelay)
}
}
}
func createInputParameters(
environmentDefinition *devcentersdk.EnvironmentDefinition,
parameterValues map[string]any,
) map[string]provisioning.InputParameter {
inputParams := map[string]provisioning.InputParameter{}
for _, param := range environmentDefinition.Parameters {
inputParams[param.Id] = provisioning.InputParameter{
Type: string(param.Type),
DefaultValue: param.Default,
Value: parameterValues[param.Id],
}
}
return inputParams
}
// hasInfraTemplates returns true if the specified path contains any infrastructure templates
func hasInfraTemplates(path string) bool {
if _, err := os.Stat(path); err != nil && errors.Is(err, os.ErrNotExist) {
return false
}
entries, err := os.ReadDir(path)
if errors.Is(err, os.ErrNotExist) {
return false
}
return len(entries) > 0
}