cli/azd/internal/repository/app_init.go (382 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package repository
import (
"context"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/internal/appdetect"
"github.com/azure/azure-dev/cli/azd/internal/cmd/add"
"github.com/azure/azure-dev/cli/azd/internal/scaffold"
"github.com/azure/azure-dev/cli/azd/internal/tracing"
"github.com/azure/azure-dev/cli/azd/internal/tracing/fields"
"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/environment"
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"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/project"
"github.com/otiai10/copy"
)
var featureCompose = alpha.MustFeatureKey("compose")
// InitFromApp initializes the infra directory and project file from the current existing app.
func (i *Initializer) InitFromApp(
ctx context.Context,
azdCtx *azdcontext.AzdContext,
initializeEnv func() (*environment.Environment, error)) error {
i.console.Message(ctx, "")
title := "Scanning app code in current directory"
i.console.ShowSpinner(ctx, title, input.Step)
wd := azdCtx.ProjectDirectory()
projects := []appdetect.Project{}
start := time.Now()
sourceDir := filepath.Join(wd, "src")
tracing.SetUsageAttributes(fields.AppInitLastStep.String("detect"))
// Prioritize src directory if it exists
if ent, err := os.Stat(sourceDir); err == nil && ent.IsDir() {
prj, err := appdetect.Detect(ctx, sourceDir)
if err == nil && len(prj) > 0 {
projects = prj
}
}
if len(projects) == 0 {
prj, err := appdetect.Detect(ctx, wd, appdetect.WithExcludePatterns([]string{
"**/eng",
"**/tool",
"**/tools"},
false))
if err != nil {
i.console.StopSpinner(ctx, title, input.GetStepResultFormat(err))
return err
}
projects = prj
}
appHostManifests := make(map[string]*apphost.Manifest)
appHostForProject := make(map[string]string)
// Load the manifests for all the App Host projects we detected, we use the manifest as part of infrastructure
// generation.
for _, prj := range projects {
if prj.Language != appdetect.DotNetAppHost {
continue
}
manifest, err := apphost.ManifestFromAppHost(ctx, prj.Path, i.dotnetCli, "")
if err != nil {
return fmt.Errorf("failed to generate manifest from app host project: %w", err)
}
appHostManifests[prj.Path] = manifest
for _, path := range apphost.ProjectPaths(manifest) {
appHostForProject[filepath.Dir(path)] = prj.Path
}
}
// Filter out all the projects owned by an App Host.
{
var filteredProject []appdetect.Project
for _, prj := range projects {
if _, has := appHostForProject[prj.Path]; !has {
filteredProject = append(filteredProject, prj)
}
}
projects = filteredProject
}
end := time.Since(start)
if i.console.IsSpinnerInteractive() {
// If the spinner is interactive, we want to show it for at least 1 second
time.Sleep((1 * time.Second) - end)
}
i.console.StopSpinner(ctx, title, input.StepDone)
var prjAppHost []appdetect.Project
for _, prj := range projects {
if prj.Language == appdetect.DotNetAppHost {
prjAppHost = append(prjAppHost, prj)
}
}
if len(prjAppHost) > 1 {
relPaths := make([]string, 0, len(prjAppHost))
for _, appHost := range prjAppHost {
rel, _ := filepath.Rel(wd, appHost.Path)
relPaths = append(relPaths, rel)
}
return fmt.Errorf(
"found multiple Aspire app host projects: %s. To fix, rerun `azd init` in each app host project directory",
ux.ListAsText(relPaths))
}
if len(prjAppHost) == 1 {
appHost := prjAppHost[0]
otherProjects := make([]string, 0, len(projects))
for _, prj := range projects {
if prj.Language != appdetect.DotNetAppHost {
rel, _ := filepath.Rel(wd, prj.Path)
otherProjects = append(otherProjects, rel)
}
}
if len(otherProjects) > 0 {
i.console.Message(
ctx,
output.WithWarningFormat(
"\nIgnoring other projects present but not referenced by app host: %s",
ux.ListAsText(otherProjects)))
}
detect := detectConfirmAppHost{console: i.console}
detect.Init(appHost, wd)
if err := detect.Confirm(ctx); err != nil {
return err
}
tracing.SetUsageAttributes(fields.AppInitLastStep.String("config"))
// Prompt for environment before proceeding with generation
newEnv, err := initializeEnv()
if err != nil {
return err
}
envManager, err := i.lazyEnvManager.GetValue()
if err != nil {
return err
}
if err := envManager.Save(ctx, newEnv); err != nil {
return err
}
i.console.Message(ctx, "\n"+output.WithBold("Generating files to run your app on Azure:")+"\n")
files, err := apphost.GenerateProjectArtifacts(
ctx,
azdCtx.ProjectDirectory(),
azdcontext.ProjectName(azdCtx.ProjectDirectory()),
appHostManifests[appHost.Path],
appHost.Path,
)
if err != nil {
return err
}
staging, err := os.MkdirTemp("", "azd-infra")
if err != nil {
return fmt.Errorf("mkdir temp: %w", err)
}
defer func() { _ = os.RemoveAll(staging) }()
for path, file := range files {
if err := os.MkdirAll(filepath.Join(staging, filepath.Dir(path)), osutil.PermissionDirectory); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(staging, path), []byte(file.Contents), osutil.PermissionFile); err != nil {
return err
}
}
skipStagingFiles, err := i.promptForDuplicates(ctx, staging, azdCtx.ProjectDirectory())
if err != nil {
return err
}
options := copy.Options{}
if skipStagingFiles != nil {
options.Skip = func(fileInfo os.FileInfo, src, dest string) (bool, error) {
_, skip := skipStagingFiles[src]
return skip, nil
}
}
if err := copy.Copy(staging, azdCtx.ProjectDirectory(), options); err != nil {
return fmt.Errorf("copying contents from temp staging directory: %w", err)
}
i.console.MessageUxItem(ctx, &ux.DoneMessage{
Message: "Generating " + output.WithHighLightFormat("./azure.yaml"),
})
i.console.MessageUxItem(ctx, &ux.DoneMessage{
Message: "Generating " + output.WithHighLightFormat("./next-steps.md"),
})
return i.writeCoreAssets(ctx, azdCtx)
}
detect := detectConfirm{console: i.console}
detect.Init(projects, wd)
tracing.SetUsageAttributes(fields.AppInitLastStep.String("modify"))
// Confirm selection of services and databases
err := detect.Confirm(ctx)
if err != nil {
return err
}
tracing.SetUsageAttributes(fields.AppInitLastStep.String("config"))
// Create the infra spec
var infraSpec *scaffold.InfraSpec
composeEnabled := i.features.IsEnabled(featureCompose)
if !composeEnabled { // backwards compatibility
spec, err := i.infraSpecFromDetect(ctx, detect)
if err != nil {
return err
}
infraSpec = &spec
// Prompt for environment before proceeding with generation
_, err = initializeEnv()
if err != nil {
return err
}
}
tracing.SetUsageAttributes(fields.AppInitLastStep.String("generate"))
title = "Generating " + output.WithHighLightFormat("./"+azdcontext.ProjectFileName)
i.console.ShowSpinner(ctx, title, input.Step)
err = i.genProjectFile(ctx, azdCtx, detect, composeEnabled)
if err != nil {
i.console.StopSpinner(ctx, title, input.GetStepResultFormat(err))
return err
}
i.console.Message(ctx, "\n"+output.WithBold("Generating files to run your app on Azure:")+"\n")
i.console.StopSpinner(ctx, title, input.StepDone)
if infraSpec != nil {
title = "Generating Infrastructure as Code files in " + output.WithHighLightFormat("./infra")
i.console.ShowSpinner(ctx, title, input.Step)
err = i.genFromInfra(ctx, azdCtx, *infraSpec)
if err != nil {
i.console.StopSpinner(ctx, title, input.GetStepResultFormat(err))
return err
}
i.console.StopSpinner(ctx, title, input.StepDone)
} else {
t, err := scaffold.Load()
if err != nil {
return fmt.Errorf("loading scaffold templates: %w", err)
}
err = scaffold.Execute(t, "next-steps-alpha.md", nil, filepath.Join(azdCtx.ProjectDirectory(), "next-steps.md"))
if err != nil {
return err
}
i.console.MessageUxItem(ctx, &ux.DoneMessage{
Message: "Generating " + output.WithHighLightFormat("./next-steps.md"),
})
}
return nil
}
func (i *Initializer) genFromInfra(
ctx context.Context,
azdCtx *azdcontext.AzdContext,
spec scaffold.InfraSpec) error {
infra := filepath.Join(azdCtx.ProjectDirectory(), "infra")
staging, err := os.MkdirTemp("", "azd-infra")
if err != nil {
return fmt.Errorf("mkdir temp: %w", err)
}
defer func() { _ = os.RemoveAll(staging) }()
t, err := scaffold.Load()
if err != nil {
return fmt.Errorf("loading scaffold templates: %w", err)
}
err = scaffold.ExecInfra(t, spec, staging)
if err != nil {
return err
}
if err := os.MkdirAll(infra, osutil.PermissionDirectory); err != nil {
return err
}
skipStagingFiles, err := i.promptForDuplicates(ctx, staging, infra)
if err != nil {
return err
}
options := copy.Options{}
if skipStagingFiles != nil {
options.Skip = func(fileInfo os.FileInfo, src, dest string) (bool, error) {
_, skip := skipStagingFiles[src]
return skip, nil
}
}
if err := copy.Copy(staging, infra, options); err != nil {
return fmt.Errorf("copying contents from temp staging directory: %w", err)
}
err = scaffold.Execute(t, "next-steps.md", spec, filepath.Join(azdCtx.ProjectDirectory(), "next-steps.md"))
if err != nil {
return err
}
i.console.MessageUxItem(ctx, &ux.DoneMessage{
Message: "Generating " + output.WithHighLightFormat("./next-steps.md"),
})
return nil
}
func (i *Initializer) genProjectFile(
ctx context.Context,
azdCtx *azdcontext.AzdContext,
detect detectConfirm,
composeEnabled bool) error {
config, err := i.prjConfigFromDetect(ctx, azdCtx.ProjectDirectory(), detect, composeEnabled)
if err != nil {
return fmt.Errorf("converting config: %w", err)
}
if composeEnabled {
config.MetaSchemaVersion = "alpha"
}
err = project.Save(
ctx,
&config,
azdCtx.ProjectPath())
if err != nil {
return fmt.Errorf("generating %s: %w", azdcontext.ProjectFileName, err)
}
return i.writeCoreAssets(ctx, azdCtx)
}
const InitGenTemplateId = "azd-init"
func (i *Initializer) prjConfigFromDetect(
ctx context.Context,
root string,
detect detectConfirm,
addResources bool) (project.ProjectConfig, error) {
config := project.ProjectConfig{
Name: azdcontext.ProjectName(root),
Metadata: &project.ProjectMetadata{
Template: fmt.Sprintf("%s@%s", InitGenTemplateId, internal.VersionInfo().Version),
},
Services: map[string]*project.ServiceConfig{},
}
svcMapping := map[string]string{}
for _, prj := range detect.Services {
svc, err := add.ServiceFromDetect(root, "", prj, project.ContainerAppTarget)
if err != nil {
return config, err
}
config.Services[svc.Name] = &svc
svcMapping[prj.Path] = svc.Name
}
if addResources {
config.Resources = map[string]*project.ResourceConfig{}
dbNames := map[appdetect.DatabaseDep]string{}
databases := slices.SortedFunc(maps.Keys(detect.Databases),
func(a appdetect.DatabaseDep, b appdetect.DatabaseDep) int {
return strings.Compare(string(a), string(b))
})
promptOpts := add.PromptOptions{PrjConfig: &config}
for _, database := range databases {
db := project.ResourceConfig{
Type: add.DbMap[database],
}
configured, err := add.Configure(ctx, &db, i.console, promptOpts)
if err != nil {
return config, err
}
config.Resources[configured.Name] = &db
dbNames[database] = configured.Name
}
backends := []*project.ResourceConfig{}
frontends := []*project.ResourceConfig{}
for _, svc := range detect.Services {
name := svcMapping[svc.Path]
resSpec := project.ResourceConfig{
Type: project.ResourceTypeHostContainerApp,
}
props := project.ContainerAppProps{
Port: -1,
}
port, err := add.PromptPort(i.console, ctx, name, svc)
if err != nil {
return config, err
}
props.Port = port
for _, db := range svc.DatabaseDeps {
// filter out databases that were removed
if _, ok := detect.Databases[db]; !ok {
continue
}
resSpec.Uses = append(resSpec.Uses, dbNames[db])
}
resSpec.Name = name
resSpec.Props = props
config.Resources[name] = &resSpec
frontend := svc.HasWebUIFramework()
if frontend {
frontends = append(frontends, &resSpec)
} else {
backends = append(backends, &resSpec)
}
}
for _, frontend := range frontends {
for _, backend := range backends {
frontend.Uses = append(frontend.Uses, backend.Name)
}
}
}
return config, nil
}