diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index a27619f0f60..3ffc01e369b 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -5,6 +5,7 @@ alphafeatures apimanagement apims appconfiguration +appdb appdetect appinsights appinsightsexporter @@ -32,15 +33,19 @@ azfile azruntime azsdk AZURECLI +azuredb azureedge azurestaticapps +azuresql azuretools azureutil azureyaml Backticks +bicept BOOLSLICE BUILDID BUILDNUMBER +buildpack buildpacks byoi cflags @@ -73,6 +78,7 @@ executil funcapp functestapp functionapp +frontends go-imath GOARCH GOCOVERDIR @@ -84,6 +90,7 @@ hotspot iidfile ineffassign javac +jsont jquery jmes keychain @@ -113,7 +120,9 @@ otlp otlpconfig otlptrace otlptracehttp +missingkey pflag +postgre preinit proxying psycopg @@ -136,6 +145,7 @@ snapshotter springapp sqlserver sstore +sqlserver staticcheck staticwebapp stdouttrace @@ -161,3 +171,12 @@ westus2 wireinject yacspin zerr +weilimtest +capps +wsgi +gunicorn +logfile +Procfile +frwk +paketo +paketobuildpacks diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 3e12d22e5bb..2018d71f639 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -40,6 +40,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/tools/kubectl" "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" "github.com/azure/azure-dev/cli/azd/pkg/tools/npm" + "github.com/azure/azure-dev/cli/azd/pkg/tools/pack" "github.com/azure/azure-dev/cli/azd/pkg/tools/python" "github.com/azure/azure-dev/cli/azd/pkg/tools/swa" "github.com/azure/azure-dev/cli/azd/pkg/tools/terraform" @@ -313,6 +314,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.RegisterSingleton(maven.NewMavenCli) container.RegisterSingleton(npm.NewNpmCli) container.RegisterSingleton(python.NewPythonCli) + container.RegisterSingleton(pack.NewPackCli) container.RegisterSingleton(swa.NewSwaCli) container.RegisterSingleton(terraform.NewTerraformCli) diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index ed52ed4cd7b..7d36690e415 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -124,13 +124,9 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { // Command title i.console.MessageUxItem(ctx, &ux.MessageTitle{ - Title: "Initializing a new project (azd init)", + Title: "Initializing an app to run on Azure", }) - // If azure.yaml project already exists, we should do the following: - // - Not prompt for template selection (user can specify --template if needed to refresh from an existing template) - // - Not overwrite azure.yaml (unless --template is explicitly specified) - // - Allow for environment initialization var existingProject bool if _, err := os.Stat(azdCtx.ProjectPath()); err == nil { existingProject = true @@ -140,81 +136,179 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { return nil, fmt.Errorf("checking if project exists: %w", err) } - if !existingProject { - err = i.repoInitializer.PromptIfNonEmpty(ctx, azdCtx) + var initTypeSelect initType + if i.flags.templatePath != "" { + // an explicit --template passed, always initialize from app template + initTypeSelect = initAppTemplate + } + + if i.flags.templatePath == "" && existingProject { + // no explicit --template, and azure.yaml exists, only initialize environment + initTypeSelect = initEnvironment + } + + if initTypeSelect == initUnknown { + initTypeSelect, err = promptInitType(i.console, ctx) if err != nil { return nil, err } + } - if i.flags.templatePath == "" { - template, err := templates.PromptTemplate(ctx, "Select a project template:", i.templateManager, i.console) - if err != nil { - return nil, err - } + initializeEnv := func() (*actions.ActionResult, error) { + envName, err := azdCtx.GetDefaultEnvironmentName() + if err != nil { + return nil, fmt.Errorf("retrieving default environment name: %w", err) + } + + if envName != "" { + return nil, environment.NewEnvironmentInitError(envName) + } - if template != nil { - i.flags.templatePath = template.RepositoryPath + base := filepath.Base(wd) + examples := []string{} + for _, c := range []string{"dev", "test", "prod"} { + suggest := environment.CleanName(base + "-" + c) + if len(suggest) > environment.EnvironmentNameMaxLength { + suggest = suggest[len(suggest)-environment.EnvironmentNameMaxLength:] } + + examples = append(examples, suggest) } + + envSpec := environmentSpec{ + environmentName: i.flags.environmentName, + subscription: i.flags.subscription, + location: i.flags.location, + examples: examples, + } + + env, err := createEnvironment(ctx, envSpec, azdCtx, i.console) + if err != nil { + return nil, fmt.Errorf("loading environment: %w", err) + } + + if err := azdCtx.SetDefaultEnvironmentName(env.GetEnvName()); err != nil { + return nil, fmt.Errorf("saving default environment: %w", err) + } + + return nil, nil } - if i.flags.templatePath != "" { - gitUri, err := templates.Absolute(i.flags.templatePath) + header := "New project initialized!" + followUp := heredoc.Docf(` + You can view the template code in your directory: %s + Learn more about running 3rd party code on our DevHub: %s`, + output.WithLinkFormat("%s", wd), + output.WithLinkFormat("%s", "https://aka.ms/azd-third-party-code-notice")) + switch initTypeSelect { + case initInfra: + header = "Your app is ready for the cloud!" + followUp = "You can provision and deploy your app to Azure by running the " + output.WithBlueFormat("azd up") + + " command in this directory. For more information on configuring your app, see " + + output.WithHighLightFormat("./next-steps.md") + err := i.repoInitializer.InitializeInfra(ctx, azdCtx, func() error { + _, err := initializeEnv() + return err + }) + if err != nil { + return nil, err + } + case initAppTemplate: + err := i.InitializeTemplate(ctx, azdCtx) if err != nil { return nil, err } - err = i.repoInitializer.Initialize(ctx, azdCtx, gitUri, i.flags.templateBranch) + _, err = initializeEnv() if err != nil { - return nil, fmt.Errorf("init from template repository: %w", err) + return nil, err } - } else if !existingProject { // do not initialize for empty if azure.yaml is present - err = i.repoInitializer.InitializeMinimal(ctx, azdCtx) + case initEnvironment: + _, err = initializeEnv() if err != nil { - return nil, fmt.Errorf("init empty repository: %w", err) + return nil, err } + // no-opt + default: + panic("unhandled init type") } - envName, err := azdCtx.GetDefaultEnvironmentName() + return &actions.ActionResult{ + Message: &actions.ResultMessage{ + Header: header, + FollowUp: followUp, + }, + }, nil +} + +type initType int + +const ( + initUnknown = iota + initInfra + initAppTemplate + initEnvironment +) + +func promptInitType(console input.Console, ctx context.Context) (initType, error) { + selection, err := console.Select(ctx, input.ConsoleOptions{ + Message: "How do you want to initialize your app?", + Options: []string{ + "Use code in the current directory", + "Select a template", + }, + }) if err != nil { - return nil, fmt.Errorf("retrieving default environment name: %w", err) + return initUnknown, err } - if envName != "" { - return nil, environment.NewEnvironmentInitError(envName) + switch selection { + case 0: + return initInfra, nil + case 1: + return initAppTemplate, nil + default: + panic("unhandled selection") } +} - suggest := environment.CleanName(filepath.Base(wd) + "-dev") - if len(suggest) > environment.EnvironmentNameMaxLength { - suggest = suggest[len(suggest)-environment.EnvironmentNameMaxLength:] +func (i *initAction) InitializeTemplate( + ctx context.Context, + azdCtx *azdcontext.AzdContext) error { + err := i.repoInitializer.PromptIfNonEmpty(ctx, azdCtx) + if err != nil { + return err } - envSpec := environmentSpec{ - environmentName: i.flags.environmentName, - subscription: i.flags.subscription, - location: i.flags.location, - suggest: suggest, - } + if i.flags.templatePath == "" { + template, err := templates.PromptTemplate(ctx, "Select a project template:", i.templateManager, i.console) + if template != nil { + i.flags.templatePath = template.RepositoryPath + } - env, err := createEnvironment(ctx, envSpec, azdCtx, i.console) - if err != nil { - return nil, fmt.Errorf("loading environment: %w", err) + if err != nil { + return err + } } - if err := azdCtx.SetDefaultEnvironmentName(env.GetEnvName()); err != nil { - return nil, fmt.Errorf("saving default environment: %w", err) + if i.flags.templatePath != "" { + gitUri, err := templates.Absolute(i.flags.templatePath) + if err != nil { + return err + } + + err = i.repoInitializer.Initialize(ctx, azdCtx, gitUri, i.flags.templateBranch) + if err != nil { + return fmt.Errorf("init from template repository: %w", err) + } + } else { + err := i.repoInitializer.InitializeMinimal(ctx, azdCtx) + if err != nil { + return fmt.Errorf("init empty repository: %w", err) + } } - return &actions.ActionResult{ - Message: &actions.ResultMessage{ - Header: "New project initialized!", - FollowUp: heredoc.Docf(` - You can view the template code in your directory: %s - Learn more about running 3rd party code on our DevHub: %s`, - output.WithLinkFormat("%s", wd), - output.WithLinkFormat("%s", "https://aka.ms/azd-third-party-code-notice")), - }, - }, nil + return nil } func getCmdInitHelpDescription(*cobra.Command) string { diff --git a/cli/azd/cmd/util.go b/cli/azd/cmd/util.go index 6647863bee9..ac94a21a00b 100644 --- a/cli/azd/cmd/util.go +++ b/cli/azd/cmd/util.go @@ -40,7 +40,20 @@ func invalidEnvironmentNameMsg(environmentName string) string { // ensureValidEnvironmentName ensures the environment name is valid, if it is not, an error is printed // and the user is prompted for a new name. -func ensureValidEnvironmentName(ctx context.Context, environmentName *string, suggest string, console input.Console) error { +func ensureValidEnvironmentName( + ctx context.Context, + environmentName *string, + examples []string, + console input.Console) error { + exampleText := "" + if len(examples) > 0 { + exampleText = "\n\nExamples:" + } + + for _, example := range examples { + exampleText += fmt.Sprintf("\n %s", example) + } + for !environment.IsValidEnvironmentName(*environmentName) { userInput, err := console.Prompt(ctx, input.ConsoleOptions{ Message: "Enter a new environment name:", @@ -49,8 +62,7 @@ func ensureValidEnvironmentName(ctx context.Context, environmentName *string, su This value is typically used by the infrastructure as code templates to name the resource group that contains the infrastructure for your application and to generate a unique suffix that is applied to resources to prevent - naming collisions.`), - DefaultValue: suggest, + naming collisions.`) + exampleText, }) if err != nil { @@ -71,8 +83,8 @@ type environmentSpec struct { environmentName string subscription string location string - // suggest is the name that is offered as a suggestion if we need to prompt the user for an environment name. - suggest string + // examples of environment names to prompt. + examples []string } // createEnvironment creates a new named environment. If an environment with this name already @@ -89,7 +101,7 @@ func createEnvironment( return nil, fmt.Errorf(errMsg) } - if err := ensureValidEnvironmentName(ctx, &envSpec.environmentName, envSpec.suggest, console); err != nil { + if err := ensureValidEnvironmentName(ctx, &envSpec.environmentName, envSpec.examples, console); err != nil { return nil, err } @@ -184,7 +196,7 @@ func loadOrCreateEnvironment( environmentName) } - if err := ensureValidEnvironmentName(ctx, &environmentName, "", console); err != nil { + if err := ensureValidEnvironmentName(ctx, &environmentName, nil, console); err != nil { return nil, false, err } diff --git a/cli/azd/cmd/util_test.go b/cli/azd/cmd/util_test.go index 64e3c8920ca..f5d5de6e5b8 100644 --- a/cli/azd/cmd/util_test.go +++ b/cli/azd/cmd/util_test.go @@ -31,7 +31,7 @@ func Test_promptEnvironmentName(t *testing.T) { environmentName := "hello" - err := ensureValidEnvironmentName(*mockContext.Context, &environmentName, "", mockContext.Console) + err := ensureValidEnvironmentName(*mockContext.Context, &environmentName, nil, mockContext.Console) require.NoError(t, err) }) @@ -44,7 +44,7 @@ func Test_promptEnvironmentName(t *testing.T) { return true }).Respond("someEnv") - err := ensureValidEnvironmentName(*mockContext.Context, &environmentName, "", mockContext.Console) + err := ensureValidEnvironmentName(*mockContext.Context, &environmentName, nil, mockContext.Console) require.NoError(t, err) require.Equal(t, "someEnv", environmentName) diff --git a/cli/azd/internal/repository/detector.go b/cli/azd/internal/repository/detector.go new file mode 100644 index 00000000000..d6055272d08 --- /dev/null +++ b/cli/azd/internal/repository/detector.go @@ -0,0 +1,226 @@ +package repository + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/azure/azure-dev/cli/azd/internal/appdetect" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" + "github.com/azure/azure-dev/cli/azd/pkg/project" +) + +func DetectionToConfig(root string, projects []appdetect.Project) (project.ProjectConfig, error) { + config := project.ProjectConfig{ + Name: filepath.Base(root), + Services: map[string]*project.ServiceConfig{}, + } + for _, prj := range projects { + rel, err := filepath.Rel(root, prj.Path) + if err != nil { + return project.ProjectConfig{}, err + } + + svc := project.ServiceConfig{} + svc.Host = "containerapp" + svc.RelativePath = rel + + language := mapLanguage(prj.Language) + if language == "" { + continue + } + svc.Language = language + + if prj.Docker != nil { + relDocker, err := filepath.Rel(prj.Path, prj.Docker.Path) + if err != nil { + return project.ProjectConfig{}, err + } + + svc.Docker = project.DockerProjectOptions{ + Path: relDocker, + } + } else { + entrypoint := "" + module := "" + if svc.Language == project.ServiceLanguagePython { + mapped := map[appdetect.Dependency]struct{}{} + for _, f := range prj.Dependencies { + mapped[f] = struct{}{} + } + + if _, ok := mapped[appdetect.PyDjango]; ok { + de, err := os.ReadDir(prj.Path) + if err != nil { + return project.ProjectConfig{}, err + } + + for _, e := range de { + if e.IsDir() { + if _, err := os.Stat(filepath.Join(prj.Path, e.Name(), "wsgi.py")); err == nil { + module = e.Name() + ".wsgi" + entrypoint = "gunicorn --access-logfile '-' --error-logfile '-' " + module + break + } + } + } + } else if _, ok := mapped["flask"]; ok { + knownFiles := []string{ + "app.py", "application.py", "index.py", "run.py", "server.py", "wsgi.py", + } + + for _, f := range knownFiles { + if _, err := os.Stat(filepath.Join(prj.Path, f)); err == nil { + module = f[:len(f)-3] + ":" + "app" + entrypoint = "gunicorn --access-logfile '-' --error-logfile '-' " + module + break + } + } + } else if _, ok := mapped["fastapi"]; ok { + matches, err := filepath.Glob(filepath.Join(prj.Path, "*/*.py")) + if err != nil { + return project.ProjectConfig{}, err + } + + search: + for _, m := range matches { + if filepath.Ext(m) == ".py" { + f, err := os.Open(m) + if err != nil { + return project.ProjectConfig{}, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "FastAPI(") { + rel, err := filepath.Rel(prj.Path, m) + if err != nil { + return project.ProjectConfig{}, err + } + moduleFile := strings.ReplaceAll(rel, "/", ".") + moduleFile = moduleFile[:len(moduleFile)-3] + module = moduleFile + ":" + "app" + entrypoint = "uvicorn " + module + " --port $PORT --host 0.0.0.0" + break search + } + } + } + } + } else { + matches, err := filepath.Glob(filepath.Join(prj.Path, "*/*.py")) + if err != nil { + return project.ProjectConfig{}, err + } + + searchMain: + for _, m := range matches { + if filepath.Ext(m) == ".py" { + f, err := os.Open(m) + if err != nil { + return project.ProjectConfig{}, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if strings.Contains(line, "__main__") { + rel, err := filepath.Rel(prj.Path, m) + if err != nil { + return project.ProjectConfig{}, err + } + entrypoint = "python " + rel + break searchMain + } + } + } + } + } + + if entrypoint != "" { + err = os.WriteFile(filepath.Join(prj.Path, "Procfile"), []byte("web: "+entrypoint), osutil.PermissionFile) + if err != nil { + return project.ProjectConfig{}, err + } + } + } + + for _, frwk := range prj.Dependencies { + switch frwk { + case appdetect.JsReact: + svc.OutputPath = "build" + case appdetect.JsAngular, appdetect.JsVue: + svc.OutputPath = "dist" + case appdetect.JsJQuery: + svc.OutputPath = "." + } + } + } + + name := filepath.Base(rel) + if name == "." { + name = config.Name + } + config.Services[name] = &svc + } + + return config, nil +} + +func GenerateProject(path string) error { + projects, err := appdetect.Detect(path) + if err != nil { + return err + } + + config := project.ProjectConfig{ + Name: filepath.Base(path), + Services: map[string]*project.ServiceConfig{}, + } + for _, prj := range projects { + rel, err := filepath.Rel(path, prj.Path) + if err != nil { + return err + } + + svc := project.ServiceConfig{} + svc.Name = filepath.Base(rel) + svc.Host = "appservice" + svc.RelativePath = rel + + switch prj.Language { + case appdetect.Python: + svc.Language = project.ServiceLanguagePython + case appdetect.DotNet: + svc.Language = project.ServiceLanguageDotNet + case appdetect.JavaScript: + svc.Language = project.ServiceLanguageJavaScript + case appdetect.TypeScript: + svc.Language = project.ServiceLanguageTypeScript + case appdetect.Java: + svc.Language = project.ServiceLanguageJava + default: + panic(fmt.Sprintf("unhandled language: %s", string(prj.Language))) + } + + if prj.Docker != nil { + relDocker, err := filepath.Rel(prj.Path, prj.Docker.Path) + if err != nil { + return err + } + + svc.Docker = project.DockerProjectOptions{ + Path: relDocker, + } + } + + config.Services[svc.Name] = &svc + } + + return project.Save(context.Background(), &config, filepath.Join(path, "azure.yaml.gen")) +} diff --git a/cli/azd/internal/repository/detector_live_test.go b/cli/azd/internal/repository/detector_live_test.go new file mode 100644 index 00000000000..6db6ffb28e1 --- /dev/null +++ b/cli/azd/internal/repository/detector_live_test.go @@ -0,0 +1,300 @@ +package repository + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/exp/slices" +) + +func TestGenerateProject_Live(t *testing.T) { + if os.Getenv("AZD_TEST_APP_GENERATE_LIVE") == "" { + t.Skip("skip live test") + } + + root := "testdata/live" + newTemplates := Discover(t, root) + + tests := []struct { + Name string + Suppressed bool + }{ + {Name: "azure-reliable-web-app-pattern-dotnet"}, + {Name: "azure-samples-app-service-javascript-sap-cloud-sdk-quickstart"}, + {Name: "azure-samples-apptemplate-wordpress-on-aca"}, + {Name: "azure-samples-asa-samples-event-driven-application"}, + {Name: "azure-samples-azure-django-postgres-aca"}, + {Name: "azure-samples-azure-health-data-services-toolkit-fhir-function-quickstart"}, + {Name: "azure-samples-azure-search-openai-demo", Suppressed: true}, + // false positive + // expected: []repository.service{repository.service{language:"python", path:"app/backend"}} + // actual : []repository.service{ + // repository.service{language:"python", path:"app/backend"}, + // repository.service{language:"ts", path:"app/frontend"}, + // repository.service{language:"python", path:"notebooks"}, repository.service{language:"python", path:"scripts"}} + + {Name: "azure-samples-azure-search-openai-demo-csharp", Suppressed: true}, + // false positive + // expected: []repository.service{repository.service{language:"dotnet", path:"app/backend"}} + // actual : []repository.service{ + // repository.service{language:"dotnet", path:"app/backend"}, + // repository.service{language:"dotnet", path:"app/frontend"}, + // repository.service{language:"dotnet", path:"app/prepdocs/PrepareDocs"}, + // repository.service{language:"python", path:"notebooks"}} + + {Name: "azure-samples-bindings-dapr-csharp-cron-postgres", Suppressed: true}, + // repository error: language should be csharp + {Name: "azure-samples-bindings-dapr-nodejs-cron-postgres"}, + {Name: "azure-samples-bindings-dapr-python-cron-postgres"}, + {Name: "azure-samples-chatgpt-quickstart"}, + {Name: "azure-samples-contoso-real-estate", Suppressed: true}, + // detection incorrect: repository has multiple packages.json + {Name: "azure-samples-fastapi-on-azure-functions"}, + {Name: "azure-samples-function-csharp-ai-textsummarize"}, + {Name: "azure-samples-function-python-ai-textsummarize"}, + {Name: "azure-samples-msdocs-django-postgresql-sample-app"}, + {Name: "azure-samples-msdocs-flask-postgresql-sample-app"}, + {Name: "azure-samples-openai-plugin-fastapi"}, + {Name: "azure-samples-pubsub-dapr-csharp-servicebus"}, + {Name: "azure-samples-pubsub-dapr-nodejs-servicebus"}, + {Name: "azure-samples-pubsub-dapr-python-servicebus"}, + {Name: "azure-samples-react-component-toolkit-openai-demo"}, + {Name: "azure-samples-spring-petclinic-java-mysql"}, + {Name: "azure-samples-svc-invoke-dapr-csharp"}, + {Name: "azure-samples-svc-invoke-dapr-nodejs"}, + {Name: "azure-samples-svc-invoke-dapr-python"}, + // todo apps have src/web specified as "js" instead of "ts" + // this is fixed using custom logic in the comparison below + {Name: "azure-samples-todo-csharp-cosmos-sql"}, + {Name: "azure-samples-todo-csharp-sql"}, + {Name: "azure-samples-todo-csharp-sql-swa-func"}, + {Name: "azure-samples-todo-java-mongo"}, + // api has both "packages.json" and "pom.xml". should be java, we break the tie giving precedence to pom.xml + {Name: "azure-samples-todo-java-mongo-aca"}, + {Name: "azure-samples-todo-nodejs-mongo"}, + {Name: "azure-samples-todo-nodejs-mongo-aca"}, + {Name: "azure-samples-todo-nodejs-mongo-aks"}, + {Name: "azure-samples-todo-nodejs-mongo-swa-func"}, + {Name: "azure-samples-todo-nodejs-mongo-terraform"}, + {Name: "azure-samples-todo-python-mongo"}, + {Name: "azure-samples-todo-python-mongo-aca"}, + {Name: "azure-samples-todo-python-mongo-swa-func"}, + {Name: "azure-samples-todo-python-mongo-terraform"}, + {Name: "bradygaster-rockpaperorleans"}, + {Name: "pamelafox-django-quiz-app"}, + {Name: "pamelafox-fastapi-azure-function-apim"}, + {Name: "pamelafox-flask-charts-api-container-app"}, + {Name: "pamelafox-flask-db-quiz-example"}, + {Name: "pamelafox-flask-gallery-container-app"}, + {Name: "pamelafox-flask-surveys-container-app"}, + {Name: "pamelafox-simple-fastapi-container"}, + {Name: "pamelafox-simple-flask-api-container"}, + {Name: "pamelafox-staticmaps-function"}, + {Name: "rpothin-servicebus-csharp-function-dataverse", Suppressed: true}, + // incorrect detection: doesn't handle functionapp + {Name: "sabbour-aks-app-template", Suppressed: true}, + // false positive: sabbour-aks-app-template has placeholder app + {Name: "savannahostrowski-jupyter-mercury-aca"}, + {Name: "tonybaloney-django-on-azure"}, + {Name: "tonybaloney-simple-flask-azd"}, + } + + existingTests := make(map[string]struct{}, len(tests)) + for _, tt := range tests { + existingTests[tt.Name] = struct{}{} + } + + for _, template := range newTemplates { + if _, ok := existingTests[template]; !ok { + t.Errorf("new template: %s", template) + } + } + + for _, tt := range tests { + if tt.Suppressed { + continue + } + + t.Run(tt.Name, func(t *testing.T) { + dir := filepath.Join(root, tt.Name) + err := GenerateProject(dir) + require.NoError(t, err) + + expectedPrj, err := project.Load(context.Background(), filepath.Join(dir, "azure.yaml")) + require.NoError(t, err) + + actualPrj, err := project.Load(context.Background(), filepath.Join(dir, "azure.yaml.gen")) + require.NoError(t, err) + + type service struct { + language string + path string + } + + expected := make([]service, 0, len(expectedPrj.Services)) + for _, svc := range expectedPrj.Services { + if svc.Language == project.ServiceLanguageCsharp || + svc.Language == project.ServiceLanguageFsharp { + svc.Language = project.ServiceLanguageDotNet + } + + if strings.HasPrefix(expectedPrj.Name, "todo-") { + if svc.Language == project.ServiceLanguageJavaScript { + svc.Language = project.ServiceLanguageTypeScript + } + } + + expected = append(expected, service{ + language: string(svc.Language), + path: filepath.Clean(svc.RelativePath), + }) + } + slices.SortFunc(expected, func(a, b service) bool { + return a.path < b.path + }) + + actual := make([]service, 0, len(actualPrj.Services)) + for _, svc := range actualPrj.Services { + actual = append(actual, service{ + language: string(svc.Language), + path: filepath.Clean(svc.RelativePath), + }) + } + slices.SortFunc(actual, func(a, b service) bool { + return a.path < b.path + }) + + assert.Equal(t, expected, actual) + }) + } +} + +var sourceRegex = regexp.MustCompile(`source:\s+'(.+?)'`) + +// Discovers templates to use for testing, cloning each template repository under root. +// Currently, this uses the current list of awesome-azd templates. +func Discover(t *testing.T, root string) []string { + resp, err := http.Get("https://raw.githubusercontent.com/Azure/awesome-azd/main/website/src/data/users.tsx") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("GitHub API returned non-200 status code: %s", resp.Status) + } + + bytes, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + + matches := sourceRegex.FindAllStringSubmatch(string(bytes), -1) + if matches == nil { + t.Fatal("found no matches") + } + + repos := make([]string, 0, len(matches)) + for _, match := range matches { + if len(match) != 2 { + panic("invalid match") + } + repos = append(repos, match[1]) + } + + slices.Sort(repos) + cloneRepositories(t, repos, root) + + entries, err := os.ReadDir(root) + require.NoError(t, err) + + names := make([]string, 0, len(entries)) + for _, ent := range entries { + names = append(names, ent.Name()) + } + + return names +} + +type CloneJob struct { + CloneURL string + TargetDir string +} + +func cloneRepositories(t *testing.T, repositories []string, rootDirectory string) { + err := os.MkdirAll(rootDirectory, 0755) + if err != nil { + t.Fatal(err) + } + jobs := make(chan CloneJob, len(repositories)) + results := make(chan bool, len(repositories)) + + for i := 0; i < 15; i++ { + go cloneWorker(t, jobs, results) + } + + host := "https://github.com/" + for _, repo := range repositories { + name := repo[len(host):] + name = strings.ToLower(name) + name = strings.TrimRight(name, "/") + name = strings.ReplaceAll(name, "/", "-") + + // Create the target directory for cloning + targetDir := filepath.Join(rootDirectory, name) + + t.Logf("repo: %s", name) + + // Check if the directory already exists + if _, err := os.Stat(targetDir); !os.IsNotExist(err) { + t.Logf("Skipping %s: Directory already exists\n", name) + results <- true + continue + } + + // Add clone job to the queue + jobs <- CloneJob{CloneURL: repo, TargetDir: targetDir} + } + + close(jobs) + + for i := 0; i < len(repositories); i++ { + <-results + } + close(results) +} + +func cloneWorker(t *testing.T, jobs <-chan CloneJob, results chan<- bool) { + for job := range jobs { + err := cloneRepository(job.CloneURL, job.TargetDir) + if err != nil { + panic(fmt.Sprintf("Error cloning %s: %s\n", job.CloneURL, err)) + } else { + t.Logf("Cloned %s\n", job.CloneURL) + } + + results <- true + } +} + +func cloneRepository(cloneURL, targetDir string) error { + cmd := exec.Command("git", "clone", cloneURL, targetDir) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to clone repository: %w\nOutput: %s", err, output) + } + + return nil +} diff --git a/cli/azd/internal/repository/infra_gen.go b/cli/azd/internal/repository/infra_gen.go new file mode 100644 index 00000000000..dc0bbf00688 --- /dev/null +++ b/cli/azd/internal/repository/infra_gen.go @@ -0,0 +1,943 @@ +package repository + +import ( + "bytes" + "context" + "embed" + "errors" + "fmt" + "io/fs" + "log" + "os" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + "text/tabwriter" + "text/template" + "time" + + "github.com/azure/azure-dev/cli/azd/internal/appdetect" + "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/azure/azure-dev/cli/azd/resources" + "github.com/fatih/color" + "github.com/otiai10/copy" + "golang.org/x/exp/maps" +) + +// A regex that matches against "likely" well-formed database names +var wellFormedDbNameRegex = regexp.MustCompile(`^[a-zA-Z\-_0-9]*$`) + +type DatabasePostgres struct { + DatabaseUser string + DatabaseName string +} + +type DatabaseCosmos struct { + DatabaseName string +} + +type Parameter struct { + Name string + Value string + Type string + Secret bool +} + +type InfraSpec struct { + Parameters []Parameter + Services []ServiceSpec + + // Databases to create + DbPostgres *DatabasePostgres + DbCosmos *DatabaseCosmos +} + +type Frontend struct { + Backends []ServiceSpec +} + +type Backend struct { + Frontends []ServiceSpec +} + +type ServiceSpec struct { + Name string + Port int + + // Front-end properties. + Frontend *Frontend + + // Back-end properties + Backend *Backend + + // Connection to a database. Only one should be set. + DbPostgres *DatabasePostgres + DbCosmos *DatabaseCosmos +} + +func supportedLanguages() []appdetect.Language { + return []appdetect.Language{ + appdetect.DotNet, + appdetect.Java, + appdetect.JavaScript, + appdetect.TypeScript, + appdetect.Python, + } +} + +func mapLanguage(l appdetect.Language) project.ServiceLanguageKind { + switch l { + case appdetect.Python: + return project.ServiceLanguagePython + case appdetect.DotNet: + return project.ServiceLanguageDotNet + case appdetect.JavaScript: + return project.ServiceLanguageJavaScript + case appdetect.TypeScript: + return project.ServiceLanguageTypeScript + case appdetect.Java: + return project.ServiceLanguageJava + default: + return "" + } +} + +func supportedFrameworks() []appdetect.Dependency { + return []appdetect.Dependency{ + appdetect.JsAngular, + appdetect.JsJQuery, + appdetect.JsVue, + appdetect.JsReact, + } +} + +func supportedDatabases() []appdetect.DatabaseDep { + return []appdetect.DatabaseDep{ + appdetect.DbMongo, + appdetect.DbPostgres, + } +} + +func projectDisplayName(p appdetect.Project) string { + name := p.Language.Display() + for _, framework := range p.Dependencies { + if framework.IsWebUIFramework() { + name = framework.Display() + } + } + + return name +} + +func dirSuggestions(input string) []string { + completions := []string{} + matches, _ := filepath.Glob(input + "*") + for _, match := range matches { + if fs, err := os.Stat(match); err == nil && fs.IsDir() { + completions = append(completions, match) + } + } + return completions +} + +func tabWrite(selections []string, padding int) ([]string, error) { + tabbed := strings.Builder{} + tabW := tabwriter.NewWriter(&tabbed, 0, 0, padding, ' ', 0) + _, err := tabW.Write([]byte(strings.Join(selections, "\n"))) + if err != nil { + return nil, err + } + err = tabW.Flush() + if err != nil { + return nil, err + } + + return strings.Split(tabbed.String(), "\n"), nil +} + +func promptDir( + ctx context.Context, + console input.Console, + message string) (string, error) { + for { + path, err := console.Prompt(ctx, input.ConsoleOptions{ + Message: message, + Suggest: dirSuggestions, + }) + if err != nil { + return "", err + } + + fs, err := os.Stat(path) + if errors.Is(err, os.ErrNotExist) || fs != nil && !fs.IsDir() { + console.Message(ctx, fmt.Sprintf("'%s' is not a valid directory", path)) + continue + } + + if err != nil { + return "", err + } + + path, err = filepath.Abs(path) + if err != nil { + return "", err + } + + return path, err + } +} + +type EntryKind string + +const ( + EntryKindDetected EntryKind = "detection" + EntryKindManual EntryKind = "manual" + EntryKindModified EntryKind = "modified" +) + +func (i *Initializer) InitializeInfra( + ctx context.Context, + azdCtx *azdcontext.AzdContext, + initializeEnv func() error) error { + wd := azdCtx.ProjectDirectory() + i.console.Message(ctx, "") + title := "Scanning app code in current directory" + // Prioritize src directory if it exists + sourceDir := filepath.Join(wd, "src") + projects := []appdetect.Project{} + if ent, err := os.Stat(sourceDir); err == nil && ent.IsDir() { + prj, err := appdetect.Detect(sourceDir) + if err == nil && len(prj) > 0 { + projects = prj + } + } + + if len(projects) == 0 { + prj, err := appdetect.Detect(wd) + if err != nil { + return err + } + + projects = prj + } + + i.console.ShowSpinner(ctx, title, input.Step) + projects, err := appdetect.Detect(wd) + time.Sleep(1 * time.Second) + + i.console.StopSpinner(ctx, title, input.GetStepResultFormat(err)) + + if err != nil { + return err + } + + detectedDbs := make(map[appdetect.DatabaseDep]EntryKind) + for _, project := range projects { + for _, dbType := range project.DatabaseDeps { + detectedDbs[dbType] = EntryKindDetected + } + } + + revision := false + +confirmDetection: + for { + if revision { + i.console.ShowSpinner(ctx, "Revising detected services", input.Step) + time.Sleep(1 * time.Second) + i.console.StopSpinner(ctx, "Revising detected services", input.StepDone) + i.console.Message(ctx, "\n"+output.WithBold("Detected services (Revised):")+"\n") + } else { + i.console.Message(ctx, "\n"+output.WithBold("Detected services:")+"\n") + } + // assume changes will be made by default + revision = true + + recommendedServices := []string{} + for _, project := range projects { + status := "" + if project.DetectionRule == string(EntryKindModified) { + status = " " + output.WithSuccessFormat("[Updated]") + } else if project.DetectionRule == string(EntryKindManual) { + status = " " + output.WithSuccessFormat("[Added]") + } + + i.console.Message(ctx, " "+output.WithBlueFormat(projectDisplayName(project))+status) + + rel, err := filepath.Rel(wd, project.Path) + if err != nil { + return err + } + relWithDot := "." + if rel != "." { + relWithDot = "./" + rel + } + i.console.Message(ctx, " "+"Detected in: "+output.WithHighLightFormat(relWithDot)) + i.console.Message(ctx, "") + + if len(recommendedServices) == 0 { + recommendedServices = append(recommendedServices, "Azure Container Apps") + } + } + + // handle detectedDbs + for db, entry := range detectedDbs { + switch db { + case appdetect.DbPostgres: + recommendedServices = append(recommendedServices, "Azure Database for PostgreSQL flexible server") + case appdetect.DbMongo: + recommendedServices = append(recommendedServices, "Azure CosmosDB API for MongoDB") + } + status := "" + if entry == EntryKindModified { + status = " " + output.WithSuccessFormat("[Updated]") + } else if entry == EntryKindManual { + status = " " + output.WithSuccessFormat("[Added]") + } + + i.console.Message(ctx, " "+output.WithBlueFormat(db.Display())+status) + i.console.Message(ctx, "") + } + + displayedServices := make([]string, 0, len(recommendedServices)) + for _, svc := range recommendedServices { + displayedServices = append(displayedServices, color.MagentaString(svc)) + } + + if len(displayedServices) > 0 { + i.console.Message(ctx, + "azd will generate the files necessary to host your app on Azure using "+ + ux.ListAsText(displayedServices)+".\n") + } + + continueOption, err := i.console.Select(ctx, input.ConsoleOptions{ + Message: "Select an option", + Options: []string{ + "Confirm and continue initializing my app", + "Add or remove a service", + }, + }) + if err != nil { + return err + } + + switch continueOption { + case 0: + break confirmDetection + case 1: + modifyIdx, err := i.console.Select(ctx, input.ConsoleOptions{ + Message: "Add or remove a service", + Options: []string{ + "Add a service", + "Remove a service", + }, + }) + if err != nil { + return err + } + + switch modifyIdx { + case 0: + languages := supportedLanguages() + frameworks := supportedFrameworks() + allDbs := supportedDatabases() + databases := make([]appdetect.DatabaseDep, 0, len(allDbs)) + for _, db := range allDbs { + if _, ok := detectedDbs[db]; !ok { + databases = append(databases, db) + } + } + selections := make([]string, 0, len(languages)+len(databases)) + entries := make([]any, 0, len(languages)+len(databases)) + + for _, lang := range languages { + selections = append(selections, fmt.Sprintf("%s\t%s", lang.Display(), "[Language]")) + entries = append(entries, lang) + } + + for _, framework := range frameworks { + selections = append(selections, fmt.Sprintf("%s\t%s", framework.Display(), "[Framework]")) + entries = append(entries, framework) + } + + for _, db := range databases { + selections = append(selections, fmt.Sprintf("%s\t%s", db.Display(), "[Database]")) + entries = append(entries, db) + } + + selections, err = tabWrite(selections, 3) + if err != nil { + return err + } + + entIdx, err := i.console.Select(ctx, input.ConsoleOptions{ + Message: "Select a language or database to add", + Options: selections, + }) + if err != nil { + return err + } + + s := appdetect.Project{} + switch entries[entIdx].(type) { + case appdetect.Language: + s.Language = entries[entIdx].(appdetect.Language) + case appdetect.DatabaseDep: + dbDep := entries[entIdx].(appdetect.DatabaseDep) + detectedDbs[dbDep] = EntryKindManual + + selection := make([]string, 0, len(projects)) + for _, prj := range projects { + selection = append(selection, + fmt.Sprintf("%s\t[%s]", projectDisplayName(prj), filepath.Base(prj.Path))) + } + + selection, err = tabWrite(selection, 3) + if err != nil { + return err + } + + idx, err := i.console.Select(ctx, input.ConsoleOptions{ + Message: "Select the service that uses this database", + Options: selection, + }) + if err != nil { + return err + } + + projects[idx].DatabaseDeps = append(projects[idx].DatabaseDeps, dbDep) + continue confirmDetection + case appdetect.Dependency: + framework := entries[entIdx].(appdetect.Dependency) + if framework.Language() != "" { + s.Dependencies = []appdetect.Dependency{framework} + s.Language = framework.Language() + } + default: + log.Panic("unhandled entry type") + } + + msg := fmt.Sprintf("Enter file path of the directory that uses '%s'", projectDisplayName(s)) + path, err := promptDir(ctx, i.console, msg) + if err != nil { + return err + } + + for idx, project := range projects { + if project.Path == path { + i.console.Message( + ctx, + fmt.Sprintf( + "\nazd previously detected '%s' at %s.\n", projectDisplayName(project), project.Path)) + + confirm, err := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Do you want to change the detected service to '%s'", projectDisplayName(s)), + }) + if err != nil { + return err + } + if confirm { + projects[idx].Language = s.Language + projects[idx].Dependencies = s.Dependencies + projects[idx].DetectionRule = string(EntryKindModified) + } else { + revision = false + } + + continue confirmDetection + } + } + + s.Path = filepath.Clean(path) + s.DetectionRule = string(EntryKindManual) + projects = append(projects, s) + continue confirmDetection + case 1: + modifyOptions := make([]string, 0, len(projects)+len(detectedDbs)) + for _, project := range projects { + rel, err := filepath.Rel(wd, project.Path) + if err != nil { + return err + } + + relWithDot := "./" + rel + modifyOptions = append( + modifyOptions, fmt.Sprintf("%s in %s", projectDisplayName(project), relWithDot)) + } + + displayDbs := maps.Keys(detectedDbs) + for _, db := range displayDbs { + modifyOptions = append(modifyOptions, db.Display()) + } + + modifyRemove: + for { + modifyIdx, err := i.console.Select(ctx, input.ConsoleOptions{ + Message: "Select the service you want to remove", + Options: modifyOptions, + }) + if err != nil { + return err + } + + if modifyIdx < len(projects) { + prj := projects[modifyIdx] + confirm, err := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Remove %s in %s?", projectDisplayName(prj), prj.Path), + }) + if err != nil { + return err + } + + if !confirm { + continue modifyRemove + } + + projects = append(projects[:modifyIdx], projects[modifyIdx+1:]...) + break modifyRemove + } else if modifyIdx < len(projects)+len(detectedDbs) { + db := displayDbs[modifyIdx-len(projects)] + + confirm, err := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Remove %s?", db.Display()), + }) + if err != nil { + return err + } + + if confirm { + delete(detectedDbs, db) + } + + break modifyRemove + } + } + + } + } + } + + spec := InfraSpec{} + for database := range detectedDbs { + dbPrompt: + for { + dbName, err := i.console.Prompt(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Input the name of the database (%s)", database.Display()), + Help: ux.InputHint{ + Title: "Database name", + Text: "Input a name for the database. This database will be created after running azd provision " + + "or azd up." + "\nYou may skip this step by hitting enter, " + + "in which case the database will not be created.", + Examples: []string{ + "appdb", + "app-db", + "app_db_1", + }, + }.ToString(), + }) + if err != nil { + return err + } + + if strings.ContainsAny(dbName, " ") { + i.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: "Database name contains whitespace. This might not be allowed by the database server.", + }) + confirm, err := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Continue with name '%s'?", dbName), + }) + if err != nil { + return err + } + + if !confirm { + continue dbPrompt + } + } else if !wellFormedDbNameRegex.MatchString(dbName) { + i.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: "Database name contains special characters. This might not be allowed by the database server.", + }) + confirm, err := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Continue with name '%s'?", dbName), + }) + if err != nil { + return err + } + + if !confirm { + continue dbPrompt + } + } + + switch database { + case appdetect.DbMongo: + spec.DbCosmos = &DatabaseCosmos{ + DatabaseName: dbName, + } + + break dbPrompt + case appdetect.DbPostgres: + spec.DbPostgres = &DatabasePostgres{ + DatabaseName: dbName, + } + + spec.Parameters = append(spec.Parameters, + Parameter{ + Name: "sqlAdminPassword", + Value: "$(secretOrRandomPassword)", + Type: "string", + Secret: true, + }, + Parameter{ + Name: "appUserPassword", + Value: "$(secretOrRandomPassword)", + Type: "string", + Secret: true, + }) + break dbPrompt + } + } + } + + backends := []ServiceSpec{} + frontends := []ServiceSpec{} + for _, project := range projects { + name := filepath.Base(project.Path) + serviceSpec := ServiceSpec{ + Name: name, + Port: -1, + } + + if project.Docker == nil || project.Docker.Path == "" { + // default buildpack ports: + // - python: 80 + // - other: 8080 + serviceSpec.Port = 8080 + // if project.Language == appdetect.Python { + // serviceSpec.Port = 80 + // } + } + + for _, framework := range project.Dependencies { + if framework.IsWebUIFramework() { + serviceSpec.Frontend = &Frontend{} + } + } + + for _, db := range project.DatabaseDeps { + switch db { + case appdetect.DbMongo: + serviceSpec.DbCosmos = spec.DbCosmos + case appdetect.DbPostgres: + serviceSpec.DbPostgres = spec.DbPostgres + } + } + spec.Services = append(spec.Services, serviceSpec) + } + + for idx := range spec.Services { + if spec.Services[idx].Port == -1 { + var port int + for { + val, err := i.console.Prompt(ctx, input.ConsoleOptions{ + Message: "What port does '" + spec.Services[idx].Name + "' listen on?", + }) + if err != nil { + return err + } + + port, err = strconv.Atoi(val) + if err != nil { + i.console.Message(ctx, "Port must be an integer. Try again or press Ctrl+C to cancel") + continue + } + + if port < 1 || port > 65535 { + i.console.Message(ctx, "Port must be a value between 1 and 65535. Try again or press Ctrl+C to cancel") + continue + } + + break + } + spec.Services[idx].Port = port + } + + if spec.Services[idx].Frontend == nil && spec.Services[idx].Port != 0 { + backends = append(backends, spec.Services[idx]) + spec.Services[idx].Backend = &Backend{} + } else { + frontends = append(frontends, spec.Services[idx]) + } + } + + // Link services together + for _, service := range spec.Services { + if service.Frontend != nil { + service.Frontend.Backends = backends + } + + if service.Backend != nil { + service.Backend.Frontends = frontends + } + + spec.Parameters = append(spec.Parameters, Parameter{ + Name: bicepName(service.Name) + "Exists", + Value: fmt.Sprintf("${SERVICE_%s_RESOURCE_EXISTS=false}", + strings.ReplaceAll(strings.ToUpper(service.Name), "-", "_")), + Type: "bool", + }) + } + + err = initializeEnv() + if err != nil { + return err + } + + i.console.Message(ctx, "\n"+output.WithBold("Generating files to run your app on Azure:")+"\n") + + generateProject := func() error { + title := "Generating " + output.WithHighLightFormat("./"+azdcontext.ProjectFileName) + i.console.ShowSpinner(ctx, title, input.Step) + defer i.console.StopSpinner(ctx, title, input.GetStepResultFormat(err)) + config, err := DetectionToConfig(wd, projects) + if err != nil { + return fmt.Errorf("converting config: %w", err) + } + err = project.Save( + ctx, + &config, + filepath.Join(wd, azdcontext.ProjectFileName)) + if err != nil { + return fmt.Errorf("generating azure.yaml: %w", err) + } + + return i.writeCoreAssets(ctx, azdCtx) + } + + err = generateProject() + if err != nil { + return err + } + + target := filepath.Join(azdCtx.ProjectDirectory(), "infra") + title = "Generating Infrastructure as Code files in " + output.WithHighLightFormat("./infra") + i.console.ShowSpinner(ctx, title, input.Step) + defer i.console.StopSpinner(ctx, title, input.GetStepResultFormat(err)) + + staging, err := os.MkdirTemp("", "azd-infra") + if err != nil { + return fmt.Errorf("mkdir temp: %w", err) + } + + defer func() { _ = os.RemoveAll(staging) }() + + err = copyFS(resources.ScaffoldBase, "scaffold/base", staging) + if err != nil { + return fmt.Errorf("copying to staging: %w", err) + } + + stagingApp := filepath.Join(staging, "app") + if err := os.MkdirAll(stagingApp, osutil.PermissionDirectory); err != nil { + return err + } + + funcMap := template.FuncMap{ + "bicepName": bicepName, + "containerAppName": containerAppName, + "upper": strings.ToUpper, + "lower": strings.ToLower, + } + + root := "scaffold/templates" + t, err := template.New("templates"). + Option("missingkey=error"). + Funcs(funcMap). + ParseFS(resources.ScaffoldTemplates, + path.Join(root, "*")) + if err != nil { + return fmt.Errorf("parsing templates: %w", err) + } + + if spec.DbCosmos != nil { + err = execute(t, "db-cosmos.bicep", spec.DbCosmos, filepath.Join(stagingApp, "db-cosmos.bicep")) + if err != nil { + return err + } + } + + if spec.DbPostgres != nil { + err = execute(t, "db-postgre.bicep", spec.DbPostgres, filepath.Join(stagingApp, "db-postgre.bicep")) + if err != nil { + return err + } + } + + for _, svc := range spec.Services { + err = execute(t, "host-containerapp.bicep", svc, filepath.Join(stagingApp, svc.Name+".bicep")) + if err != nil { + return err + } + } + + err = execute(t, "main.bicep", spec, filepath.Join(staging, "main.bicep")) + if err != nil { + return err + } + + err = execute(t, "main.parameters.json", spec, filepath.Join(staging, "main.parameters.json")) + if err != nil { + return err + } + + if err := os.MkdirAll(target, osutil.PermissionDirectory); err != nil { + return err + } + + if err := copy.Copy(staging, target); err != nil { + return fmt.Errorf("copying contents from temp staging directory: %w", err) + } + + err = execute(t, "init-summary.mdt", 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 execute(t *template.Template, name string, data any, writePath string) error { + buf := bytes.NewBufferString("") + err := t.ExecuteTemplate(buf, name, data) + if err != nil { + return fmt.Errorf("executing template: %w", err) + } + + err = os.WriteFile(writePath, buf.Bytes(), osutil.PermissionFile) + if err != nil { + return fmt.Errorf("writing service file: %w", err) + } + return nil +} + +func bicepName(name string) string { + sb := strings.Builder{} + separatorStart := -1 + for i := range name { + switch name[i] { + case '-', '_': + if separatorStart == -1 { + separatorStart = i + } + default: + if !isAsciiAlphaNumeric(name[i]) { + continue + } + char := name[i] + if separatorStart != -1 { + if separatorStart == 0 { + char = lowerCase(name[i]) + } else { + char = upperCase(name[i]) + } + separatorStart = -1 + } + + if i == 0 { + char = lowerCase(name[i]) + } + + sb.WriteByte(char) + } + } + + return sb.String() +} + +func isAsciiAlphaNumeric(c byte) bool { + return ('0' <= c && c <= '9') || ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z') +} + +func upperCase(r byte) byte { + if 'a' <= r && r <= 'z' { + r -= 'a' - 'A' + } + return r +} + +func lowerCase(r byte) byte { + if 'A' <= r && r <= 'Z' { + r += 'a' - 'A' + } + return r +} + +// Provide a reasonable limit to avoid name length issues +const containerAppNameMaxLen = 12 + +// containerAppName returns a name that is valid to be used as an infix for a container app resource. +func containerAppName(name string) string { + if len(name) > containerAppNameMaxLen { + name = name[:containerAppNameMaxLen] + } + + // trim to allowed characters: + // - only alphanumeric and '-' + // - no repeated '-' + // - no '-' as the first or last character + sb := strings.Builder{} + i := 0 + for i < len(name) { + if isAsciiAlphaNumeric(name[i]) { + sb.WriteByte(lowerCase(name[i])) + } else if name[i] == '-' || name[i] == '_' { + j := i + 1 + for j < len(name) && (name[j] == '-' || name[i] == '_') { // find consecutive matches + j++ + } + + if i != 0 && j != len(name) { // only write '-' if not first or last character + sb.WriteByte('-') + } + + i = j + continue + } + + i++ + } + + return sb.String() +} + +func copyFS(embedFs embed.FS, root string, target string) error { + return fs.WalkDir(embedFs, root, func(name string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + targetPath := filepath.Join(target, name[len(root):]) + + if d.IsDir() { + return os.MkdirAll(targetPath, osutil.PermissionDirectory) + } + + contents, err := fs.ReadFile(embedFs, name) + if err != nil { + return fmt.Errorf("reading file: %w", err) + } + return os.WriteFile(targetPath, contents, osutil.PermissionFile) + }) +} diff --git a/cli/azd/internal/repository/infra_gen_test.go b/cli/azd/internal/repository/infra_gen_test.go new file mode 100644 index 00000000000..834fbf859d3 --- /dev/null +++ b/cli/azd/internal/repository/infra_gen_test.go @@ -0,0 +1,47 @@ +package repository + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_containerAppName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"allowed characters", "MyApp_!#%^", "myapp"}, + {"dash at front or end", "-my-app-", "my-app"}, + {"multiple dashes", "my----app", "my-app"}, + {"at length", "123456789app", "123456789app"}, + {"over length", "123456789my-app", "123456789my"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := containerAppName(tt.in) + assert.Equal(t, tt.want, actual) + }) + } +} + +func Test_bicepName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"uppercase separators", "this-is-my-var-123", "thisIsMyVar123"}, + {"allowed characters", "myVar_!#%^", "myVar"}, + {"normalize casing", "MyVar", "myVar"}, + {"dash at front or end", "--my-var--", "myVar"}, + {"multiple dashes", "my----var", "myVar"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := bicepName(tt.in) + assert.Equal(t, tt.want, actual) + }) + } +} diff --git a/cli/azd/internal/tracing/events/events.go b/cli/azd/internal/tracing/events/events.go index 816aca18864..73883cc754d 100644 --- a/cli/azd/internal/tracing/events/events.go +++ b/cli/azd/internal/tracing/events/events.go @@ -18,6 +18,9 @@ const BicepInstallEvent = "tools.bicep.install" // GitHubCliInstallEvent is the name of the event which tracks the overall GitHub cli install operation. const GitHubCliInstallEvent = "tools.gh.install" +// PackCliInstallEvent is the name of the event which tracks the overall pack cli install operation. +const PackCliInstallEvent = "tools.pack.install" + // AccountSubscriptionsListEvent is the name of the event which tracks listing of account subscriptions . // See fields.AccountSubscriptionsListTenantsFound for additional event fields. const AccountSubscriptionsListEvent = "account.subscriptions.list" diff --git a/cli/azd/pkg/input/asker.go b/cli/azd/pkg/input/asker.go index 9cb8221713f..b4533971c3a 100644 --- a/cli/azd/pkg/input/asker.go +++ b/cli/azd/pkg/input/asker.go @@ -104,9 +104,12 @@ func askOnePrompt(p survey.Prompt, response interface{}, isTerminal bool, stdout opts = append(opts, withShowCursor) } - // use blue question mark for all questions opts = append(opts, survey.WithIcons(func(icons *survey.IconSet) { + // use blue question mark for all questions icons.Question.Format = "blue" + + icons.Help.Format = "black+h" + icons.Help.Text = "Hint:" })) return survey.AskOne(p, response, opts...) diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 310e99671cf..cd6617aa5f7 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -126,7 +126,11 @@ type ConsoleOptions struct { Help string Options []string DefaultValue any - IsPassword bool + + // Prompt-only options + + IsPassword bool + Suggest func(input string) (completions []string) } type ConsoleHandles struct { @@ -445,6 +449,7 @@ func promptFromOptions(options ConsoleOptions) survey.Prompt { Message: options.Message, Default: defaultValue, Help: options.Help, + Suggest: options.Suggest, } } diff --git a/cli/azd/pkg/output/colors.go b/cli/azd/pkg/output/colors.go index 3204986be54..085d02197dc 100644 --- a/cli/azd/pkg/output/colors.go +++ b/cli/azd/pkg/output/colors.go @@ -32,6 +32,10 @@ func WithGrayFormat(text string, a ...interface{}) string { return color.HiBlackString(text, a...) } +func WithBlueFormat(text string, a ...interface{}) string { + return color.BlueString(text, a...) +} + func WithBold(text string, a ...interface{}) string { format := color.New(color.Bold) return format.Sprintf(text, a...) diff --git a/cli/azd/pkg/output/ux/input_hint.go b/cli/azd/pkg/output/ux/input_hint.go new file mode 100644 index 00000000000..fc17df1dac9 --- /dev/null +++ b/cli/azd/pkg/output/ux/input_hint.go @@ -0,0 +1,33 @@ +package ux + +import ( + "strings" +) + +type InputHint struct { + Title string + + Text string + + Examples []string +} + +func (i InputHint) ToString() string { + sb := strings.Builder{} + sb.WriteString(i.Title) + sb.WriteString("\n") + sb.WriteString(i.Text) + + if len(i.Text) > 0 && i.Text[len(i.Text)-1:] != "\n" { + sb.WriteString("\n") + } + + if len(i.Examples) > 0 { + sb.WriteString("\n") + sb.WriteString("Examples:\n ") + sb.WriteString(strings.Join(i.Examples, "\n ")) + sb.WriteString("\n") + } + + return sb.String() +} diff --git a/cli/azd/pkg/project/framework_service_docker.go b/cli/azd/pkg/project/framework_service_docker.go index 74a04aa034a..8269a2cb88c 100644 --- a/cli/azd/pkg/project/framework_service_docker.go +++ b/cli/azd/pkg/project/framework_service_docker.go @@ -9,6 +9,8 @@ import ( "errors" "fmt" "log" + "os" + "path/filepath" "strings" "github.com/azure/azure-dev/cli/azd/pkg/async" @@ -18,14 +20,18 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" + "github.com/azure/azure-dev/cli/azd/pkg/tools/pack" + "github.com/benbjohnson/clock" ) +const BuilderImage = "paketobuildpacks/builder-jammy-base" + type DockerProjectOptions struct { - Path string `json:"path"` - Context string `json:"context"` - Platform string `json:"platform"` - Tag ExpandableString `json:"tag"` - BuildArgs []string `json:"buildArgs"` + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Context string `yaml:"context,omitempty" json:"context,omitempty"` + Platform string `yaml:"platform,omitempty" json:"platform,omitempty"` + Tag ExpandableString `yaml:"tag,omitempty" json:"tag,omitempty"` + BuildArgs []string `yaml:"buildArgs,omitempty" json:"buildArgs,omitempty"` } type dockerBuildResult struct { @@ -67,9 +73,11 @@ func (dpr *dockerPackageResult) MarshalJSON() ([]byte, error) { type dockerProject struct { env *environment.Environment docker docker.Docker + pack pack.PackCli framework FrameworkService containerHelper *ContainerHelper console input.Console + clock clock.Clock } // NewDockerProject creates a new instance of a Azd project that @@ -77,14 +85,18 @@ type dockerProject struct { func NewDockerProject( env *environment.Environment, docker docker.Docker, + pack pack.PackCli, containerHelper *ContainerHelper, console input.Console, + clock clock.Clock, ) CompositeFrameworkService { return &dockerProject{ env: env, docker: docker, + pack: pack, containerHelper: containerHelper, console: console, + clock: clock, } } @@ -154,8 +166,28 @@ func (p *dockerProject) Build( ) // Build the container - task.SetProgress(NewServiceProgress("Building Docker image")) + path := filepath.Join(serviceConfig.Path(), dockerOptions.Path) + _, err := os.Stat(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + task.SetError(fmt.Errorf("reading dockerfile: %w", err)) + return + } + + if errors.Is(err, os.ErrNotExist) { + task.SetProgress(NewServiceProgress("Building Docker image from source")) + buildResult, err := p.packBuild(ctx, serviceConfig, dockerOptions, imageName) + if err != nil { + task.SetError(err) + return + } + + buildResult.Restore = restoreOutput + task.SetResult(buildResult) + return + } + + task.SetProgress(NewServiceProgress("Building Docker image")) previewerWriter := p.console.ShowPreviewer(ctx, &input.ShowPreviewerOptions{ Prefix: " ", @@ -191,6 +223,54 @@ func (p *dockerProject) Build( ) } +func (p *dockerProject) packBuild( + ctx context.Context, + svc *ServiceConfig, + dockerOptions DockerProjectOptions, + imageName string) (*ServiceBuildResult, error) { + previewer := p.console.ShowPreviewer(ctx, + &input.ShowPreviewerOptions{ + Prefix: " ", + MaxLineCount: 8, + Title: "Docker (pack) Output", + }) + + builder := BuilderImage + args := []string{} + environ := []string{} + if svc.OutputPath != "" { + environ = append(environ, + "BP_NODE_RUN_SCRIPTS=build", + "BP_WEB_SERVER=nginx", + "BP_WEB_SERVER_ROOT="+svc.OutputPath, + "BP_WEB_SERVER_ENABLE_PUSH_STATE=true", + "NODE_ENV") + } + + if os.Getenv("AZD_BUILDER_IMAGE") != "" { + builder = os.Getenv("AZD_BUILDER_IMAGE") + } + err := p.pack.Build(ctx, filepath.Join(svc.Path(), dockerOptions.Context), builder, imageName, environ, args, previewer) + p.console.StopPreviewer(ctx) + if err != nil { + return nil, err + } + + imageId, err := p.docker.Inspect(ctx, imageName, "{{.Id}}") + if err != nil { + return nil, err + } + imageId = strings.TrimSpace(imageId) + + return &ServiceBuildResult{ + BuildOutputPath: imageId, + Details: &dockerBuildResult{ + ImageId: imageId, + ImageName: imageName, + }, + }, nil +} + func (p *dockerProject) Package( ctx context.Context, serviceConfig *ServiceConfig, @@ -214,7 +294,7 @@ func (p *dockerProject) Package( log.Printf("tagging image %s as %s", imageId, localTag) task.SetProgress(NewServiceProgress("Tagging Docker image")) if err := p.docker.Tag(ctx, serviceConfig.Path(), imageId, localTag); err != nil { - task.SetError(fmt.Errorf("tagging image: %w", err)) + task.SetError(err) return } diff --git a/cli/azd/pkg/project/framework_service_docker_test.go b/cli/azd/pkg/project/framework_service_docker_test.go index 98eaacecc30..f03ae92c252 100644 --- a/cli/azd/pkg/project/framework_service_docker_test.go +++ b/cli/azd/pkg/project/framework_service_docker_test.go @@ -6,6 +6,7 @@ package project import ( "context" "os" + "path/filepath" "strings" "testing" @@ -14,8 +15,10 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools/docker" "github.com/azure/azure-dev/cli/azd/pkg/tools/npm" + "github.com/azure/azure-dev/cli/azd/pkg/tools/pack" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockarmresources" "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" @@ -86,8 +89,16 @@ services: require.NoError(t, err) service := projectConfig.Services["web"] + temp := t.TempDir() + service.Project.Path = temp + service.RelativePath = "" + err = os.WriteFile(filepath.Join(temp, "Dockerfile"), []byte("FROM node:14"), 0600) + require.NoError(t, err) + npmCli := npm.NewNpmCli(mockContext.CommandRunner) docker := docker.NewDocker(mockContext.CommandRunner) + pack := pack.NewPackCliWithPath(mockContext.CommandRunner, "") + mockClock := clock.NewMock() done := make(chan bool) @@ -95,7 +106,7 @@ services: progressMessages := []string{} framework := NewDockerProject( - env, docker, NewContainerHelper(env, clock.NewMock(), nil, docker), mockinput.NewMockConsole()) + env, docker, pack, NewContainerHelper(env, mockClock, nil, docker), mockinput.NewMockConsole(), mockClock) framework.SetSource(internalFramework) buildTask := framework.Build(*mockContext.Context, service, nil) @@ -180,11 +191,18 @@ services: npmCli := npm.NewNpmCli(mockContext.CommandRunner) docker := docker.NewDocker(mockContext.CommandRunner) + pack := pack.NewPackCliWithPath(mockContext.CommandRunner, "") + mockClock := clock.NewMock() projectConfig, err := Parse(*mockContext.Context, testProj) require.NoError(t, err) service := projectConfig.Services["web"] + temp := t.TempDir() + service.Project.Path = temp + service.RelativePath = "" + err = os.WriteFile(filepath.Join(temp, "./Dockerfile.dev"), []byte("FROM node:14"), 0600) + require.NoError(t, err) done := make(chan bool) @@ -192,7 +210,7 @@ services: status := "" framework := NewDockerProject( - env, docker, NewContainerHelper(env, clock.NewMock(), nil, docker), mockinput.NewMockConsole()) + env, docker, pack, NewContainerHelper(env, mockClock, nil, docker), mockinput.NewMockConsole(), mockClock) framework.SetSource(internalFramework) buildTask := framework.Build(*mockContext.Context, service, nil) @@ -233,10 +251,18 @@ func Test_DockerProject_Build(t *testing.T) { env := environment.Ephemeral() dockerCli := docker.NewDocker(mockContext.CommandRunner) + pack := pack.NewPackCliWithPath(mockContext.CommandRunner, "") + mockClock := clock.NewMock() serviceConfig := createTestServiceConfig("./src/api", ContainerAppTarget, ServiceLanguageTypeScript) + temp := t.TempDir() + serviceConfig.Project.Path = temp + serviceConfig.RelativePath = "" + err := os.WriteFile(filepath.Join(temp, "./Dockerfile"), []byte("FROM node:14"), osutil.PermissionFile) + require.NoError(t, err) + dockerProject := NewDockerProject( - env, dockerCli, NewContainerHelper(env, clock.NewMock(), nil, dockerCli), mockinput.NewMockConsole()) + env, dockerCli, pack, NewContainerHelper(env, mockClock, nil, dockerCli), mockinput.NewMockConsole(), mockClock) buildTask := dockerProject.Build(*mockContext.Context, serviceConfig, nil) logProgress(buildTask) @@ -245,7 +271,7 @@ func Test_DockerProject_Build(t *testing.T) { require.NotNil(t, result) require.Equal(t, "IMAGE_ID", result.BuildOutputPath) require.Equal(t, "docker", runArgs.Cmd) - require.Equal(t, serviceConfig.RelativePath, runArgs.Cwd) + require.Equal(t, serviceConfig.Path(), runArgs.Cwd) require.Equal(t, []string{ "build", @@ -279,10 +305,12 @@ func Test_DockerProject_Package(t *testing.T) { env := environment.EphemeralWithValues("test", map[string]string{}) dockerCli := docker.NewDocker(mockContext.CommandRunner) + pack := pack.NewPackCliWithPath(mockContext.CommandRunner, "") + mockClock := clock.NewMock() serviceConfig := createTestServiceConfig("./src/api", ContainerAppTarget, ServiceLanguageTypeScript) dockerProject := NewDockerProject( - env, dockerCli, NewContainerHelper(env, clock.NewMock(), nil, dockerCli), mockinput.NewMockConsole()) + env, dockerCli, pack, NewContainerHelper(env, mockClock, nil, dockerCli), mockinput.NewMockConsole(), mockClock) packageTask := dockerProject.Package( *mockContext.Context, serviceConfig, diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index 143a6ab7ff1..87818666387 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -9,11 +9,11 @@ import ( type ServiceConfig struct { // Reference to the parent project configuration - Project *ProjectConfig `yaml:"omitempty"` + Project *ProjectConfig `yaml:"projectConfig,omitempty"` // The friendly name/key of the project from the azure.yaml file - Name string + Name string `yaml:"-,omitempty"` // The name used to override the default azure resource name - ResourceName ExpandableString `yaml:"resourceName"` + ResourceName ExpandableString `yaml:"resourceName,omitempty"` // The relative path to the project folder from the project root RelativePath string `yaml:"project"` // The azure hosting model to use, ex) appservice, function, containerapp @@ -21,15 +21,15 @@ type ServiceConfig struct { // The programming language of the project Language ServiceLanguageKind `yaml:"language"` // The output path for build artifacts - OutputPath string `yaml:"dist"` + OutputPath string `yaml:"dist,omitempty"` // The optional docker options - Docker DockerProjectOptions `yaml:"docker"` + Docker DockerProjectOptions `yaml:"docker,omitempty"` // The optional K8S / AKS options - K8s AksOptions `yaml:"k8s"` + K8s AksOptions `yaml:"k8s,omitempty"` // The optional Azure Spring Apps options - Spring SpringOptions `yaml:"spring"` + Spring SpringOptions `yaml:"spring,omitempty"` // The infrastructure provisioning configuration - Infra provisioning.Options `yaml:"infra"` + Infra provisioning.Options `yaml:"infra,omitempty"` // Hook configuration for service Hooks map[string]*ext.HookConfig `yaml:"hooks,omitempty"` diff --git a/cli/azd/pkg/tools/docker/docker.go b/cli/azd/pkg/tools/docker/docker.go index 82c5aece5c1..f3299cad451 100644 --- a/cli/azd/pkg/tools/docker/docker.go +++ b/cli/azd/pkg/tools/docker/docker.go @@ -33,6 +33,7 @@ type Docker interface { ) (string, error) Tag(ctx context.Context, cwd string, imageName string, tag string) error Push(ctx context.Context, cwd string, tag string) error + Inspect(ctx context.Context, imageName string, format string) (string, error) } func NewDocker(commandRunner exec.CommandRunner) Docker { @@ -147,6 +148,15 @@ func (d *docker) Push(ctx context.Context, cwd string, tag string) error { return nil } +func (d *docker) Inspect(ctx context.Context, imageName string, format string) (string, error) { + out, err := d.executeCommand(ctx, "", "image", "inspect", "--format", format, imageName) + if err != nil { + return "", fmt.Errorf("inspecting image: %w", err) + } + + return out.Stdout, nil +} + func (d *docker) versionInfo() tools.VersionInfo { return tools.VersionInfo{ MinimumVersion: semver.Version{ diff --git a/cli/azd/pkg/tools/pack/pack.go b/cli/azd/pkg/tools/pack/pack.go new file mode 100644 index 00000000000..cc01a41a631 --- /dev/null +++ b/cli/azd/pkg/tools/pack/pack.go @@ -0,0 +1,412 @@ +package pack + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/azure/azure-dev/cli/azd/internal/tracing" + "github.com/azure/azure-dev/cli/azd/internal/tracing/events" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "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/tools" + "github.com/blang/semver/v4" +) + +// PackVersion is the minimum version of pack that we require (and the one we fetch when we fetch pack on behalf of a +// user). +var PackVersion semver.Version = semver.MustParse("0.30.0-pre3") + +type PackCli interface { + Build( + ctx context.Context, + cwd string, + builder string, + imageName string, + environ []string, + args []string, + buildProgress io.Writer, + ) error +} + +// NewPackCli creates a new PackCli. azd manages its own copy of the pack CLI, stored in `$AZD_CONFIG_DIR/bin`. If +// pack is not present at this location, or if it is present but is older than the minimum supported version, it is +// downloaded. +func NewPackCli( + ctx context.Context, + console input.Console, + commandRunner exec.CommandRunner, +) (PackCli, error) { + return newPackCliImpl( + ctx, + console, + commandRunner, + http.DefaultClient, + extractCli) +} + +func NewPackCliWithPath( + commandRunner exec.CommandRunner, + cliPath string, +) PackCli { + return &packCli{ + path: cliPath, + runner: commandRunner, + } +} + +// packCliPath returns the path where we store our local copy of pack ($AZD_CONFIG_DIR/bin). +func packCliPath() (string, error) { + configDir, err := config.GetUserConfigDir() + if err != nil { + return "", err + } + + if runtime.GOOS == "windows" { + return filepath.Join(configDir, "bin", "pack.exe"), nil + } + + return filepath.Join(configDir, "bin", "pack"), nil +} + +func newPackCliImpl( + ctx context.Context, + console input.Console, + commandRunner exec.CommandRunner, + transporter policy.Transporter, + extract func(string, string) (string, error)) (PackCli, error) { + if override := os.Getenv("AZD_PACK_TOOL_PATH"); override != "" { + log.Printf("using external pack tool: %s", override) + + return &packCli{ + path: override, + runner: commandRunner, + }, nil + } + + cliPath, err := packCliPath() + if err != nil { + return nil, fmt.Errorf("finding pack: %w", err) + } + if _, err = os.Stat(cliPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("finding pack: %w", err) + } + if errors.Is(err, os.ErrNotExist) { + if err := os.MkdirAll(filepath.Dir(cliPath), osutil.PermissionDirectory); err != nil { + return nil, fmt.Errorf("downloading pack: %w", err) + } + + msg := "Acquiring pack cli" + console.ShowSpinner(ctx, msg, input.Step) + err := downloadPack(ctx, transporter, PackVersion, extract, cliPath) + console.StopSpinner(ctx, "", input.Step) + if err != nil { + return nil, fmt.Errorf("downloading pack: %w", err) + } + } + + cli := &packCli{ + path: cliPath, + runner: commandRunner, + } + + ver, err := cli.version(ctx) + if err != nil { + return nil, fmt.Errorf("checking pack version: %w", err) + } + + log.Printf("pack version: %s", ver) + + if ver.LT(PackVersion) { + log.Printf("installed pack version %s is older than %s; updating.", ver.String(), PackVersion.String()) + + msg := "Upgrading pack" + console.ShowSpinner(ctx, msg, input.Step) + err := downloadPack(ctx, transporter, PackVersion, extract, cliPath) + console.StopSpinner(ctx, "", input.Step) + if err != nil { + return nil, fmt.Errorf("upgrading pack: %w", err) + } + } + + log.Printf("using local pack: %s", cliPath) + + return cli, nil +} + +type packCli struct { + path string + runner exec.CommandRunner +} + +func (cli *packCli) version(ctx context.Context) (semver.Version, error) { + packRes, err := cli.runner.Run(ctx, exec.NewRunArgs(cli.path, "--version")) + if err != nil { + return semver.Version{}, err + } + + version, err := tools.ExtractVersion(packRes.Stdout) + if err != nil { + return semver.Version{}, err + } + + return version, nil +} + +func (cli *packCli) enableExperimental(ctx context.Context) error { + runArgs := exec.NewRunArgs(cli.path, "config", "experimental", "true") + runArgs.Interactive = false + _, err := cli.runner.Run(ctx, runArgs) + if err != nil { + return err + } + + return nil +} + +func (cli *packCli) Build( + ctx context.Context, + cwd string, + builder string, + imageName string, + environ []string, + args []string, + buildProgress io.Writer, +) error { + err := cli.enableExperimental(ctx) + if err != nil { + return err + } + + envArgs := make([]string, 0, 2*len(environ)) + for _, e := range environ { + envArgs = append(envArgs, "--env", e) + } + + runArgs := exec.NewRunArgs( + cli.path, "build", imageName, "--builder", builder, "--path", cwd) + runArgs.Args = append(runArgs.Args, envArgs...) + if len(args) > 0 { + runArgs.Args = append(runArgs.Args, args...) + } + if buildProgress != nil { + runArgs = runArgs.WithStdOut(buildProgress).WithStdErr(buildProgress) + } + + _, err = cli.runner.Run(ctx, runArgs) + if err != nil { + return err + } + + return nil +} + +func packName() string { + if runtime.GOOS == "windows" { + return "pack.exe" + } else { + return "pack" + } +} + +func extractFromZip( + zipped string, + out string) (string, error) { + zipReader, err := zip.OpenReader(zipped) + if err != nil { + return "", err + } + + log.Printf("extract from %s", zipped) + defer zipReader.Close() + + var extractedAt string + for _, file := range zipReader.File { + fileName := file.FileInfo().Name() + if !file.FileInfo().IsDir() && fileName == packName() { + log.Printf("found cli at: %s", file.Name) + fileReader, err := file.Open() + if err != nil { + return extractedAt, err + } + filePath := filepath.Join(out, fileName) + cliFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return extractedAt, err + } + defer cliFile.Close() + /* #nosec G110 - decompression bomb false positive */ + _, err = io.Copy(cliFile, fileReader) + if err != nil { + return extractedAt, err + } + extractedAt = filePath + break + } + } + if extractedAt != "" { + log.Printf("extracted to: %s", extractedAt) + return extractedAt, nil + } + return extractedAt, fmt.Errorf("github cli binary was not found within the zip file") +} +func extractFromTar( + zipped string, + out string) (string, error) { + gzFile, err := os.Open(zipped) + if err != nil { + return "", err + } + defer gzFile.Close() + + gzReader, err := gzip.NewReader(gzFile) + if err != nil { + return "", err + } + defer gzReader.Close() + + var extractedAt string + // tarReader doesn't need to be closed as it is closed by the gz reader + tarReader := tar.NewReader(gzReader) + for { + fileHeader, err := tarReader.Next() + if errors.Is(err, io.EOF) { + return extractedAt, fmt.Errorf("did not find gh cli within tar file") + } + if fileHeader == nil { + continue + } + if err != nil { + return extractedAt, err + } + // Tha name contains the path, remove it + fileNameParts := strings.Split(fileHeader.Name, "/") + fileName := fileNameParts[len(fileNameParts)-1] + // cspell: disable-next-line `Typeflag` is comming fron *tar.Header + if fileHeader.Typeflag == tar.TypeReg && fileName == "pack" { + filePath := filepath.Join(out, fileName) + ghCliFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(fileHeader.Mode)) + if err != nil { + return extractedAt, err + } + defer ghCliFile.Close() + /* #nosec G110 - decompression bomb false positive */ + _, err = io.Copy(ghCliFile, tarReader) + if err != nil { + return extractedAt, err + } + extractedAt = filePath + break + } + } + if extractedAt != "" { + return extractedAt, nil + } + return extractedAt, fmt.Errorf("unable to find pack cli within archive") +} + +// extractCli gets the Github cli from either a zip or a tar.gz +func extractCli(src, dst string) (string, error) { + if strings.HasSuffix(src, ".zip") { + return extractFromZip(src, dst) + } else if strings.HasSuffix(src, ".tgz") { + return extractFromTar(src, dst) + } + return "", fmt.Errorf("unknown format while trying to extract") +} + +// downloadPack downloads a given version of pack cli from the release site. +func downloadPack( + ctx context.Context, + transporter policy.Transporter, + version semver.Version, + extractFile func(src, dst string) (string, error), + path string) error { + systemArch := runtime.GOARCH + archString := "" // amd64 is the implicit default + if systemArch != "amd64" { + archString = fmt.Sprintf("-%s", systemArch) + } + + var releaseName string + switch runtime.GOOS { + case "windows": + releaseName = fmt.Sprintf("pack-v%s-windows%s.zip", version, archString) + case "darwin": + releaseName = fmt.Sprintf("pack-v%s-macos%s.tgz", version, archString) + case "linux": + releaseName = fmt.Sprintf("pack-v%s-linux%s.tgz", version, archString) + default: + return fmt.Errorf("unsupported platform") + } + + // example: https://github.com/buildpacks/pack/releases/download/v0.29.0/pack-v0.29.0-windows.zip + ghReleaseUrl := fmt.Sprintf("https://github.com/buildpacks/pack/releases/download/v%s/%s", version, releaseName) + log.Printf("downloading pack cli release %s -> %s", ghReleaseUrl, releaseName) + + spanCtx, span := tracing.Start(ctx, events.PackCliInstallEvent) + defer span.End() + + req, err := http.NewRequestWithContext(spanCtx, "GET", ghReleaseUrl, nil) + if err != nil { + return err + } + + resp, err := transporter.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("http error %d", resp.StatusCode) + } + + tmpPath := filepath.Dir(path) + compressedRelease, err := os.CreateTemp(tmpPath, releaseName) + if err != nil { + return err + } + defer func() { + _ = compressedRelease.Close() + _ = os.Remove(compressedRelease.Name()) + }() + + if _, err := io.Copy(compressedRelease, resp.Body); err != nil { + return err + } + if err := compressedRelease.Close(); err != nil { + return err + } + + // change file name from temporal name to the final name, as the download has completed + compressedFileName := filepath.Join(tmpPath, releaseName) + if err := osutil.Rename(ctx, compressedRelease.Name(), compressedFileName); err != nil { + return err + } + defer func() { + log.Printf("delete %s", compressedFileName) + _ = os.Remove(compressedFileName) + }() + + // unzip downloaded file + log.Printf("extracting file %s", compressedFileName) + _, err = extractFile(compressedFileName, tmpPath) + if err != nil { + return err + } + + return nil +} diff --git a/cli/azd/resources/resources.go b/cli/azd/resources/resources.go index aa406009c7c..cc9551fbcd8 100644 --- a/cli/azd/resources/resources.go +++ b/cli/azd/resources/resources.go @@ -1,6 +1,7 @@ package resources import ( + "embed" _ "embed" ) @@ -15,3 +16,9 @@ var MinimalBicep []byte //go:embed minimal/main.parameters.json var MinimalBicepParameters []byte + +//go:embed scaffold/base/* +var ScaffoldBase embed.FS + +//go:embed scaffold/templates/* +var ScaffoldTemplates embed.FS diff --git a/cli/azd/resources/scaffold/base/abbreviations.json b/cli/azd/resources/scaffold/base/abbreviations.json new file mode 100644 index 00000000000..a4fc9dfed44 --- /dev/null +++ b/cli/azd/resources/scaffold/base/abbreviations.json @@ -0,0 +1,135 @@ +{ + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} \ No newline at end of file diff --git a/cli/azd/resources/scaffold/base/modules/fetch-container-image.bicep b/cli/azd/resources/scaffold/base/modules/fetch-container-image.bicep new file mode 100644 index 00000000000..17b00776d96 --- /dev/null +++ b/cli/azd/resources/scaffold/base/modules/fetch-container-image.bicep @@ -0,0 +1,8 @@ +param exists bool +param name string + +resource existingApp 'Microsoft.App/containerApps@2023-04-01-preview' existing = if (exists) { + name: name +} + +output containers array = exists ? existingApp.properties.template.containers : [] diff --git a/cli/azd/resources/scaffold/base/shared/apps-env.bicep b/cli/azd/resources/scaffold/base/shared/apps-env.bicep new file mode 100644 index 00000000000..030b8233139 --- /dev/null +++ b/cli/azd/resources/scaffold/base/shared/apps-env.bicep @@ -0,0 +1,33 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param logAnalyticsWorkspaceName string +param applicationInsightsName string = '' + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2022-10-01' = { + name: name + location: location + tags: tags + properties: { + appLogsConfiguration: { + destination: 'log-analytics' + logAnalyticsConfiguration: { + customerId: logAnalyticsWorkspace.properties.customerId + sharedKey: logAnalyticsWorkspace.listKeys().primarySharedKey + } + } + daprAIConnectionString: applicationInsights.properties.ConnectionString + } +} + +resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' existing = { + name: logAnalyticsWorkspaceName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} + +output name string = containerAppsEnvironment.name +output domain string = containerAppsEnvironment.properties.defaultDomain diff --git a/cli/azd/resources/scaffold/base/shared/dashboard-web.bicep b/cli/azd/resources/scaffold/base/shared/dashboard-web.bicep new file mode 100644 index 00000000000..eccce0dbf6b --- /dev/null +++ b/cli/azd/resources/scaffold/base/shared/dashboard-web.bicep @@ -0,0 +1,1231 @@ +param name string +param applicationInsightsName string +param location string = resourceGroup().location +param tags object = {} + +// 2020-09-01-preview because that is the latest valid version +resource applicationInsightsDashboard 'Microsoft.Portal/dashboards@2020-09-01-preview' = { + name: name + location: location + tags: tags + properties: { + lenses: [ + { + order: 0 + parts: [ + { + position: { + x: 0 + y: 0 + colSpan: 2 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'id' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AspNetOverviewPinnedPart' + asset: { + idInputName: 'id' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'overview' + } + } + { + position: { + x: 2 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsightsName + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/ProactiveDetectionAsyncPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'ProactiveDetection' + } + } + { + position: { + x: 3 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsightsName + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/QuickPulseButtonSmallPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsightsName + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:20:33.345Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AvailabilityNavButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 5 + y: 0 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsightsName + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-08T18:47:35.237Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'ConfigurationId' + value: '78ce933e-e864-4b05-a27b-71fd55a6afad' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/AppMapButtonPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 0 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Usage' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 3 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsightsName + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + endTime: null + createdTime: '2018-05-04T01:22:35.782Z' + isInitialTime: true + grain: 1 + useDashboardTimeRange: false + } + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/UsageUsersOverviewPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + } + } + { + position: { + x: 4 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Reliability' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 7 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:42:40.072Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '8a02f7bf-ac0f-40e1-afe9-f0e72cfee77f' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladeFailuresPinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'failures' + } + } + { + position: { + x: 8 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Responsiveness\r\n' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 11 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ResourceId' + value: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + { + name: 'DataModel' + value: { + version: '1.0.0' + timeContext: { + durationMs: 86400000 + createdTime: '2018-05-04T23:43:37.804Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + isOptional: true + } + { + name: 'ConfigurationId' + value: '2a8ede4f-2bee-4b9c-aed9-2db0e8a01865' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/CuratedBladePerformancePinnedPart' + isAdapter: true + asset: { + idInputName: 'ResourceId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'performance' + } + } + { + position: { + x: 12 + y: 1 + colSpan: 3 + rowSpan: 1 + } + metadata: { + inputs: [] + type: 'Extension/HubsExtension/PartType/MarkdownPart' + settings: { + content: { + settings: { + content: '# Browser' + title: '' + subtitle: '' + } + } + } + } + } + { + position: { + x: 15 + y: 1 + colSpan: 1 + rowSpan: 1 + } + metadata: { + inputs: [ + { + name: 'ComponentId' + value: { + Name: applicationInsightsName + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'MetricsExplorerJsonDefinitionId' + value: 'BrowserPerformanceTimelineMetrics' + } + { + name: 'TimeContext' + value: { + durationMs: 86400000 + createdTime: '2018-05-08T12:16:27.534Z' + isInitialTime: false + grain: 1 + useDashboardTimeRange: false + } + } + { + name: 'CurrentFilter' + value: { + eventTypes: [ + 4 + 1 + 3 + 5 + 2 + 6 + 13 + ] + typeFacets: {} + isPermissive: false + } + } + { + name: 'id' + value: { + Name: applicationInsightsName + SubscriptionId: subscription().subscriptionId + ResourceGroup: resourceGroup().name + } + } + { + name: 'Version' + value: '1.0' + } + ] + #disable-next-line BCP036 + type: 'Extension/AppInsightsExtension/PartType/MetricsExplorerBladePinnedPart' + asset: { + idInputName: 'ComponentId' + type: 'ApplicationInsights' + } + defaultMenuItemId: 'browser' + } + } + { + position: { + x: 0 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'sessions/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Sessions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'users/count' + aggregationType: 5 + namespace: 'microsoft.insights/components/kusto' + metricVisualization: { + displayName: 'Users' + color: '#7E58FF' + } + } + ] + title: 'Unique sessions and users' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + menuid: 'segmentationUsers' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'requests/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Failed requests' + color: '#EC008C' + } + } + ] + title: 'Failed requests' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + menuid: 'failures' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'requests/duration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server response time' + color: '#00BCF2' + } + } + ] + title: 'Server response time' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + menuid: 'performance' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 2 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'browserTimings/networkDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Page load network connect time' + color: '#7E58FF' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'browserTimings/processingDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Client processing time' + color: '#44F1C8' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'browserTimings/sendDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Send request time' + color: '#EB9371' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'browserTimings/receiveDuration' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Receiving response time' + color: '#0672F1' + } + } + ] + title: 'Average page load time breakdown' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'availabilityResults/availabilityPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability' + color: '#47BDF5' + } + } + ] + title: 'Average availability' + visualization: { + chartType: 3 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + openBladeOnClick: { + openBlade: true + destinationBlade: { + extensionName: 'HubsExtension' + bladeName: 'ResourceMenuBlade' + parameters: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + menuid: 'availability' + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'exceptions/server' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Server exceptions' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'dependencies/failed' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Dependency failures' + color: '#7E58FF' + } + } + ] + title: 'Server exceptions and Dependency failures' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'performanceCounters/processorCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Processor time' + color: '#47BDF5' + } + } + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'performanceCounters/processCpuPercentage' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process CPU' + color: '#7E58FF' + } + } + ] + title: 'Average processor and process CPU utilization' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 12 + y: 5 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'exceptions/browser' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Browser exceptions' + color: '#47BDF5' + } + } + ] + title: 'Browser exceptions' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 0 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'availabilityResults/count' + aggregationType: 7 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Availability test results count' + color: '#47BDF5' + } + } + ] + title: 'Availability test results count' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 4 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'performanceCounters/processIOBytesPerSecond' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Process IO rate' + color: '#47BDF5' + } + } + ] + title: 'Average process I/O rate' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + { + position: { + x: 8 + y: 8 + colSpan: 4 + rowSpan: 3 + } + metadata: { + inputs: [ + { + name: 'options' + value: { + chart: { + metrics: [ + { + resourceMetadata: { + id: '/subscriptions/${subscription().subscriptionId}/resourceGroups/${resourceGroup().name}/providers/Microsoft.Insights/components/${applicationInsightsName}' + } + name: 'performanceCounters/memoryAvailableBytes' + aggregationType: 4 + namespace: 'microsoft.insights/components' + metricVisualization: { + displayName: 'Available memory' + color: '#47BDF5' + } + } + ] + title: 'Average available memory' + visualization: { + chartType: 2 + legendVisualization: { + isVisible: true + position: 2 + hideSubtitle: false + } + axisVisualization: { + x: { + isVisible: true + axisType: 2 + } + y: { + isVisible: true + axisType: 1 + } + } + } + } + } + } + { + name: 'sharedTimeRange' + isOptional: true + } + ] + #disable-next-line BCP036 + type: 'Extension/HubsExtension/PartType/MonitorChartPart' + settings: {} + } + } + ] + } + ] + } +} diff --git a/cli/azd/resources/scaffold/base/shared/keyvault.bicep b/cli/azd/resources/scaffold/base/shared/keyvault.bicep new file mode 100644 index 00000000000..f84f7508ddc --- /dev/null +++ b/cli/azd/resources/scaffold/base/shared/keyvault.bicep @@ -0,0 +1,31 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +@description('Service principal that should be granted read access to the KeyVault. If unset, no service principal is granted access by default') +param principalId string = '' + +var defaultAccessPolicies = !empty(principalId) ? [ + { + objectId: principalId + permissions: { secrets: [ 'get', 'list' ] } + tenantId: subscription().tenantId + } +] : [] + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { + name: name + location: location + tags: tags + properties: { + tenantId: subscription().tenantId + sku: { family: 'A', name: 'standard' } + enabledForTemplateDeployment: true + accessPolicies: union(defaultAccessPolicies, [ + // define access policies here + ]) + } +} + +output endpoint string = keyVault.properties.vaultUri +output name string = keyVault.name diff --git a/cli/azd/resources/scaffold/base/shared/monitoring.bicep b/cli/azd/resources/scaffold/base/shared/monitoring.bicep new file mode 100644 index 00000000000..4ae9796cc3b --- /dev/null +++ b/cli/azd/resources/scaffold/base/shared/monitoring.bicep @@ -0,0 +1,34 @@ +param logAnalyticsName string +param applicationInsightsName string +param location string = resourceGroup().location +param tags object = {} + +resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2021-12-01-preview' = { + name: logAnalyticsName + location: location + tags: tags + properties: any({ + retentionInDays: 30 + features: { + searchVersion: 1 + } + sku: { + name: 'PerGB2018' + } + }) +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = { + name: applicationInsightsName + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: logAnalytics.id + } +} + +output applicationInsightsName string = applicationInsights.name +output logAnalyticsWorkspaceId string = logAnalytics.id +output logAnalyticsWorkspaceName string = logAnalytics.name diff --git a/cli/azd/resources/scaffold/base/shared/registry.bicep b/cli/azd/resources/scaffold/base/shared/registry.bicep new file mode 100644 index 00000000000..613337e9d47 --- /dev/null +++ b/cli/azd/resources/scaffold/base/shared/registry.bicep @@ -0,0 +1,36 @@ +param name string +param location string = resourceGroup().location +param tags object = {} + +param adminUserEnabled bool = true +param anonymousPullEnabled bool = false +param dataEndpointEnabled bool = false +param encryption object = { + status: 'disabled' +} +param networkRuleBypassOptions string = 'AzureServices' +param publicNetworkAccess string = 'Enabled' +param sku object = { + name: 'Standard' +} +param zoneRedundancy string = 'Disabled' + +// 2022-02-01-preview needed for anonymousPullEnabled +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' = { + name: name + location: location + tags: tags + sku: sku + properties: { + adminUserEnabled: adminUserEnabled + anonymousPullEnabled: anonymousPullEnabled + dataEndpointEnabled: dataEndpointEnabled + encryption: encryption + networkRuleBypassOptions: networkRuleBypassOptions + publicNetworkAccess: publicNetworkAccess + zoneRedundancy: zoneRedundancy + } +} + +output loginServer string = containerRegistry.properties.loginServer +output name string = containerRegistry.name diff --git a/cli/azd/resources/scaffold/templates/db-cosmos.bicept b/cli/azd/resources/scaffold/templates/db-cosmos.bicept new file mode 100644 index 00000000000..3489110ca1b --- /dev/null +++ b/cli/azd/resources/scaffold/templates/db-cosmos.bicept @@ -0,0 +1,75 @@ +{{define "db-cosmos.bicep"}} +param accountName string +param location string = resourceGroup().location +param tags object = {} + +param keyVaultName string + +resource account 'Microsoft.DocumentDB/databaseAccounts@2022-08-15' = { + name: accountName + kind: 'MongoDB' + location: location + tags: tags + properties: { + consistencyPolicy: { defaultConsistencyLevel: 'Session' } + locations: [ + { + locationName: location + failoverPriority: 0 + isZoneRedundant: false + } + ] + databaseAccountOfferType: 'Standard' + enableAutomaticFailover: false + enableMultipleWriteLocations: false + apiProperties: { serverVersion: '4.0' } + capabilities: [ { name: 'EnableServerless' } ] + } +} + +{{- if .DatabaseName}} +resource database 'Microsoft.DocumentDB/databaseAccounts/mongodbDatabases@2022-05-15' = { + parent: account + name: '{{.DatabaseName}}' + properties: { + resource: { + id: '{{.DatabaseName}}' + } + } +} +{{- end}} + + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource cosmosConnectionString 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: 'cosmosConnectionString' + properties: { + value: account.listConnectionStrings().connectionStrings[0].connectionString + } +} + +// By default, no tables/collections are created. +// If you like to create collections as part of infrastructure provisioning, uncomment below. +// var collection1Name = 'Table1' +// resource collection1 'Microsoft.DocumentDb/databaseAccounts/mongodbDatabases/collections@2022-05-15' = { +// parent: database +// name: collection1Name +// properties: { +// resource: { +// id: collection1Name +// shardKey: { _id: 'Hash' } // use hash(_id) as the partition key +// indexes: [ +// // Default index on id +// { key: { keys: ['_id' ]} } +// ] +// } +// } +// } + +output accountName string = account.name +output connectionStringKey string = 'cosmosConnectionString' +{{end}} diff --git a/cli/azd/resources/scaffold/templates/db-postgre.bicept b/cli/azd/resources/scaffold/templates/db-postgre.bicept new file mode 100644 index 00000000000..e1ab6d55fc6 --- /dev/null +++ b/cli/azd/resources/scaffold/templates/db-postgre.bicept @@ -0,0 +1,132 @@ +{{define "db-postgre.bicep"}} +param serverName string +param location string = resourceGroup().location +param tags object = {} + +param keyVaultName string +param databaseUser string = 'appuser' +param databaseName string = '{{.DatabaseName}}' +param databaseConnectionKey string = 'databasePassword' +param allowAllIPsFirewall bool = false + +@secure() +param sqlAdminPassword string +@secure() +param appUserPassword string + +resource postgreServer'Microsoft.DBforPostgreSQL/flexibleServers@2022-01-20-preview' = { + location: location + tags: tags + name: serverName + sku: { + name: 'Standard_B1ms' + tier: 'Burstable' + } + properties: { + version: '13' + administratorLogin: 'django' + administratorLoginPassword: sqlAdminPassword + storage: { + storageSizeGB: 128 + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + highAvailability: { + mode: 'Disabled' + } + maintenanceWindow: { + customWindow: 'Disabled' + dayOfWeek: 0 + startHour: 0 + startMinute: 0 + } + } + + resource firewall_all 'firewallRules' = if (allowAllIPsFirewall) { + name: 'allow-all-IPs' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '255.255.255.255' + } + } +} + +resource database 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2022-01-20-preview' = { + parent: postgreServer + name: databaseName + properties: { + // Azure defaults to UTF-8 encoding, override if required. + // charset: 'string' + // collation: 'string' + } +} + +resource seedDb 'Microsoft.Resources/deploymentScripts@2020-10-01' = { + name: 'seedDb' + location: location + kind: 'AzureCLI' + properties: { + azCliVersion: '2.9.0' + retentionInterval: 'PT1H' + timeout: 'PT5M' + environmentVariables: [ + { + name: 'DBSERVER' + value: postgreServer.properties.fullyQualifiedDomainName + } + { + name: 'SQLADMIN' + value: postgreServer.properties.administratorLogin + } + { + name: 'PGPASSWORD' + value: sqlAdminPassword + } + { + name: 'DBNAME' + value: database.name + } + { + name: 'APPUSERNAME' + value: databaseUser + } + { + name: 'APPUSERPASSWORD' + value: appUserPassword + } + ] + cleanupPreference: 'OnSuccess' + scriptContent: ''' +apk add --no-cache postgresql-client + +cat < ./initDb.sql +CREATE USER ${APPUSERNAME} PASSWORD '${APPUSERPASSWORD}'; + +GRANT CONNECT ON DATABASE ${DBNAME} TO ${APPUSERNAME}; +GRANT ALL PRIVILEGES ON DATABASE ${DBNAME} TO ${APPUSERNAME}; +SCRIPT_END + +./psql --host=${DBSERVER} --dbname=${DBNAME} --username=${SQLADMIN} --file=./initDb.sql +''' + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVaultName +} + +resource dbPassword 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + parent: keyVault + name: databaseConnectionKey + properties: { + value: appUserPassword + } +} + +output databaseHost string = postgreServer.properties.fullyQualifiedDomainName +output databaseName string = databaseName +output databaseUser string = databaseUser +output databaseConnectionKey string = databaseConnectionKey +{{end}} diff --git a/cli/azd/resources/scaffold/templates/host-containerapp.bicept b/cli/azd/resources/scaffold/templates/host-containerapp.bicept new file mode 100644 index 00000000000..e051645fec3 --- /dev/null +++ b/cli/azd/resources/scaffold/templates/host-containerapp.bicept @@ -0,0 +1,183 @@ +{{define "host-containerapp.bicep"}} +param name string +param location string = resourceGroup().location +param tags object = {} + +param identityName string +param containerRegistryName string +param containerAppsEnvironmentName string +param applicationInsightsName string +{{- if .DbCosmos}} +@secure() +param cosmosDbConnectionString string +{{- end}} +{{- if .DbPostgres}} +param databaseHost string +param databaseUser string +param databaseName string +@secure() +param databasePassword string +{{- end}} +{{- if .Frontend}} +param apiUrls array +{{- end}} +{{- if (and .Backend .Backend.Frontends)}} +param allowedOrigins array +{{- end}} +param exists bool + +resource identity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { + name: identityName + location: location +} + +resource containerRegistry 'Microsoft.ContainerRegistry/registries@2022-02-01-preview' existing = { + name: containerRegistryName +} + +resource containerAppsEnvironment 'Microsoft.App/managedEnvironments@2023-04-01-preview' existing = { + name: containerAppsEnvironmentName +} + +resource applicationInsights 'Microsoft.Insights/components@2020-02-02' existing = { + name: applicationInsightsName +} + +resource acrPullRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: containerRegistry + name: guid(subscription().id, resourceGroup().id, identity.id, 'acrPullRole') + properties: { + roleDefinitionId: subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', '7f951dda-4ed3-4680-a7ca-43fe172d538d') + principalType: 'ServicePrincipal' + principalId: identity.properties.principalId + } +} + +module fetchLatestImage '../modules/fetch-container-image.bicep' = { + name: '${name}-fetch-image' + params: { + exists: exists + name: name + } +} + +resource app 'Microsoft.App/containerApps@2023-04-01-preview' = { + name: name + location: location + tags: union(tags, {'azd-service-name': '{{.Name}}' }) + dependsOn: [ acrPullRole ] + identity: { + type: 'UserAssigned' + userAssignedIdentities: { '${identity.id}': {} } + } + properties: { + managedEnvironmentId: containerAppsEnvironment.id + configuration: { + {{- if ne .Port 0}} + ingress: { + external: true + targetPort: {{.Port}} + transport: 'auto' + {{- if (and .Backend .Backend.Frontends)}} + corsPolicy: { + allowedOrigins: union(allowedOrigins, [ + // define additional allowed origins here + ]) + } + {{- end}} + } + {{- end}} + registries: [ + { + server: '${containerRegistryName}.azurecr.io' + identity: identity.id + } + ] + secrets: [ + {{- if .DbCosmos}} + { + name: 'azure-cosmos-connection-string' + value: cosmosDbConnectionString + } + {{- end}} + {{- if .DbPostgres}} + { + name: 'db-pass' + value: databasePassword + } + {{- end}} + ] + } + template: { + containers: [ + { + image: fetchLatestImage.outputs.?containers[?0].?image ?? 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' + name: 'main' + env: [ + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsights.properties.ConnectionString + } + {{- if .DbCosmos}} + { + name: 'AZURE_COSMOS_MONGODB_CONNECTION_STRING' + secretRef: 'azure-cosmos-connection-string' + } + {{- end}} + {{- if .DbPostgres}} + { + name: 'DB_HOST' + value: databaseHost + } + { + name: 'DB_USER' + value: databaseUser + } + { + name: 'DB_NAME' + value: databaseName + } + { + name: 'DB_PASS' + secretRef: 'db-pass' + } + { + name: 'DB_PORT' + value: '5432' + } + {{- end}} + {{- if .Frontend}} + {{- range $i, $e := .Frontend.Backends}} + { + name: '{{upper .Name}}_BASE_URL' + value: apiUrls[{{$i}}] + } + {{- end}} + {{- end}} + {{- if ne .Port 0}} + { + name: 'PORT' + value: '{{ .Port }}' + } + {{- end}} + ] + resources: { + cpu: json('1.0') + memory: '2.0Gi' + } + } + ] + scale: { + minReplicas: 1 + maxReplicas: 10 + } + } + } +} + +output defaultDomain string = containerAppsEnvironment.properties.defaultDomain +output name string = app.name +output uri string = 'https://${app.properties.configuration.ingress.fqdn}' +output id string = app.id +{{end}} diff --git a/cli/azd/resources/scaffold/templates/host-staticwebapp.bicept b/cli/azd/resources/scaffold/templates/host-staticwebapp.bicept new file mode 100644 index 00000000000..bddff933bfe --- /dev/null +++ b/cli/azd/resources/scaffold/templates/host-staticwebapp.bicept @@ -0,0 +1,33 @@ +{{define "host-staticwebapp.bicep"}} +param name string +param location string = resourceGroup().location +param tags object = {} + +param backendResourceId string +param backendName string + +resource web 'Microsoft.Web/staticSites@2022-03-01' = { + name: name + location: location + tags: tags + sku: { + name: 'Standard' + tier: 'Standard' + } + properties: { + provider: 'Custom' + } +} + +resource backend 'Microsoft.Web/staticSites/linkedBackends@2022-03-01' = { + name: backendName + parent: web + properties: { + backendResourceId: backendResourceId + region: location + } +} + +output name string = web.name +output uri string = 'https://${web.properties.defaultHostname}' +{{end}} diff --git a/cli/azd/resources/scaffold/templates/init-summary.mdt b/cli/azd/resources/scaffold/templates/init-summary.mdt new file mode 100644 index 00000000000..7ce8e515c7b --- /dev/null +++ b/cli/azd/resources/scaffold/templates/init-summary.mdt @@ -0,0 +1,70 @@ +{{define "init-summary.mdt"}} +# Next Steps + +## 1. Define environment variables for running services + +Modify or add environment variables to configure the running application. Environment variables can be configured by modifying the `env` node in the following files: +{{range .Services}} +- [app/{{.Name}}.bicep](./infra/app/{{.Name}}.bicep) +{{- end}} + +To define a secret as an environment variable, the secret can first be stored in KeyVault. + +## 2. Provision infrastructure and deploy application code + +Run `azd up` to get your app running in Azure. `azd up` will perform both infrastructure provisioning (`azd provision`) and code deployment (`azd deploy`) in a single command. +Visit the service endpoints listed to see your application up-and-running! + +To troubleshoot any issues, see [troubleshooting](#troubleshooting). + +## Details + +### What was added + +To describe the infrastructure and application, `azure.yaml` along with Infrastructure as Code files using Bicep were added with the following directory structure: + +```yaml +- azure.yaml # azd project configuration +- infra/ # Infrastructure as Code (bicep) files + - main.bicep # main deployment module + - app/ # Application resource modules + - shared/ # Shared resource modules + - lib/ # Library modules +``` + +Each bicep file declares resources to be provisioned. The resources are provisioned when running `azd up` or `azd provision`. +{{range .Services}} +- [app/{{.Name}}.bicep](./infra/app/{{.Name}}.bicep) - Azure Container Apps resources to host the '{{.Name}}' service. +{{- end}} +{{- if .DbPostgres}} +- [app/db-postgres.bicep](./infra/app/db-postgres.bicep) - Azure Postgres Flexible Server to host the '{{.DbPostgres.DatabaseName}}' database. +{{- end}} +{{- if .DbCosmosMongo}} +- [app/db-cosmos-mongo.bicep](./infra/app/db-cosmos-mongo.bicep) - Azure Cosmos DB (MongoDB) to host the '{{.DbCosmosMongo.DatabaseName}}' database. +{{- end}} +- [shared/keyvault.bicep](./infra/shared/keyvault.bicep) - Azure KeyVault to store secrets. +- [shared/monitoring.bicep](./infra/shared/monitoring.bicep) - Azure Log Analytics workspace and Application Insights to log and store instrumentation logs. +- [shared/registry.bicep](./infra/shared/registry.bicep) - Azure Container Registry to store docker images. + +More information about [Bicep](https://aka.ms/bicep) language. + +### Billing + +Visit the *Cost Management + Billing* page in Azure Portal to track current spend. For more information about how you're billed, and how you can monitor the costs incurred in your Azure subscriptions, visit [billing overview](https://learn.microsoft.com/en-us/azure/developer/intro/azure-developer-billing). + +### Troubleshooting + +Q: I visited the service endpoint listed, and I'm seeing a blank or error page. + +A: Your service may have failed to start or misconfigured. To investigate further: + +1. Click on the resource group link shown to visit Azure Portal. +1. Navigate to the specific Azure Container App resource for the service. +1. Select *Monitoring -> Log stream* under the navigation pane. +1. Observe the log output to identify any errors. +1. If there are no errors, ensure that the ingress port matches the port that your service listens on: + 1. Under *Settings -> Ingress*, ensure the *Target port* matches the desired port. + 1. After modifying this setting, ensure the setting is also updated in the local bicep configuration file. + +For additional information about setting up your `azd` project, visit our official [docs](https://learn.microsoft.com/en-us/azure/developer/azure-developer-cli/make-azd-compatible?pivots=azd-convert). +{{ end}} diff --git a/cli/azd/resources/scaffold/templates/main.bicept b/cli/azd/resources/scaffold/templates/main.bicept new file mode 100644 index 00000000000..002777d3928 --- /dev/null +++ b/cli/azd/resources/scaffold/templates/main.bicept @@ -0,0 +1,165 @@ +{{define "main.bicep"}} +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the environment that can be used as part of naming resource convention') +param environmentName string + +@minLength(1) +@description('Primary location for all resources') +param location string + +{{range .Parameters}} +{{- if .Secret}} +@secure() +{{- end}} +param {{.Name}} {{.Type}} +{{- end}} + +// Tags that should be applied to all resources. +// +// Note that 'azd-service-name' tags should be applied separately to service host resources. +// Example usage: +// tags: union(tags, { 'azd-service-name': }) +var tags = { + 'azd-env-name': environmentName +} + +var abbrs = loadJsonContent('./abbreviations.json') +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +resource rg 'Microsoft.Resources/resourceGroups@2022-09-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} + +module monitoring './shared/monitoring.bicep' = { + name: 'monitoring' + params: { + location: location + tags: tags + logAnalyticsName: '${abbrs.operationalInsightsWorkspaces}${resourceToken}' + applicationInsightsName: '${abbrs.insightsComponents}${resourceToken}' + } + scope: rg +} + +module dashboard './shared/dashboard-web.bicep' = { + name: 'dashboard' + params: { + name: '${abbrs.portalDashboards}${resourceToken}' + applicationInsightsName: monitoring.outputs.applicationInsightsName + location: location + tags: tags + } + scope: rg +} + +module registry './shared/registry.bicep' = { + name: 'registry' + params: { + location: location + tags: tags + name: '${abbrs.containerRegistryRegistries}${resourceToken}' + } + scope: rg +} + +module keyVault './shared/keyvault.bicep' = { + name: 'keyvault' + params: { + location: location + tags: tags + name: '${abbrs.keyVaultVaults}${resourceToken}' + } + scope: rg +} + +module appsEnv './shared/apps-env.bicep' = { + name: 'apps-env' + params: { + name: '${abbrs.appManagedEnvironments}${resourceToken}' + location: location + tags: tags + applicationInsightsName: monitoring.outputs.applicationInsightsName + logAnalyticsWorkspaceName: monitoring.outputs.logAnalyticsWorkspaceName + } + scope: rg +} + +resource vault 'Microsoft.KeyVault/vaults@2022-07-01' existing = { + name: keyVault.outputs.name + scope: rg +} +{{- if .DbCosmos}} +module cosmosDb './app/db-cosmos.bicep' = { + name: 'cosmosDb' + params: { + accountName: '${abbrs.documentDBDatabaseAccounts}${resourceToken}' + location: location + tags: tags + keyVaultName: keyVault.outputs.name + } + scope: rg +} +{{- end}} +{{- if .DbPostgres}} +module postgresDb './app/db-postgre.bicep' = { + name: 'postgresDb' + params: { + serverName: '${abbrs.dBforPostgreSQLServers}${resourceToken}' + location: location + tags: tags + sqlAdminPassword: sqlAdminPassword + appUserPassword: appUserPassword + keyVaultName: keyVault.outputs.name + allowAllIPsFirewall: true + } + scope: rg +} +{{- end}} + +{{range .Services}} +module {{bicepName .Name}} './app/{{.Name}}.bicep' = { + name: '{{.Name}}' + params: { + name: '${abbrs.appContainerApps}{{containerAppName .Name}}-${resourceToken}' + location: location + tags: tags + identityName: '${abbrs.managedIdentityUserAssignedIdentities}{{containerAppName .Name}}-${resourceToken}' + applicationInsightsName: monitoring.outputs.applicationInsightsName + containerAppsEnvironmentName: appsEnv.outputs.name + containerRegistryName: registry.outputs.name + exists: {{bicepName .Name}}Exists + {{- if .DbCosmos}} + cosmosDbConnectionString: vault.getSecret(cosmosDb.outputs.connectionStringKey) + {{- end}} + {{- if .DbPostgres}} + databaseName: postgresDb.outputs.databaseName + databaseHost: postgresDb.outputs.databaseHost + databaseUser: postgresDb.outputs.databaseUser + databasePassword: vault.getSecret(postgresDb.outputs.databaseConnectionKey) + {{- end}} + {{- if .Frontend}} + apiUrls: [ + {{- range .Frontend.Backends}} + {{bicepName .Name}}.outputs.uri + {{- end}} + ] + {{- end}} + {{- if (and .Backend .Backend.Frontends)}} + allowedOrigins: [ + {{- range .Backend.Frontends}} + 'https://${abbrs.appContainerApps}{{containerAppName .Name}}-${resourceToken}.${appsEnv.outputs.domain}' + {{- end}} + ] + {{- end}} + } + scope: rg +} +{{- end}} + +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = registry.outputs.loginServer +{{end}} diff --git a/cli/azd/resources/scaffold/templates/main.parameters.jsont b/cli/azd/resources/scaffold/templates/main.parameters.jsont new file mode 100644 index 00000000000..5226c312950 --- /dev/null +++ b/cli/azd/resources/scaffold/templates/main.parameters.jsont @@ -0,0 +1,22 @@ +{{define "main.parameters.json"}} +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + {{- range .Parameters}} + "{{.Name}}": { + "value": "{{.Value}}" + }, + {{- end}} + "principalId": { + "value": "${AZURE_PRINCIPAL_ID}" + } + } +} +{{end}} diff --git a/cli/azd/test/functional/init_test.go b/cli/azd/test/functional/init_test.go index 88915670a01..20907cef2bb 100644 --- a/cli/azd/test/functional/init_test.go +++ b/cli/azd/test/functional/init_test.go @@ -32,7 +32,7 @@ func Test_CLI_Init_Minimal(t *testing.T) { _, err := cli.RunCommandWithStdIn( ctx, - "Minimal\nTESTENV\n", + "Select a template\nMinimal\nTESTENV\n", "init", ) require.NoError(t, err) @@ -79,7 +79,8 @@ func Test_CLI_Init_Minimal_With_Existing_Infra(t *testing.T) { _, err = cli.RunCommandWithStdIn( ctx, - "y\n"+ // Say yes to initialize in existing folder + "Select a template\n"+ + "y\n"+ // Say yes to initialize in existing folder "Minimal\n"+ // Choose minimal "TESTENV\n", // Provide environment name "init", @@ -123,7 +124,7 @@ func Test_CLI_Init_CanUseTemplate(t *testing.T) { _, err := cli.RunCommandWithStdIn( ctx, - "TESTENV\n", + "\nTESTENV\n", "init", "--template", "cosmos-dotnet-core-todo-app", diff --git a/cli/azd/test/functional/telemetry_test.go b/cli/azd/test/functional/telemetry_test.go index f02886d6427..75f9e8ee940 100644 --- a/cli/azd/test/functional/telemetry_test.go +++ b/cli/azd/test/functional/telemetry_test.go @@ -242,7 +242,7 @@ func Test_CLI_Telemetry_NestedCommands(t *testing.T) { _, err := cli.RunCommandWithStdIn( ctx, // Choose the default minimal template - "\n"+stdinForInit(envName), + "Select a template\n\n"+stdinForInit(envName), "init") require.NoError(t, err) diff --git a/go.mod b/go.mod index b3564c4738e..dab3d7669f3 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,10 @@ require ( gopkg.in/yaml.v3 v3.0.1 ) +require github.com/bmatcuk/doublestar/v4 v4.6.0 + +require golang.org/x/text v0.8.0 // indirect + require github.com/buger/goterm v1.0.4 require gopkg.in/dnaeon/go-vcr.v3 v3.1.2 @@ -95,7 +99,6 @@ require ( golang.org/x/crypto v0.7.0 // indirect golang.org/x/net v0.8.0 // indirect golang.org/x/term v0.6.0 // indirect - golang.org/x/text v0.8.0 // indirect google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect google.golang.org/grpc v1.53.0 // indirect google.golang.org/protobuf v1.28.1 // indirect